@specific.dev/spectest 0.77.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/dist/components/s3.d.ts +1 -3
- package/dist/components/s3.js +14 -6
- 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
- package/src/components/s3.ts +16 -9
|
@@ -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/dist/components/s3.d.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { type S3ClientLike } from "../s3.js";
|
|
2
2
|
export interface S3Options {
|
|
3
|
-
/** HTTP port the S3 API is served on. Default `9090`. */
|
|
4
|
-
port?: number;
|
|
5
3
|
/**
|
|
6
4
|
* Buckets to create on first boot. The backing server's own `initialBuckets`
|
|
7
5
|
* env is unreliable across versions, so the component creates them in `setup`
|
|
@@ -17,7 +15,7 @@ export interface S3Options {
|
|
|
17
15
|
/**
|
|
18
16
|
* Hostnames to additionally serve the store at over **HTTPS** (CA-trusted),
|
|
19
17
|
* via the daemon's TLS-terminating reverse proxy. Optional — defaults to none;
|
|
20
|
-
* the store is always reachable at `http://<key
|
|
18
|
+
* the store is always reachable at `http://<key>:9090` regardless. Set this
|
|
21
19
|
* only when the app under test hardcodes a specific prod S3 endpoint it can't
|
|
22
20
|
* be told to override (`https://<bucket>.s3.amazonaws.com`, a provider's
|
|
23
21
|
* storage host): the daemon mints a cert for each host and proxies to the
|
package/dist/components/s3.js
CHANGED
|
@@ -3,6 +3,15 @@ import { S3Client } from "../s3.js";
|
|
|
3
3
|
// is the current release; avoid exactly 5.0.0, which crashed at startup on
|
|
4
4
|
// Java 25 (`NoClassDefFoundError: KotlinBuiltIns$2`) — fixed in 5.1.0.
|
|
5
5
|
const S3_IMAGE = "adobe/s3mock:5.1.0";
|
|
6
|
+
// Port the store listens on, fixed like the image and deliberately NOT an
|
|
7
|
+
// option. The backing server's HTTP port is baked into the image, and nothing
|
|
8
|
+
// the component returns can move it — so a `port` option could only relabel
|
|
9
|
+
// the ready check, the declared ports and the client endpoint while the
|
|
10
|
+
// container kept listening here. That is exactly what it did until 0.78.0: a
|
|
11
|
+
// project that passed one got 60 s of refused probes and an environment that
|
|
12
|
+
// never booted, with a healthy container beside it. A service reaches the
|
|
13
|
+
// store at `http://<key>:9090`.
|
|
14
|
+
const S3_PORT = 9090;
|
|
6
15
|
/**
|
|
7
16
|
* A ready-to-use, S3-compatible object store (backed by
|
|
8
17
|
* [`adobe/s3mock`](https://github.com/adobe/S3Mock)), with an instrumented S3
|
|
@@ -35,7 +44,6 @@ const S3_IMAGE = "adobe/s3mock:5.1.0";
|
|
|
35
44
|
* ```
|
|
36
45
|
*/
|
|
37
46
|
export function s3(opts = {}) {
|
|
38
|
-
const port = opts.port ?? 9090;
|
|
39
47
|
const buckets = opts.buckets ?? [];
|
|
40
48
|
const defaultBucket = opts.bucket ?? buckets[0];
|
|
41
49
|
const accessKeyId = opts.accessKeyId ?? "s3";
|
|
@@ -43,16 +51,16 @@ export function s3(opts = {}) {
|
|
|
43
51
|
const region = opts.region ?? "us-east-1";
|
|
44
52
|
return {
|
|
45
53
|
image: { type: "registry", reference: S3_IMAGE },
|
|
46
|
-
ports: [
|
|
54
|
+
ports: [S3_PORT],
|
|
47
55
|
...(opts.env ? { env: opts.env } : {}),
|
|
48
56
|
...(opts.hosts
|
|
49
|
-
? { tls: opts.hosts.map((hostname) => ({ hostname, port })) }
|
|
57
|
+
? { tls: opts.hosts.map((hostname) => ({ hostname, port: S3_PORT })) }
|
|
50
58
|
: {}),
|
|
51
59
|
// GET / returns the ListBuckets XML with 200 once the store is up.
|
|
52
|
-
readyCheck: { type: "http", port, path: "/", timeoutSecs: 60 },
|
|
60
|
+
readyCheck: { type: "http", port: S3_PORT, path: "/", timeoutSecs: 60 },
|
|
53
61
|
helpers: ({ name }) => ({
|
|
54
62
|
client: new S3Client({
|
|
55
|
-
endpoint: `http://${name}:${
|
|
63
|
+
endpoint: `http://${name}:${S3_PORT}`,
|
|
56
64
|
region,
|
|
57
65
|
accessKeyId,
|
|
58
66
|
secretAccessKey,
|
|
@@ -67,7 +75,7 @@ export function s3(opts = {}) {
|
|
|
67
75
|
// CreateBucket is a plain `PUT` on the bucket root; the store
|
|
68
76
|
// returns 200 (or 409 if it already exists — idempotent enough to
|
|
69
77
|
// ignore).
|
|
70
|
-
const res = await fetch(`http://${name}:${
|
|
78
|
+
const res = await fetch(`http://${name}:${S3_PORT}/${bucket}`, {
|
|
71
79
|
method: "PUT",
|
|
72
80
|
});
|
|
73
81
|
if (!res.ok && res.status !== 409) {
|