@cosmicdrift/kumiko-dev-server 0.288.0 → 0.290.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dev-server",
3
- "version": "0.288.0",
3
+ "version": "0.290.0",
4
4
  "description": "Dev-tooling for Kumiko apps: local dev-server bootstrap (runDevApp), scaffolding, codegen. Its compose-stacks/env-schema subpaths are consumed at prod boot too — see @cosmicdrift/kumiko-server-runtime for the prod runner itself.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -59,9 +59,10 @@
59
59
  "kumiko-upgrade": "./bin/kumiko-upgrade.ts"
60
60
  },
61
61
  "dependencies": {
62
- "@cosmicdrift/kumiko-bundled-features": "0.288.0",
63
- "@cosmicdrift/kumiko-framework": "0.288.0",
64
- "@cosmicdrift/kumiko-server-runtime": "0.288.0",
62
+ "@cosmicdrift/kumiko-bundled-features": "0.290.0",
63
+ "@cosmicdrift/kumiko-framework": "0.290.0",
64
+ "@cosmicdrift/kumiko-headless": "0.290.0",
65
+ "@cosmicdrift/kumiko-server-runtime": "0.290.0",
65
66
  "ts-morph": "^28.0.0"
66
67
  },
67
68
  "publishConfig": {
@@ -509,6 +509,83 @@ describe("createKumikoServer — hot-reload broadcast", () => {
509
509
  });
510
510
  });
511
511
 
512
+ // kumiko-framework#3026: runDevApp gains resolvePageHead parity with
513
+ // runProdApp — both now call the same @cosmicdrift/kumiko-headless/apex
514
+ // resolveAndInjectPageHead, so a resolver produces the same tags in the
515
+ // same order under dev as under prod (see run-prod-app-static-files.test.ts's
516
+ // "resolver meta → og-tags appear" case for the prod-side equivalent). The
517
+ // real 300ms-timeout path is covered once, in headless's own unit test for
518
+ // resolveAndInjectPageHead (same shared function) — no need to re-run a slow
519
+ // timeout wait here too.
520
+ describe("createKumikoServer resolvePageHead", () => {
521
+ test("resolver meta → head tags injected, same tags + order as runProdApp", async () => {
522
+ handle = await createKumikoServer({
523
+ features: [probeFeature],
524
+ port: 0,
525
+ installSignalHandlers: false,
526
+ resolvePageHead: async () => ({
527
+ title: "Vehicle X",
528
+ description: "A car",
529
+ ogImage: "https://x/i.png",
530
+ }),
531
+ });
532
+ const res = await handle.fetch(new Request("http://localhost/"));
533
+ expect(res.status).toBe(200);
534
+ const body = await res.text();
535
+ expect(body).not.toContain("<title>Kumiko</title>");
536
+ expect((body.match(/kumiko-page-head/g) ?? []).length).toBe(1);
537
+ expect(body).toContain("<title>Vehicle X</title>");
538
+ expect(body).toContain('<meta name="description" content="A car" />');
539
+ expect(body).toContain('<meta property="og:title" content="Vehicle X" />');
540
+ expect(body).toContain('<meta property="og:description" content="A car" />');
541
+ expect(body).toContain('<meta property="og:image" content="https://x/i.png" />');
542
+
543
+ const titleIdx = body.indexOf("<title>Vehicle X</title>");
544
+ const descIdx = body.indexOf('<meta name="description"');
545
+ const ogTitleIdx = body.indexOf('<meta property="og:title"');
546
+ const ogDescIdx = body.indexOf('<meta property="og:description"');
547
+ const ogImageIdx = body.indexOf('<meta property="og:image"');
548
+ expect(titleIdx).toBeLessThan(descIdx);
549
+ expect(descIdx).toBeLessThan(ogTitleIdx);
550
+ expect(ogTitleIdx).toBeLessThan(ogDescIdx);
551
+ expect(ogDescIdx).toBeLessThan(ogImageIdx);
552
+ });
553
+
554
+ test("resolver throws → 200 with the unchanged default shell, not a 500", async () => {
555
+ handle = await createKumikoServer({
556
+ features: [probeFeature],
557
+ port: 0,
558
+ installSignalHandlers: false,
559
+ resolvePageHead: async () => {
560
+ throw new Error("boom");
561
+ },
562
+ });
563
+ const res = await handle.fetch(new Request("http://localhost/"));
564
+ expect(res.status).toBe(200);
565
+ const body = await res.text();
566
+ expect(body).not.toContain("kumiko-page-head");
567
+ expect(body).toMatch(/<div id="root">/);
568
+ });
569
+
570
+ test("resolver returns null → 200 with the unchanged default shell", async () => {
571
+ handle = await createKumikoServer({
572
+ features: [probeFeature],
573
+ port: 0,
574
+ installSignalHandlers: false,
575
+ resolvePageHead: async () => null,
576
+ });
577
+ const res = await handle.fetch(new Request("http://localhost/"));
578
+ expect(res.status).toBe(200);
579
+ expect(await res.text()).not.toContain("kumiko-page-head");
580
+ });
581
+
582
+ test("no resolvePageHead configured → no page-head marker in the response", async () => {
583
+ const h = await boot();
584
+ const res = await h.fetch(new Request("http://localhost/"));
585
+ expect(await res.text()).not.toContain("kumiko-page-head");
586
+ });
587
+ });
588
+
512
589
  // kumiko-framework#2435: tryHonoFirst used to decide "no route matched"
513
590
  // purely from the status code — a matched httpRoute answering 404 on
514
591
  // purpose (e.g. default-deny reads) was indistinguishable from an
@@ -0,0 +1,8 @@
1
+ [
2
+ {
3
+ "version": "0.289.0",
4
+ "type": "improvement",
5
+ "title": "runDevApp gains resolvePageHead for dev/e2e parity with runProdApp",
6
+ "detail": "RunDevAppOptions/CreateKumikoServerOptions gain resolvePageHead, same PageHeadResolver signature as RunProdAppOptions. Applied to every templated HTML response (default single-entry shell and a host-dispatched \"html\" entry) via the shared @cosmicdrift/kumiko-headless/apex resolveAndInjectPageHead — same 300ms timeout, same error/null/timeout-falls-back-to-unchanged-200-shell semantics as prod (kumiko-framework#3026). The dev-only static-html hostDispatch kind (raw file passthrough, no bundle/schema injection either) is unaffected. No behavior change for apps that don't set resolvePageHead."
7
+ }
8
+ ]
@@ -20,8 +20,17 @@ import { readFile, realpath, watch } from "node:fs/promises";
20
20
  import { tmpdir } from "node:os";
21
21
  import { join, resolve, sep } from "node:path";
22
22
  import { resolveAnonymousAccessFromRegistry } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
23
- import { type AuthRoutesConfig, generateToken } from "@cosmicdrift/kumiko-framework/api";
24
- import { buildAppSchema, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
23
+ import {
24
+ type AuthRoutesConfig,
25
+ buildRequestContextDataFromRequest,
26
+ generateToken,
27
+ requestContext,
28
+ } from "@cosmicdrift/kumiko-framework/api";
29
+ import {
30
+ buildAppSchema,
31
+ createAnonymousUser,
32
+ type FeatureDefinition,
33
+ } from "@cosmicdrift/kumiko-framework/engine";
25
34
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
26
35
  import {
27
36
  pushEntityProjectionTables,
@@ -30,6 +39,11 @@ import {
30
39
  type TestStackOptions,
31
40
  TestUsers,
32
41
  } from "@cosmicdrift/kumiko-framework/stack";
42
+ import {
43
+ type PageHeadResolver,
44
+ type PageHeadSystemQuery,
45
+ resolveAndInjectPageHead,
46
+ } from "@cosmicdrift/kumiko-headless/apex";
33
47
  import { startDevJobRunners } from "@cosmicdrift/kumiko-server-runtime/boot/job-run-logger";
34
48
  import { buildBunServeOptions } from "@cosmicdrift/kumiko-server-runtime/bun-serve-options";
35
49
  import {
@@ -209,6 +223,16 @@ export type CreateKumikoServerOptions = {
209
223
  * /og-image, …) Vorrang vor dem Dev-Asset-Pfad haben. `deps` statt
210
224
  * `ctx` weil dies kein HandlerContext ist — kein user/tenant. */
211
225
  readonly extraRoutes?: (app: import("hono").Hono, deps: ExtraRoutesSystemDeps) => void;
226
+ /** Per-request head-metadata resolver (Open-Graph/title/description) —
227
+ * same option, same `PageHeadResolver` signature, and same shared
228
+ * resolve+inject call (`resolveAndInjectPageHead`) as `runProdApp`'s
229
+ * `resolvePageHead`, so dev and e2e exercise the exact code prod does
230
+ * (kumiko-framework#3026). Consulted for every templated HTML response
231
+ * (the default single-entry shell and a host-dispatched `"html"` entry),
232
+ * right before it's served. On error, `null`, or a timeout, the shell
233
+ * ships unchanged with status 200 — never a 500. `systemQuery` inside
234
+ * the resolver runs as the anonymous role, same as prod. */
235
+ readonly resolvePageHead?: PageHeadResolver;
212
236
  };
213
237
 
214
238
  export type KumikoServerHandle = {
@@ -918,7 +942,15 @@ export async function createKumikoServer(
918
942
  // bestimmt nachdem er hostDispatch evaluiert hat. Ohne hostDispatch
919
943
  // ist es immer "client" mit Schema-Inject true (Single-Entry-Default
920
944
  // damit der Client TypeScript-Schemas findet).
921
- const htmlResponse = async (entryName: string, doInjectSchema: boolean): Promise<Response> => {
945
+ //
946
+ // resolvePageHead goes through the same headless resolveAndInjectPageHead
947
+ // that runProdApp uses, so dev and e2e exercise the one timeout and
948
+ // fallback path instead of a second copy of it (#3026).
949
+ const htmlResponse = async (
950
+ entryName: string,
951
+ doInjectSchema: boolean,
952
+ req: Request,
953
+ ): Promise<Response> => {
922
954
  const template = htmlTemplates.get(entryName) ?? defaultTemplate;
923
955
  const headers = new Headers();
924
956
  headers.set("Content-Type", "text/html; charset=utf-8");
@@ -931,6 +963,19 @@ export async function createKumikoServer(
931
963
  let html = injectReload(template);
932
964
  if (stylesheetPath !== undefined) html = injectStylesheet(html);
933
965
  if (doInjectSchema) html = injectSchema(html, appSchemaJson);
966
+ if (options.resolvePageHead !== undefined) {
967
+ const url = new URL(req.url);
968
+ const host = req.headers.get("host") ?? url.host;
969
+ const systemQuery: PageHeadSystemQuery = (type, payload, tenantId) =>
970
+ requestContext.run(requestContext.get() ?? buildRequestContextDataFromRequest(req), () =>
971
+ stack.dispatcher.query(type, payload, createAnonymousUser(tenantId)),
972
+ );
973
+ html = await resolveAndInjectPageHead(html, options.resolvePageHead, {
974
+ path: url.pathname,
975
+ host,
976
+ systemQuery,
977
+ });
978
+ }
934
979
  return new Response(html, { headers });
935
980
  };
936
981
 
@@ -1058,9 +1103,9 @@ export async function createKumikoServer(
1058
1103
  headers: { "Content-Type": "text/html; charset=utf-8" },
1059
1104
  });
1060
1105
  }
1061
- return htmlResponse(dispatch.entryName, dispatch.injectSchema ?? true);
1106
+ return htmlResponse(dispatch.entryName, dispatch.injectSchema ?? true, req);
1062
1107
  }
1063
- return htmlResponse("client", true);
1108
+ return htmlResponse("client", true, req);
1064
1109
  }
1065
1110
 
1066
1111
  // Static assets under public/ — see tryServePublicAsset's own comment
@@ -45,10 +45,6 @@ import {
45
45
  SESSIONS_FEATURE,
46
46
  } from "@cosmicdrift/kumiko-bundled-features/sessions";
47
47
  import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
48
- import {
49
- resolveTenantLifecycleGate,
50
- TENANT_LIFECYCLE_FEATURE,
51
- } from "@cosmicdrift/kumiko-bundled-features/tenant-lifecycle";
52
48
  import type { SessionMetadata, TokenVerifier } from "@cosmicdrift/kumiko-framework/api";
53
49
  import { createInMemoryLoginRateLimiter } from "@cosmicdrift/kumiko-framework/api";
54
50
  import {
@@ -56,7 +52,6 @@ import {
56
52
  configurePiiSubjectKms,
57
53
  type KmsAdapter,
58
54
  } from "@cosmicdrift/kumiko-framework/crypto";
59
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
60
55
  import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
61
56
  import {
62
57
  collectWriteHandlerQns,
@@ -74,6 +69,7 @@ import {
74
69
  import type { EnvelopeCipher, MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
75
70
  import type { TestStack } from "@cosmicdrift/kumiko-framework/stack";
76
71
  import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
72
+ import type { PageHeadResolver } from "@cosmicdrift/kumiko-headless/apex";
77
73
  import { applyBootSeeds } from "@cosmicdrift/kumiko-server-runtime/boot/apply-boot-seeds";
78
74
  import { resolveBootCrypto } from "@cosmicdrift/kumiko-server-runtime/boot/boot-crypto";
79
75
  import {
@@ -187,6 +183,11 @@ export type RunDevAppOptions = {
187
183
  * geforderten — sonst weiß der Server nicht welche HTML er liefern
188
184
  * soll. */
189
185
  readonly hostDispatch?: CreateKumikoServerOptions["hostDispatch"];
186
+ /** Per-request head-metadata resolver — same option, same signature as
187
+ * `runProdApp`'s `resolvePageHead`, so `bun dev` and every e2e that boots
188
+ * through `runDevApp` show the same resolved head prod does. See
189
+ * `CreateKumikoServerOptions.resolvePageHead` for the full contract. */
190
+ readonly resolvePageHead?: PageHeadResolver;
190
191
  /** CSS-Entry. Default: package-export `@cosmicdrift/kumiko-renderer-web/styles.css`
191
192
  * wenn ein client-Entry gesetzt ist. `false` deaktiviert die CSS-Pipeline. */
192
193
  readonly stylesheet?: string | false;
@@ -431,10 +432,8 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
431
432
  // needs the real db+registry (only concrete after setupTestStack). Wired
432
433
  // whenever a provider feature (personal-access-tokens today) is mounted.
433
434
  let tokenVerifier: TokenVerifier | undefined;
434
- let lifecycleDb: DbConnection | undefined;
435
435
  const patFeature = features.find((f) => f.name === PAT_FEATURE);
436
436
  const mfaFeature = features.find((f) => f.name === AUTH_MFA_FEATURE);
437
- const tenantLifecycleFeature = features.find((f) => f.name === TENANT_LIFECYCLE_FEATURE);
438
437
  const patAuthFragment = patFeature
439
438
  ? {
440
439
  tokenVerifier: (rawToken: string) => {
@@ -450,18 +449,6 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
450
449
  }
451
450
  : {};
452
451
 
453
- const tenantLifecycleAuthFragment = tenantLifecycleFeature
454
- ? {
455
- resolveTenantLifecycleStatus: async (tenantId: string) => {
456
- if (!lifecycleDb) {
457
- throw new Error("[runDevApp] tenant-lifecycle gate accessed before onAfterSetup");
458
- }
459
- const gate = await resolveTenantLifecycleGate(lifecycleDb, tenantId);
460
- return gate ? { status: gate.status } : null;
461
- },
462
- }
463
- : {};
464
-
465
452
  // Parity with runProdApp (#1372 review): gate on sessionStore provider, not
466
453
  // the sessions feature name — a custom provider must also wire auth
467
454
  // callbacks. sessionStoreProviderMounted computed above via the registry
@@ -482,6 +469,7 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
482
469
  ...(options.clientEntry !== undefined && { clientEntry: options.clientEntry }),
483
470
  ...(options.clientEntries !== undefined && { clientEntries: options.clientEntries }),
484
471
  ...(options.hostDispatch !== undefined && { hostDispatch: options.hostDispatch }),
472
+ ...(options.resolvePageHead !== undefined && { resolvePageHead: options.resolvePageHead }),
485
473
  ...(options.stylesheet !== undefined && { stylesheet: options.stylesheet }),
486
474
  ...(options.htmlPath !== undefined && { htmlPath: options.htmlPath }),
487
475
  ...(options.port !== undefined && { port: options.port }),
@@ -518,7 +506,6 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
518
506
  }),
519
507
  ...sessionAuthFragment,
520
508
  ...patAuthFragment,
521
- ...tenantLifecycleAuthFragment,
522
509
  ...(mfaFeature && {
523
510
  mfaVerifyHandler: AuthMfaHandlers.verify,
524
511
  mfaPreauthEnableStartHandler: AuthMfaHandlers.enableStartPreauth,
@@ -558,7 +545,6 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
558
545
  },
559
546
  }),
560
547
  onAfterSetup: async (stack) => {
561
- lifecycleDb = stack.db;
562
548
  // Sprint-8a: build tier-resolver BEFORE any seeds so seeds can rely
563
549
  // on the resolver being live (e.g. seed that writes a SystemAdmin's
564
550
  // tier-assignment can immediately read tier-cuts).