@ykdz/template 0.0.22 → 0.0.23

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.
Files changed (27) hide show
  1. package/node_modules/@ykdz/template-builtin-source/package.json +1 -1
  2. package/node_modules/@ykdz/template-builtin-source/templates/preset-source.json +7 -0
  3. package/node_modules/@ykdz/template-builtin-source/templates/vike-app/.github/workflows/check.yml +2 -8
  4. package/node_modules/@ykdz/template-builtin-source/templates/vike-app/web/Dockerfile +45 -9
  5. package/node_modules/@ykdz/template-builtin-source/templates/vike-app/web/pages/index/+Page.telefunc.ts +1 -2
  6. package/node_modules/@ykdz/template-builtin-source/templates/vike-app/web/playwright.config.ts +45 -9
  7. package/node_modules/@ykdz/template-builtin-source/templates/vike-app/web/scripts/check-standalone-deployment.ts +449 -0
  8. package/node_modules/@ykdz/template-builtin-source/templates/vike-app/web/scripts/container-entrypoint.sh +52 -0
  9. package/node_modules/@ykdz/template-builtin-source/templates/vike-app/web/scripts/prepare-database.sh +6 -0
  10. package/node_modules/@ykdz/template-builtin-source/templates/vike-app/web/scripts/run-playwright.ts +95 -25
  11. package/node_modules/@ykdz/template-builtin-source/templates/vike-app/web/server/app.ts +1 -1
  12. package/node_modules/@ykdz/template-core/dist/dependency-catalog.js +13 -0
  13. package/node_modules/@ykdz/template-core/dist/generated-scenarios.d.ts +3 -2
  14. package/node_modules/@ykdz/template-core/dist/generated-scenarios.js +99 -13
  15. package/node_modules/@ykdz/template-core/dist/module-graph.d.ts +12 -0
  16. package/node_modules/@ykdz/template-core/dist/module-graph.js +18 -0
  17. package/node_modules/@ykdz/template-core/dist/pnpm-workspace.yaml +7 -4
  18. package/node_modules/@ykdz/template-core/dist/preset-projection.d.ts +1 -2
  19. package/node_modules/@ykdz/template-core/dist/project-github.d.ts +0 -8
  20. package/node_modules/@ykdz/template-core/dist/project-github.js +11 -4
  21. package/node_modules/@ykdz/template-core/dist/projection-capabilities.js +15 -13
  22. package/node_modules/@ykdz/template-core/dist/renderer.js +1 -1
  23. package/node_modules/@ykdz/template-core/dist/template-boundary-check.js +1 -1
  24. package/node_modules/@ykdz/template-core/package.json +2 -1
  25. package/node_modules/@ykdz/template-shared/dist/index.d.ts +6 -6
  26. package/node_modules/@ykdz/template-shared/package.json +1 -0
  27. package/package.json +5 -4
@@ -65,7 +65,6 @@
65
65
  "dependencies": {
66
66
  "@ykdz/template-core": "workspace:*",
67
67
  "@ykdz/template-shared": "workspace:*",
68
- "typescript": "catalog:",
69
68
  "valibot": "catalog:"
70
69
  },
71
70
  "devDependencies": {
@@ -73,6 +72,7 @@
73
72
  "@playwright/test": "catalog:",
74
73
  "@tailwindcss/vite": "catalog:",
75
74
  "@types/node": "catalog:",
75
+ "@typescript/native": "catalog:",
76
76
  "@vikejs/hono": "catalog:",
77
77
  "@vitejs/plugin-vue": "catalog:",
78
78
  "drizzle-kit": "catalog:",
@@ -223,6 +223,7 @@
223
223
  "oxfmt",
224
224
  "oxlint",
225
225
  "oxlint-tsgolint",
226
+ "srvx",
226
227
  "tailwindcss",
227
228
  "telefunc",
228
229
  "typescript",
@@ -263,6 +264,9 @@
263
264
  "web/pages/index/+Page.telefunc.ts",
264
265
  "web/pages/tailwind.css",
265
266
  "web/playwright.config.ts",
267
+ "web/scripts/check-standalone-deployment.ts",
268
+ "web/scripts/container-entrypoint.sh",
269
+ "web/scripts/prepare-database.sh",
266
270
  "web/scripts/run-playwright.ts",
267
271
  "web/turbo.json",
268
272
  "web/server/api.ts",
@@ -311,6 +315,9 @@
311
315
  "vike-app/web/Dockerfile",
312
316
  "vike-app/web/Dockerfile.dockerignore",
313
317
  "vike-app/web/playwright.config.ts",
318
+ "vike-app/web/scripts/check-standalone-deployment.ts",
319
+ "vike-app/web/scripts/container-entrypoint.sh",
320
+ "vike-app/web/scripts/prepare-database.sh",
314
321
  "vike-app/web/vite.config.ts",
315
322
  "vike-app/web/turbo.json",
316
323
  "vike-app/web/vitest.config.ts"
@@ -15,14 +15,8 @@ jobs:
15
15
  with:
16
16
  node-version-file: package.json
17
17
  - run: corepack enable
18
+ - uses: docker/setup-buildx-action@v3
18
19
  - run: pnpm install
19
20
  - run: pnpm --filter ./apps/web exec playwright install --with-deps chromium
20
21
  - run: pnpm run check
21
-
22
- docker-image:
23
- runs-on: ubuntu-latest
24
- steps:
25
- - uses: actions/checkout@v7
26
- - uses: docker/setup-buildx-action@v3
27
- - run: docker buildx build --load --file apps/web/Dockerfile --target runtime --tag vike-app:check .
28
- - run: docker buildx build --load --file apps/web/Dockerfile --target database-preparation --tag vike-app-db-prepare:check .
22
+ - run: pnpm run check:deployment
@@ -1,7 +1,10 @@
1
1
  # syntax=docker/dockerfile:1.7
2
2
 
3
+ ARG DEPLOYMENT_BUILD_ID="local"
4
+
3
5
  FROM node:{{NODE_VERSION}}-bookworm-slim AS base
4
6
  ARG PACKAGE_MANAGER_PIN="{{PACKAGE_MANAGER_PIN}}"
7
+ ENV COREPACK_HOME="/corepack"
5
8
  ENV PNPM_HOME="/pnpm"
6
9
  ENV PATH="$PNPM_HOME:$PATH"
7
10
  WORKDIR /repo
@@ -25,6 +28,21 @@ COPY --from=pruner /repo/out/json/ ./
25
28
  RUN pnpm install --offline --frozen-lockfile
26
29
  COPY --from=pruner /repo/out/full/ ./
27
30
  RUN pnpm --filter ./apps/web run build:run
31
+ RUN pnpm --filter ./apps/web deploy --prod --legacy /runtime-deploy \
32
+ && rm -rf /runtime-deploy/node_modules/@*/db \
33
+ && find /runtime-deploy/node_modules/.pnpm -mindepth 1 -maxdepth 1 -type d -name '*@file+packages+db' -exec rm -rf '{}' +
34
+
35
+ FROM node:{{NODE_VERSION}}-bookworm-slim AS application-runtime
36
+ ENV NODE_ENV="production"
37
+ WORKDIR /app
38
+ RUN groupadd --system --gid 1001 nodejs \
39
+ && useradd --system --uid 1001 --gid nodejs --home-dir /app --shell /usr/sbin/nologin app \
40
+ && install -d -o app -g nodejs /app /data
41
+ COPY --from=build --chown=app:nodejs /runtime-deploy/node_modules ./node_modules
42
+ COPY --from=build --chown=app:nodejs /repo/apps/web/dist ./dist
43
+ COPY --chmod=755 --from=build /repo/apps/web/scripts/container-entrypoint.sh /usr/local/bin/container-entrypoint
44
+ ENV DATABASE_FILE="/data/app.sqlite"
45
+ USER app
28
46
 
29
47
  FROM base AS database-preparation
30
48
  ENV NODE_ENV="production"
@@ -32,16 +50,34 @@ COPY --from=pruner /repo/out/json/pnpm-lock.yaml ./pnpm-lock.yaml
32
50
  COPY --from=pruner /repo/out/json/pnpm-workspace.yaml ./pnpm-workspace.yaml
33
51
  RUN pnpm fetch
34
52
  COPY --from=pruner /repo/out/json/ ./
35
- RUN pnpm install --offline --frozen-lockfile
53
+ RUN pnpm install --offline --frozen-lockfile --prod=false
36
54
  COPY --from=pruner /repo/out/full/ ./
37
- CMD ["pnpm", "--dir", "packages/db", "run", "db:prepare:deploy"]
38
-
39
- FROM node:{{NODE_VERSION}}-bookworm-slim AS runtime
40
- ENV NODE_ENV="production"
41
- WORKDIR /app
42
55
  RUN groupadd --system --gid 1001 nodejs \
43
- && useradd --system --uid 1001 --gid nodejs --home-dir /app --shell /usr/sbin/nologin app
44
- COPY --from=build --chown=app:nodejs /repo/apps/web/dist ./dist
56
+ && useradd --system --uid 1001 --gid nodejs --home-dir /app --shell /usr/sbin/nologin app \
57
+ && install -d -o app -g nodejs /app /data
58
+ COPY --chmod=755 --from=pruner /repo/out/full/apps/web/scripts/container-entrypoint.sh /usr/local/bin/container-entrypoint
59
+ COPY --chmod=755 --from=pruner /repo/out/full/apps/web/scripts/prepare-database.sh /usr/local/bin/prepare-database
60
+ ENV DATABASE_FILE="/data/app.sqlite"
45
61
  USER app
62
+
63
+ # Final deployment target: standalone
64
+ # One-container deployments prepare the database before startup.
65
+ FROM database-preparation AS standalone
66
+ ARG DEPLOYMENT_BUILD_ID
67
+ LABEL org.opencontainers.image.version="$DEPLOYMENT_BUILD_ID"
68
+ WORKDIR /app
69
+ COPY --from=application-runtime --chown=app:nodejs /app/node_modules ./node_modules
70
+ COPY --from=application-runtime --chown=app:nodejs /app/dist ./dist
71
+ EXPOSE 3000
72
+ ENTRYPOINT ["/usr/local/bin/container-entrypoint"]
73
+ CMD ["prepare-and-start"]
74
+
75
+ # Final deployment target: runtime
76
+ # Start-only image for databases prepared by a matching standalone image.
77
+ FROM application-runtime AS runtime
78
+ ARG DEPLOYMENT_BUILD_ID
79
+ LABEL org.opencontainers.image.version="$DEPLOYMENT_BUILD_ID"
80
+ ENV CONTAINER_CAPABILITY="start-only"
46
81
  EXPOSE 3000
47
- CMD ["node", "dist/server/index.mjs"]
82
+ ENTRYPOINT ["/usr/local/bin/container-entrypoint"]
83
+ CMD ["start-only"]
@@ -1,6 +1,5 @@
1
- import { getContext } from "telefunc";
2
-
3
1
  // @template-anchor db-package-import
2
+ import { getContext } from "telefunc";
4
3
 
5
4
  export type Todo = Awaited<ReturnType<typeof onLoadTodos>>[number];
6
5
 
@@ -1,5 +1,31 @@
1
1
  import { defineConfig, devices } from "@playwright/test";
2
2
 
3
+ const externalBaseUrlName = "PLAYWRIGHT_EXTERNAL_BASE_URL";
4
+
5
+ function externalBaseUrl(): string | undefined {
6
+ if (!Object.hasOwn(process.env, externalBaseUrlName)) {
7
+ return undefined;
8
+ }
9
+
10
+ const value = process.env[externalBaseUrlName]?.trim();
11
+ if (!value) {
12
+ throw new Error(`${externalBaseUrlName} must be a non-empty HTTP(S) URL`);
13
+ }
14
+
15
+ let url: URL;
16
+ try {
17
+ url = new URL(value);
18
+ } catch {
19
+ throw new Error(`${externalBaseUrlName} must be a valid HTTP(S) URL`);
20
+ }
21
+
22
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
23
+ throw new Error(`${externalBaseUrlName} must use HTTP or HTTPS`);
24
+ }
25
+
26
+ return url.toString();
27
+ }
28
+
3
29
  function requiredPort(name: string): number {
4
30
  return Number(requiredEnv(name));
5
31
  }
@@ -14,9 +40,14 @@ function requiredEnv(name: string): string {
14
40
  return value;
15
41
  }
16
42
 
17
- const previewPort = requiredPort("PLAYWRIGHT_WEB_PORT");
18
- const previewUrl = `http://127.0.0.1:${previewPort}`;
19
- const databaseFile = requiredEnv("DATABASE_FILE");
43
+ const externalServiceUrl = externalBaseUrl();
44
+ const previewPort = externalServiceUrl
45
+ ? undefined
46
+ : requiredPort("PLAYWRIGHT_WEB_PORT");
47
+ const previewUrl = externalServiceUrl ?? `http://127.0.0.1:${previewPort}`;
48
+ const databaseFile = externalServiceUrl
49
+ ? undefined
50
+ : requiredEnv("DATABASE_FILE");
20
51
 
21
52
  function shellValue(value: string): string {
22
53
  return `'${value.replaceAll("'", "'\\''")}'`;
@@ -27,13 +58,18 @@ export default defineConfig({
27
58
  timeout: 60_000,
28
59
  use: {
29
60
  baseURL: previewUrl,
30
- trace: "on-first-retry",
31
- },
32
- webServer: {
33
- command: `DATABASE_FILE=${shellValue(databaseFile)} pnpm --dir ../../packages/db run db:prepare:test && PORT=${previewPort} node dist/server/index.mjs`,
34
- reuseExistingServer: !process.env.CI,
35
- url: previewUrl,
61
+ trace: "retain-on-failure",
36
62
  },
63
+ ...(externalServiceUrl
64
+ ? {}
65
+ : {
66
+ webServer: {
67
+ command: `DATABASE_FILE=${shellValue(databaseFile!)} pnpm --dir ../../packages/db run db:prepare:test && PORT=${previewPort} node dist/server/index.mjs`,
68
+ reuseExistingServer: !process.env.CI,
69
+ timeout: 60_000,
70
+ url: previewUrl,
71
+ },
72
+ }),
37
73
  projects: [
38
74
  {
39
75
  name: "chromium",
@@ -0,0 +1,449 @@
1
+ import { spawn } from "node:child_process";
2
+ import path from "node:path";
3
+
4
+ const webRoot = path.resolve(import.meta.dirname, "..");
5
+ const repositoryRoot = path.resolve(webRoot, "../..");
6
+ const uniqueId = `${process.pid}-${Date.now()}`;
7
+ const standaloneImage = `vike-deployment-check-standalone:${uniqueId}`;
8
+ const runtimeImage = `vike-deployment-check-runtime:${uniqueId}`;
9
+ const containers = {
10
+ standalone: `vike-deployment-check-standalone-${uniqueId}`,
11
+ preparation: `vike-deployment-check-preparation-${uniqueId}`,
12
+ runtime: `vike-deployment-check-runtime-${uniqueId}`,
13
+ unprepared: `vike-deployment-check-unprepared-${uniqueId}`,
14
+ } as const;
15
+ const volumes = {
16
+ standalone: `vike-deployment-check-standalone-${uniqueId}`,
17
+ prepared: `vike-deployment-check-prepared-${uniqueId}`,
18
+ unprepared: `vike-deployment-check-unprepared-${uniqueId}`,
19
+ } as const;
20
+ const abortController = new AbortController();
21
+ const forwardedSignals = ["SIGINT", "SIGTERM"] as const;
22
+ const signalHandlers = new Map<NodeJS.Signals, () => void>();
23
+ const defaultReadinessTimeoutMs = 60_000;
24
+
25
+ interface CommandResult {
26
+ readonly stderr: string;
27
+ readonly stdout: string;
28
+ }
29
+
30
+ interface CommandOptions {
31
+ readonly cwd?: string;
32
+ readonly env?: NodeJS.ProcessEnv;
33
+ readonly signal?: AbortSignal;
34
+ readonly timeout: number;
35
+ }
36
+
37
+ type DeploymentMode =
38
+ | "standalone"
39
+ | "external-preparation"
40
+ | "runtime"
41
+ | "fresh-runtime";
42
+
43
+ async function deploymentPhase<T>(
44
+ mode: DeploymentMode,
45
+ phase: string,
46
+ operation: () => Promise<T>,
47
+ ): Promise<T> {
48
+ try {
49
+ return await operation();
50
+ } catch (error: unknown) {
51
+ throw new Error(`Deployment mode ${mode} failed during ${phase}.`, {
52
+ cause: error,
53
+ });
54
+ }
55
+ }
56
+
57
+ function commandDisplay(command: string, args: readonly string[]): string {
58
+ return [command, ...args].map((part) => JSON.stringify(part)).join(" ");
59
+ }
60
+
61
+ function runCommand(
62
+ command: string,
63
+ args: readonly string[],
64
+ options: CommandOptions,
65
+ ): Promise<CommandResult> {
66
+ return new Promise((resolve, reject) => {
67
+ const child = spawn(command, args, {
68
+ cwd: options.cwd,
69
+ env: options.env,
70
+ signal: options.signal,
71
+ stdio: ["ignore", "pipe", "pipe"],
72
+ });
73
+ let stderr = "";
74
+ let stdout = "";
75
+ let timedOut = false;
76
+ let settled = false;
77
+ child.stdout.setEncoding("utf8");
78
+ child.stderr.setEncoding("utf8");
79
+ child.stdout.on("data", (chunk: string) => {
80
+ stdout += chunk;
81
+ });
82
+ child.stderr.on("data", (chunk: string) => {
83
+ stderr += chunk;
84
+ });
85
+ const timeout = setTimeout(() => {
86
+ timedOut = true;
87
+ child.kill("SIGKILL");
88
+ }, options.timeout);
89
+ const finish = (error?: Error): void => {
90
+ if (settled) return;
91
+ settled = true;
92
+ clearTimeout(timeout);
93
+ if (error === undefined) {
94
+ resolve({ stderr, stdout });
95
+ } else {
96
+ reject(Object.assign(error, { stderr, stdout }));
97
+ }
98
+ };
99
+ child.once("error", (error) => finish(error));
100
+ child.once("close", (code, signal) => {
101
+ if (code === 0 && !timedOut) {
102
+ finish();
103
+ return;
104
+ }
105
+ finish(
106
+ Object.assign(
107
+ new Error(
108
+ `${commandDisplay(command, args)} ${
109
+ timedOut
110
+ ? `timed out after ${options.timeout}ms`
111
+ : `exited with code ${String(code)}${signal === null ? "" : ` from ${signal}`}`
112
+ }\nstdout:\n${stdout}\nstderr:\n${stderr}`,
113
+ ),
114
+ { code, signal },
115
+ ),
116
+ );
117
+ });
118
+ });
119
+ }
120
+
121
+ function readinessTimeoutMs(): number {
122
+ const configured = process.env.STANDALONE_DEPLOYMENT_READINESS_TIMEOUT_MS;
123
+ if (configured === undefined) {
124
+ return defaultReadinessTimeoutMs;
125
+ }
126
+
127
+ const timeout = Number(configured);
128
+ if (!Number.isSafeInteger(timeout) || timeout <= 0) {
129
+ throw new Error(
130
+ "STANDALONE_DEPLOYMENT_READINESS_TIMEOUT_MS must be a positive integer",
131
+ );
132
+ }
133
+ return timeout;
134
+ }
135
+
136
+ async function dockerWithTimeout(
137
+ timeout: number,
138
+ ...args: string[]
139
+ ): Promise<string> {
140
+ const { stdout } = await runCommand("docker", args, {
141
+ cwd: repositoryRoot,
142
+ signal: abortController.signal,
143
+ timeout,
144
+ });
145
+ return stdout.trim();
146
+ }
147
+
148
+ async function docker(...args: string[]): Promise<string> {
149
+ return dockerWithTimeout(600_000, ...args);
150
+ }
151
+
152
+ async function cleanup(): Promise<void> {
153
+ const cleanupCommands = [
154
+ ...Object.values(containers).map((name) => ["rm", "--force", name]),
155
+ ...Object.values(volumes).map((name) => ["volume", "rm", "--force", name]),
156
+ ["image", "rm", "--force", runtimeImage],
157
+ ["image", "rm", "--force", standaloneImage],
158
+ ];
159
+
160
+ for (const args of cleanupCommands) {
161
+ try {
162
+ await runCommand("docker", args, {
163
+ cwd: repositoryRoot,
164
+ timeout: 30_000,
165
+ });
166
+ } catch {
167
+ // A resource may not have been created yet.
168
+ }
169
+ }
170
+ }
171
+
172
+ async function containerLogs(container: string): Promise<string> {
173
+ try {
174
+ const { stderr, stdout } = await runCommand("docker", ["logs", container], {
175
+ cwd: repositoryRoot,
176
+ signal: AbortSignal.timeout(10_000),
177
+ timeout: 10_000,
178
+ });
179
+ return [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
180
+ } catch (error) {
181
+ return `Container logs unavailable: ${String(error)}`;
182
+ }
183
+ }
184
+
185
+ async function deploymentLogs(): Promise<string> {
186
+ const logs = await Promise.all(
187
+ Object.entries(containers).map(
188
+ async ([phase, container]) =>
189
+ `${phase} container logs:\n${await containerLogs(container)}`,
190
+ ),
191
+ );
192
+ return logs.join("\n");
193
+ }
194
+
195
+ async function publishedBaseUrl(container: string): Promise<string> {
196
+ const portOutput = await dockerWithTimeout(
197
+ 10_000,
198
+ "port",
199
+ container,
200
+ "3000/tcp",
201
+ );
202
+ const match = /:(\d+)$/u.exec(portOutput.split("\n")[0] ?? "");
203
+ if (!match?.[1]) {
204
+ throw new Error(
205
+ `Could not determine the published web port: ${portOutput}`,
206
+ );
207
+ }
208
+ return `http://127.0.0.1:${match[1]}`;
209
+ }
210
+
211
+ async function waitForReady(
212
+ phase: "Standalone" | "Runtime",
213
+ container: string,
214
+ baseUrl: string,
215
+ ): Promise<void> {
216
+ const timeout = readinessTimeoutMs();
217
+ const deadline = Date.now() + timeout;
218
+ while (Date.now() < deadline) {
219
+ try {
220
+ const response = await fetch(baseUrl, {
221
+ signal: AbortSignal.timeout(2_000),
222
+ });
223
+ if (response.ok) {
224
+ return;
225
+ }
226
+ } catch {
227
+ // The container may still be starting the server.
228
+ }
229
+
230
+ const running = await dockerWithTimeout(
231
+ 5_000,
232
+ "inspect",
233
+ "--format={{.State.Running}}",
234
+ container,
235
+ );
236
+ if (running !== "true") {
237
+ throw new Error(
238
+ `${phase} container exited before readiness.\n${await containerLogs(container)}`,
239
+ );
240
+ }
241
+ await new Promise((resolve) => setTimeout(resolve, 250));
242
+ }
243
+
244
+ throw new Error(
245
+ `${phase} container was not ready within ${timeout}ms.\n${await containerLogs(container)}`,
246
+ );
247
+ }
248
+
249
+ async function assertProcessIdentity(
250
+ phase: "Standalone" | "Runtime",
251
+ container: string,
252
+ ): Promise<void> {
253
+ await docker(
254
+ "exec",
255
+ container,
256
+ "node",
257
+ "--input-type=module",
258
+ "--eval",
259
+ `import { statSync } from "node:fs";
260
+ import { DatabaseSync } from "node:sqlite";
261
+ const expectedIdentity = 1001;
262
+ if (process.getuid() !== expectedIdentity || process.getgid() !== expectedIdentity) {
263
+ throw new Error(\`${phase} process identity must be 1001:1001, received \${process.getuid()}:\${process.getgid()}\`);
264
+ }
265
+ const databaseStat = statSync(process.env.DATABASE_FILE);
266
+ if (databaseStat.uid !== expectedIdentity || databaseStat.gid !== expectedIdentity) {
267
+ throw new Error(\`Database ownership must be 1001:1001, received \${databaseStat.uid}:\${databaseStat.gid}\`);
268
+ }`,
269
+ );
270
+ }
271
+
272
+ async function assertEmptyDatabase(container: string): Promise<void> {
273
+ await docker(
274
+ "exec",
275
+ container,
276
+ "node",
277
+ "--input-type=module",
278
+ "--eval",
279
+ `import { DatabaseSync } from "node:sqlite";
280
+ const db = new DatabaseSync(process.env.DATABASE_FILE);
281
+ const row = db.prepare("select count(*) as count from todos").get();
282
+ if (row.count !== 0) throw new Error(\`Fresh deployment must contain zero TODOs, received \${row.count}\`);`,
283
+ );
284
+ }
285
+
286
+ async function runPlaywright(baseUrl: string): Promise<void> {
287
+ await runCommand("pnpm", ["--dir", "apps/web", "run", "test:e2e:run"], {
288
+ cwd: repositoryRoot,
289
+ env: {
290
+ ...process.env,
291
+ PLAYWRIGHT_EXTERNAL_BASE_URL: baseUrl,
292
+ },
293
+ signal: abortController.signal,
294
+ timeout: 120_000,
295
+ });
296
+ }
297
+
298
+ async function runStartedDeployment(
299
+ mode: "standalone" | "runtime",
300
+ readinessLabel: "Standalone" | "Runtime",
301
+ container: string,
302
+ volume: string,
303
+ image: string,
304
+ ): Promise<void> {
305
+ await deploymentPhase(mode, "start", async () =>
306
+ docker(
307
+ "run",
308
+ "--detach",
309
+ "--name",
310
+ container,
311
+ "--publish",
312
+ "127.0.0.1::3000",
313
+ "--volume",
314
+ `${volume}:/data`,
315
+ image,
316
+ ),
317
+ );
318
+ const baseUrl = await deploymentPhase(mode, "start", async () =>
319
+ publishedBaseUrl(container),
320
+ );
321
+ await deploymentPhase(mode, "readiness", async () =>
322
+ waitForReady(readinessLabel, container, baseUrl),
323
+ );
324
+ await deploymentPhase(mode, "identity", async () =>
325
+ assertProcessIdentity(readinessLabel, container),
326
+ );
327
+ await deploymentPhase(mode, "empty-database", async () =>
328
+ assertEmptyDatabase(container),
329
+ );
330
+ await deploymentPhase(mode, "playwright", async () => runPlaywright(baseUrl));
331
+ }
332
+
333
+ async function assertUnpreparedRuntimeFails(): Promise<void> {
334
+ try {
335
+ await dockerWithTimeout(
336
+ 30_000,
337
+ "run",
338
+ "--name",
339
+ containers.unprepared,
340
+ "--volume",
341
+ `${volumes.unprepared}:/data`,
342
+ runtimeImage,
343
+ );
344
+ } catch (error) {
345
+ const failure = error as {
346
+ code?: unknown;
347
+ stderr?: string;
348
+ stdout?: string;
349
+ };
350
+ const output = `${failure.stdout ?? ""}\n${failure.stderr ?? ""}`;
351
+ if (typeof failure.code === "number" && failure.code !== 0) {
352
+ if (!output.includes("Database is not ready")) {
353
+ throw new Error(
354
+ `Unprepared runtime did not report database readiness failure.\n${output}\n${await containerLogs(containers.unprepared)}`,
355
+ { cause: error },
356
+ );
357
+ }
358
+ return;
359
+ }
360
+ throw error;
361
+ }
362
+
363
+ throw new Error("Unprepared runtime started successfully");
364
+ }
365
+
366
+ for (const signal of forwardedSignals) {
367
+ const handler = (): void => {
368
+ abortController.abort(new Error(`Received ${signal}`));
369
+ };
370
+ signalHandlers.set(signal, handler);
371
+ process.once(signal, handler);
372
+ }
373
+
374
+ try {
375
+ await deploymentPhase("standalone", "build", async () =>
376
+ docker(
377
+ "build",
378
+ "--file",
379
+ "apps/web/Dockerfile",
380
+ "--target",
381
+ "standalone",
382
+ "--build-arg",
383
+ `DEPLOYMENT_BUILD_ID=${uniqueId}`,
384
+ "--tag",
385
+ standaloneImage,
386
+ ".",
387
+ ),
388
+ );
389
+ await deploymentPhase("runtime", "build", async () =>
390
+ docker(
391
+ "build",
392
+ "--file",
393
+ "apps/web/Dockerfile",
394
+ "--target",
395
+ "runtime",
396
+ "--build-arg",
397
+ `DEPLOYMENT_BUILD_ID=${uniqueId}`,
398
+ "--tag",
399
+ runtimeImage,
400
+ ".",
401
+ ),
402
+ );
403
+ for (const volume of Object.values(volumes)) {
404
+ await docker("volume", "create", volume);
405
+ }
406
+
407
+ await runStartedDeployment(
408
+ "standalone",
409
+ "Standalone",
410
+ containers.standalone,
411
+ volumes.standalone,
412
+ standaloneImage,
413
+ );
414
+
415
+ await deploymentPhase("external-preparation", "prepare", async () =>
416
+ docker(
417
+ "run",
418
+ "--name",
419
+ containers.preparation,
420
+ "--volume",
421
+ `${volumes.prepared}:/data`,
422
+ standaloneImage,
423
+ "prepare-only",
424
+ ),
425
+ );
426
+ await runStartedDeployment(
427
+ "runtime",
428
+ "Runtime",
429
+ containers.runtime,
430
+ volumes.prepared,
431
+ runtimeImage,
432
+ );
433
+
434
+ await deploymentPhase("fresh-runtime", "rejection", async () =>
435
+ assertUnpreparedRuntimeFails(),
436
+ );
437
+ } catch (error) {
438
+ console.error(error);
439
+ console.error(await deploymentLogs());
440
+ process.exitCode = 1;
441
+ } finally {
442
+ for (const signal of forwardedSignals) {
443
+ const handler = signalHandlers.get(signal);
444
+ if (handler) {
445
+ process.removeListener(signal, handler);
446
+ }
447
+ }
448
+ await cleanup();
449
+ }