@cosmicdrift/kumiko-server-runtime 0.319.0 → 0.320.0
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 +4 -8
- package/src/__tests__/compose-features-wiring.integration.test.ts +159 -1
- package/src/__tests__/require-env.test.ts +5 -0
- package/src/__tests__/resolve-auth-mail.test.ts +75 -0
- package/src/__tests__/run-prod-app-spec.test.ts +56 -3
- package/src/__tests__/run-prod-app-static-files.test.ts +5 -19
- package/src/__tests__/run-prod-app.integration.test.ts +138 -38
- package/src/bun-serve-options.ts +41 -3
- package/src/changes.json +25 -0
- package/src/compose-features.ts +2 -2
- package/src/index.ts +1 -1
- package/src/run-prod-app-boot-context.ts +55 -15
- package/src/run-prod-app-static-files.ts +40 -43
- package/src/run-prod-app.ts +77 -56
- package/src/security-headers.ts +4 -4
- package/src/try-hono-first.ts +12 -6
- package/src/__tests__/inject-schema.test.ts +0 -100
- package/src/inject-schema.ts +0 -33
package/src/run-prod-app.ts
CHANGED
|
@@ -75,6 +75,7 @@ import {
|
|
|
75
75
|
type ExtraRouteDefinition,
|
|
76
76
|
type LoginRateLimiter,
|
|
77
77
|
loadJwtSecretOrKeyring,
|
|
78
|
+
type PostAuthLandingResolver,
|
|
78
79
|
type SseBroker,
|
|
79
80
|
type TokenVerifier,
|
|
80
81
|
} from "@cosmicdrift/kumiko-framework/api";
|
|
@@ -90,7 +91,6 @@ import {
|
|
|
90
91
|
type DbRunner,
|
|
91
92
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
92
93
|
import {
|
|
93
|
-
buildAppSchema,
|
|
94
94
|
collectWriteHandlerQns,
|
|
95
95
|
createRegistry,
|
|
96
96
|
type EffectiveFeaturesResolver,
|
|
@@ -145,7 +145,7 @@ import Redis from "ioredis";
|
|
|
145
145
|
import { applyBootSeeds } from "./boot/apply-boot-seeds";
|
|
146
146
|
import { resolveBootCrypto } from "./boot/boot-crypto";
|
|
147
147
|
import { jobRunLoggerCallbacks } from "./boot/job-run-logger";
|
|
148
|
-
import { buildBunServeOptions } from "./bun-serve-options";
|
|
148
|
+
import { buildBunServeOptions, resolveDerivedMaxRequestBodySize } from "./bun-serve-options";
|
|
149
149
|
import { buildComposeAuthOptions, composeFeatures } from "./compose-features";
|
|
150
150
|
import { makeDispatchSystemWrite, type SystemWireDeps } from "./extra-routes-deps";
|
|
151
151
|
import { assertPiiBootInvariants } from "./pii-boot-gate";
|
|
@@ -248,17 +248,24 @@ function makeDryRunHandle(): ProdAppHandle {
|
|
|
248
248
|
};
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
-
/** Wrapper
|
|
251
|
+
/** Wrapper API for the password-reset flow.
|
|
252
252
|
*
|
|
253
|
-
*
|
|
254
|
-
* (+ appName/locale)
|
|
255
|
-
* sendResetEmail
|
|
256
|
-
* resolveAuthMail
|
|
257
|
-
|
|
253
|
+
* Since the delivery migration PasswordResetOptions carries `appUrl`
|
|
254
|
+
* (+ appName/locale) itself and the handler mails via ctx.notify — no
|
|
255
|
+
* sendResetEmail callback anymore. Apps pass `auth.mail` (convenience,
|
|
256
|
+
* resolveAuthMail builds the appUrl) OR an explicit block.
|
|
257
|
+
*
|
|
258
|
+
* hmacSecret is optional here (unlike PasswordResetOptions): resolveAuthMail
|
|
259
|
+
* backfills it, so overriding just appUrl needs no secret of its own. */
|
|
260
|
+
export type PasswordResetSetup = Omit<PasswordResetOptions, "hmacSecret"> & {
|
|
261
|
+
readonly hmacSecret?: string;
|
|
262
|
+
};
|
|
258
263
|
|
|
259
|
-
/** Wrapper
|
|
260
|
-
* PasswordResetSetup
|
|
261
|
-
export type EmailVerificationSetup = EmailVerificationOptions
|
|
264
|
+
/** Wrapper API for the email-verification flow, symmetric to
|
|
265
|
+
* PasswordResetSetup (appUrl via delivery, optional hmacSecret). */
|
|
266
|
+
export type EmailVerificationSetup = Omit<EmailVerificationOptions, "hmacSecret"> & {
|
|
267
|
+
readonly hmacSecret?: string;
|
|
268
|
+
};
|
|
262
269
|
|
|
263
270
|
/** Wrapper-API für Magic-Link Self-Signup. = SignupOptions (appUrl, die
|
|
264
271
|
* Mail geht via delivery/ctx.notify wie reset/verify). Anders als reset/
|
|
@@ -303,6 +310,9 @@ export type AuthMailOptions = {
|
|
|
303
310
|
readonly from?: string;
|
|
304
311
|
/** Einzelne Auth-Pfade überschreiben (Default DEFAULT_AUTH_PATHS). */
|
|
305
312
|
readonly paths?: Partial<AuthPaths>;
|
|
313
|
+
/** Reset/verify token secret, decoupled from JWT_SECRET rotation.
|
|
314
|
+
* Falls back to resolveAuthMail's call-site secret when unset. */
|
|
315
|
+
readonly hmacSecret?: string;
|
|
306
316
|
};
|
|
307
317
|
|
|
308
318
|
export type RunProdAppAuthOptions = {
|
|
@@ -373,6 +383,10 @@ export type RunProdAppAuthOptions = {
|
|
|
373
383
|
* env var when unset; both unset means the pre-#1539 spoofable default
|
|
374
384
|
* (0 hops). Set this to your real ingress hop count (typically 1). */
|
|
375
385
|
readonly trustedProxyHops?: number;
|
|
386
|
+
/** Server-computed post-auth redirect (see AuthRoutesConfig.postAuthLanding).
|
|
387
|
+
* Result lands in the login/signup/invite response as `landingPath`; an
|
|
388
|
+
* invalid path or a throwing resolver just omits the field. */
|
|
389
|
+
readonly postAuthLanding?: PostAuthLandingResolver;
|
|
376
390
|
};
|
|
377
391
|
|
|
378
392
|
/** Hook for app-specific seeding — runs after the admin (when auth is
|
|
@@ -403,23 +417,25 @@ export type ExtraContextOption =
|
|
|
403
417
|
| Record<string, unknown>
|
|
404
418
|
| ((deps: RunProdAppDeps) => Record<string, unknown>);
|
|
405
419
|
|
|
406
|
-
/** Per-
|
|
407
|
-
* hostDispatch
|
|
408
|
-
* - "html":
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
* - "redirect": 301/302
|
|
413
|
-
* - "not-found":
|
|
420
|
+
/** Per-host routing decision for the staticDir fallback, returned from
|
|
421
|
+
* hostDispatch. Three modes:
|
|
422
|
+
* - "html": serve a specific HTML file (relative to staticDir), optionally
|
|
423
|
+
* with CSP. HTML never carries a schema — createKumikoApp fetches it
|
|
424
|
+
* itself from the authenticated GET /api/schema, after the
|
|
425
|
+
* clientFeature gates (auth) let it through.
|
|
426
|
+
* - "redirect": 301/302 to the given location.
|
|
427
|
+
* - "not-found": reject outright (e.g. unknown subdomain).
|
|
414
428
|
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
429
|
+
* Only consulted when the path would otherwise fall through to the HTML
|
|
430
|
+
* fallback — i.e. for "/", "/index.html", or SPA routes that neither Hono
|
|
431
|
+
* matches nor a concrete disk file. Asset paths (/assets/*) and API paths
|
|
432
|
+
* run independent of the host. */
|
|
419
433
|
export type HostDispatchResult =
|
|
420
434
|
| {
|
|
421
435
|
readonly kind: "html";
|
|
422
436
|
readonly file: string;
|
|
437
|
+
/** @deprecated Ignored — HTML never carries the schema; createKumikoApp
|
|
438
|
+
* fetches it from the authenticated GET /api/schema. */
|
|
423
439
|
readonly injectSchema?: boolean;
|
|
424
440
|
readonly csp?: string;
|
|
425
441
|
}
|
|
@@ -458,6 +474,12 @@ export type RunProdAppOptions = {
|
|
|
458
474
|
readonly validateBootOptions?: ValidateBootOptions;
|
|
459
475
|
/** Listen-Port. Default 3000 (or $PORT). */
|
|
460
476
|
readonly port?: number;
|
|
477
|
+
/** Ceiling Bun.serve buffers a request body to before any app-level
|
|
478
|
+
* body-limit middleware runs. Default is derived from `createFilesFeature`'s
|
|
479
|
+
* configured `maxUploadSize`/field `maxSize` (see
|
|
480
|
+
* resolveDerivedMaxRequestBodySize in bun-serve-options.ts) — override
|
|
481
|
+
* only for a custom ceiling unrelated to that config. */
|
|
482
|
+
readonly maxRequestBodySize?: number;
|
|
461
483
|
/** Auth-mode: standard features + routes wired, admin seeded. */
|
|
462
484
|
readonly auth?: RunProdAppAuthOptions;
|
|
463
485
|
/** Custom seed functions, run after the admin seed (when auth-mode). */
|
|
@@ -481,21 +503,19 @@ export type RunProdAppOptions = {
|
|
|
481
503
|
* for any path that doesn't match an /api/ handler. Use this for the
|
|
482
504
|
* public status page HTML, embed widget JS, etc. */
|
|
483
505
|
readonly staticDir?: string;
|
|
484
|
-
/** Host-aware
|
|
485
|
-
* (
|
|
486
|
-
* `admin.publicstatus.eu` (
|
|
487
|
-
*
|
|
506
|
+
/** Host-aware routing hook for multi-tenant + multi-app deployments
|
|
507
|
+
* (e.g. publicstatus's `<sub>.publicstatus.eu` (public page) +
|
|
508
|
+
* `admin.publicstatus.eu` (admin UI) + `publicstatus.eu` (apex/
|
|
509
|
+
* marketing) in the SAME container).
|
|
488
510
|
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
511
|
+
* Called when the staticDir fallback would generate an HTML response
|
|
512
|
+
* (root or SPA route). Default behavior without hostDispatch: index.html
|
|
513
|
+
* (single-app), no schema.
|
|
492
514
|
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
* werden. CSP-Header pro Host können zusätzlich Asset-Pfade
|
|
498
|
-
* einschränken. */
|
|
515
|
+
* Schema safety: HTML (per-host as well as single-app) never carries an
|
|
516
|
+
* AppSchema — createKumikoApp fetches it via GET /api/schema, which sits
|
|
517
|
+
* behind the auth middleware and only serves non-anonymous, signed-in
|
|
518
|
+
* users. Per-host CSP headers can additionally restrict asset paths. */
|
|
499
519
|
readonly hostDispatch?: HostDispatchFn;
|
|
500
520
|
/** Per-request head-metadata resolver (Open-Graph/title/description) for
|
|
501
521
|
* the static-fallback HTML shell — see `PageHeadResolver`. Consulted on
|
|
@@ -664,6 +684,12 @@ export type RunProdAppOptions = {
|
|
|
664
684
|
* overridden. `false` disables the whole block; per-header overrides
|
|
665
685
|
* via the object form — see SecurityHeadersOption. */
|
|
666
686
|
readonly securityHeaders?: SecurityHeadersOption;
|
|
687
|
+
/** Number of trusted reverse-proxy hops for client-IP resolution — 1:1
|
|
688
|
+
* durchgereicht an `buildServer`s
|
|
689
|
+
* top-level `ServerOptions.trustedProxyHops`. Wins over the deprecated
|
|
690
|
+
* `auth.trustedProxyHops` and `KUMIKO_TRUSTED_PROXY_HOPS`. Default 0 =
|
|
691
|
+
* trust no proxy header, only the socket address (or "unknown") counts. */
|
|
692
|
+
readonly trustedProxyHops?: number;
|
|
667
693
|
};
|
|
668
694
|
|
|
669
695
|
export type ProdAppHandle = {
|
|
@@ -799,6 +825,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
799
825
|
// deployment — a self-inflicted DoS, worse than staying on the default.
|
|
800
826
|
const trustedProxyHopsFromEnv = readEnv("KUMIKO_TRUSTED_PROXY_HOPS", envSource);
|
|
801
827
|
const trustedProxyHops = ((): number | undefined => {
|
|
828
|
+
if (options.trustedProxyHops !== undefined) return options.trustedProxyHops;
|
|
802
829
|
if (options.auth?.trustedProxyHops !== undefined) return options.auth.trustedProxyHops;
|
|
803
830
|
if (trustedProxyHopsFromEnv === undefined) return undefined;
|
|
804
831
|
// Digits-only — parseInt("0x10")/("1e3")/("2x") would silently coerce
|
|
@@ -989,11 +1016,6 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
989
1016
|
...addConfigAccessorFactory({ ...autoExtraContext, ...resolvedExtraContext }, registry),
|
|
990
1017
|
...(options.defaultLocale !== undefined && { defaultLocale: options.defaultLocale }),
|
|
991
1018
|
};
|
|
992
|
-
// @cast-boundary engine-bridge — searchAdapter is an optional context-extension
|
|
993
|
-
// (SharedContextFields.searchAdapter), invisible to extraContext's loose
|
|
994
|
-
// Record<string, unknown>-based typing. Presence check only, no need to
|
|
995
|
-
// pull in the SearchAdapter type here.
|
|
996
|
-
const searchAdapterMissing = !(extraContext as { searchAdapter?: unknown }).searchAdapter;
|
|
997
1019
|
const baseAnonymousAccess =
|
|
998
1020
|
typeof options.anonymousAccess === "function"
|
|
999
1021
|
? options.anonymousAccess(deps)
|
|
@@ -1075,6 +1097,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
1075
1097
|
...(options.observabilityOptions && { observabilityOptions: options.observabilityOptions }),
|
|
1076
1098
|
...(options.metrics && { metrics: options.metrics }),
|
|
1077
1099
|
...(options.rateLimit && { rateLimit: options.rateLimit }),
|
|
1100
|
+
...(trustedProxyHops !== undefined && { trustedProxyHops }),
|
|
1078
1101
|
...(options.extraRoutes && { extraRoutes: options.extraRoutes }),
|
|
1079
1102
|
...(effectiveAuth && {
|
|
1080
1103
|
auth: {
|
|
@@ -1101,6 +1124,9 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
1101
1124
|
unsafeSkipOriginCheck: effectiveAuth.unsafeSkipOriginCheck,
|
|
1102
1125
|
}),
|
|
1103
1126
|
...(trustedProxyHops !== undefined && { trustedProxyHops }),
|
|
1127
|
+
...(effectiveAuth.postAuthLanding !== undefined && {
|
|
1128
|
+
postAuthLanding: effectiveAuth.postAuthLanding,
|
|
1129
|
+
}),
|
|
1104
1130
|
...sessionAuthFragment,
|
|
1105
1131
|
...patAuthFragment,
|
|
1106
1132
|
...(mfaFeature && {
|
|
@@ -1222,18 +1248,6 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
1222
1248
|
});
|
|
1223
1249
|
}
|
|
1224
1250
|
|
|
1225
|
-
// 8. Build the AppSchema once + serialize. Wird beim Static-Fallback
|
|
1226
|
-
// in die index.html injiziert damit createKumikoApp() im Browser
|
|
1227
|
-
// `window.__KUMIKO_SCHEMA__` synchron lesen kann — gleicher Pfad
|
|
1228
|
-
// wie im dev-server, damit der Client-Code keine Sonderfall-
|
|
1229
|
-
// Branch zwischen dev/prod braucht. Boot-once weil Features
|
|
1230
|
-
// nach dem Start nicht mehr ändern.
|
|
1231
|
-
// TODO: Sobald per-Tenant- oder per-User-Schema kommt (Feature-Toggles
|
|
1232
|
-
// pro Tenant, Auth-Rolle gated Screens), muss die Injection pro
|
|
1233
|
-
// Request rendern — staticDir-Fallback einen render(req)-Hook bekommen
|
|
1234
|
-
// statt eines fixed JSON-Strings. Heute: registry-static, also OK.
|
|
1235
|
-
const appSchemaJson = JSON.stringify(buildAppSchema(registry, { searchAdapterMissing }));
|
|
1236
|
-
|
|
1237
1251
|
// `extraRoutes` itself is already mounted — buildServer wires it inside
|
|
1238
1252
|
// createApiEntrypoint/createAllInOneEntrypoint, before this function ever
|
|
1239
1253
|
// runs. `wire` is the remaining escape-hatch for app-wired co-running
|
|
@@ -1301,19 +1315,20 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
1301
1315
|
? buildStaticFallback(
|
|
1302
1316
|
entrypoint.app.fetch.bind(entrypoint.app),
|
|
1303
1317
|
options.staticDir,
|
|
1304
|
-
appSchemaJson,
|
|
1305
1318
|
options.hostDispatch,
|
|
1306
1319
|
options.resolvePageHead
|
|
1307
1320
|
? { resolvePageHead: options.resolvePageHead, dispatcher: entrypoint.dispatcher }
|
|
1308
1321
|
: undefined,
|
|
1309
1322
|
entrypoint.dispatcher,
|
|
1323
|
+
trustedProxyHops ?? 0,
|
|
1310
1324
|
)
|
|
1311
1325
|
: // No staticDir (split-deploy / API-only container) → app.fetch's
|
|
1312
1326
|
// response goes straight to the client, bypassing buildStaticFallback
|
|
1313
1327
|
// (and with it tryHonoFirst) entirely. Must strip the router-miss
|
|
1314
1328
|
// marker here too, same reason as the /api/* passthrough in
|
|
1315
1329
|
// run-prod-app-static-files.ts (see try-hono-first.ts).
|
|
1316
|
-
async (req: Request) =>
|
|
1330
|
+
async (req: Request, socketAddress?: string) =>
|
|
1331
|
+
stripNoRouteMatchHeader(await entrypoint.app.fetch(req, socketAddress)),
|
|
1317
1332
|
options.securityHeaders,
|
|
1318
1333
|
);
|
|
1319
1334
|
|
|
@@ -1338,7 +1353,13 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
1338
1353
|
"Under Node/vitest pass `autoListen: false` and call the returned `fetch()` directly.",
|
|
1339
1354
|
);
|
|
1340
1355
|
}
|
|
1341
|
-
handle.server = Bun.serve(
|
|
1356
|
+
handle.server = Bun.serve(
|
|
1357
|
+
buildBunServeOptions(
|
|
1358
|
+
listenPort,
|
|
1359
|
+
fetchHandler,
|
|
1360
|
+
options.maxRequestBodySize ?? resolveDerivedMaxRequestBodySize(registry),
|
|
1361
|
+
),
|
|
1362
|
+
);
|
|
1342
1363
|
|
|
1343
1364
|
// SIGTERM/SIGINT — graceful shutdown. Only registered when we
|
|
1344
1365
|
// actually own a Bun-server, otherwise the test process picks up
|
package/src/security-headers.ts
CHANGED
|
@@ -43,13 +43,13 @@ export function resolveSecurityHeaders(
|
|
|
43
43
|
// Sets each header only when absent so per-response values (e.g. the
|
|
44
44
|
// per-host CSP from hostDispatch) always win over the runtime default.
|
|
45
45
|
export function withSecurityHeaders(
|
|
46
|
-
handler: (req: Request) => Response | Promise<Response>,
|
|
46
|
+
handler: (req: Request, socketAddress?: string) => Response | Promise<Response>,
|
|
47
47
|
option: SecurityHeadersOption | undefined,
|
|
48
|
-
): (req: Request) => Response | Promise<Response> {
|
|
48
|
+
): (req: Request, socketAddress?: string) => Response | Promise<Response> {
|
|
49
49
|
const defaults = resolveSecurityHeaders(option);
|
|
50
50
|
if (defaults.length === 0) return handler;
|
|
51
|
-
return async (req: Request): Promise<Response> => {
|
|
52
|
-
const res = await handler(req);
|
|
51
|
+
return async (req: Request, socketAddress?: string): Promise<Response> => {
|
|
52
|
+
const res = await handler(req, socketAddress);
|
|
53
53
|
try {
|
|
54
54
|
for (const [name, value] of defaults) {
|
|
55
55
|
if (!res.headers.has(name)) res.headers.set(name, value);
|
package/src/try-hono-first.ts
CHANGED
|
@@ -31,11 +31,13 @@
|
|
|
31
31
|
import { NO_ROUTE_MATCH_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api";
|
|
32
32
|
|
|
33
33
|
export type HonoLikeApp = {
|
|
34
|
-
// Hono.app.fetch is `(req) => Response | Promise<Response>` (sync if
|
|
35
|
-
// handlers are sync, otherwise a Promise). createApiEntrypoint's
|
|
34
|
+
// Hono.app.fetch is `(req, env?) => Response | Promise<Response>` (sync if
|
|
35
|
+
// all handlers are sync, otherwise a Promise). createApiEntrypoint's
|
|
36
36
|
// apiHandler matches the same shape. The union accepts both — we await
|
|
37
|
-
// below, which works for either case.
|
|
38
|
-
|
|
37
|
+
// below, which works for either case. `socketAddress` rides in as `env`
|
|
38
|
+
// so requestIdMiddleware/rate-limit can read it via `c.env` without Hono
|
|
39
|
+
// ever seeing a raw Bun `Server`.
|
|
40
|
+
readonly fetch: (req: Request, socketAddress?: string) => Response | Promise<Response>;
|
|
39
41
|
};
|
|
40
42
|
|
|
41
43
|
export type HonoFirstResult = {
|
|
@@ -69,8 +71,12 @@ export function stripNoRouteMatchHeader(response: Response): Response {
|
|
|
69
71
|
* req.clone() because downstream needs to read the request body again
|
|
70
72
|
* (future-proofing for POST/PUT/PATCH — only GET routes today).
|
|
71
73
|
*/
|
|
72
|
-
export async function tryHonoFirst(
|
|
73
|
-
|
|
74
|
+
export async function tryHonoFirst(
|
|
75
|
+
app: HonoLikeApp,
|
|
76
|
+
req: Request,
|
|
77
|
+
socketAddress?: string,
|
|
78
|
+
): Promise<HonoFirstResult> {
|
|
79
|
+
const response = await app.fetch(req.clone(), socketAddress);
|
|
74
80
|
const isRouterMiss = response.status === 404 && response.headers.has(NO_ROUTE_MATCH_HEADER_NAME);
|
|
75
81
|
stripNoRouteMatchHeader(response);
|
|
76
82
|
return { matched: !isRouterMiss, response };
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
// injectSchema is shared by the dev-server (every HTML response) and
|
|
2
|
-
// prod-server (static-fallback index.html) paths. A bug here would be a
|
|
3
|
-
// silent production failure: createKumikoApp wouldn't find
|
|
4
|
-
// `window.__KUMIKO_SCHEMA__` and would mount empty. Tests pin idempotency
|
|
5
|
-
// plus the two insertion points (before the /client.js tag or before </body>).
|
|
6
|
-
|
|
7
|
-
import { describe, expect, test } from "bun:test";
|
|
8
|
-
import { injectSchema } from "../inject-schema";
|
|
9
|
-
|
|
10
|
-
const SCHEMA = '{"features":[]}';
|
|
11
|
-
const TAG = `<script>window.__KUMIKO_SCHEMA__=${SCHEMA};</script>`;
|
|
12
|
-
|
|
13
|
-
describe("injectSchema", () => {
|
|
14
|
-
test("HTML mit /client.js-Tag: Schema-Tag wird DAVOR eingefügt", () => {
|
|
15
|
-
const html = '<html><body><script src="/client.js" defer></script></body></html>';
|
|
16
|
-
const out = injectSchema(html, SCHEMA);
|
|
17
|
-
expect(out).toContain(TAG);
|
|
18
|
-
// Schema MUST come before the client bundle — otherwise createKumikoApp()
|
|
19
|
-
// runs before window.__KUMIKO_SCHEMA__ is set.
|
|
20
|
-
expect(out.indexOf(TAG)).toBeLessThan(out.indexOf('<script src="/client.js"'));
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
test("HTML mit module-form /client.js-Tag: Schema-Tag wird DAVOR eingefügt", () => {
|
|
24
|
-
const html = '<html><body><script type="module" src="/client.js"></script></body></html>';
|
|
25
|
-
const out = injectSchema(html, SCHEMA);
|
|
26
|
-
expect(out).toContain(TAG);
|
|
27
|
-
// Dev templates use `type="module"`, not the classic form — insertion
|
|
28
|
-
// must match this tag pattern too.
|
|
29
|
-
expect(out.indexOf(TAG)).toBeLessThan(out.indexOf('<script type="module" src="/client.js"'));
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test("HTML ohne /client.js-Tag: Schema-Tag wird vor </body> eingefügt", () => {
|
|
33
|
-
const html = "<html><body><div id=root></div></body></html>";
|
|
34
|
-
const out = injectSchema(html, SCHEMA);
|
|
35
|
-
expect(out).toContain(TAG);
|
|
36
|
-
expect(out.indexOf(TAG)).toBeLessThan(out.indexOf("</body>"));
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("HTML ohne </body>: Schema-Tag wird angehängt (defensiver Fallback)", () => {
|
|
40
|
-
const html = "<div>fragment</div>";
|
|
41
|
-
const out = injectSchema(html, SCHEMA);
|
|
42
|
-
expect(out.endsWith(TAG)).toBe(true);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test("Idempotent: bei bereits injectem Schema kein zweiter Tag", () => {
|
|
46
|
-
const html = `<html><body>${TAG}</body></html>`;
|
|
47
|
-
const out = injectSchema(html, '{"features":[{"differentSchema":true}]}');
|
|
48
|
-
// Original tag stays, no second tag added — the marker check otherwise
|
|
49
|
-
// prevents stacking tags on repeated reads.
|
|
50
|
-
expect(out).toBe(html);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test("Schema mit Komplex-Daten (entities + screens) bleibt valides JS", () => {
|
|
54
|
-
const complex = JSON.stringify({
|
|
55
|
-
features: [
|
|
56
|
-
{
|
|
57
|
-
featureName: "items",
|
|
58
|
-
entities: { item: { fields: { title: { type: "text" } } } },
|
|
59
|
-
screens: [{ id: "list", type: "entityList", entity: "item", columns: ["title"] }],
|
|
60
|
-
},
|
|
61
|
-
],
|
|
62
|
-
});
|
|
63
|
-
const html = '<html><body><script src="/client.js"></script></body></html>';
|
|
64
|
-
const out = injectSchema(html, complex);
|
|
65
|
-
// Sanity: the injected script must be valid JS (object-literal syntax,
|
|
66
|
-
// no HTML-reserved chars in the JSON that would break the <script>
|
|
67
|
-
// block — JSON.stringify doesn't escape /, < etc., but the standard
|
|
68
|
-
// chars used here are harmless).
|
|
69
|
-
expect(out).toContain(`window.__KUMIKO_SCHEMA__=${complex}`);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("Schema mit $-Replacement-Patterns bleibt byte-identisch (kein HTML-Splicing)", () => {
|
|
73
|
-
// String.prototype.replace() interprets $$, $&, $`, $' specially in the
|
|
74
|
-
// REPLACEMENT argument. The injected schema JSON string lands there — an
|
|
75
|
-
// i18n label containing "$'" or similar would otherwise splice parts of
|
|
76
|
-
// `html` into the script tag. A replacer-function argument sidesteps that.
|
|
77
|
-
const dollarSchema = '{"label":"$\' tail $$ amp $& end"}';
|
|
78
|
-
const dollarTag = `<script>window.__KUMIKO_SCHEMA__=${dollarSchema};</script>`;
|
|
79
|
-
const html = '<html><body><script src="/client.js"></script></body></html>';
|
|
80
|
-
const out = injectSchema(html, dollarSchema);
|
|
81
|
-
// Insertion point is right before the /client.js tag, not the start of
|
|
82
|
-
// the document — built via indexOf/slice, not .replace(), so the
|
|
83
|
-
// expected value doesn't itself run through the same $-pattern footgun.
|
|
84
|
-
const clientScriptIdx = html.indexOf('<script src="/client.js"');
|
|
85
|
-
const expected = html.slice(0, clientScriptIdx) + dollarTag + html.slice(clientScriptIdx);
|
|
86
|
-
expect(out).toBe(expected);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
test("Schema mit $-Replacement-Patterns, Insertion vor </body>", () => {
|
|
90
|
-
const dollarSchema = '{"label":"$\' tail $$ amp $& end"}';
|
|
91
|
-
const dollarTag = `<script>window.__KUMIKO_SCHEMA__=${dollarSchema};</script>`;
|
|
92
|
-
const html = "<html><body><div id=root></div></body></html>";
|
|
93
|
-
const out = injectSchema(html, dollarSchema);
|
|
94
|
-
// Built via slice/concat, not .replace() — the expected value must not
|
|
95
|
-
// itself run through the same $-pattern footgun the fix removes.
|
|
96
|
-
const bodyIdx = html.indexOf("</body>");
|
|
97
|
-
const expected = html.slice(0, bodyIdx) + dollarTag + html.slice(bodyIdx);
|
|
98
|
-
expect(out).toBe(expected);
|
|
99
|
-
});
|
|
100
|
-
});
|
package/src/inject-schema.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
// Injiziert das Server-aufgelöste AppSchema in das HTML-Template damit
|
|
2
|
-
// createKumikoApp() es synchron unter `window.__KUMIKO_SCHEMA__` vorfindet.
|
|
3
|
-
// JSON ist valides JS — direkt eingebettet, der Browser parsed das
|
|
4
|
-
// Object-Literal nativ.
|
|
5
|
-
//
|
|
6
|
-
// Geteilt zwischen dev-server (Schema kommt frisch aus dem laufenden
|
|
7
|
-
// Prozess) und prod-server (Schema wird beim Boot einmal berechnet und
|
|
8
|
-
// in die statisch ausgelieferte index.html injiziert). Beide Pfade
|
|
9
|
-
// nutzen dieselbe Tag-Form damit `createKumikoApp` den Lookup nicht je
|
|
10
|
-
// nach Kontext anders machen muss.
|
|
11
|
-
//
|
|
12
|
-
// Idempotenz: wenn das HTML schon einen __KUMIKO_SCHEMA__-Marker hat,
|
|
13
|
-
// wird nicht doppelt injected — verhindert dass repeated index.html-
|
|
14
|
-
// Reads (prod) oder bereits-vorbereitete templates (custom CI-builds)
|
|
15
|
-
// stacking-Tags produzieren.
|
|
16
|
-
|
|
17
|
-
// `<` als < serialisieren: verhindert `</script>`-Breakout aus dem
|
|
18
|
-
// RAWTEXT-Block, JSON bleibt valides JS.
|
|
19
|
-
function scriptSafeJsonHtml(json: string): string {
|
|
20
|
-
return json.replace(/</g, "\\u003c");
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const CLIENT_SCRIPT_TAG_RE = /<script\b[^>]*\ssrc="\/client\.js"/;
|
|
24
|
-
|
|
25
|
-
export function injectSchema(html: string, schemaJson: string): string {
|
|
26
|
-
if (html.includes("__KUMIKO_SCHEMA__")) return html;
|
|
27
|
-
const tag = `<script>window.__KUMIKO_SCHEMA__=${scriptSafeJsonHtml(schemaJson)};</script>`;
|
|
28
|
-
const clientScriptMatch = html.match(CLIENT_SCRIPT_TAG_RE);
|
|
29
|
-
if (clientScriptMatch) {
|
|
30
|
-
return html.replace(CLIENT_SCRIPT_TAG_RE, (m) => `${tag}${m}`);
|
|
31
|
-
}
|
|
32
|
-
return html.includes("</body>") ? html.replace("</body>", () => `${tag}</body>`) : html + tag;
|
|
33
|
-
}
|