@teamscala/service-boot 0.23.460 → 0.23.462
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
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
},
|
|
7
7
|
"name": "@teamscala/service-boot",
|
|
8
8
|
"type": "module",
|
|
9
|
-
"version": "0.23.
|
|
9
|
+
"version": "0.23.462",
|
|
10
10
|
"sideEffects": false,
|
|
11
11
|
"imports": {
|
|
12
12
|
"#service-boot/*": "./src/*",
|
|
@@ -372,14 +372,6 @@
|
|
|
372
372
|
"types": "./src/csrf-protection.d.ts",
|
|
373
373
|
"default": "./src/csrf-protection.ts"
|
|
374
374
|
},
|
|
375
|
-
"./discover-blocks": {
|
|
376
|
-
"types": "./src/discover-blocks.d.ts",
|
|
377
|
-
"default": "./src/discover-blocks.ts"
|
|
378
|
-
},
|
|
379
|
-
"./discover-blocks.ts": {
|
|
380
|
-
"types": "./src/discover-blocks.d.ts",
|
|
381
|
-
"default": "./src/discover-blocks.ts"
|
|
382
|
-
},
|
|
383
375
|
"./expand-roster": {
|
|
384
376
|
"types": "./src/expand-roster.d.ts",
|
|
385
377
|
"default": "./src/expand-roster.ts"
|
|
@@ -800,6 +792,14 @@
|
|
|
800
792
|
"types": "./src/is-react-element-type.d.ts",
|
|
801
793
|
"default": "./src/is-react-element-type.ts"
|
|
802
794
|
},
|
|
795
|
+
"./load-page-manifest": {
|
|
796
|
+
"types": "./src/load-page-manifest.d.ts",
|
|
797
|
+
"default": "./src/load-page-manifest.ts"
|
|
798
|
+
},
|
|
799
|
+
"./load-page-manifest.ts": {
|
|
800
|
+
"types": "./src/load-page-manifest.d.ts",
|
|
801
|
+
"default": "./src/load-page-manifest.ts"
|
|
802
|
+
},
|
|
803
803
|
"./load-service-install-row": {
|
|
804
804
|
"types": "./src/load-service-install-row.d.ts",
|
|
805
805
|
"default": "./src/load-service-install-row.ts"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @system ui-service-boot
|
|
3
|
+
* @status handwritten
|
|
4
|
+
* @edit edit directly
|
|
5
|
+
*
|
|
6
|
+
* Loads the service's GENERATED `src/page-manifest.ts` — the codegen output of
|
|
7
|
+
* the `page-manifest` transform, emitted from `block_schema` rows. That file is
|
|
8
|
+
* the single artefact describing which Puck components this service renders.
|
|
9
|
+
*
|
|
10
|
+
* This REPLACES `discover-blocks.ts`, a hand-written runtime scan that queried
|
|
11
|
+
* block_schema at boot and dynamic-imported each row. Deleting it is the point.
|
|
12
|
+
* The scan was introduced to survive one dead row, and it bought that by moving
|
|
13
|
+
* a build-time, deterministic, generated fact into a runtime path with four
|
|
14
|
+
* independent ways to silently resolve nothing: a registry read that answers
|
|
15
|
+
* with an empty array instead of failing, an install probe that reports "not
|
|
16
|
+
* installed" for installed packages, a `node_modules` lookup relative to
|
|
17
|
+
* `process.cwd()`, and a per-block try/catch that swallows each failure into a
|
|
18
|
+
* string nobody reads when the boot dies first. Measured 2026-09-03: 91 active
|
|
19
|
+
* block_schema rows, a generated manifest carrying 63 blocks sitting on disk,
|
|
20
|
+
* and the scan loading ZERO — for hours, across every UI service.
|
|
21
|
+
*
|
|
22
|
+
* Generation is the platform's answer to exactly this (`one-generator-pattern`,
|
|
23
|
+
* `business-logic-is-rows-plus-executors`): a bad row fails codegen loudly, at
|
|
24
|
+
* build time, with the file in front of you. It cannot present as a service
|
|
25
|
+
* that boots and renders nothing.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
import type { RendererComponentEntry } from "./types.ts";
|
|
30
|
+
|
|
31
|
+
interface PageManifestModule {
|
|
32
|
+
components?: Record<string, RendererComponentEntry>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Reads the generated manifest's `components` export. Throws when the file is
|
|
37
|
+
* absent or exports nothing — an empty manifest means codegen has not run for
|
|
38
|
+
* this service, which is a build problem the operator must see named, not a
|
|
39
|
+
* degraded runtime state to paper over.
|
|
40
|
+
*/
|
|
41
|
+
export async function loadPageManifest(
|
|
42
|
+
serviceSlug: string,
|
|
43
|
+
): Promise<Record<string, RendererComponentEntry>> {
|
|
44
|
+
const manifestPath = join(process.cwd(), "src", "page-manifest.ts");
|
|
45
|
+
let mod: PageManifestModule;
|
|
46
|
+
try {
|
|
47
|
+
mod = (await import(manifestPath)) as PageManifestModule;
|
|
48
|
+
} catch (err) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`[${serviceSlug}] could not import the generated manifest at ${manifestPath}: ` +
|
|
51
|
+
`${err instanceof Error ? err.message : String(err)}. ` +
|
|
52
|
+
`Run \`scala-tools codegen run --output ${serviceSlug}-page-manifest --force\`.`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const components = mod.components;
|
|
57
|
+
if (!components || Object.keys(components).length === 0) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`[${serviceSlug}] the generated manifest at ${manifestPath} exports no components. ` +
|
|
60
|
+
`Regenerate it: \`scala-tools codegen run --output ${serviceSlug}-page-manifest --force\`.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return components;
|
|
64
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* Params are interpolated from URL params and prior resolved data.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
14
15
|
import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
|
|
15
16
|
import type { ResolverFnKey } from "@teamscala/db/registry/generated/resolver-fn";
|
|
16
17
|
import { ResolverFnConfigSchema } from "@teamscala/db-validation/registry-schemas/resolver-fn";
|
|
@@ -77,7 +77,7 @@ import {
|
|
|
77
77
|
import { injectSecurityHeaders } from "./security-headers.ts";
|
|
78
78
|
import { registerPageRoutes } from "./register-page-routes.ts";
|
|
79
79
|
import { runBootHookList } from "./run-boot-hook-list.ts";
|
|
80
|
-
import {
|
|
80
|
+
import { loadPageManifest } from "./load-page-manifest.ts";
|
|
81
81
|
import { registerPageServerClient } from "./register-page-server-client.ts";
|
|
82
82
|
import { createPhase1PageHandler, healthHandler } from "./two-phase-readiness.ts";
|
|
83
83
|
|
|
@@ -250,10 +250,10 @@ export async function buildMinimalApp(
|
|
|
250
250
|
registerPageServerClient(serviceSlug),
|
|
251
251
|
);
|
|
252
252
|
|
|
253
|
-
// NOW
|
|
254
|
-
// register-page-routes consumes the result.
|
|
255
|
-
const discoverPromise = runStep("
|
|
256
|
-
|
|
253
|
+
// NOW load the generated manifest (getServerClient is set). Awaited below
|
|
254
|
+
// before register-page-routes consumes the result.
|
|
255
|
+
const discoverPromise = runStep("load-page-manifest", () =>
|
|
256
|
+
loadPageManifest(serviceSlug),
|
|
257
257
|
);
|
|
258
258
|
|
|
259
259
|
const wsRouteCount = await runStep("register-ws-routes", () =>
|
|
@@ -268,9 +268,9 @@ export async function buildMinimalApp(
|
|
|
268
268
|
// CRITICAL: Block discovery and page routes MUST be registered BEFORE serving,
|
|
269
269
|
// because Hono's RegExpRouter compiles on first request and becomes immutable.
|
|
270
270
|
// We defer only client asset building (expensive Vite build) to Phase 2.
|
|
271
|
-
let
|
|
271
|
+
let components: Awaited<ReturnType<typeof loadPageManifest>>;
|
|
272
272
|
try {
|
|
273
|
-
|
|
273
|
+
components = await discoverPromise;
|
|
274
274
|
} catch (err) {
|
|
275
275
|
throw new BootFatalError(
|
|
276
276
|
"ui-service.page-manifest",
|
|
@@ -279,46 +279,10 @@ export async function buildMinimalApp(
|
|
|
279
279
|
err,
|
|
280
280
|
);
|
|
281
281
|
}
|
|
282
|
+
log.info(
|
|
283
|
+
`[two-phase-boot:${serviceSlug}] Loaded ${Object.keys(components).length} block(s) from the generated manifest`,
|
|
284
|
+
);
|
|
282
285
|
|
|
283
|
-
const { components, loaded, skipped } = discovered;
|
|
284
|
-
// REPORT THE DISCOVERY BEFORE JUDGING IT. This used to throw BootFatalError
|
|
285
|
-
// on `loaded.length === 0` ABOVE these two log lines, so the one boot that
|
|
286
|
-
// most needed the diagnosis was the only boot that printed none of it: the
|
|
287
|
-
// operator saw "Zero Puck components loaded" and nothing about WHICH rows
|
|
288
|
-
// were considered or WHY each was rejected. Measured 2026-09-03: 91 active
|
|
289
|
-
// block_schema rows, zero loaded, zero skip lines — and the absence of skip
|
|
290
|
-
// lines was itself unreadable, because "no candidate survived the install
|
|
291
|
-
// pre-filter" and "every candidate failed to import" are indistinguishable
|
|
292
|
-
// when neither prints. Hours went into re-deriving from outside the process
|
|
293
|
-
// what these lines already knew.
|
|
294
|
-
if (loaded.length > 0) {
|
|
295
|
-
log.info(
|
|
296
|
-
`[two-phase-boot:${serviceSlug}] Discovered ${loaded.length} block(s): ${loaded.join(", ")}`,
|
|
297
|
-
);
|
|
298
|
-
}
|
|
299
|
-
if (skipped.length > 0) {
|
|
300
|
-
log.warn(
|
|
301
|
-
`[two-phase-boot:${serviceSlug}] Skipped ${skipped.length} block_schema row(s) ` +
|
|
302
|
-
`(missing componentPath/componentExport or import failed): ${skipped.join(", ")}`,
|
|
303
|
-
);
|
|
304
|
-
}
|
|
305
|
-
// SOFT-CRASH, NEVER HARD-CRASH (operator ruling 2026-09-03: "it's meant to
|
|
306
|
-
// soft crash with errors, so why the fuck is it hard crashing"). A service
|
|
307
|
-
// that refuses to start serves NOTHING — no page, no health endpoint, no
|
|
308
|
-
// error page — so a block-discovery problem presents to a client as a 502
|
|
309
|
-
// from a dead port, which is strictly less information than a served page
|
|
310
|
-
// with its blocks missing. The degraded state is loud (ERROR, both counts,
|
|
311
|
-
// every skip reason) and inspectable over HTTP, which is what makes it
|
|
312
|
-
// fixable; refusing the boot only hid the cause. `no-uncontrolled-repetition-
|
|
313
|
-
// or-cascade` is unaffected — nothing restarts, the process stays up.
|
|
314
|
-
if (loaded.length === 0) {
|
|
315
|
-
log.error(
|
|
316
|
-
`[two-phase-boot:${serviceSlug}] DEGRADED: zero Puck components loaded ` +
|
|
317
|
-
`from ${skipped.length} considered block_schema row(s). Pages will render ` +
|
|
318
|
-
`without blocks until this is fixed. Check service_install.block_packages, ` +
|
|
319
|
-
`the block_schema rows, and the skip reasons logged above.`,
|
|
320
|
-
);
|
|
321
|
-
}
|
|
322
286
|
|
|
323
287
|
// Run boot hooks BEFORE serving - some hooks may register routes and must
|
|
324
288
|
// run before Hono's matcher is compiled by the first request
|
|
@@ -366,7 +330,10 @@ export async function buildMinimalApp(
|
|
|
366
330
|
databaseUrl,
|
|
367
331
|
blockPackages,
|
|
368
332
|
components,
|
|
369
|
-
loaded
|
|
370
|
-
skipped
|
|
333
|
+
// The generated manifest IS the loaded set — every entry it exports is a
|
|
334
|
+
// block this service renders, so there is no "skipped" half any more. A
|
|
335
|
+
// row that cannot produce a component fails codegen, not boot.
|
|
336
|
+
loaded: Object.keys(components),
|
|
337
|
+
skipped: [] as string[],
|
|
371
338
|
};
|
|
372
339
|
}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { expect, test } from "bun:test";
|
|
2
|
-
import { registerPrismaClient } from "@teamscala/db/prisma-registry";
|
|
3
|
-
import type { PrismaClient } from "@teamscala/db/client";
|
|
4
|
-
import { discoverBlocks } from "./discover-blocks.ts";
|
|
5
|
-
|
|
6
|
-
test("block discovery reads the global registry through the registered database client", async () => {
|
|
7
|
-
let calls = 0;
|
|
8
|
-
registerPrismaClient({
|
|
9
|
-
registry_entries: {
|
|
10
|
-
findMany: async () => {
|
|
11
|
-
calls += 1;
|
|
12
|
-
return [];
|
|
13
|
-
},
|
|
14
|
-
},
|
|
15
|
-
} as unknown as PrismaClient);
|
|
16
|
-
|
|
17
|
-
const result = await discoverBlocks([]);
|
|
18
|
-
expect(calls).toBe(1);
|
|
19
|
-
expect(result).toEqual({ components: {}, loaded: [], skipped: [] });
|
|
20
|
-
});
|
package/src/discover-blocks.ts
DELETED
|
@@ -1,250 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @system ui-service-boot
|
|
3
|
-
* @status handwritten
|
|
4
|
-
* @edit edit directly
|
|
5
|
-
*
|
|
6
|
-
* Surgical block discovery: at boot, queries every block_schema row in the
|
|
7
|
-
* registry whose componentPath starts with one of the service's declared
|
|
8
|
-
* block_packages, then dynamic-imports each one. Per-block try/catch — one
|
|
9
|
-
* dead componentPath logs and skips, the rest still register and the page
|
|
10
|
-
* renders the surviving blocks. This replaces the static
|
|
11
|
-
* `.scala-install/page-manifest.ts` codegen output, which had the failure
|
|
12
|
-
* mode of a single dead block_schema row crashing the whole manifest at
|
|
13
|
-
* compile time.
|
|
14
|
-
*
|
|
15
|
-
* Empty block_packages is NOT fatal here: it falls back to scanning all
|
|
16
|
-
* active block_schema (uninstalled packages skip via the per-block try/catch),
|
|
17
|
-
* so a service whose row is unpopulated but whose blocks are installed still
|
|
18
|
-
* renders. The real "booted with zero blocks" fail-closed is at the call site
|
|
19
|
-
* (loaded.length === 0 → BootFatalError). A DB-unreachable error still throws —
|
|
20
|
-
* no fallback to a package-side puckBlocks scan, which masked blockless boots.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
24
|
-
import { join } from "node:path";
|
|
25
|
-
import { getPrisma } from "@teamscala/db/prisma-registry";
|
|
26
|
-
import { queryActiveRegistryRows } from "@teamscala/db/registry/loader/query-rows";
|
|
27
|
-
import { BlockSchemaConfigSchema } from "@teamscala/db-validation/registry-schemas/block-schema";
|
|
28
|
-
import * as v from "valibot";
|
|
29
|
-
import { isReactElementType } from "./is-react-element-type.ts";
|
|
30
|
-
import type { RendererComponentEntry } from "./types.ts";
|
|
31
|
-
|
|
32
|
-
export interface DiscoveredBlocks {
|
|
33
|
-
components: Record<string, RendererComponentEntry>;
|
|
34
|
-
loaded: string[];
|
|
35
|
-
skipped: string[];
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
interface BlockSchemaConfig {
|
|
39
|
-
componentPath?: string;
|
|
40
|
-
componentExport?: string;
|
|
41
|
-
fields?: Record<string, unknown>;
|
|
42
|
-
defaults?: Record<string, unknown>;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export async function discoverBlocks(
|
|
46
|
-
blockPackages: string[],
|
|
47
|
-
): Promise<DiscoveredBlocks> {
|
|
48
|
-
// Scope discovery to what is ACTUALLY INSTALLED, never the authored `block_packages` list. That list was an authored hint that silently
|
|
49
|
-
// DRIFTED: a page-used package missing from it caused its blocks to be skipped and rendered BLANK with only a warning (scala-sign, 2026-06-04 —
|
|
50
|
-
// SignIn / SignatureField / ScrollProgress all blanked because sign-in-block had fallen out of the list). The filesystem is the source of truth: a page
|
|
51
|
-
// can only compose blocks from packages installed as the service's
|
|
52
|
-
// dependencies, so "installed @teamgbg package" is always a correct superset
|
|
53
|
-
// of "packages the pages need". A block_schema whose package is installed is
|
|
54
|
-
// imported; an uninstalled one is skipped (its import would fail anyway).
|
|
55
|
-
// This makes an authored-list mistake STRUCTURALLY UNABLE to blank a
|
|
56
|
-
// page-used block. The install dir is also narrower than "all block_schema",
|
|
57
|
-
// so the 2026-05-29 empty-list-scans-everything incident cannot recur either.
|
|
58
|
-
const serviceModules = join(process.cwd(), "node_modules");
|
|
59
|
-
const installedCache = new Map<string, boolean>();
|
|
60
|
-
const isInstalled = async (pkg: string): Promise<boolean> => {
|
|
61
|
-
const cached = installedCache.get(pkg);
|
|
62
|
-
if (cached !== undefined) return cached;
|
|
63
|
-
// Probe the package's package.json, NOT its directory. Bun.file() on a
|
|
64
|
-
// DIRECTORY reports exists() === false, so the previous directory probe
|
|
65
|
-
// answered "not installed" for every package that was in fact installed
|
|
66
|
-
// — and because this predicate gates every block below, that skipped
|
|
67
|
-
// every block and booted the service with zero Puck components. Measured
|
|
68
|
-
// 2026-09-01: scala-portal, scala-public, scala-forms and scala-projects
|
|
69
|
-
// all FATAL at ui-service.page-manifest with their packages present on
|
|
70
|
-
// disk at the expected versions. A file probe is the correct shape.
|
|
71
|
-
const ok = await Bun.file(
|
|
72
|
-
join(serviceModules, ...pkg.split("/"), "package.json"),
|
|
73
|
-
).exists();
|
|
74
|
-
installedCache.set(pkg, ok);
|
|
75
|
-
return ok;
|
|
76
|
-
};
|
|
77
|
-
// `blockPackages` is retained only to surface authored-vs-installed drift to
|
|
78
|
-
// the operator; it no longer gates discovery, so it can never blank a block.
|
|
79
|
-
if (blockPackages.length > 0) {
|
|
80
|
-
const installed = await Promise.all(
|
|
81
|
-
blockPackages.map(async (p) => ({ p, ok: await isInstalled(p) })),
|
|
82
|
-
);
|
|
83
|
-
const stale = installed.filter((x) => !x.ok).map((x) => x.p);
|
|
84
|
-
if (stale.length > 0) {
|
|
85
|
-
getAppLogger().warn(
|
|
86
|
-
`[discover-blocks] block_packages lists ${stale.length} package(s) that are not installed (ignored): ${stale.join(", ")}`,
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const components: Record<string, RendererComponentEntry> = {};
|
|
92
|
-
const loaded: string[] = [];
|
|
93
|
-
const skipped: string[] = [];
|
|
94
|
-
|
|
95
|
-
// Block schemas are global registry data, not a model owned by this UI
|
|
96
|
-
// service. The page-server ORPC client is intentionally service-scoped and
|
|
97
|
-
// therefore cannot expose registry_entries. Use the registered database
|
|
98
|
-
// client for this shared model, as every other registry reader does.
|
|
99
|
-
// READ THROUGH THE SHARED HELPER, NOT A SECOND HAND-WRITTEN QUERY. This
|
|
100
|
-
// used to call prisma.registry_entries.findMany directly with its own
|
|
101
|
-
// where clause — a duplicate of a read that already exists once in
|
|
102
|
-
// queryActiveRegistryRows and is exercised by every other registry reader.
|
|
103
|
-
//
|
|
104
|
-
// The duplicate returned an EMPTY ARRAY. Measured 2026-09-03: 91 active
|
|
105
|
-
// block_schema rows in the database, ZERO loaded, and not a single skip
|
|
106
|
-
// line logged — so the loop below never ran once. The helper resolves the
|
|
107
|
-
// same rows correctly because it falls back to raw SQL when the v8
|
|
108
|
-
// accessor cannot answer; the hand-written copy had no such path and its
|
|
109
|
-
// silence was indistinguishable from "there are no blocks".
|
|
110
|
-
//
|
|
111
|
-
// Every UI service then hit the fail-closed at the call site and refused to
|
|
112
|
-
// boot, so the operator saw nothing at all rather than "0 of 91 blocks".
|
|
113
|
-
// Two copies of one read, and the copy nobody exercised is the one that
|
|
114
|
-
// broke — delete it rather than repair it.
|
|
115
|
-
const rows = (await queryActiveRegistryRows(
|
|
116
|
-
getPrisma() as never,
|
|
117
|
-
{ type: "block_schema" },
|
|
118
|
-
)) as Array<{ slug: string; config: unknown }>;
|
|
119
|
-
rows.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
120
|
-
|
|
121
|
-
// Parallel block discovery (no-serial-per-unit-work-on-hot-paths: boot is a
|
|
122
|
-
// recurring cycle via the package-poller's restarts). Previously each block's
|
|
123
|
-
// component was imported SERIALLY (sum of ~44 import times) under a 3s
|
|
124
|
-
// timeout — heavy blocks (e.g. AiChat with Plate) exceeded 3s, were SKIPPED
|
|
125
|
-
// at boot, and paid their full import at first render (cold-start cost).
|
|
126
|
-
// Bounded-parallel (chunks of 8) collapses boot to ceil(N/8) waves of the
|
|
127
|
-
// max import time, and the raised 8s timeout lets heavy blocks import at
|
|
128
|
-
// boot (shifted off the first render). `skipped`/`loaded`/`components`
|
|
129
|
-
// mutations are safe concurrently (single-threaded; atomic at await points).
|
|
130
|
-
const loadBlock = async (row: { slug: string; config: unknown }) => {
|
|
131
|
-
const rawConfig = parseConfig(row.config);
|
|
132
|
-
const rawComponentPath = getStringProperty(rawConfig, "componentPath");
|
|
133
|
-
const rawPackage = rawComponentPath
|
|
134
|
-
? rawComponentPath.split("/").slice(0, 2).join("/")
|
|
135
|
-
: null;
|
|
136
|
-
if (!rawPackage || !(await isInstalled(rawPackage))) {
|
|
137
|
-
skipped.push(
|
|
138
|
-
`${row.slug} (${rawPackage ?? "missing componentPath"} not installed)`,
|
|
139
|
-
);
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
let cfg: BlockSchemaConfig;
|
|
144
|
-
try {
|
|
145
|
-
cfg = v.parse(BlockSchemaConfigSchema, rawConfig) as BlockSchemaConfig;
|
|
146
|
-
} catch (err) {
|
|
147
|
-
skipped.push(
|
|
148
|
-
`${row.slug} (invalid block_schema config: ${formatValidationError(err)})`,
|
|
149
|
-
);
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const componentPath = cfg.componentPath;
|
|
154
|
-
const componentExport = cfg.componentExport;
|
|
155
|
-
if (!componentPath || !componentExport) {
|
|
156
|
-
skipped.push(`${row.slug} (missing componentPath/componentExport)`);
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
// Scope to installed packages: each block_schema's componentPath must
|
|
160
|
-
// start with a package present in the service's node_modules.
|
|
161
|
-
const pkgPrefix = componentPath.split("/").slice(0, 2).join("/");
|
|
162
|
-
if (!(await isInstalled(pkgPrefix))) {
|
|
163
|
-
skipped.push(`${row.slug} (${pkgPrefix} not installed)`);
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
try {
|
|
167
|
-
const TIMEOUT_MS = 8_000;
|
|
168
|
-
const mod = (await Promise.race([
|
|
169
|
-
import(componentPath),
|
|
170
|
-
new Promise<never>((_, rej) =>
|
|
171
|
-
setTimeout(
|
|
172
|
-
() => rej(new Error(`import timeout >${TIMEOUT_MS}ms`)),
|
|
173
|
-
TIMEOUT_MS,
|
|
174
|
-
),
|
|
175
|
-
),
|
|
176
|
-
])) as Record<string, unknown>;
|
|
177
|
-
const component = mod[componentExport] ?? mod.default;
|
|
178
|
-
if (!isReactElementType(component)) {
|
|
179
|
-
skipped.push(
|
|
180
|
-
`${row.slug} (${componentExport} not a component in ${componentPath})`,
|
|
181
|
-
);
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
components[row.slug] = {
|
|
185
|
-
render: component as RendererComponentEntry["render"],
|
|
186
|
-
fields: (cfg.fields ?? {}) as never,
|
|
187
|
-
// block_schema.defaults → the SSR component entry, so buildPuckConfig
|
|
188
|
-
// merges them UNDER authored props at render (blocks-fully-programmable:
|
|
189
|
-
// DB-declared defaults reach render, never a component parameter
|
|
190
|
-
// constant). Puck's RSC <Render> ignores defaultProps itself.
|
|
191
|
-
defaultProps: (cfg.defaults ?? {}) as never,
|
|
192
|
-
};
|
|
193
|
-
loaded.push(row.slug);
|
|
194
|
-
} catch (err) {
|
|
195
|
-
skipped.push(`${row.slug} (${(err as Error).message})`);
|
|
196
|
-
}
|
|
197
|
-
};
|
|
198
|
-
// Pre-filter to rows the service can actually import, so expected-absent
|
|
199
|
-
// blocks don't pollute the boot log as "skipped". A service (e.g.
|
|
200
|
-
// scala-projects) installs only a subset of the platform's ~45 blocks; the
|
|
201
|
-
// rest are expected-absent (their imports would fail anyway), not warnings.
|
|
202
|
-
// The filesystem remains the source of truth (block-discovery-scopes-by-
|
|
203
|
-
// install): an uninstalled block is simply not considered. Rows with a
|
|
204
|
-
// missing componentPath are kept so `loadBlock` still flags them (real data
|
|
205
|
-
// signal); `skipped` thus catches only genuine failures (installed package
|
|
206
|
-
// but import/config/export failed) and the call-site WARN becomes signal.
|
|
207
|
-
const candidateRows = [];
|
|
208
|
-
for (const row of rows) {
|
|
209
|
-
const cp = getStringProperty(parseConfig(row.config), "componentPath");
|
|
210
|
-
if (!cp) {
|
|
211
|
-
candidateRows.push(row);
|
|
212
|
-
continue;
|
|
213
|
-
}
|
|
214
|
-
const pkg = cp.split("/").slice(0, 2).join("/");
|
|
215
|
-
if (await isInstalled(pkg)) candidateRows.push(row);
|
|
216
|
-
}
|
|
217
|
-
// All blocks concurrent (module imports are in-process I/O + V8 compile — no
|
|
218
|
-
// fork tax, so bounded concurrency isn't needed here; the chunk-of-8 was
|
|
219
|
-
// needlessly serializing 44 imports into 6 waves when 1 wave suffices).
|
|
220
|
-
await Promise.all(candidateRows.map(loadBlock));
|
|
221
|
-
|
|
222
|
-
return { components, loaded, skipped };
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function parseConfig(config: unknown): unknown {
|
|
226
|
-
if (typeof config !== "string") return config;
|
|
227
|
-
try {
|
|
228
|
-
return JSON.parse(config) as unknown;
|
|
229
|
-
} catch {
|
|
230
|
-
// Malformed JSON string → absent signal, never the raw string: the caller's
|
|
231
|
-
// valibot parse then fails and the block is skipped (fail-closed), rather
|
|
232
|
-
// than a non-object masquerading as config (protective-layers-fail-explicitly).
|
|
233
|
-
return null;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function getStringProperty(value: unknown, key: string): string | null {
|
|
238
|
-
if (!value || typeof value !== "object") return null;
|
|
239
|
-
const maybe = (value as Record<string, unknown>)[key];
|
|
240
|
-
return typeof maybe === "string" ? maybe : null;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function formatValidationError(err: unknown): string {
|
|
244
|
-
if (err && typeof err === "object" && "issues" in err) {
|
|
245
|
-
const issues = (err as { issues?: Array<{ message?: string }> }).issues;
|
|
246
|
-
const first = Array.isArray(issues) ? issues[0]?.message : undefined;
|
|
247
|
-
if (first) return first;
|
|
248
|
-
}
|
|
249
|
-
return err instanceof Error ? err.message : String(err);
|
|
250
|
-
}
|