@specific.dev/spectest 0.78.0 → 0.79.1

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.
@@ -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,
@@ -40,6 +40,7 @@ import { codeOf } from "./methods";
40
40
  import {
41
41
  PROTOCOL_VERSION,
42
42
  decodeFrame,
43
+ describeThrow,
43
44
  encodeFrame,
44
45
  fail,
45
46
  ok,
@@ -115,12 +116,11 @@ export function serve(
115
116
  //
116
117
  // A refusal the method meant to make (an unknown case, a second test
117
118
  // while one is running) reports its own kind and just its message. An
118
- // unexpected throw is a bug in the method and carries the stack,
119
- // which is the only place that information exists.
119
+ // unexpected throw carries its message AND its stack — in that order,
120
+ // because a stack alone is sometimes headerless (`describeThrow`).
120
121
  const e = err as Error;
121
122
  const code = codeOf(err);
122
- const detail =
123
- code === "handler_error" ? (e?.stack ?? e?.message ?? String(err)) : e.message;
123
+ const detail = code === "handler_error" ? describeThrow(err) : e.message;
124
124
  send(fail(req.id, detail, code));
125
125
  }
126
126
  };
@@ -18,6 +18,7 @@ import {
18
18
  KNOWN_METHODS,
19
19
  PROTOCOL_VERSION,
20
20
  decodeFrame,
21
+ describeThrow,
21
22
  encodeFrame,
22
23
  fail,
23
24
  ok,
@@ -146,3 +147,44 @@ describe("replies", () => {
146
147
  expect(fail("7", "boom", "c")!.error!.code).toBe("c");
147
148
  });
148
149
  });
150
+
151
+ describe("describeThrow", () => {
152
+ test("an ordinary stack, which already opens with the message, is untouched", () => {
153
+ const err = new Error("docker build for web failed:\nCOPY: not found");
154
+ expect(describeThrow(err)).toBe(err.stack);
155
+ });
156
+
157
+ /**
158
+ * The shape this function exists for, produced rather than written by
159
+ * hand: a promise that rejects while nobody awaits it yet and is
160
+ * observed a turn later comes back with a headerless stack. That is a
161
+ * bootstrap image build (`daemon.ts` starts every build at once and a
162
+ * service awaits its own prep when the DAG reaches it), and the build
163
+ * log lives in the message it drops.
164
+ */
165
+ test("a headerless stack gets its message back", async () => {
166
+ const message = "docker build for client-app failed:\n#12 ERROR: killed";
167
+ const rejected = (async () => {
168
+ throw new Error(message);
169
+ })();
170
+ rejected.catch(() => undefined);
171
+ await new Promise((r) => setTimeout(r, 5));
172
+ let caught: unknown;
173
+ try {
174
+ await rejected;
175
+ } catch (e) {
176
+ caught = e;
177
+ }
178
+ const described = describeThrow(caught);
179
+ expect(described).toContain(message);
180
+ expect(described.startsWith(`Error: ${message}`)).toBe(true);
181
+ // The frames survive, and the bare `Error` line they hung under does not.
182
+ expect(described).toContain(" at ");
183
+ expect(described).not.toContain("\nError\n");
184
+ });
185
+
186
+ test("a non-Error keeps whatever it can say for itself", () => {
187
+ expect(describeThrow("plain string")).toBe("plain string");
188
+ expect(describeThrow({ message: "no stack here" })).toBe("Error: no stack here");
189
+ });
190
+ });
@@ -161,3 +161,36 @@ export function ok(id: string, result: Record<string, unknown> = {}): ResponseFr
161
161
  export function fail(id: string, message: string, code?: string): ResponseFrame {
162
162
  return { kind: "response", id, ok: false, error: code ? { message, code } : { message } };
163
163
  }
164
+
165
+ /**
166
+ * Describe a thrown value for the wire: the **message first**, then the
167
+ * frames — never the stack on its own.
168
+ *
169
+ * `error.stack` normally opens with `Error: <message>`, which is why it
170
+ * looked like the richer of the two. It is not always: Bun drops that
171
+ * header when the rejection is observed a turn later than it settled —
172
+ * a promise that rejected while nobody was awaiting it yet, which is
173
+ * exactly what a bootstrap image build is (`daemon.ts` starts every
174
+ * build at once and each service awaits its own prep when the DAG
175
+ * reaches it). The whole diagnosis of a failed build IS the message
176
+ * there: `docker build for <svc> failed:` plus the build log. Sending
177
+ * the stack alone published four frames and nothing else — no docker,
178
+ * no BuildKit, no compiler error (reported by the Harmony project,
179
+ * 2026-09-09; the same run's log had the message intact).
180
+ *
181
+ * A stack that already carries the message is returned as it stands, so
182
+ * the ordinary case is unchanged.
183
+ */
184
+ export function describeThrow(err: unknown): string {
185
+ const e = err as { message?: unknown; stack?: unknown; name?: unknown } | null | undefined;
186
+ const message = typeof e?.message === "string" ? e.message : "";
187
+ const stack = typeof e?.stack === "string" ? e.stack : "";
188
+ if (!message) return stack || String(err);
189
+ if (stack.includes(message)) return stack;
190
+ const name = typeof e?.name === "string" && e.name.length > 0 ? e.name : "Error";
191
+ // The headerless form is `<Name>\n at …`. Drop that bare first line
192
+ // and write the header ourselves, so the result reads like the stack a
193
+ // reader expects rather than a message with a stray `Error` in it.
194
+ const frames = stack.startsWith(`${name}\n`) ? stack.slice(name.length + 1) : stack;
195
+ return frames.length > 0 ? `${name}: ${message}\n${frames}` : `${name}: ${message}`;
196
+ }