@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.
- package/dist/components/expo.d.ts +73 -3
- package/dist/components/expo.js +333 -31
- package/dist/components/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/expo.test.ts +227 -0
- package/src/components/expo.ts +432 -38
- package/src/components/index.ts +1 -0
|
@@ -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
|
|
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
|
-
/**
|
|
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;
|
package/dist/components/expo.js
CHANGED
|
@@ -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 (
|
|
6
|
-
//
|
|
7
|
-
// and
|
|
8
|
-
//
|
|
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
|
|
17
|
-
// routes. Embedded as a file rather than `npx serve` so the
|
|
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
|
-
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
409
|
+
image,
|
|
108
410
|
files: [
|
|
109
411
|
{ path: "/spectest-expo-serve.mjs", content: STATIC_SERVER },
|
|
110
412
|
],
|
|
111
|
-
command: `node /spectest-expo-serve.mjs
|
|
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
|
@@ -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
|
+
});
|
package/src/components/expo.ts
CHANGED
|
@@ -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 (
|
|
6
|
-
//
|
|
7
|
-
// and
|
|
8
|
-
//
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
53
|
-
// routes. Embedded as a file rather than `npx serve` so the
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
543
|
+
image,
|
|
150
544
|
files: [
|
|
151
545
|
{ path: "/spectest-expo-serve.mjs", content: STATIC_SERVER },
|
|
152
546
|
],
|
|
153
|
-
command: `node /spectest-expo-serve.mjs
|
|
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 },
|