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