@ultimat3/cli 1.0.0 → 1.2.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/package.json +23 -23
- package/src/app-load.ts +10 -0
- package/src/cmd-build.ts +28 -4
- package/src/cmd-dev.ts +4 -1
- package/src/dev-render.ts +8 -4
- package/src/dev-roles.ts +40 -2
- package/src/dev-services.ts +6 -1
- package/src/errors.ts +59 -0
- package/src/index.ts +30 -4
- package/src/mcp-errors.ts +6 -0
- package/src/metrics-endpoint.ts +72 -0
- package/src/migrations.ts +45 -0
- package/src/prerender.ts +71 -0
- package/src/serve.ts +200 -0
- package/src/templates/index.ts +1 -0
- package/src/templates/scaffold-app.ts +55 -0
- package/src/templates/scaffold-container.ts +264 -0
- package/src/templates/scaffold-docs.ts +5 -27
- package/src/templates/scaffold-repo.ts +13 -3
package/src/prerender.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// `x build --target static` — the `site/` surface, rendered once and written as files a CDN or an
|
|
2
|
+
// object store can serve with no process behind it. Enumeration, hashing and the output path are
|
|
3
|
+
// `@ultimat3/render`'s `renderStatic`; the document is the one `x dev` serves. This file decides
|
|
4
|
+
// only which routes qualify and where the bytes land.
|
|
5
|
+
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import type { RouteEntry } from '@ultimat3/render';
|
|
8
|
+
import { renderStatic, routeEntries } from '@ultimat3/render';
|
|
9
|
+
import { loadApp } from './app-load';
|
|
10
|
+
import { appManifest } from './app-manifest';
|
|
11
|
+
import { routeDocument } from './dev-render';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `static` only. `isr` revalidates and `ssr`/`stream`/`spa` need a process, so writing any of them
|
|
15
|
+
* to disk would publish a page whose staleness nothing can correct — and the route already
|
|
16
|
+
* declared which of the five it is.
|
|
17
|
+
*/
|
|
18
|
+
export const isPrerenderable = (entry: RouteEntry): boolean => entry.config.render === 'static';
|
|
19
|
+
|
|
20
|
+
export interface PrerenderOptions {
|
|
21
|
+
readonly root: string;
|
|
22
|
+
readonly out: string;
|
|
23
|
+
/** Origin the rendered `<head>` builds canonical and og:url from. */
|
|
24
|
+
readonly origin?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PrerenderedPage {
|
|
28
|
+
readonly path: string;
|
|
29
|
+
/** Relative to `out`, POSIX, as `renderStatic` computed it. */
|
|
30
|
+
readonly file: string;
|
|
31
|
+
readonly hash: string;
|
|
32
|
+
readonly bytes: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PrerenderReport {
|
|
36
|
+
readonly out: string;
|
|
37
|
+
readonly buildId: string;
|
|
38
|
+
readonly pages: readonly PrerenderedPage[];
|
|
39
|
+
/** Routes that exist and are not static. Reported, so "only 2 pages" is never a mystery. */
|
|
40
|
+
readonly skipped: readonly string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const DEFAULT_ORIGIN = 'https://localhost';
|
|
44
|
+
|
|
45
|
+
export async function prerenderSite(options: PrerenderOptions): Promise<PrerenderReport> {
|
|
46
|
+
// The same load `x dev` and `x manifest` perform: importing the app's modules IS what fills the
|
|
47
|
+
// route registry, so there is no route table to prerender before this runs.
|
|
48
|
+
await loadApp(options.root);
|
|
49
|
+
const buildId = (await appManifest(options.root)).manifest.buildId;
|
|
50
|
+
const origin = options.origin ?? DEFAULT_ORIGIN;
|
|
51
|
+
const pages: PrerenderedPage[] = [];
|
|
52
|
+
const skipped: string[] = [];
|
|
53
|
+
|
|
54
|
+
for (const entry of routeEntries()) {
|
|
55
|
+
if (!isPrerenderable(entry)) {
|
|
56
|
+
skipped.push(entry.path);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const artifacts = await renderStatic(
|
|
60
|
+
entry,
|
|
61
|
+
({ path, params }) => routeDocument(entry, { url: new URL(path, origin).href, params }),
|
|
62
|
+
{ buildId },
|
|
63
|
+
);
|
|
64
|
+
for (const artifact of artifacts) {
|
|
65
|
+
const file = join(options.out, artifact.outputPath);
|
|
66
|
+
const bytes = await Bun.write(file, artifact.html);
|
|
67
|
+
pages.push({ path: artifact.path, file: artifact.outputPath, hash: artifact.hash, bytes });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { out: options.out, buildId, pages, skipped };
|
|
71
|
+
}
|
package/src/serve.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// What a container starts. `apps/web/server.ts` is three lines that call `runRole`, so the boot a
|
|
2
|
+
// production process performs is framework code with tests rather than app code the author has to
|
|
3
|
+
// get right — and it is the SAME code `x dev` runs, minus the watcher, minus `/_x`, minus
|
|
4
|
+
// `dev: true`. The only production-shaped decisions live here: which role, which port, and the
|
|
5
|
+
// fact that a container must bind every interface.
|
|
6
|
+
|
|
7
|
+
import { listActions, toRoute } from '@ultimat3/action';
|
|
8
|
+
import type { Role } from '@ultimat3/core';
|
|
9
|
+
import { isRole, logger, ROLES } from '@ultimat3/core';
|
|
10
|
+
import { type MigrationReport, migrate } from '@ultimat3/db';
|
|
11
|
+
import type { Route } from '@ultimat3/http';
|
|
12
|
+
import { loadApp } from './app-load';
|
|
13
|
+
import { appManifest } from './app-manifest';
|
|
14
|
+
import { assetRoutes } from './dev-assets';
|
|
15
|
+
import { startQueue } from './dev-queue';
|
|
16
|
+
import { appRoutes } from './dev-render';
|
|
17
|
+
import type { RunningRoles, WebBinding } from './dev-roles';
|
|
18
|
+
import { startRoles } from './dev-roles';
|
|
19
|
+
import type { RunningServices } from './dev-runtime';
|
|
20
|
+
import { startServices } from './dev-runtime';
|
|
21
|
+
import type { Env } from './dev-services';
|
|
22
|
+
import { resolveServices } from './dev-services';
|
|
23
|
+
import { PortInvalidError, RoleUnknownError } from './errors';
|
|
24
|
+
import { holdUntilShutdown } from './hold';
|
|
25
|
+
import { DEFAULT_METRICS_PORT } from './metrics-endpoint';
|
|
26
|
+
import { readMigrations } from './migrations';
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_PORT = 3000;
|
|
29
|
+
|
|
30
|
+
/** Every interface. A container bound to loopback is unreachable through its own port mapping. */
|
|
31
|
+
export const CONTAINER_BINDING: WebBinding = { dev: false, hostname: '0.0.0.0' };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `ROLE` is the one knob one image exposes. Validated rather than defaulted: a typo that fell back
|
|
35
|
+
* to `web` would start a process that serves nothing the operator asked for and reports healthy.
|
|
36
|
+
*/
|
|
37
|
+
export function roleFromEnv(env: Env): Role {
|
|
38
|
+
const raw = env['ROLE'] ?? 'web';
|
|
39
|
+
if (!isRole(raw)) throw new RoleUnknownError({ role: raw, known: ROLES });
|
|
40
|
+
return raw;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `Number.parseInt` would read `80abc` as 80, so the whole string has to be a port — a
|
|
45
|
+
* partially-parsed port is a deploy that binds somewhere nobody asked for.
|
|
46
|
+
*/
|
|
47
|
+
function portValue(env: Env, name: string, fallback: number): number {
|
|
48
|
+
const raw = env[name];
|
|
49
|
+
if (raw === undefined || raw.trim().length === 0) return fallback;
|
|
50
|
+
const port = Number(raw.trim());
|
|
51
|
+
if (!Number.isInteger(port) || port < 0 || port > 65_535)
|
|
52
|
+
throw new PortInvalidError({ value: raw, name });
|
|
53
|
+
return port;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Every PaaS injects `PORT` and routes traffic to exactly it. */
|
|
57
|
+
export function portFromEnv(env: Env): number {
|
|
58
|
+
return portValue(env, 'PORT', DEFAULT_PORT);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The scrape port, deliberately its own env var and not `PORT + n`: an operator who moves the app
|
|
63
|
+
* port must not silently move the port their Prometheus is configured against, and the roles that
|
|
64
|
+
* set no `PORT` at all — `worker`, `scheduler`, `replicator` — still need this one.
|
|
65
|
+
*/
|
|
66
|
+
export function metricsPortFromEnv(env: Env): number {
|
|
67
|
+
return portValue(env, 'METRICS_PORT', DEFAULT_METRICS_PORT);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ServeOptions {
|
|
71
|
+
readonly root: string;
|
|
72
|
+
readonly env: Env;
|
|
73
|
+
/** Overrides `ROLE`; `runRole` reads the environment when this is absent. */
|
|
74
|
+
readonly role?: Role;
|
|
75
|
+
/** Overrides `PORT`. 0 asks the kernel for an ephemeral one, which is what a test wants. */
|
|
76
|
+
readonly port?: number;
|
|
77
|
+
/** Overrides `METRICS_PORT`, on the same terms. */
|
|
78
|
+
readonly metricsPort?: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ServedApp {
|
|
82
|
+
readonly kind: 'served';
|
|
83
|
+
readonly role: Role;
|
|
84
|
+
/** `http://…` for the web role; null for the roles that open no HTTP socket. */
|
|
85
|
+
readonly url: string | null;
|
|
86
|
+
readonly buildId: string;
|
|
87
|
+
readonly running: RunningRoles;
|
|
88
|
+
readonly runtime: RunningServices;
|
|
89
|
+
stop(): Promise<void>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface MigratedApp {
|
|
93
|
+
readonly kind: 'migrated';
|
|
94
|
+
readonly role: 'migrate';
|
|
95
|
+
readonly report: MigrationReport;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type StartedApp = ServedApp | MigratedApp;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The release phase, as a role. `migrate` is not a server: it applies the app's own migrations
|
|
102
|
+
* through `@ultimat3/db`'s ledger — advisory lock, per-migration checksum, app-version fence — and
|
|
103
|
+
* exits, so a platform that runs one container to completion before the rest start (Heroku's
|
|
104
|
+
* release phase, a compose `service_completed_successfully`, a Kubernetes Job) has exactly one
|
|
105
|
+
* thing to run and no framework-specific flag to learn.
|
|
106
|
+
*
|
|
107
|
+
* It boots the queue, not the whole runtime: this role touches the database and nothing else, and
|
|
108
|
+
* `startQueue` is what installs `db()` for `migrate()` to find.
|
|
109
|
+
*/
|
|
110
|
+
export async function runMigrations(options: ServeOptions): Promise<MigratedApp> {
|
|
111
|
+
const queue = await startQueue(resolveServices(options.root, options.env));
|
|
112
|
+
try {
|
|
113
|
+
const migrations = await readMigrations(options.root);
|
|
114
|
+
const report = await migrate({
|
|
115
|
+
migrations,
|
|
116
|
+
...(options.env['APP_VERSION'] === undefined
|
|
117
|
+
? {}
|
|
118
|
+
: { appVersion: options.env['APP_VERSION'] }),
|
|
119
|
+
});
|
|
120
|
+
logger.info('ultimate migrate applied', {
|
|
121
|
+
applied: report.applied.length,
|
|
122
|
+
available: migrations.length,
|
|
123
|
+
appVersion: report.appVersion,
|
|
124
|
+
});
|
|
125
|
+
return { kind: 'migrated', role: 'migrate', report };
|
|
126
|
+
} finally {
|
|
127
|
+
await queue.stop();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Boot order is `x dev`'s, for the reason `x dev` gives: services, then the app's own modules
|
|
133
|
+
* (importing them IS the registration), then the role that serves what they registered. The route
|
|
134
|
+
* table is the same three contributions minus the dashboard — a `/_x` in production would expose
|
|
135
|
+
* the app's policy matrix, its outbox and its spans to the internet.
|
|
136
|
+
*/
|
|
137
|
+
export async function serveApp(options: ServeOptions): Promise<ServedApp> {
|
|
138
|
+
const role = options.role ?? roleFromEnv(options.env);
|
|
139
|
+
const runtime = await startServices(resolveServices(options.root, options.env), options.env);
|
|
140
|
+
// Importing the app's modules IS the registration: every route, action and job below is
|
|
141
|
+
// whatever this call put in the registries.
|
|
142
|
+
await loadApp(options.root);
|
|
143
|
+
// The build stamps `BUILD_ID` into the image; unstamped, the manifest's content hash is the same
|
|
144
|
+
// answer computed here, so `x-ultimate-build` is never absent and never a lie. Projected only
|
|
145
|
+
// when unstamped, because a stamped image already paid for it at build time and a replica's boot
|
|
146
|
+
// should not repeat it — the load above is the part every boot needs either way.
|
|
147
|
+
const stamped = options.env['BUILD_ID'];
|
|
148
|
+
const buildId =
|
|
149
|
+
stamped !== undefined && stamped.length > 0
|
|
150
|
+
? stamped
|
|
151
|
+
: (await appManifest(options.root)).manifest.buildId;
|
|
152
|
+
const routes: readonly Route[] = [
|
|
153
|
+
...listActions().map(toRoute),
|
|
154
|
+
...assetRoutes({ root: options.root, storage: runtime.storage }),
|
|
155
|
+
...appRoutes({ buildId }),
|
|
156
|
+
];
|
|
157
|
+
const port = options.port ?? portFromEnv(options.env);
|
|
158
|
+
// An in-process caller asking for an ephemeral app port is a test, and a test that grabbed the
|
|
159
|
+
// fixed 9090 would fail the next suite to boot beside it. An environment that names the port
|
|
160
|
+
// still wins — that is the deploy talking.
|
|
161
|
+
const metricsPort =
|
|
162
|
+
options.metricsPort ??
|
|
163
|
+
(port === 0 && options.env['METRICS_PORT'] === undefined ? 0 : metricsPortFromEnv(options.env));
|
|
164
|
+
const running = await startRoles({
|
|
165
|
+
roles: [role],
|
|
166
|
+
port,
|
|
167
|
+
metricsPort,
|
|
168
|
+
buildId,
|
|
169
|
+
runtime,
|
|
170
|
+
routes,
|
|
171
|
+
env: options.env,
|
|
172
|
+
http: CONTAINER_BINDING,
|
|
173
|
+
});
|
|
174
|
+
return {
|
|
175
|
+
kind: 'served',
|
|
176
|
+
role,
|
|
177
|
+
url: running.url,
|
|
178
|
+
buildId,
|
|
179
|
+
running,
|
|
180
|
+
runtime,
|
|
181
|
+
async stop() {
|
|
182
|
+
await running.stop();
|
|
183
|
+
await runtime.stop();
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* What `apps/web/server.ts` calls. Returns for `migrate` — the process is meant to exit — and
|
|
190
|
+
* holds for every other role until core's drain completes, so SIGTERM from a rolling restart takes
|
|
191
|
+
* the three-phase path (stop accepting, finish in-flight, close) instead of killing a query.
|
|
192
|
+
*/
|
|
193
|
+
export async function runRole(options: ServeOptions): Promise<StartedApp> {
|
|
194
|
+
const role = options.role ?? roleFromEnv(options.env);
|
|
195
|
+
if (role === 'migrate') return runMigrations({ ...options, role });
|
|
196
|
+
const app = await serveApp({ ...options, role });
|
|
197
|
+
logger.info('ultimate started', { role: app.role, url: app.url, buildId: app.buildId });
|
|
198
|
+
await holdUntilShutdown('serve', () => app.stop())();
|
|
199
|
+
return app;
|
|
200
|
+
}
|
package/src/templates/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ export { resourceFiles } from './resource';
|
|
|
18
18
|
export type { RouteOptions, Surface } from './route';
|
|
19
19
|
export { routeFiles } from './route';
|
|
20
20
|
export { appFiles } from './scaffold-app';
|
|
21
|
+
export { containerFiles } from './scaffold-container';
|
|
21
22
|
export { docsFiles, EXECUTABLE_FILES } from './scaffold-docs';
|
|
22
23
|
export { i18nIndex } from './scaffold-i18n';
|
|
23
24
|
export { repoFiles } from './scaffold-repo';
|
|
@@ -280,6 +280,59 @@ export function AdminHome() {
|
|
|
280
280
|
}
|
|
281
281
|
`;
|
|
282
282
|
|
|
283
|
+
// The two entry files a deploy needs. Both are deliberately thin: which role a container is, which
|
|
284
|
+
// port it binds, how it drains and what a static build enumerates are the framework's answers, so
|
|
285
|
+
// an upgrade moves them without a codemod in every app that ever shipped.
|
|
286
|
+
|
|
287
|
+
const server =
|
|
288
|
+
(): string => `// The production entry. \`docker/Dockerfile\` starts this, and \`x build --target binary\` compiles it.
|
|
289
|
+
// ROLE selects what this process is — web, sync, worker, scheduler, replicator, or migrate, which
|
|
290
|
+
// applies the migrations and exits. PORT is bound on every interface, because a container bound to
|
|
291
|
+
// localhost is unreachable through its own port mapping.
|
|
292
|
+
|
|
293
|
+
import { join } from 'node:path';
|
|
294
|
+
import { runRole } from '@ultimat3/cli';
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Where the app is. From this file normally — the image's WORKDIR is not the app root's business.
|
|
298
|
+
* A \`--compile\` binary is the exception: its \`import.meta.dir\` is Bun's virtual filesystem, which
|
|
299
|
+
* holds this module's bundled imports and none of the app's source, and the framework's registries
|
|
300
|
+
* are filled by scanning that source at boot. So a binary reads its root from the directory it is
|
|
301
|
+
* started in — it is a launcher for an app tree, not a self-contained copy of one.
|
|
302
|
+
*/
|
|
303
|
+
const root = import.meta.dir.startsWith('/$bunfs')
|
|
304
|
+
? process.cwd()
|
|
305
|
+
: join(import.meta.dir, '..', '..');
|
|
306
|
+
|
|
307
|
+
// Guarded, because the framework's module scan imports every file under apps/*/ to fill its
|
|
308
|
+
// registries — an unguarded boot would start a server inside \`x verify\`.
|
|
309
|
+
if (import.meta.main) {
|
|
310
|
+
await runRole({ root, env: Bun.env });
|
|
311
|
+
}
|
|
312
|
+
`;
|
|
313
|
+
|
|
314
|
+
const prerender =
|
|
315
|
+
(): string => `// The static entry. \`x build --target static\` runs this with \`--out <dir>\` and it writes one HTML
|
|
316
|
+
// file per \`render: 'static'\` route — a CDN or an object store then serves site/ with no process
|
|
317
|
+
// behind it. Every other render mode needs a running app and is reported as skipped, never emitted.
|
|
318
|
+
|
|
319
|
+
import { join } from 'node:path';
|
|
320
|
+
import { prerenderSite } from '@ultimat3/cli';
|
|
321
|
+
|
|
322
|
+
const root = join(import.meta.dir, '..', '..');
|
|
323
|
+
const flag = Bun.argv.indexOf('--out');
|
|
324
|
+
const out = (flag === -1 ? undefined : Bun.argv[flag + 1]) ?? join(root, '.x', 'static');
|
|
325
|
+
// SITE_ORIGIN is what canonical and og:url are built from; the default is only ever a local build.
|
|
326
|
+
const origin = Bun.env['SITE_ORIGIN'];
|
|
327
|
+
|
|
328
|
+
if (import.meta.main) {
|
|
329
|
+
const report = await prerenderSite({ root, out, ...(origin === undefined ? {} : { origin }) });
|
|
330
|
+
await Bun.stdout.write(
|
|
331
|
+
\`\${JSON.stringify({ ok: true, out: report.out, pages: report.pages.length, skipped: report.skipped })}\\n\`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
`;
|
|
335
|
+
|
|
283
336
|
const placeholder = (surface: string, app: NameSet): string => `# ${surface}
|
|
284
337
|
|
|
285
338
|
Placeholder. The monorepo shape exists now so adding ${surface} later is a new directory, not a
|
|
@@ -297,6 +350,8 @@ export function appFiles(app: NameSet): readonly GeneratedFile[] {
|
|
|
297
350
|
return [
|
|
298
351
|
{ path: 'apps/web/package.json', contents: webPackage(app) },
|
|
299
352
|
{ path: 'apps/web/tsconfig.json', contents: tsconfig() },
|
|
353
|
+
{ path: 'apps/web/server.ts', contents: server() },
|
|
354
|
+
{ path: 'apps/web/prerender.ts', contents: prerender() },
|
|
300
355
|
{ path: 'apps/web/site/icon.png', contents: icon() },
|
|
301
356
|
{ path: 'apps/web/site/page.tsx', contents: sitePage(app) },
|
|
302
357
|
{ path: 'apps/web/site/page.module.scss', contents: siteStyle() },
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// The container half of what `x new` writes: the image, what to ignore when building it, the
|
|
2
|
+
// production topology, and the one page that explains how a platform runs the release phase.
|
|
3
|
+
// Split from scaffold-docs.ts because these four are one subject and that file is another.
|
|
4
|
+
//
|
|
5
|
+
// Axiom 7 in file form. Nothing here names a cloud: `$PORT`, `0.0.0.0`, `/readyz` and a
|
|
6
|
+
// run-to-completion migrate step are conventions every container platform shares — a Heroku
|
|
7
|
+
// buildpack, a Render blueprint or a fly.toml would be the primitive that never ships.
|
|
8
|
+
|
|
9
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
10
|
+
|
|
11
|
+
const dockerfile = (
|
|
12
|
+
app: NameSet,
|
|
13
|
+
): string => `# One image, every role. ROLE selects behaviour at start, so there is exactly one artifact to
|
|
14
|
+
# promote — the image that passed staging is the image production runs.
|
|
15
|
+
#
|
|
16
|
+
# x build --target docker --tag ${app.kebab}:$(git rev-parse --short HEAD)
|
|
17
|
+
# docker run --rm -e ROLE=migrate -e DATABASE_URL=... ${app.kebab}:... # release phase
|
|
18
|
+
# docker run -e ROLE=web -e PORT=8080 -p 8080:8080 -e DATABASE_URL=... ${app.kebab}:...
|
|
19
|
+
#
|
|
20
|
+
# syntax=docker/dockerfile:1
|
|
21
|
+
|
|
22
|
+
# ---------- deps: runtime dependencies only, cached on the workspace manifests ----------
|
|
23
|
+
FROM oven/bun:1.3-alpine AS deps
|
|
24
|
+
WORKDIR /app
|
|
25
|
+
COPY package.json bun.lock ./
|
|
26
|
+
# The workspace members' manifests are what \`bun install\` resolves against; their sources are not.
|
|
27
|
+
COPY apps ./apps
|
|
28
|
+
COPY packages ./packages
|
|
29
|
+
RUN bun install --frozen-lockfile --production
|
|
30
|
+
|
|
31
|
+
# ---------- runtime ----------
|
|
32
|
+
# No build stage and no second gate: \`x verify\` is the gate and \`x build\` runs the static steps
|
|
33
|
+
# before it ever calls \`docker build\`. Re-running typecheck and lint here would need the
|
|
34
|
+
# devDependencies the \`--production\` install above deliberately leaves out — which is exactly how
|
|
35
|
+
# a build stage came to run \`tsc\` and \`biome\` against a tree that had neither.
|
|
36
|
+
FROM oven/bun:1.3-alpine AS runtime
|
|
37
|
+
WORKDIR /app
|
|
38
|
+
COPY --from=deps /app/node_modules ./node_modules
|
|
39
|
+
COPY . .
|
|
40
|
+
|
|
41
|
+
# The immutable content hash this image serves. Stamped by CI (\`--build-arg BUILD_ID=$(git rev-parse HEAD)\`);
|
|
42
|
+
# without one the server computes the same hash from the manifest at boot. Never \`latest\`.
|
|
43
|
+
ARG BUILD_ID=
|
|
44
|
+
ENV NODE_ENV=production \\
|
|
45
|
+
ROLE=web \\
|
|
46
|
+
PORT=3000 \\
|
|
47
|
+
BUILD_ID=\${BUILD_ID}
|
|
48
|
+
|
|
49
|
+
# Documentation only — the platform decides the real port and injects it as PORT. The server binds
|
|
50
|
+
# whatever arrives, on 0.0.0.0, because a container bound to localhost is unreachable through its
|
|
51
|
+
# own port mapping, its load balancer and every health probe alike.
|
|
52
|
+
EXPOSE 3000
|
|
53
|
+
|
|
54
|
+
# Every role serves /healthz and /readyz. /readyz flips to 503 on SIGTERM *before* the socket
|
|
55
|
+
# closes, so a rolling restart drains in-flight work instead of dropping it.
|
|
56
|
+
HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=3 CMD \\
|
|
57
|
+
bun --eval "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/readyz').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"
|
|
58
|
+
|
|
59
|
+
# \`.x/\` is where any binding that is still embedded keeps its state — the local storage disk, and
|
|
60
|
+
# PGlite if DATABASE_URL is unset. Owned by the runtime user, because /app is not: the alternative
|
|
61
|
+
# is a non-root process failing at boot on a directory it is the only one that ever writes.
|
|
62
|
+
RUN mkdir -p /app/.x && chown -R bun:bun /app/.x
|
|
63
|
+
|
|
64
|
+
USER bun
|
|
65
|
+
# apps/web/server.ts reads ROLE and PORT and nothing else. \`migrate\` applies the migrations and
|
|
66
|
+
# exits; every other role serves until SIGTERM.
|
|
67
|
+
ENTRYPOINT ["bun", "apps/web/server.ts"]
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* BuildKit prefers `<dockerfile>.dockerignore` over the context root's, so this sits beside the
|
|
72
|
+
* Dockerfile. Without it `COPY . .` ships `node_modules` and `.x/` — a stale host `node_modules`
|
|
73
|
+
* would shadow the `--production` install the deps stage just made.
|
|
74
|
+
*/
|
|
75
|
+
const dockerignore = (): string => `node_modules
|
|
76
|
+
**/node_modules
|
|
77
|
+
**/.x
|
|
78
|
+
**/dist
|
|
79
|
+
**/*.tsbuildinfo
|
|
80
|
+
.git
|
|
81
|
+
.env
|
|
82
|
+
.env.*.local
|
|
83
|
+
coverage
|
|
84
|
+
**/test-results
|
|
85
|
+
**/playwright-report
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
const composeProd = (
|
|
89
|
+
app: NameSet,
|
|
90
|
+
): string => `# The production topology: one service per role, one image, differing only by ROLE and replicas.
|
|
91
|
+
# What \`x deploy --method compose\` runs. migrate runs to completion before anything serves.
|
|
92
|
+
#
|
|
93
|
+
# IMAGE=ghcr.io/you/${app.kebab}:1.2.3 x deploy --image ghcr.io/you/${app.kebab}:1.2.3
|
|
94
|
+
name: ${app.kebab}
|
|
95
|
+
|
|
96
|
+
x-image: &image
|
|
97
|
+
image: \${IMAGE:-${app.kebab}:dev}
|
|
98
|
+
env_file: [../.env.production]
|
|
99
|
+
restart: unless-stopped
|
|
100
|
+
stop_grace_period: 30s # SIGTERM → drain in-flight requests, jobs and sockets
|
|
101
|
+
depends_on:
|
|
102
|
+
db: { condition: service_healthy }
|
|
103
|
+
|
|
104
|
+
services:
|
|
105
|
+
db:
|
|
106
|
+
image: postgres:17-alpine
|
|
107
|
+
environment:
|
|
108
|
+
POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
|
|
109
|
+
POSTGRES_DB: ${app.kebab}
|
|
110
|
+
volumes: ['pgdata:/var/lib/postgresql/data']
|
|
111
|
+
healthcheck:
|
|
112
|
+
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
|
113
|
+
interval: 5s
|
|
114
|
+
restart: unless-stopped
|
|
115
|
+
|
|
116
|
+
# The release phase. Applies pending migrations under an advisory lock and exits; every serving
|
|
117
|
+
# role waits for it to complete, so no replica ever serves against a schema it does not ship.
|
|
118
|
+
migrate:
|
|
119
|
+
<<: *image
|
|
120
|
+
environment: [ROLE=migrate]
|
|
121
|
+
restart: 'no'
|
|
122
|
+
|
|
123
|
+
web:
|
|
124
|
+
<<: *image
|
|
125
|
+
environment: [ROLE=web]
|
|
126
|
+
depends_on:
|
|
127
|
+
db: { condition: service_healthy }
|
|
128
|
+
migrate: { condition: service_completed_successfully }
|
|
129
|
+
deploy: { replicas: 2 } # stateless: scales on RPS
|
|
130
|
+
ports: ['3000:3000']
|
|
131
|
+
|
|
132
|
+
sync:
|
|
133
|
+
<<: *image
|
|
134
|
+
environment: [ROLE=sync]
|
|
135
|
+
depends_on:
|
|
136
|
+
db: { condition: service_healthy }
|
|
137
|
+
migrate: { condition: service_completed_successfully }
|
|
138
|
+
deploy: { replicas: 1 } # scales on concurrent websockets; no sticky sessions
|
|
139
|
+
ports: ['3001:3001']
|
|
140
|
+
|
|
141
|
+
worker:
|
|
142
|
+
<<: *image
|
|
143
|
+
environment: [ROLE=worker]
|
|
144
|
+
depends_on:
|
|
145
|
+
db: { condition: service_healthy }
|
|
146
|
+
migrate: { condition: service_completed_successfully }
|
|
147
|
+
deploy: { replicas: 1 } # scales on queue depth
|
|
148
|
+
|
|
149
|
+
scheduler:
|
|
150
|
+
<<: *image
|
|
151
|
+
environment: [ROLE=scheduler]
|
|
152
|
+
depends_on:
|
|
153
|
+
db: { condition: service_healthy }
|
|
154
|
+
migrate: { condition: service_completed_successfully }
|
|
155
|
+
deploy: { replicas: 1 } # fixed 1; leadership is a Postgres advisory lock
|
|
156
|
+
|
|
157
|
+
volumes:
|
|
158
|
+
pgdata:
|
|
159
|
+
`;
|
|
160
|
+
|
|
161
|
+
const readme = (app: NameSet): string => `# docker
|
|
162
|
+
|
|
163
|
+
One image, every role. \`ROLE\` selects behaviour at start, so there is one artifact to promote and
|
|
164
|
+
nothing to rebuild between staging and production.
|
|
165
|
+
|
|
166
|
+
| Role | Does | Listens |
|
|
167
|
+
|---|---|---|
|
|
168
|
+
| \`web\` | HTTP: pages, actions, assets | \`$PORT\` (default 3000) |
|
|
169
|
+
| \`sync\` | websockets for live queries | \`$PORT + 1\` |
|
|
170
|
+
| \`worker\` | the job queue | — |
|
|
171
|
+
| \`scheduler\` | cron tasks; leadership is a Postgres advisory lock | — |
|
|
172
|
+
| \`replicator\` | the logical replication slot, exactly one per database | — |
|
|
173
|
+
| \`migrate\` | applies pending migrations and **exits** | — |
|
|
174
|
+
|
|
175
|
+
Every serving role answers \`/healthz\` and \`/readyz\`. \`/readyz\` flips to 503 on \`SIGTERM\` before the
|
|
176
|
+
socket closes, which is what makes a rolling restart drain instead of drop.
|
|
177
|
+
|
|
178
|
+
## Build and run
|
|
179
|
+
|
|
180
|
+
\`\`\`sh
|
|
181
|
+
x build --target docker --tag ${app.kebab}:dev
|
|
182
|
+
docker run --rm -e ROLE=migrate -e DATABASE_URL=postgres://... ${app.kebab}:dev
|
|
183
|
+
docker run -p 3000:3000 -e DATABASE_URL=postgres://... ${app.kebab}:dev
|
|
184
|
+
\`\`\`
|
|
185
|
+
|
|
186
|
+
## One box, every role
|
|
187
|
+
|
|
188
|
+
\`\`\`sh
|
|
189
|
+
docker compose -f docker/docker-compose.prod.yml up -d # db → migrate → the rest
|
|
190
|
+
x deploy --image ${app.kebab}:dev --dry-run --json # the same plan, printed
|
|
191
|
+
\`\`\`
|
|
192
|
+
|
|
193
|
+
## The other two build targets
|
|
194
|
+
|
|
195
|
+
\`\`\`sh
|
|
196
|
+
x build --target static --out dist/static # one HTML file per \`render: 'static'\` route
|
|
197
|
+
x build --target binary --out dist/app # a single executable, no Bun install needed
|
|
198
|
+
\`\`\`
|
|
199
|
+
|
|
200
|
+
The binary bundles the framework, not the app: the registries are filled by scanning
|
|
201
|
+
\`apps/*/{site,app,api,shared}\` at boot, so it is a launcher that must be **started from the app
|
|
202
|
+
root**, with the source tree beside it. The image is the self-contained artifact.
|
|
203
|
+
|
|
204
|
+
## A PaaS (Heroku, Render, Fly, Railway, Cloud Run, App Runner…)
|
|
205
|
+
|
|
206
|
+
The framework ships **no** platform primitives — no buildpack, no \`app.json\`, no \`fly.toml\`, no
|
|
207
|
+
adapter. It does not need to: every one of these platforms builds a Dockerfile and every one of
|
|
208
|
+
them expects the same three things, which this image already does.
|
|
209
|
+
|
|
210
|
+
| The platform does | The image does |
|
|
211
|
+
|---|---|
|
|
212
|
+
| injects \`PORT\` and routes traffic to it | \`apps/web/server.ts\` binds exactly \`$PORT\`, refusing a value that is not a port (\`X_PORT_INVALID\`) rather than defaulting past it |
|
|
213
|
+
| requires the process to bind \`0.0.0.0\` | it binds every interface; loopback would be unreachable from outside the container |
|
|
214
|
+
| polls a health path | \`/readyz\` for "may I have traffic", \`/healthz\` for "am I alive" |
|
|
215
|
+
| sends \`SIGTERM\`, then \`SIGKILL\` after a grace period | drains in three phases: stop accepting, finish in-flight, close |
|
|
216
|
+
|
|
217
|
+
Set \`DATABASE_URL\` and deploy the Dockerfile. That is the whole integration.
|
|
218
|
+
|
|
219
|
+
### Release-phase migrations — the one way
|
|
220
|
+
|
|
221
|
+
Run **the same image** with \`ROLE=migrate\` before the new release serves traffic. It applies every
|
|
222
|
+
pending migration in \`packages/db/migrations\` under a Postgres advisory lock, records each in the
|
|
223
|
+
\`x_migrations\` ledger with its checksum, and exits 0. Concurrent migrators serialise; a checksum
|
|
224
|
+
that no longer matches an applied migration stops the release instead of corrupting it.
|
|
225
|
+
|
|
226
|
+
| Platform | Where the command goes |
|
|
227
|
+
|---|---|
|
|
228
|
+
| Heroku | \`release: bun apps/web/server.ts\` in \`Procfile\`, with \`ROLE=migrate\` on the release dyno |
|
|
229
|
+
| Render | \`preDeployCommand: ROLE=migrate bun apps/web/server.ts\` |
|
|
230
|
+
| Fly.io | \`[deploy] release_command = "bun apps/web/server.ts"\` with \`ROLE=migrate\` |
|
|
231
|
+
| Railway | a pre-deploy command running the same |
|
|
232
|
+
| Kubernetes | an \`initContainer\` or a \`Job\` on the same image with \`ROLE=migrate\` |
|
|
233
|
+
| Compose | the \`migrate\` service; every other role waits on \`service_completed_successfully\` |
|
|
234
|
+
|
|
235
|
+
There is no \`x db migrate\` in that list on purpose: it is the developer's command and it needs the
|
|
236
|
+
toolchain, while the release phase runs the shipped image and nothing else.
|
|
237
|
+
|
|
238
|
+
## Environment
|
|
239
|
+
|
|
240
|
+
| Key | Meaning | Unset means |
|
|
241
|
+
|---|---|---|
|
|
242
|
+
| \`ROLE\` | which process this is | \`web\` |
|
|
243
|
+
| \`PORT\` | the port the web role binds | 3000 |
|
|
244
|
+
| \`DATABASE_URL\` | Postgres | embedded PGlite — never in production |
|
|
245
|
+
| \`BUILD_ID\` | the immutable build hash clients are served against | computed from the manifest at boot |
|
|
246
|
+
| \`NATS_URL\` | multi-node realtime transport | in-process fanout, single node only |
|
|
247
|
+
| \`S3_ENDPOINT\` | object storage | a local directory |
|
|
248
|
+
|
|
249
|
+
## Kubernetes
|
|
250
|
+
|
|
251
|
+
\`x deploy --method helm\` expects a chart at \`docker/helm\`. \`x new\` does not write one — a chart is
|
|
252
|
+
a topology decision, not a scaffold default. Copy \`docker/helm\` from the framework repository, or
|
|
253
|
+
stay on \`--method compose\`.
|
|
254
|
+
`;
|
|
255
|
+
|
|
256
|
+
/** The container files for a new app, in the order a reader meets them. */
|
|
257
|
+
export function containerFiles(app: NameSet): readonly GeneratedFile[] {
|
|
258
|
+
return [
|
|
259
|
+
{ path: 'docker/Dockerfile', contents: dockerfile(app) },
|
|
260
|
+
{ path: 'docker/Dockerfile.dockerignore', contents: dockerignore() },
|
|
261
|
+
{ path: 'docker/docker-compose.prod.yml', contents: composeProd(app) },
|
|
262
|
+
{ path: 'docker/README.md', contents: readme(app) },
|
|
263
|
+
];
|
|
264
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// The human-authored half of what `x new` writes: the READMEs, the agent-facing convention files,
|
|
2
|
-
// the bin/ shims and the
|
|
3
|
-
// be scrolled to find the other — one file, one job applies to templates too.
|
|
2
|
+
// the bin/ shims and the optional dev compose. Separated from the config half so neither file has
|
|
3
|
+
// to be scrolled to find the other — one file, one job applies to templates too. The image, its
|
|
4
|
+
// ignore file, the production topology and the deploy page are `scaffold-container.ts`.
|
|
4
5
|
|
|
5
6
|
import type { GeneratedFile, NameSet } from './naming';
|
|
7
|
+
import { containerFiles } from './scaffold-container';
|
|
6
8
|
|
|
7
9
|
const agents = (app: NameSet): string => `# AGENTS.md
|
|
8
10
|
|
|
@@ -114,30 +116,6 @@ services:
|
|
|
114
116
|
ports: ['9000:9000']
|
|
115
117
|
`;
|
|
116
118
|
|
|
117
|
-
const dockerfile = (
|
|
118
|
-
app: NameSet,
|
|
119
|
-
): string => `# One image, all roles. ROLE selects behaviour at start; nothing else differs between processes.
|
|
120
|
-
FROM oven/bun:1.3-alpine AS deps
|
|
121
|
-
WORKDIR /src
|
|
122
|
-
COPY package.json bun.lock ./
|
|
123
|
-
COPY apps ./apps
|
|
124
|
-
COPY packages ./packages
|
|
125
|
-
RUN bun install --frozen-lockfile --production
|
|
126
|
-
|
|
127
|
-
FROM oven/bun:1.3-alpine AS build
|
|
128
|
-
WORKDIR /src
|
|
129
|
-
COPY --from=deps /src/node_modules ./node_modules
|
|
130
|
-
COPY . .
|
|
131
|
-
RUN bunx x build --target binary --out /out/${app.kebab}
|
|
132
|
-
|
|
133
|
-
FROM gcr.io/distroless/base-debian12 AS runtime
|
|
134
|
-
COPY --from=build /out/${app.kebab} /app/${app.kebab}
|
|
135
|
-
ENV ROLE=web PORT=3000
|
|
136
|
-
EXPOSE 3000
|
|
137
|
-
USER 65532:65532
|
|
138
|
-
ENTRYPOINT ["/app/${app.kebab}"]
|
|
139
|
-
`;
|
|
140
|
-
|
|
141
119
|
/** Docs, shims and container files for a new app, in the order a reader meets them. */
|
|
142
120
|
export function docsFiles(app: NameSet): readonly GeneratedFile[] {
|
|
143
121
|
return [
|
|
@@ -147,8 +125,8 @@ export function docsFiles(app: NameSet): readonly GeneratedFile[] {
|
|
|
147
125
|
{ path: 'bin/setup', contents: binSetup() },
|
|
148
126
|
{ path: 'bin/dev', contents: binDev() },
|
|
149
127
|
{ path: 'bin/check', contents: binCheck() },
|
|
150
|
-
{ path: 'docker/Dockerfile', contents: dockerfile(app) },
|
|
151
128
|
{ path: 'docker/docker-compose.dev.yml', contents: composeDev(app) },
|
|
129
|
+
...containerFiles(app),
|
|
152
130
|
];
|
|
153
131
|
}
|
|
154
132
|
|