1/**
2 * Extra files packed next to a Fly/Hetzner/Railway program (Docker
3 * context or unit zip).
4 *
5 * `dest: "."` means "this directory IS the image/unit root" (`COPY . /app`),
6 * not a subfolder. {@link contextRootOf} then treats that source as the
7 * context, and {@link posixRelUnder} places `main` relative to it so
8 * `isExternal` ENTRYPOINT is `node /app/serve-node.mjs` instead of
9 * bundling `index.mjs`. Hashing skips gitignore (a parent `dist` rule
10 * would empty the hash) but still excludes `node_modules` / `.git` /
11 * `.next/cache` / `.alchemy` so the glob stays bounded.
12 */
13import * as Effect from "effect/Effect";
14import * as FileSystem from "effect/FileSystem";
15import * as Path from "effect/Path";
16import { dotAlchemyDirectory } from "../AlchemyContext.ts";
17import { isPathWithin } from "./isPathWithin.ts";
18import { hashDirectory } from "../Command/Memo.ts";
19import { initialCwd } from "./Node.ts";
20import { sha256 } from "./sha256.ts";
21
22const ioConcurrency = 16;
23
24/** Extra-file dest that means "merge this directory into the image/unit root". */
25export const CONTEXT_ROOT_DEST = ".";
26
27export const isContextRootDest = (dest: string): boolean =>
28 dest === "." || dest === "";
29
30/**
31 * Hash extra-file trees without gitignore (a parent `dist` rule would
32 * empty the hash) but skip the directories that make a recursive glob
33 * unbounded.
34 */
35export const EXTRA_FILES_HASH_EXCLUDE = [
36 "**/node_modules/**",
37 "**/.git/**",
38 "**/.next/cache/**",
39 "**/.alchemy/**",
40];
41
42/**
43 * Extra file or directory copied next to a bundled program (Docker
44 * context, unit archive, …). `dest` is relative to the image/unit root.
45 */
46export interface ExtraFile {
47 /** Local file or directory (absolute, or relative to {@link initialCwd}). */
48 readonly source: string;
49 /**
50 * Destination relative to the image/unit root (e.g. `"dist"`, `".next"`).
51 * `"."` means merge into the root.
52 */
53 readonly dest: string;
54}
55
56const posix = (value: string): string => value.replaceAll("\\", "/");
57
58/**
59 * Path of `file` relative to `root`, POSIX. Empty string if they are the
60 * same directory. Returns `undefined` when `file` is not under `root`.
61 */
62export const posixRelUnder = (
63 root: string,
64 file: string,
65 path: {
66 readonly resolve: (...segments: string[]) => string;
67 readonly relative: (from: string, to: string) => string;
68 readonly isAbsolute: (value: string) => boolean;
69 },
70): string | undefined => {
71 const from = posix(path.resolve(root));
72 const to = posix(path.resolve(file));
73 const rel = posix(path.relative(from, to));
74 if (rel === "") return "";
75 if (path.isAbsolute(rel) || rel === ".." || rel.startsWith("../")) {
76 return undefined;
77 }
78 return rel;
79};
80
81/**
82 * Docker/zip context root for an unbundled (`isExternal`) program. If
83 * some extraFile is `dest: "."`, that source is the context; otherwise
84 * the context is `dirname(main)`.
85 */
86export const contextRootOf = (
87 main: string,
88 extraFiles: ReadonlyArray<{ source: string; dest: string }>,
89 path: Path.Path,
90 resolveSource: (source: string) => string,
91): string => {
92 const root = extraFiles.find((file) => isContextRootDest(file.dest));
93 return root !== undefined ? resolveSource(root.source) : path.dirname(main);
94};
95
96/** Normalize a COPY destination so it cannot escape the root. `"."` is the root. */
97export const extraFileDestination = (destination: string): string => {
98 const normalized = destination.replaceAll("\\", "/").replace(/^\/+/, "");
99 if (isContextRootDest(normalized)) return CONTEXT_ROOT_DEST;
100 const parts = normalized
101 .split("/")
102 .filter((part) => part.length > 0 && part !== "." && part !== "..");
103 return parts.length === 0 ? "dist" : parts.join("/");
104};
105
106export const resolveExtraSource = (
107 source: string,
108 path: {
109 readonly isAbsolute: (value: string) => boolean;
110 readonly resolve: (...segments: string[]) => string;
111 },
112) => (path.isAbsolute(source) ? source : path.resolve(initialCwd, source));
113
114const skipCopySegment = (segment: string) =>
115 segment === ".git" || segment === ".alchemy";
116
117export const hashExtraFiles = Effect.fn(function* (
118 extraFiles: ReadonlyArray<ExtraFile> | undefined,
119) {
120 const fs = yield* FileSystem.FileSystem;
121 const path = yield* Path.Path;
122 const runtimeBase = process.cwd();
123 const dotAlchemy = yield* dotAlchemyDirectory;
124 const entries = yield* Effect.all(
125 (extraFiles ?? []).map((extra) =>
126 Effect.gen(function* () {
127 const dest = extraFileDestination(extra.dest);
128 const source = resolveExtraSource(extra.source, path);
129 const exists = yield* fs
130 .exists(source)
131 .pipe(Effect.orElseSucceed(() => false));
132 if (!exists || isPathWithin(dotAlchemy, source, runtimeBase))
133 return [dest, ""] as const;
134 const stat = yield* fs.stat(source);
135 const hash =
136 stat.type === "Directory"
137 ? yield* hashDirectory({
138 cwd: source,
139 memo: {
140 exclude: EXTRA_FILES_HASH_EXCLUDE,
141 lockfile: false,
142 },
143 }).pipe(Effect.orElseSucceed(() => ""))
144 : yield* sha256(yield* fs.readFile(source));
145 return [dest, hash] as const;
146 }),
147 ),
148 { concurrency: ioConcurrency },
149 );
150 return Object.fromEntries(entries) as Record<string, string>;
151});
152
153/**
154 * Copy a file or directory without macOS `clonefile`. Nitro/Nuxt
155 * `.output/server` trees (and their nested `node_modules`) fail
156 * `fs.copy` with `EINVAL: invalid argument, clonefile`. Nested
157 * `node_modules` are copied — nitro's node preset emits runtime
158 * deps there (`solid-js`, `seroval`, …) and the host imports them.
159 */
160export const copyTree = Effect.fn(function* (from: string, to: string) {
161 const fs = yield* FileSystem.FileSystem;
162 const path = yield* Path.Path;
163 const runtimeBase = process.cwd();
164 const dotAlchemy = yield* dotAlchemyDirectory;
165 if (isPathWithin(dotAlchemy, from, runtimeBase)) return;
166 const stat = yield* fs
167 .stat(from)
168 .pipe(Effect.catch(() => Effect.succeed(undefined)));
169 if (stat === undefined) return;
170 if (stat.type !== "Directory") {
171 if (stat.type !== "File") return;
172 yield* fs.makeDirectory(path.dirname(to), { recursive: true });
173 const contents = yield* fs.readFile(from);
174 yield* fs.writeFile(to, contents);
175 return;
176 }
177 yield* fs.makeDirectory(to, { recursive: true });
178 const names = yield* fs.readDirectory(from, { recursive: true });
179 yield* Effect.all(
180 names.flatMap((name) => {
181 if (
182 name.split(/[\\/]/).some(skipCopySegment) ||
183 isPathWithin(dotAlchemy, path.join(from, name), runtimeBase)
184 )
185 return [];
186 return [
187 Effect.gen(function* () {
188 const src = path.join(from, name);
189 const item = yield* fs
190 .stat(src)
191 .pipe(Effect.catch(() => Effect.succeed(undefined)));
192 if (item === undefined || item.type !== "File") return;
193 const dst = path.join(to, name);
194 yield* fs.makeDirectory(path.dirname(dst), { recursive: true });
195 const contents = yield* fs.readFile(src);
196 yield* fs.writeFile(dst, contents);
197 }),
198 ];
199 }),
200 { concurrency: ioConcurrency },
201 );
202});
203
204export const copyExtraFiles = Effect.fn(function* (
205 contextDir: string,
206 extraFiles: ReadonlyArray<ExtraFile> | undefined,
207 options?: {
208 readonly onMissing?: (file: {
209 readonly source: string;
210 readonly dest: string;
211 }) => Effect.Effect<unknown, any>;
212 },
213) {
214 const fs = yield* FileSystem.FileSystem;
215 const path = yield* Path.Path;
216 yield* Effect.all(
217 (extraFiles ?? []).map((extra) =>
218 Effect.gen(function* () {
219 const source = resolveExtraSource(extra.source, path);
220 const destName = extraFileDestination(extra.dest);
221 const exists = yield* fs
222 .exists(source)
223 .pipe(Effect.orElseSucceed(() => false));
224 if (!exists) {
225 if (options?.onMissing !== undefined) {
226 yield* options.onMissing({ source, dest: destName });
227 }
228 return;
229 }
230 if (isContextRootDest(destName)) {
231 const stat = yield* fs.stat(source);
232 if (stat.type === "Directory") {
233 const names = yield* fs.readDirectory(source);
234 yield* Effect.all(
235 names.map((name) =>
236 copyTree(path.join(source, name), path.join(contextDir, name)),
237 ),
238 { concurrency: ioConcurrency },
239 );
240 } else {
241 yield* copyTree(
242 source,
243 path.join(contextDir, path.basename(source)),
244 );
245 }
246 return;
247 }
248 const dest = path.join(contextDir, destName);
249 if (yield* fs.exists(dest).pipe(Effect.orElseSucceed(() => false))) {
250 yield* fs.remove(dest, { recursive: true });
251 }
252 yield* fs.makeDirectory(path.dirname(dest), { recursive: true });
253 yield* copyTree(source, dest);
254 }),
255 ),
256 { concurrency: ioConcurrency },
257 );
258});