1import * as Effect from "effect/Effect";
2import type { Zippable } from "fflate";
3import { Buffer } from "node:buffer";
4
5export interface ZipFile {
6 path: string;
7 content: string | Uint8Array<ArrayBufferLike>;
8 /**
9 * Unix file mode to record in the archive entry (e.g. `0o755` to keep an
10 * executable bit). Omit to use the archiver's default.
11 */
12 mode?: number;
13}
14
15// ZIP timestamps use local calendar fields; local midnight keeps the encoded
16// date identical in every timezone, including those west of UTC.
17const archiveDate = new Date(1980, 0, 1);
18
19export const zipCode = Effect.fn(function* (
20 content: string | Uint8Array<ArrayBufferLike>,
21 files?: ReadonlyArray<ZipFile>,
22) {
23 return yield* zipFiles([{ path: "index.mjs", content }, ...(files ?? [])]);
24});
25
26/**
27 * Package `files` into a deterministic zip archive: entries are sorted by
28 * path and stamped with a fixed timestamp so identical inputs always produce
29 * identical bytes.
30 */
31export const zipFiles = Effect.fn(function* (files: ReadonlyArray<ZipFile>) {
32 const { zipSync, strToU8 } = yield* Effect.promise(() => import("fflate"));
33 const entries: Zippable = Object.create(null);
34 for (const file of [...files].sort((a, b) =>
35 a.path < b.path ? -1 : a.path > b.path ? 1 : 0,
36 )) {
37 entries[file.path] = [
38 typeof file.content === "string" ? strToU8(file.content) : file.content,
39 { attrs: (file.mode ?? 0o100644) << 16 },
40 ];
41 }
42 return Buffer.from(zipSync(entries, { mtime: archiveDate, os: 3 }));
43});
44
45/**
46 * Expand a zip archive into its file entries. Directory entries are dropped,
47 * so every returned path addresses real content.
48 */
49export const unzipFiles = Effect.fn(function* (
50 archive: Uint8Array<ArrayBufferLike>,
51) {
52 const { unzipSync } = yield* Effect.promise(() => import("fflate"));
53 const entries = yield* Effect.try(() => unzipSync(archive));
54 const files: Record<string, Uint8Array<ArrayBufferLike>> = Object.create(
55 null,
56 );
57 for (const [path, content] of Object.entries(entries)) {
58 if (path.endsWith("/")) continue;
59 files[path] = content;
60 }
61 return files;
62});