@cosmicdrift/kumiko-server-runtime 0.322.0 → 0.323.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-server-runtime",
3
- "version": "0.322.0",
3
+ "version": "0.323.0",
4
4
  "description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold/codegen tooling.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -76,9 +76,9 @@
76
76
  }
77
77
  },
78
78
  "dependencies": {
79
- "@cosmicdrift/kumiko-bundled-features": "0.322.0",
80
- "@cosmicdrift/kumiko-framework": "0.322.0",
81
- "@cosmicdrift/kumiko-headless": "0.322.0",
79
+ "@cosmicdrift/kumiko-bundled-features": "0.323.0",
80
+ "@cosmicdrift/kumiko-framework": "0.323.0",
81
+ "@cosmicdrift/kumiko-headless": "0.323.0",
82
82
  "temporal-polyfill": "^0.3.2"
83
83
  },
84
84
  "publishConfig": {
@@ -2,7 +2,7 @@
2
2
  // in the same process (CLI subprocess tests do not contribute to lcov).
3
3
 
4
4
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
5
- import { existsSync } from "node:fs";
5
+ import { existsSync, readdirSync } from "node:fs";
6
6
  import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
7
7
  import { tmpdir } from "node:os";
8
8
  import { join, resolve } from "node:path";
@@ -139,7 +139,7 @@ describe("buildProdBundle in-process (Bun.build)", () => {
139
139
  }
140
140
  });
141
141
 
142
- test("multi-entry client-admin + client-public → two hashed bundles", async () => {
142
+ test("multi-entry client-admin + client-public via kumiko.clientEntries → two hashed bundles", async () => {
143
143
  await mkdir(join(tmp, "src"), { recursive: true });
144
144
  await writeFile(join(tmp, "src/client-admin.ts"), `console.log("admin");`);
145
145
  await writeFile(join(tmp, "src/client-public.ts"), `console.log("public");`);
@@ -154,11 +154,40 @@ describe("buildProdBundle in-process (Bun.build)", () => {
154
154
  );
155
155
  await writeFile(join(tmp, "package.json"), `{"name":"multi-inproc","private":true}`);
156
156
 
157
- const result = await buildProdBundle({ cwd: tmp, stylesheet: false });
157
+ const result = await buildProdBundle({
158
+ cwd: tmp,
159
+ stylesheet: false,
160
+ clientEntries: [
161
+ { name: "admin", sourceFile: "./src/client-admin.ts" },
162
+ { name: "public", sourceFile: "./src/client-public.ts" },
163
+ ],
164
+ });
158
165
 
159
166
  expect(result.manifest["client-admin.js"]).toMatch(/^\/assets\/client-admin-/);
160
167
  expect(result.manifest["client-public.js"]).toMatch(/^\/assets\/client-public-/);
161
168
  expect(existsSync(join(tmp, "dist/admin.html"))).toBe(true);
162
169
  expect(existsSync(join(tmp, "dist/index.html"))).toBe(true);
163
170
  });
171
+
172
+ test("undeclared helper next to declared entries produces no bundle for it", async () => {
173
+ await mkdir(join(tmp, "src"), { recursive: true });
174
+ await writeFile(join(tmp, "src/client-public.ts"), `console.log("public");`);
175
+ await writeFile(join(tmp, "src/client-helper.ts"), `export const helper = "unused";`);
176
+ await mkdir(join(tmp, "public"), { recursive: true });
177
+ await writeFile(
178
+ join(tmp, "public/index.html"),
179
+ `<!doctype html><html><body><script type="module" src="/client-public.js"></script></body></html>`,
180
+ );
181
+ await writeFile(join(tmp, "package.json"), `{"name":"undeclared-helper","private":true}`);
182
+
183
+ const result = await buildProdBundle({
184
+ cwd: tmp,
185
+ stylesheet: false,
186
+ clientEntries: [{ name: "public", sourceFile: "./src/client-public.ts" }],
187
+ });
188
+
189
+ expect(Object.keys(result.manifest)).toEqual(["client-public.js"]);
190
+ const assetFiles = readdirSync(join(tmp, "dist/assets"));
191
+ expect(assetFiles.some((f) => f.startsWith("client-helper-"))).toBe(false);
192
+ });
164
193
  });
@@ -14,15 +14,17 @@ import { join } from "node:path";
14
14
  import {
15
15
  buildMissingTemplateError,
16
16
  type ClientEntry,
17
+ type ClientEntryDeclaration,
17
18
  computeBuildId,
18
- discoverClientEntries,
19
19
  discoverHtmlTemplate,
20
20
  formatBuildResult,
21
21
  injectAssetTags,
22
+ readClientEntriesConfig,
23
+ resolveClientEntries,
22
24
  } from "../build-prod-bundle";
23
25
 
24
- // Synthetic single-entry helper. Realer Build erzeugt das via
25
- // discoverClientEntries; hier reicht die Form für injectAssetTags-Tests.
26
+ // Synthetic single-entry: injectAssetTags only needs the shape, not a
27
+ // resolveClientEntries round-trip.
26
28
  function clientEntry(): ClientEntry {
27
29
  return {
28
30
  name: "client",
@@ -41,7 +43,11 @@ function namedEntry(name: string): ClientEntry {
41
43
  };
42
44
  }
43
45
 
44
- describe("build-prod-bundle/discovery", () => {
46
+ async function writeJson(path: string, value: unknown): Promise<void> {
47
+ await writeFile(path, JSON.stringify(value));
48
+ }
49
+
50
+ describe("build-prod-bundle/discovery (convention mode)", () => {
45
51
  let workDir = "";
46
52
 
47
53
  beforeEach(async () => {
@@ -52,11 +58,11 @@ describe("build-prod-bundle/discovery", () => {
52
58
  await rm(workDir, { recursive: true, force: true });
53
59
  });
54
60
 
55
- test("discoverClientEntries findet single-mode src/client.tsx", async () => {
61
+ test("resolveClientEntries findet single-mode src/client.tsx wenn nichts deklariert ist", async () => {
56
62
  await mkdir(join(workDir, "src"), { recursive: true });
57
63
  await writeFile(join(workDir, "src/client.tsx"), "// single");
58
64
 
59
- const entries = discoverClientEntries(workDir);
65
+ const entries = resolveClientEntries(workDir, {});
60
66
 
61
67
  expect(entries).toHaveLength(1);
62
68
  expect(entries[0]?.name).toBe("client");
@@ -64,41 +70,46 @@ describe("build-prod-bundle/discovery", () => {
64
70
  expect(entries[0]?.htmlPath).toBe("index.html");
65
71
  });
66
72
 
67
- test("discoverClientEntries findet multi-mode client-public + client-admin", async () => {
68
- await mkdir(join(workDir, "src"), { recursive: true });
69
- await writeFile(join(workDir, "src/client-public.tsx"), "// public");
70
- await writeFile(join(workDir, "src/client-admin.tsx"), "// admin");
71
-
72
- const entries = discoverClientEntries(workDir);
73
+ test("resolveClientEntries gibt leeres Array zurück wenn nichts da und nichts deklariert ist", () => {
74
+ expect(resolveClientEntries(workDir, {})).toEqual([]);
75
+ });
73
76
 
74
- // Sortiert nach name.
75
- expect(entries.map((e) => e.name)).toEqual(["admin", "public"]);
77
+ // #2305 regression: a plain module imported by the real entry, named
78
+ // like a legacy client-<suffix>.tsx file, must not turn into an error or
79
+ // a second entry once src/client.tsx exists (single mode wins outright).
80
+ test("src/client.tsx neben src/client-utils.tsx → genau ein Entry, kein Fehler", async () => {
81
+ await mkdir(join(workDir, "src"), { recursive: true });
82
+ await writeFile(join(workDir, "src/client.tsx"), "// real entry");
83
+ await writeFile(join(workDir, "src/client-utils.tsx"), "export const utils = [];");
76
84
 
77
- const admin = entries.find((e) => e.name === "admin");
78
- expect(admin?.manifestKey).toBe("client-admin.js");
79
- expect(admin?.htmlPath).toBe("admin.html");
85
+ const entries = resolveClientEntries(workDir, {});
80
86
 
81
- const pub = entries.find((e) => e.name === "public");
82
- expect(pub?.manifestKey).toBe("client-public.js");
83
- // "public" mappt auf das Default-Template (index.html), nicht
84
- // public.html — das ist Convention damit das default-served-Template
85
- // den vom-User-erwarteten Namen behält.
86
- expect(pub?.htmlPath).toBe("index.html");
87
+ expect(entries).toHaveLength(1);
88
+ expect(entries[0]?.name).toBe("client");
87
89
  });
88
90
 
89
- test("discoverClientEntries multi-mode dominiert über single-mode wenn beide da", async () => {
91
+ test("legacy src/client-<suffix>.tsx ohne Deklaration → Migrations-Fehler mit package.json-Snippet", async () => {
90
92
  await mkdir(join(workDir, "src"), { recursive: true });
91
- await writeFile(join(workDir, "src/client.tsx"), "// single");
92
93
  await writeFile(join(workDir, "src/client-admin.tsx"), "// admin");
94
+ await writeFile(join(workDir, "src/client-public.tsx"), "// public");
93
95
 
94
- const entries = discoverClientEntries(workDir);
95
-
96
- // Multi-mode aktiv → "client" wird ignoriert.
97
- expect(entries.map((e) => e.name)).toEqual(["admin"]);
96
+ expect(() => resolveClientEntries(workDir, {})).toThrow(/client-admin\.tsx/);
97
+ expect(() => resolveClientEntries(workDir, {})).toThrow(/client-public\.tsx/);
98
+ expect(() => resolveClientEntries(workDir, {})).toThrow(/kumiko\.clientEntries/);
99
+ expect(() => resolveClientEntries(workDir, {})).toThrow(
100
+ /"name":\s*"admin",\s*"sourceFile":\s*"\.\/src\/client-admin\.tsx"/,
101
+ );
98
102
  });
99
103
 
100
- test("discoverClientEntries gibt leeres Array zurück wenn nichts da", () => {
101
- expect(discoverClientEntries(workDir)).toEqual([]);
104
+ test("legacy src/client-<suffix>.ts (kein x) → Migrations-Snippet nennt die exakte .ts-Extension", async () => {
105
+ await mkdir(join(workDir, "src"), { recursive: true });
106
+ await writeFile(join(workDir, "src/client-admin.ts"), "// admin");
107
+
108
+ expect(() => resolveClientEntries(workDir, {})).toThrow(/src\/client-admin\.ts\b/);
109
+ expect(() => resolveClientEntries(workDir, {})).toThrow(
110
+ /"sourceFile":\s*"\.\/src\/client-admin\.ts"/,
111
+ );
112
+ expect(() => resolveClientEntries(workDir, {})).not.toThrow(/client-admin\.tsx/);
102
113
  });
103
114
 
104
115
  test("discoverHtmlTemplate findet index.html im cwd", async () => {
@@ -128,6 +139,350 @@ describe("build-prod-bundle/discovery", () => {
128
139
  });
129
140
  });
130
141
 
142
+ describe("build-prod-bundle/resolveClientEntries (declared)", () => {
143
+ let workDir = "";
144
+
145
+ beforeEach(async () => {
146
+ workDir = await mkdtemp(join(tmpdir(), "kumiko-build-declared-"));
147
+ });
148
+
149
+ afterEach(async () => {
150
+ await rm(workDir, { recursive: true, force: true });
151
+ });
152
+
153
+ async function writeSourceFiles(names: readonly string[]): Promise<void> {
154
+ await mkdir(join(workDir, "src"), { recursive: true });
155
+ for (const name of names) {
156
+ await writeFile(join(workDir, `src/${name}`), `// ${name}`);
157
+ }
158
+ }
159
+
160
+ test("clientEntry → single entry named 'client'", async () => {
161
+ await writeSourceFiles(["main.tsx"]);
162
+
163
+ const entries = resolveClientEntries(workDir, { clientEntry: "./src/main.tsx" });
164
+
165
+ expect(entries).toHaveLength(1);
166
+ expect(entries[0]?.name).toBe("client");
167
+ expect(entries[0]?.manifestKey).toBe("client.js");
168
+ expect(entries[0]?.sourceFile).toBe(join(workDir, "src/main.tsx"));
169
+ expect(entries[0]?.htmlPath).toBe("index.html");
170
+ });
171
+
172
+ test("clientEntry: sourceFile fehlt → throws", () => {
173
+ expect(() => resolveClientEntries(workDir, { clientEntry: "./src/missing.tsx" })).toThrow(
174
+ /kumiko\.clientEntry.*does not exist/s,
175
+ );
176
+ });
177
+
178
+ test("clientEntry with non-source extension throws even when the file exists", async () => {
179
+ await writeSourceFiles(["app.json"]);
180
+
181
+ expect(() => resolveClientEntries(workDir, { clientEntry: "./src/app.json" })).toThrow(
182
+ /kumiko\.clientEntry "\.\/src\/app\.json" must point to a \.ts, \.tsx, \.js or \.jsx file/,
183
+ );
184
+ });
185
+
186
+ test("clientEntry und clientEntries gleichzeitig → mutual-exclusion error", async () => {
187
+ await writeSourceFiles(["main.tsx", "client-admin.tsx"]);
188
+
189
+ expect(() =>
190
+ resolveClientEntries(workDir, {
191
+ clientEntry: "./src/main.tsx",
192
+ clientEntries: [{ name: "admin", sourceFile: "./src/client-admin.tsx" }],
193
+ }),
194
+ ).toThrow(/mutually exclusive/);
195
+ });
196
+
197
+ test("clientEntries: leeres Array → throws", () => {
198
+ expect(() => resolveClientEntries(workDir, { clientEntries: [] })).toThrow(
199
+ /"kumiko\.clientEntries" is empty/,
200
+ );
201
+ });
202
+
203
+ test("clientEntries: ungültiger name (führender Großbuchstabe) → throws", async () => {
204
+ await writeSourceFiles(["client-admin.tsx"]);
205
+
206
+ expect(() =>
207
+ resolveClientEntries(workDir, {
208
+ clientEntries: [{ name: "Admin", sourceFile: "./src/client-admin.tsx" }],
209
+ }),
210
+ ).toThrow(/invalid "kumiko\.clientEntries\[\]\.name"/);
211
+ });
212
+
213
+ test("clientEntries: name mit '..' → throws", async () => {
214
+ await writeSourceFiles(["client-admin.tsx"]);
215
+
216
+ expect(() =>
217
+ resolveClientEntries(workDir, {
218
+ clientEntries: [{ name: "../x", sourceFile: "./src/client-admin.tsx" }],
219
+ }),
220
+ ).toThrow(/invalid "kumiko\.clientEntries\[\]\.name"/);
221
+ });
222
+
223
+ test("clientEntries: sourceFile escaping cwd via '..' → throws", () => {
224
+ expect(() =>
225
+ resolveClientEntries(workDir, {
226
+ clientEntries: [{ name: "admin", sourceFile: "../outside.tsx" }],
227
+ }),
228
+ ).toThrow(/resolves outside the app root/);
229
+ });
230
+
231
+ test("clientEntries: absolute sourceFile outside cwd → throws", () => {
232
+ expect(() =>
233
+ resolveClientEntries(workDir, {
234
+ clientEntries: [{ name: "admin", sourceFile: "/etc/passwd" }],
235
+ }),
236
+ ).toThrow(/resolves outside the app root/);
237
+ });
238
+
239
+ test("clientEntries: htmlPath escaping cwd → throws", async () => {
240
+ await writeSourceFiles(["client-admin.tsx"]);
241
+
242
+ expect(() =>
243
+ resolveClientEntries(workDir, {
244
+ clientEntries: [
245
+ { name: "admin", sourceFile: "./src/client-admin.tsx", htmlPath: "../outside.html" },
246
+ ],
247
+ }),
248
+ ).toThrow(/resolves outside the app root/);
249
+ });
250
+
251
+ test("clientEntries: sourceFile existiert nicht → throws", () => {
252
+ expect(() =>
253
+ resolveClientEntries(workDir, {
254
+ clientEntries: [{ name: "admin", sourceFile: "./src/client-admin.tsx" }],
255
+ }),
256
+ ).toThrow(/sourceFile ".*" does not exist/);
257
+ });
258
+
259
+ test("clientEntries: sourceFile with non-source extension throws even when the file exists", async () => {
260
+ await writeSourceFiles(["client-admin.css"]);
261
+
262
+ expect(() =>
263
+ resolveClientEntries(workDir, {
264
+ clientEntries: [{ name: "admin", sourceFile: "./src/client-admin.css" }],
265
+ }),
266
+ ).toThrow(
267
+ /kumiko\.clientEntries\["admin"\]\.sourceFile "\.\/src\/client-admin\.css" must point to a \.ts, \.tsx, \.js or \.jsx file/,
268
+ );
269
+ });
270
+
271
+ test("clientEntries: htmlPath with non-html extension throws", async () => {
272
+ await writeSourceFiles(["client-admin.tsx"]);
273
+
274
+ expect(() =>
275
+ resolveClientEntries(workDir, {
276
+ clientEntries: [
277
+ {
278
+ name: "admin",
279
+ sourceFile: "./src/client-admin.tsx",
280
+ htmlPath: "./public/manifest.json",
281
+ },
282
+ ],
283
+ }),
284
+ ).toThrow(
285
+ /kumiko\.clientEntries\["admin"\]\.htmlPath "\.\/public\/manifest\.json" must point to an \.html file/,
286
+ );
287
+ });
288
+
289
+ test("clientEntries: doppelter name → throws", async () => {
290
+ await writeSourceFiles(["client-admin.tsx", "client-admin2.tsx"]);
291
+
292
+ expect(() =>
293
+ resolveClientEntries(workDir, {
294
+ clientEntries: [
295
+ { name: "admin", sourceFile: "./src/client-admin.tsx" },
296
+ { name: "admin", sourceFile: "./src/client-admin2.tsx" },
297
+ ],
298
+ }),
299
+ ).toThrow(/duplicate "kumiko\.clientEntries\[\]\.name" "admin"/);
300
+ });
301
+
302
+ test("clientEntries: zwei Entries mit gleichem html-basename (default 'public' vs. explizit) → throws", async () => {
303
+ await writeSourceFiles(["client-public.tsx", "client-legacy.tsx"]);
304
+
305
+ expect(() =>
306
+ resolveClientEntries(workDir, {
307
+ clientEntries: [
308
+ { name: "public", sourceFile: "./src/client-public.tsx" },
309
+ { name: "legacy", sourceFile: "./src/client-legacy.tsx", htmlPath: "./index.html" },
310
+ ],
311
+ }),
312
+ ).toThrow(/resolve to the same HTML output file "index\.html"/);
313
+ });
314
+
315
+ test("clientEntries: zwei Entries mit identischem source-basename → throws", async () => {
316
+ await mkdir(join(workDir, "src/admin"), { recursive: true });
317
+ await mkdir(join(workDir, "src/public"), { recursive: true });
318
+ await writeFile(join(workDir, "src/admin/index.tsx"), "// admin");
319
+ await writeFile(join(workDir, "src/public/index.tsx"), "// public");
320
+
321
+ expect(() =>
322
+ resolveClientEntries(workDir, {
323
+ clientEntries: [
324
+ { name: "admin", sourceFile: "./src/admin/index.tsx", htmlPath: "./admin.html" },
325
+ { name: "public", sourceFile: "./src/public/index.tsx" },
326
+ ],
327
+ }),
328
+ ).toThrow(/share the source basename "index"/);
329
+ });
330
+
331
+ test("publicstatus-Shape: public ohne htmlPath, admin/auth mit explizitem htmlPath", async () => {
332
+ await mkdir(join(workDir, "public"), { recursive: true });
333
+ await writeFile(join(workDir, "public/admin.html"), "<html></html>");
334
+ await writeFile(join(workDir, "public/auth.html"), "<html></html>");
335
+ await writeSourceFiles(["client-public.tsx", "client-admin.tsx", "client-auth.tsx"]);
336
+
337
+ const entries = resolveClientEntries(workDir, {
338
+ clientEntries: [
339
+ { name: "public", sourceFile: "./src/client-public.tsx" },
340
+ { name: "admin", sourceFile: "./src/client-admin.tsx", htmlPath: "./public/admin.html" },
341
+ { name: "auth", sourceFile: "./src/client-auth.tsx", htmlPath: "./public/auth.html" },
342
+ ],
343
+ });
344
+
345
+ expect(entries.map((e) => e.name)).toEqual(["admin", "auth", "public"]);
346
+ const publicEntry = entries.find((e) => e.name === "public");
347
+ expect(publicEntry?.manifestKey).toBe("client-public.js");
348
+ expect(publicEntry?.htmlPath).toBe("index.html");
349
+ const admin = entries.find((e) => e.name === "admin");
350
+ expect(admin?.manifestKey).toBe("client-admin.js");
351
+ expect(admin?.htmlPath).toBe(join(workDir, "public/admin.html"));
352
+ const auth = entries.find((e) => e.name === "auth");
353
+ expect(auth?.htmlPath).toBe(join(workDir, "public/auth.html"));
354
+ });
355
+
356
+ test("show-pony-Shape: admin und public beide mit explizitem htmlPath", async () => {
357
+ await mkdir(join(workDir, "public"), { recursive: true });
358
+ await writeFile(join(workDir, "public/admin.html"), "<html></html>");
359
+ await writeFile(join(workDir, "public/index.html"), "<html></html>");
360
+ await writeSourceFiles(["client-admin.tsx", "client-public.tsx"]);
361
+
362
+ const entries = resolveClientEntries(workDir, {
363
+ clientEntries: [
364
+ { name: "admin", sourceFile: "./src/client-admin.tsx", htmlPath: "./public/admin.html" },
365
+ { name: "public", sourceFile: "./src/client-public.tsx", htmlPath: "./public/index.html" },
366
+ ],
367
+ });
368
+
369
+ expect(entries.map((e) => e.manifestKey)).toEqual(["client-admin.js", "client-public.js"]);
370
+ expect(entries.map((e) => e.htmlPath)).toEqual([
371
+ join(workDir, "public/admin.html"),
372
+ join(workDir, "public/index.html"),
373
+ ]);
374
+ });
375
+
376
+ test("offlot-app-Shape: einziger Entry 'app' mit explizitem htmlPath", async () => {
377
+ await mkdir(join(workDir, "public"), { recursive: true });
378
+ await writeFile(join(workDir, "public/app.html"), "<html></html>");
379
+ await writeSourceFiles(["client-app.tsx"]);
380
+
381
+ const entries = resolveClientEntries(workDir, {
382
+ clientEntries: [
383
+ { name: "app", sourceFile: "./src/client-app.tsx", htmlPath: "./public/app.html" },
384
+ ],
385
+ });
386
+
387
+ expect(entries).toHaveLength(1);
388
+ expect(entries[0]?.manifestKey).toBe("client-app.js");
389
+ expect(entries[0]?.htmlPath).toBe(join(workDir, "public/app.html"));
390
+ });
391
+ });
392
+
393
+ describe("build-prod-bundle/readClientEntriesConfig", () => {
394
+ let workDir = "";
395
+
396
+ beforeEach(async () => {
397
+ workDir = await mkdtemp(join(tmpdir(), "kumiko-build-config-"));
398
+ });
399
+
400
+ afterEach(async () => {
401
+ await rm(workDir, { recursive: true, force: true });
402
+ });
403
+
404
+ test("kein package.json → {}", () => {
405
+ expect(readClientEntriesConfig(workDir)).toEqual({});
406
+ });
407
+
408
+ test("package.json ohne kumiko-Block → {}", async () => {
409
+ await writeJson(join(workDir, "package.json"), { name: "app" });
410
+
411
+ expect(readClientEntriesConfig(workDir)).toEqual({});
412
+ });
413
+
414
+ test("valides kumiko.clientEntry", async () => {
415
+ await writeJson(join(workDir, "package.json"), {
416
+ name: "app",
417
+ kumiko: { clientEntry: "./src/client.tsx" },
418
+ });
419
+
420
+ expect(readClientEntriesConfig(workDir)).toEqual({ clientEntry: "./src/client.tsx" });
421
+ });
422
+
423
+ test("valides kumiko.clientEntries", async () => {
424
+ const declared: readonly ClientEntryDeclaration[] = [
425
+ { name: "public", sourceFile: "./src/client-public.tsx" },
426
+ { name: "admin", sourceFile: "./src/client-admin.tsx", htmlPath: "./public/admin.html" },
427
+ ];
428
+ await writeJson(join(workDir, "package.json"), {
429
+ name: "app",
430
+ kumiko: { clientEntries: declared },
431
+ });
432
+
433
+ expect(readClientEntriesConfig(workDir)).toEqual({ clientEntries: declared });
434
+ });
435
+
436
+ test("kumiko.clientEntry ist keine string → throws", async () => {
437
+ await writeJson(join(workDir, "package.json"), { name: "app", kumiko: { clientEntry: 42 } });
438
+
439
+ expect(() => readClientEntriesConfig(workDir)).toThrow(
440
+ /"kumiko\.clientEntry" must be a string/,
441
+ );
442
+ });
443
+
444
+ test("kumiko.clientEntries ist kein Array → throws", async () => {
445
+ await writeJson(join(workDir, "package.json"), {
446
+ name: "app",
447
+ kumiko: { clientEntries: { name: "admin" } },
448
+ });
449
+
450
+ expect(() => readClientEntriesConfig(workDir)).toThrow(
451
+ /"kumiko\.clientEntries" must be an array/,
452
+ );
453
+ });
454
+
455
+ test("kumiko.clientEntries[] ohne sourceFile → throws", async () => {
456
+ await writeJson(join(workDir, "package.json"), {
457
+ name: "app",
458
+ kumiko: { clientEntries: [{ name: "admin" }] },
459
+ });
460
+
461
+ expect(() => readClientEntriesConfig(workDir)).toThrow(
462
+ /"kumiko\.clientEntries\[0\]\.sourceFile" must be a non-empty string/,
463
+ );
464
+ });
465
+
466
+ test("kumiko.clientEntries[].htmlPath ist keine string → throws", async () => {
467
+ await writeJson(join(workDir, "package.json"), {
468
+ name: "app",
469
+ kumiko: {
470
+ clientEntries: [{ name: "admin", sourceFile: "./src/client-admin.tsx", htmlPath: 1 }],
471
+ },
472
+ });
473
+
474
+ expect(() => readClientEntriesConfig(workDir)).toThrow(
475
+ /"kumiko\.clientEntries\[0\]\.htmlPath" must be a string/,
476
+ );
477
+ });
478
+
479
+ test("kaputtes JSON → throws", async () => {
480
+ await writeFile(join(workDir, "package.json"), "{not json");
481
+
482
+ expect(() => readClientEntriesConfig(workDir)).toThrow(/Invalid JSON/);
483
+ });
484
+ });
485
+
131
486
  describe("build-prod-bundle/injectAssetTags", () => {
132
487
  test("ersetzt /client.js durch hashed URL im script-tag", () => {
133
488
  const html = `<html><body><script type="module" src="/client.js"></script></body></html>`;
@@ -317,34 +672,22 @@ describe("build-prod-bundle/discovery edges", () => {
317
672
  await rm(workDir, { recursive: true, force: true });
318
673
  });
319
674
 
320
- test("discoverClientEntries accepts src/client.ts (no x)", async () => {
675
+ test("resolveClientEntries accepts src/client.ts (no x)", async () => {
321
676
  await mkdir(join(workDir, "src"), { recursive: true });
322
677
  await writeFile(join(workDir, "src/client.ts"), "// single ts");
323
- const entries = discoverClientEntries(workDir);
678
+ const entries = resolveClientEntries(workDir, {});
324
679
  expect(entries).toHaveLength(1);
325
680
  expect(entries[0]?.name).toBe("client");
326
681
  expect(entries[0]?.sourceFile.endsWith("src/client.ts")).toBe(true);
327
682
  });
328
683
 
329
- test("discoverClientEntries ignores non-matching client-* names", async () => {
684
+ test("resolveClientEntries ignores non-matching legacy client-* names (no migration error)", async () => {
330
685
  await mkdir(join(workDir, "src"), { recursive: true });
331
686
  // Uppercase / leading digit / underscore violate ^client-([a-z][a-z0-9-]*)\.tsx?$
332
687
  await writeFile(join(workDir, "src/client-Admin.tsx"), "// bad");
333
688
  await writeFile(join(workDir, "src/client-1bad.tsx"), "// bad");
334
689
  await writeFile(join(workDir, "src/client_admin.tsx"), "// bad");
335
- expect(discoverClientEntries(workDir)).toEqual([]);
336
- });
337
-
338
- // #2305: a module only imported by another client-*.tsx still matches
339
- // the entry pattern and becomes a second bundle entry.
340
- test("discoverClientEntries treats a plain imported module named client-<x>.tsx as its own entry", async () => {
341
- await mkdir(join(workDir, "src"), { recursive: true });
342
- await writeFile(join(workDir, "src/client-app.tsx"), "// real entry");
343
- await writeFile(join(workDir, "src/client-features.tsx"), "export const clientFeatures = [];");
344
-
345
- const entries = discoverClientEntries(workDir);
346
-
347
- expect(entries.map((e) => e.name)).toEqual(["app", "features"]);
690
+ expect(resolveClientEntries(workDir, {})).toEqual([]);
348
691
  });
349
692
  });
350
693
 
@@ -358,25 +701,24 @@ describe("build-prod-bundle/buildMissingTemplateError", () => {
358
701
  };
359
702
  }
360
703
 
361
- test("names the discovered source file and the client-<suffix> convention", () => {
704
+ test("names the declaring entry name and its sourceFile", () => {
362
705
  const message = buildMissingTemplateError(
363
706
  { "client-features.js": "/assets/client-features-abcd.js" },
364
707
  multiEntry(),
365
708
  );
366
709
 
367
710
  expect(message).toContain("src/client-features.tsx");
368
- expect(message).toContain("client-<suffix>.tsx");
369
- expect(message).toContain("umbenennen");
711
+ expect(message).toContain("kumiko.clientEntries");
712
+ expect(message).toContain('Entry "features"');
370
713
  });
371
714
 
372
- test("single-mode entry gets no rename hint (nothing was auto-discovered by suffix)", () => {
715
+ test("single-mode entry gets no declaration hint", () => {
373
716
  const message = buildMissingTemplateError(
374
717
  { "client.js": "/assets/client-abcd.js" },
375
718
  clientEntry(),
376
719
  );
377
720
 
378
721
  expect(message).toContain("src/client.tsx");
379
- expect(message).not.toContain("umbenennen");
380
- expect(message).not.toContain("client-<suffix>.tsx");
722
+ expect(message).not.toContain("kumiko.clientEntries");
381
723
  });
382
724
  });
@@ -3,9 +3,16 @@
3
3
  // die App-Struktur, Bun.build + Tailwind + Public-Folder-Copy
4
4
  // produzieren ein deploybares dist/.
5
5
  //
6
- // Convention (alles optional, fehlt was → übersprungen):
6
+ // Client entries (fw#2320 — explicit, no filename inference):
7
+ //
8
+ // package.json "kumiko.clientEntries": [{ name, sourceFile, htmlPath? }]
9
+ // → one Bun.build bundle per declared entry
10
+ // package.json "kumiko.clientEntry": "./src/…"
11
+ // → single bundle, name "client"
12
+ // (neither declared) → convention: src/client.tsx | src/client.ts
13
+ //
14
+ // Convention for everything else (all optional, missing → skipped):
7
15
  //
8
- // src/client.tsx | src/client.ts → Bun.build (splitting + hash + asset-loader)
9
16
  // src/styles.css → Tailwind one-shot
10
17
  // (oder fallback auf @cosmicdrift/kumiko-renderer-web/styles.css
11
18
  // wenn nur clientEntry da ist und kein eigenes CSS)
@@ -40,10 +47,11 @@
40
47
  // alles andere (public/) → default (auto-cache)
41
48
 
42
49
  import { createHash } from "node:crypto";
43
- import { existsSync, readdirSync } from "node:fs";
50
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
44
51
  import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
45
52
  import { tmpdir } from "node:os";
46
- import { join, resolve } from "node:path";
53
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
54
+ import { isPlainObject, parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
47
55
  import { Temporal } from "temporal-polyfill";
48
56
  import { canResolveTailwindStylesheet, resolveTailwindCli } from "./resolve-tailwind-cli";
49
57
 
@@ -62,6 +70,22 @@ export type BuildProdBundleOptions = {
62
70
  * @cosmicdrift/kumiko-renderer-web/styles.css wenn clientEntry da ist.
63
71
  * `false` deaktiviert die CSS-Pipeline explizit. */
64
72
  readonly stylesheet?: string | false;
73
+ } & ClientEntriesConfig;
74
+
75
+ /** One declared client entry, read from package.json `kumiko.clientEntries`.
76
+ * `name` becomes the output filename (`client-<name>.js` / `<name>.html`
77
+ * unless `htmlPath` overrides it). */
78
+ export type ClientEntryDeclaration = {
79
+ readonly name: string;
80
+ readonly sourceFile: string;
81
+ readonly htmlPath?: string;
82
+ };
83
+
84
+ /** The two mutually exclusive shapes read from package.json `kumiko.*`.
85
+ * Neither set → convention mode (src/client.tsx | src/client.ts). */
86
+ export type ClientEntriesConfig = {
87
+ readonly clientEntry?: string;
88
+ readonly clientEntries?: readonly ClientEntryDeclaration[];
65
89
  };
66
90
 
67
91
  export type BuildManifest = Readonly<Record<string, string>>;
@@ -132,7 +156,7 @@ export async function buildProdBundle(options: BuildProdBundleOptions = {}): Pro
132
156
  const assetsDir = join(outDir, ASSETS_DIR);
133
157
 
134
158
  // 1. Discovery: was ist da?
135
- const clientEntries = discoverClientEntries(cwd);
159
+ const clientEntries = resolveClientEntries(cwd, options);
136
160
  const firstClientSource = clientEntries[0]?.sourceFile;
137
161
  const stylesheet = resolveStylesheetEntry(cwd, firstClientSource, options.stylesheet);
138
162
  const publicDir = resolve(cwd, "public");
@@ -140,8 +164,8 @@ export async function buildProdBundle(options: BuildProdBundleOptions = {}): Pro
140
164
 
141
165
  if (clientEntries.length === 0 && !hasPublicDir) {
142
166
  throw new Error(
143
- `[kumiko build] nothing to build in ${cwd} — expected at least one of: ` +
144
- `src/client.tsx, src/client-*.tsx, public/`,
167
+ `[kumiko build] nothing to build in ${cwd} — expected one of: ` +
168
+ `package.json "kumiko.clientEntry"/"kumiko.clientEntries", src/client.tsx, public/`,
145
169
  );
146
170
  }
147
171
 
@@ -230,35 +254,166 @@ export async function buildProdBundle(options: BuildProdBundleOptions = {}): Pro
230
254
 
231
255
  // Single client-entry shape — one bundle, one html-template.
232
256
  export type ClientEntry = {
233
- /** Logical name. "client" für single-mode; sonst der Suffix von
234
- * src/client-<suffix>.tsx (z.B. "public", "admin"). */
257
+ /** Logical name. "client" for single-entry mode (package.json
258
+ * `kumiko.clientEntry` or the src/client.tsx convention); otherwise the
259
+ * `name` declared in package.json `kumiko.clientEntries[]`. */
235
260
  readonly name: string;
236
- /** TypeScript-Source. */
261
+ /** Absolute path to the TypeScript entry module. */
237
262
  readonly sourceFile: string;
238
- /** Manifest-key & logical-asset-path. "client.js" für single, sonst
239
- * "client-<name>.js". */
263
+ /** Manifest key & logical asset path. "client.js" for single-entry mode,
264
+ * otherwise "client-<name>.js". */
240
265
  readonly manifestKey: string;
241
- /** HTML-template-Pfad relativ zum cwd. "index.html" für single oder
242
- * "public"-entry; sonst "<name>.html". Naming bewusst symmetrisch
243
- * zu `runDevApp.clientEntries[].htmlPath` damit Build und Dev-Server
244
- * dieselbe Konvention verwenden. */
266
+ /** HTML template path — absolute when declared or discovered on disk,
267
+ * otherwise a bare default filename ("index.html", "<name>.html")
268
+ * resolved against cwd by the caller. Naming is deliberately symmetric
269
+ * to `runDevApp.clientEntries[].htmlPath` so build and dev server agree
270
+ * on the same convention. */
245
271
  readonly htmlPath: string;
246
272
  };
247
273
 
248
- // @internal — exported nur für Unit-Tests. Konsumenten gehen über
249
- // buildProdBundle.
250
- //
251
- // Discovery-Pattern:
252
- // - Falls `src/client-<suffix>.tsx` files existieren → multi-entry-mode,
253
- // ein Bundle pro Datei. "public" mapped auf index.html (default),
254
- // andere Suffixe auf "<suffix>.html".
255
- // - Sonst falls `src/client.tsx` oder `src/client.ts` existiert →
256
- // single-entry-mode mit name "client" + index.html.
257
- // - Sonst leeres Array (keine Client-Bundles).
258
- export function discoverClientEntries(cwd: string): readonly ClientEntry[] {
259
- const multi = discoverMultiClientEntries(cwd);
260
- if (multi.length > 0) return multi;
274
+ const ENTRY_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
275
+
276
+ // The extension guard and both basename derivations (collision check, Bun.build
277
+ // output mapping) must agree, otherwise an accepted entry maps to no bundle.
278
+ const SOURCE_EXTENSION_PATTERN = /\.(?:tsx?|jsx?)$/;
279
+
280
+ /** Resolves the client entries to build. Public — also called from
281
+ * `kumiko-build`/`kumiko build` via `@cosmicdrift/kumiko-dev-server/build`,
282
+ * not just from unit tests.
283
+ *
284
+ * Precedence (fw#2320 — explicit declaration, no filename inference):
285
+ * 1. `declared.clientEntry` and `declared.clientEntries` both set → throw.
286
+ * 2. `declared.clientEntries` (non-empty) → one entry per declaration.
287
+ * 3. `declared.clientEntry` → single entry, name "client".
288
+ * 4. Neither → convention: src/client.tsx | src/client.ts (name "client").
289
+ * If that convention finds nothing but src/ still has legacy
290
+ * `client-<suffix>.tsx` files, throws a migration error instead of
291
+ * silently shipping a build without their bundles. */
292
+ export function resolveClientEntries(
293
+ cwd: string,
294
+ declared: ClientEntriesConfig,
295
+ ): readonly ClientEntry[] {
296
+ const hasClientEntry = declared.clientEntry !== undefined;
297
+ const hasClientEntries = declared.clientEntries !== undefined;
298
+ if (hasClientEntry && hasClientEntries) {
299
+ throw new Error(
300
+ '[kumiko build] package.json "kumiko.clientEntry" and "kumiko.clientEntries" ' +
301
+ "are mutually exclusive — declare only one.",
302
+ );
303
+ }
304
+ if (declared.clientEntries !== undefined) {
305
+ return resolveDeclaredMultiEntries(cwd, declared.clientEntries);
306
+ }
307
+ if (declared.clientEntry !== undefined) {
308
+ return [resolveDeclaredSingleEntry(cwd, declared.clientEntry)];
309
+ }
310
+ return resolveConventionEntries(cwd);
311
+ }
261
312
 
313
+ function resolveDeclaredMultiEntries(
314
+ cwd: string,
315
+ declarations: readonly ClientEntryDeclaration[],
316
+ ): readonly ClientEntry[] {
317
+ if (declarations.length === 0) {
318
+ throw new Error(
319
+ '[kumiko build] package.json "kumiko.clientEntries" is empty — omit the key ' +
320
+ "entirely to use the src/client.tsx convention.",
321
+ );
322
+ }
323
+ const entries = declarations.map((decl) => resolveDeclaredEntry(cwd, decl));
324
+
325
+ const seenNames = new Set<string>();
326
+ for (const entry of entries) {
327
+ if (seenNames.has(entry.name)) {
328
+ throw new Error(
329
+ `[kumiko build] duplicate "kumiko.clientEntries[].name" "${entry.name}" — names must be unique.`,
330
+ );
331
+ }
332
+ seenNames.add(entry.name);
333
+ }
334
+
335
+ const seenHtmlBasenames = new Set<string>();
336
+ for (const entry of entries) {
337
+ const base = basenameOf(entry.htmlPath);
338
+ if (seenHtmlBasenames.has(base)) {
339
+ throw new Error(
340
+ `[kumiko build] two "kumiko.clientEntries" entries resolve to the same HTML ` +
341
+ `output file "${base}" — set an explicit "htmlPath" on one of them.`,
342
+ );
343
+ }
344
+ seenHtmlBasenames.add(base);
345
+ }
346
+
347
+ // Bun.build's output → ClientEntry mapping (buildClientBundles) matches on
348
+ // the source basename without extension. Two entries sharing it would
349
+ // silently map to the same bundle.
350
+ const seenSourceBasenames = new Set<string>();
351
+ for (const entry of entries) {
352
+ const base = basenameOf(entry.sourceFile).replace(SOURCE_EXTENSION_PATTERN, "");
353
+ if (seenSourceBasenames.has(base)) {
354
+ throw new Error(
355
+ `[kumiko build] two "kumiko.clientEntries" entries share the source basename ` +
356
+ `"${base}" ("${entry.sourceFile}") — rename one of the source files.`,
357
+ );
358
+ }
359
+ seenSourceBasenames.add(base);
360
+ }
361
+
362
+ return [...entries].sort((a, b) => a.name.localeCompare(b.name));
363
+ }
364
+
365
+ function resolveDeclaredEntry(cwd: string, decl: ClientEntryDeclaration): ClientEntry {
366
+ if (!ENTRY_NAME_PATTERN.test(decl.name)) {
367
+ throw new Error(
368
+ `[kumiko build] invalid "kumiko.clientEntries[].name" "${decl.name}" — must match ` +
369
+ `^[a-z][a-z0-9-]*$ (it becomes the output filename client-${decl.name}.js).`,
370
+ );
371
+ }
372
+ const sourceFile = resolveWithinCwd(
373
+ cwd,
374
+ decl.sourceFile,
375
+ `kumiko.clientEntries["${decl.name}"].sourceFile`,
376
+ );
377
+ assertValidSourceExtension(decl.sourceFile, `kumiko.clientEntries["${decl.name}"].sourceFile`);
378
+ if (!isExistingFile(sourceFile)) {
379
+ throw new Error(
380
+ `[kumiko build] kumiko.clientEntries["${decl.name}"].sourceFile "${decl.sourceFile}" ` +
381
+ `does not exist (resolved: ${sourceFile}).`,
382
+ );
383
+ }
384
+ const htmlPath =
385
+ decl.htmlPath !== undefined
386
+ ? resolveDeclaredHtmlPath(cwd, decl.name, decl.htmlPath)
387
+ : decl.name === "public"
388
+ ? (discoverHtmlTemplateFor(cwd, "index") ?? "index.html")
389
+ : (discoverHtmlTemplateFor(cwd, decl.name) ?? `${decl.name}.html`);
390
+ return { name: decl.name, sourceFile, manifestKey: `client-${decl.name}.js`, htmlPath };
391
+ }
392
+
393
+ function resolveDeclaredHtmlPath(cwd: string, entryName: string, htmlPath: string): string {
394
+ const label = `kumiko.clientEntries["${entryName}"].htmlPath`;
395
+ const resolved = resolveWithinCwd(cwd, htmlPath, label);
396
+ assertValidHtmlExtension(htmlPath, label);
397
+ return resolved;
398
+ }
399
+
400
+ function resolveDeclaredSingleEntry(cwd: string, clientEntry: string): ClientEntry {
401
+ const sourceFile = resolveWithinCwd(cwd, clientEntry, "kumiko.clientEntry");
402
+ assertValidSourceExtension(clientEntry, "kumiko.clientEntry");
403
+ if (!isExistingFile(sourceFile)) {
404
+ throw new Error(
405
+ `[kumiko build] kumiko.clientEntry "${clientEntry}" does not exist (resolved: ${sourceFile}).`,
406
+ );
407
+ }
408
+ return {
409
+ name: "client",
410
+ sourceFile,
411
+ manifestKey: "client.js",
412
+ htmlPath: discoverHtmlTemplateFor(cwd, "index") ?? "index.html",
413
+ };
414
+ }
415
+
416
+ function resolveConventionEntries(cwd: string): readonly ClientEntry[] {
262
417
  for (const candidate of ["src/client.tsx", "src/client.ts"]) {
263
418
  const sourceFile = resolve(cwd, candidate);
264
419
  if (existsSync(sourceFile)) {
@@ -272,10 +427,19 @@ export function discoverClientEntries(cwd: string): readonly ClientEntry[] {
272
427
  ];
273
428
  }
274
429
  }
430
+ const legacyEntries = findLegacyClientFiles(cwd);
431
+ if (legacyEntries.length > 0) {
432
+ throw new Error(buildLegacyMultiEntryMigrationError(legacyEntries));
433
+ }
275
434
  return [];
276
435
  }
277
436
 
278
- function discoverMultiClientEntries(cwd: string): readonly ClientEntry[] {
437
+ type LegacyClientFile = { readonly name: string; readonly file: string };
438
+
439
+ // Pre-fw#2320 multi-entry convention: any src/client-<suffix>.tsx(x) became
440
+ // its own bundle. Detecting these without a declaration means an unmigrated
441
+ // app would silently ship without their bundles — throw instead.
442
+ function findLegacyClientFiles(cwd: string): readonly LegacyClientFile[] {
279
443
  const srcDir = resolve(cwd, "src");
280
444
  if (!existsSync(srcDir)) return [];
281
445
  let files: readonly string[];
@@ -284,25 +448,122 @@ function discoverMultiClientEntries(cwd: string): readonly ClientEntry[] {
284
448
  } catch {
285
449
  return [];
286
450
  }
287
- const out: ClientEntry[] = [];
451
+ const found: LegacyClientFile[] = [];
288
452
  for (const file of files) {
289
453
  const match = /^client-([a-z][a-z0-9-]*)\.tsx?$/.exec(file);
290
- const suffix = match?.[1];
291
- if (!suffix) continue;
292
- const sourceFile = resolve(srcDir, file);
293
- out.push({
294
- name: suffix,
295
- sourceFile,
296
- manifestKey: `client-${suffix}.js`,
297
- // "public"-entry serviert die default-page (index.html), sonst pro
298
- // Suffix ein eigenes Template.
299
- htmlPath:
300
- suffix === "public"
301
- ? (discoverHtmlTemplateFor(cwd, "index") ?? "index.html")
302
- : (discoverHtmlTemplateFor(cwd, suffix) ?? `${suffix}.html`),
303
- });
454
+ if (match?.[1]) found.push({ name: match[1], file });
455
+ }
456
+ return found.sort((a, b) => a.name.localeCompare(b.name));
457
+ }
458
+
459
+ function buildLegacyMultiEntryMigrationError(entries: readonly LegacyClientFile[]): string {
460
+ const fileNames = entries.map((e) => `src/${e.file}`).join(", ");
461
+ const snippet = entries
462
+ .map((e) => ` { "name": "${e.name}", "sourceFile": "./src/${e.file}" }`)
463
+ .join(",\n");
464
+ return (
465
+ `[kumiko build] found ${fileNames} but no "kumiko.clientEntries" declaration in ` +
466
+ `package.json — production builds no longer infer entries from filenames (fw#2320). Add:\n\n` +
467
+ ` "kumiko": {\n` +
468
+ ` "clientEntries": [\n` +
469
+ `${snippet}\n` +
470
+ ` ]\n` +
471
+ ` }\n`
472
+ );
473
+ }
474
+
475
+ function resolveWithinCwd(cwd: string, value: string, label: string): string {
476
+ const resolved = resolve(cwd, value);
477
+ const rel = relative(cwd, resolved);
478
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
479
+ throw new Error(`[kumiko build] ${label} "${value}" resolves outside the app root ${cwd}.`);
480
+ }
481
+ return resolved;
482
+ }
483
+
484
+ function isExistingFile(path: string): boolean {
485
+ return existsSync(path) && statSync(path).isFile();
486
+ }
487
+
488
+ // The basename of htmlPath becomes a dist output filename (e.g. "manifest.json"
489
+ // would collide with a file the build writes itself) — restrict it to .html.
490
+ function assertValidHtmlExtension(value: string, label: string): void {
491
+ if (!value.endsWith(".html")) {
492
+ throw new Error(`[kumiko build] ${label} "${value}" must point to an .html file.`);
493
+ }
494
+ }
495
+
496
+ function assertValidSourceExtension(value: string, label: string): void {
497
+ if (!SOURCE_EXTENSION_PATTERN.test(value)) {
498
+ throw new Error(
499
+ `[kumiko build] ${label} "${value}" must point to a .ts, .tsx, .js or .jsx file.`,
500
+ );
501
+ }
502
+ }
503
+
504
+ /** Reads package.json → `kumiko.clientEntry` / `kumiko.clientEntries`.
505
+ * Missing file or missing keys → `{}` (falls back to convention mode). A
506
+ * malformed value under either key throws — a typo here must fail the
507
+ * build loudly instead of silently dropping declared entries. */
508
+ export function readClientEntriesConfig(cwd: string): ClientEntriesConfig {
509
+ const pkgJsonPath = resolve(cwd, "package.json");
510
+ if (!existsSync(pkgJsonPath)) return {};
511
+ const raw = readFileSync(pkgJsonPath, "utf8");
512
+ const parsed = parseJsonOrThrow<unknown>(raw, pkgJsonPath);
513
+ if (!isPlainObject(parsed)) return {};
514
+ const kumiko = parsed["kumiko"];
515
+ if (!isPlainObject(kumiko)) return {};
516
+
517
+ const result: { clientEntry?: string; clientEntries?: readonly ClientEntryDeclaration[] } = {};
518
+
519
+ if ("clientEntry" in kumiko) {
520
+ const value = kumiko["clientEntry"];
521
+ if (typeof value !== "string") {
522
+ throw new Error(
523
+ `[kumiko build] package.json "kumiko.clientEntry" must be a string, got ${typeof value}.`,
524
+ );
525
+ }
526
+ result.clientEntry = value;
527
+ }
528
+
529
+ if ("clientEntries" in kumiko) {
530
+ const value = kumiko["clientEntries"];
531
+ if (!Array.isArray(value)) {
532
+ throw new Error(
533
+ `[kumiko build] package.json "kumiko.clientEntries" must be an array, got ${typeof value}.`,
534
+ );
535
+ }
536
+ result.clientEntries = value.map((item, index) => parseClientEntryDeclaration(item, index));
537
+ }
538
+
539
+ return result;
540
+ }
541
+
542
+ function parseClientEntryDeclaration(item: unknown, index: number): ClientEntryDeclaration {
543
+ if (!isPlainObject(item)) {
544
+ throw new Error(
545
+ `[kumiko build] package.json "kumiko.clientEntries[${index}]" must be an object.`,
546
+ );
304
547
  }
305
- return out.sort((a, b) => a.name.localeCompare(b.name));
548
+ const name = item["name"];
549
+ const sourceFile = item["sourceFile"];
550
+ const htmlPath = item["htmlPath"];
551
+ if (typeof name !== "string" || name.length === 0) {
552
+ throw new Error(
553
+ `[kumiko build] package.json "kumiko.clientEntries[${index}].name" must be a non-empty string.`,
554
+ );
555
+ }
556
+ if (typeof sourceFile !== "string" || sourceFile.length === 0) {
557
+ throw new Error(
558
+ `[kumiko build] package.json "kumiko.clientEntries[${index}].sourceFile" must be a non-empty string.`,
559
+ );
560
+ }
561
+ if (htmlPath !== undefined && typeof htmlPath !== "string") {
562
+ throw new Error(
563
+ `[kumiko build] package.json "kumiko.clientEntries[${index}].htmlPath" must be a string when set.`,
564
+ );
565
+ }
566
+ return { name, sourceFile, ...(htmlPath !== undefined && { htmlPath }) };
306
567
  }
307
568
 
308
569
  function discoverHtmlTemplateFor(cwd: string, basename: string): string | undefined {
@@ -475,7 +736,10 @@ async function buildClientBundles(
475
736
  // entry-output zurück auf seinen ClientEntry via Basename-match.
476
737
  const result: Record<string, string> = {};
477
738
  for (const entry of entries) {
478
- const baseName = (entry.sourceFile.split("/").pop() ?? "").replace(/\.tsx?$/, "");
739
+ const baseName = (entry.sourceFile.split("/").pop() ?? "").replace(
740
+ SOURCE_EXTENSION_PATTERN,
741
+ "",
742
+ );
479
743
  const match = entryOutputs.find((o) => {
480
744
  const outName = o.path.split("/").pop() ?? "";
481
745
  return outName.startsWith(`${baseName}-`);
@@ -537,7 +801,8 @@ async function renderHtml(
537
801
  }
538
802
 
539
803
  // @internal — exported for unit tests only. See #2305: the message must
540
- // name the source file and the filename convention.
804
+ // name the source file and, for a declared multi-entry, which package.json
805
+ // "kumiko.clientEntries" entry it came from.
541
806
  export function buildMissingTemplateError(manifest: BuildManifest, entry: ClientEntry): string {
542
807
  const cssLine = manifest["styles.css"]
543
808
  ? ` <link rel="stylesheet" href="/styles.css" />\n`
@@ -548,15 +813,11 @@ export function buildMissingTemplateError(manifest: BuildManifest, entry: Client
548
813
  : "";
549
814
  const sourceBasename = basenameOf(entry.sourceFile);
550
815
  // Single-mode entries always carry manifestKey "client.js"; any other
551
- // value came from discoverMultiClientEntries' filename match.
816
+ // value came from a declared package.json "kumiko.clientEntries" entry.
552
817
  const isMultiEntry = entry.manifestKey !== "client.js";
553
818
  const discoveryHint = isMultiEntry
554
- ? `"src/${sourceBasename}" wurde automatisch als eigener Bundle-Entry erkannt — ` +
555
- `jede Datei nach dem Muster src/client-<suffix>.tsx zählt als Entry (hier: Suffix "${entry.name}"). ` +
556
- `War das nicht beabsichtigt, z. B. weil die Datei nur ein von einem anderen client-*.tsx ` +
557
- `importiertes Modul ist: Datei umbenennen (ohne "client-"-Präfix), dann verschwindet der Entry.\n` +
558
- `\n` +
559
- `War es beabsichtigt:\n`
819
+ ? `Entry "${entry.name}" comes from package.json "kumiko.clientEntries" ` +
820
+ `(sourceFile: "src/${sourceBasename}").\n\n`
560
821
  : "";
561
822
  return (
562
823
  `[kumiko build] kein ${entry.htmlPath} gefunden für Entry "src/${sourceBasename}", ` +
package/src/changes.json CHANGED
@@ -1,4 +1,18 @@
1
1
  [
2
+ {
3
+ "version": "0.323.0",
4
+ "type": "breaking",
5
+ "title": "buildProdBundle takes client entries explicitly via package.json \"kumiko.clientEntries\"/\"kumiko.clientEntry\" — no more filename inference",
6
+ "detail": "`discoverClientEntries(cwd)`, which scanned `src/` for\n`client-<suffix>.tsx` files and treated every match as its own bundle\nentry, is replaced by `resolveClientEntries(cwd, declared)` plus\n`readClientEntriesConfig(cwd)`, a boundary parser for package.json\n`kumiko.clientEntry` / `kumiko.clientEntries`. `buildProdBundle(options)`\nnow accepts the same `clientEntry`/`clientEntries` shape directly.\n\n`kumiko.clientEntries` entries are validated: `name` must match\n`^[a-z][a-z0-9-]*$` (it becomes the output filename), `sourceFile` (and\n`htmlPath`, if set) must resolve inside the app root, `sourceFile` must\nexist, and names/html-output-files/source-basenames must be unique\nacross the list. `clientEntry` and `clientEntries` are mutually\nexclusive; an empty `clientEntries` array is rejected (omit the key\ninstead). Apps with only `src/client.tsx` or `src/client.ts` need no\ndeclaration — that convention is unchanged.",
7
+ "migration": "Apps with `src/client-<name>.tsx` entries: add the entry list to\npackage.json, using the same objects already passed to\n`runDevApp`/`createKumikoServer`'s `clientEntries` (paths relative to the\napp root). Without this, the build now throws instead of silently\nbuilding without those bundles.\n\n publicstatus:\n\n \"kumiko\": {\n \"clientEntries\": [\n { \"name\": \"public\", \"sourceFile\": \"./src/client-public.tsx\" },\n { \"name\": \"admin\", \"sourceFile\": \"./src/client-admin.tsx\", \"htmlPath\": \"./public/admin.html\" },\n { \"name\": \"auth\", \"sourceFile\": \"./src/client-auth.tsx\", \"htmlPath\": \"./public/auth.html\" }\n ]\n }\n\n show-pony:\n\n \"kumiko\": {\n \"clientEntries\": [\n { \"name\": \"admin\", \"sourceFile\": \"./src/client-admin.tsx\", \"htmlPath\": \"./public/admin.html\" },\n { \"name\": \"public\", \"sourceFile\": \"./src/client-public.tsx\", \"htmlPath\": \"./public/index.html\" }\n ]\n }\n\n offlot-app:\n\n \"kumiko\": {\n \"clientEntries\": [\n { \"name\": \"app\", \"sourceFile\": \"./src/client-app.tsx\", \"htmlPath\": \"./public/app.html\" }\n ]\n }\n\nApps using a single entry that isn't `src/client.tsx`: add\n`\"kumiko\": { \"clientEntry\": \"./src/…\" }` instead.\n\nApps with only `src/client.tsx` | `src/client.ts`: no action needed.\n\n`discoverClientEntries` (exported from\n`@cosmicdrift/kumiko-dev-server/build`) is removed; callers use\n`resolveClientEntries(cwd, readClientEntriesConfig(cwd))`."
8
+ },
9
+ {
10
+ "version": "0.323.0",
11
+ "type": "improvement",
12
+ "title": "runProdApp wires auth-email-password's new invite-info query into invite.infoHandler",
13
+ "detail": "When the invite feature is enabled, runProdApp now passes AuthQueries.inviteInfo as invite.infoHandler, mounting POST /auth/invite-info automatically.",
14
+ "migration": "No action needed: purely additive wiring, no option changes."
15
+ },
2
16
  {
3
17
  "version": "0.321.0",
4
18
  "type": "improvement",
@@ -46,6 +46,7 @@ import {
46
46
  AuthHandlers,
47
47
  type AuthMailLocale,
48
48
  type AuthPaths,
49
+ AuthQueries,
49
50
  type EmailVerificationOptions,
50
51
  type InviteOptions,
51
52
  type PasswordResetOptions,
@@ -1168,6 +1169,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
1168
1169
  acceptHandler: AuthHandlers.inviteAccept,
1169
1170
  acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
1170
1171
  signupCompleteHandler: AuthHandlers.inviteSignupComplete,
1172
+ infoHandler: AuthQueries.inviteInfo,
1171
1173
  },
1172
1174
  }),
1173
1175
  },