@ultimat3/cli 22.15.0 → 24.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 +36 -13
- package/README.md +31 -8
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/affected.ts +27 -35
- package/src/api-registration.ts +125 -11
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +6 -20
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +5 -11
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +16 -8
- package/src/app-mcp.ts +10 -23
- package/src/app-permissions-borrowed.ts +129 -0
- package/src/app-permissions-site.ts +24 -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 +27 -34
- package/src/cdp-shot-driver.ts +3 -1
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +107 -11
- package/src/cmd-db-backfill.ts +25 -12
- package/src/cmd-db.ts +66 -16
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy.ts +4 -4
- package/src/cmd-dev.ts +9 -8
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +75 -24
- package/src/cmd-env.ts +26 -2
- package/src/cmd-generate.ts +195 -47
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +10 -9
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +136 -7
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +46 -15
- package/src/cmd-new.ts +15 -1
- package/src/cmd-planned.ts +15 -8
- package/src/cmd-routes.ts +27 -3
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +95 -18
- package/src/cmd-shot-matrix.ts +1 -3
- package/src/cmd-shot.ts +12 -3
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +13 -29
- package/src/cmd-verify.ts +13 -5
- 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/db-subscribes.ts +12 -4
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +51 -29
- package/src/dev-lock.ts +4 -7
- package/src/dev-route-table.ts +9 -0
- package/src/dispatch.ts +3 -6
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -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/drift-replica-identity.ts +42 -0
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +55 -3
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +28 -2
- package/src/generate-files.ts +79 -12
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +52 -1
- package/src/generate-shadow.ts +46 -0
- package/src/generate-write.ts +62 -40
- package/src/generated-imports.ts +59 -0
- package/src/gh-target.ts +12 -7
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-audit.ts +4 -1
- 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 +12 -5
- package/src/island-bundle.ts +3 -15
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +95 -21
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +7 -4
- package/src/json-merge.ts +46 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +41 -4
- package/src/mcp-host.ts +16 -9
- package/src/mcp-test-run.ts +38 -0
- package/src/messages.ts +35 -0
- package/src/metrics-endpoint.ts +95 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +24 -9
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +5 -20
- package/src/permission-grants.ts +41 -9
- package/src/prerender-out.ts +34 -7
- package/src/prerender.ts +8 -5
- package/src/publish-closure.ts +101 -0
- package/src/pwa-artifacts.ts +26 -94
- package/src/registry.ts +2 -0
- package/src/revalidated-response.ts +4 -3
- package/src/role-load.ts +143 -0
- package/src/role-replicator.ts +16 -0
- package/src/role-start.ts +27 -5
- package/src/role-sync.ts +55 -20
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-assets.ts +11 -7
- package/src/runtime-bindings.ts +4 -4
- package/src/runtime-cache.ts +38 -47
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +8 -33
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +1 -1
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +39 -11
- package/src/runtime-realtime.ts +9 -45
- package/src/runtime-render.ts +68 -35
- package/src/runtime-services.ts +22 -18
- package/src/runtime-storage.ts +10 -5
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/schema-dump-drift.ts +64 -0
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +114 -158
- package/src/serve-drain.ts +9 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +169 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/shot-locale.ts +5 -23
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +1 -1
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-edit.ts +49 -0
- package/src/source-files.ts +6 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/sync-url.ts +2 -2
- package/src/tasks-facts.ts +3 -26
- package/src/templates/action.ts +147 -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/naming.ts +28 -0
- 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 +41 -35
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +177 -53
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +20 -2
- 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 +144 -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-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +32 -7
- package/src/templates/scaffold-helm.ts +31 -1
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +61 -3
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +74 -105
- 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 +14 -2
- package/src/test-ignores.ts +25 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +5 -14
- package/src/test-shards.ts +41 -93
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +6 -31
- package/src/transport-calls.ts +105 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +43 -53
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +353 -0
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +171 -45
- package/src/verify-part-step.ts +90 -0
- 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 +116 -37
- package/src/verify-shard.ts +33 -11
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +83 -8
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/build-errors.ts +0 -54
- 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/verify-errors.ts +0 -49
package/src/registry.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// dev server it never started (plan 101 slice 12 d).
|
|
8
8
|
|
|
9
9
|
import { affectedSpec } from './cmd-affected-spec';
|
|
10
|
+
import { authSpec } from './cmd-auth-spec';
|
|
10
11
|
import { buildSpec } from './cmd-build-spec';
|
|
11
12
|
import { ciSpec } from './cmd-ci-spec';
|
|
12
13
|
import { dbSpec } from './cmd-db-spec';
|
|
@@ -85,6 +86,7 @@ export const LAZY_COMMANDS: readonly LazyCommand[] = [
|
|
|
85
86
|
lazy(deploySpec, async () => (await import('./cmd-deploy')).deployCommand),
|
|
86
87
|
lazy(envSpec, async () => (await import('./cmd-env')).envCommand),
|
|
87
88
|
lazy(secretsSpec, async () => (await import('./cmd-secrets')).secretsCommand),
|
|
89
|
+
lazy(authSpec, async () => (await import('./cmd-auth')).authCommand),
|
|
88
90
|
lazy(manifestSpec, async () => (await import('./cmd-manifest')).manifestCommand),
|
|
89
91
|
lazy(routesSpec, async () => (await import('./cmd-routes')).routesCommand),
|
|
90
92
|
lazy(actionsSpec, async () => (await import('./cmd-registries')).actionsCommand),
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// name that outlives its bytes is a file no client refreshes for a year.
|
|
5
5
|
|
|
6
6
|
import type { UltimateRequest } from '@ultimat3/http';
|
|
7
|
+
import { contentHash } from '@ultimat3/render/server';
|
|
7
8
|
import { etagMatches } from './runtime-storage';
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -14,8 +15,8 @@ import { etagMatches } from './runtime-storage';
|
|
|
14
15
|
export const REVALIDATE_CACHE_CONTROL = 'public, max-age=0, must-revalidate';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
|
-
* A strong validator from the bytes —
|
|
18
|
-
* taken here over bytes because an icon is not a string. Revalidating costs a request and no body.
|
|
18
|
+
* A strong validator from the bytes — `@ultimat3/render`'s `contentHash`, the function a document's
|
|
19
|
+
* ETag is, taken here over bytes because an icon is not a string. Revalidating costs a request and no body.
|
|
19
20
|
*/
|
|
20
21
|
export function revalidatedResponse(
|
|
21
22
|
request: UltimateRequest,
|
|
@@ -25,7 +26,7 @@ export function revalidatedResponse(
|
|
|
25
26
|
// Copied, not passed through: a `Uint8Array<ArrayBufferLike>` may be backed by a
|
|
26
27
|
// `SharedArrayBuffer`, which `Response` does not accept — `runtime-assets.ts`'s rule, verbatim.
|
|
27
28
|
const bytes = typeof body === 'string' ? new TextEncoder().encode(body) : new Uint8Array(body);
|
|
28
|
-
const etag = `"${
|
|
29
|
+
const etag = `"${contentHash(bytes)}"`;
|
|
29
30
|
const headers = {
|
|
30
31
|
'content-type': contentType,
|
|
31
32
|
'cache-control': REVALIDATE_CACHE_CONTROL,
|
package/src/role-load.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// What a container role imports of the app. `web`, `sync` and `replicator` answer for registries a
|
|
2
|
+
// request, a subscription or the WAL names — routes, queries, entities — so they import every
|
|
3
|
+
// module. `worker` and `scheduler` run jobs and tasks: they import the API index and every module
|
|
4
|
+
// that brings no document (a component, a stylesheet) with it, and nothing else.
|
|
5
|
+
|
|
6
|
+
// why: Bun ships no path API; the API index is named app-root-relative and imported absolute.
|
|
7
|
+
import { join, resolve } from 'node:path';
|
|
8
|
+
import type { Role } from '@ultimat3/core';
|
|
9
|
+
import { isJsonObject, logger, UltimateError } from '@ultimat3/core';
|
|
10
|
+
import { registeredJobs, registeredTasks } from '@ultimat3/jobs';
|
|
11
|
+
import type { AppScan } from './app-load';
|
|
12
|
+
import { scanAppModules } from './app-load';
|
|
13
|
+
import { API_INDEX } from './app-root';
|
|
14
|
+
import { createDocumentGraph } from './document-graph';
|
|
15
|
+
|
|
16
|
+
/** `everything`: the scan every tool runs. `background`: no document a job does not import. */
|
|
17
|
+
export type RoleLoad = 'everything' | 'background';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Typed over `Role`, so a seventh role does not compile until somebody decides what it imports.
|
|
21
|
+
* `migrate` never reaches a load — `runMigrations` reads SQL files — and says `everything` so a
|
|
22
|
+
* caller that boots it by hand is given the whole app rather than a guess.
|
|
23
|
+
*/
|
|
24
|
+
const DECLARED: Readonly<Record<Role, RoleLoad>> = {
|
|
25
|
+
web: 'everything',
|
|
26
|
+
// A subscription names a live query, and a page module may export one.
|
|
27
|
+
sync: 'everything',
|
|
28
|
+
// The slot decodes every table an entity declares, wherever the entity is declared.
|
|
29
|
+
replicator: 'everything',
|
|
30
|
+
worker: 'background',
|
|
31
|
+
scheduler: 'background',
|
|
32
|
+
migrate: 'everything',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** A `Map`, so the read below is an own-key lookup — never a member of `Object.prototype`. */
|
|
36
|
+
export const ROLE_LOADS: ReadonlyMap<Role, RoleLoad> = new Map(
|
|
37
|
+
Object.entries(DECLARED) as [Role, RoleLoad][],
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
/** What `role` imports. A value that is no role — an untyped caller's — is given everything. */
|
|
41
|
+
export const roleLoadFor = (role: Role): RoleLoad => ROLE_LOADS.get(role) ?? 'everything';
|
|
42
|
+
|
|
43
|
+
/** The file the gate holds fresh (`manifest` step) and every image carries beside the source. */
|
|
44
|
+
const MANIFEST_FILE = 'x.manifest.json';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A background load that left out a job or task the committed manifest names. Logged, never
|
|
48
|
+
* thrown: the boot answers it by importing the rest of the app, so the role runs everything it
|
|
49
|
+
* ran before — it only pays for the documents this load exists to leave out.
|
|
50
|
+
*/
|
|
51
|
+
export class RoleLoadIncompleteError extends UltimateError {
|
|
52
|
+
constructor(input: { role: Role; missing: readonly string[] }) {
|
|
53
|
+
super({
|
|
54
|
+
code: 'X_ROLE_LOAD_INCOMPLETE',
|
|
55
|
+
cause: `the ${input.role} role imported ${API_INDEX} and every module that reaches no component or stylesheet, and ${MANIFEST_FILE} names ${String(input.missing.length)} job(s) or task(s) that load did not register (${input.missing.join(', ')}), so the whole app was imported instead`,
|
|
56
|
+
fix: `add import * as <module> from the file that declares it and <module> to jobs: [...] or tasks: [...] in ${API_INDEX}, then x manifest`,
|
|
57
|
+
meta: { role: input.role, missing: input.missing },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `name` of every entry of one manifest list — `[]` for a list that is absent or malformed. */
|
|
63
|
+
function namesIn(manifest: Record<string, unknown>, key: 'jobs' | 'tasks'): readonly string[] {
|
|
64
|
+
const list = manifest[key];
|
|
65
|
+
if (!Array.isArray(list)) return [];
|
|
66
|
+
return list.flatMap((entry: unknown) =>
|
|
67
|
+
isJsonObject(entry) && typeof entry['name'] === 'string' ? [entry['name']] : [],
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Every job and task the committed manifest names that this process has not registered —
|
|
73
|
+
* `undefined` when there is no manifest to check against, which is not the same answer as "none".
|
|
74
|
+
*/
|
|
75
|
+
export async function unregisteredFromManifest(
|
|
76
|
+
root: string,
|
|
77
|
+
): Promise<readonly string[] | undefined> {
|
|
78
|
+
const parsed: unknown = await Bun.file(join(root, MANIFEST_FILE))
|
|
79
|
+
.json()
|
|
80
|
+
.catch(() => undefined);
|
|
81
|
+
if (!isJsonObject(parsed)) return undefined;
|
|
82
|
+
const jobs = new Set(registeredJobs().map((job) => job.name));
|
|
83
|
+
const tasks = new Set(registeredTasks().map((task) => task.name));
|
|
84
|
+
return [
|
|
85
|
+
...namesIn(parsed, 'jobs').filter((name) => !jobs.has(name)),
|
|
86
|
+
...namesIn(parsed, 'tasks').filter((name) => !tasks.has(name)),
|
|
87
|
+
].sort();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The background scan alone, with no proof and no fallback: the API index and every module that
|
|
92
|
+
* brings no document. What a worker imports when nothing is wrong — and what the gate imports in a
|
|
93
|
+
* child to compare against the whole app (`verify-role-load.ts`).
|
|
94
|
+
*/
|
|
95
|
+
export async function scanBackground(root: string): Promise<AppScan> {
|
|
96
|
+
const index = resolve(root, API_INDEX);
|
|
97
|
+
// why `require.cache`: Bun 1.4 lists every evaluated ES module there, by absolute path — the
|
|
98
|
+
// same table `app-reload-graph.ts` evicts from.
|
|
99
|
+
const loaded = (absolute: string): boolean => absolute in require.cache;
|
|
100
|
+
const graph = createDocumentGraph(loaded);
|
|
101
|
+
return scanAppModules(root, {
|
|
102
|
+
track: false,
|
|
103
|
+
// The index first (`appModulePaths` leads with it): what it imports is what the app runs.
|
|
104
|
+
// Then every module already here, and every one that brings no document with it — a service,
|
|
105
|
+
// a catalog, a storage declaration registers on import and no job names it.
|
|
106
|
+
include: async (absolute) =>
|
|
107
|
+
absolute === index || loaded(absolute) || !(await graph.reachesDocument(absolute)),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The background load, then the proof it was enough. A job runs by NAME — the queue row carries
|
|
113
|
+
* it — so the one thing this load must not do is leave a named job unregistered. The gate already
|
|
114
|
+
* requires every job and task to be named from a `defineApi()` call (`X_JOB_UNREGISTERED`); the
|
|
115
|
+
* manifest is the list that call produced, and a name it holds that this load did not register is
|
|
116
|
+
* answered by importing everything, loudly.
|
|
117
|
+
*/
|
|
118
|
+
async function loadBackground(root: string, role: Role): Promise<AppScan> {
|
|
119
|
+
const scan = await scanBackground(root);
|
|
120
|
+
const missing = await unregisteredFromManifest(root);
|
|
121
|
+
if (missing !== undefined && missing.length === 0) return scan;
|
|
122
|
+
if (missing === undefined) {
|
|
123
|
+
logger.warn('ultimate role load unverified', {
|
|
124
|
+
role,
|
|
125
|
+
cause: `${MANIFEST_FILE} is absent or unreadable, so nothing names the jobs this role must run and the whole app was imported`,
|
|
126
|
+
fix: 'x manifest',
|
|
127
|
+
});
|
|
128
|
+
} else {
|
|
129
|
+
const error = new RoleLoadIncompleteError({ role, missing });
|
|
130
|
+
logger.error(error.format(), { code: error.code, role, missing });
|
|
131
|
+
}
|
|
132
|
+
return scanAppModules(root, { track: false });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* What `serve-boot.ts` calls instead of `loadApp`: the scan a role needs and none of what a tool
|
|
137
|
+
* reads from it afterwards — no reload graph, no error-code walk of the app's source.
|
|
138
|
+
*/
|
|
139
|
+
export async function loadAppForRole(root: string, role: Role): Promise<AppScan> {
|
|
140
|
+
return roleLoadFor(role) === 'background'
|
|
141
|
+
? loadBackground(root, role)
|
|
142
|
+
: scanAppModules(root, { track: false });
|
|
143
|
+
}
|
package/src/role-replicator.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// a `ROLE=replicator` container starts; the only difference is that this process also runs the
|
|
4
4
|
// roles reading the other end of that transport.
|
|
5
5
|
|
|
6
|
+
import { registerReadinessCheck } from '@ultimat3/core';
|
|
6
7
|
import { describeEntities } from '@ultimat3/entity';
|
|
7
8
|
import { ReplicatorSlotHeldError } from '@ultimat3/realtime';
|
|
8
9
|
import type { Replicator, Transport } from '@ultimat3/realtime/server';
|
|
@@ -72,6 +73,18 @@ const noEntitiesRefusal = (): BadFlagError =>
|
|
|
72
73
|
fix: 'x g entity Post title:text — then x dev --role replicator',
|
|
73
74
|
});
|
|
74
75
|
|
|
76
|
+
/** The name `/readyz` reports the replicator under. */
|
|
77
|
+
export const REPLICATOR_READINESS_CHECK = 'replicator';
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Ready means REPLICATING. A stream that ends is restarted by the replicator itself, and until it
|
|
81
|
+
* is back `running` is `false`: this process holds no slot and publishes nothing, so it must not be
|
|
82
|
+
* counted as the database's replicator. Returns the unregister.
|
|
83
|
+
*/
|
|
84
|
+
export function watchReplicatorReadiness(replicator: Pick<Replicator, 'running'>): () => void {
|
|
85
|
+
return registerReadinessCheck(REPLICATOR_READINESS_CHECK, () => replicator.running);
|
|
86
|
+
}
|
|
87
|
+
|
|
75
88
|
/**
|
|
76
89
|
* Lock first, feed second. A replicator that opened its slot before losing the lock race would
|
|
77
90
|
* have already consumed WAL the holder is responsible for, and `pg_try_advisory_lock` is the only
|
|
@@ -95,11 +108,14 @@ export async function startReplicator(options: StartReplicatorOptions): Promise<
|
|
|
95
108
|
if (!(await replicator.start())) {
|
|
96
109
|
throw new ReplicatorSlotHeldError({ key: replicatorLockKey(slot) });
|
|
97
110
|
}
|
|
111
|
+
const unregister = watchReplicatorReadiness(replicator);
|
|
98
112
|
return {
|
|
99
113
|
replicator,
|
|
100
114
|
slot,
|
|
101
115
|
detail: selection.detail,
|
|
102
116
|
async stop() {
|
|
117
|
+
// First: a replicator being stopped on purpose is not a failing one.
|
|
118
|
+
unregister();
|
|
103
119
|
await replicator.stop();
|
|
104
120
|
},
|
|
105
121
|
};
|
package/src/role-start.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// something every `x dev` in a team should do to the same server by simply starting.
|
|
8
8
|
|
|
9
9
|
import type { Role } from '@ultimat3/core';
|
|
10
|
-
import { createContext, isRole, logger, ROLES } from '@ultimat3/core';
|
|
10
|
+
import { createContext, isRole, logger, markReady, ROLES } from '@ultimat3/core';
|
|
11
11
|
import type { RateLimitStore, Route, ServerHandle, WebSocketMount } from '@ultimat3/http';
|
|
12
12
|
import {
|
|
13
13
|
configuredAuthenticator,
|
|
@@ -28,13 +28,18 @@ import {
|
|
|
28
28
|
} from '@ultimat3/jobs';
|
|
29
29
|
import type { SyncWs } from '@ultimat3/realtime/server';
|
|
30
30
|
import { errorPageHook } from './error-pages';
|
|
31
|
-
import { BadFlagError,
|
|
32
|
-
import {
|
|
31
|
+
import { BadFlagError, RuntimeDriverSplitError } from './errors';
|
|
32
|
+
import {
|
|
33
|
+
DEFAULT_METRICS_PORT,
|
|
34
|
+
startMetricsEndpoint,
|
|
35
|
+
whenMetricsPortTaken,
|
|
36
|
+
} from './metrics-endpoint';
|
|
33
37
|
import { rolesUnderRealtime } from './role-realtime';
|
|
34
38
|
import { startReplicator } from './role-replicator';
|
|
35
39
|
import type { RunningRoles, StartRolesOptions } from './role-start-types';
|
|
36
40
|
import { DEV_ROLES } from './role-start-types';
|
|
37
41
|
import { prepareSync, type RunningSync } from './role-sync';
|
|
42
|
+
import { startWorkerWake } from './role-wake';
|
|
38
43
|
import type { Env } from './runtime-bindings';
|
|
39
44
|
import { devHooks } from './runtime-hooks';
|
|
40
45
|
import { workerOptionsFor } from './runtime-jobs';
|
|
@@ -44,6 +49,7 @@ import type { RunningServices } from './runtime-services';
|
|
|
44
49
|
import { inlineScriptSources } from './script-csp';
|
|
45
50
|
import { inlineStyleSources } from './style-csp';
|
|
46
51
|
import { syncConnectSources } from './sync-url';
|
|
52
|
+
import { TrustedProxyHopsInvalidError } from './trusted-hops-error';
|
|
47
53
|
import { DEV_BINDING } from './web-binding';
|
|
48
54
|
|
|
49
55
|
// Declared beside the option types so `x dev`'s spec reads it without loading this module.
|
|
@@ -155,8 +161,8 @@ export function trustedHopsFromEnv(env: Env): number | null {
|
|
|
155
161
|
const hops = Number(raw);
|
|
156
162
|
// A malformed count is refused rather than defaulted: reading the header at the wrong index is
|
|
157
163
|
// trusting a value the client typed, which is the failure trusting a proxy exists to avoid.
|
|
158
|
-
if (!Number.isInteger(hops) || hops < 1 || hops > MAX_PROXY_HOPS) {
|
|
159
|
-
throw new
|
|
164
|
+
if (!/^\d+$/.test(raw) || !Number.isInteger(hops) || hops < 1 || hops > MAX_PROXY_HOPS) {
|
|
165
|
+
throw new TrustedProxyHopsInvalidError({ value: raw });
|
|
160
166
|
}
|
|
161
167
|
return hops;
|
|
162
168
|
}
|
|
@@ -304,6 +310,9 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
304
310
|
startMetricsEndpoint({
|
|
305
311
|
port: options.metricsPort ?? (options.port === 0 ? 0 : DEFAULT_METRICS_PORT),
|
|
306
312
|
hostname: binding.hostname,
|
|
313
|
+
// `x dev` is one process running several roles: its `process_info` says which.
|
|
314
|
+
role: selected.join('+'),
|
|
315
|
+
whenTaken: whenMetricsPortTaken(binding.dev, options.env),
|
|
307
316
|
});
|
|
308
317
|
started.push(async () => metrics.stop());
|
|
309
318
|
|
|
@@ -350,6 +359,13 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
350
359
|
// one hands the failure rollback the same window a dropped `await` gives the teardown below.
|
|
351
360
|
if (relay !== null) started.push(() => relay.stop());
|
|
352
361
|
|
|
362
|
+
// The cross-process wake: one LISTEN session, so a job another pod committed starts now
|
|
363
|
+
// instead of at the next poll. On `worker` alone — it is the role whose two loops (the claim
|
|
364
|
+
// and the relay above) hear it; a web or scheduler pod holding a session would pin a
|
|
365
|
+
// connection per replica to wake nothing (`role-wake.ts`).
|
|
366
|
+
const wake = selected.includes('worker') ? startWorkerWake(options.runtime.db) : null;
|
|
367
|
+
if (wake !== null) started.push(() => wake.stop());
|
|
368
|
+
|
|
353
369
|
// `state` and `leader`, not the defaults. `createMemorySchedulerState` forgets every watermark
|
|
354
370
|
// on restart, so a rolling deploy re-fires or skips whatever was due across it, and
|
|
355
371
|
// `soleLeader()` makes every replica the leader — three `scheduler` pods, three of every task.
|
|
@@ -391,6 +407,10 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
391
407
|
replicatorHere: replicator !== null,
|
|
392
408
|
});
|
|
393
409
|
started.push(async () => live.stop());
|
|
410
|
+
// The web server and the sync node mark ready when they bind; a process with neither — a
|
|
411
|
+
// worker, scheduler or replicator pod — is ready once its roles started, which is what the
|
|
412
|
+
// scrape port's `/readyz` answers (`metrics-endpoint.ts`). The checks still decide after this.
|
|
413
|
+
if (server === null && sync === null) markReady();
|
|
394
414
|
|
|
395
415
|
return {
|
|
396
416
|
roles: selected,
|
|
@@ -413,6 +433,8 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
|
|
|
413
433
|
// and AWAITED, because a pass is a `driver.enqueue` followed by a `markPublished`. Dropped,
|
|
414
434
|
// this returns between the two and the lines below close the pool under the row it was
|
|
415
435
|
// about to mark: re-published next boot at best, a rejection against a closed pool at worst.
|
|
436
|
+
// The session first: a notification arriving after this wakes a loop that is about to stop.
|
|
437
|
+
await wake?.stop();
|
|
416
438
|
await relay?.stop();
|
|
417
439
|
await worker?.stop('x dev stopped');
|
|
418
440
|
await sync?.stop();
|
package/src/role-sync.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// and a listener of its own — and because that file is the boot's index, not its detail.
|
|
4
4
|
|
|
5
5
|
import { createContext, logger, UltimateError } from '@ultimat3/core';
|
|
6
|
-
import type
|
|
6
|
+
import { configuredHttp, type WebSocketMount } from '@ultimat3/http';
|
|
7
7
|
import { listQueries } from '@ultimat3/query';
|
|
8
8
|
import type { SyncNode, SyncWs } from '@ultimat3/realtime/server';
|
|
9
9
|
import {
|
|
@@ -120,7 +120,8 @@ export interface PreparedSync {
|
|
|
120
120
|
readonly mount: WebSocketMount<SyncWs>;
|
|
121
121
|
/**
|
|
122
122
|
* Bind `PORT + 1`, and answer with the same object `startSync` always did. `appUrl` is the web
|
|
123
|
-
* role's own origin when one runs here
|
|
123
|
+
* role's own origin when one runs here: named in the boot line beside the node's, and — under
|
|
124
|
+
* `x dev` with an `APP_URL` declared — admitted as a page origin (`devOrigins`).
|
|
124
125
|
*/
|
|
125
126
|
listen(appUrl: string | null): Promise<RunningSync>;
|
|
126
127
|
/** Release the node when nothing ever bound it — a `web` role that threw after it was built. */
|
|
@@ -176,6 +177,35 @@ export function registerLiveQueries(options: StartRolesOptions): LiveQueryRegist
|
|
|
176
177
|
return registry;
|
|
177
178
|
}
|
|
178
179
|
|
|
180
|
+
const healthDetailPeersOf = (
|
|
181
|
+
peers: readonly string[] | undefined,
|
|
182
|
+
): { readonly healthDetailPeers?: readonly string[] } =>
|
|
183
|
+
peers === undefined ? {} : { healthDetailPeers: peers };
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* What `x dev` adds to a DECLARED list: the origin its own web role is serving pages on, under
|
|
187
|
+
* each loopback spelling a browser may have been pointed at. A declaration is the whole list, and
|
|
188
|
+
* the scaffold's `.env` declares `APP_URL=http://localhost:3000` — so `x dev --port 4000`, or dev
|
|
189
|
+
* opened as `127.0.0.1`, was a page on an origin nothing named and every socket was refused.
|
|
190
|
+
*
|
|
191
|
+
* Nothing is added to an EMPTY list: an undeclared node admits the origin it was reached on, which
|
|
192
|
+
* already covers these and a forwarded host besides — and one entry would end that rule. Nothing
|
|
193
|
+
* is added outside dev either: in a container the declaration is the operator's, and a wrong one
|
|
194
|
+
* must refuse rather than be papered over by whatever address the process bound.
|
|
195
|
+
*/
|
|
196
|
+
function devOrigins(
|
|
197
|
+
options: StartRolesOptions,
|
|
198
|
+
declared: readonly string[],
|
|
199
|
+
appUrl: string | null,
|
|
200
|
+
): string[] {
|
|
201
|
+
if (!(options.http ?? DEV_BINDING).dev || appUrl === null || declared.length === 0) return [];
|
|
202
|
+
const own = new URL(appUrl);
|
|
203
|
+
const loopback = ['localhost', '127.0.0.1', '[::1]'].map(
|
|
204
|
+
(host) => `${own.protocol}//${host}:${own.port}`,
|
|
205
|
+
);
|
|
206
|
+
return [own.origin, ...loopback].filter((origin) => !declared.includes(origin));
|
|
207
|
+
}
|
|
208
|
+
|
|
179
209
|
/**
|
|
180
210
|
* The node itself: its hub, its registry, its authenticator and its change subscription. Nothing
|
|
181
211
|
* bound — `listen()` and the `web` role's mount are the two doors, and this is what is behind both.
|
|
@@ -195,6 +225,7 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
|
|
|
195
225
|
// `exp`), or resolves identity from a header the adapter deliberately does not retain.
|
|
196
226
|
const authenticate = options.overrides?.syncAuthenticate ?? syncAuthenticator(options.buildId);
|
|
197
227
|
const registry = registerLiveQueries(options);
|
|
228
|
+
const origins = syncOriginsFrom(options.env);
|
|
198
229
|
const node = createSyncNode({
|
|
199
230
|
hub,
|
|
200
231
|
registry,
|
|
@@ -202,9 +233,17 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
|
|
|
202
233
|
buildId: options.buildId,
|
|
203
234
|
sockets,
|
|
204
235
|
...(authenticate === undefined ? {} : { authenticate }),
|
|
205
|
-
//
|
|
206
|
-
// admitted
|
|
207
|
-
|
|
236
|
+
// `APP_URL`'s origin, and when one is declared it is the WHOLE list: the origin this node was
|
|
237
|
+
// reached on is no longer admitted beside it. Undeclared, that origin (and its https spelling
|
|
238
|
+
// when TLS ended at a proxy) is what is admitted; anything else is refused before
|
|
239
|
+
// `authenticate` (X_SOCKET_ORIGIN_REFUSED). Held by reference: `listen` adds `x dev`'s own.
|
|
240
|
+
allowedOrigins: origins,
|
|
241
|
+
// Dev may keep the reached-on origin beside a declaration and a container may not: `x dev`
|
|
242
|
+
// binds loopback, so a `Host` reaches it only through a forward its own user set up.
|
|
243
|
+
...((options.http ?? DEV_BINDING).dev ? { admitReachedOrigin: true } : {}),
|
|
244
|
+
// Who this node's OWN `/healthz` and `/readyz` tell the detail to: the app's one declaration,
|
|
245
|
+
// the same list the web role reads. Unset, the node keeps its default (the box itself).
|
|
246
|
+
...healthDetailPeersOf(configuredHttp()?.healthDetailPeers),
|
|
208
247
|
// Tier 1 is presence, and without a registry the node answers a topic subscribe with no member
|
|
209
248
|
// list at all — the KV bucket the transport just created would hold nothing and every `sync`
|
|
210
249
|
// container would run a presence-less protocol. It reads and writes `transport.shared`, so it
|
|
@@ -223,7 +262,10 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
|
|
|
223
262
|
// second copy of `/_x/sync` here is the copy that stays behind when it moves.
|
|
224
263
|
mount: { path: node.path, fetch: node.fetch, websocket: node.websocket },
|
|
225
264
|
stop: () => node.stop(),
|
|
226
|
-
listen: async (appUrl) =>
|
|
265
|
+
listen: async (appUrl) => {
|
|
266
|
+
origins.push(...devOrigins(options, origins, appUrl));
|
|
267
|
+
return await listen(options, node, { registry, hub }, appUrl);
|
|
268
|
+
},
|
|
227
269
|
};
|
|
228
270
|
}
|
|
229
271
|
|
|
@@ -235,27 +277,20 @@ export async function prepareSync(options: StartRolesOptions): Promise<PreparedS
|
|
|
235
277
|
* neighbouring port, and reversing the order would answer a second `x dev` on this checkout with
|
|
236
278
|
* "port 3001 is in use" when the fact worth printing is that 3000 is.
|
|
237
279
|
*
|
|
238
|
-
* Port 0
|
|
239
|
-
*
|
|
240
|
-
* `
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
* listener's own bound address either way, never a string built from the port that was requested.
|
|
280
|
+
* Port 0 IS passed through to the kernel, web role or not. A scratch server (`x shot`, `ui.shot`,
|
|
281
|
+
* a test) asks for 0, and its pages dial `/_x/sync` on their OWN origin — the web role's mount
|
|
282
|
+
* (`sync-url.ts`) — so nothing computes this listener's port from the web port's. Deriving
|
|
283
|
+
* `PORT + 1` from the kernel's answer asked for one specific port nobody had checked, and under
|
|
284
|
+
* load another socket held it: the boot died `X_PORT_IN_USE` (role-realtime.test.ts, 6 of 20 with
|
|
285
|
+
* 12,000 loopback connections held). The reported url is the listener's own bound address.
|
|
245
286
|
*/
|
|
246
|
-
function syncPortFrom(requested: number, appUrl: string | null): number {
|
|
247
|
-
if (requested !== 0 || appUrl === null) return syncPortFor(requested);
|
|
248
|
-
const bound = Number(new URL(appUrl).port);
|
|
249
|
-
return Number.isInteger(bound) && bound > 0 ? syncPortFor(bound) : 0;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
287
|
async function listen(
|
|
253
288
|
options: StartRolesOptions,
|
|
254
289
|
node: SyncNode,
|
|
255
290
|
feeds: Pick<RunningSync, 'registry' | 'hub'>,
|
|
256
291
|
appUrl: string | null,
|
|
257
292
|
): Promise<RunningSync> {
|
|
258
|
-
const port =
|
|
293
|
+
const port = syncPortFor(options.port);
|
|
259
294
|
try {
|
|
260
295
|
// The SAME interface the web role binds, resolved from the same option and the same default.
|
|
261
296
|
// Without this the sync node took Bun's `0.0.0.0` while `x dev`'s web role took `localhost`,
|
package/src/role-wake.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The worker role's cross-process wake, and the one series that says whether it is PROVEN. The
|
|
2
|
+
// mechanism is `@ultimat3/jobs`' (`startQueueWake`); this decides who holds the LISTEN session —
|
|
3
|
+
// the worker, and only over a client that can listen — and publishes `queue_wake_live`, because a
|
|
4
|
+
// transaction-pooling proxy disables the wake in silence and the poll hides that it did.
|
|
5
|
+
|
|
6
|
+
import { gauge } from '@ultimat3/core';
|
|
7
|
+
import type { DbClient } from '@ultimat3/db';
|
|
8
|
+
import { canListen } from '@ultimat3/db';
|
|
9
|
+
import type { QueueWake } from '@ultimat3/jobs';
|
|
10
|
+
import { startQueueWake } from '@ultimat3/jobs';
|
|
11
|
+
import { pgExecutorFor } from './runtime-queue';
|
|
12
|
+
|
|
13
|
+
// The wake the gauge reads. One observer for the life of the process: a gauge redeclared with a
|
|
14
|
+
// different `observe` is refused, and `x dev` starts the roles again on every restart-in-place.
|
|
15
|
+
let current: QueueWake | undefined;
|
|
16
|
+
let declared = false;
|
|
17
|
+
|
|
18
|
+
const wakeLive = (): number => (current?.live() === true ? 1 : 0);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Starts the wake for a worker, or answers `null` when the client cannot hold a session (a
|
|
22
|
+
* hand-built runtime): the claim loop and the relay then keep the poll they always had.
|
|
23
|
+
*/
|
|
24
|
+
export function startWorkerWake(db: DbClient): QueueWake | null {
|
|
25
|
+
if (!canListen(db)) return null;
|
|
26
|
+
if (!declared) {
|
|
27
|
+
declared = true;
|
|
28
|
+
gauge('queue_wake_live', {
|
|
29
|
+
unit: '1',
|
|
30
|
+
description:
|
|
31
|
+
'1 while a notification has crossed the worker LISTEN session, 0 while jobs start on the poll alone',
|
|
32
|
+
observe: wakeLive,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
const wake = startQueueWake({ listener: db, executor: pgExecutorFor(db) });
|
|
36
|
+
current = wake;
|
|
37
|
+
return {
|
|
38
|
+
live: () => wake.live(),
|
|
39
|
+
async stop(): Promise<void> {
|
|
40
|
+
// Only the wake still published: a stop of one that was already replaced silences nothing.
|
|
41
|
+
if (current === wake) current = undefined;
|
|
42
|
+
await wake.stop();
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Mounting the app's generated admin. `defineAdmin()` declares it; this file is the whole of what
|
|
2
|
+
// serves it — one catch-all under each admin's base path, in `x dev` and in the container alike,
|
|
3
|
+
// through the same document pipeline every page uses. The CLI contributes the HTTP crossing and
|
|
4
|
+
// nothing else: which screen a URL names, who may see it and what it renders are the admin's.
|
|
5
|
+
|
|
6
|
+
import type { AdminApp, AdminRouteConfig } from '@ultimat3/admin';
|
|
7
|
+
import type { Route, RouteMeta } from '@ultimat3/http';
|
|
8
|
+
import { asCtx, html, redirect, routeNotFound } from '@ultimat3/http';
|
|
9
|
+
import type { RouteEntry } from '@ultimat3/render';
|
|
10
|
+
import { compilePattern } from '@ultimat3/render';
|
|
11
|
+
import { renderSsr } from '@ultimat3/render/server';
|
|
12
|
+
import type { DocumentOptions } from './document-options';
|
|
13
|
+
import { routeDocument } from './runtime-render';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `@ultimat3/admin`'s `ADMIN_MOUNTS`, read off the GLOBAL symbol registry rather than imported.
|
|
17
|
+
* Importing that package loads every screen and registers its stylesheets, so an app that declared
|
|
18
|
+
* no admin would carry one into its documents (axiom 6); asking the registry costs nothing. An app
|
|
19
|
+
* that DID declare one has already loaded the package, which is when `screens()` below imports it.
|
|
20
|
+
*/
|
|
21
|
+
const ADMIN_MOUNTS: symbol = Symbol.for('ultimate.admin.mounts');
|
|
22
|
+
|
|
23
|
+
/** The file a mounted screen is attributed to — a package, because no app file declares it. */
|
|
24
|
+
export const ADMIN_MOUNT_FILE = '@ultimat3/admin';
|
|
25
|
+
|
|
26
|
+
const declared = (): ReadonlyMap<string, AdminApp> => {
|
|
27
|
+
const held = (globalThis as { [key: symbol]: unknown })[ADMIN_MOUNTS];
|
|
28
|
+
return held instanceof Map ? (held as ReadonlyMap<string, AdminApp>) : new Map();
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export interface AdminMountOptions extends Pick<DocumentOptions, 'themeHead' | 'origin'> {
|
|
32
|
+
readonly buildId: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The route table's own entry shape, for a screen nobody registered there: the document builder
|
|
37
|
+
* reads a `RouteEntry`, and an admin screen is a route — `ssr`, gated, `hydrate: 'never'` — whose
|
|
38
|
+
* component is the body the screen just answered with.
|
|
39
|
+
*/
|
|
40
|
+
const entryFor = (route: AdminRouteConfig, body: unknown): RouteEntry => ({
|
|
41
|
+
file: ADMIN_MOUNT_FILE,
|
|
42
|
+
path: route.path,
|
|
43
|
+
// An admin screen is behind sign-in: it carries the `app/` surface's stylesheet, never `site/`'s.
|
|
44
|
+
surface: 'app',
|
|
45
|
+
config: route.config,
|
|
46
|
+
suspenseBoundaries: 0,
|
|
47
|
+
islands: [],
|
|
48
|
+
pattern: compilePattern(route.path),
|
|
49
|
+
component: () => body,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `auth: 'required'` is the pipeline's half — nobody anonymous reaches a screen, and a sign-in
|
|
54
|
+
* redirect is the app's own. `enforcedBy: 'handler'` is the other half: the screen holds the row a
|
|
55
|
+
* row-level rule reads, so deciding in the `authz` stage too would be a second authz system that
|
|
56
|
+
* knows strictly less and answers first.
|
|
57
|
+
*/
|
|
58
|
+
const metaFor = (base: string): RouteMeta => ({
|
|
59
|
+
name: `admin:${base}`,
|
|
60
|
+
auth: 'required',
|
|
61
|
+
policy: 'admin:read',
|
|
62
|
+
enforcedBy: 'handler',
|
|
63
|
+
render: 'ssr',
|
|
64
|
+
tags: ['admin'],
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
async function answer(
|
|
68
|
+
base: string,
|
|
69
|
+
method: 'GET' | 'POST',
|
|
70
|
+
options: AdminMountOptions,
|
|
71
|
+
request: Parameters<Route['handler']>[0],
|
|
72
|
+
context: Parameters<Route['handler']>[1],
|
|
73
|
+
): Promise<Response> {
|
|
74
|
+
// Read per request, never captured: `x dev` re-evaluates the module that declares the admin
|
|
75
|
+
// when it changes, and the admin a request meets is the one the author just saved.
|
|
76
|
+
const app = declared().get(base);
|
|
77
|
+
// Dynamic, and only here: reached only when an admin is mounted, by which point the app has
|
|
78
|
+
// already loaded the package — and after `loadApp` installed the `.tsx` loader its screens need.
|
|
79
|
+
const { adminRouteMatch } = await import('@ultimat3/admin');
|
|
80
|
+
const matched = app === undefined ? null : adminRouteMatch(app, request.url.pathname);
|
|
81
|
+
if (app === undefined || matched === null) throw routeNotFound(method, request.url.pathname);
|
|
82
|
+
|
|
83
|
+
const form = method === 'POST' ? await request.bodyRaw() : null;
|
|
84
|
+
const response = await matched.route.respond({
|
|
85
|
+
ctx: await app.requestCtx(request.raw),
|
|
86
|
+
params: matched.params,
|
|
87
|
+
url: request.url.href,
|
|
88
|
+
method,
|
|
89
|
+
// A POST with no body is a form with no fields — a button-only form posts exactly that.
|
|
90
|
+
form: method === 'POST' ? (isRecord(form) ? form : {}) : null,
|
|
91
|
+
});
|
|
92
|
+
if (response.kind === 'redirect') {
|
|
93
|
+
// 303, always: the browser must GET the page it lands on, not re-POST the form at it.
|
|
94
|
+
const moved = redirect(response.location, 303);
|
|
95
|
+
moved.headers.set('cache-control', 'private, no-store');
|
|
96
|
+
return moved;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const entry = entryFor(matched.route, response.body);
|
|
100
|
+
const data = { url: request.url.href, params: matched.params };
|
|
101
|
+
const result = await renderSsr(
|
|
102
|
+
{ entry, params: matched.params, url: request.url, ctx: asCtx(context) },
|
|
103
|
+
() =>
|
|
104
|
+
routeDocument(entry, data, {
|
|
105
|
+
...(options.themeHead === undefined ? {} : { themeHead: options.themeHead }),
|
|
106
|
+
...(options.origin === undefined ? {} : { origin: options.origin }),
|
|
107
|
+
}),
|
|
108
|
+
{ buildId: options.buildId, status: response.status },
|
|
109
|
+
);
|
|
110
|
+
// `renderSsr` answers the string the render function returned; a stream is `stream`'s shape.
|
|
111
|
+
const document = typeof result.body === 'string' ? result.body : '';
|
|
112
|
+
return html(document, { status: result.status, headers: result.headers });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
|
116
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
117
|
+
|
|
118
|
+
const METHODS: readonly ('GET' | 'POST')[] = ['GET', 'POST'];
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Two paths per admin and two methods on each: the base path itself (the dashboard) and one
|
|
122
|
+
* catch-all under it. The admin's own table is matched INSIDE the handler, against the table as
|
|
123
|
+
* it is now — so a page added in a save is served without this list being rebuilt, and a URL the
|
|
124
|
+
* admin does not declare is the framework's ordinary 404.
|
|
125
|
+
*
|
|
126
|
+
* Empty for an app that declared no admin: nothing is mounted, and nothing is imported.
|
|
127
|
+
*/
|
|
128
|
+
export function adminMountRoutes(options: AdminMountOptions): readonly Route[] {
|
|
129
|
+
return [...declared().keys()].flatMap((base) =>
|
|
130
|
+
[base, `${base}/*rest`].flatMap((path) =>
|
|
131
|
+
METHODS.map(
|
|
132
|
+
(method): Route => ({
|
|
133
|
+
method,
|
|
134
|
+
path,
|
|
135
|
+
meta: metaFor(base),
|
|
136
|
+
handler: (request, context) => answer(base, method, options, request, context),
|
|
137
|
+
}),
|
|
138
|
+
),
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
}
|