@cosmicdrift/kumiko-dev-server 0.159.1 → 0.160.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.159.1",
3
+ "version": "0.160.0",
4
4
  "description": "Dev-tooling for Kumiko apps: local dev-server bootstrap (runDevApp), scaffolding, codegen. Not shipped into production node_modules — see @cosmicdrift/kumiko-server-runtime for the prod boot path.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -54,9 +54,9 @@
54
54
  "kumiko-schema-check": "./bin/kumiko-schema-check.ts"
55
55
  },
56
56
  "dependencies": {
57
- "@cosmicdrift/kumiko-bundled-features": "0.159.1",
58
- "@cosmicdrift/kumiko-framework": "0.159.1",
59
- "@cosmicdrift/kumiko-server-runtime": "0.159.1",
57
+ "@cosmicdrift/kumiko-bundled-features": "0.160.0",
58
+ "@cosmicdrift/kumiko-framework": "0.160.0",
59
+ "@cosmicdrift/kumiko-server-runtime": "0.160.0",
60
60
  "ts-morph": "^28.0.0"
61
61
  },
62
62
  "publishConfig": {
@@ -0,0 +1,63 @@
1
+ // createKumikoServer error / graceful-degradation paths — boot rejects,
2
+ // stylesheet pipeline failures, missing CSS route.
3
+
4
+ import { afterEach, describe, expect, test } from "bun:test";
5
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { createKumikoServer, type KumikoServerHandle } from "../create-kumiko-server";
10
+
11
+ const emptyFeature = defineFeature("dev-server-errors-probe", () => {});
12
+
13
+ let handle: KumikoServerHandle | undefined;
14
+
15
+ afterEach(async () => {
16
+ if (handle) {
17
+ await handle.stop();
18
+ handle = undefined;
19
+ }
20
+ });
21
+
22
+ describe("createKumikoServer — client bundle failure", () => {
23
+ test("broken clientEntry rejects at boot with client bundle failed", async () => {
24
+ const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-bundle-fail-"));
25
+ const entry = join(tmpDir, "client.tsx");
26
+ writeFileSync(entry, "const x = {{{\n");
27
+ try {
28
+ await expect(
29
+ createKumikoServer({
30
+ features: [emptyFeature],
31
+ port: 0,
32
+ installSignalHandlers: false,
33
+ clientEntry: entry,
34
+ stylesheet: false,
35
+ }),
36
+ ).rejects.toThrow(/client bundle failed|Bundle failed/);
37
+ } finally {
38
+ rmSync(tmpDir, { recursive: true, force: true });
39
+ }
40
+ });
41
+
42
+ test("_buildBundle throw propagates at boot", async () => {
43
+ const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-stub-fail-"));
44
+ const entry = join(tmpDir, "client.tsx");
45
+ writeFileSync(entry, "// noop\n");
46
+ try {
47
+ await expect(
48
+ createKumikoServer({
49
+ features: [emptyFeature],
50
+ port: 0,
51
+ installSignalHandlers: false,
52
+ clientEntry: entry,
53
+ stylesheet: false,
54
+ _buildBundle: async () => {
55
+ throw new Error("stub build blew up");
56
+ },
57
+ }),
58
+ ).rejects.toThrow(/stub build blew up/);
59
+ } finally {
60
+ rmSync(tmpDir, { recursive: true, force: true });
61
+ }
62
+ });
63
+ });
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
@@ -336,3 +336,123 @@ describe("createKumikoServer extraRoutes-deps", () => {
336
336
  expect(body.data?.roles).toContain("SystemAdmin");
337
337
  });
338
338
  });
339
+
340
+ describe("createKumikoServer — stylesheet tailwind failure (graceful)", () => {
341
+ test("missing stylesheet entry boots without CSS — GET /styles.css → 404", async () => {
342
+ const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-css-fail-"));
343
+ const entry = join(tmpDir, "client.tsx");
344
+ writeFileSync(entry, "export const x = 1;\n");
345
+ try {
346
+ handle = await createKumikoServer({
347
+ features: [probeFeature],
348
+ port: 0,
349
+ installSignalHandlers: false,
350
+ clientEntry: entry,
351
+ stylesheet: join(tmpDir, "does-not-exist.css"),
352
+ _buildBundle: async () => ({ js: "// stub", map: "" }),
353
+ });
354
+ const cssRes = await handle.fetch(new Request("http://localhost/styles.css"));
355
+ expect(cssRes.status).toBe(404);
356
+ expect(await cssRes.text()).toBe("no stylesheet");
357
+ } finally {
358
+ rmSync(tmpDir, { recursive: true, force: true });
359
+ }
360
+ });
361
+ });
362
+
363
+ describe("createKumikoServer — real Bun.build (buildClient)", () => {
364
+ test("clientEntry without _buildBundle produces a JS bundle via Bun.build", async () => {
365
+ const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-real-build-"));
366
+ const entry = join(tmpDir, "client.tsx");
367
+ // Minimal entry Bun.build can emit — no JSX, no imports.
368
+ writeFileSync(entry, "export const ping = 1;\n");
369
+ // Empty dirs matching a glob — exercises expandWatchPatterns without
370
+ // writing files (avoids process.exit(75) from bare .tsx events).
371
+ mkdirSync(join(tmpDir, "pkg-a"));
372
+ mkdirSync(join(tmpDir, "pkg-b"));
373
+ try {
374
+ handle = await createKumikoServer({
375
+ features: [probeFeature],
376
+ port: 0,
377
+ installSignalHandlers: false,
378
+ clientEntry: entry,
379
+ stylesheet: false,
380
+ watchDirs: [join(tmpDir, "pkg-*")],
381
+ });
382
+ const res = await handle.fetch(new Request("http://localhost/client.js"));
383
+ expect(res.status).toBe(200);
384
+ expect(res.headers.get("content-type")).toMatch(/application\/javascript/);
385
+ const body = await res.text();
386
+ expect(body.length).toBeGreaterThan(0);
387
+ expect(body).toMatch(/ping/);
388
+ } finally {
389
+ rmSync(tmpDir, { recursive: true, force: true });
390
+ }
391
+ });
392
+ });
393
+
394
+ describe("createKumikoServer — hot-reload broadcast", () => {
395
+ test("file change under web/ rebuilds and broadcasts SSE reload", async () => {
396
+ const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-watch-"));
397
+ const entry = join(tmpDir, "client.tsx");
398
+ const webDir = join(tmpDir, "web");
399
+ mkdirSync(webDir);
400
+ writeFileSync(entry, "export const ping = 1;\n");
401
+
402
+ let builds = 0;
403
+ try {
404
+ handle = await createKumikoServer({
405
+ features: [probeFeature],
406
+ port: 0,
407
+ installSignalHandlers: false,
408
+ clientEntry: entry,
409
+ stylesheet: false,
410
+ // Only the entry dir is watched (no extra watchDirs) — a nested
411
+ // web/page.tsx event arrives as "web/page.tsx" → hot-reload.
412
+ // Watching web/ separately would fire bare "page.tsx" → restart
413
+ // → process.exit(75) and kill the test runner.
414
+ _buildBundle: async () => {
415
+ builds += 1;
416
+ return { js: `// build-${builds}`, map: "" };
417
+ },
418
+ });
419
+
420
+ const sseRes = await handle.fetch(new Request("http://localhost/_reload"));
421
+ expect(sseRes.status).toBe(200);
422
+ const reader = sseRes.body?.getReader();
423
+ expect(reader).toBeDefined();
424
+ if (!reader) return;
425
+
426
+ await reader.read(); // drain connected comment
427
+
428
+ const initialBuilds = builds;
429
+ writeFileSync(join(webDir, "page.tsx"), "export const x = 1;\n");
430
+
431
+ const deadline = Date.now() + 3000;
432
+ let sawReload = false;
433
+ while (Date.now() < deadline && !sawReload) {
434
+ const readPromise = reader.read();
435
+ const timeout = new Promise<{ done: true; value: undefined }>((resolve) =>
436
+ setTimeout(() => resolve({ done: true, value: undefined }), 200),
437
+ );
438
+ const { value, done } = await Promise.race([readPromise, timeout]);
439
+ if (done || value === undefined) continue;
440
+ const chunk = new TextDecoder().decode(value);
441
+ if (chunk.includes("event: reload")) sawReload = true;
442
+ }
443
+ await reader.cancel();
444
+
445
+ expect(builds).toBeGreaterThan(initialBuilds);
446
+ expect(sawReload).toBe(true);
447
+
448
+ const js = await handle.fetch(new Request("http://localhost/client.js"));
449
+ expect(await js.text()).toMatch(/build-/);
450
+
451
+ // Abort watchers before teardown rmSync can fire a restart event.
452
+ await handle.stop();
453
+ handle = undefined;
454
+ } finally {
455
+ rmSync(tmpDir, { recursive: true, force: true });
456
+ }
457
+ });
458
+ });
@@ -64,6 +64,22 @@ describe("resolveStylesheet", () => {
64
64
  }
65
65
  });
66
66
 
67
+ test("Bun.resolveSync failure → undefined (catch path, no throw)", () => {
68
+ const tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "kumiko-resolve-catch-")));
69
+ const cwdBefore = process.cwd();
70
+ process.chdir(tmpDir);
71
+ try {
72
+ const out = resolveStylesheet({
73
+ features: [],
74
+ clientEntry: "./entry.tsx",
75
+ });
76
+ expect(out).toBeUndefined();
77
+ } finally {
78
+ process.chdir(cwdBefore);
79
+ rmSync(tmpDir, { recursive: true, force: true });
80
+ }
81
+ });
82
+
67
83
  test("undefined + clientEntry + src/styles.css existiert → returns App-Theme-Override", () => {
68
84
  // Auto-Detection greift VOR dem renderer-web-Fallback: Wenn die App
69
85
  // ein eigenes src/styles.css hat (App-Theme-Pattern), wird das
@@ -42,6 +42,8 @@ export type ScaffoldFeatureEntry = {
42
42
  readonly importPath: string;
43
43
  readonly exportName: string;
44
44
  readonly callExpression: string;
45
+ /** Runtime args for factory-style exports that need config the codegen text can't parse back out (e.g. `{ scopes: {} }`). Omit for zero-arg factories and object-style exports. */
46
+ readonly callArgs?: readonly unknown[];
45
47
  };
46
48
 
47
49
  export type ScaffoldAppOptions = {
@@ -850,11 +852,8 @@ async function instantiateScaffoldFeatures(
850
852
  `scaffoldApp: ${entry.importPath} missing export ${entry.exportName} for ${entry.callExpression}`,
851
853
  );
852
854
  }
853
- if (entry.callExpression.endsWith("()")) {
854
- if (typeof exp !== "function") {
855
- throw new Error(`scaffoldApp: ${entry.exportName} is not callable (${entry.importPath})`);
856
- }
857
- instances.push((exp as () => FeatureDefinition)());
855
+ if (typeof exp === "function") {
856
+ instances.push((exp as (...args: unknown[]) => FeatureDefinition)(...(entry.callArgs ?? [])));
858
857
  } else {
859
858
  instances.push(exp as FeatureDefinition);
860
859
  }