@cosmicdrift/kumiko-dev-server 0.326.1 → 0.328.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.326.1",
3
+ "version": "0.328.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>",
@@ -60,10 +60,10 @@
60
60
  "kumiko-upgrade": "./bin/kumiko-upgrade.ts"
61
61
  },
62
62
  "dependencies": {
63
- "@cosmicdrift/kumiko-bundled-features": "0.326.1",
64
- "@cosmicdrift/kumiko-framework": "0.326.1",
65
- "@cosmicdrift/kumiko-headless": "0.326.1",
66
- "@cosmicdrift/kumiko-server-runtime": "0.326.1",
63
+ "@cosmicdrift/kumiko-bundled-features": "0.328.0",
64
+ "@cosmicdrift/kumiko-framework": "0.328.0",
65
+ "@cosmicdrift/kumiko-headless": "0.328.0",
66
+ "@cosmicdrift/kumiko-server-runtime": "0.328.0",
67
67
  "ts-morph": "^28.0.0"
68
68
  },
69
69
  "publishConfig": {
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import {
@@ -927,6 +927,55 @@ describe("createKumikoServer — public/ static files", () => {
927
927
  });
928
928
  });
929
929
 
930
+ describe("createKumikoServer — renderer-web fonts under /assets/kumiko/fonts", () => {
931
+ const FONTS_SOURCE_DIR = join(import.meta.dir, "../../../renderer-web/src/fonts");
932
+
933
+ async function withFixtureCwd(run: () => Promise<void>): Promise<void> {
934
+ const tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "kumiko-fonts-")));
935
+ const cwdBefore = process.cwd();
936
+ process.chdir(tmpDir);
937
+ try {
938
+ handle = await createKumikoServer({
939
+ features: [probeFeature],
940
+ port: 0,
941
+ installSignalHandlers: false,
942
+ });
943
+ await run();
944
+ } finally {
945
+ process.chdir(cwdBefore);
946
+ rmSync(tmpDir, { recursive: true, force: true });
947
+ }
948
+ }
949
+
950
+ test("GET on a shipped font → 200, font/woff2, the file's bytes", async () => {
951
+ await withFixtureCwd(async () => {
952
+ const res = await handle?.fetch(
953
+ new Request("http://localhost/assets/kumiko/fonts/ibm-plex-sans-latin-400.woff2"),
954
+ );
955
+ expect(res?.status).toBe(200);
956
+ expect(res?.headers.get("content-type")).toBe("font/woff2");
957
+ const expected = readFileSync(join(FONTS_SOURCE_DIR, "ibm-plex-sans-latin-400.woff2"));
958
+ expect(Buffer.from(await (res as Response).arrayBuffer()).equals(expected)).toBe(true);
959
+ });
960
+ });
961
+
962
+ test("unknown names and traversal attempts → 404, never file content", async () => {
963
+ await withFixtureCwd(async () => {
964
+ for (const path of [
965
+ "/assets/kumiko/fonts/nope.woff2",
966
+ "/assets/kumiko/fonts/OFL.txt",
967
+ "/assets/kumiko/fonts/../styles.css",
968
+ "/assets/kumiko/fonts/..%2fstyles.css",
969
+ "/assets/kumiko/fonts/%2e%2e%2f%2e%2e%2fpackage.json",
970
+ "/assets/kumiko/fonts/ibm-plex-sans-latin-400.woff2/x",
971
+ ]) {
972
+ const res = await handle?.fetch(new Request(`http://localhost${path}`));
973
+ expect(res?.status, path).toBe(404);
974
+ }
975
+ });
976
+ });
977
+ });
978
+
930
979
  describe("createKumikoServer — real Bun.serve socket resolves the client IP (fw#3260 addendum)", () => {
931
980
  // Every other test in this file drives h.fetch()/handle.fetch() directly,
932
981
  // bypassing Bun.serve's real TCP socket entirely — none of them can catch
@@ -0,0 +1,72 @@
1
+ // setupTestStackFromFeatures wires prod's job run-logger by default: a job
2
+ // declared with tenantVisibleFailure records its final failure per tenant, and
3
+ // drainJobs() only rejects after that row is written.
4
+
5
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
+ import { createJobsFeature, JobQueries } from "@cosmicdrift/kumiko-bundled-features/jobs";
7
+ import { defineFeature, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
8
+ import { createTestUser, type TestStack, testTenantId } from "@cosmicdrift/kumiko-framework/stack";
9
+ import * as z from "zod";
10
+ import { setupTestStackFromFeatures } from "../setup-test-stack-from-features";
11
+
12
+ const FAILURE_KEY = "app:errors.generationFailed";
13
+ const PROVIDER_MESSAGE = "provider 429: prompt with customer data rejected";
14
+
15
+ const tenantA = testTenantId(1);
16
+ const tenantB = testTenantId(2);
17
+ const userA = createTestUser({ id: 1, tenantId: tenantA, roles: ["Admin"] });
18
+ const userB = createTestUser({ id: 2, tenantId: tenantB, roles: ["Admin"] });
19
+
20
+ const appFeature = defineFeature("app", (r) => {
21
+ r.writeHandler(
22
+ defineWriteHandler({
23
+ name: "generate",
24
+ description: "Test-only: starts a job that always fails.",
25
+ schema: z.object({ campaignId: z.string() }),
26
+ access: { roles: ["Admin"] },
27
+ handler: async (event) => ({ isSuccess: true as const, data: { ...event.payload } }),
28
+ }),
29
+ );
30
+ r.job(
31
+ "generateTexts",
32
+ {
33
+ trigger: { on: "app:write:generate" },
34
+ retries: 0,
35
+ tenantVisibleFailure: { messageKey: FAILURE_KEY, subjectFields: ["campaignId"] },
36
+ },
37
+ async () => {
38
+ throw new Error(PROVIDER_MESSAGE);
39
+ },
40
+ );
41
+ });
42
+
43
+ type FailureRow = { readonly jobName: string; readonly messageKey: string };
44
+
45
+ let stack: TestStack;
46
+
47
+ beforeAll(async () => {
48
+ stack = await setupTestStackFromFeatures([appFeature, createJobsFeature()], {
49
+ jobs: { consumerLane: "worker" },
50
+ });
51
+ });
52
+
53
+ afterAll(async () => {
54
+ await stack.cleanup();
55
+ });
56
+
57
+ describe("setupTestStackFromFeatures({ jobs }) run-logger default", () => {
58
+ test("a failed tenant-visible job is readable by its tenant only, once drainJobs() rejects", async () => {
59
+ await stack.http.writeOk("app:write:generate", { campaignId: "campaign-1" }, userA);
60
+
61
+ await expect(stack.drainJobs()).rejects.toThrow(/app:job:generate-texts/);
62
+
63
+ const own = await stack.http.queryOk<{ rows: FailureRow[] }>(JobQueries.failures, {}, userA);
64
+ expect(own.rows).toHaveLength(1);
65
+ expect(own.rows[0]?.jobName).toBe("app:job:generate-texts");
66
+ expect(own.rows[0]?.messageKey).toBe(FAILURE_KEY);
67
+ expect(JSON.stringify(own)).not.toContain(PROVIDER_MESSAGE);
68
+
69
+ const other = await stack.http.queryOk<{ rows: FailureRow[] }>(JobQueries.failures, {}, userB);
70
+ expect(other.rows).toHaveLength(0);
71
+ });
72
+ });
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.328.0",
4
+ "type": "improvement",
5
+ "title": "Dev server serves the renderer-web fonts",
6
+ "detail": "GET `/assets/kumiko/fonts/<file>.woff2` streams the packaged IBM Plex files with `font/woff2` and a one day cache header. Names outside the allowlist answer 404."
7
+ },
2
8
  {
3
9
  "version": "0.323.0",
4
10
  "type": "improvement",
@@ -55,6 +55,11 @@ import {
55
55
  makeDispatchSystemWrite,
56
56
  type SystemWireDeps,
57
57
  } from "@cosmicdrift/kumiko-server-runtime/extra-routes-deps";
58
+ import {
59
+ RENDERER_WEB_FONT_FILE_PATTERN,
60
+ RENDERER_WEB_FONTS_URL_PREFIX,
61
+ resolveRendererWebFontsDir,
62
+ } from "@cosmicdrift/kumiko-server-runtime/renderer-web-fonts";
58
63
  import {
59
64
  canResolveTailwindStylesheet,
60
65
  resolveTailwindCli,
@@ -72,8 +77,8 @@ import {
72
77
  // Bun.serve + real sockets.
73
78
  const hasBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
74
79
 
75
- // Bun.serve returns a parametrised Server<WebSocketData>; we don't
76
- // touch WebSockets here, so the narrow `unknown` binding is plenty.
80
+ // Bun.serve returns a parametrised Server<WebSocketData>; nothing here reads
81
+ // the socket data, so the narrow `unknown` binding is plenty.
77
82
  // `Bun` isn't declared in Node types, so we fall back to `unknown`
78
83
  // and only resolve the type when Bun is actually around.
79
84
  type BunServer = typeof Bun extends undefined ? unknown : ReturnType<typeof Bun.serve>;
@@ -458,6 +463,7 @@ const PUBLIC_FILE_MIME_TYPES = new Map<string, string>([
458
463
  ["txt", "text/plain; charset=utf-8"],
459
464
  ["xml", "application/xml; charset=utf-8"],
460
465
  ["webmanifest", "application/manifest+json"],
466
+ ["woff2", "font/woff2"],
461
467
  ]);
462
468
 
463
469
  function publicFileMimeType(filePath: string): string {
@@ -1081,6 +1087,9 @@ export async function createKumikoServer(
1081
1087
  // App-root convention (same as expandWatchPatterns/resolveStylesheet above):
1082
1088
  // process.cwd() is the app workspace, so public/ is its static asset dir.
1083
1089
  const publicDir = resolve(process.cwd(), "public");
1090
+ // The app's cwd first, this package's own location as fallback (the cwd may
1091
+ // not have renderer-web installed, e.g. a fixture dir).
1092
+ const rendererWebFontsDir = resolveRendererWebFontsDir([process.cwd(), import.meta.dir]);
1084
1093
 
1085
1094
  const handleFetch = async (req: Request, socketAddress?: string): Promise<Response> => {
1086
1095
  const url = new URL(req.url);
@@ -1122,6 +1131,26 @@ export async function createKumikoServer(
1122
1131
  }
1123
1132
  }
1124
1133
 
1134
+ if (url.pathname.startsWith(RENDERER_WEB_FONTS_URL_PREFIX) && req.method === "GET") {
1135
+ const fontFile = url.pathname.slice(RENDERER_WEB_FONTS_URL_PREFIX.length);
1136
+ // The allowlist pattern excludes "/", "." segments and encoded traversal.
1137
+ if (rendererWebFontsDir === undefined || !RENDERER_WEB_FONT_FILE_PATTERN.test(fontFile)) {
1138
+ return new Response("not found", { status: 404 });
1139
+ }
1140
+ try {
1141
+ const bytes = await readFile(join(rendererWebFontsDir, fontFile));
1142
+ // @cast-boundary Buffer satisfies BodyInit at runtime, bun-types just doesn't say so.
1143
+ return new Response(bytes as unknown as BodyInit, {
1144
+ headers: { "Content-Type": "font/woff2", "Cache-Control": "public, max-age=86400" },
1145
+ });
1146
+ } catch (err) {
1147
+ if ((err as { code?: string }).code === "ENOENT") {
1148
+ return new Response("not found", { status: 404 });
1149
+ }
1150
+ throw err;
1151
+ }
1152
+ }
1153
+
1125
1154
  if (url.pathname === "/styles.css" && req.method === "GET") {
1126
1155
  if (stylesheetPath === undefined) return new Response("no stylesheet", { status: 404 });
1127
1156
  const css = await readFile(stylesheetPath, "utf-8");
@@ -1239,7 +1268,10 @@ export async function createKumikoServer(
1239
1268
  // (idleTimeout: 0). Spec-Test in run-prod-app-spec.test.ts pinst das.
1240
1269
  const server = hasBun
1241
1270
  ? (globalThis as { Bun: { serve: (opts: unknown) => BunServer } }).Bun.serve(
1242
- buildBunServeOptions(port, handleFetch, resolveDerivedMaxRequestBodySize(stack.registry)),
1271
+ buildBunServeOptions(port, handleFetch, resolveDerivedMaxRequestBodySize(stack.registry), {
1272
+ upgradeFetch: async (req, env) =>
1273
+ stripNoRouteMatchHeader(await stack.app.fetch(req, env)),
1274
+ }),
1243
1275
  )
1244
1276
  : undefined;
1245
1277
 
@@ -12,6 +12,7 @@ import {
12
12
  type TestStack,
13
13
  type TestStackOptions,
14
14
  } from "@cosmicdrift/kumiko-framework/stack";
15
+ import { jobRunLoggerCallbacks } from "@cosmicdrift/kumiko-server-runtime/boot/job-run-logger";
15
16
  import {
16
17
  type ComposeFeaturesOptions,
17
18
  composeFeatures,
@@ -59,8 +60,20 @@ export async function setupTestStackFromFeatures(
59
60
  const features = composeFeatures(appFeatures, { includeBundled, authOptions });
60
61
  const extraContext = mergeExtraContext(stackOptions.extraContext, presets);
61
62
 
63
+ // Same run-logger wiring as prod, so jobs that declare tenantVisibleFailure
64
+ // write their failure rows in tests too.
65
+ const defaultRunLogger: NonNullable<NonNullable<TestStackOptions["jobs"]>["runLogger"]> = ({
66
+ registry,
67
+ db,
68
+ }) => jobRunLoggerCallbacks(registry, db);
69
+ const jobs =
70
+ stackOptions.jobs === undefined
71
+ ? undefined
72
+ : { runLogger: defaultRunLogger, ...stackOptions.jobs };
73
+
62
74
  return setupTestStack({
63
75
  ...stackOptions,
76
+ ...(jobs !== undefined && { jobs }),
64
77
  features,
65
78
  ...(extraContext !== undefined && { extraContext }),
66
79
  });