@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.
- package/package.json +1016 -0
- package/src/agreement-gate.test.ts +118 -0
- package/src/agreement-gate.ts +887 -0
- package/src/asset-cache-key.ts +59 -0
- package/src/asset-optimize.ts +93 -0
- package/src/asset-proxy.ts +148 -0
- package/src/asset-social-card.ts +58 -0
- package/src/attach-agreement-magic.ts +62 -0
- package/src/attach-portal-members.ts +107 -0
- package/src/attach-proposal-accept.ts +123 -0
- package/src/attach-proposal-magic.ts +55 -0
- package/src/attach-transfers-upload.ts +100 -0
- package/src/auth-gate.ts +385 -0
- package/src/bind-page-handler.ts +223 -0
- package/src/build-client-assets/input-hashing.ts +206 -0
- package/src/build-client-assets/source-candidates.ts +115 -0
- package/src/build-client-assets.test.ts +216 -0
- package/src/build-client-assets.ts +962 -0
- package/src/compose-page-title.test.ts +88 -0
- package/src/compose-page-title.ts +73 -0
- package/src/content-gate/access.ts +89 -0
- package/src/content-gate/config-types.ts +108 -0
- package/src/content-gate/gate.ts +74 -0
- package/src/content-gate/parse-cookie.ts +23 -0
- package/src/content-gate/token.test.ts +70 -0
- package/src/content-gate/token.ts +148 -0
- package/src/content-page-floor.test.ts +127 -0
- package/src/content-page-floor.ts +70 -0
- package/src/discover-blocks.ts +228 -0
- package/src/expand-roster.ts +197 -0
- package/src/fn-routers/agreement-completion-email.ts +115 -0
- package/src/fn-routers/agreement-send-router.ts +303 -0
- package/src/fn-routers/ai-health-router.ts +64 -0
- package/src/fn-routers/auth-router.ts +131 -0
- package/src/fn-routers/cache-admin-router.ts +50 -0
- package/src/fn-routers/chat-session-router.ts +455 -0
- package/src/fn-routers/email-defaults.ts +22 -0
- package/src/fn-routers/email-link-ttl.ts +23 -0
- package/src/fn-routers/index.ts +70 -0
- package/src/fn-routers/panel-data-router.ts +93 -0
- package/src/fn-routers/persist-chat-messages.test.ts +86 -0
- package/src/fn-routers/persist-chat-messages.ts +96 -0
- package/src/fn-routers/plans-router.ts +141 -0
- package/src/fn-routers/proposal-send-router.ts +207 -0
- package/src/fn-routers/public-agreement-router.ts +865 -0
- package/src/fn-routers/public-assessment-router.ts +421 -0
- package/src/fn-routers/public-gpt-session-router.ts +373 -0
- package/src/fn-routers/sign-in-flow-router.ts +196 -0
- package/src/fn-routers/task-sync-bind.test.ts +105 -0
- package/src/fn-routers/task-sync-classify.test.ts +109 -0
- package/src/fn-routers/task-sync-dispatch.test.ts +44 -0
- package/src/fn-routers/task-sync-dispatch.ts +124 -0
- package/src/fn-routers/task-sync-router.test.ts +279 -0
- package/src/fn-routers/task-sync-router.ts +1036 -0
- package/src/fn-routers/tasks-router.ts +77 -0
- package/src/fn-routers/user-preferences-router.ts +139 -0
- package/src/fn-routers/work-items-router.ts +341 -0
- package/src/gate-ttls.ts +56 -0
- package/src/gpt-gate.ts +76 -0
- package/src/handlers/bun-stable-watch.ts +89 -0
- package/src/handlers/projects-digest.ts +198 -0
- package/src/handlers/prune-stale-data.ts +110 -0
- package/src/handlers/registry-stale-inactive-sweep.ts +82 -0
- package/src/handlers/scala-tools-currency-watch.test.ts +60 -0
- package/src/handlers/scala-tools-currency-watch.ts +171 -0
- package/src/handlers/scheduled-browser-tests.ts +85 -0
- package/src/handlers/sweep-event-log.ts +98 -0
- package/src/handlers/sweep-host-cache.ts +211 -0
- package/src/handlers/upstream-issue-sync.ts +210 -0
- package/src/handlers/whatsapp-liveness-handler.test.ts +59 -0
- package/src/handlers/whatsapp-liveness-logic.ts +53 -0
- package/src/handlers/whatsapp-liveness.test.ts +78 -0
- package/src/handlers/whatsapp-liveness.ts +125 -0
- package/src/hono-error-handler.ts +55 -0
- package/src/install-harness.ts +80 -0
- package/src/install-logging.ts +182 -0
- package/src/load-service-install-row.ts +98 -0
- package/src/load-service-prisma-client.ts +53 -0
- package/src/mount-orpc-router.ts +148 -0
- package/src/mount-server-packages.ts +155 -0
- package/src/mount.ts +18 -0
- package/src/mutable-app-ref.ts +68 -0
- package/src/page-route-shell-types.ts +16 -0
- package/src/page-server-orpc/audit-revert-router.ts +102 -0
- package/src/page-server-orpc/hub-mcp-client.ts +20 -0
- package/src/page-server-orpc/portal-diagnostics-router.ts +170 -0
- package/src/page-server-orpc/puck-pages-router.ts +272 -0
- package/src/page-server-orpc/server-client.ts +31 -0
- package/src/page-server-orpc/templates-router.ts +116 -0
- package/src/page-server-orpc/user-preferences-router.ts +132 -0
- package/src/pg-boss-handler-registry.ts +36 -0
- package/src/pg-boss-mount.ts +295 -0
- package/src/proposal-authorized-emails.ts +65 -0
- package/src/proposal-chat-inject.ts +141 -0
- package/src/proposal-gate.ts +175 -0
- package/src/read-html-template.test.ts +74 -0
- package/src/read-html-template.ts +102 -0
- package/src/read-viewport-from-request.ts +36 -0
- package/src/readiness-gate.ts +39 -0
- package/src/register-api-routes.ts +127 -0
- package/src/register-crash-handlers.ts +39 -0
- package/src/register-page-routes.ts +62 -0
- package/src/register-page-server-client.ts +109 -0
- package/src/register-registry-page-route/ai-chat-prefetch.ts +106 -0
- package/src/register-registry-page-route/graph-cache.test.ts +84 -0
- package/src/register-registry-page-route/graph-cache.ts +90 -0
- package/src/register-registry-page-route/meta-title.ts +73 -0
- package/src/register-registry-page-route/proposal-approval-email-token.ts +78 -0
- package/src/register-registry-page-route/proposal-brand-stamping.ts +60 -0
- package/src/register-registry-page-route/resolve-page-data.ts +518 -0
- package/src/register-registry-page-route.ts +765 -0
- package/src/register-static-assets.ts +180 -0
- package/src/register-ws-routes.ts +180 -0
- package/src/resolve-app-base-url.ts +28 -0
- package/src/resolve-brand-favicon.ts +65 -0
- package/src/resolve-from-loader-config.test.ts +44 -0
- package/src/resolve-from-loader-config.ts +291 -0
- package/src/resolve-service-context.ts +67 -0
- package/src/resolve-theme-mode.ts +61 -0
- package/src/resolvers/fetch-org-brand.ts +28 -0
- package/src/resolvers/portal-route-paths.test.ts +102 -0
- package/src/resolvers/portal-route-paths.ts +83 -0
- package/src/resolvers/website-by-host-map.ts +49 -0
- package/src/resource-chat-inject.ts +168 -0
- package/src/resource-not-found.ts +263 -0
- package/src/run-boot-hook-list.ts +75 -0
- package/src/run-boot-hooks.ts +97 -0
- package/src/scala-tools-currency-watchdog.ts +160 -0
- package/src/security-headers.ts +59 -0
- package/src/serve/serve-ws.test.ts +135 -0
- package/src/serve/serve.ts +190 -0
- package/src/server-error-page.test.ts +102 -0
- package/src/server-error-page.ts +95 -0
- package/src/start-scheduled-jobs.ts +84 -0
- package/src/two-phase-boot.ts +449 -0
- package/src/types.ts +52 -0
- package/src/ui-service.ts +171 -0
- 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 <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";
|