@teamscala/service-boot 0.23.284

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/package.json +1016 -0
  2. package/src/agreement-gate.test.ts +118 -0
  3. package/src/agreement-gate.ts +887 -0
  4. package/src/asset-cache-key.ts +59 -0
  5. package/src/asset-optimize.ts +93 -0
  6. package/src/asset-proxy.ts +148 -0
  7. package/src/asset-social-card.ts +58 -0
  8. package/src/attach-agreement-magic.ts +62 -0
  9. package/src/attach-portal-members.ts +107 -0
  10. package/src/attach-proposal-accept.ts +123 -0
  11. package/src/attach-proposal-magic.ts +55 -0
  12. package/src/attach-transfers-upload.ts +100 -0
  13. package/src/auth-gate.ts +385 -0
  14. package/src/bind-page-handler.ts +223 -0
  15. package/src/build-client-assets/input-hashing.ts +206 -0
  16. package/src/build-client-assets/source-candidates.ts +115 -0
  17. package/src/build-client-assets.test.ts +216 -0
  18. package/src/build-client-assets.ts +962 -0
  19. package/src/compose-page-title.test.ts +88 -0
  20. package/src/compose-page-title.ts +73 -0
  21. package/src/content-gate/access.ts +89 -0
  22. package/src/content-gate/config-types.ts +108 -0
  23. package/src/content-gate/gate.ts +74 -0
  24. package/src/content-gate/parse-cookie.ts +23 -0
  25. package/src/content-gate/token.test.ts +70 -0
  26. package/src/content-gate/token.ts +148 -0
  27. package/src/content-page-floor.test.ts +127 -0
  28. package/src/content-page-floor.ts +70 -0
  29. package/src/discover-blocks.ts +228 -0
  30. package/src/expand-roster.ts +197 -0
  31. package/src/fn-routers/agreement-completion-email.ts +115 -0
  32. package/src/fn-routers/agreement-send-router.ts +303 -0
  33. package/src/fn-routers/ai-health-router.ts +64 -0
  34. package/src/fn-routers/auth-router.ts +131 -0
  35. package/src/fn-routers/cache-admin-router.ts +50 -0
  36. package/src/fn-routers/chat-session-router.ts +455 -0
  37. package/src/fn-routers/email-defaults.ts +22 -0
  38. package/src/fn-routers/email-link-ttl.ts +23 -0
  39. package/src/fn-routers/index.ts +70 -0
  40. package/src/fn-routers/panel-data-router.ts +93 -0
  41. package/src/fn-routers/persist-chat-messages.test.ts +86 -0
  42. package/src/fn-routers/persist-chat-messages.ts +96 -0
  43. package/src/fn-routers/plans-router.ts +141 -0
  44. package/src/fn-routers/proposal-send-router.ts +207 -0
  45. package/src/fn-routers/public-agreement-router.ts +865 -0
  46. package/src/fn-routers/public-assessment-router.ts +421 -0
  47. package/src/fn-routers/public-gpt-session-router.ts +373 -0
  48. package/src/fn-routers/sign-in-flow-router.ts +196 -0
  49. package/src/fn-routers/task-sync-bind.test.ts +105 -0
  50. package/src/fn-routers/task-sync-classify.test.ts +109 -0
  51. package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
  52. package/src/fn-routers/task-sync-dispatch.ts +124 -0
  53. package/src/fn-routers/task-sync-router.test.ts +279 -0
  54. package/src/fn-routers/task-sync-router.ts +1036 -0
  55. package/src/fn-routers/tasks-router.ts +77 -0
  56. package/src/fn-routers/user-preferences-router.ts +139 -0
  57. package/src/fn-routers/work-items-router.ts +341 -0
  58. package/src/gate-ttls.ts +56 -0
  59. package/src/gpt-gate.ts +76 -0
  60. package/src/handlers/bun-stable-watch.ts +89 -0
  61. package/src/handlers/projects-digest.ts +198 -0
  62. package/src/handlers/prune-stale-data.ts +110 -0
  63. package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
  64. package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
  65. package/src/handlers/scala-tools-currency-watch.ts +171 -0
  66. package/src/handlers/scheduled-browser-tests.ts +85 -0
  67. package/src/handlers/sweep-event-log.ts +98 -0
  68. package/src/handlers/sweep-host-cache.ts +211 -0
  69. package/src/handlers/upstream-issue-sync.ts +210 -0
  70. package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
  71. package/src/handlers/whatsapp-liveness-logic.ts +53 -0
  72. package/src/handlers/whatsapp-liveness.test.ts +78 -0
  73. package/src/handlers/whatsapp-liveness.ts +125 -0
  74. package/src/hono-error-handler.ts +55 -0
  75. package/src/install-harness.ts +80 -0
  76. package/src/install-logging.ts +182 -0
  77. package/src/load-service-install-row.ts +98 -0
  78. package/src/load-service-prisma-client.ts +53 -0
  79. package/src/mount-orpc-router.ts +148 -0
  80. package/src/mount-server-packages.ts +155 -0
  81. package/src/mount.ts +18 -0
  82. package/src/mutable-app-ref.ts +68 -0
  83. package/src/page-route-shell-types.ts +16 -0
  84. package/src/page-server-orpc/audit-revert-router.ts +102 -0
  85. package/src/page-server-orpc/hub-mcp-client.ts +20 -0
  86. package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
  87. package/src/page-server-orpc/puck-pages-router.ts +272 -0
  88. package/src/page-server-orpc/server-client.ts +31 -0
  89. package/src/page-server-orpc/templates-router.ts +116 -0
  90. package/src/page-server-orpc/user-preferences-router.ts +132 -0
  91. package/src/pg-boss-handler-registry.ts +36 -0
  92. package/src/pg-boss-mount.ts +295 -0
  93. package/src/proposal-authorized-emails.ts +65 -0
  94. package/src/proposal-chat-inject.ts +141 -0
  95. package/src/proposal-gate.ts +175 -0
  96. package/src/read-html-template.test.ts +74 -0
  97. package/src/read-html-template.ts +102 -0
  98. package/src/read-viewport-from-request.ts +36 -0
  99. package/src/readiness-gate.ts +39 -0
  100. package/src/register-api-routes.ts +127 -0
  101. package/src/register-crash-handlers.ts +39 -0
  102. package/src/register-page-routes.ts +62 -0
  103. package/src/register-page-server-client.ts +109 -0
  104. package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
  105. package/src/register-registry-page-route/graph-cache.test.ts +84 -0
  106. package/src/register-registry-page-route/graph-cache.ts +90 -0
  107. package/src/register-registry-page-route/meta-title.ts +73 -0
  108. package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
  109. package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
  110. package/src/register-registry-page-route/resolve-page-data.ts +518 -0
  111. package/src/register-registry-page-route.ts +765 -0
  112. package/src/register-static-assets.ts +180 -0
  113. package/src/register-ws-routes.ts +180 -0
  114. package/src/resolve-app-base-url.ts +28 -0
  115. package/src/resolve-brand-favicon.ts +65 -0
  116. package/src/resolve-from-loader-config.test.ts +44 -0
  117. package/src/resolve-from-loader-config.ts +291 -0
  118. package/src/resolve-service-context.ts +67 -0
  119. package/src/resolve-theme-mode.ts +61 -0
  120. package/src/resolvers/fetch-org-brand.ts +28 -0
  121. package/src/resolvers/portal-route-paths.test.ts +102 -0
  122. package/src/resolvers/portal-route-paths.ts +83 -0
  123. package/src/resolvers/website-by-host-map.ts +49 -0
  124. package/src/resource-chat-inject.ts +168 -0
  125. package/src/resource-not-found.ts +263 -0
  126. package/src/run-boot-hook-list.ts +75 -0
  127. package/src/run-boot-hooks.ts +97 -0
  128. package/src/scala-tools-currency-watchdog.ts +160 -0
  129. package/src/security-headers.ts +59 -0
  130. package/src/serve/serve-ws.test.ts +135 -0
  131. package/src/serve/serve.ts +190 -0
  132. package/src/server-error-page.test.ts +102 -0
  133. package/src/server-error-page.ts +95 -0
  134. package/src/start-scheduled-jobs.ts +84 -0
  135. package/src/two-phase-boot.ts +449 -0
  136. package/src/types.ts +52 -0
  137. package/src/ui-service.ts +171 -0
  138. package/src/ws-boot-self-test.ts +114 -0
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * The branded HTML error page for a FAILED PAGE RENDER — the page-server
7
+ * render catch's response surface (constitution `error-responses-via-error-
8
+ * report`). When SSR resolve+render fails (render budget exhausted, DEGRADED
9
+ * protective layer, a thrown loader), the browser is mid-navigation: serving
10
+ * a JSON body there is the violation this closes (2026-07-24: the operator
11
+ * saw a raw `{"error":"Request failed"}` blob where the dashboard should be).
12
+ *
13
+ * Classification stays in `buildErrorReport` (`@teamscala/os`) — the ONE
14
+ * classifier; this file is ONLY the HTML shaper for the page-navigation
15
+ * surface (API/ORPC `onError` keeps the JSON body, consumed by code). It
16
+ * renders the report's already-decided status + Retry-After + message, so the
17
+ * screen, the log line, and the GlitchTip event all share one `errorId`.
18
+ *
19
+ * Deliberately NOT a Puck page: it must render even when data loading is what
20
+ * failed. Sibling of `resource-not-found.ts` (the missing-resource floor) —
21
+ * same dark branded template + org-logo resolver, distinct trigger.
22
+ */
23
+
24
+ import { escapeHtml } from "@teamscala/formatters/escapeHtml";
25
+ import { brandLogoImgTag } from "@teamscala/os/brand/brand-logo-img-tag";
26
+ import type { ErrorReport } from "@teamscala/os/errors/error-report";
27
+
28
+ /**
29
+ * Render an `ErrorReport` as a branded HTML page. Pure shaper — the caller
30
+ * classifies via `buildErrorReport` and resolves the logo first. Skew-tolerant
31
+ * by construction: a missing logo degrades to a plain branded dark page.
32
+ */
33
+ export function buildServerErrorPageResponse(
34
+ report: ErrorReport,
35
+ logoUrl?: string | null,
36
+ ): Response {
37
+ const transient = report.transient === true;
38
+ // A transient failure (render timeout, DEGRADED budget, module swap) is
39
+ // recoverable — the screen says "coming back", not "broken". Everything
40
+ // else is a real error: show the message + errorId so the operator can
41
+ // find it in GlitchTip without grepping logs.
42
+ const title = transient ? "Temporarily unavailable" : "Something went wrong";
43
+ const heading = transient ? "This page is taking longer than expected" : "We couldn't load this page";
44
+ const explanation = transient
45
+ ? (report.reason ?? "The service is busy or warming up. It should be available again shortly.")
46
+ : (report.message || "An unexpected error occurred while rendering this page.");
47
+ // Inline data-URI src (resolveErrorPageLogo) + reserved width/height via
48
+ // the shared helper — the logo ships in the initial HTML bytes, no
49
+ // post-paint proxy pop-in under degraded conditions
50
+ // (logos-inline-on-branded-server-pages).
51
+ const logoImg = brandLogoImgTag(logoUrl, { height: 40, width: 160 });
52
+ const retry = transient
53
+ ? `<p style="margin-top:.75rem">It should reload automatically — or <a href="/" style="color:#cfcfd6">try again</a>.</p>`
54
+ : "";
55
+ const idLine = report.errorId
56
+ ? `<p style="margin-top:1.25rem;font-size:.8rem;color:#6b7280">Reference&nbsp;<code style="color:#9aa0ab">${escapeHtml(report.errorId)}</code></p>`
57
+ : "";
58
+ const html = `<!doctype html>
59
+ <html lang="en">
60
+ <head>
61
+ <meta charset="utf-8">
62
+ <meta name="viewport" content="width=device-width, initial-scale=1">
63
+ <meta name="robots" content="noindex">
64
+ <title>${escapeHtml(title)}</title>
65
+ <link rel="icon" href="/favicon.ico">
66
+ <style>
67
+ body { margin: 0; min-height: 100vh; display: flex; align-items: center;
68
+ justify-content: center; background: #0a0e1a; color: #e7e7e7;
69
+ font-family: system-ui, sans-serif; text-align: center; }
70
+ main { padding: 2rem; max-width: 30rem; }
71
+ img { height: 48px; width: auto; margin-bottom: 1.5rem; }
72
+ h1 { font-size: 1.25rem; font-weight: 600; margin: 0 0 .5rem; }
73
+ p { color: #9aa0ab; line-height: 1.5; margin: 0; }
74
+ a:hover { opacity: .9; }
75
+ </style>
76
+ </head>
77
+ <body>
78
+ <main>
79
+ ${logoImg}
80
+ <h1>${escapeHtml(heading)}</h1>
81
+ <p>${escapeHtml(explanation)}</p>
82
+ ${retry}
83
+ ${idLine}
84
+ </main>
85
+ </body>
86
+ </html>`;
87
+ const headers: Record<string, string> = {
88
+ "content-type": "text/html; charset=utf-8",
89
+ "cache-control": "no-store, no-transform",
90
+ };
91
+ if (report.retryAfter !== undefined) {
92
+ headers["retry-after"] = String(report.retryAfter);
93
+ }
94
+ return new Response(html, { status: report.http.status, headers });
95
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @system server-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Starts `service_install.scheduled_jobs[]`. Two kinds:
7
+ *
8
+ * - `long_running`: handler invoked once at boot, runs for the lifetime of
9
+ * the process (e.g. SSE subscribers, WAL watchers). Failures are logged.
10
+ * - `recurring`: declared here but cron scheduling lands via pg-boss in a
11
+ * follow-up — currently a no-op log.
12
+ *
13
+ * Implementations are registered by server_packages via
14
+ * ctx.registerScheduledJob during the mount phase.
15
+ */
16
+
17
+ import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
18
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
19
+ import type { MountRegistry } from "@teamscala/os/runtime-contracts/mount-context";
20
+
21
+ export function startScheduledJobs(
22
+ config: ServiceInstallConfig,
23
+ mountRegistry: MountRegistry,
24
+ serviceSlug: string,
25
+ ): void {
26
+ const log = getAppLogger();
27
+ for (const rawJob of config.scheduled_jobs ?? []) {
28
+ const job = rawJob as {
29
+ name: string;
30
+ kind: string;
31
+ [key: string]: unknown;
32
+ };
33
+ const handler = mountRegistry.scheduledJobs.get(job.name);
34
+ if (!handler) {
35
+ log.warn(
36
+ `[scala-install:${serviceSlug}] scheduled_job "${job.name}" has no registered handler — skipping`,
37
+ );
38
+ continue;
39
+ }
40
+ if (job.kind === "long_running") {
41
+ handler({ name: job.name, attempt: 1 })?.catch?.((err: unknown) => {
42
+ log.error(
43
+ `[scala-install:${serviceSlug}] long_running job "${job.name}" failed: ${err}`,
44
+ );
45
+ });
46
+ log.info(
47
+ `[scala-install:${serviceSlug}] long_running job "${job.name}" started`,
48
+ );
49
+ } else {
50
+ // Recurring handlers manage their own setInterval — invoke once at
51
+ // boot so the timer arms. pg-boss-driven scheduling lands separately
52
+ // for cluster-wide coordination, but fire-and-forget is sufficient
53
+ // for single-node dev and matches how long_running handlers behave.
54
+ handler({ name: job.name, attempt: 1 })?.catch?.((err: unknown) => {
55
+ log.error(
56
+ `[scala-install:${serviceSlug}] recurring job "${job.name}" boot invocation failed: ${err}`,
57
+ );
58
+ });
59
+ log.info(
60
+ `[scala-install:${serviceSlug}] recurring job "${job.name}" started`,
61
+ );
62
+ }
63
+ }
64
+
65
+ // Hollow-shell detection — the symmetric half of the row-without-handler
66
+ // warn above. A handler a server_package registered at mount but the
67
+ // service_install row never declared is silently dead: the package believes
68
+ // its watchdog/subscriber runs, the runtime never invokes it, and nothing
69
+ // says so. A registry edit that rewrites scheduled_jobs drops an entry with
70
+ // no diff and no error, so boot is the only place both sets are known.
71
+ // Twice now a watchdog sat dead this way — the fleet stalled-run watchdog
72
+ // (2026-06-05) and task-list-watchdog, dropped by a row edit on 2026-07-23
73
+ // and unnoticed for two days. Loud at boot so the gap is visible same day.
74
+ const declared = new Set(
75
+ (config.scheduled_jobs ?? []).map((j) => (j as { name: string }).name),
76
+ );
77
+ for (const name of mountRegistry.scheduledJobs.keys()) {
78
+ if (!declared.has(name)) {
79
+ log.error(
80
+ `[scala-install:${serviceSlug}] scheduled job handler "${name}" is registered but NOT declared in service_install.scheduled_jobs — it will NEVER run. Add it to the row or remove the registration.`,
81
+ );
82
+ }
83
+ }
84
+ }
@@ -0,0 +1,449 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Two-phase boot for UI services. Splits the boot process into:
7
+ * 1. Minimal app (tool-only surface): ORPC, data routes, auth gates
8
+ * 2. Full app (with pages and hydration): adds block discovery, page routes,
9
+ * client asset building, boot hooks, scheduled jobs
10
+ *
11
+ * The minimal app starts serving immediately (<10s), then the full app is
12
+ * built in the background and atomically swapped in. This eliminates the
13
+ * ~25s channel outage window during scala-agents restart.
14
+ */
15
+
16
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
17
+ import { buildWakingPageHtml } from "@teamscala/os/brand/waking-page-html";
18
+ import { BootFatalError } from "@teamscala/os/errors/boot-error";
19
+ import { createTimer } from "@teamscala/timing/create-timer";
20
+ import { Hono } from "hono";
21
+ import { compress } from "hono/compress";
22
+ import { cors } from "hono/cors";
23
+ import { timing } from "hono/timing";
24
+ import { attachContentGateAccess } from "./content-gate/access.ts";
25
+ import { initGateTtls } from "./gate-ttls.ts";
26
+ import { agreementAccessConfig } from "./agreement-gate.ts";
27
+ import { proposalAccessConfig } from "./proposal-gate.ts";
28
+ import { gptAccessConfig } from "./gpt-gate.ts";
29
+ import { attachProposalAccept } from "./attach-proposal-accept.ts";
30
+ import { attachAgreementMagic } from "./attach-agreement-magic.ts";
31
+ import { getCookieDomain } from "@teamscala/db/registry/cookie-domain";
32
+ import { attachProposalMagic } from "./attach-proposal-magic.ts";
33
+ import { attachPortalMembers } from "./attach-portal-members.ts";
34
+ import { attachTransfersUpload } from "./attach-transfers-upload.ts";
35
+ import { attachAuthGate } from "./auth-gate.ts";
36
+ import { attachRequestLogger } from "./install-logging.ts";
37
+ import type { ServiceInstallConfig } from "./load-service-install-row.ts";
38
+ import { mountOrpcRouter } from "./mount-orpc-router.ts";
39
+ import { mountServerPackages, preloadServerPackages } from "./mount-server-packages.ts";
40
+ import { mountPgBoss } from "./pg-boss-mount.ts";
41
+ import { registerApiRoutes } from "./register-api-routes.ts";
42
+ import { readinessGate } from "./readiness-gate.ts";
43
+ import { registerStaticAssets } from "./register-static-assets";
44
+ import { registerWsRoutes } from "./register-ws-routes.ts";
45
+ import { createRequestErrorHandler } from "./hono-error-handler.ts";
46
+ import {
47
+ buildResourceNotFoundResponse,
48
+ resolveErrorPageLogo,
49
+ } from "./resource-not-found.ts";
50
+ import { injectSecurityHeaders } from "./security-headers.ts";
51
+ import { startScheduledJobs } from "./start-scheduled-jobs.ts";
52
+ import type { PageServerDeps } from "./ui-service.ts";
53
+ import { registerPageRoutes } from "./register-page-routes.ts";
54
+ import { runBootHookList } from "./run-boot-hook-list.ts";
55
+ import { buildClientAssets } from "./build-client-assets.ts";
56
+ import { discoverBlocks } from "./discover-blocks.ts";
57
+ import { swapApp } from "./mutable-app-ref.ts";
58
+ import type { CreatePageHandler } from "./types.ts";
59
+
60
+ // Module-level storage for the real page handler, updated when Phase 2 completes.
61
+ // This allows the temporary Phase 1 handler to delegate to the real handler once ready.
62
+ let realPageHandler: CreatePageHandler | null = null;
63
+ let fullAppReady = false;
64
+
65
+ /**
66
+ * Set the real page handler (called when Phase 2 completes).
67
+ */
68
+ export function setPageHandler(handler: CreatePageHandler): void {
69
+ realPageHandler = handler;
70
+ fullAppReady = true;
71
+ }
72
+
73
+ /**
74
+ * Get the current page handler state.
75
+ */
76
+ export function getPageHandlerState(): { ready: boolean; hasHandler: boolean } {
77
+ return { ready: fullAppReady, hasHandler: realPageHandler !== null };
78
+ }
79
+
80
+ export interface MinimalAppResult {
81
+ app: Hono;
82
+ mountRegistry: Awaited<ReturnType<typeof mountServerPackages>>;
83
+ apiRouteCount: number;
84
+ wsRouteCount: number;
85
+ pageRouteCount: number;
86
+ config: ServiceInstallConfig;
87
+ serviceSlug: string;
88
+ databaseUrl: string;
89
+ blockPackages: string[];
90
+ components: Record<string, unknown>; // From discoverBlocks
91
+ loaded: string[]; // Discovered block names
92
+ skipped: string[]; // Skipped block names
93
+ }
94
+
95
+ export interface FullAppResult {
96
+ app: Hono;
97
+ blocks: { slug: string; packagePath: string }[];
98
+ routes: string[];
99
+ pgBossJobs: Array<{ handlerSlug: string }>;
100
+ }
101
+
102
+ /**
103
+ * Build the minimal tool-only app. This excludes the expensive operations:
104
+ * - Client asset building (Vite build)
105
+ * - Boot hooks (may have expensive initialization)
106
+ * - Scheduled jobs (can start later)
107
+ * - pg-boss (can start later)
108
+ *
109
+ * CRITICAL: All route registration MUST happen here before serving, because
110
+ * Hono's RegExpRouter compiles on the first request and becomes immutable.
111
+ * Once compiled, no new routes can be added. So we include:
112
+ * - Block discovery and page routes (registers all page paths)
113
+ * - All other routes (API, WS, data, static assets)
114
+ *
115
+ * The minimal app includes everything needed for MCP tools and ORPC:
116
+ * - Error handling, readiness gate, health check
117
+ * - Security headers, CORS, compression, timing
118
+ * - Content gate access, auth gates, static assets
119
+ * - ORPC router, API routes, WS routes, data routes, page routes
120
+ */
121
+ export async function buildMinimalApp(
122
+ config: ServiceInstallConfig,
123
+ serviceSlug: string,
124
+ databaseUrl: string,
125
+ ): Promise<MinimalAppResult> {
126
+ const log = getAppLogger();
127
+ const blockPackages = ((config as { block_packages?: string[] })
128
+ .block_packages ?? []) as string[];
129
+
130
+ const app = new Hono();
131
+
132
+ // Per-step instrumentation inside the boot-ui-service phase. Same
133
+ // createTimer + wall-clock convention as create-boot-pipeline's runPhase
134
+ // (timing-is-the-only-timing, per-phase-instrumentation) — NOT a second
135
+ // telemetry surface. The boot-ui-service phase is 60% of boot; this
136
+ // breakdown localises the cost so optimisation targets the real hotspot
137
+ // instead of guessing. Spans flow to GlitchTip (timing's trace-emitter is
138
+ // wired by the time we reach here); the breakdown line is `pm2 logs`-visible.
139
+ const stepTimings: Record<string, number> = {};
140
+ async function runStep<T>(step: string, fn: () => Promise<T>): Promise<T> {
141
+ const timer = createTimer(`${serviceSlug}:boot.minimal.${step}`);
142
+ const start = Date.now();
143
+ try {
144
+ return await timer.timeAsync(fn);
145
+ } finally {
146
+ stepTimings[step] = Date.now() - start;
147
+ }
148
+ }
149
+
150
+ // Error handlers
151
+ app.onError(createRequestErrorHandler("service-boot:minimal"));
152
+
153
+ app.notFound(async (c) => {
154
+ const accept = c.req.header("accept") ?? "";
155
+ if (/\btext\/html\b/.test(accept)) {
156
+ const logo = await resolveErrorPageLogo(serviceSlug);
157
+ return buildResourceNotFoundResponse("page", logo, 404);
158
+ }
159
+ return c.json({ error: "Not Found" }, 404);
160
+ });
161
+
162
+ // Core middleware
163
+ app.use("*", readinessGate);
164
+ app.get("/health", (c) => c.json({ status: "ok" }));
165
+ app.use("*", injectSecurityHeaders);
166
+ const cookieDomain = await getCookieDomain();
167
+ app.use(
168
+ "*",
169
+ cors({
170
+ origin: (origin) => {
171
+ if (!origin) return "";
172
+ if (origin.endsWith(cookieDomain)) return origin;
173
+ return "";
174
+ },
175
+ allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
176
+ allowHeaders: ["Content-Type", "Authorization"],
177
+ credentials: true,
178
+ }),
179
+ );
180
+ app.use("*", compress());
181
+ app.use("*", timing());
182
+ attachRequestLogger(app, serviceSlug);
183
+
184
+ // Fire independent I/O + compilation NOW, overlapping with the gate chain
185
+ // below. These three touch NO app route and have no ordering dependency on
186
+ // the gate/mount sequence, so they run concurrently with it:
187
+ // - preloadServerPackages: warms Bun's module cache (CPU-bound compilation
188
+ // that overlaps the gate chain's cross-internet DB I/O — same speculative
189
+ // pattern as create-boot-pipeline's preloads). mountServerPackages later
190
+ // hits the cache (instant imports), paying only the ~33ms of mount work.
191
+ // - initGateTtls: loads config rows into module-level vars. The TTL getters
192
+ // are read at REQUEST time (inside route-handler closures), not at boot,
193
+ // so this only needs to finish before serving — safe to run in parallel.
194
+ // - discoverBlocks: loads the page-manifest (no app mutation, no gate
195
+ // dependency). Its result is consumed by registerPageRoutes much later.
196
+ const serverPackages = ((config as { server_packages?: string[] })
197
+ .server_packages ?? []) as string[];
198
+ const preloadPromise = runStep("preload-server-packages", () =>
199
+ preloadServerPackages(serverPackages),
200
+ );
201
+ const ttlPromise = runStep("init-gate-ttls", () => initGateTtls());
202
+ const discoverPromise = runStep("discover-blocks", () =>
203
+ discoverBlocks(blockPackages),
204
+ );
205
+ // Attach an immediate rejection sink: discoverPromise is fired here but
206
+ // awaited much later (after the gate chain + mounts). If discoverBlocks
207
+ // rejects during that overlap window, the rejection would surface as an
208
+ // unhandledRejection before the await below can catch it. The sink is a
209
+ // no-op — the real error still propagates at the `await discoverPromise`
210
+ // site because each consumer of a rejected promise receives the rejection.
211
+ discoverPromise.catch(() => {});
212
+
213
+ // Public access endpoints — MUST be registered before the auth gate (Hono
214
+ // dispatches middleware + handlers in registration order; the content-gate
215
+ // POST routes and magic-link endpoints are public and must be reachable
216
+ // without a session, so they precede the auth gate's app.use("*") wildcard).
217
+ // These are mutually independent — distinct paths, no shared mutable state
218
+ // beyond the Hono app's synchronous route array — so they parallelise.
219
+ await runStep("public-routes", () =>
220
+ Promise.all([
221
+ attachContentGateAccess(app, serviceSlug, agreementAccessConfig),
222
+ attachContentGateAccess(app, serviceSlug, proposalAccessConfig),
223
+ attachContentGateAccess(app, serviceSlug, gptAccessConfig),
224
+ attachProposalAccept(app, serviceSlug),
225
+ attachAgreementMagic(app, serviceSlug),
226
+ attachProposalMagic(app, serviceSlug),
227
+ attachPortalMembers(app),
228
+ attachTransfersUpload(app),
229
+ ]),
230
+ );
231
+
232
+ // Auth gate — registers /api/auth/* + the session-check wildcard middleware.
233
+ // MUST follow the public routes above (load-bearing Hono registration order)
234
+ // and MUST precede mountServerPackages: server-package daemon routes (e.g.
235
+ // cli-session's /rpc/*) are subject to the session gate, which requires the
236
+ // gate's wildcard registered first to apply. Running these concurrently would
237
+ // make that ordering non-deterministic — so the gate stays serial here.
238
+ await runStep("attach-auth-gate", () =>
239
+ attachAuthGate(app, config, serviceSlug),
240
+ );
241
+ registerStaticAssets(app, serviceSlug, config.brand_org);
242
+
243
+ // Server packages — the cold imports were preloaded above and are now in
244
+ // Bun's module cache, so this pays only the mount work (~33ms, not ~2.8s of
245
+ // serial compilation). Route registration happens HERE (after the auth gate),
246
+ // preserving the gate-vs-daemon-route ordering.
247
+ const mountRegistry = await runStep("mount-server-packages", () =>
248
+ mountServerPackages(app, config, serviceSlug),
249
+ );
250
+ // Ensure the speculative preload finished before proceeding (it populates
251
+ // the same cache mountServerPackages just consumed; awaiting now is a no-op
252
+ // if the preload already settled, and makes the ordering guarantee explicit).
253
+ await preloadPromise;
254
+ const apiRouteCount = registerApiRoutes(app, config, mountRegistry, serviceSlug);
255
+
256
+ if (config.orpc_router !== false) {
257
+ await runStep("mount-orpc-router", () => mountOrpcRouter(app, serviceSlug));
258
+ }
259
+
260
+ const wsRouteCount = await runStep("register-ws-routes", () =>
261
+ registerWsRoutes(app, config, mountRegistry, serviceSlug),
262
+ );
263
+
264
+ // Resolve the deferred TTL load (started at the top — should be long done,
265
+ // overlapped with the gate chain + mount work). The getters are read at
266
+ // request time, but the cache must be populated before the first request.
267
+ await ttlPromise;
268
+
269
+ // CRITICAL: Block discovery and page routes MUST be registered BEFORE serving,
270
+ // because Hono's RegExpRouter compiles on first request and becomes immutable.
271
+ // We defer only client asset building (expensive Vite build) to Phase 2.
272
+ let discovered: Awaited<ReturnType<typeof discoverBlocks>>;
273
+ try {
274
+ discovered = await discoverPromise;
275
+ } catch (err) {
276
+ throw new BootFatalError(
277
+ "ui-service.page-manifest",
278
+ serviceSlug,
279
+ `Failed to load src/page-manifest.ts. Run scala-tools codegen for the service before boot. Error: ${err instanceof Error ? err.message : err}`,
280
+ err,
281
+ );
282
+ }
283
+
284
+ const { components, loaded, skipped } = discovered;
285
+ if (loaded.length === 0) {
286
+ throw new BootFatalError(
287
+ "ui-service.page-manifest",
288
+ serviceSlug,
289
+ "Zero Puck components loaded from src/page-manifest.ts. Fix service_install.block_packages or block_schema, regenerate the page manifest, then boot again.",
290
+ );
291
+ }
292
+ if (loaded.length > 0) {
293
+ log.info(
294
+ `[two-phase-boot:${serviceSlug}] Discovered ${loaded.length} block(s): ${loaded.join(", ")}`,
295
+ );
296
+ }
297
+ if (skipped.length > 0) {
298
+ log.warn(
299
+ `[two-phase-boot:${serviceSlug}] Skipped ${skipped.length} block_schema row(s) ` +
300
+ `(missing componentPath/componentExport or import failed): ${skipped.join(", ")}`,
301
+ );
302
+ }
303
+
304
+ // Run boot hooks BEFORE serving - some hooks may register routes and must
305
+ // run before Hono's matcher is compiled by the first request
306
+ await runStep("run-boot-hooks", () =>
307
+ runBootHookList(config, mountRegistry, serviceSlug),
308
+ );
309
+
310
+ // Register page routes with a dynamic handler that delegates to the real handler once Phase 2 completes.
311
+ // All route paths must be registered before serving to avoid Hono
312
+ // RegExpRouter compilation issues (matcher becomes immutable).
313
+ //
314
+ // CRITICAL: During Phase 1 (minimal app), page requests MUST return 503 BEFORE
315
+ // attempting to resolve page data. The config object doesn't have proper loadData
316
+ // set up until Phase 2, and attempting to resolve undefined loadData crashes
317
+ // resolveDataGraph with "undefined is not an object (evaluating 'Object.keys(spec)')".
318
+ const registeredPageRoutes = await runStep("register-page-routes", () =>
319
+ registerPageRoutes({
320
+ app,
321
+ config,
322
+ components,
323
+ serviceSlug,
324
+ createPageHandler: (pageConfig) => {
325
+ // Return a handler that checks if the full app is ready BEFORE processing
326
+ return async (req, extras) => {
327
+ // If full app is NOT ready yet, return the branded waking page
328
+ // (the same pulsating-logo holding page scale-to-zero uses) —
329
+ // never bare plain text (loading-via-logo-loading).
330
+ if (!fullAppReady || !realPageHandler) {
331
+ return new Response(buildWakingPageHtml({}), {
332
+ status: 503,
333
+ headers: {
334
+ "content-type": "text/html; charset=utf-8",
335
+ "cache-control": "no-store, no-transform",
336
+ "retry-after": "5",
337
+ },
338
+ });
339
+ }
340
+ // Full app is ready - delegate to the real handler
341
+ const handler = realPageHandler(pageConfig);
342
+ return handler(req, extras);
343
+ };
344
+ },
345
+ }),
346
+ );
347
+ const pageRouteCount = registeredPageRoutes.length;
348
+
349
+ const steps = Object.entries(stepTimings)
350
+ .map(([step, ms]) => `${step}=${ms}ms`)
351
+ .join(" ");
352
+ // NOTE: several steps run concurrently (speculative preload, init-gate-ttls,
353
+ // discover-blocks overlap the gate chain), so the step sum exceeds the
354
+ // wall-clock buildMinimalApp duration. Compare to the boot-ui-service phase
355
+ // timing in the Boot complete line for the true wall-clock cost.
356
+ log.info(
357
+ `[two-phase-boot:${serviceSlug}] buildMinimalApp breakdown (steps overlap) — ${steps}`,
358
+ );
359
+
360
+ log.info(
361
+ `[two-phase-boot:${serviceSlug}] Minimal app ready: ${apiRouteCount} api route(s), ${wsRouteCount} ws route(s), ${pageRouteCount} page route(s)`,
362
+ );
363
+
364
+ return {
365
+ app,
366
+ mountRegistry,
367
+ apiRouteCount,
368
+ wsRouteCount,
369
+ pageRouteCount,
370
+ config,
371
+ serviceSlug,
372
+ databaseUrl,
373
+ blockPackages,
374
+ components,
375
+ loaded,
376
+ skipped,
377
+ };
378
+ }
379
+
380
+ /**
381
+ * Build the full app with hydration. This adds the expensive operations that
382
+ * were deferred from the minimal app:
383
+ * - Client asset building (Vite build)
384
+ * - Boot hooks (may have expensive initialization)
385
+ * - Scheduled jobs (can start later)
386
+ * - pg-boss (can start later)
387
+ *
388
+ * This runs in the background after the minimal app is already serving.
389
+ * NOTE: All routes (including page routes) are already registered in Phase 1,
390
+ * so we don't need to modify the app here. The app reference stays the same.
391
+ */
392
+ export async function buildFullApp(
393
+ minimalResult: MinimalAppResult,
394
+ deps: PageServerDeps,
395
+ ): Promise<FullAppResult> {
396
+ const log = getAppLogger();
397
+ const { config, serviceSlug, mountRegistry, components, loaded, app: minimalApp } = minimalResult;
398
+
399
+ // Client asset building (expensive: Vite build)
400
+ await buildClientAssets(serviceSlug);
401
+
402
+ // Scheduled jobs
403
+ startScheduledJobs(config, mountRegistry, serviceSlug);
404
+
405
+ // pg-boss (mount for UI services - wasn't mounted in minimal app)
406
+ const { jobs: pgBossJobs } = await mountPgBoss({
407
+ databaseUrl: minimalResult.databaseUrl,
408
+ serviceSlug,
409
+ packages: config.server_packages ?? [],
410
+ });
411
+ if (pgBossJobs.length > 0) {
412
+ log.info(
413
+ `[two-phase-boot:${serviceSlug}] pg-boss mounted ${pgBossJobs.length} job(s): ${pgBossJobs.map((j) => j.handlerSlug).join(", ")}`,
414
+ );
415
+ }
416
+
417
+ log.info(
418
+ `[two-phase-boot:${serviceSlug}] Full app built: ${loaded.length} block(s), ` +
419
+ `${Object.keys(components).length} component(s)`,
420
+ );
421
+
422
+ // Set the real page handler so the dynamic handler in Phase 1 can delegate to it
423
+ setPageHandler(deps.createPageHandler);
424
+ log.info(
425
+ `[two-phase-boot:${serviceSlug}] Real page handler registered - page routes now delegate to full app`,
426
+ );
427
+
428
+ const resolvedBlocks = loaded.map((slug) => ({
429
+ slug,
430
+ packagePath: "block_packages",
431
+ }));
432
+
433
+ return {
434
+ app: minimalApp, // Same app reference, all routes already registered
435
+ blocks: resolvedBlocks,
436
+ routes: [], // Routes already registered in Phase 1
437
+ pgBossJobs,
438
+ };
439
+ }
440
+
441
+ /**
442
+ * Swap the minimal app for the full app. This atomically replaces the
443
+ * app reference that Bun.serve is using.
444
+ */
445
+ export function swapToFullApp(fullApp: Hono, serviceSlug: string): void {
446
+ const oldLabel = swapApp(fullApp, "full");
447
+ const log = getAppLogger();
448
+ log.info(`[two-phase-boot:${serviceSlug}] Swapped from ${oldLabel} app to full app`);
449
+ }
package/src/types.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Shared type definitions for service-boot boot helpers.
7
+ */
8
+
9
+ type RendererComponent = (props: Record<string, unknown>) => unknown;
10
+
11
+ export interface RendererComponentEntry {
12
+ render: RendererComponent;
13
+ fields?: Record<string, { type: string }>;
14
+ /** block_schema.defaults, carried onto the entry by discoverBlocks so
15
+ * buildPuckConfig merges them under authored props at SSR
16
+ * (blocks-fully-programmable). */
17
+ defaultProps?: Record<string, unknown>;
18
+ }
19
+
20
+ export interface PageHandlerExtras {
21
+ htmlTemplate: string;
22
+ userId?: string;
23
+ orgId?: string;
24
+ searchParams?: Record<string, string>;
25
+ nonce?: string;
26
+ }
27
+
28
+ export type CreatePageHandler = (
29
+ config: PageHandlerConfig,
30
+ ) => (request: Request, extras: PageHandlerExtras) => Promise<Response>;
31
+
32
+ export interface PageHandlerConfig {
33
+ components: Record<string, RendererComponentEntry>;
34
+ providers?: string[];
35
+ loadData: (request: Request) => Promise<unknown>;
36
+ renderMode?: "public" | "portals" | "utility";
37
+ /**
38
+ * Owning service slug — used by the page-server render catch to resolve
39
+ * the branded logo on the render-error page. Mirrors service-runtime's
40
+ * PageHandlerConfig.
41
+ */
42
+ serviceSlug?: string;
43
+ /**
44
+ * Redirect a signed-in user's `/` to their org-scoped portal dashboard.
45
+ * Set only for services serving a `/*` catchall page_route (the portal);
46
+ * single-site portal-mode services serve their real page at `/`.
47
+ * Mirrors service-runtime's PageHandlerConfig (1.15.82).
48
+ */
49
+ portalHomeRedirect?: boolean;
50
+ }
51
+
52
+ export type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";