@teamscala/service-boot 0.23.447 → 0.23.449
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
CHANGED
|
@@ -41,13 +41,11 @@ describe("mountOrpcRouter generated-artifact seam", () => {
|
|
|
41
41
|
);
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
-
test("the
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
// callers (sdk-mcp-forwarder) already handle: an absent gateway URL
|
|
50
|
-
// means "SDK tools unusable in this service".
|
|
44
|
+
test("the generic mount no longer injects a gateway URL — the executor derives its host from the registry", async () => {
|
|
45
|
+
// The retired MCP_GATEWAY_URL env name and its injector are gone: the
|
|
46
|
+
// sdk-mcp-forwarder resolves the SDK execution host from the
|
|
47
|
+
// mcp_server/scala-mcp registry row via loadRegistryConfig. A missing
|
|
48
|
+
// row refuses at the boundary (assertSdkHostResolved), never via env.
|
|
51
49
|
const service = await mkdtemp(join(tmpdir(), "mount-orpc-router-test-"));
|
|
52
50
|
tempDirs.push(service);
|
|
53
51
|
await mkdir(join(service, "prisma/generated/orpc/routers"), {
|
|
@@ -64,8 +62,9 @@ describe("mountOrpcRouter generated-artifact seam", () => {
|
|
|
64
62
|
process.chdir(service);
|
|
65
63
|
|
|
66
64
|
// Stub the collaborators the mount reaches BEFORE configureToolExecutor
|
|
67
|
-
// — the point is to capture the opts object and
|
|
68
|
-
|
|
65
|
+
// — the point is to capture the opts object and assert the injector is
|
|
66
|
+
// absent.
|
|
67
|
+
let seenKeys: Array<string> = [];
|
|
69
68
|
mock.module("@teamscala/orpc/app-router", () => ({
|
|
70
69
|
getAppRouter: async () => ({
|
|
71
70
|
healthz: { health: async () => ({ ok: true }) },
|
|
@@ -75,18 +74,14 @@ describe("mountOrpcRouter generated-artifact seam", () => {
|
|
|
75
74
|
getPrisma: () => ({}),
|
|
76
75
|
}));
|
|
77
76
|
mock.module("@teamscala/tool-executor/configure", () => ({
|
|
78
|
-
configure: (opts:
|
|
79
|
-
|
|
77
|
+
configure: (opts: Record<string, unknown>) => {
|
|
78
|
+
seenKeys = Object.keys(opts);
|
|
80
79
|
},
|
|
81
80
|
}));
|
|
82
81
|
|
|
83
82
|
const app = { post: () => {} };
|
|
84
83
|
await mountOrpcRouter(app as never, "scala-mcp");
|
|
85
84
|
|
|
86
|
-
expect(
|
|
87
|
-
// THE regression: invoking the getter must not throw.
|
|
88
|
-
expect(() => capture.getMcpGatewayUrl!()).not.toThrow();
|
|
89
|
-
// And it returns undefined — explicit, never a ReferenceError.
|
|
90
|
-
expect(capture.getMcpGatewayUrl!()).toBeUndefined();
|
|
85
|
+
expect(seenKeys).not.toContain("getMcpGatewayUrl");
|
|
91
86
|
});
|
|
92
87
|
});
|
package/src/mount-orpc-router.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
14
14
|
import { registerFnRouters } from "./fn-routers/index.ts";
|
|
15
|
-
import type { InjectedDb8 } from "@teamscala/
|
|
15
|
+
import type { InjectedDb8 } from "@teamscala/db/db8-registry";
|
|
16
16
|
import type { Hono } from "hono";
|
|
17
17
|
import { bodyLimit } from "hono/body-limit";
|
|
18
18
|
|
|
@@ -86,6 +86,11 @@ export async function mountOrpcRouter(
|
|
|
86
86
|
connectionString: process.env.DATABASE_URL,
|
|
87
87
|
serviceName: serviceSlug,
|
|
88
88
|
})) as never;
|
|
89
|
+
// Register for db-core internals (registry loaders, raw fallbacks)
|
|
90
|
+
// routing v8-first via getDb8() — the same coexistence shape as
|
|
91
|
+
// the RPC context injection above.
|
|
92
|
+
const { registerDb8 } = await import("@teamscala/db/db8-registry");
|
|
93
|
+
registerDb8(db8 as never);
|
|
89
94
|
}
|
|
90
95
|
} catch (err) {
|
|
91
96
|
log.info(
|
|
@@ -122,14 +127,6 @@ export async function mountOrpcRouter(
|
|
|
122
127
|
configureToolExecutor({
|
|
123
128
|
getAppRouter: () => getAppRouter(),
|
|
124
129
|
getPrisma: () => getConfiguredPrisma(),
|
|
125
|
-
// Central MCP gateway URL — sdk:* tool calls from any non-host service
|
|
126
|
-
// forward here (scala-mcp owns every @teamscala/api-* adapter per
|
|
127
|
-
// operator ruling 2026-08-26). Returns undefined explicitly: callers
|
|
128
|
-
// (sdk-mcp-forwarder) treat absent as "SDK tool unusable in this
|
|
129
|
-
// service" and refuse with that message. The previous form read an
|
|
130
|
-
// out-of-scope `ctx` (c39d279) and would have thrown ReferenceError
|
|
131
|
-
// on first invoke instead of returning undefined.
|
|
132
|
-
getMcpGatewayUrl: () => undefined,
|
|
133
130
|
createHostCommandInvoker: (config) => createHostCommandInvoker(config as never),
|
|
134
131
|
getCallerContext: () => getCallerContext(),
|
|
135
132
|
decryptSecretConfig: (raw) => decryptSecretConfig(raw as never),
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
|
|
54
54
|
import { createCache } from "@teamscala/cache/create-cache";
|
|
55
55
|
import { readClientBuildStatus } from "./build-client-assets/manifest.ts";
|
|
56
|
+
import { themeFontsLink } from "./theme-fonts-link.ts";
|
|
56
57
|
import { join } from "node:path";
|
|
57
58
|
|
|
58
59
|
interface AssetManifest {
|
|
@@ -67,7 +68,7 @@ const PAGE_DOCUMENT = `<!doctype html>
|
|
|
67
68
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
|
|
68
69
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
69
70
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
70
|
-
|
|
71
|
+
<!--theme-fonts-->
|
|
71
72
|
<link rel="stylesheet" href="{{stylesPath}}" />
|
|
72
73
|
<!--head-outlet-->
|
|
73
74
|
</head>
|
|
@@ -140,7 +141,11 @@ export async function readHtmlTemplate(): Promise<string> {
|
|
|
140
141
|
}
|
|
141
142
|
let html = PAGE_DOCUMENT
|
|
142
143
|
.replace(/\{\{stylesPath\}\}/g, manifest.stylesPath)
|
|
143
|
-
.replace(/\{\{hydratePath\}\}/g, manifest.hydratePath)
|
|
144
|
+
.replace(/\{\{hydratePath\}\}/g, manifest.hydratePath)
|
|
145
|
+
// The head's font families derive from the css_theme row (one source);
|
|
146
|
+
// captured at cache build — boot-time semantics, a row edit reaches the
|
|
147
|
+
// envelope on the next service restart.
|
|
148
|
+
.replace("<!--theme-fonts-->", () => themeFontsLink());
|
|
144
149
|
// Tombstone stamp — surfaces a failed client build at the served layer.
|
|
145
150
|
// `<meta>` in <head> is the minimum-invasive vehicle (no layout shift, no
|
|
146
151
|
// JS dependency, visible via view-source / devtools / a future status
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* The served <head> carries every font family the css_theme row declares —
|
|
7
|
+
* derived from the row (one source), never a hard-coded family list. The old
|
|
8
|
+
* envelope loaded only Inter, so the proposal heading variant's manuscript
|
|
9
|
+
* serif never loaded and fell back to Times (2026-09-02).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
13
|
+
|
|
14
|
+
type ConfigRow = { fonts?: Record<string, string> } | undefined;
|
|
15
|
+
let injectedConfig: ConfigRow;
|
|
16
|
+
|
|
17
|
+
mock.module("@teamscala/db/registry/accessors", () => ({
|
|
18
|
+
getEntryConfig: (_type: string, _slug: string) => injectedConfig,
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
const { readHtmlTemplate } = await import("./read-html-template.ts");
|
|
22
|
+
const { primaryFontFamily, themeFontFamilies, themeFontsLink } = await import(
|
|
23
|
+
"./theme-fonts-link.ts"
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const { mkdir, mkdtemp, rm } = await import("node:fs/promises");
|
|
27
|
+
const { tmpdir } = await import("node:os");
|
|
28
|
+
const { join } = await import("node:path");
|
|
29
|
+
const { beforeEach, afterEach } = await import("bun:test");
|
|
30
|
+
|
|
31
|
+
let dir: string;
|
|
32
|
+
let origCwd: string;
|
|
33
|
+
|
|
34
|
+
beforeEach(async () => {
|
|
35
|
+
origCwd = process.cwd();
|
|
36
|
+
dir = await mkdtemp(join(tmpdir(), "theme-fonts-link-"));
|
|
37
|
+
await mkdir(join(dir, "dist", "client"), { recursive: true });
|
|
38
|
+
process.chdir(dir);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(async () => {
|
|
42
|
+
process.chdir(origCwd);
|
|
43
|
+
await rm(dir, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
async function writeManifest(): Promise<void> {
|
|
47
|
+
await Bun.file(
|
|
48
|
+
join(dir, "dist", "client", "asset-manifest.json"),
|
|
49
|
+
).write(
|
|
50
|
+
`${JSON.stringify({ stylesPath: "/assets/styles.css", hydratePath: "/assets/hydrate.js" }, null, 2)}\n`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function servedHead(): Promise<string> {
|
|
55
|
+
await writeManifest();
|
|
56
|
+
const html = await readHtmlTemplate();
|
|
57
|
+
return html.slice(0, html.indexOf("</head>"));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("theme-fonts-link", () => {
|
|
61
|
+
test("primaryFontFamily takes the first family of a stack", () => {
|
|
62
|
+
expect(primaryFontFamily("Inter, sans-serif")).toBe("Inter");
|
|
63
|
+
expect(primaryFontFamily("'Playfair Display'")).toBe("Playfair Display");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("families derive from the css_theme row, deduplicated", () => {
|
|
67
|
+
injectedConfig = {
|
|
68
|
+
fonts: {
|
|
69
|
+
body: "Inter, sans-serif",
|
|
70
|
+
heading: "Inter, sans-serif",
|
|
71
|
+
manuscript: "Playfair Display",
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
expect(themeFontFamilies()).toEqual(["Inter", "Playfair Display"]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("the served head carries every family the row declares", async () => {
|
|
78
|
+
injectedConfig = {
|
|
79
|
+
fonts: {
|
|
80
|
+
body: "Inter, sans-serif",
|
|
81
|
+
heading: "Inter, sans-serif",
|
|
82
|
+
manuscript: "Playfair Display",
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
const head = await servedHead();
|
|
86
|
+
expect(head).toContain("family=Inter");
|
|
87
|
+
expect(head).toContain(encodeURIComponent("Playfair Display"));
|
|
88
|
+
expect(head).not.toContain("<!--theme-fonts-->");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("an absent row degrades to no fonts link — never throws", async () => {
|
|
92
|
+
injectedConfig = undefined;
|
|
93
|
+
expect(themeFontsLink()).toBe("");
|
|
94
|
+
const head = await servedHead();
|
|
95
|
+
expect(head).not.toContain("fonts.googleapis.com/css2");
|
|
96
|
+
expect(head).toContain("<!--head-outlet-->");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Derives the document head's Google Fonts link from the `css_theme` row's
|
|
7
|
+
* `fonts` map — the ONE source for the families a page's variants reference
|
|
8
|
+
* (`the-theme-decides-colour-not-the-component` extends to type). The old
|
|
9
|
+
* envelope hard-coded Inter, so a proposal heading naming a manuscript serif
|
|
10
|
+
* ("Playfair Display") never had its family loaded and fell back to Times
|
|
11
|
+
* (2026-09-02). Now every family the row declares is loaded; a family a
|
|
12
|
+
* variant names that the row does not declare is a row defect, caught at the
|
|
13
|
+
* row's schema boundary.
|
|
14
|
+
*
|
|
15
|
+
* Read from the boot registry cache (captured at service boot, the same
|
|
16
|
+
* semantics as the resource_loader capture) and guarded: a missing or
|
|
17
|
+
* unreadable row degrades to NO fonts link — system fonts render, the page
|
|
18
|
+
* still works. This module must never throw into the document builder.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { getEntryConfig } from "@teamscala/db/registry/accessors";
|
|
22
|
+
|
|
23
|
+
interface ThemeFontsConfig {
|
|
24
|
+
fonts?: Record<string, string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The primary family name of a CSS font stack: "Inter, sans-serif" →
|
|
28
|
+
* "Inter"; "Playfair Display" → "Playfair Display". */
|
|
29
|
+
export function primaryFontFamily(stack: string): string {
|
|
30
|
+
const first = stack.split(",")[0] ?? "";
|
|
31
|
+
return first.trim().replace(/^['"]|['"]$/g, "");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The declared theme families, deduplicated by primary family, in row
|
|
35
|
+
* order (body, heading, manuscript, mono…). Empty when the row is absent. */
|
|
36
|
+
export function themeFontFamilies(): string[] {
|
|
37
|
+
try {
|
|
38
|
+
const config = getEntryConfig<ThemeFontsConfig>("css_theme", "default");
|
|
39
|
+
const fonts = config?.fonts ?? {};
|
|
40
|
+
const seen = new Set<string>();
|
|
41
|
+
const families: string[] = [];
|
|
42
|
+
for (const value of Object.values(fonts)) {
|
|
43
|
+
if (typeof value !== "string" || value.trim() === "") continue;
|
|
44
|
+
const primary = primaryFontFamily(value);
|
|
45
|
+
if (primary === "" || seen.has(primary)) continue;
|
|
46
|
+
seen.add(primary);
|
|
47
|
+
families.push(primary);
|
|
48
|
+
}
|
|
49
|
+
return families;
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The <link> tag loading every declared theme family, or "" when none. */
|
|
56
|
+
export function themeFontsLink(): string {
|
|
57
|
+
const families = themeFontFamilies();
|
|
58
|
+
if (families.length === 0) return "";
|
|
59
|
+
const WEIGHTS = "400;500;600;700";
|
|
60
|
+
const params = families
|
|
61
|
+
.map((family) => `family=${encodeURIComponent(family)}:wght@${WEIGHTS}`)
|
|
62
|
+
.join("&");
|
|
63
|
+
return `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?${params}&display=optional" />`;
|
|
64
|
+
}
|