@cosmicdrift/kumiko-dev-server 0.329.0 → 0.330.2

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.
Files changed (125) hide show
  1. package/README.md +17 -0
  2. package/bin/kumiko-build.ts +3 -2
  3. package/bin/kumiko-dev.ts +1 -1
  4. package/bin/kumiko-init-deploy.ts +1 -1
  5. package/bin/kumiko-schema-check.ts +4 -1
  6. package/dist/build-server-bundle.d.ts +31 -0
  7. package/dist/build-server-bundle.js +282 -0
  8. package/dist/build.d.ts +2 -0
  9. package/dist/build.js +5 -0
  10. package/dist/cli.d.ts +5 -0
  11. package/dist/cli.js +9 -0
  12. package/dist/codegen/index.d.ts +4 -0
  13. package/dist/codegen/index.js +4 -0
  14. package/dist/codegen/render.d.ts +55 -0
  15. package/dist/codegen/render.js +273 -0
  16. package/dist/codegen/run-codegen.d.ts +20 -0
  17. package/dist/codegen/run-codegen.js +219 -0
  18. package/dist/codegen/scan-events.d.ts +76 -0
  19. package/dist/codegen/scan-events.js +447 -0
  20. package/dist/codegen/watch.d.ts +33 -0
  21. package/dist/codegen/watch.js +112 -0
  22. package/dist/compose-stacks.d.ts +60 -0
  23. package/dist/compose-stacks.js +127 -0
  24. package/dist/crash-tracker.d.ts +17 -0
  25. package/dist/crash-tracker.js +37 -0
  26. package/dist/create-kumiko-server.d.ts +195 -0
  27. package/dist/create-kumiko-server.js +1040 -0
  28. package/dist/env-schema.d.ts +15 -0
  29. package/dist/env-schema.js +60 -0
  30. package/dist/few-shot-corpus.d.ts +72 -0
  31. package/dist/few-shot-corpus.js +262 -0
  32. package/dist/framework-version.d.ts +1 -0
  33. package/dist/framework-version.js +31 -0
  34. package/dist/index.d.ts +22 -0
  35. package/dist/index.js +33 -0
  36. package/dist/init-deploy-cli.d.ts +10 -0
  37. package/dist/init-deploy-cli.js +111 -0
  38. package/dist/kebab.d.ts +1 -0
  39. package/{src/kebab.ts → dist/kebab.js} +2 -3
  40. package/dist/run-dev-app.d.ts +174 -0
  41. package/dist/run-dev-app.js +403 -0
  42. package/dist/scaffold-app-feature.d.ts +16 -0
  43. package/dist/scaffold-app-feature.js +240 -0
  44. package/dist/scaffold-app.d.ts +45 -0
  45. package/dist/scaffold-app.js +852 -0
  46. package/dist/scaffold-demo-tasks.d.ts +8 -0
  47. package/{src/scaffold-demo-tasks.ts → dist/scaffold-demo-tasks.js} +69 -98
  48. package/dist/scaffold-deploy.d.ts +92 -0
  49. package/dist/scaffold-deploy.js +254 -0
  50. package/dist/scaffold-feature.d.ts +26 -0
  51. package/dist/scaffold-feature.js +163 -0
  52. package/dist/schema-apply.d.ts +11 -0
  53. package/dist/schema-apply.js +95 -0
  54. package/dist/schema-check-core.d.ts +2 -0
  55. package/{src/schema-check-core.ts → dist/schema-check-core.js} +8 -10
  56. package/dist/schema-tables-auth-mode.d.ts +2 -0
  57. package/{src/schema-tables-auth-mode.ts → dist/schema-tables-auth-mode.js} +2 -10
  58. package/dist/schema-tables-minimal.d.ts +2 -0
  59. package/{src/schema-tables-minimal.ts → dist/schema-tables-minimal.js} +2 -11
  60. package/dist/setup-test-stack-from-features.d.ts +11 -0
  61. package/dist/setup-test-stack-from-features.js +42 -0
  62. package/dist/welcome-banner.d.ts +15 -0
  63. package/dist/welcome-banner.js +27 -0
  64. package/package.json +71 -24
  65. package/src/changes.json +6 -0
  66. package/src/__tests__/build-prod-bundle.integration.test.ts +0 -282
  67. package/src/__tests__/build-server-bundle.test.ts +0 -228
  68. package/src/__tests__/classify-change.test.ts +0 -87
  69. package/src/__tests__/compose-stacks.test.ts +0 -273
  70. package/src/__tests__/config-resolver-default.integration.test.ts +0 -165
  71. package/src/__tests__/crash-tracker.test.ts +0 -89
  72. package/src/__tests__/create-kumiko-server-errors.test.ts +0 -54
  73. package/src/__tests__/create-kumiko-server-options.test.ts +0 -33
  74. package/src/__tests__/create-kumiko-server.integration.test.ts +0 -1007
  75. package/src/__tests__/discover-format.test.ts +0 -32
  76. package/src/__tests__/env-schema.integration.test.ts +0 -248
  77. package/src/__tests__/env-schema.test.ts +0 -132
  78. package/src/__tests__/few-shot-corpus.test.ts +0 -316
  79. package/src/__tests__/framework-version.test.ts +0 -45
  80. package/src/__tests__/init-deploy-cli.test.ts +0 -113
  81. package/src/__tests__/kumiko-schema-check.test.ts +0 -56
  82. package/src/__tests__/merge-extra-context.test.ts +0 -67
  83. package/src/__tests__/resolve-stylesheet.test.ts +0 -132
  84. package/src/__tests__/run-dev-app-boot-validation.test.ts +0 -38
  85. package/src/__tests__/run-dev-app.integration.test.ts +0 -290
  86. package/src/__tests__/runtime-classification.test.ts +0 -26
  87. package/src/__tests__/saas-identity-wire.integration.test.ts +0 -427
  88. package/src/__tests__/scaffold-app-feature.test.ts +0 -216
  89. package/src/__tests__/scaffold-app.test.ts +0 -630
  90. package/src/__tests__/scaffold-demo-tasks.test.ts +0 -47
  91. package/src/__tests__/scaffold-deploy.test.ts +0 -464
  92. package/src/__tests__/scaffold-dev-cycle.integration.test.ts +0 -109
  93. package/src/__tests__/scaffold-feature.test.ts +0 -143
  94. package/src/__tests__/schema-apply.integration.test.ts +0 -191
  95. package/src/__tests__/setup-test-stack-from-features.integration.test.ts +0 -30
  96. package/src/__tests__/setup-test-stack-job-run-logger.integration.test.ts +0 -72
  97. package/src/__tests__/walkthrough.integration.test.ts +0 -140
  98. package/src/__tests__/welcome-banner.test.ts +0 -42
  99. package/src/build-server-bundle.ts +0 -346
  100. package/src/build.ts +0 -28
  101. package/src/codegen/__tests__/render-codegen.test.ts +0 -100
  102. package/src/codegen/__tests__/run-codegen.test.ts +0 -665
  103. package/src/codegen/__tests__/strict-mode-diagnostics.test.ts +0 -347
  104. package/src/codegen/__tests__/watch.test.ts +0 -222
  105. package/src/codegen/index.ts +0 -19
  106. package/src/codegen/render.ts +0 -305
  107. package/src/codegen/run-codegen.ts +0 -278
  108. package/src/codegen/scan-events.ts +0 -565
  109. package/src/codegen/watch.ts +0 -140
  110. package/src/compose-stacks.ts +0 -193
  111. package/src/crash-tracker.ts +0 -56
  112. package/src/create-kumiko-server.ts +0 -1397
  113. package/src/env-schema.ts +0 -72
  114. package/src/few-shot-corpus.ts +0 -370
  115. package/src/framework-version.ts +0 -39
  116. package/src/index.ts +0 -105
  117. package/src/init-deploy-cli.ts +0 -148
  118. package/src/run-dev-app.ts +0 -694
  119. package/src/scaffold-app-feature.ts +0 -283
  120. package/src/scaffold-app.ts +0 -1033
  121. package/src/scaffold-deploy.ts +0 -393
  122. package/src/scaffold-feature.ts +0 -226
  123. package/src/schema-apply.ts +0 -123
  124. package/src/setup-test-stack-from-features.ts +0 -80
  125. package/src/welcome-banner.ts +0 -43
@@ -1,1397 +0,0 @@
1
- // Dev server bootstrap. Wires the real Kumiko stack behind a Bun.serve
2
- // shell that also bundles the client, serves it at /client.js, mints
3
- // a JWT for a dev-admin on GET /, and broadcasts SSE reloads when
4
- // source files change. One import + one call is enough for any
5
- // sample's server.ts — the 150-line boilerplate of pre-dev-server
6
- // days lives here now.
7
- //
8
- // Not for production:
9
- // - auto-mints a JWT for TestUsers.admin on every GET / (anyone
10
- // hitting the server ends up as admin)
11
- // - bundles the client in-process (prod uses a prebuilt dist)
12
- // - no rate-limit, no helmet, no secure-cookie flags
13
- //
14
- // The companion prod entry will land at `@cosmicdrift/kumiko-framework/server`
15
- // with a different options shape (clientDist, auth config, db url).
16
-
17
- import { spawn } from "node:child_process";
18
- import { existsSync, mkdtempSync, statSync } from "node:fs";
19
- import { readFile, realpath, watch } from "node:fs/promises";
20
- import { tmpdir } from "node:os";
21
- import { join, resolve, sep } from "node:path";
22
- import { resolveAnonymousAccessFromRegistry } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
23
- import {
24
- type AuthRoutesConfig,
25
- buildRequestContextDataFromRequest,
26
- createClientIpResolver,
27
- type ExtraRouteDefinition,
28
- generateToken,
29
- requestContext,
30
- } from "@cosmicdrift/kumiko-framework/api";
31
- import {
32
- buildAppSchema,
33
- createAnonymousUser,
34
- type FeatureDefinition,
35
- } from "@cosmicdrift/kumiko-framework/engine";
36
- import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
37
- import {
38
- pushEntityProjectionTables,
39
- setupTestStack,
40
- type TestStack,
41
- type TestStackOptions,
42
- TestUsers,
43
- } from "@cosmicdrift/kumiko-framework/stack";
44
- import {
45
- type PageHeadResolver,
46
- type PageHeadSystemQuery,
47
- resolveAndInjectPageHead,
48
- } from "@cosmicdrift/kumiko-headless/apex";
49
- import { startDevJobRunners } from "@cosmicdrift/kumiko-server-runtime/boot/job-run-logger";
50
- import {
51
- buildBunServeOptions,
52
- resolveDerivedMaxRequestBodySize,
53
- } from "@cosmicdrift/kumiko-server-runtime/bun-serve-options";
54
- import {
55
- makeDispatchSystemWrite,
56
- type SystemWireDeps,
57
- } from "@cosmicdrift/kumiko-server-runtime/extra-routes-deps";
58
- import {
59
- RENDERER_WEB_FONT_FILE_PATTERN,
60
- RENDERER_WEB_FONTS_URL_PREFIX,
61
- resolveRendererWebFontsDir,
62
- } from "@cosmicdrift/kumiko-server-runtime/renderer-web-fonts";
63
- import {
64
- canResolveTailwindStylesheet,
65
- resolveTailwindCli,
66
- } from "@cosmicdrift/kumiko-server-runtime/resolve-tailwind-cli";
67
- import {
68
- type HonoLikeApp,
69
- stripNoRouteMatchHeader,
70
- tryHonoFirst,
71
- } from "@cosmicdrift/kumiko-server-runtime/try-hono-first";
72
-
73
- // Runtime-detection. The dev-server is meant to run under Bun (Kumiko's
74
- // target runtime), but the test-suite runs under vitest on Node — we
75
- // gate every Bun.* call so the module at least LOADS under Node, and
76
- // tests drive the fetch-handler directly instead of going through
77
- // Bun.serve + real sockets.
78
- const hasBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
79
-
80
- // Bun.serve returns a parametrised Server<WebSocketData>; nothing here reads
81
- // the socket data, so the narrow `unknown` binding is plenty.
82
- // `Bun` isn't declared in Node types, so we fall back to `unknown`
83
- // and only resolve the type when Bun is actually around.
84
- type BunServer = typeof Bun extends undefined ? unknown : ReturnType<typeof Bun.serve>;
85
-
86
- // @wrapper-known semantic-alias
87
- // biome-ignore lint/suspicious/noConsole: dev-server status logging
88
- const logInfo = (msg: string): void => console.log(msg);
89
- // @wrapper-known semantic-alias
90
- // biome-ignore lint/suspicious/noConsole: dev-server error logging
91
- const logError = (...args: unknown[]): void => console.error(...args);
92
-
93
- /** Multi-entry mode for apps that ship several separate bundles (e.g.
94
- * publicstatus: `admin.<base>` loads the admin UI, otherwise the public
95
- * page).
96
- *
97
- * kumiko-build reads the same entry list from package.json
98
- * `kumiko.clientEntries` and serves `/client-<name>.js` per HTTP — the
99
- * dev server and the prod build stay in sync via that shared declaration,
100
- * not a filename convention. Mutually exclusive with `clientEntry`.
101
- * Multi-entry apps MUST also set `hostDispatch` — otherwise the server
102
- * has no way to know which HTML to serve. */
103
- export type DevClientEntry = {
104
- /** Logical name. Should match the corresponding package.json
105
- * `kumiko.clientEntries[].name` so dev and prod produce identical
106
- * asset URLs (`/client-<name>.js`). */
107
- readonly name: string;
108
- /** Absoluter Pfad zur Browser-Entry-Datei. */
109
- readonly sourceFile: string;
110
- /** Optional eigenes HTML-Template für diesen Entry. Wenn nicht gesetzt,
111
- * wird `htmlPath` (das default-Template) für alle Entries genutzt. */
112
- readonly htmlPath?: string;
113
- };
114
-
115
- /** Discriminated union, identical in shape to `runProdApp.hostDispatch`, so
116
- * dev/prod routing mirrors 1:1 — an apex 404 in prod is an apex 404 in dev
117
- * (with an `/etc/hosts` entry for the affected domain). HTML never carries
118
- * a schema, in dev or prod — createKumikoApp fetches it itself from the
119
- * authenticated GET /api/schema. */
120
- export type DevHostDispatchResult =
121
- | {
122
- readonly kind: "html";
123
- readonly entryName: string;
124
- /** @deprecated Ignored — HTML never carries the schema; createKumikoApp
125
- * fetches it from the authenticated GET /api/schema. */
126
- readonly injectSchema?: boolean;
127
- }
128
- | {
129
- /** Static HTML: serves a file verbatim, no bundle injection. Mirrors
130
- * prod's `{ kind: "html", file: ... }` for marketing/apex pages that
131
- * don't need React. Path relative to the server CWD. */
132
- readonly kind: "static-html";
133
- readonly file: string;
134
- }
135
- | { readonly kind: "redirect"; readonly to: string; readonly status?: 301 | 302 }
136
- | { readonly kind: "not-found" };
137
-
138
- /** Picks an entry by inspecting the incoming request. Wird von
139
- * Multi-Entry-Apps gesetzt; im Single-Entry-Mode irrelevant. */
140
- export type DevHostDispatch = (
141
- req: Request,
142
- deps: { readonly systemQuery: PageHeadSystemQuery },
143
- ) => DevHostDispatchResult | Promise<DevHostDispatchResult>;
144
-
145
- export type CreateKumikoServerOptions = {
146
- /** Features whose entities, handlers, and screens get wired into the
147
- * dev stack. Pass every feature the app is supposed to run. */
148
- readonly features: readonly FeatureDefinition[];
149
- /** Absolute path to the browser entry module. The dev-server runs
150
- * `Bun.build` on it and serves the output at `/client.js`. Omit to
151
- * run a headless API-only dev-stack (rare — every sample has one).
152
- * Mutually exclusive mit `clientEntries`. */
153
- readonly clientEntry?: string;
154
- /** Multi-Entry-Mode: mehrere getrennte Bundles, jeweils unter
155
- * `/client-<name>.js`. Mutually exclusive mit `clientEntry`. Setze
156
- * `hostDispatch` mit, sonst bleibt unklar welches Template zurück-
157
- * geht. */
158
- readonly clientEntries?: readonly DevClientEntry[];
159
- /** Multi-Entry-Mode: Routing pro Request. Inspiziert `Host` (oder
160
- * was auch immer) und liefert eine Discriminated-Union zurück
161
- * (html → entry-bundle, redirect → 30x, not-found → 404).
162
- * Symmetric zu `runProdApp.hostDispatch` damit dev/prod-Drift
163
- * beim Routing unmöglich ist. */
164
- readonly hostDispatch?: DevHostDispatch;
165
- /** @internal — ersetzt `Bun.build` für Tests. Default ruft die echte
166
- * Bun-Toolchain. Tests unter Node injizieren einen Stub damit der
167
- * Routing-Pfad treibbar bleibt ohne Bun.build aufzurufen.
168
- * KEIN Public-API-Surface — präfixiert mit `_` damit Konsumenten
169
- * wissen dass das ein Test-Seam ist. */
170
- readonly _buildBundle?: (sourceFile: string) => Promise<{
171
- readonly js: string;
172
- readonly map: string;
173
- }>;
174
- /** Absolute path to the CSS entry (typically styles.css with
175
- * @import "tailwindcss"). The dev-server builds it with the Tailwind
176
- * CLI and serves the result at /styles.css; `stylesheetWatch` decides
177
- * whether the CLI keeps running with `--watch` afterwards.
178
- *
179
- * When `undefined` and a client entry is set, the app's own
180
- * src/styles.css wins, otherwise `@cosmicdrift/kumiko-renderer-web/styles.css`
181
- * resolves via package exports, so no sample hardcodes a monorepo-relative path.
182
- *
183
- * `stylesheet: false` disables the CSS pipeline. */
184
- readonly stylesheet?: string | false;
185
- /** `true`: the Tailwind CLI keeps running with `--watch` after the
186
- * initial build. `false`: CSS is built once at boot, no watcher.
187
- * When `undefined`, env `KUMIKO_DEV_STYLESHEET_WATCH=0` means `false`,
188
- * anything else `true`. `defineAppE2eConfig` sets that env: Tailwind v4
189
- * subscribes to the whole app cwd without a gitignore filter, so every
190
- * Playwright artifact write under test-results/ would trigger a rebuild. */
191
- readonly stylesheetWatch?: boolean;
192
- /** Optional HTML template served at `GET /`. The dev-server injects
193
- * a `<script type="module" src="/client.js">` and a reload-listener
194
- * snippet into `</body>` if those aren't already there. Defaults to a
195
- * minimal empty-body document — enough to boot the client. */
196
- readonly htmlPath?: string;
197
- /** Port to listen on. Default 4173. Overridable via `PORT` env. */
198
- readonly port?: number;
199
- /** Extra directories to watch for reload triggers. The entry's
200
- * directory is watched automatically. */
201
- readonly watchDirs?: readonly string[];
202
- /** When false, no SIGINT/SIGTERM handlers are installed. Tests set
203
- * this so repeated `createKumikoServer` calls don't accumulate
204
- * listeners on the process. Default true (dev-server behaviour). */
205
- readonly installSignalHandlers?: boolean;
206
- /** Auth-Route-Config (login, tenants, switch-tenant, logout). Wenn
207
- * gesetzt wird die Auto-JWT-Mint auf GET / abgeschaltet — der
208
- * Client ist dann selbst fürs Login zuständig. Zur echten Wirkung
209
- * brauchen die dazugehörigen Features (user/tenant/auth-email-
210
- * password) via `features` drin sein. */
211
- readonly auth?: AuthRoutesConfig;
212
- /** Extra-AppContext-Keys (z.B. configResolver für config-feature).
213
- * Wird an setupTestStack weitergereicht. Siehe TestStackOptions
214
- * für die erlaubten Shapes (object oder factory-function). */
215
- readonly extraContext?: TestStackOptions["extraContext"];
216
- /** Anonymous-Access aktivieren — Requests ohne JWT werden als
217
- * Pseudo-User mit Rolle `anonymous` durchgelassen, sofern der
218
- * Handler `roles: ["anonymous"]` deklariert. Tenant-Resolution per
219
- * Header/Cookie/Default; siehe AnonymousAccessConfig. */
220
- readonly anonymousAccess?: TestStackOptions["anonymousAccess"];
221
- /** File-Storage-Provider — durchgereicht an setupTestStack. Wenn gesetzt,
222
- * mountet der Stack die Upload-Routes (POST/GET/DELETE /api/files) +
223
- * `ctx.files` und legt die file_refs-Tabelle an. Für Demos:
224
- * `{ storageProvider: createInMemoryFileProvider() }`. */
225
- readonly files?: TestStackOptions["files"];
226
- /** Feature-toggle resolver — durchgereicht an setupTestStack. Wenn
227
- * gesetzt, konsultiert der dispatcher-feature-gate, hook-filter, MSP-
228
- * filter den callback; absent = alle features always-on. Erforderlich
229
- * für Tier-Composition (Sprint 8) wo per-Tenant unterschiedliche
230
- * features aktiv sein sollen. Die typische produktive Implementierung
231
- * ist `() => globalFeatureToggleRuntime.effectiveFeatures` post-boot
232
- * (createLateBoundHolder-pattern, weil runtime stack.db braucht). */
233
- readonly effectiveFeatures?: TestStackOptions["effectiveFeatures"];
234
- /** Wird nach dem Aufsetzen der Entity-Tabellen aufgerufen. Hook für
235
- * non-entity-tables (unsafePushTables) und Seeding (admin user, initial
236
- * tenant, …). Muss idempotent sein — im persistent-DB-Modus läuft
237
- * es bei jedem Boot. */
238
- readonly onAfterSetup?: (stack: TestStack) => Promise<void>;
239
- /** Declarative HTTP-routes outside the /api/write|query|batch pipeline —
240
- * symmetric to runProdApp.extraRoutes (kumiko-framework#3050). Mounted
241
- * inside buildServer (setupTestStack), before the Static/HTML-fallback,
242
- * so an own GET (/feed.xml, /og-image, …) wins over the dev-asset path. */
243
- readonly extraRoutes?: readonly ExtraRouteDefinition[];
244
- /** Forwarded to setupTestStack → buildServer's top-level
245
- * `ServerOptions.trustedProxyHops`. Dev usually runs unproxied, so this
246
- * is normally left unset (default 0). */
247
- readonly trustedProxyHops?: number;
248
- /** Hook for app-wired co-running components that need the system-write
249
- * dispatcher — runs after buildServer, before onAfterSetup (seeds), with
250
- * NO `app` (routes are declared via `extraRoutes`, not wired here). */
251
- readonly wire?: (deps: SystemWireDeps) => void | Promise<void>;
252
- /** Per-request head-metadata resolver (Open-Graph/title/description) —
253
- * same option, same `PageHeadResolver` signature, and same shared
254
- * resolve+inject call (`resolveAndInjectPageHead`) as `runProdApp`'s
255
- * `resolvePageHead`, so dev and e2e exercise the exact code prod does
256
- * (kumiko-framework#3026). Consulted for every templated HTML response
257
- * (the default single-entry shell and a host-dispatched `"html"` entry),
258
- * right before it's served. On error, `null`, or a timeout, the shell
259
- * ships unchanged with status 200 — never a 500. `systemQuery` inside
260
- * the resolver runs as the anonymous role, same as prod. */
261
- readonly resolvePageHead?: PageHeadResolver;
262
- };
263
-
264
- export type KumikoServerHandle = {
265
- /** The fetch handler that routes a Request through the dev-server
266
- * layer (HTML, /client.js, /_reload, SSE) and falls back to the
267
- * underlying Kumiko stack. Tests call this directly to exercise
268
- * the routing without going through real sockets. */
269
- readonly fetch: (req: Request) => Promise<Response>;
270
- /** Bun.serve instance. `undefined` when running outside Bun (e.g.
271
- * in vitest under Node) — the handle still works via `.fetch`. */
272
- readonly server: BunServer | undefined;
273
- readonly stack: TestStack;
274
- /** Stops the server and tears down the stack (DB + redis). */
275
- readonly stop: () => Promise<void>;
276
- };
277
-
278
- const CSRF_COOKIE = "kumiko_csrf";
279
- const AUTH_COOKIE = "kumiko_auth";
280
-
281
- // Duplicated in packages/testing/src/e2e/constants.ts: the Playwright config
282
- // runs under Node and cannot import this module.
283
- export const STYLESHEET_WATCH_ENV = "KUMIKO_DEV_STYLESHEET_WATCH";
284
-
285
- // Duplicated in packages/testing/src/e2e/constants.ts: the Playwright config
286
- // runs under Node and cannot import this module.
287
- export const PROD_BUNDLES_ENV = "KUMIKO_DEV_PROD_BUNDLES";
288
-
289
- // Reload snippet injected into every page-load so the browser
290
- // subscribes to /_reload without the HTML needing to hard-code it.
291
- //
292
- // Zwei reload-Trigger:
293
- // - explizites `reload`-Event vom Server beim hot-reload (rebuild + send)
294
- // - implizites: jede SSE-Connection bekommt beim Connect ein `boot`-Event
295
- // mit der bootId des aktuellen Server-Process. Das Snippet merkt sich
296
- // die erste bootId; wenn nach einem Reconnect (Server-Restart!) eine
297
- // ANDERE bootId kommt, refresh — sonst bleibt der Browser ewig auf
298
- // dem alten Bundle hängen wenn der Watcher classifyChange="restart"
299
- // gewählt hat oder der User Ctrl-C/bun dev gemacht hat.
300
- const RELOAD_SNIPPET = `
301
- <script>
302
- (() => {
303
- const es = new EventSource("/_reload");
304
- let firstBootId = null;
305
- es.addEventListener("boot", (e) => {
306
- if (firstBootId === null) {
307
- firstBootId = e.data;
308
- } else if (firstBootId !== e.data) {
309
- location.reload();
310
- }
311
- });
312
- es.addEventListener("reload", () => location.reload());
313
- })();
314
- </script>
315
- `;
316
-
317
- // Minimal HTML when the caller didn't hand one in. `#root` is the
318
- // default mount target for `createKumikoApp`, so the one-line client
319
- // can attach without the sample having to ship its own template.
320
- //
321
- // `type="module"` is load-bearing: a classic script turns the bundle's
322
- // top-level declarations into window properties, so a dependency exporting
323
- // `function history()` (prosemirror-history, pulled in by tiptap) silently
324
- // replaces `window.history` and every pushState afterwards throws. Prod
325
- // (build-prod-bundle) has always emitted the module form.
326
- //
327
- // `interactive-widget=resizes-content` (fw#1918): without it, a mobile
328
- // keyboard shrinks only the visual viewport, not the layout viewport that
329
- // `position: fixed` anchors to — a fixed bottom action bar would stay
330
- // pinned behind the keyboard instead of above it.
331
- const DEFAULT_HTML = `<!doctype html>
332
- <html lang="en">
333
- <head>
334
- <meta charset="utf-8" />
335
- <meta name="viewport" content="width=device-width,initial-scale=1,interactive-widget=resizes-content" />
336
- <title>Kumiko</title>
337
- </head>
338
- <body>
339
- <div id="root"></div>
340
- <script type="module" src="/client.js"></script>
341
- </body>
342
- </html>
343
- `;
344
-
345
- type ClientBundle = {
346
- readonly js: string;
347
- readonly map: string;
348
- // Only set when PROD_BUNDLES_ENV builds with splitting. Path -> content,
349
- // e.g. "/chunk-abc123.js" -> "...".
350
- readonly chunks?: ReadonlyMap<string, string>;
351
- };
352
-
353
- async function buildClient(entry: string): Promise<ClientBundle> {
354
- if (!hasBun) {
355
- throw new Error(
356
- "[kumiko-server] clientEntry is only supported under Bun — Bun.build is unavailable in this runtime.",
357
- );
358
- }
359
- const unminified = process.env["KUMIKO_DEV_UNMINIFIED"] === "1";
360
- // Same splitting/sourcemap/NODE_ENV as build-prod-bundle.ts, opt-in via
361
- // env so `bun dev` stays unchanged.
362
- const prodShaped = process.env[PROD_BUNDLES_ENV] === "1";
363
- const built = await Bun.build({
364
- entrypoints: [entry],
365
- target: "browser",
366
- minify: !unminified,
367
- sourcemap: prodShaped ? "none" : "linked",
368
- splitting: prodShaped,
369
- ...(prodShaped ? { define: { "process.env.NODE_ENV": JSON.stringify("production") } } : {}),
370
- });
371
- if (!built.success) {
372
- logError("[kumiko-server] client bundle failed:");
373
- for (const log of built.logs) logError(log);
374
- throw new Error("client bundle failed");
375
- }
376
- const jsOutput = built.outputs.find((o) => o.kind === "entry-point");
377
- const mapOutput = built.outputs.find((o) => o.path.endsWith(".js.map"));
378
- if (!jsOutput) throw new Error("[kumiko-server] client bundle produced no .js output");
379
- const chunks = new Map<string, string>();
380
- for (const output of built.outputs) {
381
- if (output.kind !== "chunk") continue;
382
- const chunkPath = `/${output.path.replace(/^\.\//, "")}`;
383
- chunks.set(chunkPath, await output.text());
384
- }
385
- return {
386
- js: await jsOutput.text(),
387
- map: mapOutput ? await mapOutput.text() : "",
388
- chunks,
389
- };
390
- }
391
-
392
- type ReloadClient = {
393
- readonly controller: ReadableStreamDefaultController<Uint8Array>;
394
- readonly encoder: TextEncoder;
395
- closed: boolean;
396
- };
397
-
398
- function injectReload(html: string): string {
399
- if (html.includes("/_reload")) return html;
400
- return html.includes("</body>")
401
- ? html.replace("</body>", `${RELOAD_SNIPPET}</body>`)
402
- : html + RELOAD_SNIPPET;
403
- }
404
-
405
- // Injiziert <link rel="stylesheet" href="/styles.css"> in den <head>,
406
- // wenn es noch nicht da ist. Wird nur aufgerufen wenn die App das
407
- // stylesheet-Option genutzt hat — andernfalls kommt keine CSS-Route.
408
- function injectStylesheet(html: string): string {
409
- if (html.includes('href="/styles.css"')) return html;
410
- const link = '<link rel="stylesheet" href="/styles.css" />';
411
- return html.includes("</head>")
412
- ? html.replace("</head>", ` ${link}\n</head>`)
413
- : `${link}${html}`;
414
- }
415
-
416
- // GET/HEAD to a non-API, non-SSE path — the dispatch-worthy set shared by
417
- // the SPA catch-all and the public/-file lookup below (they split on
418
- // whether the path has a dot).
419
- function isRoutableGetOrHead(req: Request, pathname: string): boolean {
420
- return (
421
- (req.method === "GET" || req.method === "HEAD") &&
422
- !pathname.startsWith("/api/") &&
423
- !pathname.startsWith("/sse")
424
- );
425
- }
426
-
427
- // Resolves a request pathname to a file inside publicDir, or undefined if
428
- // it isn't one. Two traversal vectors, both must be blocked:
429
- // - literal ".." segments (new URL() already collapses these in
430
- // `pathname`, but resolve()+containment is checked regardless — no
431
- // path-safety may depend on caller behavior upstream).
432
- // - percent-encoded slashes (e.g. "%2e%2e%2f"): WHATWG URL parsing only
433
- // normalizes dot-segments that are delimited by a literal "/", so an
434
- // encoded slash survives into `pathname` untouched. decodeURIComponent
435
- // resolves it to "../" before the containment check runs.
436
- function resolvePublicFilePath(pathname: string, publicDir: string): string | undefined {
437
- let decoded: string;
438
- try {
439
- decoded = decodeURIComponent(pathname);
440
- } catch {
441
- return undefined;
442
- }
443
- const resolved = resolve(publicDir, `.${decoded}`);
444
- return resolved === publicDir || resolved.startsWith(publicDir + sep) ? resolved : undefined;
445
- }
446
-
447
- // Same extension → content-type mapping as prod's
448
- // run-prod-app-static-files.ts#mimeTypeFor — that function isn't part of
449
- // server-runtime's public exports (its own package.json "exports" map),
450
- // so this is a small self-contained copy rather than a new cross-package
451
- // export for one call site.
452
- const PUBLIC_FILE_MIME_TYPES = new Map<string, string>([
453
- ["html", "text/html; charset=utf-8"],
454
- ["js", "text/javascript; charset=utf-8"],
455
- ["mjs", "text/javascript; charset=utf-8"],
456
- ["css", "text/css; charset=utf-8"],
457
- ["json", "application/json; charset=utf-8"],
458
- ["svg", "image/svg+xml"],
459
- ["png", "image/png"],
460
- ["jpg", "image/jpeg"],
461
- ["jpeg", "image/jpeg"],
462
- ["ico", "image/x-icon"],
463
- ["txt", "text/plain; charset=utf-8"],
464
- ["xml", "application/xml; charset=utf-8"],
465
- ["webmanifest", "application/manifest+json"],
466
- ["woff2", "font/woff2"],
467
- ]);
468
-
469
- function publicFileMimeType(filePath: string): string {
470
- const ext = filePath.toLowerCase().split(".").pop() ?? "";
471
- return PUBLIC_FILE_MIME_TYPES.get(ext) ?? "application/octet-stream";
472
- }
473
-
474
- // Reads a file under publicDir for the dev-server's static-asset fallback
475
- // (dev-parity with prod's disk lookup in buildStaticFallback). undefined
476
- // means "not a servable file here" — caller treats that as a router miss,
477
- // same as ENOENT/EISDIR/ENOTDIR further down.
478
- async function servePublicFile(
479
- pathname: string,
480
- publicDir: string,
481
- ): Promise<{ readonly bytes: Uint8Array; readonly mime: string } | undefined> {
482
- const filePath = resolvePublicFilePath(pathname, publicDir);
483
- if (filePath === undefined) return undefined;
484
- try {
485
- const bytes = await readFile(filePath);
486
- // Lexical containment (above) only catches "..", not a symlink inside
487
- // publicDir that points outside it on disk — realpath resolves the
488
- // actual target and the same containment check runs against it.
489
- const real = await realpath(filePath);
490
- if (real !== publicDir && !real.startsWith(publicDir + sep)) return undefined;
491
- return { bytes, mime: publicFileMimeType(filePath) };
492
- } catch (err) {
493
- const code = (err as { code?: string }).code;
494
- if (code === "ENOENT" || code === "EISDIR" || code === "ENOTDIR") return undefined;
495
- throw err;
496
- }
497
- }
498
-
499
- // Static assets under public/ — prod serves these via buildStaticFallback's
500
- // disk lookup (run-prod-app-static-files.ts), dev previously had no
501
- // equivalent: a dotted path (e.g. /marketing/hero.png) fell straight
502
- // through to the API stack and 404ed. Hono still goes first (an
503
- // r.httpRoute could itself own a dotted path), then the file on disk,
504
- // then the router-miss 404 — mirrors handleFetch's SPA branch. undefined
505
- // means "not a static-asset request", caller falls through to the next
506
- // route.
507
- async function tryServePublicAsset(
508
- req: Request,
509
- pathname: string,
510
- app: HonoLikeApp,
511
- publicDir: string,
512
- socketAddress?: string,
513
- ): Promise<Response | undefined> {
514
- if (!isRoutableGetOrHead(req, pathname) || !pathname.includes(".")) return undefined;
515
- const honoTry = await tryHonoFirst(app, req, socketAddress);
516
- if (honoTry.matched) {
517
- return honoTry.response;
518
- }
519
- const file = await servePublicFile(pathname, publicDir);
520
- if (file !== undefined) {
521
- // @cast-boundary Buffer satisfies BodyInit at runtime, bun-types
522
- // just doesn't say so — same cast run-prod-app-static-files.ts uses.
523
- return new Response(file.bytes as unknown as BodyInit, {
524
- headers: { "Content-Type": file.mime },
525
- });
526
- }
527
- return honoTry.response;
528
- }
529
-
530
- async function watchDir(
531
- dir: string,
532
- onChange: (filename: string) => void,
533
- signal: AbortSignal,
534
- ): Promise<void> {
535
- // AbortSignal wird vom Server-stop() ausgelöst: ohne den Abort liefe
536
- // die for-await-Schleife bis zum Process-Exit weiter. Im Test-Setup
537
- // (afterEach räumt tmpdir mit rmSync auf) sähe der Watcher dann das
538
- // rmSync, klassifizierte's als "restart" und riefe process.exit(75) —
539
- // bubbles als unhandled error in vitest hoch.
540
- const watcher = watch(dir, { recursive: true, signal });
541
- try {
542
- for await (const ev of watcher) {
543
- if (ev.filename) onChange(ev.filename);
544
- }
545
- } catch (err) {
546
- // signal.abort() wirft AbortError aus dem async-iterator; das ist
547
- // gewollt und kein Fehler. Andere Errors weiterreichen.
548
- // skip: AbortSignal fired the abort, this is expected teardown not a real error
549
- if ((err as { name?: string }).name === "AbortError") return;
550
- throw err;
551
- }
552
- }
553
-
554
- // Klassifiziert eine geänderte Datei: `hot-reload` für Client-Side
555
- // (Browser-Bundle rebuild + reload), `restart` für Server-Side (Bun-
556
- // Module-Cache zwingt einen Process-Restart durch), `ignore` für
557
- // alles was den Server nicht beeinflusst (Tests, .css, .json…).
558
- //
559
- // Heuristik:
560
- // - Tests (`__tests__/` oder `*.test.ts(x)`) → ignore
561
- // - `.ts` / `.tsx` außer Tests:
562
- // - Client-side-Dirs (`/web/`, `/admin/`, `/public/`, `/client/`)
563
- // oder die client-entry-Datei selbst → hot-reload
564
- // - sonst → restart (könnte Schema/Feature-Definition sein)
565
- // - andere Dateitypen → ignore (kein TS rebuild nötig)
566
- //
567
- // Warum mehrere Dirs für client-side: in Kumiko-Samples gibt's keine
568
- // Convention. publicstatus splittet `/admin/` (Admin-Bundle) und
569
- // `/public/` (Anonymous-Bundle); beammycar nutzt `/web/` für seine
570
- // Feature-Web-Code; ältere Samples haben einfach `/client.tsx` neben
571
- // dem Server. Der Watcher muss alle drei verstehen, sonst löst ein
572
- // Edit der Bridge-Component einen kompletten Server-Restart aus —
573
- // kostet 2-3s, droppt die Test-DB im ephemeral-Modus, reseed läuft
574
- // erneut. Ineffektiv und für der User verwirrend.
575
- //
576
- // Exportiert für Tests; intern wird's von der Watcher-Loop gerufen.
577
- export function classifyChange(filename: string): "restart" | "hot-reload" | "ignore" {
578
- if (!filename.endsWith(".ts") && !filename.endsWith(".tsx")) return "ignore";
579
- if (filename.includes("__tests__")) return "ignore";
580
- if (filename.endsWith(".test.ts") || filename.endsWith(".test.tsx")) return "ignore";
581
- if (
582
- filename.endsWith(".integration.ts") ||
583
- filename.endsWith(".integration.test.ts") ||
584
- filename.endsWith(".e2e.ts")
585
- ) {
586
- return "ignore";
587
- }
588
- // Plattformpfad-agnostisch: prüfen auf POSIX und Windows-Trenner.
589
- // Wir matchen sowohl `<sep><dir><sep>` als auch trailing-`<sep><dir>`
590
- // (für Watcher-Filenames die als relativer Pfad ankommen).
591
- const clientSubdirs = ["web", "admin", "public", "client"];
592
- for (const dir of clientSubdirs) {
593
- if (
594
- filename.includes(`/${dir}/`) ||
595
- filename.includes(`\\${dir}\\`) ||
596
- filename.startsWith(`${dir}/`) ||
597
- filename.startsWith(`${dir}\\`)
598
- ) {
599
- return "hot-reload";
600
- }
601
- }
602
- if (filename.endsWith("/client.tsx") || filename.endsWith("/client.ts")) {
603
- return "hot-reload";
604
- }
605
- return "restart";
606
- }
607
-
608
- // Expandiert watchDirs-Patterns auf konkrete Verzeichnisse. Ein Eintrag
609
- // ohne `*` wird als gewöhnlicher Pfad resolved; mit `*` wird er per
610
- // glob expanded und alle Treffer die Verzeichnisse sind übernommen.
611
- // Erlaubt z.B. `"../../../packages/*/src"` statt vier hart-kodierte
612
- // Pfade. Glob ist sync — wird einmal beim Boot ausgewertet, nicht
613
- // während der Watcher läuft.
614
- function expandWatchPatterns(patterns: readonly string[]): string[] {
615
- const out: string[] = [];
616
- for (const p of patterns) {
617
- if (!p.includes("*")) {
618
- out.push(resolve(p));
619
- continue;
620
- }
621
- // expandWatchPatterns wird nur unter Bun aufgerufen (createKumikoServer
622
- // ist Bun-only); der ?.! -dance ist nötig weil TS Bun nicht im
623
- // globalThis-default sieht. Wenn Bun fehlt, ist der Aufrufstapel eh
624
- // schon fail-fast unten in Bun.serve.
625
- const BunRef = (
626
- globalThis as {
627
- Bun?: {
628
- Glob: new (
629
- p: string,
630
- ) => { scanSync: (opts: { onlyFiles: false; cwd: string }) => Iterable<string> };
631
- };
632
- }
633
- ).Bun;
634
- if (!BunRef) throw new Error("expandWatchPatterns requires Bun.Glob");
635
- const glob = new BunRef.Glob(p);
636
- const matches = Array.from(glob.scanSync({ onlyFiles: false, cwd: process.cwd() }));
637
- for (const m of matches) {
638
- const abs = resolve(m);
639
- try {
640
- if (statSync(abs).isDirectory()) out.push(abs);
641
- } catch {
642
- // ignore unreadable matches — typisch defekte Symlinks
643
- }
644
- }
645
- }
646
- return out;
647
- }
648
-
649
- // Resolve den Pfad zur Tailwind-Entry-CSS. Mehrere Fälle:
650
- // - Explicit string → den resolved'en absoluten Pfad verwenden
651
- // - false → CSS-Pipeline aus (undefined zurück)
652
- // - undefined + client(s):
653
- // 1. App-eigenes src/styles.css (App-Theme-Override) wenn vorhanden
654
- // 2. Sonst Default `@cosmicdrift/kumiko-renderer-web/styles.css` über Package-Exports
655
- // - undefined + kein clientEntry/clientEntries: undefined (keine CSS nötig)
656
- //
657
- // Auto-Detection von src/styles.css spiegelt die Logik aus
658
- // build-prod-bundle:resolveStylesheetEntry — damit dev und prod identisch
659
- // resolven. Ohne diesen Check müsste jede App `stylesheet: "./src/styles.css"`
660
- // setzen, sonst greift in dev der renderer-web-Default und Brand-Tokens
661
- // werden ignoriert (DX-Falle).
662
- //
663
- // @internal — exportiert nur für Unit-Tests, nicht aus dem Package-Index
664
- // re-exportiert. Konsumenten gehen ausschließlich über die `stylesheet`-
665
- // Option der createKumikoServer-Aufrufstelle.
666
- export function resolveStylesheet(options: CreateKumikoServerOptions): string | undefined {
667
- if (options.stylesheet === false) return undefined;
668
- if (typeof options.stylesheet === "string") return resolve(options.stylesheet);
669
- const hasAnyEntry =
670
- options.clientEntry !== undefined ||
671
- (options.clientEntries !== undefined && options.clientEntries.length > 0);
672
- if (!hasAnyEntry) return undefined;
673
-
674
- // App-eigenes src/styles.css schlägt den renderer-web-Default — gleiche
675
- // Logik wie kumiko-build, damit lokal/prod identisch bauen.
676
- const local = resolve(process.cwd(), "src/styles.css");
677
- if (existsSync(local)) return local;
678
-
679
- // Bun.resolveSync folgt Package-Exports — "./styles.css" in renderer-web's
680
- // package.json. Das Monorepo auflöst direkt auf den Workspace-File, eine
681
- // installierte Fremd-App auf den node_modules-File. Kein `fileURLToPath`
682
- // nötig, Bun gibt schon einen absoluten FS-Pfad zurück.
683
- if (!hasBun) {
684
- // Unit-Tests unter vitest/Node landen hier. Ohne Bun können wir die
685
- // Package-Export-Resolution nicht machen — und im Test-Kontext gibt's
686
- // keine echte Tailwind-Pipeline. Skip still, keine Fehlermeldung nötig.
687
- return undefined;
688
- }
689
- try {
690
- const resolved = (
691
- globalThis as { Bun: { resolveSync: (id: string, from: string) => string } }
692
- ).Bun.resolveSync("@cosmicdrift/kumiko-renderer-web/styles.css", process.cwd());
693
- const bun = (globalThis as { Bun: { resolveSync: (id: string, from: string) => string } }).Bun;
694
- if (!canResolveTailwindStylesheet(resolved, { bun, cwd: process.cwd() })) {
695
- return undefined;
696
- }
697
- return resolved;
698
- } catch (err) {
699
- logError(
700
- "[kumiko-server] couldn't auto-resolve @cosmicdrift/kumiko-renderer-web/styles.css — " +
701
- "pass `stylesheet: <path>` or `stylesheet: false` explicitly.",
702
- err,
703
- );
704
- return undefined;
705
- }
706
- }
707
-
708
- // The E2E template can only reach an app's own server entry through its env,
709
- // so the env has to count whenever the option is left undefined.
710
- //
711
- // @internal — exported for unit tests only, not re-exported from the index.
712
- export function resolveStylesheetWatch(
713
- options: Pick<CreateKumikoServerOptions, "stylesheetWatch">,
714
- env: Readonly<Record<string, string | undefined>> = process.env,
715
- ): boolean {
716
- if (options.stylesheetWatch !== undefined) return options.stylesheetWatch;
717
- return env[STYLESHEET_WATCH_ENV] !== "0";
718
- }
719
-
720
- // Non-fatal by design (returns undefined): if the CLI can't be resolved or
721
- // the initial build fails (flaky network, missing dependency), the server
722
- // boots without CSS instead of dying.
723
- async function startTailwindPipeline(
724
- entryCss: string,
725
- { watch }: { readonly watch: boolean },
726
- ): Promise<{ outPath: string; kill: () => void } | undefined> {
727
- const bunResolver = hasBun
728
- ? (globalThis as { Bun: { resolveSync: (id: string, from: string) => string } }).Bun
729
- : undefined;
730
- const cliPath = resolveTailwindCli({ bun: bunResolver, cwd: process.cwd() });
731
- if (cliPath === undefined) {
732
- logError(
733
- "[kumiko-server] @tailwindcss/cli nicht auflösbar — booting ohne CSS-Pipeline. " +
734
- "`bun install` und Restart, um Styles zu aktivieren.",
735
- );
736
- return undefined;
737
- }
738
- const outDir = mkdtempSync(join(tmpdir(), "kumiko-tw-"));
739
- const outPath = join(outDir, "styles.css");
740
- logInfo(
741
- watch
742
- ? `[kumiko-server] tailwind watcher → ${outPath}`
743
- : `[kumiko-server] tailwind one-shot build → ${outPath}`,
744
- );
745
- const bunPath = process.argv[0] ?? "bun";
746
- // Initial-Build blockend, damit der erste /styles.css-Request kein
747
- // 404 bekommt. Dann den watcher im Hintergrund mit unref() — sonst
748
- // hing er beim Parent-Crash als orphan-process.
749
- try {
750
- await new Promise<void>((resolvePromise, rejectPromise) => {
751
- const child = spawn(bunPath, ["run", cliPath, "-i", entryCss, "-o", outPath], {
752
- stdio: "inherit",
753
- });
754
- child.on("exit", (code) => {
755
- if (code === 0) resolvePromise();
756
- else rejectPromise(new Error(`tailwind one-shot-build exit ${code}`));
757
- });
758
- child.on("error", rejectPromise);
759
- });
760
- } catch (err) {
761
- logError("[kumiko-server] tailwind one-shot-build fehlgeschlagen — booting ohne CSS:", err);
762
- return undefined;
763
- }
764
- if (!watch) {
765
- return { outPath, kill: () => {} };
766
- }
767
- const watcher = spawn(bunPath, ["run", cliPath, "-i", entryCss, "-o", outPath, "--watch"], {
768
- stdio: "inherit",
769
- });
770
- watcher.unref();
771
- return {
772
- outPath,
773
- kill: () => {
774
- try {
775
- watcher.kill("SIGTERM");
776
- } catch {
777
- // schon tot oder nie gestartet — nicht weiter relevant
778
- }
779
- },
780
- };
781
- }
782
-
783
- /** @internal — normalisierte Client-Entry-Form, einheitlich über
784
- * Single-Mode (`clientEntry`) und Multi-Mode (`clientEntries`). */
785
- type NormalizedEntry = {
786
- readonly name: string;
787
- readonly sourceFile: string;
788
- readonly htmlPath: string | undefined;
789
- };
790
-
791
- /** URL-Pfad unter dem ein Entry ausgeliefert wird. "client" → /client.js
792
- * (Single-Mode-Default), sonst "/client-<name>.js". Single-Source-of-Truth
793
- * damit Routing + Logging dieselbe Konvention nutzen. */
794
- function assetPathFor(entryName: string): string {
795
- return entryName === "client" ? "/client.js" : `/client-${entryName}.js`;
796
- }
797
-
798
- function normalizeEntries(options: CreateKumikoServerOptions): readonly NormalizedEntry[] {
799
- if (options.clientEntries !== undefined && options.clientEntry !== undefined) {
800
- throw new Error(
801
- "[kumiko-server] clientEntry und clientEntries sind mutually exclusive — wähle eins",
802
- );
803
- }
804
- if (options.clientEntries !== undefined && options.clientEntries.length > 0) {
805
- if (options.hostDispatch === undefined) {
806
- throw new Error(
807
- "[kumiko-server] clientEntries braucht hostDispatch — sonst weiß der Server nicht welches Template er liefern soll",
808
- );
809
- }
810
- return options.clientEntries.map((e) => ({
811
- name: e.name,
812
- sourceFile: resolve(e.sourceFile),
813
- htmlPath: e.htmlPath,
814
- }));
815
- }
816
- if (options.clientEntry !== undefined) {
817
- return [{ name: "client", sourceFile: resolve(options.clientEntry), htmlPath: undefined }];
818
- }
819
- return [];
820
- }
821
-
822
- export async function createKumikoServer(
823
- options: CreateKumikoServerOptions,
824
- ): Promise<KumikoServerHandle> {
825
- const port = options.port ?? Number(process.env["PORT"] ?? 4173);
826
-
827
- // --- client bundles (single-entry oder multi-entry über dieselbe Map) ---
828
- const entries = normalizeEntries(options);
829
- const buildBundle = options._buildBundle ?? buildClient;
830
- const clientBundles = new Map<string, ClientBundle>();
831
- for (const e of entries) {
832
- const bundle = await buildBundle(e.sourceFile);
833
- clientBundles.set(e.name, bundle);
834
- logInfo(
835
- `[kumiko-server] client bundle ${e.name}: ${bundle.js.length.toLocaleString()} bytes` +
836
- (bundle.map ? ` (+ ${bundle.map.length.toLocaleString()} bytes sourcemap)` : ""),
837
- );
838
- }
839
-
840
- // --- Tailwind stylesheet (optional) ---
841
- // The Tailwind CLI writes to a temp file that every /styles.css request
842
- // reads fresh: no in-memory signalling needed, and the browser only
843
- // reloads after a bundle rebuild anyway.
844
- //
845
- // Without an explicit `stylesheet`, the default resolves through the
846
- // package exports of `@cosmicdrift/kumiko-renderer-web`, which works both
847
- // for a workspace link and for an installed app (node_modules).
848
- let stylesheetPath: string | undefined;
849
- let killTailwind: (() => void) | undefined;
850
- const resolvedStylesheet = resolveStylesheet(options);
851
- if (resolvedStylesheet !== undefined) {
852
- const handle = await startTailwindPipeline(resolvedStylesheet, {
853
- watch: resolveStylesheetWatch(options),
854
- });
855
- if (handle !== undefined) {
856
- stylesheetPath = handle.outPath;
857
- killTailwind = handle.kill;
858
- }
859
- }
860
-
861
- // --- HTML templates ---
862
- // Single-Entry: ein Template (htmlPath oder DEFAULT_HTML) für alles.
863
- // Multi-Entry: pro Entry ein Template (entry.htmlPath ?? options.htmlPath
864
- // ?? DEFAULT_HTML). Der hostDispatch wählt zur Request-Zeit.
865
- const defaultTemplate =
866
- options.htmlPath !== undefined
867
- ? await readFile(resolve(options.htmlPath), "utf-8")
868
- : DEFAULT_HTML;
869
- const htmlTemplates = new Map<string, string>();
870
- for (const e of entries) {
871
- htmlTemplates.set(
872
- e.name,
873
- e.htmlPath !== undefined ? await readFile(resolve(e.htmlPath), "utf-8") : defaultTemplate,
874
- );
875
- }
876
-
877
- // --- Kumiko stack ---
878
- // KUMIKO_DEV_DB_NAME switches the underlying testDb from ephemeral
879
- // (fresh kumiko_test_<random>, dropped on cleanup) to persistent
880
- // (reuses the named DB across restarts). The var is framework-scoped
881
- // on purpose — every dev-server pattern benefits from the same
882
- // toggle, not just one sample.
883
- const devDbName = process.env["KUMIKO_DEV_DB_NAME"];
884
- const persistentDb = devDbName !== undefined && devDbName !== "";
885
-
886
- // Redis is shared across boots AND across apps (unlike the per-boot test
887
- // DB, which is ephemeral or persistent per KUMIKO_DEV_DB_NAME), so every
888
- // boot needs its own queue-name prefix — otherwise two dev/e2e servers
889
- // running in parallel drain each other's queues ("Unknown job: ..."). A
890
- // persistent DB implies a stable prefix so jobs survive a restart; an
891
- // ephemeral DB gets a fresh prefix per boot, matching the fresh DB.
892
- const jobQueueNamePrefix = persistentDb
893
- ? `kumiko-dev-${devDbName}`
894
- : `kumiko-dev-${crypto.randomUUID()}`;
895
-
896
- logInfo(
897
- `[kumiko-server] booting Kumiko stack${
898
- persistentDb ? ` — persistent DB "${devDbName}"` : " — ephemeral test DB"
899
- }…`,
900
- );
901
- const stack = await setupTestStack({
902
- features: options.features,
903
- ...(persistentDb && { dbName: devDbName, persistentDb: true }),
904
- ...(options.auth !== undefined && { authConfig: options.auth }),
905
- ...(options.extraContext !== undefined && { extraContext: options.extraContext }),
906
- ...(options.anonymousAccess !== undefined && { anonymousAccess: options.anonymousAccess }),
907
- enrichAnonymousAccess: (base, deps) => resolveAnonymousAccessFromRegistry(base, deps),
908
- ...(options.files !== undefined && { files: options.files }),
909
- ...(options.effectiveFeatures !== undefined && {
910
- effectiveFeatures: options.effectiveFeatures,
911
- }),
912
- ...(options.extraRoutes !== undefined && { extraRoutes: options.extraRoutes }),
913
- ...(options.trustedProxyHops !== undefined && { trustedProxyHops: options.trustedProxyHops }),
914
- // jobs.consumerLane unset = enqueuer-only; startDevJobRunners below is
915
- // the sole consumer/cron-scheduler per lane, so runOnBoot/cron jobs
916
- // don't double-fire. queueNamePrefix keeps this boot's queues isolated
917
- // from every other dev/e2e server sharing the same Redis.
918
- jobs: { queueNamePrefix: jobQueueNamePrefix },
919
- });
920
- await createEventsTable(stack.db);
921
- await pushEntityProjectionTables(stack, stack.registry);
922
-
923
- // `extraRoutes` is already mounted — setupTestStack's buildServer() call
924
- // wired it above, before this function ever runs. `wire` is the
925
- // remaining escape-hatch for app-wired co-running components that need
926
- // the system-write dispatcher but don't declare a route.
927
- if (options.wire !== undefined) {
928
- await options.wire({
929
- db: stack.db,
930
- // Der nackte ioredis-Client (nicht der TestRedis-Wrapper) —
931
- // Parität mit runProdApp, App-Code soll in dev+prod dasselbe sehen.
932
- redis: stack.redis.redis,
933
- registry: stack.registry,
934
- dispatchSystemWrite: makeDispatchSystemWrite(stack.dispatcher),
935
- });
936
- }
937
-
938
- // Hook für Caller-spezifische Tables + Seed. Läuft nach den Entity-
939
- // Tabellen damit das Sample auf `stack.db` / `stack.dispatcher`
940
- // zugreifen kann, und VOR dem Server-Start damit der erste HTTP-Request
941
- // bereits gegen einen gefüllten State läuft. Idempotenz ist Caller-
942
- // Verantwortung (persistent-DB-Modus läuft es bei jedem Boot).
943
- if (options.onAfterSetup !== undefined) {
944
- await options.onAfterSetup(stack);
945
- }
946
-
947
- // setupTestStack konfiguriert den eventDispatcher, startet ihn aber
948
- // NICHT — Integration-Tests drain'en deterministisch via runOnce().
949
- // Ein Dev-Server will das laufende Polling, damit SSE-Broadcasts
950
- // (system-hook sse, Priorität 1001) von selbst an connected Clients
951
- // fließen. Ohne start() bleiben alle Events in der events-Tabelle
952
- // liegen und die Tabs sehen nichts.
953
- if (stack.eventDispatcher) {
954
- await stack.eventDispatcher.start();
955
- }
956
-
957
- const redisUrl = `redis://${stack.redis.redis.options.host}:${stack.redis.redis.options.port}/${stack.redis.redis.options.db}`;
958
- const devJobRunners = await startDevJobRunners({
959
- registry: stack.registry,
960
- db: stack.db,
961
- // The stack's own AppContext, not a `{ db, registry }` literal: these
962
- // runners consume the lanes, and a job reaching for ctx.files /
963
- // ctx.notify / ctx.config would otherwise die here while the very same
964
- // handler works on the request path (kumiko-framework#1232).
965
- context: stack.context,
966
- redisUrl,
967
- // Same dispatcher the request path writes through — without this,
968
- // ctx.write/ctx.writeAs/ctx.queryAs inside a dev-run job throw on their
969
- // first call (kumiko-framework#2553).
970
- dispatcher: stack.dispatcher,
971
- // Same isolation reasoning as setupTestStack's jobs.queueNamePrefix
972
- // above — both must use the same prefix so enqueuer and consumer agree
973
- // on queue names.
974
- queueNamePrefix: jobQueueNamePrefix,
975
- });
976
-
977
- // Dev user = TestUsers.admin. Demo features are openToAll but the
978
- // auth-middleware still needs a valid JWT to let the request past.
979
- // Nicht genutzt wenn `options.auth` gesetzt ist — dann macht der Client
980
- // selbst den Login.
981
- const autoMintJwt = options.auth === undefined;
982
- const devUser = TestUsers.admin;
983
-
984
- // Build once at boot purely to surface authoring warnings (settings-hub
985
- // placement etc.) at the same point prod would — clients fetch the actual
986
- // schema themselves from GET /api/schema, so the result itself is
987
- // discarded here.
988
- buildAppSchema(stack.registry, {
989
- authoringWarnings: true,
990
- searchAdapterMissing: !stack.context.searchAdapter,
991
- });
992
-
993
- // --- SSE reload ---
994
- // bootId identifiziert diese spezifische Server-Process-Instanz. Wird
995
- // beim Connect an jeden Browser geschickt; Browser merkt sich den
996
- // ersten Wert und refresht wenn beim Reconnect ein anderer kommt
997
- // (= Server wurde restartet, alter JS-Bundle ist stale). Siehe
998
- // RELOAD_SNIPPET oben.
999
- const bootId = String(Date.now());
1000
- const reloadClients = new Set<ReloadClient>();
1001
- const broadcastReload = (): void => {
1002
- const payload = "event: reload\ndata: now\n\n";
1003
- for (const client of reloadClients) {
1004
- if (client.closed) continue;
1005
- try {
1006
- client.controller.enqueue(client.encoder.encode(payload));
1007
- } catch {
1008
- client.closed = true;
1009
- }
1010
- }
1011
- };
1012
-
1013
- // Build a fresh HTML response. In auto-mint mode (no auth config) we
1014
- // pack a valid JWT + CSRF cookie right in — deep-links work immediately
1015
- // without login (and that auth cookie is what a client-side fetch to
1016
- // GET /api/schema authenticates with). In auth mode we serve the bare
1017
- // HTML; the client then goes through /auth/login and gets its cookies
1018
- // from there.
1019
- //
1020
- // entryName is decided by the caller (handleFetch) after evaluating
1021
- // hostDispatch. Without hostDispatch it's always "client" (single-entry
1022
- // default).
1023
- //
1024
- // Anonymous-role systemQuery, shared by resolvePageHead and hostDispatch —
1025
- // mirrors runProdApp's HostDispatchFn deps so dev/prod stay symmetric.
1026
- // Own resolver mirroring the same effective hops as setupTestStack's
1027
- // internal buildServer — this path runs for
1028
- // page-head/hostDispatch resolution, ahead of requestIdMiddleware's
1029
- // /api/* mount, so it can't reuse that instance. Two independent
1030
- // warn-once flags in dev is an accepted, low-risk gap (dev normally
1031
- // runs unproxied — see CreateKumikoServerOptions.trustedProxyHops).
1032
- const pageHeadClientIpResolver = createClientIpResolver(
1033
- options.trustedProxyHops ?? 0,
1034
- "createKumikoServer(pageHead)",
1035
- );
1036
- const buildDevSystemQuery =
1037
- (req: Request, socketAddress?: string): PageHeadSystemQuery =>
1038
- (type, payload, tenantId) =>
1039
- requestContext.run(
1040
- requestContext.get() ??
1041
- buildRequestContextDataFromRequest(req, {
1042
- resolver: pageHeadClientIpResolver,
1043
- socketAddress,
1044
- }),
1045
- () => stack.dispatcher.query(type, payload, createAnonymousUser(tenantId)),
1046
- );
1047
-
1048
- // resolvePageHead goes through the same headless resolveAndInjectPageHead
1049
- // that runProdApp uses, so dev and e2e exercise the one timeout and
1050
- // fallback path instead of a second copy of it (#3026).
1051
- const htmlResponse = async (
1052
- entryName: string,
1053
- req: Request,
1054
- socketAddress?: string,
1055
- ): Promise<Response> => {
1056
- const template = htmlTemplates.get(entryName) ?? defaultTemplate;
1057
- const headers = new Headers();
1058
- headers.set("Content-Type", "text/html; charset=utf-8");
1059
- if (autoMintJwt) {
1060
- const jwt = await stack.jwt.sign(devUser);
1061
- const csrf = generateToken();
1062
- headers.append("Set-Cookie", `${AUTH_COOKIE}=${jwt}; Path=/; HttpOnly; SameSite=Lax`);
1063
- headers.append("Set-Cookie", `${CSRF_COOKIE}=${csrf}; Path=/; SameSite=Lax`);
1064
- }
1065
- let html = injectReload(template);
1066
- if (stylesheetPath !== undefined) html = injectStylesheet(html);
1067
- if (options.resolvePageHead !== undefined) {
1068
- const url = new URL(req.url);
1069
- const host = req.headers.get("host") ?? url.host;
1070
- html = await resolveAndInjectPageHead(html, options.resolvePageHead, {
1071
- path: url.pathname,
1072
- host,
1073
- systemQuery: buildDevSystemQuery(req, socketAddress),
1074
- });
1075
- }
1076
- return new Response(html, { headers });
1077
- };
1078
-
1079
- // --- Fetch handler (runtime-neutral) ---
1080
- // Bundle-Pfad-Lookup: für jede Entry serven wir
1081
- // GET /client[-name].js → JS-Bundle
1082
- // GET /client[-name].js.map → Sourcemap
1083
- // assetPathFor() ist die Single-Source-of-Truth für die URL-Form.
1084
- const bundleByAssetPath = new Map<string, string>();
1085
- for (const e of entries) bundleByAssetPath.set(assetPathFor(e.name), e.name);
1086
-
1087
- // App-root convention (same as expandWatchPatterns/resolveStylesheet above):
1088
- // process.cwd() is the app workspace, so public/ is its static asset dir.
1089
- const publicDir = resolve(process.cwd(), "public");
1090
- // The app's cwd first, this package's own location as fallback (the cwd may
1091
- // not have renderer-web installed, e.g. a fixture dir).
1092
- const rendererWebFontsDir = resolveRendererWebFontsDir([process.cwd(), import.meta.dir]);
1093
-
1094
- const handleFetch = async (req: Request, socketAddress?: string): Promise<Response> => {
1095
- const url = new URL(req.url);
1096
-
1097
- // Specific routes first — assets, reload-SSE, API.
1098
- if (req.method === "GET") {
1099
- const bundleName = bundleByAssetPath.get(url.pathname);
1100
- if (bundleName !== undefined) {
1101
- const bundle = clientBundles.get(bundleName);
1102
- if (bundle === undefined) return new Response("no bundle", { status: 404 });
1103
- return new Response(bundle.js, {
1104
- headers: { "Content-Type": "application/javascript; charset=utf-8" },
1105
- });
1106
- }
1107
- // Looked up in the live bundles instead of a separate index, which a
1108
- // hot-reload rebuild would leave stale.
1109
- for (const bundle of clientBundles.values()) {
1110
- const chunk = bundle.chunks?.get(url.pathname);
1111
- if (chunk !== undefined) {
1112
- return new Response(chunk, {
1113
- headers: { "Content-Type": "application/javascript; charset=utf-8" },
1114
- });
1115
- }
1116
- }
1117
-
1118
- // .js.map-Variante: gleicher Lookup mit /.map abgeschnitten.
1119
- if (url.pathname.endsWith(".js.map")) {
1120
- const jsPath = url.pathname.slice(0, -".map".length);
1121
- const mapName = bundleByAssetPath.get(jsPath);
1122
- if (mapName !== undefined) {
1123
- const bundle = clientBundles.get(mapName);
1124
- if (bundle === undefined || !bundle.map) {
1125
- return new Response("no map", { status: 404 });
1126
- }
1127
- return new Response(bundle.map, {
1128
- headers: { "Content-Type": "application/json; charset=utf-8" },
1129
- });
1130
- }
1131
- }
1132
- }
1133
-
1134
- if (url.pathname.startsWith(RENDERER_WEB_FONTS_URL_PREFIX) && req.method === "GET") {
1135
- const fontFile = url.pathname.slice(RENDERER_WEB_FONTS_URL_PREFIX.length);
1136
- // The allowlist pattern excludes "/", "." segments and encoded traversal.
1137
- if (rendererWebFontsDir === undefined || !RENDERER_WEB_FONT_FILE_PATTERN.test(fontFile)) {
1138
- return new Response("not found", { status: 404 });
1139
- }
1140
- try {
1141
- const bytes = await readFile(join(rendererWebFontsDir, fontFile));
1142
- // @cast-boundary Buffer satisfies BodyInit at runtime, bun-types just doesn't say so.
1143
- return new Response(bytes as unknown as BodyInit, {
1144
- headers: { "Content-Type": "font/woff2", "Cache-Control": "public, max-age=86400" },
1145
- });
1146
- } catch (err) {
1147
- if ((err as { code?: string }).code === "ENOENT") {
1148
- return new Response("not found", { status: 404 });
1149
- }
1150
- throw err;
1151
- }
1152
- }
1153
-
1154
- if (url.pathname === "/styles.css" && req.method === "GET") {
1155
- if (stylesheetPath === undefined) return new Response("no stylesheet", { status: 404 });
1156
- const css = await readFile(stylesheetPath, "utf-8");
1157
- return new Response(css, {
1158
- headers: { "Content-Type": "text/css; charset=utf-8" },
1159
- });
1160
- }
1161
-
1162
- if (url.pathname === "/_reload" && req.method === "GET") {
1163
- const encoder = new TextEncoder();
1164
- const stream = new ReadableStream<Uint8Array>({
1165
- start(controller) {
1166
- const entry: ReloadClient = { controller, encoder, closed: false };
1167
- reloadClients.add(entry);
1168
- controller.enqueue(encoder.encode(": connected\n\n"));
1169
- // boot-Event: Browser-Snippet vergleicht das mit der ersten
1170
- // bootId. Verschiedener Wert nach Reconnect = Server wurde
1171
- // restartet → location.reload().
1172
- controller.enqueue(encoder.encode(`event: boot\ndata: ${bootId}\n\n`));
1173
- },
1174
- cancel() {
1175
- for (const c of reloadClients) {
1176
- if (c.closed) reloadClients.delete(c);
1177
- }
1178
- },
1179
- });
1180
- return new Response(stream, {
1181
- headers: {
1182
- "Content-Type": "text/event-stream",
1183
- "Cache-Control": "no-cache",
1184
- Connection: "keep-alive",
1185
- },
1186
- });
1187
- }
1188
-
1189
- // SPA catch-all: any GET to a non-API, non-asset path returns the
1190
- // HTML shell. The client-side router then reads location.pathname
1191
- // and mounts the right screen. The "no dot" filter skips
1192
- // /favicon.ico etc. (let the stack's 404 handler respond).
1193
- //
1194
- // Backend routes that live outside /api (currently just /sse) have
1195
- // to be excluded explicitly, otherwise the catch-all would shadow
1196
- // the real Hono route with HTML and EventSource would never
1197
- // connect.
1198
- //
1199
- // Plus: r.httpRoute-deklarierte Feature-Routes (z.B. /legal/*) liegen
1200
- // ebenfalls außerhalb /api und matchen sonst diesen catch-all. Wir
1201
- // probieren daher ZUERST stack.app.fetch — wenn Hono eine matchende
1202
- // Route hat, gewinnt sie. 404 vom Hono-stack → SPA-fallback wie
1203
- // bisher. Das spiegelt runProdApp's doc-intent ("Hono matched VOR
1204
- // staticDir-fallback") und macht r.httpRoute mit non-/api paths im
1205
- // dev-server symmetrisch zu prod.
1206
- if (
1207
- // HEAD mitnehmen — prod (runProdApp) fällt für GET UND HEAD auf die
1208
- // SPA zurück; ohne das liefert dev 404 wo prod 200 liefert.
1209
- isRoutableGetOrHead(req, url.pathname) &&
1210
- !url.pathname.includes(".")
1211
- ) {
1212
- const honoTry = await tryHonoFirst(stack.app, req, socketAddress);
1213
- if (honoTry.matched) {
1214
- return honoTry.response;
1215
- }
1216
- // Discriminated-Dispatch — symmetric zu prod. Ohne hostDispatch
1217
- // landet das im Single-Entry-Default ("client" + Schema-Inject).
1218
- if (options.hostDispatch !== undefined) {
1219
- const dispatch = await options.hostDispatch(req, {
1220
- systemQuery: buildDevSystemQuery(req, socketAddress),
1221
- });
1222
- if (dispatch.kind === "redirect") {
1223
- return new Response(null, {
1224
- status: dispatch.status ?? 302,
1225
- headers: { Location: dispatch.to },
1226
- });
1227
- }
1228
- if (dispatch.kind === "not-found") {
1229
- return new Response("Not Found", { status: 404 });
1230
- }
1231
- if (dispatch.kind === "static-html") {
1232
- // Raw file serve, no bundle injection — mirrors prod's
1233
- // `{ kind: "html", file: ... }` static-html branch.
1234
- const file = await readFile(dispatch.file, "utf-8");
1235
- return new Response(file, {
1236
- headers: { "Content-Type": "text/html; charset=utf-8" },
1237
- });
1238
- }
1239
- return htmlResponse(dispatch.entryName, req, socketAddress);
1240
- }
1241
- return htmlResponse("client", req, socketAddress);
1242
- }
1243
-
1244
- // Static assets under public/ — see tryServePublicAsset's own comment
1245
- // for the Hono → file → router-miss ordering.
1246
- const staticAsset = await tryServePublicAsset(
1247
- req,
1248
- url.pathname,
1249
- stack.app,
1250
- publicDir,
1251
- socketAddress,
1252
- );
1253
- if (staticAsset !== undefined) return staticAsset;
1254
-
1255
- // Bypasses tryHonoFirst entirely (API paths, /sse, non-GET/HEAD), so the
1256
- // router-miss marker must be stripped here too — otherwise an unmatched
1257
- // path would leak it straight to the client (see try-hono-first.ts's
1258
- // header-hygiene note).
1259
- return stripNoRouteMatchHeader(await stack.app.fetch(req, socketAddress));
1260
- };
1261
-
1262
- // --- HTTP server (Bun only) ---
1263
- // Under Node/vitest we skip Bun.serve entirely — the handle's
1264
- // .fetch() is the test surface. Real dev runs under Bun, where
1265
- // Bun.serve wires the listener.
1266
- // Bun.serve-Options kommen aus buildBunServeOptions (run-prod-app.ts)
1267
- // damit Dev und Prod genau dieselben SSE-relevanten Defaults nutzen
1268
- // (idleTimeout: 0). Spec-Test in run-prod-app-spec.test.ts pinst das.
1269
- const server = hasBun
1270
- ? (globalThis as { Bun: { serve: (opts: unknown) => BunServer } }).Bun.serve(
1271
- buildBunServeOptions(port, handleFetch, resolveDerivedMaxRequestBodySize(stack.registry), {
1272
- upgradeFetch: async (req, env) =>
1273
- stripNoRouteMatchHeader(await stack.app.fetch(req, env)),
1274
- }),
1275
- )
1276
- : undefined;
1277
-
1278
- // --- file watcher → rebundle + reload, oder process-restart bei Schema-Änderungen ---
1279
- // Heuristik: alles in `web/` oder `__tests__/` ist client-side oder
1280
- // test-only — Hot-Reload reicht (rebuild + broadcast reload). Alles
1281
- // andere ist server-side; Bun cached die Module-Imports, also würde ein
1282
- // Schema-Change in feature.ts nicht durchschlagen ohne process-restart.
1283
- // Wir exiten dann mit Code 75 (EX_TEMPFAIL) — `kumiko-dev` Wrapper
1284
- // detected das und respawnt.
1285
- //
1286
- // watcherAbort wird beim stop() ausgelöst → fs.watch beendet die
1287
- // async-iteration → kein Watcher überlebt einen Test-Teardown und
1288
- // klassifiziert ein rmSync(tmpdir) als "restart needed".
1289
- const watcherAbort = new AbortController();
1290
- if (entries.length > 0) {
1291
- // Watch-Dirs: alle entry-Verzeichnisse (deduped) plus die explizit
1292
- // angegebenen watchDirs. In Multi-Entry-Setups liegen die Entries
1293
- // oft im selben src/-Verzeichnis (`src/client-admin.tsx` +
1294
- // `src/client-public.tsx`) — der Set kollabiert das auf einen
1295
- // Watcher pro Verzeichnis.
1296
- const entryDirs = new Set<string>();
1297
- for (const e of entries) entryDirs.add(resolve(e.sourceFile, ".."));
1298
- const dirs = [...entryDirs, ...expandWatchPatterns(options.watchDirs ?? [])];
1299
- for (const dir of dirs) {
1300
- void watchDir(
1301
- dir,
1302
- async (filename) => {
1303
- const action = classifyChange(filename);
1304
- // skip: file change classified as ignore (test/css/json), nothing to rebuild
1305
- if (action === "ignore") return;
1306
- if (action === "restart") {
1307
- logInfo(
1308
- `[kumiko-server] schema change in ${filename} — restarting (Bun caches imports, hot-reload reicht hier nicht). ` +
1309
- "The kumiko-dev wrapper (package.json's `dev` script) respawns automatically " +
1310
- "and re-syncs new r.entity() columns on the new boot; running this entry " +
1311
- "directly (not via kumiko-dev) exits here without restarting.",
1312
- );
1313
- await stop();
1314
- process.exit(75);
1315
- }
1316
- try {
1317
- // Alle Entries rebuilden — auch wenn nur eine Datei sich
1318
- // ändert, wir wissen nicht welche Entries sie importieren.
1319
- // Bei zwei Entries mit shared Code triggert ein Edit der
1320
- // gemeinsamen Datei beide Bundles neu, das ist gewollt.
1321
- for (const e of entries) {
1322
- const rebuilt = await buildBundle(e.sourceFile);
1323
- clientBundles.set(e.name, rebuilt);
1324
- }
1325
- logInfo(`[kumiko-server] rebuilt on ${filename}, broadcasting reload`);
1326
- broadcastReload();
1327
- } catch {
1328
- // buildClient already logged the failure; keep serving the
1329
- // last good bundle until the next successful rebuild.
1330
- }
1331
- },
1332
- watcherAbort.signal,
1333
- );
1334
- }
1335
- }
1336
-
1337
- const stop = async (): Promise<void> => {
1338
- // Watcher zuerst stoppen damit kein onChange während des Teardowns
1339
- // mehr feuert (sonst können tmpdir-rmSync ein process.exit(75)
1340
- // auslösen).
1341
- watcherAbort.abort();
1342
- if (killTailwind) killTailwind();
1343
- if (server !== undefined) {
1344
- (server as { stop: (closeActive?: boolean) => void }).stop(true);
1345
- }
1346
- if (stack.eventDispatcher) {
1347
- await stack.eventDispatcher.stop();
1348
- }
1349
- await devJobRunners.stop();
1350
- await stack.cleanup();
1351
- };
1352
-
1353
- // --- graceful shutdown ---
1354
- // Signal handlers fire on Ctrl-C / kill. Without them, repeated dev
1355
- // restarts leak Postgres pools, lassen Tailwind-Watcher als orphan
1356
- // hängen und (in persistent mode) hinterlassen temp Clients.
1357
- // uncaughtException + unhandledRejection: Crashes hatten den Tailwind-
1358
- // Watcher nicht gekillt, der lief munter weiter im Hintergrund. Jetzt
1359
- // räumen wir auch im Fehlerfall auf bevor wir mit non-zero exit'n.
1360
- const installHandlers = options.installSignalHandlers ?? true;
1361
- if (installHandlers) {
1362
- for (const sig of ["SIGINT", "SIGTERM"] as const) {
1363
- process.on(sig, async () => {
1364
- logInfo(`[kumiko-server] ${sig} — cleaning up…`);
1365
- await stop();
1366
- process.exit(0);
1367
- });
1368
- }
1369
- process.on("uncaughtException", async (err) => {
1370
- logError("[kumiko-server] uncaughtException — cleaning up…", err);
1371
- try {
1372
- await stop();
1373
- } finally {
1374
- process.exit(1);
1375
- }
1376
- });
1377
- process.on("unhandledRejection", async (err) => {
1378
- logError("[kumiko-server] unhandledRejection — cleaning up…", err);
1379
- try {
1380
- await stop();
1381
- } finally {
1382
- process.exit(1);
1383
- }
1384
- });
1385
- }
1386
-
1387
- if (server !== undefined) {
1388
- logInfo(
1389
- `[kumiko-server] listening on http://localhost:${port}` +
1390
- (entries.length > 0
1391
- ? ` (hot reload on ${entries.length === 1 ? "client entry" : `${entries.length} entries`})`
1392
- : ""),
1393
- );
1394
- }
1395
-
1396
- return { fetch: handleFetch, server, stack, stop };
1397
- }