@cosmicdrift/kumiko-dev-server 0.288.0 → 0.289.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.289.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.289.0",
63
+ "@cosmicdrift/kumiko-framework": "0.289.0",
64
+ "@cosmicdrift/kumiko-headless": "0.289.0",
65
+ "@cosmicdrift/kumiko-server-runtime": "0.289.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
@@ -74,6 +74,7 @@ import {
74
74
  import type { EnvelopeCipher, MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
75
75
  import type { TestStack } from "@cosmicdrift/kumiko-framework/stack";
76
76
  import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
77
+ import type { PageHeadResolver } from "@cosmicdrift/kumiko-headless/apex";
77
78
  import { applyBootSeeds } from "@cosmicdrift/kumiko-server-runtime/boot/apply-boot-seeds";
78
79
  import { resolveBootCrypto } from "@cosmicdrift/kumiko-server-runtime/boot/boot-crypto";
79
80
  import {
@@ -187,6 +188,11 @@ export type RunDevAppOptions = {
187
188
  * geforderten — sonst weiß der Server nicht welche HTML er liefern
188
189
  * soll. */
189
190
  readonly hostDispatch?: CreateKumikoServerOptions["hostDispatch"];
191
+ /** Per-request head-metadata resolver — same option, same signature as
192
+ * `runProdApp`'s `resolvePageHead`, so `bun dev` and every e2e that boots
193
+ * through `runDevApp` show the same resolved head prod does. See
194
+ * `CreateKumikoServerOptions.resolvePageHead` for the full contract. */
195
+ readonly resolvePageHead?: PageHeadResolver;
190
196
  /** CSS-Entry. Default: package-export `@cosmicdrift/kumiko-renderer-web/styles.css`
191
197
  * wenn ein client-Entry gesetzt ist. `false` deaktiviert die CSS-Pipeline. */
192
198
  readonly stylesheet?: string | false;
@@ -482,6 +488,7 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
482
488
  ...(options.clientEntry !== undefined && { clientEntry: options.clientEntry }),
483
489
  ...(options.clientEntries !== undefined && { clientEntries: options.clientEntries }),
484
490
  ...(options.hostDispatch !== undefined && { hostDispatch: options.hostDispatch }),
491
+ ...(options.resolvePageHead !== undefined && { resolvePageHead: options.resolvePageHead }),
485
492
  ...(options.stylesheet !== undefined && { stylesheet: options.stylesheet }),
486
493
  ...(options.htmlPath !== undefined && { htmlPath: options.htmlPath }),
487
494
  ...(options.port !== undefined && { port: options.port }),