1import * as Effect from "effect/Effect";
2// @effect-diagnostics-next-line nodeBuiltinImport:off -- type-only, to adapt a Node Readable into Effect.
3import type * as NodeStream from "node:stream";
4import * as Yauzl from "yauzl";
5
6/** Closing the scope closes the archive. */
7export const openZipArchive = Effect.fn("zipArchive.open")(function* <E>(
8 archivePath: string,
9 makeError: (detail: string, cause: unknown) => E,
10) {
11 const opened = yield* Effect.acquireRelease(
12 Effect.callback<
13 {
14 readonly zip: Yauzl.ZipFile;
15 readonly error: () => E | undefined;
16 readonly close: Effect.Effect<void>;
17 },
18 E
19 >((resume) => {
20 Yauzl.open(
21 archivePath,
22 { lazyEntries: true, autoClose: false, validateEntrySizes: true, strictFileNames: true },
23 (error, zip) => {
24 if (error || !zip) {
25 resume(Effect.fail(makeError("Could not open the verified archive.", error)));
26 return;
27 }
28 let closed = false;
29 let archiveError: E | undefined;
30 zip.on("close", () => {
31 closed = true;
32 });
33 zip.on("error", (cause: unknown) => {
34 archiveError = makeError("The archive could not be read.", cause);
35 });
36 resume(
37 Effect.succeed({
38 zip,
39 error: () => archiveError,
40 close: Effect.callback<void>((finish) => {
41 if (closed) {
42 finish(Effect.void);
43 return;
44 }
45 const onClose = () => {
46 zip.removeListener("error", onError);
47 finish(Effect.void);
48 };
49 const onError = (cause: unknown) => {
50 zip.removeListener("close", onClose);
51 finish(Effect.die(makeError("Could not close the archive.", cause)));
52 };
53 zip.once("close", onClose);
54 zip.once("error", onError);
55 zip.close();
56 }),
57 }),
58 );
59 },
60 );
61 }),
62 (opened) => opened.close,
63 );
64
65 const next = Effect.callback<Yauzl.Entry | null, E>((resume) => {
66 const existingError = opened.error();
67 if (existingError) {
68 resume(Effect.fail(existingError));
69 return;
70 }
71 const cleanup = () => {
72 opened.zip.removeListener("entry", onEntry);
73 opened.zip.removeListener("end", onEnd);
74 opened.zip.removeListener("error", onError);
75 };
76 const onEntry = (entry: Yauzl.Entry) => {
77 cleanup();
78 resume(Effect.succeed(entry));
79 };
80 const onEnd = () => {
81 cleanup();
82 resume(Effect.succeed(null));
83 };
84 const onError = (cause: unknown) => {
85 cleanup();
86 resume(Effect.fail(makeError("The archive could not be read.", cause)));
87 };
88 opened.zip.once("entry", onEntry);
89 opened.zip.once("end", onEnd);
90 opened.zip.once("error", onError);
91 opened.zip.readEntry();
92 return Effect.sync(cleanup);
93 });
94
95 const streamEntry = (entry: Yauzl.Entry) =>
96 Effect.acquireRelease(
97 Effect.callback<NodeStream.Readable, E>((resume) => {
98 opened.zip.openReadStream(entry, (cause, readable) => {
99 resume(
100 cause || !readable
101 ? Effect.fail(makeError("Could not read an archive member.", cause))
102 : Effect.succeed(readable),
103 );
104 });
105 }),
106 (readable) =>
107 Effect.sync(() => {
108 readable.destroy();
109 }),
110 );
111 return { entryCount: opened.zip.entryCount, next, streamEntry };
112});