@ultimat3/cli 22.15.0 → 23.0.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/CLAUDE.md +23 -8
- package/README.md +28 -5
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/api-registration.ts +36 -11
- package/src/app-boundaries.ts +6 -20
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +14 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-reload-graph.ts +7 -30
- package/src/app-transport.ts +219 -0
- package/src/boundary-findings.ts +16 -4
- package/src/browser-transport.ts +160 -0
- package/src/budgets.ts +25 -1
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +56 -9
- package/src/cmd-db.ts +60 -11
- package/src/cmd-doctor.ts +35 -11
- package/src/cmd-generate.ts +118 -17
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +120 -3
- package/src/cmd-new.ts +15 -1
- package/src/cmd-routes.ts +15 -1
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +48 -12
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-verify.ts +10 -2
- package/src/comment-safe.ts +16 -0
- package/src/coverage-floor.ts +334 -0
- package/src/coverage-lcov.ts +193 -0
- package/src/coverage-source.ts +136 -0
- package/src/db-object-drift.ts +30 -0
- package/src/db-replay-engine.ts +115 -0
- package/src/db-schema-dump.ts +150 -0
- package/src/db-schema-refresh.ts +122 -0
- package/src/dev-dashboard.ts +2 -2
- package/src/dev-route-table.ts +9 -0
- package/src/doctor-coverage.ts +39 -0
- package/src/doctor-guards.ts +54 -0
- package/src/doctor-sealed.ts +132 -0
- package/src/document-graph.ts +82 -0
- package/src/error-codes.ts +32 -0
- package/src/framework-schema.ts +15 -0
- package/src/generate-files.ts +76 -10
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +19 -0
- package/src/generate-write.ts +15 -27
- package/src/generated-imports.ts +59 -0
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-registration.ts +36 -0
- package/src/image-prepare.ts +51 -8
- package/src/import-closure.ts +165 -0
- package/src/index.ts +11 -2
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +14 -7
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +3 -0
- package/src/json-merge.ts +46 -0
- package/src/mcp-errors.ts +29 -1
- package/src/messages.ts +33 -0
- package/src/metrics-endpoint.ts +71 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +18 -9
- package/src/permission-grants.ts +41 -9
- package/src/publish-closure.ts +101 -0
- package/src/role-load.ts +143 -0
- package/src/role-start.ts +18 -1
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-queue.ts +8 -1
- package/src/runtime-render.ts +6 -0
- package/src/runtime-services.ts +2 -1
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-dump-drift.ts +64 -0
- package/src/serve-boot.ts +85 -157
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +164 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/source-edit.ts +49 -0
- package/src/tasks-facts.ts +4 -1
- package/src/templates/action.ts +144 -11
- package/src/templates/admin-catalog.ts +58 -0
- package/src/templates/admin-page.ts +36 -8
- package/src/templates/admin.ts +57 -17
- package/src/templates/backfill.ts +121 -87
- package/src/templates/entity.ts +140 -44
- package/src/templates/guard-animated-layout-property.ts +4 -11
- package/src/templates/guard-bare-error.ts +9 -13
- package/src/templates/guard-focus-visible.ts +4 -11
- package/src/templates/guard-image-dimensions.ts +6 -6
- package/src/templates/guard-island-without-states.ts +2 -4
- package/src/templates/guard-raw-breakpoint.ts +210 -0
- package/src/templates/guard-raw-colour.ts +4 -11
- package/src/templates/guard-raw-length.ts +375 -0
- package/src/templates/guard-raw-motion.ts +213 -0
- package/src/templates/guard-raw-shadow.ts +187 -0
- package/src/templates/guard-raw-z-index.ts +166 -0
- package/src/templates/guard-repo-raw-sql.ts +345 -0
- package/src/templates/guard-semantic-interactive.ts +6 -6
- package/src/templates/guard-undeclared-custom-property.ts +217 -0
- package/src/templates/guard-undefined-style-class.ts +215 -0
- package/src/templates/guard-untranslated-string.ts +6 -6
- package/src/templates/guard-unzoned-date.ts +9 -13
- package/src/templates/index.ts +3 -2
- package/src/templates/island.ts +34 -51
- package/src/templates/job-body-test.ts +121 -0
- package/src/templates/job.ts +23 -8
- package/src/templates/policy.ts +16 -9
- package/src/templates/query.ts +242 -26
- package/src/templates/resource-create.ts +62 -18
- package/src/templates/resource-form-island.ts +58 -80
- package/src/templates/resource-page.ts +269 -0
- package/src/templates/resource-service.ts +96 -0
- package/src/templates/resource.ts +86 -61
- package/src/templates/route.ts +38 -29
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +171 -51
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container.ts +16 -0
- package/src/templates/scaffold-dashboard-bare.ts +64 -2
- package/src/templates/scaffold-dashboard-example.ts +124 -31
- package/src/templates/scaffold-dashboard-shared.ts +1 -20
- package/src/templates/scaffold-dashboard.ts +1 -2
- package/src/templates/scaffold-db-client.ts +142 -0
- package/src/templates/scaffold-db-package.ts +90 -8
- package/src/templates/scaffold-docs.ts +50 -5
- package/src/templates/scaffold-guards.ts +54 -11
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +52 -2
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +36 -70
- package/src/templates/scaffold-site.ts +45 -19
- package/src/templates/scaffold-ui-package.ts +18 -0
- package/src/templates/slice-foundation.ts +46 -8
- package/src/test-counts.ts +18 -2
- package/src/transport-calls.ts +105 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/verify-checks.ts +26 -7
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +256 -0
- package/src/verify-errors.ts +15 -6
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +117 -24
- package/src/verify-progress.ts +25 -0
- package/src/verify-role-load-probe.ts +54 -0
- package/src/verify-role-load.ts +148 -0
- package/src/verify-run.ts +93 -35
- package/src/verify-shard.ts +20 -9
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +78 -6
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/cmd-dev-fixture.ts +0 -204
- package/src/dev-live-fixture.ts +0 -122
- package/src/policy-fixture.ts +0 -78
- package/src/realtime-browser-probe-fixture.ts +0 -9
- package/src/role-start-fixture.ts +0 -78
- package/src/scaffold-fixture.ts +0 -160
- package/src/templates/island-fixture.ts +0 -76
package/src/runtime-queue.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
setJobsFacade,
|
|
34
34
|
} from '@ultimat3/jobs';
|
|
35
35
|
import { applyFrameworkSchema } from './framework-schema';
|
|
36
|
+
import { appExtensions } from './migration-extensions';
|
|
36
37
|
import type { DevServices } from './runtime-bindings';
|
|
37
38
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
38
39
|
import { attachReplica, type ReplicaEnv, replicaUrlFor } from './runtime-replica';
|
|
@@ -93,7 +94,13 @@ export function startDb(services: DevServices, env: ReplicaEnv): StartedDb {
|
|
|
93
94
|
binding.mode === 'embedded'
|
|
94
95
|
? // `pgliteDataDir` is `@ultimat3/db`'s own reader of the `pglite://` form; a second parser
|
|
95
96
|
// here is a second thing to keep right when the form changes.
|
|
96
|
-
|
|
97
|
+
// Linked at boot, from the app's own migrations: PGlite cannot `create extension` one it
|
|
98
|
+
// was not handed, and the schema dump's scratch replay already asks this same reader — an
|
|
99
|
+
// app that replayed in the gate and failed in `x dev` was the two disagreeing.
|
|
100
|
+
createPgliteClient({
|
|
101
|
+
dataDir: pgliteDataDir(binding.url),
|
|
102
|
+
extensions: () => appExtensions(services.root),
|
|
103
|
+
})
|
|
97
104
|
: createPostgresClient({ url: binding.url });
|
|
98
105
|
const attached = attachReplica(client, replicaUrlFor(binding, env));
|
|
99
106
|
setDbClient(attached.client);
|
package/src/runtime-render.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
// why: Bun ships no path API; an island's file is its route file's directory joined to its `src`.
|
|
18
18
|
import { posix } from 'node:path';
|
|
19
|
+
import { actionPathStyle } from '@ultimat3/action';
|
|
19
20
|
import { clientScopeOf } from '@ultimat3/auth';
|
|
20
21
|
import type { Ctx } from '@ultimat3/core';
|
|
21
22
|
import { CLIENT_SCOPE_HEADER } from '@ultimat3/core';
|
|
@@ -30,6 +31,7 @@ import { currentLocale, localeConfig } from '@ultimat3/i18n';
|
|
|
30
31
|
import type { IslandCollector, RenderResult, RouteData, RouteEntry } from '@ultimat3/render';
|
|
31
32
|
import {
|
|
32
33
|
clientBootTags,
|
|
34
|
+
clientPathStyleTags,
|
|
33
35
|
clientPersistTags,
|
|
34
36
|
clientScopeTag,
|
|
35
37
|
clientSyncTags,
|
|
@@ -130,6 +132,10 @@ const headFor = async (
|
|
|
130
132
|
}),
|
|
131
133
|
[
|
|
132
134
|
...(options.sync === undefined ? [] : clientSyncTags(options.sync)),
|
|
135
|
+
// Every document, shareable ones included: the style is the app's, not a visitor's, and
|
|
136
|
+
// it is the only way an island's `rpc()` learns it. Read per render — `defineApi` may run
|
|
137
|
+
// after these routes are built. `'resource'`, the default, is no tag.
|
|
138
|
+
...clientPathStyleTags(actionPathStyle()),
|
|
133
139
|
...router,
|
|
134
140
|
// The page boot rides the scope tag: its whole job — restoring a principal's persisted
|
|
135
141
|
// records and replaying its queued writes — is per principal, and a shareable document
|
package/src/runtime-services.ts
CHANGED
|
@@ -182,7 +182,8 @@ export function startStorage(services: DevServices, env: Env, override?: Storage
|
|
|
182
182
|
bucket,
|
|
183
183
|
endpoint: binding.url,
|
|
184
184
|
...(env['S3_REGION'] === undefined ? {} : { region: env['S3_REGION'] }),
|
|
185
|
-
//
|
|
185
|
+
// A gateway addressed by host and port needs path style; R2 and AWS do not. Declared,
|
|
186
|
+
// never sniffed from the endpoint.
|
|
186
187
|
forcePathStyle: env['S3_FORCE_PATH_STYLE'] === '1',
|
|
187
188
|
}),
|
|
188
189
|
},
|
|
@@ -151,6 +151,7 @@ const overlay = (root: string, app: string): string =>
|
|
|
151
151
|
// name, and `cli/serve` as `packages/cli/serve`. Longest prefix wins, so these are
|
|
152
152
|
// consulted first. `@ultimat3/cli/serve` is what `x new`'s `apps/web/server.ts` imports.
|
|
153
153
|
'@ultimat3/ui/icons/*': [`${root}/packages/ui/src/icons/glyphs/*`],
|
|
154
|
+
'@ultimat3/query/client': [`${root}/packages/query/src/client`],
|
|
154
155
|
'@ultimat3/render/server': [`${root}/packages/render/src/server`],
|
|
155
156
|
'@ultimat3/cli/serve': [`${root}/packages/cli/src/serve-entry`],
|
|
156
157
|
'@ultimat3/testing/test-types': [`${root}/packages/testing/src/test-types`],
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The `drift` step's fourth rail: is the committed schema dump what the migrations produce, and
|
|
2
|
+
// does a database loaded from it come back the same? The other three rails read files and open
|
|
3
|
+
// nothing; this one replays the migrations on a scratch embedded database, because "what the
|
|
4
|
+
// migrations produce" is a fact only a database can state.
|
|
5
|
+
|
|
6
|
+
// why: Bun has no directory probe — `Bun.file().exists()` answers about a file.
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
// why: Bun ships no path joiner.
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
11
|
+
import type { Migration } from '@ultimat3/db';
|
|
12
|
+
import type { SchemaDumpDifference } from '@ultimat3/db/schema-dump';
|
|
13
|
+
import { compareSchemaDump, schemaDumpDrift } from '@ultimat3/db/schema-dump';
|
|
14
|
+
import {
|
|
15
|
+
hasSchemaDump,
|
|
16
|
+
type ReplayedSchema,
|
|
17
|
+
readSchemaDump,
|
|
18
|
+
replayFor,
|
|
19
|
+
SCHEMA_DUMP_DIR,
|
|
20
|
+
} from './db-schema-dump';
|
|
21
|
+
import { dumpNeverGenerated, replayFailure } from './db-schema-refresh';
|
|
22
|
+
import { DB_PACKAGE } from './drift';
|
|
23
|
+
import { readMigrations } from './migrations';
|
|
24
|
+
import type { Finding } from './output';
|
|
25
|
+
import { findingFrom } from './output';
|
|
26
|
+
|
|
27
|
+
/** Injected so this module's rules are testable without booting a database per case. */
|
|
28
|
+
export type SchemaReplay = (migrations: readonly Migration[]) => Promise<ReplayedSchema>;
|
|
29
|
+
|
|
30
|
+
const findingFor = (difference: SchemaDumpDifference): Finding => ({
|
|
31
|
+
...findingFrom(schemaDumpDrift(difference)),
|
|
32
|
+
docs: ERROR_DOCS_URL,
|
|
33
|
+
at: `${SCHEMA_DUMP_DIR}/${difference.path}`,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Empty result = the dump is the migrations' own.
|
|
38
|
+
*
|
|
39
|
+
* An app with no migration and no dump owes none — zero recorded against zero dumped is agreement,
|
|
40
|
+
* the rule `checkSourceDrift` states for entities, and what keeps `x new --no-example` green. From
|
|
41
|
+
* the first migration on the dump is owed: a missing directory is one finding naming the command
|
|
42
|
+
* that writes it, reported without booting a database.
|
|
43
|
+
*
|
|
44
|
+
* Both halves are reported together. A stale file and a dump that does not round-trip are two
|
|
45
|
+
* repairs, and hiding the second behind the first hands out `x db gen` for a dump regenerating
|
|
46
|
+
* cannot fix.
|
|
47
|
+
*/
|
|
48
|
+
export async function checkSchemaDump(
|
|
49
|
+
root: string,
|
|
50
|
+
env: Readonly<Record<string, string | undefined>> = Bun.env,
|
|
51
|
+
replay: SchemaReplay = replayFor(root, env, true),
|
|
52
|
+
): Promise<readonly Finding[]> {
|
|
53
|
+
if (!existsSync(join(root, DB_PACKAGE))) return [];
|
|
54
|
+
const migrations = await readMigrations(root);
|
|
55
|
+
if (!hasSchemaDump(root)) return migrations.length === 0 ? [] : [dumpNeverGenerated()];
|
|
56
|
+
const committed = await readSchemaDump(root);
|
|
57
|
+
let replayed: ReplayedSchema;
|
|
58
|
+
try {
|
|
59
|
+
replayed = await replay(migrations);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
return [replayFailure(error)];
|
|
62
|
+
}
|
|
63
|
+
return [...compareSchemaDump(committed, replayed.files), ...replayed.reload].map(findingFor);
|
|
64
|
+
}
|
package/src/serve-boot.ts
CHANGED
|
@@ -1,46 +1,69 @@
|
|
|
1
|
-
// The
|
|
2
|
-
//
|
|
3
|
-
// that file's header states — the SAME boot `x dev` runs, minus the watcher,
|
|
4
|
-
// — are this file's.
|
|
1
|
+
// The serving roles' boot: the services started, the app loaded, its route table built, the roles
|
|
2
|
+
// started. Behind ONE `await import()` in `serve.ts` (the entry points), so `ROLE=migrate` loads
|
|
3
|
+
// none of it; the rules that file's header states — the SAME boot `x dev` runs, minus the watcher,
|
|
4
|
+
// `/_x` and `dev: true` — are this file's. A role imports what it runs: the web surface
|
|
5
|
+
// (`serve-web.ts`) and the manifest projection are behind `await import()` in turn, so a worker's
|
|
6
|
+
// module graph holds neither.
|
|
5
7
|
|
|
6
8
|
import type { Role } from '@ultimat3/core';
|
|
7
|
-
import { configureLifecycle } from '@ultimat3/core';
|
|
8
|
-
import type { Route } from '@ultimat3/http';
|
|
9
|
-
import { describeRoutes } from '@ultimat3/render';
|
|
10
|
-
import { createIsrController } from '@ultimat3/render/server';
|
|
11
|
-
import { apiMountRoutes, apiRoutes, pagePostRoutes } from './api-routes';
|
|
12
|
-
import { loadSignInPath } from './app-auth';
|
|
13
|
-
import { loadApp } from './app-load';
|
|
14
|
-
import { appManifest } from './app-manifest';
|
|
15
|
-
import { mountAppMcp } from './app-mcp';
|
|
16
|
-
import { errorPageStyleSources } from './error-page-csp';
|
|
17
|
-
import { islandRoutes } from './island-routes';
|
|
18
|
-
import { loadOrBuildIslands } from './island-store';
|
|
9
|
+
import { configureLifecycle, logger } from '@ultimat3/core';
|
|
19
10
|
import type { MetricsEndpoint } from './metrics-endpoint';
|
|
20
11
|
import { startOtlpExport } from './otlp-export';
|
|
21
|
-
import {
|
|
22
|
-
import { loadSpeculation, pageSpeculation } from './page-speculation';
|
|
23
|
-
import { pageSync } from './page-sync';
|
|
24
|
-
import { loadPwaArtifacts } from './pwa-artifacts';
|
|
12
|
+
import { loadAppForRole, roleLoadFor } from './role-load';
|
|
25
13
|
import { startRoles } from './role-start';
|
|
26
|
-
import {
|
|
27
|
-
import { appRoutes } from './runtime-render';
|
|
14
|
+
import { resolveServices } from './runtime-bindings';
|
|
28
15
|
import { replicaOverrides } from './runtime-replica';
|
|
29
16
|
import type { RunningServices } from './runtime-services';
|
|
30
|
-
import {
|
|
31
|
-
import { seoRoutes } from './seo-routes';
|
|
17
|
+
import { startServices } from './runtime-services';
|
|
32
18
|
import { loadDrainConfig, loadHealthConfig } from './serve-drain';
|
|
33
19
|
import { configureReporting, containerBinding, metricsPortFor, portFromEnv } from './serve-env';
|
|
20
|
+
import { adoptPrebuiltStyles, reportBootBuilds, watchBootBuilds } from './serve-prebuilt';
|
|
34
21
|
import type { ServedApp, ServeOptions } from './serve-types';
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The build stamps `BUILD_ID` into the image; unstamped, the manifest's content hash is the same
|
|
25
|
+
* answer computed here, so `x-ultimate-build` is never absent and never a lie. Projected only when
|
|
26
|
+
* unstamped, because a stamped image already paid for it at build time and a replica's boot should
|
|
27
|
+
* not repeat it. The projection describes the WHOLE app — routes included — so an unstamped worker
|
|
28
|
+
* imports everything its own load left out, and says so.
|
|
29
|
+
*/
|
|
30
|
+
async function buildIdFor(options: ServeOptions, role: Role): Promise<string> {
|
|
31
|
+
const stamped = options.env['BUILD_ID'];
|
|
32
|
+
if (stamped !== undefined && stamped.length > 0) return stamped;
|
|
33
|
+
if (roleLoadFor(role) === 'background') {
|
|
34
|
+
logger.warn('ultimate build id unstamped', {
|
|
35
|
+
role,
|
|
36
|
+
cause:
|
|
37
|
+
'BUILD_ID is unset, so this role imported every page of the app to compute the build id the web role serves',
|
|
38
|
+
fix: 'x build --target docker',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
const { appManifest } = await import('./app-manifest');
|
|
42
|
+
return (await appManifest(options.root)).manifest.buildId;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Everything `serveApp` does after the scrape listener is open: the services, then the roles over
|
|
47
|
+
* them. One export, so `serve.ts` reaches the whole serving graph through ONE `await import()`.
|
|
48
|
+
*/
|
|
49
|
+
export async function bootServing(boot: {
|
|
50
|
+
readonly options: ServeOptions;
|
|
51
|
+
readonly role: Role;
|
|
52
|
+
readonly acquired: (() => void | Promise<void>)[];
|
|
53
|
+
readonly metrics: MetricsEndpoint;
|
|
54
|
+
}): Promise<ServedApp> {
|
|
55
|
+
const { options } = boot;
|
|
56
|
+
const runtime = await startServices(
|
|
57
|
+
resolveServices(options.root, options.env),
|
|
58
|
+
options.env,
|
|
59
|
+
options.runtime,
|
|
60
|
+
);
|
|
61
|
+
boot.acquired.push(() => runtime.stop());
|
|
62
|
+
return bootRoles({ ...boot, runtime });
|
|
63
|
+
}
|
|
41
64
|
|
|
42
65
|
/** The half of `serveApp` whose every acquisition is registered for rollback. */
|
|
43
|
-
|
|
66
|
+
async function bootRoles(boot: {
|
|
44
67
|
readonly options: ServeOptions;
|
|
45
68
|
readonly role: Role;
|
|
46
69
|
readonly runtime: RunningServices;
|
|
@@ -49,18 +72,19 @@ export async function bootRoles(boot: {
|
|
|
49
72
|
readonly metrics: MetricsEndpoint;
|
|
50
73
|
}): Promise<ServedApp> {
|
|
51
74
|
const { options, role, runtime, acquired, metrics } = boot;
|
|
75
|
+
// The image's compiled stylesheets, before the first page is imported — importing one IS
|
|
76
|
+
// compiling its sheet — and the count of what this boot compiles anyway.
|
|
77
|
+
adoptPrebuiltStyles(options.root);
|
|
78
|
+
const bootBuilds = watchBootBuilds();
|
|
52
79
|
// Importing the app's modules IS the registration: every route, action and job below is
|
|
53
|
-
// whatever this call put in the registries.
|
|
54
|
-
await
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const buildId =
|
|
61
|
-
stamped !== undefined && stamped.length > 0
|
|
62
|
-
? stamped
|
|
63
|
-
: (await appManifest(options.root)).manifest.buildId;
|
|
80
|
+
// whatever this call put in the registries — for THIS role (`role-load.ts`).
|
|
81
|
+
const loaded = await loadAppForRole(options.root, role);
|
|
82
|
+
// A module that would not import registered nothing, and a process that only logs the roles it
|
|
83
|
+
// started gives no sign of it: each one is a line an operator can act on.
|
|
84
|
+
for (const finding of loaded.findings) {
|
|
85
|
+
logger.error('ultimate app module failed to load', { role, ...finding });
|
|
86
|
+
}
|
|
87
|
+
const buildId = await buildIdFor(options, role);
|
|
64
88
|
// Before the first socket opens: everything above this line fails loudly into the container's
|
|
65
89
|
// own logs, everything below it is a served request, a claimed job or a routed frame.
|
|
66
90
|
configureReporting(options.env, buildId);
|
|
@@ -72,8 +96,16 @@ export async function bootRoles(boot: {
|
|
|
72
96
|
acquired.push(stopOtlp);
|
|
73
97
|
// Only the web role serves pages, so only it builds islands and assembles a route table: a
|
|
74
98
|
// worker, scheduler, sync or replicator pod compiled every island of the app on every boot for
|
|
75
|
-
// nothing it would ever serve (plan 101, slice 12 e).
|
|
76
|
-
|
|
99
|
+
// nothing it would ever serve (plan 101, slice 12 e). The MODULE is the web role's as well —
|
|
100
|
+
// imported here, it is render, PWA, SEO and MCP code no other role evaluates.
|
|
101
|
+
const web =
|
|
102
|
+
role === 'web'
|
|
103
|
+
? await (await import('./serve-web')).webSurface(options, runtime, buildId)
|
|
104
|
+
: undefined;
|
|
105
|
+
// Islands and stylesheets are the image build's to make (`x build --target prebuilt`). A role
|
|
106
|
+
// that made either here served correctly and paid for it — seconds of CPU and a compiler's
|
|
107
|
+
// memory, on every start of every replica — so it says so once, with the Dockerfile line.
|
|
108
|
+
reportBootBuilds(role, bootBuilds(web?.islandsBuilt));
|
|
77
109
|
const port = options.port ?? portFromEnv(options.env);
|
|
78
110
|
// An in-process caller asking for an ephemeral app port is a test, and a test that grabbed the
|
|
79
111
|
// fixed 9090 would fail the next suite to boot beside it. An environment that names the port
|
|
@@ -93,13 +125,15 @@ export async function bootRoles(boot: {
|
|
|
93
125
|
runtime,
|
|
94
126
|
routes: web?.routes ?? [],
|
|
95
127
|
env: options.env,
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
128
|
+
// The three facts only a process serving documents has (`WebSurface`); `startWeb` is their
|
|
129
|
+
// one reader, so every other role hands none.
|
|
130
|
+
...(web === undefined
|
|
131
|
+
? {}
|
|
132
|
+
: {
|
|
133
|
+
signInPath: web.signInPath,
|
|
134
|
+
inlineStyles: web.inlineStyles,
|
|
135
|
+
inlineScripts: web.inlineScripts,
|
|
136
|
+
}),
|
|
103
137
|
// The app's own `apps/web/site/errors/<status>.html`, resolved inside `startWeb` so this
|
|
104
138
|
// process and `x dev` cannot answer a browser differently.
|
|
105
139
|
root: options.root,
|
|
@@ -129,109 +163,3 @@ export async function bootRoles(boot: {
|
|
|
129
163
|
},
|
|
130
164
|
};
|
|
131
165
|
}
|
|
132
|
-
|
|
133
|
-
/** What only the web role serves: its islands, its documents and every route beside them. */
|
|
134
|
-
async function webSurface(
|
|
135
|
-
options: ServeOptions,
|
|
136
|
-
runtime: RunningServices,
|
|
137
|
-
buildId: string,
|
|
138
|
-
): Promise<{ readonly routes: readonly Route[]; readonly inlineScripts: readonly string[] }> {
|
|
139
|
-
// Read from the store `x build --target docker` wrote and VERIFIED against this app and this
|
|
140
|
-
// runtime (`island-store.ts`); built here only when there is no store to trust, which is correct
|
|
141
|
-
// and slower, and logged with its reason.
|
|
142
|
-
const islands = await loadOrBuildIslands(options.root);
|
|
143
|
-
// The same two strings `x dev` resolves, from the same reader: a `<link rel="manifest">` served
|
|
144
|
-
// on a laptop and absent in the image is exactly the dev/prod difference this file exists to
|
|
145
|
-
// prevent, and it is the one an operator cannot see without installing the app.
|
|
146
|
-
const pwa = await loadPwaArtifacts(options.root);
|
|
147
|
-
const theme = themeBoot(await loadThemeMode(options.root));
|
|
148
|
-
// `site.origin` and `seo.robots.disallow`: the absolute URLs every document and the sitemap carry.
|
|
149
|
-
const site = await loadSiteSettings(options.root);
|
|
150
|
-
const origin = publicOrigin(options.env, site);
|
|
151
|
-
// The page's sync target and its scripts — the same call `x dev` makes, so the two cannot differ.
|
|
152
|
-
// Before the service worker, which precaches those scripts.
|
|
153
|
-
const sync = await pageSync(options.root, options.env, buildId, runtime.realtime);
|
|
154
|
-
// The client router, when a surface opted in (`navigation.client`) — the same call in both boots.
|
|
155
|
-
const declared = await loadNavigation(options.root);
|
|
156
|
-
const navigation = await pageNavigation(options.root, declared, buildId);
|
|
157
|
-
// The browser's own prefetch, for the documents that carry no router (`navigation.speculation`).
|
|
158
|
-
const speculation = pageSpeculation({
|
|
159
|
-
config: await loadSpeculation(options.root),
|
|
160
|
-
client: declared.surfaces,
|
|
161
|
-
});
|
|
162
|
-
// The worker, from the SAME route table this process is about to serve — `describeRoutes()` is
|
|
163
|
-
// the one projection `x.manifest.json`, `/_x`, the sitemap and `sw.js` are all built from, so a
|
|
164
|
-
// route added here cannot be missing from the precache manifest.
|
|
165
|
-
const serviceWorker =
|
|
166
|
-
pwa === undefined
|
|
167
|
-
? undefined
|
|
168
|
-
: serviceWorkerArtifacts({
|
|
169
|
-
pwa,
|
|
170
|
-
buildId,
|
|
171
|
-
routes: describeRoutes(),
|
|
172
|
-
islands,
|
|
173
|
-
styles: styleBundle(),
|
|
174
|
-
scripts: [
|
|
175
|
-
...sync.scripts,
|
|
176
|
-
...(navigation.script === undefined ? [] : [navigation.script]),
|
|
177
|
-
],
|
|
178
|
-
});
|
|
179
|
-
// The app's own MCP endpoint, through the same call `x dev` makes — see `app-mcp.ts`.
|
|
180
|
-
const mcpMount = await mountAppMcp(options.root);
|
|
181
|
-
// The app's own disks when it declared any (`defineStorage` in an app module), else this boot's.
|
|
182
|
-
const served = servedStorage(runtime.storage);
|
|
183
|
-
const routes: readonly Route[] = [
|
|
184
|
-
...apiRoutes(),
|
|
185
|
-
// The bearer doors onto a cut of the same routes (`defineApi({ http: { mounts } })`), counted
|
|
186
|
-
// on the store this boot's limiter uses, so one token's allowance is one number fleet-wide.
|
|
187
|
-
...apiMountRoutes(options.runtime?.rateLimitStore ?? runtime.rateLimitStore),
|
|
188
|
-
...mcpMount.routes,
|
|
189
|
-
// The app's own plain routes (`apps/<app>/runtime.ts` `routes`), before any page can shadow one.
|
|
190
|
-
...(options.runtime?.routes ?? []),
|
|
191
|
-
...(serviceWorker === undefined ? [] : serviceWorkerRoutes(serviceWorker)),
|
|
192
|
-
...assetRoutes({
|
|
193
|
-
root: options.root,
|
|
194
|
-
storage: served,
|
|
195
|
-
...(options.runtime?.images === undefined ? {} : { images: options.runtime.images }),
|
|
196
|
-
...(pwa === undefined ? {} : { pwa }),
|
|
197
|
-
}),
|
|
198
|
-
...storageRoutes({ storage: served }),
|
|
199
|
-
...islandRoutes(() => islands),
|
|
200
|
-
// The surface stylesheets the documents link. Built from the registry the `loadApp` above
|
|
201
|
-
// filled, so this process serves exactly the CSS it renders against.
|
|
202
|
-
...styleRoutes(() => styleBundle()),
|
|
203
|
-
// `robots.txt` and `sitemap.xml`, the same two files the static export writes (`site-seo.ts`).
|
|
204
|
-
...seoRoutes({ env: options.env, site, root: options.root }),
|
|
205
|
-
// The page's one socket: its worker script, served beside the islands for their reason.
|
|
206
|
-
...sync.routes,
|
|
207
|
-
...navigation.routes,
|
|
208
|
-
// A page's `POST`, bound to an action (`defineRoute({ post })`) — beside the page's `GET`.
|
|
209
|
-
...pagePostRoutes(),
|
|
210
|
-
...appRoutes({
|
|
211
|
-
buildId,
|
|
212
|
-
resolveIsland: (file) => islands.resolverFor(file),
|
|
213
|
-
...(sync.head === undefined ? {} : { sync: sync.head }),
|
|
214
|
-
persisted: sync.persisted,
|
|
215
|
-
...(navigation.head === undefined ? {} : { navigation: navigation.head }),
|
|
216
|
-
themeHead: theme.head,
|
|
217
|
-
...(speculation === undefined ? {} : { speculationHead: speculation.head }),
|
|
218
|
-
// A `static` page is the same bytes for every request of this process: rendered once.
|
|
219
|
-
memoStatic: true,
|
|
220
|
-
...(origin === undefined ? {} : { origin }),
|
|
221
|
-
...(pwa === undefined
|
|
222
|
-
? {}
|
|
223
|
-
: { pwaHead: (locale: string) => pwa.headFor(locale) + (serviceWorker?.head ?? '') }),
|
|
224
|
-
// Only when a store was supplied. `createIsrController` defaults to a per-process memory
|
|
225
|
-
// store, so twelve replicas hold twelve of them and a purge tag regenerates one twelfth of
|
|
226
|
-
// the fleet while the other eleven keep serving the page it just invalidated.
|
|
227
|
-
...(options.runtime?.isrStore === undefined
|
|
228
|
-
? {}
|
|
229
|
-
: { isr: createIsrController({ buildId, store: options.runtime.isrStore }) }),
|
|
230
|
-
}),
|
|
231
|
-
];
|
|
232
|
-
return {
|
|
233
|
-
routes,
|
|
234
|
-
// The theme boot and the speculation rules: the two inline bodies every document may carry.
|
|
235
|
-
inlineScripts: [theme.cspSource, ...(speculation === undefined ? [] : [speculation.cspSource])],
|
|
236
|
-
};
|
|
237
|
-
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Where the image's prebuilt store lives and the one command that writes it. A leaf with no
|
|
2
|
+
// import, so the Dockerfile template, the build command and the container boot all spell them
|
|
3
|
+
// from here without loading each other.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* App-root-relative. NOT under `.x/`: that is the state directory, which the scaffolded compose
|
|
7
|
+
* topology mounts a tmpfs over and every ignore file drops, so a store written there never reached
|
|
8
|
+
* a running container. Under `node_modules` because that is the one directory every tool already
|
|
9
|
+
* leaves alone — git, the linter, every source walker and the image context — and the one the
|
|
10
|
+
* image build has just written, so it is in a layer and readable under a read-only root.
|
|
11
|
+
*/
|
|
12
|
+
export const PREBUILT_DIR = 'node_modules/.cache/ultimate';
|
|
13
|
+
|
|
14
|
+
export const PREBUILT_ISLANDS_DIR = `${PREBUILT_DIR}/islands`;
|
|
15
|
+
|
|
16
|
+
export const PREBUILT_SASS_DIR = `${PREBUILT_DIR}/sass`;
|
|
17
|
+
|
|
18
|
+
/** The Dockerfile line that writes the store — one spelling, for the template and every `fix:`. */
|
|
19
|
+
export const PREBUILT_COMMAND = 'bun node_modules/@ultimat3/cli/src/bin.ts build --target prebuilt';
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The image's prebuilt store, as a container sees it: where `x build --target prebuilt` wrote the
|
|
2
|
+
// island chunks and the compiled stylesheets during `docker build`, and the one log line a role
|
|
3
|
+
// owes when it had to build either at boot instead. A leaf of the boot graph — the half that
|
|
4
|
+
// WRITES the store (`image-prepare.ts`) carries the island builder and is never loaded here.
|
|
5
|
+
|
|
6
|
+
// why: the store is adopted before the first app module is imported, and that import path is
|
|
7
|
+
// synchronous end to end (`compileStylesheet` runs inside Bun's `onLoad`); Bun has no sync exists.
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
// why: Bun ships no path-join primitive.
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import type { Role } from '@ultimat3/core';
|
|
12
|
+
import { logger, UltimateError } from '@ultimat3/core';
|
|
13
|
+
import { sassCompilations, setSassCacheDir } from '@ultimat3/render/server';
|
|
14
|
+
import { PREBUILT_COMMAND, PREBUILT_SASS_DIR } from './serve-prebuilt-paths';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Points the Sass cache at the image's own entries, when the image has any. Before the app is
|
|
18
|
+
* imported, because importing a page IS compiling its stylesheet. An entry is keyed by the
|
|
19
|
+
* sheet's absolute path, which is why the store is written inside the image build: same `WORKDIR`,
|
|
20
|
+
* same paths. Answers whether there was a store to adopt.
|
|
21
|
+
*/
|
|
22
|
+
export function adoptPrebuiltStyles(root: string): boolean {
|
|
23
|
+
const dir = join(root, PREBUILT_SASS_DIR);
|
|
24
|
+
if (!existsSync(dir)) return false;
|
|
25
|
+
setSassCacheDir(dir);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What a role built at boot that the image build exists to have built once. */
|
|
30
|
+
export interface BootBuilds {
|
|
31
|
+
/** Island chunks built by this boot, and the one sentence saying why the store was not served. */
|
|
32
|
+
readonly islands?: { readonly chunks: number; readonly reason: string };
|
|
33
|
+
/** Stylesheets this boot ran Sass for. */
|
|
34
|
+
readonly stylesheets: number;
|
|
35
|
+
readonly elapsedMs: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Logged, never thrown: a build at boot is correct and only slow, so the role serves what it
|
|
40
|
+
* would have served — it pays seconds of CPU and a compiler's worth of memory on every start.
|
|
41
|
+
*/
|
|
42
|
+
export class ImageNotPrebuiltError extends UltimateError {
|
|
43
|
+
constructor(input: { readonly role: Role } & BootBuilds) {
|
|
44
|
+
const islands = input.islands;
|
|
45
|
+
const built = [
|
|
46
|
+
...(islands === undefined
|
|
47
|
+
? []
|
|
48
|
+
: [`built ${String(islands.chunks)} island chunk(s) because ${islands.reason}`]),
|
|
49
|
+
...(input.stylesheets === 0
|
|
50
|
+
? []
|
|
51
|
+
: [`compiled ${String(input.stylesheets)} stylesheet(s) the image holds no result for`]),
|
|
52
|
+
];
|
|
53
|
+
super({
|
|
54
|
+
code: 'X_IMAGE_NOT_PREBUILT',
|
|
55
|
+
cause: `the ${input.role} role ${built.join(' and ')}, ${String(input.elapsedMs)} ms into its boot — work this image repeats on every start of every replica`,
|
|
56
|
+
fix: `edit docker/Dockerfile: add RUN ${PREBUILT_COMMAND} after COPY . . in the runtime stage, then rebuild the image`,
|
|
57
|
+
meta: {
|
|
58
|
+
role: input.role,
|
|
59
|
+
islands: islands?.chunks ?? 0,
|
|
60
|
+
stylesheets: input.stylesheets,
|
|
61
|
+
elapsedMs: input.elapsedMs,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Starts counting what a boot compiles; the answer is what `reportBootBuilds` is handed. */
|
|
68
|
+
export function watchBootBuilds(): (islands?: BootBuilds['islands']) => BootBuilds {
|
|
69
|
+
const started = Bun.nanoseconds();
|
|
70
|
+
const compiled = sassCompilations();
|
|
71
|
+
return (islands) => ({
|
|
72
|
+
...(islands === undefined || islands.chunks === 0 ? {} : { islands }),
|
|
73
|
+
stylesheets: sassCompilations() - compiled,
|
|
74
|
+
elapsedMs: Math.round((Bun.nanoseconds() - started) / 1e6),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One `error` line when the boot built anything, and nothing when it built nothing. */
|
|
79
|
+
export function reportBootBuilds(role: Role, builds: BootBuilds): boolean {
|
|
80
|
+
if (builds.islands === undefined && builds.stylesheets === 0) return false;
|
|
81
|
+
const error = new ImageNotPrebuiltError({ role, ...builds });
|
|
82
|
+
logger.error(error.format(), { code: error.code, ...error.meta });
|
|
83
|
+
return true;
|
|
84
|
+
}
|