@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,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-handlers
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Routes every service exposes regardless of registry config: /health
|
|
7
|
+
* (liveness probe), /favicon.svg (default brand mark), /api/assets/*
|
|
8
|
+
* (S3 asset proxy), and /assets/* (client bundle + stylesheet produced
|
|
9
|
+
* by build-client-assets.ts into dist/client/, served identically in
|
|
10
|
+
* dev and prod per scala-os/ui.md `bun-build-only`).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import type { Hono } from "hono";
|
|
15
|
+
import { etag } from "hono/etag";
|
|
16
|
+
import { buildErrorReport } from "@teamscala/os/errors/error-report";
|
|
17
|
+
import { handleAssetProxy } from "./asset-proxy";
|
|
18
|
+
import { resolveBrandFaviconPath, warmBrandFaviconCache } from "./resolve-brand-favicon";
|
|
19
|
+
|
|
20
|
+
const FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#6366f1"/><text x="16" y="23" text-anchor="middle" font-size="20" font-family="system-ui" fill="white">S</text></svg>`;
|
|
21
|
+
|
|
22
|
+
export async function registerStaticAssets(
|
|
23
|
+
app: Hono,
|
|
24
|
+
serviceSlug: string,
|
|
25
|
+
brandOrg?: string,
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
// Warm the brand-favicon cache at boot — the S3 existence probe is a 6-21s
|
|
28
|
+
// cold call that otherwise stalls the first /favicon.ico after every
|
|
29
|
+
// restart. Fire-and-forget; never blocks boot or a request.
|
|
30
|
+
warmBrandFaviconCache(brandOrg);
|
|
31
|
+
|
|
32
|
+
app.get("/health", (c) =>
|
|
33
|
+
c.json({
|
|
34
|
+
status: "ok",
|
|
35
|
+
service: serviceSlug,
|
|
36
|
+
timestamp: new Date().toISOString(),
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// Localhost-gated heap-attribution probe — restores the object-composition
|
|
41
|
+
// data the removed /_healthz surface exposed: the heapSize-vs-RSS split
|
|
42
|
+
// (JS heap vs native — OTel/Prisma-engine/buffers/compiled code) plus the
|
|
43
|
+
// top object types by count. Read-only, no secrets; gated to a localhost
|
|
44
|
+
// Host so it never answers over the public domain. Registered here (before
|
|
45
|
+
// auth + page routes) so it is reachable on every service kind, like
|
|
46
|
+
// /health (`verify-via-127.0.0.1`, sibling of `runtime-metrics-via-railway-cli`).
|
|
47
|
+
app.get("/_heap", async (c) => {
|
|
48
|
+
const host = c.req.header("host") ?? "";
|
|
49
|
+
const isLocal =
|
|
50
|
+
host.startsWith("127.0.0.1") || host.startsWith("localhost") || host.startsWith("[::1]");
|
|
51
|
+
if (!isLocal) return c.json({ error: "localhost only" }, 403);
|
|
52
|
+
try {
|
|
53
|
+
const { heapStats } = await import("bun:jsc");
|
|
54
|
+
const s = heapStats() as unknown as {
|
|
55
|
+
heapSize: number;
|
|
56
|
+
heapCapacity: number;
|
|
57
|
+
objectCount: number;
|
|
58
|
+
objectTypeCounts?: Record<string, number>;
|
|
59
|
+
};
|
|
60
|
+
const rss = process.memoryUsage.rss();
|
|
61
|
+
const topObjectTypes = Object.fromEntries(
|
|
62
|
+
Object.entries(s.objectTypeCounts ?? {})
|
|
63
|
+
.sort((a, b) => b[1] - a[1])
|
|
64
|
+
.slice(0, 40),
|
|
65
|
+
);
|
|
66
|
+
return c.json({
|
|
67
|
+
service: serviceSlug,
|
|
68
|
+
rss,
|
|
69
|
+
heapSize: s.heapSize,
|
|
70
|
+
heapCapacity: s.heapCapacity,
|
|
71
|
+
objectCount: s.objectCount,
|
|
72
|
+
nativeApprox: Math.max(0, rss - s.heapSize),
|
|
73
|
+
topObjectTypes,
|
|
74
|
+
});
|
|
75
|
+
} catch (err) {
|
|
76
|
+
const report = buildErrorReport(err, { service: serviceSlug, url: c.req.url });
|
|
77
|
+
return new Response(JSON.stringify(report.http.body), {
|
|
78
|
+
status: report.http.status,
|
|
79
|
+
headers: report.http.headers,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Build-id: the content hash of the CURRENT client bundle, read FRESH from
|
|
85
|
+
// the asset manifest on every call (never a process-pinned copy) so a
|
|
86
|
+
// post-restart rebuild reports its new id immediately. The client self-heal
|
|
87
|
+
// (page-client/boot/build-self-heal) polls this on tab refocus; if it differs
|
|
88
|
+
// from the build the tab booted with, a new build shipped and the open tab is
|
|
89
|
+
// stale → it reloads. no-store HTML + immutable hashed assets already prevent
|
|
90
|
+
// staleness on every FRESH load; this is what closes the one remaining hole —
|
|
91
|
+
// a tab left OPEN across a deploy, holding old HTML pinned to old asset
|
|
92
|
+
// hashes, with nothing telling it to re-fetch (the "my tablet still shows the
|
|
93
|
+
// old broken page" class). The id derives from hydratePath exactly as the
|
|
94
|
+
// SSR-stamped window.__SCALA_BUILD__ does (render-page-html), so they can
|
|
95
|
+
// never structurally disagree and trigger a reload loop.
|
|
96
|
+
app.get("/api/build-id", async (c) => {
|
|
97
|
+
const path = join(process.cwd(), "dist", "client", "asset-manifest.json");
|
|
98
|
+
let build = "dev";
|
|
99
|
+
try {
|
|
100
|
+
const raw = await Bun.file(path).text();
|
|
101
|
+
const manifest = JSON.parse(raw) as { hydratePath?: string };
|
|
102
|
+
build = /hydrate-([a-z0-9]+)\.js/.exec(manifest.hydratePath ?? "")?.[1] ?? "dev";
|
|
103
|
+
} catch {}
|
|
104
|
+
return c.json({ build }, 200, { "cache-control": "no-store" });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// A branded host (service_install.config.brand_org) never serves the
|
|
108
|
+
// generic platform mark — both favicon paths resolve the owning org's
|
|
109
|
+
// icon from its asset store; the generic SVG remains only for hosts
|
|
110
|
+
// with no declared owner (multi-tenant / infra).
|
|
111
|
+
app.get("/favicon.svg", async (_c) => {
|
|
112
|
+
const brand = await resolveBrandFaviconPath(brandOrg);
|
|
113
|
+
if (brand)
|
|
114
|
+
return new Response(null, {
|
|
115
|
+
status: 302,
|
|
116
|
+
headers: { location: brand, "cache-control": "public, max-age=300" },
|
|
117
|
+
});
|
|
118
|
+
return new Response(FAVICON_SVG, {
|
|
119
|
+
headers: {
|
|
120
|
+
"content-type": "image/svg+xml",
|
|
121
|
+
"cache-control": "public, max-age=3600",
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
app.get("/favicon.ico", async (c) => {
|
|
127
|
+
const brand = await resolveBrandFaviconPath(brandOrg);
|
|
128
|
+
// 302 (not 301): the brand asset is row-driven and must stay
|
|
129
|
+
// re-resolvable — a permanent redirect would pin stale targets in
|
|
130
|
+
// browser caches, the exact failure this route exists to end.
|
|
131
|
+
// cache-control lets the browser cache the redirect (5min) so it
|
|
132
|
+
// doesn't re-hit the origin per page load — mirrors /favicon.svg.
|
|
133
|
+
c.header("cache-control", "public, max-age=300");
|
|
134
|
+
return c.redirect(brand ?? "/favicon.svg", 302);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Same-origin S3 asset proxy: /api/assets/<key> → bucket. Public,
|
|
138
|
+
// immutable cache, no auth. Canonical URL form for every logo, favicon,
|
|
139
|
+
// avatar, uploaded asset — emitted by toAssetPath() in @teamscala/core-ui/theme.
|
|
140
|
+
app.get("/api/assets/*", (c) => handleAssetProxy(c.req.raw));
|
|
141
|
+
|
|
142
|
+
app.use("/assets/*", etag());
|
|
143
|
+
|
|
144
|
+
app.get("/assets/*", async (c) => {
|
|
145
|
+
const distPath = join(
|
|
146
|
+
process.cwd(),
|
|
147
|
+
"dist",
|
|
148
|
+
"client",
|
|
149
|
+
c.req.path.replace(/^\/assets\//, ""),
|
|
150
|
+
);
|
|
151
|
+
const file = Bun.file(distPath);
|
|
152
|
+
if (await file.exists()) {
|
|
153
|
+
return new Response(file, {
|
|
154
|
+
headers: { "cache-control": "public, max-age=31536000, immutable" },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return c.notFound();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Extension auto-update files (.crx + updates.xml) served from ./ext/.
|
|
161
|
+
// Chrome's extension updater fetches updates.xml over plain HTTPS with no
|
|
162
|
+
// auth, so this route is registered before any auth gate and on every UI
|
|
163
|
+
// service. Files are served with short-lived cache so updates propagate
|
|
164
|
+
// promptly. The ./ext/ directory is gitignored and populated by the
|
|
165
|
+
// extension build pipeline.
|
|
166
|
+
app.get("/ext/*", async (c) => {
|
|
167
|
+
const filePath = join(
|
|
168
|
+
process.cwd(),
|
|
169
|
+
"ext",
|
|
170
|
+
c.req.path.replace(/^\/ext\//, ""),
|
|
171
|
+
);
|
|
172
|
+
const file = Bun.file(filePath);
|
|
173
|
+
if (await file.exists()) {
|
|
174
|
+
return new Response(file, {
|
|
175
|
+
headers: { "cache-control": "public, max-age=3600" },
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return c.notFound();
|
|
179
|
+
});
|
|
180
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system server-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Registers each `service_install.websocket_routes[]` entry as a Hono WS route.
|
|
7
|
+
* The ORPC WS (`/api/rpc/ws`) is mounted by the canonical primitive
|
|
8
|
+
* `mountOrpcWs` (@teamscala/orpc/ws/server) over the FULLY-COMPOSED app router —
|
|
9
|
+
* this runs AFTER registerFnRouters, so the eager RPCHandler snapshots every
|
|
10
|
+
* namespace, and any `output: eventIterator(...)` procedure dispatches (rule
|
|
11
|
+
* `orpc-ws-via-mount-orpc-ws`). It is NOT a server_package WsRouteHandler — it
|
|
12
|
+
* is exempt from the legacy invariant below.
|
|
13
|
+
*
|
|
14
|
+
* The legacy `WsRouteHandler` interface (open/message/close on a Bun
|
|
15
|
+
* ServerWebSocket) is preserved by an adapter for already-migrated
|
|
16
|
+
* server_packages (terminal-ws); new WS handlers should use Hono's WSEvents
|
|
17
|
+
* directly.
|
|
18
|
+
*
|
|
19
|
+
* INVARIANT (legacy paths only): every handler registered via
|
|
20
|
+
* mountCtx.registerWsRoute MUST have a matching entry in config.websocket_routes
|
|
21
|
+
* (minus /api/rpc/ws), and vice versa. A mismatch is fatal — the service cannot
|
|
22
|
+
* boot with handlers declared but never wired (silent 404 on upgrade), or routes
|
|
23
|
+
* that point at handlers no server_package registered.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { getAppRouter } from "@teamscala/orpc/app-router";
|
|
27
|
+
import { mountOrpcWs } from "@teamscala/orpc/ws/server";
|
|
28
|
+
import type { ServiceInstallConfig } from "@teamscala/db/registry/loader/load-service-install-row";
|
|
29
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
30
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
31
|
+
import { BootFatalError } from "@teamscala/os/errors/boot-error";
|
|
32
|
+
import type {
|
|
33
|
+
MountRegistry,
|
|
34
|
+
WsRouteHandler,
|
|
35
|
+
} from "@teamscala/os/runtime-contracts/mount-context";
|
|
36
|
+
import type { Context, Hono } from "hono";
|
|
37
|
+
import * as v from "valibot";
|
|
38
|
+
import { upgradeWebSocket } from "./serve/serve.ts";
|
|
39
|
+
|
|
40
|
+
const ORPC_WS_PATH = "/api/rpc/ws";
|
|
41
|
+
|
|
42
|
+
export async function registerWsRoutes(
|
|
43
|
+
app: Hono,
|
|
44
|
+
config: ServiceInstallConfig,
|
|
45
|
+
mountRegistry: MountRegistry,
|
|
46
|
+
serviceSlug: string,
|
|
47
|
+
): Promise<number> {
|
|
48
|
+
const log = getAppLogger();
|
|
49
|
+
const allDeclared = new Set(
|
|
50
|
+
(config.websocket_routes ?? []).map((r) => (r as { path: string }).path),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// ORPC WS: mounted by mountOrpcWs over the app router. Not a legacy
|
|
54
|
+
// server_package handler — exempt from the invariant + the count below.
|
|
55
|
+
if (allDeclared.has(ORPC_WS_PATH)) {
|
|
56
|
+
const router = await getAppRouter();
|
|
57
|
+
// WS keepalive cadence is an operational tunable sourced from the
|
|
58
|
+
// registry (configured-primitives + operator ruling "move stuff out of
|
|
59
|
+
// code") — changing it is a registry_edit on config/orpc-ws, never a
|
|
60
|
+
// republish of @teamscala/orpc. Missing/empty row falls back to the
|
|
61
|
+
// primitive's default interval.
|
|
62
|
+
const wsCfg = await loadOrpcWsConfig(log, serviceSlug);
|
|
63
|
+
// Casts: mountOrpcWs's hand-rolled UpgradeWebSocket/Router types can't
|
|
64
|
+
// exactly mirror the injected hono upgradeWebSocket + getAppRouter's
|
|
65
|
+
// AppRouter at this configured-primitive boundary (mirroring would need
|
|
66
|
+
// importing hono/bun, unpairing createBunWebSocket). orpc type cleanup is
|
|
67
|
+
// a follow-up; the runtime shapes are correct.
|
|
68
|
+
mountOrpcWs(app, router as never, upgradeWebSocket as never, {
|
|
69
|
+
path: ORPC_WS_PATH,
|
|
70
|
+
pingIntervalMs: wsCfg.pingIntervalMs,
|
|
71
|
+
});
|
|
72
|
+
log.info(
|
|
73
|
+
`[scala-install:${serviceSlug}] /api/rpc/ws mounted via mountOrpcWs (${Object.keys(router).length} router keys)`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Legacy declared paths (every non-ORPC route): require a server_package
|
|
78
|
+
// WsRouteHandler + wire via the adapter.
|
|
79
|
+
const legacyPaths = [...allDeclared].filter((p) => p !== ORPC_WS_PATH);
|
|
80
|
+
for (const path of legacyPaths) {
|
|
81
|
+
const handler = mountRegistry.wsRoutes.get(path);
|
|
82
|
+
if (!handler) {
|
|
83
|
+
throw new BootFatalError(
|
|
84
|
+
"register-ws-routes",
|
|
85
|
+
serviceSlug,
|
|
86
|
+
`websocket_route "${path}" declared in service_install but no server_package registered a handler for it. ` +
|
|
87
|
+
`Either remove the route declaration or add a server_package that calls ctx.registerWsRoute("${path}", handler).`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
app.get(path, adaptLegacyWsHandler(handler, path));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Unwired: handlers registered by a server_package for a path NOT declared
|
|
94
|
+
// in config.websocket_routes. (/api/rpc/ws is never registered by a
|
|
95
|
+
// server_package — it is mounted by mountOrpcWs above.)
|
|
96
|
+
const unwired = [...mountRegistry.wsRoutes.keys()].filter(
|
|
97
|
+
(p) => !allDeclared.has(p),
|
|
98
|
+
);
|
|
99
|
+
if (unwired.length > 0) {
|
|
100
|
+
throw new BootFatalError(
|
|
101
|
+
"register-ws-routes",
|
|
102
|
+
serviceSlug,
|
|
103
|
+
`server_package(s) registered WS handler(s) for path(s) not declared in config.websocket_routes: [${unwired.map((p) => `"${p}"`).join(", ")}]. ` +
|
|
104
|
+
`These handlers would never receive upgrades. Add the missing path(s) to service_install.websocket_routes or remove the registration.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const count = allDeclared.size;
|
|
109
|
+
log.info(
|
|
110
|
+
`[scala-install:${serviceSlug}] ${count} WS route(s) registered, 0 unwired handler(s)`,
|
|
111
|
+
);
|
|
112
|
+
return count;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function adaptLegacyWsHandler(legacy: WsRouteHandler, pattern: string) {
|
|
116
|
+
return upgradeWebSocket((c: Context) => {
|
|
117
|
+
const params = c.req.param() as Record<string, string>;
|
|
118
|
+
const upgradeReq = c.req.raw;
|
|
119
|
+
let extraData: Record<string, unknown> = {};
|
|
120
|
+
return {
|
|
121
|
+
async onOpen(_evt: Event, wsc: { raw?: unknown }) {
|
|
122
|
+
const ws = wsc.raw as never as { data: Record<string, unknown> };
|
|
123
|
+
if (legacy.upgradeData) {
|
|
124
|
+
extraData = await legacy.upgradeData(upgradeReq, params);
|
|
125
|
+
}
|
|
126
|
+
ws.data = { ...ws.data, route: pattern, params, ...extraData };
|
|
127
|
+
await legacy.open?.(ws as never);
|
|
128
|
+
},
|
|
129
|
+
async onMessage(
|
|
130
|
+
evt: MessageEvent<string | ArrayBuffer>,
|
|
131
|
+
wsc: { raw?: unknown },
|
|
132
|
+
) {
|
|
133
|
+
const ws = wsc.raw as never;
|
|
134
|
+
const msg =
|
|
135
|
+
typeof evt.data === "string"
|
|
136
|
+
? evt.data
|
|
137
|
+
: Buffer.from(evt.data as ArrayBuffer);
|
|
138
|
+
await legacy.message?.(ws, msg);
|
|
139
|
+
},
|
|
140
|
+
async onClose(evt: CloseEvent, wsc: { raw?: unknown }) {
|
|
141
|
+
const ws = wsc.raw as never;
|
|
142
|
+
await legacy.close?.(ws, evt.code, evt.reason);
|
|
143
|
+
},
|
|
144
|
+
} as Parameters<typeof upgradeWebSocket>[0] extends (
|
|
145
|
+
c: infer _Context,
|
|
146
|
+
) => infer R
|
|
147
|
+
? R
|
|
148
|
+
: never;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* WS keepalive tunable, sourced from the `config/orpc-ws` registry row so the
|
|
154
|
+
* cadence is a `registry_edit` — never a republish of `@teamscala/orpc`
|
|
155
|
+
* (`configured-primitives` + operator ruling "move stuff out of code"). Loose
|
|
156
|
+
* schema: an absent `pingIntervalMs` (or a missing row on a fresh install)
|
|
157
|
+
* returns `{}`, and `mountOrpcWs` then falls back to its built-in default
|
|
158
|
+
* interval, so a misconfigured/missing row never fatals the WS mount.
|
|
159
|
+
*/
|
|
160
|
+
const orpcWsConfigSchema = v.looseObject({
|
|
161
|
+
pingIntervalMs: v.optional(v.number()),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
async function loadOrpcWsConfig(
|
|
165
|
+
log: { info(msg: string): void; warn(msg: string): void },
|
|
166
|
+
serviceSlug: string,
|
|
167
|
+
): Promise<{ pingIntervalMs?: number }> {
|
|
168
|
+
try {
|
|
169
|
+
return (await loadRegistryConfig(
|
|
170
|
+
"config",
|
|
171
|
+
"orpc-ws",
|
|
172
|
+
orpcWsConfigSchema,
|
|
173
|
+
)) as { pingIntervalMs?: number };
|
|
174
|
+
} catch (err) {
|
|
175
|
+
log.warn(
|
|
176
|
+
`[scala-install:${serviceSlug}] config/orpc-ws not readable (${err instanceof Error ? err.message : String(err)}); /api/rpc/ws keepalive using the primitive default interval`,
|
|
177
|
+
);
|
|
178
|
+
return {};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The scala-projects app's own public hostname, read from its `service_install`
|
|
7
|
+
* row rather than hardcoded — a service knows its own deploy config only
|
|
8
|
+
* through the registry (registries-are-source-of-truth), never a literal in
|
|
9
|
+
* source. Cached for the process lifetime: this changes only on a redeploy,
|
|
10
|
+
* not per-request. Shared by every server-side path that builds a link back
|
|
11
|
+
* into the app (approval emails, mention emails, digest emails).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { ServiceInstallConfigSchema } from "@teamscala/db-validation/registry-schemas/service-install";
|
|
15
|
+
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
16
|
+
|
|
17
|
+
let cachedAppUrl: string | null = null;
|
|
18
|
+
|
|
19
|
+
export async function resolveAppBaseUrl(): Promise<string> {
|
|
20
|
+
if (cachedAppUrl) return cachedAppUrl;
|
|
21
|
+
try {
|
|
22
|
+
const config = await loadRegistryConfig("service_install", "scala-projects", ServiceInstallConfigSchema);
|
|
23
|
+
cachedAppUrl = `https://${config.public_hostname}`;
|
|
24
|
+
} catch {
|
|
25
|
+
cachedAppUrl = "https://projects.scala.business";
|
|
26
|
+
}
|
|
27
|
+
return cachedAppUrl;
|
|
28
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Host-level brand favicon resolution. A single-brand UI service's host has
|
|
7
|
+
* a KNOWN owner (`service_install.config.brand_org`) — serving the generic
|
|
8
|
+
* platform "S" at the implicit /favicon.ico path there is a defect: browsers
|
|
9
|
+
* and crawlers hit that path directly and their favicon caches are sticky
|
|
10
|
+
* (operator-observed 2026-06-05 on launcha's proposal pages).
|
|
11
|
+
*
|
|
12
|
+
* Resolution is by convention from the org's asset store:
|
|
13
|
+
* `<brand_org>/logos/favicon.png`. Existence is probed once through the
|
|
14
|
+
* in-process asset proxy and cached (positive AND negative) so the favicon
|
|
15
|
+
* route never adds S3 latency per request. No brand_org, or no asset at the
|
|
16
|
+
* convention key → null, and the caller serves the generic fallback.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createCache } from "@teamscala/cache/create-cache";
|
|
20
|
+
import { handleAssetProxy } from "./asset-proxy";
|
|
21
|
+
|
|
22
|
+
// The brand favicon path is a CONVENTION (`<brandOrg>/logos/favicon.png`) —
|
|
23
|
+
// the key never changes for a given host, only the image at that key does
|
|
24
|
+
// (served + cached separately by /api/assets/*). So the resolved PATH is
|
|
25
|
+
// stable for the process lifetime; a long TTL is safe + avoids re-probing S3
|
|
26
|
+
// (a cold stat takes 6-21s — see warmBrandFaviconCache).
|
|
27
|
+
const cache = createCache<string | null>("service-boot:brand-favicon", {
|
|
28
|
+
ttlMs: 30 * 60_000,
|
|
29
|
+
maxSize: 8,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export async function resolveBrandFaviconPath(
|
|
33
|
+
brandOrg: string | undefined,
|
|
34
|
+
): Promise<string | null> {
|
|
35
|
+
if (!brandOrg) return null;
|
|
36
|
+
return cache.fetchOrCompute(brandOrg, async () => {
|
|
37
|
+
const key = `${brandOrg}/logos/favicon.png`;
|
|
38
|
+
const path = `/api/assets/${encodeURIComponent(key)}`;
|
|
39
|
+
try {
|
|
40
|
+
const probe = await handleAssetProxy(
|
|
41
|
+
new Request(`http://favicon.local${path}`),
|
|
42
|
+
);
|
|
43
|
+
return probe.ok ? path : null;
|
|
44
|
+
} catch {
|
|
45
|
+
// Asset store unreachable — degrade to the generic fallback rather
|
|
46
|
+
// than failing the favicon route (block-level fail-open, loud at
|
|
47
|
+
// the proxy's own logging).
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Eagerly resolve + cache the favicon at boot so the FIRST user request never
|
|
55
|
+
* pays the S3 existence-probe latency. The probe (`handleAssetProxy` →
|
|
56
|
+
* `s3.file(key).stat()`) is a COLD S3 call — the connection setup to the asset
|
|
57
|
+
* store takes 6s locally / up to 21s in prod, and the in-process cache resets
|
|
58
|
+
* on every restart (deploys, package-poller). Without warming, the first
|
|
59
|
+
* /favicon.ico after each restart stalls for that duration (operator-reported
|
|
60
|
+
* 23s). Fire-and-forget — runs in the background during boot, never blocks.
|
|
61
|
+
*/
|
|
62
|
+
export function warmBrandFaviconCache(brandOrg: string | undefined): void {
|
|
63
|
+
if (!brandOrg) return;
|
|
64
|
+
void resolveBrandFaviconPath(brandOrg);
|
|
65
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* resolve-from-loader-config.test.ts — locks in the interpolation contract that
|
|
7
|
+
* resolver_fn sources depend on: a bare `:sourceKey` ref passes the WHOLE
|
|
8
|
+
* resolved source object (so a resolver_input like {response: ":response"}
|
|
9
|
+
* reaches the resolver), while a URL param (e.g. :id) still resolves from params.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, test } from "bun:test";
|
|
13
|
+
import { interpolateMeta } from "./resolve-from-loader-config";
|
|
14
|
+
|
|
15
|
+
describe("interpolateMeta (resolver_input ref contract)", () => {
|
|
16
|
+
test("a bare :sourceKey ref passes the whole resolved source object", () => {
|
|
17
|
+
const out = interpolateMeta(
|
|
18
|
+
{ response: ":response", assessment: ":assessment" },
|
|
19
|
+
{
|
|
20
|
+
response: { total_score: 48, category_scores: { "Be Seen": 46 } },
|
|
21
|
+
assessment: { title: "Launcha" },
|
|
22
|
+
},
|
|
23
|
+
);
|
|
24
|
+
// Whole objects flow through — the bug that blanked every report was these
|
|
25
|
+
// resolving to undefined (bare refs read from URL params, not resolved).
|
|
26
|
+
expect(out.response).toEqual({ total_score: 48, category_scores: { "Be Seen": 46 } });
|
|
27
|
+
expect(out.assessment).toEqual({ title: "Launcha" });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("a bare ref with no resolved source key is left as the literal (interpolateMeta fallback)", () => {
|
|
31
|
+
// `:id` has no resolved source named "id" and params is empty here, so it
|
|
32
|
+
// does not resolve — interpolateMeta leaves the literal in place.
|
|
33
|
+
const out = interpolateMeta({ id: ":id" }, {});
|
|
34
|
+
expect(out.id).toBe(":id");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("a dotted ref still reads a field off a resolved source", () => {
|
|
38
|
+
const out = interpolateMeta(
|
|
39
|
+
{ score: ":response.total_score" },
|
|
40
|
+
{ response: { total_score: 48 } },
|
|
41
|
+
);
|
|
42
|
+
expect(out.score).toBe(48);
|
|
43
|
+
});
|
|
44
|
+
});
|