@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.
|
|
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.
|
|
80
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
81
|
-
"@cosmicdrift/kumiko-headless": "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({
|
|
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
|
|
25
|
-
//
|
|
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
|
-
|
|
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("
|
|
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 =
|
|
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("
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
|
78
|
-
expect(admin?.manifestKey).toBe("client-admin.js");
|
|
79
|
-
expect(admin?.htmlPath).toBe("admin.html");
|
|
85
|
+
const entries = resolveClientEntries(workDir, {});
|
|
80
86
|
|
|
81
|
-
|
|
82
|
-
expect(
|
|
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("
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
expect(
|
|
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("
|
|
101
|
-
|
|
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("
|
|
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 =
|
|
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("
|
|
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(
|
|
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
|
|
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("
|
|
369
|
-
expect(message).toContain("
|
|
711
|
+
expect(message).toContain("kumiko.clientEntries");
|
|
712
|
+
expect(message).toContain('Entry "features"');
|
|
370
713
|
});
|
|
371
714
|
|
|
372
|
-
test("single-mode entry gets no
|
|
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("
|
|
380
|
-
expect(message).not.toContain("client-<suffix>.tsx");
|
|
722
|
+
expect(message).not.toContain("kumiko.clientEntries");
|
|
381
723
|
});
|
|
382
724
|
});
|
package/src/build-prod-bundle.ts
CHANGED
|
@@ -3,9 +3,16 @@
|
|
|
3
3
|
// die App-Struktur, Bun.build + Tailwind + Public-Folder-Copy
|
|
4
4
|
// produzieren ein deploybares dist/.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
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 =
|
|
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
|
|
144
|
-
`
|
|
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"
|
|
234
|
-
* src/client
|
|
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
|
|
261
|
+
/** Absolute path to the TypeScript entry module. */
|
|
237
262
|
readonly sourceFile: string;
|
|
238
|
-
/** Manifest
|
|
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
|
|
242
|
-
* "
|
|
243
|
-
*
|
|
244
|
-
*
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
? `"
|
|
555
|
-
`
|
|
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",
|
package/src/run-prod-app.ts
CHANGED
|
@@ -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
|
},
|