@specific.dev/spectest 0.78.0 → 0.79.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,65 @@
1
1
  import type { MobileApp } from "../mobile.js";
2
+ /** The package managers the generated Dockerfile can install with. */
3
+ export type ExpoPackageManager = "npm" | "pnpm" | "yarn" | "bun";
2
4
  export interface ExpoOptions {
3
5
  /** Port the static server listens on. Default `8081`. */
4
6
  port?: number;
5
7
  /**
6
- * App directory inside the project, relative to the build context root.
8
+ * App directory inside the project, relative to the project root.
7
9
  * Default `"."` (the project root is the Expo app). Set this when the
8
- * Expo app lives in a subdirectory of a larger repo.
10
+ * Expo app lives in a subdirectory of a larger repo. It is also the
11
+ * build context, so the image can only copy files from below it.
9
12
  */
10
13
  appDir?: string;
11
- /** Node base image tag. Default `"20-bookworm-slim"`. */
14
+ /**
15
+ * Node base image tag for the generated Dockerfile. The default comes
16
+ * from the app: `engines.node` in its `package.json`, else `.nvmrc` or
17
+ * `.node-version`, else `"22-bookworm-slim"`. A declared version becomes
18
+ * the tag `<major>-bookworm-slim`.
19
+ */
12
20
  nodeVersion?: string;
21
+ /**
22
+ * Package manager for the generated Dockerfile. The default comes from
23
+ * the app: its `packageManager` field, else its lockfile
24
+ * (`pnpm-lock.yaml`, `yarn.lock`, `bun.lock`, `package-lock.json`), else
25
+ * npm. With a lockfile the install is a locked one (`npm ci`,
26
+ * `pnpm install --frozen-lockfile`, …), so a lockfile that does not agree
27
+ * with `package.json` fails the build instead of installing something
28
+ * else.
29
+ */
30
+ packageManager?: ExpoPackageManager;
13
31
  /** Output dir `expo export` writes to (relative to the app dir). Default `"dist"`. */
14
32
  outputDir?: string;
33
+ /**
34
+ * Build the image from a Dockerfile in your repo instead of the generated
35
+ * one. The path is relative to the project root. Two rules apply to that
36
+ * image, and nothing else does: it must hold the web export at
37
+ * {@link staticDir} (default `/app/dist`), and it must have `node` on the
38
+ * PATH, because the container command is the static server this component
39
+ * mounts into it.
40
+ *
41
+ * The options that describe the generated Dockerfile — `nodeVersion`,
42
+ * `packageManager`, `outputDir`, and `EXPO_PUBLIC_*` entries in `env` —
43
+ * do not apply to your file, so naming them together is an error rather
44
+ * than a setting that does nothing.
45
+ */
46
+ dockerfile?: string;
47
+ /**
48
+ * Build context for {@link dockerfile}, relative to the project root.
49
+ * Default {@link appDir}. Use it when the Dockerfile builds from a
50
+ * directory above itself, as in a monorepo.
51
+ */
52
+ context?: string;
53
+ /** Stage to build, as `docker build --target <stage>`. */
54
+ target?: string;
55
+ /** Build args, as `--build-arg NAME=value`. */
56
+ buildArgs?: Record<string, string>;
57
+ /**
58
+ * Directory INSIDE the image that holds the web export. Default
59
+ * `/app/<outputDir>`, which is where the generated Dockerfile writes it.
60
+ * Set it when your own Dockerfile exports somewhere else.
61
+ */
62
+ staticDir?: string;
15
63
  /**
16
64
  * Extra environment variables. `EXPO_PUBLIC_*` keys are additionally baked
17
65
  * into the image build so `expo export` inlines them into the bundle —
@@ -29,6 +77,15 @@ export interface ExpoOptions {
29
77
  }
30
78
  /** The handle `expo()` exposes on `ctx.svc.<name>` — a {@link MobileApp}. */
31
79
  export type ExpoHelpers = MobileApp;
80
+ /**
81
+ * The Node major version a range asks for. Clauses with an exclusive upper
82
+ * bound (`<21`) name a version the app must NOT run, so they are dropped
83
+ * first; of the rest the highest wins, because a range like `18 || 20 || 22`
84
+ * lists what the app supports and the newest is the one to build on.
85
+ *
86
+ * Exported for the unit test; it is not part of the component's surface.
87
+ */
88
+ export declare function nodeMajorFromRange(range: string): number | null;
32
89
  /**
33
90
  * A ready-to-use Expo (web) service. Drop into `environment.services` and
34
91
  * drive it with `ctx.mobile`:
@@ -45,8 +102,21 @@ export type ExpoHelpers = MobileApp;
45
102
  */
46
103
  export declare function expo(opts?: ExpoOptions): {
47
104
  image: {
105
+ type: "dockerfile";
106
+ path: string;
107
+ content?: never;
108
+ context?: string;
109
+ exclude?: readonly string[];
110
+ target?: string;
111
+ buildArgs?: Readonly<Record<string, string>>;
112
+ } | {
48
113
  type: "dockerfile";
49
114
  content: string;
115
+ path?: never;
116
+ context?: string;
117
+ exclude?: readonly string[];
118
+ target?: string;
119
+ buildArgs?: Readonly<Record<string, string>>;
50
120
  };
51
121
  files: {
52
122
  path: string;
@@ -2,20 +2,42 @@
2
2
  // to the web, built for `ctx.mobile(...)`.
3
3
  //
4
4
  // Serving model: STATIC EXPORT. The image runs `expo export -p web` at build
5
- // time (producing `dist/`) and the container just serves those static files
6
- // with a tiny Node server. No Metro at runtime — so the ready-check is fast
7
- // and deterministic and the bundle rides the image-layer cache, instead of
8
- // paying a non-deterministic first-request bundle on every boot.
5
+ // time (which makes `dist/`) and the container serves those files with a
6
+ // small Node server. There is no Metro at runtime, so the ready check is fast
7
+ // and the bundle rides the image-layer cache.
8
+ //
9
+ // The generated Dockerfile is written for the cache. It installs the
10
+ // dependencies from the manifests BEFORE it copies the source, so a source
11
+ // edit does not install them again. The package manager comes from the app
12
+ // itself (its `packageManager` field, else its lockfile), and it installs
13
+ // from the lockfile when there is one. Its download store and Metro's
14
+ // transform cache are BuildKit cache mounts, so each build starts from the
15
+ // work of the build before it.
16
+ //
17
+ // An app with its own Dockerfile gives `dockerfile` instead. The contract is
18
+ // then small and it is written down: the image must hold the web export at
19
+ // `staticDir` (default `/app/dist`), and it must have `node` on the PATH.
20
+ // The server names the directory and stops if the export is not there.
9
21
  //
10
22
  // The service's `helpers` factory returns a `MobileApp` handle (the resolved
11
23
  // in-VM URL), so a test drives it with `ctx.mobile(ctx.svc.app)` — no URL, no
12
24
  // `navigate`.
25
+ import { existsSync, readFileSync, statSync } from "node:fs";
26
+ import { join } from "node:path";
13
27
  import { mobileApp } from "../mobile.js";
14
28
  // A dependency-free static file server with SPA fallback. Bind-mounted into
15
29
  // the container (so it isn't baked into the image build) and run as the
16
- // container command. Serves `dist/`, falling back to index.html for client
17
- // routes. Embedded as a file rather than `npx serve` so the container needs
18
- // no extra install at runtime.
30
+ // container command. Serves the export directory, falling back to index.html
31
+ // for client routes. Embedded as a file rather than `npx serve` so the
32
+ // container needs no extra install at runtime.
33
+ //
34
+ // Landmine: this is a `String.raw` template, so a backtick or a `${` in it
35
+ // ends the string. Write the text without them.
36
+ //
37
+ // It reads the export directory before it listens. An image that holds no
38
+ // export answers nothing, so without this check the only report is a ready
39
+ // check that times out 60 seconds later. That is the one part of the
40
+ // `dockerfile` contract a machine can test, so it tests it.
19
41
  const STATIC_SERVER = String.raw `import { createServer } from "node:http";
20
42
  import { stat, readFile } from "node:fs/promises";
21
43
  import { join, extname, normalize, resolve } from "node:path";
@@ -35,6 +57,18 @@ const TYPES = {
35
57
  ".ttf": "font/ttf", ".otf": "font/otf", ".wasm": "application/wasm",
36
58
  };
37
59
 
60
+ try {
61
+ await stat(join(root, "index.html"));
62
+ } catch {
63
+ console.error(
64
+ "[expo-static] " + root + " holds no index.html.\n" +
65
+ "expo() serves the web export from that directory in the image.\n" +
66
+ "Make the image build write the 'expo export --platform web' output there,\n" +
67
+ "or point expo({ staticDir }) at the directory that holds it.",
68
+ );
69
+ process.exit(1);
70
+ }
71
+
38
72
  async function send(res, fp) {
39
73
  const body = await readFile(fp);
40
74
  res.writeHead(200, { "content-type": TYPES[extname(fp)] || "application/octet-stream" });
@@ -61,6 +95,236 @@ createServer(async (req, res) => {
61
95
  }
62
96
  }).listen(port, "0.0.0.0", () => console.log("[expo-static] serving " + root + " on :" + port));
63
97
  `;
98
+ /** Where Metro keeps its transform cache. It is `os.tmpdir()/metro-cache`,
99
+ * which is what `@expo/metro-config` sets. A cache mount there keeps the
100
+ * transforms of the build before this one. */
101
+ const METRO_CACHE_DIR = "/tmp/metro-cache";
102
+ /** The project root inside the VM. Read at call time, like `supabase()`
103
+ * reads it: a component is loaded before a hook context exists. */
104
+ function projectRootPath() {
105
+ return process.env.SPECTEST_WORKSPACE ?? "/workspace";
106
+ }
107
+ function readAppFacts(appDir) {
108
+ const dir = join(projectRootPath(), appDir);
109
+ if (!existsSync(dir)) {
110
+ throw new Error(`expo(): appDir ${JSON.stringify(appDir)} is not in the project`);
111
+ }
112
+ if (!statSync(dir).isDirectory()) {
113
+ throw new Error(`expo(): appDir ${JSON.stringify(appDir)} is not a directory`);
114
+ }
115
+ const has = (name) => existsSync(join(dir, name));
116
+ let pkg = null;
117
+ if (has("package.json")) {
118
+ try {
119
+ pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
120
+ }
121
+ catch (e) {
122
+ throw new Error(`expo(): ${appDir}/package.json is not valid JSON: ${String(e)}`);
123
+ }
124
+ }
125
+ return { dir, pkg, has };
126
+ }
127
+ /** First line of a version file, without a `v` prefix. */
128
+ function readVersionFile(facts, name) {
129
+ if (!facts.has(name))
130
+ return null;
131
+ const text = readFileSync(join(facts.dir, name), "utf8").split("\n")[0].trim();
132
+ return text === "" ? null : text.replace(/^v/, "");
133
+ }
134
+ /**
135
+ * The Node major version a range asks for. Clauses with an exclusive upper
136
+ * bound (`<21`) name a version the app must NOT run, so they are dropped
137
+ * first; of the rest the highest wins, because a range like `18 || 20 || 22`
138
+ * lists what the app supports and the newest is the one to build on.
139
+ *
140
+ * Exported for the unit test; it is not part of the component's surface.
141
+ */
142
+ export function nodeMajorFromRange(range) {
143
+ // A version is one token, so only its first number is a major: `18.20.4`
144
+ // asks for Node 18, not Node 20.
145
+ const kept = range.replace(/<=?\s*\d+(?:\.[\dx*]+)*/gi, " ");
146
+ const majors = [...kept.matchAll(/(\d+)(?:\.[\dx*]+)*/gi)].map((m) => Number(m[1]));
147
+ const usable = majors.filter((n) => n >= 12 && n <= 99);
148
+ return usable.length === 0 ? null : Math.max(...usable);
149
+ }
150
+ /** The Node the app declares, and where it said so. */
151
+ function declaredNode(facts) {
152
+ const engines = facts.pkg?.engines;
153
+ const candidates = [
154
+ ["engines.node", typeof engines?.node === "string" ? engines.node : null],
155
+ [".nvmrc", readVersionFile(facts, ".nvmrc")],
156
+ [".node-version", readVersionFile(facts, ".node-version")],
157
+ ];
158
+ for (const [source, value] of candidates) {
159
+ if (value === null)
160
+ continue;
161
+ const major = nodeMajorFromRange(value);
162
+ // `lts/*` and other names carry no number. Say nothing and use the default.
163
+ if (major === null)
164
+ continue;
165
+ return { tag: `${major}-bookworm-slim`, source: `${source} ${JSON.stringify(value)}` };
166
+ }
167
+ return null;
168
+ }
169
+ /** Node to build on when the app declares none. */
170
+ const DEFAULT_NODE = "22-bookworm-slim";
171
+ /** A lockfile per package manager. `bun.lockb` is the older binary one. */
172
+ const LOCKFILES = {
173
+ pnpm: ["pnpm-lock.yaml"],
174
+ yarn: ["yarn.lock"],
175
+ bun: ["bun.lock", "bun.lockb"],
176
+ npm: ["package-lock.json", "npm-shrinkwrap.json"],
177
+ };
178
+ /** The package manager the app uses, and where it said so. */
179
+ function declaredPackageManager(facts) {
180
+ // `packageManager` is the one the app pins for Corepack, so it wins.
181
+ const field = facts.pkg?.packageManager;
182
+ if (typeof field === "string") {
183
+ const name = field.split("@")[0];
184
+ if (name in LOCKFILES)
185
+ return { pm: name, source: `packageManager ${JSON.stringify(field)}` };
186
+ }
187
+ // Order matters only for an app that holds two lockfiles. The one that is
188
+ // not npm's is the deliberate one, because npm writes its own on any
189
+ // `npm install` a tool ran by accident.
190
+ for (const pm of ["pnpm", "yarn", "bun", "npm"]) {
191
+ const found = LOCKFILES[pm].find((f) => facts.has(f));
192
+ if (found !== undefined)
193
+ return { pm, source: found };
194
+ }
195
+ return null;
196
+ }
197
+ /**
198
+ * The install step for one package manager.
199
+ *
200
+ * Only the files an install reads are copied before it, so a source edit
201
+ * keeps the installed layer. `patches` is one of them: patch-package and
202
+ * pnpm's `patchedDependencies` both apply patches during the install, and an
203
+ * install that cannot see them applies none and says so in a line nobody
204
+ * reads.
205
+ */
206
+ function installPlan(facts, pm) {
207
+ const lock = LOCKFILES[pm].find((f) => facts.has(f));
208
+ const shared = ["package.json", ...(lock === undefined ? [] : [lock])];
209
+ const optional = (names) => names.filter((n) => facts.has(n));
210
+ const patches = optional(["patches"]);
211
+ const pinned = typeof facts.pkg?.packageManager === "string";
212
+ switch (pm) {
213
+ case "npm":
214
+ return {
215
+ manifests: [...shared, ...optional([".npmrc"]), ...patches],
216
+ env: [],
217
+ bootstrap: [],
218
+ cacheDir: "/root/.npm",
219
+ // `npm ci` needs the lockfile and installs exactly it.
220
+ install: lock === undefined ? "npm install" : "npm ci",
221
+ exec: "npx expo",
222
+ locked: lock !== undefined,
223
+ };
224
+ case "pnpm":
225
+ return {
226
+ manifests: [...shared, ...optional([".npmrc", "pnpm-workspace.yaml", ".pnpmfile.cjs"]), ...patches],
227
+ // Corepack asks before it downloads a package manager. Answer first.
228
+ env: ["ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0"],
229
+ bootstrap: ["corepack enable pnpm"],
230
+ // The store is a cache mount, so it is not on the same filesystem as
231
+ // node_modules. pnpm then copies where it usually links, which costs
232
+ // disk in the image but keeps the download.
233
+ cacheDir: "/pnpm-store",
234
+ install: `pnpm install --store-dir /pnpm-store${lock === undefined ? "" : " --frozen-lockfile"}`,
235
+ exec: "pnpm exec expo",
236
+ locked: lock !== undefined,
237
+ };
238
+ case "yarn": {
239
+ // Yarn 1 comes with the Node image. Yarn 2 and later are a per-project
240
+ // release that Corepack installs, and they keep the cache elsewhere.
241
+ const berry = facts.has(".yarnrc.yml") || /^yarn@[2-9]/.test(String(facts.pkg?.packageManager ?? ""));
242
+ if (berry) {
243
+ return {
244
+ manifests: [...shared, ...optional([".yarnrc.yml", ".npmrc", ".yarn"]), ...patches],
245
+ env: ["ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0", "ENV YARN_ENABLE_GLOBAL_CACHE=1"],
246
+ bootstrap: [pinned ? "corepack enable yarn" : "corepack enable yarn && corepack prepare yarn@stable --activate"],
247
+ cacheDir: "/root/.yarn/berry/cache",
248
+ install: `yarn install${lock === undefined ? "" : " --immutable"}`,
249
+ exec: "yarn expo",
250
+ locked: lock !== undefined,
251
+ };
252
+ }
253
+ return {
254
+ manifests: [...shared, ...optional([".yarnrc", ".npmrc"]), ...patches],
255
+ env: [],
256
+ bootstrap: [],
257
+ cacheDir: "/yarn-cache",
258
+ install: `yarn install --cache-folder /yarn-cache${lock === undefined ? "" : " --frozen-lockfile"}`,
259
+ exec: "yarn expo",
260
+ locked: lock !== undefined,
261
+ };
262
+ }
263
+ case "bun":
264
+ return {
265
+ manifests: [...shared, ...optional([".npmrc", "bunfig.toml"]), ...patches],
266
+ env: [],
267
+ // The Node image has no Bun. Bun publishes itself to npm.
268
+ bootstrap: ["npm install -g bun"],
269
+ cacheDir: "/root/.bun/install/cache",
270
+ install: `bun install${lock === undefined ? "" : " --frozen-lockfile"}`,
271
+ exec: "bunx expo",
272
+ locked: lock !== undefined,
273
+ };
274
+ }
275
+ }
276
+ /** `ENV` lines for the `EXPO_PUBLIC_*` entries, ready for the Dockerfile. */
277
+ function publicEnvLines(env) {
278
+ return Object.entries(env)
279
+ .filter(([k]) => k.startsWith("EXPO_PUBLIC_"))
280
+ .map(([k, v]) => {
281
+ if (/[\r\n]/.test(v)) {
282
+ throw new Error(`expo(): env ${k} must not contain newlines (baked into the Dockerfile)`);
283
+ }
284
+ return `ENV ${k}="${v.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
285
+ });
286
+ }
287
+ /**
288
+ * The generated Dockerfile.
289
+ *
290
+ * The order of the steps is the whole point. The manifests come first and the
291
+ * install runs on them alone, so an edit to the app keeps that layer. The
292
+ * source comes next. The `EXPO_PUBLIC_*` values come last before the export,
293
+ * because Expo inlines them at export time and a change to one must not
294
+ * install the dependencies again.
295
+ *
296
+ * Two cache mounts survive the build: the package manager's download store,
297
+ * and Metro's transform cache. They stay in this project's BuildKit state
298
+ * (CONTAINER_STORE.md), so the next cold build starts from them. They need
299
+ * BuildKit, which every builder in a VM is.
300
+ */
301
+ function generateDockerfile(input) {
302
+ const { nodeVersion, plan, publicEnv, outputDir } = input;
303
+ const lines = [`FROM node:${nodeVersion}`, "WORKDIR /app", "ENV CI=1", ...plan.env];
304
+ for (const step of plan.bootstrap)
305
+ lines.push(`RUN ${step}`);
306
+ lines.push(`COPY ${plan.manifests.join(" ")} ./`);
307
+ lines.push(`RUN --mount=type=cache,target=${plan.cacheDir},sharing=locked ${plan.install}`);
308
+ lines.push("COPY . .");
309
+ lines.push(...publicEnv);
310
+ lines.push(`RUN --mount=type=cache,target=${METRO_CACHE_DIR},sharing=locked ` +
311
+ `${plan.exec} export --platform web --output-dir ${outputDir}`);
312
+ return lines.join("\n") + "\n";
313
+ }
314
+ /** Options that describe the generated Dockerfile, and thus mean nothing
315
+ * beside `dockerfile`. Each is named if it is set. */
316
+ function conflictingOptions(opts) {
317
+ const named = [];
318
+ if (opts.nodeVersion !== undefined)
319
+ named.push("nodeVersion");
320
+ if (opts.packageManager !== undefined)
321
+ named.push("packageManager");
322
+ if (opts.outputDir !== undefined)
323
+ named.push("outputDir");
324
+ if (Object.keys(opts.env ?? {}).some((k) => k.startsWith("EXPO_PUBLIC_")))
325
+ named.push("EXPO_PUBLIC_* in env");
326
+ return named;
327
+ }
64
328
  /**
65
329
  * A ready-to-use Expo (web) service. Drop into `environment.services` and
66
330
  * drive it with `ctx.mobile`:
@@ -78,37 +342,75 @@ createServer(async (req, res) => {
78
342
  export function expo(opts = {}) {
79
343
  const port = opts.port ?? 8081;
80
344
  const appDir = opts.appDir ?? ".";
81
- const nodeVersion = opts.nodeVersion ?? "20-bookworm-slim";
82
345
  const outputDir = opts.outputDir ?? "dist";
83
- // Expo inlines EXPO_PUBLIC_* variables into the bundle at `expo export`
84
- // time, so they must exist during the image build — runtime container env
85
- // is too late. Bake them as ENV lines placed after `npm install` (an env
86
- // edit re-exports without re-installing) and before the export RUN. They
87
- // remain in `env` too, so server-side reads in the container agree.
88
- const publicEnv = Object.entries(opts.env ?? {}).filter(([k]) => k.startsWith("EXPO_PUBLIC_"));
89
- for (const [k, v] of publicEnv) {
90
- if (/[\r\n]/.test(v)) {
91
- throw new Error(`expo(): env ${k} must not contain newlines (baked into the Dockerfile)`);
346
+ const staticDir = opts.staticDir ?? `/app/${outputDir}`;
347
+ // The server resolves this path in the container, so a relative one would
348
+ // depend on the image's WORKDIR.
349
+ if (!staticDir.startsWith("/")) {
350
+ throw new Error(`expo(): staticDir ${JSON.stringify(staticDir)} must be an absolute path inside the image`);
351
+ }
352
+ let image;
353
+ if (opts.dockerfile !== undefined) {
354
+ // Your own Dockerfile. Everything that describes ours is refused here
355
+ // rather than ignored, because a setting that does nothing is found only
356
+ // by the person who reads the built image.
357
+ const ignored = conflictingOptions(opts);
358
+ if (ignored.length > 0) {
359
+ throw new Error(`expo(): ${ignored.join(", ")} ${ignored.length === 1 ? "describes" : "describe"} the Dockerfile expo() writes, ` +
360
+ `and you gave your own with \`dockerfile\`. Move ${ignored.length === 1 ? "it" : "them"} into ` +
361
+ `${opts.dockerfile}, and use \`staticDir\` to say where that build writes the web export.`);
92
362
  }
363
+ image = {
364
+ type: "dockerfile",
365
+ path: opts.dockerfile,
366
+ context: opts.context ?? appDir,
367
+ ...(opts.target === undefined ? {} : { target: opts.target }),
368
+ ...(opts.buildArgs === undefined ? {} : { buildArgs: opts.buildArgs }),
369
+ };
370
+ }
371
+ else {
372
+ if (opts.context !== undefined) {
373
+ throw new Error("expo(): `context` names the build context of your own `dockerfile`; the generated one builds from `appDir`");
374
+ }
375
+ const facts = readAppFacts(appDir);
376
+ if (facts.pkg === null) {
377
+ throw new Error(`expo(): no package.json in ${appDir === "." ? "the project root" : appDir}. ` +
378
+ `Set \`appDir\` to the directory of the Expo app, or build the image with \`dockerfile\`.`);
379
+ }
380
+ const node = opts.nodeVersion !== undefined
381
+ ? { tag: opts.nodeVersion, source: "nodeVersion" }
382
+ : (declaredNode(facts) ?? { tag: DEFAULT_NODE, source: "the default" });
383
+ const chosen = opts.packageManager !== undefined
384
+ ? { pm: opts.packageManager, source: "packageManager option" }
385
+ : (declaredPackageManager(facts) ?? { pm: "npm", source: "the default" });
386
+ const plan = installPlan(facts, chosen.pm);
387
+ console.log(`expo(): node:${node.tag} (${node.source}), ${plan.install} ` +
388
+ `(${chosen.source}${plan.locked ? "" : ", no lockfile"}).`);
389
+ image = {
390
+ type: "dockerfile",
391
+ content: generateDockerfile({
392
+ nodeVersion: node.tag,
393
+ plan,
394
+ publicEnv: publicEnvLines(opts.env ?? {}),
395
+ outputDir,
396
+ }),
397
+ // The app dir is the context, so the image copies from it directly and
398
+ // the ignore rules that apply are the app's own. `exclude` gets the
399
+ // last word over them: a `node_modules` from the host would replace the
400
+ // one the install made, and the old export would leave dead files
401
+ // beside the new one.
402
+ context: appDir,
403
+ exclude: ["node_modules", "**/node_modules", ".expo", "**/.expo", outputDir],
404
+ ...(opts.target === undefined ? {} : { target: opts.target }),
405
+ ...(opts.buildArgs === undefined ? {} : { buildArgs: opts.buildArgs }),
406
+ };
93
407
  }
94
- const publicEnvLines = publicEnv
95
- .map(([k, v]) => `ENV ${k}="${v.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`)
96
- .join("\n");
97
- // CI=1 keeps `expo export` non-interactive. Static export resolves the web
98
- // bundle deterministically into `outputDir`.
99
- const dockerfile = `FROM node:${nodeVersion}
100
- WORKDIR /app
101
- ENV CI=1
102
- COPY ${appDir}/ ./
103
- RUN npm install
104
- ${publicEnvLines ? `${publicEnvLines}\n` : ""}RUN npx expo export --platform web --output-dir ${outputDir}
105
- `;
106
408
  return {
107
- image: { type: "dockerfile", content: dockerfile },
409
+ image,
108
410
  files: [
109
411
  { path: "/spectest-expo-serve.mjs", content: STATIC_SERVER },
110
412
  ],
111
- command: `node /spectest-expo-serve.mjs /app/${outputDir} ${port}`,
413
+ command: `node /spectest-expo-serve.mjs ${staticDir} ${port}`,
112
414
  env: { ...(opts.env ?? {}) },
113
415
  ports: [port],
114
416
  readyCheck: { type: "http", port, path: "/", timeoutSecs: 60 },
@@ -2,7 +2,7 @@ export { postgres, type PostgresOptions, type PostgresHelpers, } from "./postgre
2
2
  export { s3, type S3Options, type S3Helpers, } from "./s3.js";
3
3
  export { SQL, type SqlClient, type SqlOptions } from "../sql.js";
4
4
  export { k3s, type K3sOptions, type K3sHelpers, type K3sClient, type TaggedObjectApi, type KubernetesObjectRef, type ApplyOptions, type RolloutTarget, type WaitOptions, type LogsOptions, type KubernetesObject, type KubernetesListObject, } from "./k3s.js";
5
- export { expo, type ExpoOptions, type ExpoHelpers, } from "./expo.js";
5
+ export { expo, type ExpoOptions, type ExpoHelpers, type ExpoPackageManager, } from "./expo.js";
6
6
  export { supabase, type SupabaseOptions, type SupabaseHelpers, type SupabaseStack, } from "./supabase.js";
7
7
  export { email, type EmailOptions, type EmailHelpers, type EmailMessage, type EmailSummary, type EmailMatch, type EmailAttachment, } from "./email.js";
8
8
  export { aws, type AwsOptions, type LambdaOptions, } from "./aws.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.78.0",
3
+ "version": "0.79.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -0,0 +1,227 @@
1
+ // What `expo()` builds from a real app directory: which package manager it
2
+ // installs with, which Node it builds on, and the order of the Dockerfile
3
+ // steps. The order is what makes a source edit cheap, so it is the part a
4
+ // test has to hold: an install that moves below the source COPY runs again
5
+ // on every edit, and nothing about the environment looks wrong when it does.
6
+
7
+ import { afterEach, afterAll, beforeAll, describe, expect, test } from "bun:test";
8
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ import { expo, nodeMajorFromRange } from "./expo.js";
13
+ import type { ServiceConfig } from "../index.js";
14
+
15
+ let root = "";
16
+ const prevWorkspace = process.env.SPECTEST_WORKSPACE;
17
+
18
+ beforeAll(() => {
19
+ root = mkdtempSync(join(tmpdir(), "expo-app-"));
20
+ process.env.SPECTEST_WORKSPACE = root;
21
+ });
22
+ afterEach(() => rmSync(root, { recursive: true, force: true }));
23
+ afterAll(() => {
24
+ if (prevWorkspace === undefined) delete process.env.SPECTEST_WORKSPACE;
25
+ else process.env.SPECTEST_WORKSPACE = prevWorkspace;
26
+ rmSync(root, { recursive: true, force: true });
27
+ });
28
+
29
+ /** Write an app into the workspace. `files` is path → content. */
30
+ function app(files: Record<string, string>, dir = "."): void {
31
+ for (const [rel, content] of Object.entries(files)) {
32
+ const abs = join(root, dir, rel);
33
+ mkdirSync(join(abs, ".."), { recursive: true });
34
+ writeFileSync(abs, content);
35
+ }
36
+ }
37
+
38
+ const pkg = (extra: Record<string, unknown> = {}): string =>
39
+ JSON.stringify({ name: "app", dependencies: { expo: "51.0.0" }, ...extra });
40
+
41
+ /** The generated Dockerfile of a service. */
42
+ function dockerfile(svc: ServiceConfig): string {
43
+ const image = svc.image as { content?: string };
44
+ if (image.content === undefined) throw new Error("service does not build a generated Dockerfile");
45
+ return image.content;
46
+ }
47
+
48
+ /** Line index of the first line that holds `needle`. */
49
+ function lineOf(text: string, needle: string): number {
50
+ const i = text.split("\n").findIndex((l) => l.includes(needle));
51
+ if (i < 0) throw new Error(`no line with ${needle} in:\n${text}`);
52
+ return i;
53
+ }
54
+
55
+ describe("expo() Dockerfile caching", () => {
56
+ test("installs from the manifests before it copies the source", () => {
57
+ app({ "package.json": pkg(), "package-lock.json": "{}", "App.js": "x" });
58
+ const df = dockerfile(expo());
59
+
60
+ // The manifests, the install, then everything else. An edit to App.js
61
+ // invalidates only the last two steps.
62
+ expect(lineOf(df, "COPY package.json package-lock.json ./")).toBeLessThan(lineOf(df, "npm ci"));
63
+ expect(lineOf(df, "npm ci")).toBeLessThan(lineOf(df, "COPY . ."));
64
+ expect(lineOf(df, "COPY . .")).toBeLessThan(lineOf(df, "expo export"));
65
+ });
66
+
67
+ test("keeps the package store and the Metro cache across builds", () => {
68
+ app({ "package.json": pkg(), "package-lock.json": "{}" });
69
+ const df = dockerfile(expo());
70
+
71
+ expect(df).toContain("RUN --mount=type=cache,target=/root/.npm,sharing=locked npm ci");
72
+ expect(df).toContain("--mount=type=cache,target=/tmp/metro-cache");
73
+ });
74
+
75
+ test("bakes EXPO_PUBLIC_* after the source, so an edit to one only exports again", () => {
76
+ app({ "package.json": pkg(), "package-lock.json": "{}" });
77
+ const df = dockerfile(expo({ env: { EXPO_PUBLIC_API: "https://api.test", OTHER: "no" } }));
78
+
79
+ expect(lineOf(df, "npm ci")).toBeLessThan(lineOf(df, "ENV EXPO_PUBLIC_API"));
80
+ expect(lineOf(df, "ENV EXPO_PUBLIC_API")).toBeLessThan(lineOf(df, "expo export"));
81
+ // Only the public ones are baked; the rest are container env.
82
+ expect(df).not.toContain("OTHER");
83
+ });
84
+
85
+ test("copies patches, because the install applies them", () => {
86
+ app({
87
+ "package.json": pkg(),
88
+ "package-lock.json": "{}",
89
+ "patches/expo+51.0.0.patch": "diff",
90
+ });
91
+ expect(dockerfile(expo())).toContain("COPY package.json package-lock.json patches ./");
92
+ });
93
+
94
+ test("holds the host's node_modules and the old export out of the context", () => {
95
+ app({ "package.json": pkg() });
96
+ const image = expo().image as { context?: string; exclude?: readonly string[] };
97
+ expect(image.context).toBe(".");
98
+ expect(image.exclude).toContain("node_modules");
99
+ expect(image.exclude).toContain("dist");
100
+ });
101
+ });
102
+
103
+ describe("expo() package manager", () => {
104
+ test("npm installs from the lockfile, and without one it resolves", () => {
105
+ app({ "package.json": pkg(), "package-lock.json": "{}" });
106
+ expect(dockerfile(expo())).toContain("npm ci");
107
+
108
+ rmSync(join(root, "package-lock.json"));
109
+ expect(dockerfile(expo())).toContain("npm install");
110
+ });
111
+
112
+ test("pnpm comes from its lockfile, with Corepack and its own store", () => {
113
+ app({ "package.json": pkg(), "pnpm-lock.yaml": "lockfileVersion: 9" });
114
+ const df = dockerfile(expo());
115
+
116
+ expect(df).toContain("RUN corepack enable pnpm");
117
+ expect(df).toContain("pnpm install --store-dir /pnpm-store --frozen-lockfile");
118
+ expect(df).toContain("pnpm exec expo export");
119
+ });
120
+
121
+ test("yarn 1 uses the image's own yarn; yarn 2+ is a Corepack release", () => {
122
+ app({ "package.json": pkg(), "yarn.lock": "" });
123
+ const classic = dockerfile(expo());
124
+ expect(classic).toContain("yarn install --cache-folder /yarn-cache --frozen-lockfile");
125
+ expect(classic).not.toContain("corepack");
126
+
127
+ app({ "package.json": pkg({ packageManager: "yarn@4.5.0" }), ".yarnrc.yml": "nodeLinker: node-modules" });
128
+ const berry = dockerfile(expo());
129
+ expect(berry).toContain("corepack enable yarn");
130
+ expect(berry).toContain("yarn install --immutable");
131
+ expect(berry).toContain("ENV YARN_ENABLE_GLOBAL_CACHE=1");
132
+ });
133
+
134
+ test("bun is installed into the Node image", () => {
135
+ app({ "package.json": pkg(), "bun.lock": "" });
136
+ const df = dockerfile(expo());
137
+ expect(df).toContain("RUN npm install -g bun");
138
+ expect(df).toContain("bun install --frozen-lockfile");
139
+ expect(df).toContain("bunx expo export");
140
+ });
141
+
142
+ test("the packageManager field wins over a stray lockfile", () => {
143
+ app({ "package.json": pkg({ packageManager: "pnpm@9.1.0" }), "package-lock.json": "{}" });
144
+ expect(dockerfile(expo())).toContain("pnpm install");
145
+ });
146
+
147
+ test("the option wins over both", () => {
148
+ app({ "package.json": pkg(), "package-lock.json": "{}" });
149
+ expect(dockerfile(expo({ packageManager: "yarn" }))).toContain("yarn install");
150
+ });
151
+ });
152
+
153
+ describe("expo() Node version", () => {
154
+ test("takes the version the app declares", () => {
155
+ app({ "package.json": pkg({ engines: { node: ">=22" } }) });
156
+ expect(dockerfile(expo())).toContain("FROM node:22-bookworm-slim");
157
+
158
+ app({ "package.json": pkg(), ".nvmrc": "v20.11.1\n" });
159
+ expect(dockerfile(expo())).toContain("FROM node:20-bookworm-slim");
160
+ });
161
+
162
+ test("builds on 22 when the app declares nothing readable", () => {
163
+ app({ "package.json": pkg(), ".nvmrc": "lts/iron\n" });
164
+ expect(dockerfile(expo())).toContain("FROM node:22-bookworm-slim");
165
+ });
166
+
167
+ test("the option wins", () => {
168
+ app({ "package.json": pkg({ engines: { node: ">=22" } }) });
169
+ expect(dockerfile(expo({ nodeVersion: "20-alpine" }))).toContain("FROM node:20-alpine");
170
+ });
171
+
172
+ test("a range names what to build on, not what to avoid", () => {
173
+ // An upper bound says which version must NOT run, so it is not a choice.
174
+ expect(nodeMajorFromRange(">=18 <21")).toBe(18);
175
+ expect(nodeMajorFromRange("18 || 20 || 22")).toBe(22);
176
+ expect(nodeMajorFromRange("^20.9.0")).toBe(20);
177
+ // One version is one token — its first number is the major.
178
+ expect(nodeMajorFromRange("18.20.4")).toBe(18);
179
+ expect(nodeMajorFromRange("22.x")).toBe(22);
180
+ expect(nodeMajorFromRange("lts/*")).toBeNull();
181
+ });
182
+ });
183
+
184
+ describe("expo() with your own Dockerfile", () => {
185
+ test("builds the repo file from the app dir, and serves the documented path", () => {
186
+ app({ "package.json": pkg(), "Dockerfile": "FROM node:22\n" }, "mobile");
187
+ const svc = expo({ appDir: "mobile", dockerfile: "mobile/Dockerfile" });
188
+
189
+ expect(svc.image).toEqual({ type: "dockerfile", path: "mobile/Dockerfile", context: "mobile" });
190
+ expect(svc.command).toContain("/app/dist");
191
+ });
192
+
193
+ test("staticDir says where that build wrote the export", () => {
194
+ app({ "package.json": pkg(), "Dockerfile": "FROM nginx\n" });
195
+ const svc = expo({ dockerfile: "Dockerfile", staticDir: "/srv/web" });
196
+ expect(svc.command).toBe("node /spectest-expo-serve.mjs /srv/web 8081");
197
+ });
198
+
199
+ test("refuses a staticDir that depends on the image's WORKDIR", () => {
200
+ app({ "package.json": pkg(), "Dockerfile": "FROM nginx\n" });
201
+ expect(() => expo({ dockerfile: "Dockerfile", staticDir: "web" })).toThrow(/absolute path/);
202
+ });
203
+
204
+ test("refuses the options that describe the generated Dockerfile", () => {
205
+ app({ "package.json": pkg(), "Dockerfile": "FROM node:22\n" });
206
+ expect(() => expo({ dockerfile: "Dockerfile", nodeVersion: "22-alpine" })).toThrow(/nodeVersion/);
207
+ expect(() => expo({ dockerfile: "Dockerfile", env: { EXPO_PUBLIC_API: "x" } })).toThrow(/EXPO_PUBLIC/);
208
+ // `env` that Expo does not inline is the container's, and stays allowed.
209
+ expect(expo({ dockerfile: "Dockerfile", env: { API: "x" } }).env).toEqual({ API: "x" });
210
+ });
211
+
212
+ test("the static server stops on an image with no export in it", () => {
213
+ app({ "package.json": pkg() });
214
+ const server = expo().files!.find((f) => f.path.endsWith(".mjs"))!.content;
215
+ expect(server).toContain("holds no index.html");
216
+ expect(server).toContain("process.exit(1)");
217
+ });
218
+ });
219
+
220
+ describe("expo() app directory", () => {
221
+ test("names the directory when there is no app in it", () => {
222
+ app({ "package.json": pkg() });
223
+ expect(() => expo({ appDir: "mobile" })).toThrow(/appDir "mobile" is not in the project/);
224
+ app({ "readme.md": "x" }, "mobile");
225
+ expect(() => expo({ appDir: "mobile" })).toThrow(/no package.json in mobile/);
226
+ });
227
+ });
@@ -2,32 +2,96 @@
2
2
  // to the web, built for `ctx.mobile(...)`.
3
3
  //
4
4
  // Serving model: STATIC EXPORT. The image runs `expo export -p web` at build
5
- // time (producing `dist/`) and the container just serves those static files
6
- // with a tiny Node server. No Metro at runtime — so the ready-check is fast
7
- // and deterministic and the bundle rides the image-layer cache, instead of
8
- // paying a non-deterministic first-request bundle on every boot.
5
+ // time (which makes `dist/`) and the container serves those files with a
6
+ // small Node server. There is no Metro at runtime, so the ready check is fast
7
+ // and the bundle rides the image-layer cache.
8
+ //
9
+ // The generated Dockerfile is written for the cache. It installs the
10
+ // dependencies from the manifests BEFORE it copies the source, so a source
11
+ // edit does not install them again. The package manager comes from the app
12
+ // itself (its `packageManager` field, else its lockfile), and it installs
13
+ // from the lockfile when there is one. Its download store and Metro's
14
+ // transform cache are BuildKit cache mounts, so each build starts from the
15
+ // work of the build before it.
16
+ //
17
+ // An app with its own Dockerfile gives `dockerfile` instead. The contract is
18
+ // then small and it is written down: the image must hold the web export at
19
+ // `staticDir` (default `/app/dist`), and it must have `node` on the PATH.
20
+ // The server names the directory and stops if the export is not there.
9
21
  //
10
22
  // The service's `helpers` factory returns a `MobileApp` handle (the resolved
11
23
  // in-VM URL), so a test drives it with `ctx.mobile(ctx.svc.app)` — no URL, no
12
24
  // `navigate`.
13
25
 
14
- import type { ServiceDefinition } from "../index.js";
26
+ import { existsSync, readFileSync, statSync } from "node:fs";
27
+ import { join } from "node:path";
28
+
29
+ import type { ServiceDefinition, ServiceImage } from "../index.js";
15
30
  import { mobileApp } from "../mobile.js";
16
31
  import type { MobileApp } from "../mobile.js";
17
32
 
33
+ /** The package managers the generated Dockerfile can install with. */
34
+ export type ExpoPackageManager = "npm" | "pnpm" | "yarn" | "bun";
35
+
18
36
  export interface ExpoOptions {
19
37
  /** Port the static server listens on. Default `8081`. */
20
38
  port?: number;
21
39
  /**
22
- * App directory inside the project, relative to the build context root.
40
+ * App directory inside the project, relative to the project root.
23
41
  * Default `"."` (the project root is the Expo app). Set this when the
24
- * Expo app lives in a subdirectory of a larger repo.
42
+ * Expo app lives in a subdirectory of a larger repo. It is also the
43
+ * build context, so the image can only copy files from below it.
25
44
  */
26
45
  appDir?: string;
27
- /** Node base image tag. Default `"20-bookworm-slim"`. */
46
+ /**
47
+ * Node base image tag for the generated Dockerfile. The default comes
48
+ * from the app: `engines.node` in its `package.json`, else `.nvmrc` or
49
+ * `.node-version`, else `"22-bookworm-slim"`. A declared version becomes
50
+ * the tag `<major>-bookworm-slim`.
51
+ */
28
52
  nodeVersion?: string;
53
+ /**
54
+ * Package manager for the generated Dockerfile. The default comes from
55
+ * the app: its `packageManager` field, else its lockfile
56
+ * (`pnpm-lock.yaml`, `yarn.lock`, `bun.lock`, `package-lock.json`), else
57
+ * npm. With a lockfile the install is a locked one (`npm ci`,
58
+ * `pnpm install --frozen-lockfile`, …), so a lockfile that does not agree
59
+ * with `package.json` fails the build instead of installing something
60
+ * else.
61
+ */
62
+ packageManager?: ExpoPackageManager;
29
63
  /** Output dir `expo export` writes to (relative to the app dir). Default `"dist"`. */
30
64
  outputDir?: string;
65
+ /**
66
+ * Build the image from a Dockerfile in your repo instead of the generated
67
+ * one. The path is relative to the project root. Two rules apply to that
68
+ * image, and nothing else does: it must hold the web export at
69
+ * {@link staticDir} (default `/app/dist`), and it must have `node` on the
70
+ * PATH, because the container command is the static server this component
71
+ * mounts into it.
72
+ *
73
+ * The options that describe the generated Dockerfile — `nodeVersion`,
74
+ * `packageManager`, `outputDir`, and `EXPO_PUBLIC_*` entries in `env` —
75
+ * do not apply to your file, so naming them together is an error rather
76
+ * than a setting that does nothing.
77
+ */
78
+ dockerfile?: string;
79
+ /**
80
+ * Build context for {@link dockerfile}, relative to the project root.
81
+ * Default {@link appDir}. Use it when the Dockerfile builds from a
82
+ * directory above itself, as in a monorepo.
83
+ */
84
+ context?: string;
85
+ /** Stage to build, as `docker build --target <stage>`. */
86
+ target?: string;
87
+ /** Build args, as `--build-arg NAME=value`. */
88
+ buildArgs?: Record<string, string>;
89
+ /**
90
+ * Directory INSIDE the image that holds the web export. Default
91
+ * `/app/<outputDir>`, which is where the generated Dockerfile writes it.
92
+ * Set it when your own Dockerfile exports somewhere else.
93
+ */
94
+ staticDir?: string;
31
95
  /**
32
96
  * Extra environment variables. `EXPO_PUBLIC_*` keys are additionally baked
33
97
  * into the image build so `expo export` inlines them into the bundle —
@@ -49,9 +113,17 @@ export type ExpoHelpers = MobileApp;
49
113
 
50
114
  // A dependency-free static file server with SPA fallback. Bind-mounted into
51
115
  // the container (so it isn't baked into the image build) and run as the
52
- // container command. Serves `dist/`, falling back to index.html for client
53
- // routes. Embedded as a file rather than `npx serve` so the container needs
54
- // no extra install at runtime.
116
+ // container command. Serves the export directory, falling back to index.html
117
+ // for client routes. Embedded as a file rather than `npx serve` so the
118
+ // container needs no extra install at runtime.
119
+ //
120
+ // Landmine: this is a `String.raw` template, so a backtick or a `${` in it
121
+ // ends the string. Write the text without them.
122
+ //
123
+ // It reads the export directory before it listens. An image that holds no
124
+ // export answers nothing, so without this check the only report is a ready
125
+ // check that times out 60 seconds later. That is the one part of the
126
+ // `dockerfile` contract a machine can test, so it tests it.
55
127
  const STATIC_SERVER = String.raw`import { createServer } from "node:http";
56
128
  import { stat, readFile } from "node:fs/promises";
57
129
  import { join, extname, normalize, resolve } from "node:path";
@@ -71,6 +143,18 @@ const TYPES = {
71
143
  ".ttf": "font/ttf", ".otf": "font/otf", ".wasm": "application/wasm",
72
144
  };
73
145
 
146
+ try {
147
+ await stat(join(root, "index.html"));
148
+ } catch {
149
+ console.error(
150
+ "[expo-static] " + root + " holds no index.html.\n" +
151
+ "expo() serves the web export from that directory in the image.\n" +
152
+ "Make the image build write the 'expo export --platform web' output there,\n" +
153
+ "or point expo({ staticDir }) at the directory that holds it.",
154
+ );
155
+ process.exit(1);
156
+ }
157
+
74
158
  async function send(res, fp) {
75
159
  const body = await readFile(fp);
76
160
  res.writeHead(200, { "content-type": TYPES[extname(fp)] || "application/octet-stream" });
@@ -98,6 +182,274 @@ createServer(async (req, res) => {
98
182
  }).listen(port, "0.0.0.0", () => console.log("[expo-static] serving " + root + " on :" + port));
99
183
  `;
100
184
 
185
+ /** Where Metro keeps its transform cache. It is `os.tmpdir()/metro-cache`,
186
+ * which is what `@expo/metro-config` sets. A cache mount there keeps the
187
+ * transforms of the build before this one. */
188
+ const METRO_CACHE_DIR = "/tmp/metro-cache";
189
+
190
+ /** The project root inside the VM. Read at call time, like `supabase()`
191
+ * reads it: a component is loaded before a hook context exists. */
192
+ function projectRootPath(): string {
193
+ return process.env.SPECTEST_WORKSPACE ?? "/workspace";
194
+ }
195
+
196
+ /** What the app dir tells us about itself, read once at load. */
197
+ interface AppFacts {
198
+ /** Absolute path of the app dir in the VM. */
199
+ dir: string;
200
+ /** Parsed `package.json`, or `null` if it could not be parsed. */
201
+ pkg: Record<string, unknown> | null;
202
+ /** Is there such a file or directory in the app dir? */
203
+ has(name: string): boolean;
204
+ }
205
+
206
+ function readAppFacts(appDir: string): AppFacts {
207
+ const dir = join(projectRootPath(), appDir);
208
+ if (!existsSync(dir)) {
209
+ throw new Error(`expo(): appDir ${JSON.stringify(appDir)} is not in the project`);
210
+ }
211
+ if (!statSync(dir).isDirectory()) {
212
+ throw new Error(`expo(): appDir ${JSON.stringify(appDir)} is not a directory`);
213
+ }
214
+ const has = (name: string): boolean => existsSync(join(dir, name));
215
+ let pkg: Record<string, unknown> | null = null;
216
+ if (has("package.json")) {
217
+ try {
218
+ pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as Record<string, unknown>;
219
+ } catch (e) {
220
+ throw new Error(`expo(): ${appDir}/package.json is not valid JSON: ${String(e)}`);
221
+ }
222
+ }
223
+ return { dir, pkg, has };
224
+ }
225
+
226
+ /** First line of a version file, without a `v` prefix. */
227
+ function readVersionFile(facts: AppFacts, name: string): string | null {
228
+ if (!facts.has(name)) return null;
229
+ const text = readFileSync(join(facts.dir, name), "utf8").split("\n")[0]!.trim();
230
+ return text === "" ? null : text.replace(/^v/, "");
231
+ }
232
+
233
+ /**
234
+ * The Node major version a range asks for. Clauses with an exclusive upper
235
+ * bound (`<21`) name a version the app must NOT run, so they are dropped
236
+ * first; of the rest the highest wins, because a range like `18 || 20 || 22`
237
+ * lists what the app supports and the newest is the one to build on.
238
+ *
239
+ * Exported for the unit test; it is not part of the component's surface.
240
+ */
241
+ export function nodeMajorFromRange(range: string): number | null {
242
+ // A version is one token, so only its first number is a major: `18.20.4`
243
+ // asks for Node 18, not Node 20.
244
+ const kept = range.replace(/<=?\s*\d+(?:\.[\dx*]+)*/gi, " ");
245
+ const majors = [...kept.matchAll(/(\d+)(?:\.[\dx*]+)*/gi)].map((m) => Number(m[1]));
246
+ const usable = majors.filter((n) => n >= 12 && n <= 99);
247
+ return usable.length === 0 ? null : Math.max(...usable);
248
+ }
249
+
250
+ /** The Node the app declares, and where it said so. */
251
+ function declaredNode(facts: AppFacts): { tag: string; source: string } | null {
252
+ const engines = facts.pkg?.engines as { node?: unknown } | undefined;
253
+ const candidates: Array<[string, string | null]> = [
254
+ ["engines.node", typeof engines?.node === "string" ? engines.node : null],
255
+ [".nvmrc", readVersionFile(facts, ".nvmrc")],
256
+ [".node-version", readVersionFile(facts, ".node-version")],
257
+ ];
258
+ for (const [source, value] of candidates) {
259
+ if (value === null) continue;
260
+ const major = nodeMajorFromRange(value);
261
+ // `lts/*` and other names carry no number. Say nothing and use the default.
262
+ if (major === null) continue;
263
+ return { tag: `${major}-bookworm-slim`, source: `${source} ${JSON.stringify(value)}` };
264
+ }
265
+ return null;
266
+ }
267
+
268
+ /** Node to build on when the app declares none. */
269
+ const DEFAULT_NODE = "22-bookworm-slim";
270
+
271
+ /** A lockfile per package manager. `bun.lockb` is the older binary one. */
272
+ const LOCKFILES: Record<ExpoPackageManager, readonly string[]> = {
273
+ pnpm: ["pnpm-lock.yaml"],
274
+ yarn: ["yarn.lock"],
275
+ bun: ["bun.lock", "bun.lockb"],
276
+ npm: ["package-lock.json", "npm-shrinkwrap.json"],
277
+ };
278
+
279
+ /** The package manager the app uses, and where it said so. */
280
+ function declaredPackageManager(facts: AppFacts): { pm: ExpoPackageManager; source: string } | null {
281
+ // `packageManager` is the one the app pins for Corepack, so it wins.
282
+ const field = facts.pkg?.packageManager;
283
+ if (typeof field === "string") {
284
+ const name = field.split("@")[0] as ExpoPackageManager;
285
+ if (name in LOCKFILES) return { pm: name, source: `packageManager ${JSON.stringify(field)}` };
286
+ }
287
+ // Order matters only for an app that holds two lockfiles. The one that is
288
+ // not npm's is the deliberate one, because npm writes its own on any
289
+ // `npm install` a tool ran by accident.
290
+ for (const pm of ["pnpm", "yarn", "bun", "npm"] as const) {
291
+ const found = LOCKFILES[pm].find((f) => facts.has(f));
292
+ if (found !== undefined) return { pm, source: found };
293
+ }
294
+ return null;
295
+ }
296
+
297
+ /** Everything the Dockerfile needs to install with one package manager. */
298
+ interface InstallPlan {
299
+ /** Files and directories to copy before the install step. */
300
+ manifests: string[];
301
+ /** `ENV` lines the package manager needs. */
302
+ env: string[];
303
+ /** `RUN` lines that make the package manager available. */
304
+ bootstrap: string[];
305
+ /** Directory the download store lives in — the cache mount target. */
306
+ cacheDir: string;
307
+ /** The install command. */
308
+ install: string;
309
+ /** How to run the local `expo` binary. */
310
+ exec: string;
311
+ /** Is this install locked to the lockfile? */
312
+ locked: boolean;
313
+ }
314
+
315
+ /**
316
+ * The install step for one package manager.
317
+ *
318
+ * Only the files an install reads are copied before it, so a source edit
319
+ * keeps the installed layer. `patches` is one of them: patch-package and
320
+ * pnpm's `patchedDependencies` both apply patches during the install, and an
321
+ * install that cannot see them applies none and says so in a line nobody
322
+ * reads.
323
+ */
324
+ function installPlan(facts: AppFacts, pm: ExpoPackageManager): InstallPlan {
325
+ const lock = LOCKFILES[pm].find((f) => facts.has(f));
326
+ const shared = ["package.json", ...(lock === undefined ? [] : [lock])];
327
+ const optional = (names: string[]): string[] => names.filter((n) => facts.has(n));
328
+ const patches = optional(["patches"]);
329
+ const pinned = typeof facts.pkg?.packageManager === "string";
330
+
331
+ switch (pm) {
332
+ case "npm":
333
+ return {
334
+ manifests: [...shared, ...optional([".npmrc"]), ...patches],
335
+ env: [],
336
+ bootstrap: [],
337
+ cacheDir: "/root/.npm",
338
+ // `npm ci` needs the lockfile and installs exactly it.
339
+ install: lock === undefined ? "npm install" : "npm ci",
340
+ exec: "npx expo",
341
+ locked: lock !== undefined,
342
+ };
343
+ case "pnpm":
344
+ return {
345
+ manifests: [...shared, ...optional([".npmrc", "pnpm-workspace.yaml", ".pnpmfile.cjs"]), ...patches],
346
+ // Corepack asks before it downloads a package manager. Answer first.
347
+ env: ["ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0"],
348
+ bootstrap: ["corepack enable pnpm"],
349
+ // The store is a cache mount, so it is not on the same filesystem as
350
+ // node_modules. pnpm then copies where it usually links, which costs
351
+ // disk in the image but keeps the download.
352
+ cacheDir: "/pnpm-store",
353
+ install: `pnpm install --store-dir /pnpm-store${lock === undefined ? "" : " --frozen-lockfile"}`,
354
+ exec: "pnpm exec expo",
355
+ locked: lock !== undefined,
356
+ };
357
+ case "yarn": {
358
+ // Yarn 1 comes with the Node image. Yarn 2 and later are a per-project
359
+ // release that Corepack installs, and they keep the cache elsewhere.
360
+ const berry = facts.has(".yarnrc.yml") || /^yarn@[2-9]/.test(String(facts.pkg?.packageManager ?? ""));
361
+ if (berry) {
362
+ return {
363
+ manifests: [...shared, ...optional([".yarnrc.yml", ".npmrc", ".yarn"]), ...patches],
364
+ env: ["ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0", "ENV YARN_ENABLE_GLOBAL_CACHE=1"],
365
+ bootstrap: [pinned ? "corepack enable yarn" : "corepack enable yarn && corepack prepare yarn@stable --activate"],
366
+ cacheDir: "/root/.yarn/berry/cache",
367
+ install: `yarn install${lock === undefined ? "" : " --immutable"}`,
368
+ exec: "yarn expo",
369
+ locked: lock !== undefined,
370
+ };
371
+ }
372
+ return {
373
+ manifests: [...shared, ...optional([".yarnrc", ".npmrc"]), ...patches],
374
+ env: [],
375
+ bootstrap: [],
376
+ cacheDir: "/yarn-cache",
377
+ install: `yarn install --cache-folder /yarn-cache${lock === undefined ? "" : " --frozen-lockfile"}`,
378
+ exec: "yarn expo",
379
+ locked: lock !== undefined,
380
+ };
381
+ }
382
+ case "bun":
383
+ return {
384
+ manifests: [...shared, ...optional([".npmrc", "bunfig.toml"]), ...patches],
385
+ env: [],
386
+ // The Node image has no Bun. Bun publishes itself to npm.
387
+ bootstrap: ["npm install -g bun"],
388
+ cacheDir: "/root/.bun/install/cache",
389
+ install: `bun install${lock === undefined ? "" : " --frozen-lockfile"}`,
390
+ exec: "bunx expo",
391
+ locked: lock !== undefined,
392
+ };
393
+ }
394
+ }
395
+
396
+ /** `ENV` lines for the `EXPO_PUBLIC_*` entries, ready for the Dockerfile. */
397
+ function publicEnvLines(env: Record<string, string>): string[] {
398
+ return Object.entries(env)
399
+ .filter(([k]) => k.startsWith("EXPO_PUBLIC_"))
400
+ .map(([k, v]) => {
401
+ if (/[\r\n]/.test(v)) {
402
+ throw new Error(`expo(): env ${k} must not contain newlines (baked into the Dockerfile)`);
403
+ }
404
+ return `ENV ${k}="${v.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
405
+ });
406
+ }
407
+
408
+ /**
409
+ * The generated Dockerfile.
410
+ *
411
+ * The order of the steps is the whole point. The manifests come first and the
412
+ * install runs on them alone, so an edit to the app keeps that layer. The
413
+ * source comes next. The `EXPO_PUBLIC_*` values come last before the export,
414
+ * because Expo inlines them at export time and a change to one must not
415
+ * install the dependencies again.
416
+ *
417
+ * Two cache mounts survive the build: the package manager's download store,
418
+ * and Metro's transform cache. They stay in this project's BuildKit state
419
+ * (CONTAINER_STORE.md), so the next cold build starts from them. They need
420
+ * BuildKit, which every builder in a VM is.
421
+ */
422
+ function generateDockerfile(input: {
423
+ nodeVersion: string;
424
+ plan: InstallPlan;
425
+ publicEnv: string[];
426
+ outputDir: string;
427
+ }): string {
428
+ const { nodeVersion, plan, publicEnv, outputDir } = input;
429
+ const lines = [`FROM node:${nodeVersion}`, "WORKDIR /app", "ENV CI=1", ...plan.env];
430
+ for (const step of plan.bootstrap) lines.push(`RUN ${step}`);
431
+ lines.push(`COPY ${plan.manifests.join(" ")} ./`);
432
+ lines.push(`RUN --mount=type=cache,target=${plan.cacheDir},sharing=locked ${plan.install}`);
433
+ lines.push("COPY . .");
434
+ lines.push(...publicEnv);
435
+ lines.push(
436
+ `RUN --mount=type=cache,target=${METRO_CACHE_DIR},sharing=locked ` +
437
+ `${plan.exec} export --platform web --output-dir ${outputDir}`,
438
+ );
439
+ return lines.join("\n") + "\n";
440
+ }
441
+
442
+ /** Options that describe the generated Dockerfile, and thus mean nothing
443
+ * beside `dockerfile`. Each is named if it is set. */
444
+ function conflictingOptions(opts: ExpoOptions): string[] {
445
+ const named: string[] = [];
446
+ if (opts.nodeVersion !== undefined) named.push("nodeVersion");
447
+ if (opts.packageManager !== undefined) named.push("packageManager");
448
+ if (opts.outputDir !== undefined) named.push("outputDir");
449
+ if (Object.keys(opts.env ?? {}).some((k) => k.startsWith("EXPO_PUBLIC_"))) named.push("EXPO_PUBLIC_* in env");
450
+ return named;
451
+ }
452
+
101
453
  /**
102
454
  * A ready-to-use Expo (web) service. Drop into `environment.services` and
103
455
  * drive it with `ctx.mobile`:
@@ -115,42 +467,84 @@ createServer(async (req, res) => {
115
467
  export function expo(opts: ExpoOptions = {}) {
116
468
  const port = opts.port ?? 8081;
117
469
  const appDir = opts.appDir ?? ".";
118
- const nodeVersion = opts.nodeVersion ?? "20-bookworm-slim";
119
470
  const outputDir = opts.outputDir ?? "dist";
471
+ const staticDir = opts.staticDir ?? `/app/${outputDir}`;
472
+ // The server resolves this path in the container, so a relative one would
473
+ // depend on the image's WORKDIR.
474
+ if (!staticDir.startsWith("/")) {
475
+ throw new Error(`expo(): staticDir ${JSON.stringify(staticDir)} must be an absolute path inside the image`);
476
+ }
120
477
 
121
- // Expo inlines EXPO_PUBLIC_* variables into the bundle at `expo export`
122
- // time, so they must exist during the image build — runtime container env
123
- // is too late. Bake them as ENV lines placed after `npm install` (an env
124
- // edit re-exports without re-installing) and before the export RUN. They
125
- // remain in `env` too, so server-side reads in the container agree.
126
- const publicEnv = Object.entries(opts.env ?? {}).filter(([k]) =>
127
- k.startsWith("EXPO_PUBLIC_"),
128
- );
129
- for (const [k, v] of publicEnv) {
130
- if (/[\r\n]/.test(v)) {
131
- throw new Error(`expo(): env ${k} must not contain newlines (baked into the Dockerfile)`);
478
+ let image: ServiceImage;
479
+ if (opts.dockerfile !== undefined) {
480
+ // Your own Dockerfile. Everything that describes ours is refused here
481
+ // rather than ignored, because a setting that does nothing is found only
482
+ // by the person who reads the built image.
483
+ const ignored = conflictingOptions(opts);
484
+ if (ignored.length > 0) {
485
+ throw new Error(
486
+ `expo(): ${ignored.join(", ")} ${ignored.length === 1 ? "describes" : "describe"} the Dockerfile expo() writes, ` +
487
+ `and you gave your own with \`dockerfile\`. Move ${ignored.length === 1 ? "it" : "them"} into ` +
488
+ `${opts.dockerfile}, and use \`staticDir\` to say where that build writes the web export.`,
489
+ );
490
+ }
491
+ image = {
492
+ type: "dockerfile",
493
+ path: opts.dockerfile,
494
+ context: opts.context ?? appDir,
495
+ ...(opts.target === undefined ? {} : { target: opts.target }),
496
+ ...(opts.buildArgs === undefined ? {} : { buildArgs: opts.buildArgs }),
497
+ };
498
+ } else {
499
+ if (opts.context !== undefined) {
500
+ throw new Error("expo(): `context` names the build context of your own `dockerfile`; the generated one builds from `appDir`");
132
501
  }
502
+ const facts = readAppFacts(appDir);
503
+ if (facts.pkg === null) {
504
+ throw new Error(
505
+ `expo(): no package.json in ${appDir === "." ? "the project root" : appDir}. ` +
506
+ `Set \`appDir\` to the directory of the Expo app, or build the image with \`dockerfile\`.`,
507
+ );
508
+ }
509
+
510
+ const node = opts.nodeVersion !== undefined
511
+ ? { tag: opts.nodeVersion, source: "nodeVersion" }
512
+ : (declaredNode(facts) ?? { tag: DEFAULT_NODE, source: "the default" });
513
+ const chosen = opts.packageManager !== undefined
514
+ ? { pm: opts.packageManager, source: "packageManager option" }
515
+ : (declaredPackageManager(facts) ?? { pm: "npm" as const, source: "the default" });
516
+ const plan = installPlan(facts, chosen.pm);
517
+ console.log(
518
+ `expo(): node:${node.tag} (${node.source}), ${plan.install} ` +
519
+ `(${chosen.source}${plan.locked ? "" : ", no lockfile"}).`,
520
+ );
521
+
522
+ image = {
523
+ type: "dockerfile",
524
+ content: generateDockerfile({
525
+ nodeVersion: node.tag,
526
+ plan,
527
+ publicEnv: publicEnvLines(opts.env ?? {}),
528
+ outputDir,
529
+ }),
530
+ // The app dir is the context, so the image copies from it directly and
531
+ // the ignore rules that apply are the app's own. `exclude` gets the
532
+ // last word over them: a `node_modules` from the host would replace the
533
+ // one the install made, and the old export would leave dead files
534
+ // beside the new one.
535
+ context: appDir,
536
+ exclude: ["node_modules", "**/node_modules", ".expo", "**/.expo", outputDir],
537
+ ...(opts.target === undefined ? {} : { target: opts.target }),
538
+ ...(opts.buildArgs === undefined ? {} : { buildArgs: opts.buildArgs }),
539
+ };
133
540
  }
134
- const publicEnvLines = publicEnv
135
- .map(([k, v]) => `ENV ${k}="${v.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`)
136
- .join("\n");
137
-
138
- // CI=1 keeps `expo export` non-interactive. Static export resolves the web
139
- // bundle deterministically into `outputDir`.
140
- const dockerfile = `FROM node:${nodeVersion}
141
- WORKDIR /app
142
- ENV CI=1
143
- COPY ${appDir}/ ./
144
- RUN npm install
145
- ${publicEnvLines ? `${publicEnvLines}\n` : ""}RUN npx expo export --platform web --output-dir ${outputDir}
146
- `;
147
541
 
148
542
  return {
149
- image: { type: "dockerfile", content: dockerfile },
543
+ image,
150
544
  files: [
151
545
  { path: "/spectest-expo-serve.mjs", content: STATIC_SERVER },
152
546
  ],
153
- command: `node /spectest-expo-serve.mjs /app/${outputDir} ${port}`,
547
+ command: `node /spectest-expo-serve.mjs ${staticDir} ${port}`,
154
548
  env: { ...(opts.env ?? {}) },
155
549
  ports: [port],
156
550
  readyCheck: { type: "http" as const, port, path: "/", timeoutSecs: 60 },
@@ -41,6 +41,7 @@ export {
41
41
  expo,
42
42
  type ExpoOptions,
43
43
  type ExpoHelpers,
44
+ type ExpoPackageManager,
44
45
  } from "./expo.js";
45
46
  export {
46
47
  supabase,