@cosmicdrift/kumiko-dev-server 0.1.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/bin/kumiko-build.ts +85 -0
- package/bin/kumiko-dev.ts +90 -0
- package/package.json +45 -0
- package/src/__tests__/build-prod-bundle.integration.ts +265 -0
- package/src/__tests__/build-prod-bundle.test.ts +262 -0
- package/src/__tests__/cache-headers.test.ts +70 -0
- package/src/__tests__/classify-change.test.ts +87 -0
- package/src/__tests__/compose-features-wiring.integration.ts +352 -0
- package/src/__tests__/compose-features.test.ts +81 -0
- package/src/__tests__/crash-tracker.test.ts +89 -0
- package/src/__tests__/create-kumiko-server.integration.ts +286 -0
- package/src/__tests__/few-shot-corpus.test.ts +311 -0
- package/src/__tests__/inject-schema.test.ts +62 -0
- package/src/__tests__/resolve-stylesheet.test.ts +90 -0
- package/src/__tests__/resolve-tailwind-cli.test.ts +49 -0
- package/src/__tests__/run-prod-app-spec.test.ts +57 -0
- package/src/__tests__/run-prod-app.integration.ts +535 -0
- package/src/__tests__/scaffold-feature.test.ts +143 -0
- package/src/__tests__/try-hono-first.test.ts +63 -0
- package/src/build-prod-bundle.ts +587 -0
- package/src/build-server-bundle.ts +308 -0
- package/src/build.ts +28 -0
- package/src/codegen/__tests__/run-codegen.test.ts +494 -0
- package/src/codegen/__tests__/strict-mode-diagnostics.test.ts +467 -0
- package/src/codegen/__tests__/watch.test.ts +186 -0
- package/src/codegen/index.ts +17 -0
- package/src/codegen/render.ts +225 -0
- package/src/codegen/run-codegen.ts +157 -0
- package/src/codegen/scan-events.ts +574 -0
- package/src/codegen/watch.ts +127 -0
- package/src/compose-features.ts +128 -0
- package/src/crash-tracker.ts +56 -0
- package/src/create-kumiko-server.ts +1010 -0
- package/src/drizzle-config.ts +44 -0
- package/src/drizzle-tables-auth-mode.ts +32 -0
- package/src/drizzle-tables-minimal.ts +22 -0
- package/src/few-shot-corpus.ts +369 -0
- package/src/index.ts +57 -0
- package/src/inject-schema.ts +24 -0
- package/src/resolve-tailwind-cli.ts +28 -0
- package/src/run-dev-app.ts +290 -0
- package/src/run-prod-app.ts +892 -0
- package/src/scaffold-feature.ts +226 -0
- package/src/try-hono-first.ts +46 -0
|
@@ -0,0 +1,1010 @@
|
|
|
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, watch } from "node:fs/promises";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { join, resolve } from "node:path";
|
|
22
|
+
import { type AuthRoutesConfig, generateToken } from "@cosmicdrift/kumiko-framework/api";
|
|
23
|
+
import { buildAppSchema, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
24
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
25
|
+
import {
|
|
26
|
+
ensureEntityTable,
|
|
27
|
+
setupTestStack,
|
|
28
|
+
type TestStack,
|
|
29
|
+
type TestStackOptions,
|
|
30
|
+
TestUsers,
|
|
31
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
32
|
+
import { injectSchema } from "./inject-schema";
|
|
33
|
+
import { resolveTailwindCli } from "./resolve-tailwind-cli";
|
|
34
|
+
import { buildBunServeOptions } from "./run-prod-app";
|
|
35
|
+
import { tryHonoFirst } from "./try-hono-first";
|
|
36
|
+
|
|
37
|
+
// Runtime-detection. The dev-server is meant to run under Bun (Kumiko's
|
|
38
|
+
// target runtime), but the test-suite runs under vitest on Node — we
|
|
39
|
+
// gate every Bun.* call so the module at least LOADS under Node, and
|
|
40
|
+
// tests drive the fetch-handler directly instead of going through
|
|
41
|
+
// Bun.serve + real sockets.
|
|
42
|
+
const hasBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
|
|
43
|
+
|
|
44
|
+
// Bun.serve returns a parametrised Server<WebSocketData>; we don't
|
|
45
|
+
// touch WebSockets here, so the narrow `unknown` binding is plenty.
|
|
46
|
+
// `Bun` isn't declared in Node types, so we fall back to `unknown`
|
|
47
|
+
// and only resolve the type when Bun is actually around.
|
|
48
|
+
type BunServer = typeof Bun extends undefined ? unknown : ReturnType<typeof Bun.serve>;
|
|
49
|
+
|
|
50
|
+
// biome-ignore lint/suspicious/noConsole: dev-server status logging
|
|
51
|
+
const logInfo = (msg: string): void => console.log(msg);
|
|
52
|
+
// biome-ignore lint/suspicious/noConsole: dev-server error logging
|
|
53
|
+
const logError = (...args: unknown[]): void => console.error(...args);
|
|
54
|
+
|
|
55
|
+
/** Multi-Entry-Mode für Apps die mehrere getrennte Bundles ausliefern
|
|
56
|
+
* (z.B. publicstatus: `admin.<base>` lädt Admin-UI, sonst Public-Page).
|
|
57
|
+
*
|
|
58
|
+
* Spiegelt die Convention von kumiko-build (`src/client-<name>.tsx`) und
|
|
59
|
+
* serviert `/client-<name>.js` per HTTP. Multi-Entry ist mutually
|
|
60
|
+
* exclusive mit `clientEntry`. Wer Multi-Entry nutzt MUSS auch
|
|
61
|
+
* `hostDispatch` setzen — sonst weiß der Server nicht welches HTML
|
|
62
|
+
* er rausgeben soll. */
|
|
63
|
+
export type DevClientEntry = {
|
|
64
|
+
/** Logical Name. Frei wählbar; Convention: gleicher Suffix wie
|
|
65
|
+
* `src/client-<name>.tsx` damit der Build identische Asset-URLs
|
|
66
|
+
* liefert (`/client-<name>.js`). */
|
|
67
|
+
readonly name: string;
|
|
68
|
+
/** Absoluter Pfad zur Browser-Entry-Datei. */
|
|
69
|
+
readonly sourceFile: string;
|
|
70
|
+
/** Optional eigenes HTML-Template für diesen Entry. Wenn nicht gesetzt,
|
|
71
|
+
* wird `htmlPath` (das default-Template) für alle Entries genutzt. */
|
|
72
|
+
readonly htmlPath?: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Discriminated-Union, identisch zur Form von `runProdApp.hostDispatch`.
|
|
76
|
+
* Damit kann Dev/Prod-Routing 1:1 gespiegelt werden — ein Apex-404 in
|
|
77
|
+
* Prod ist ein Apex-404 in Dev (mit `/etc/hosts`-Eintrag für die
|
|
78
|
+
* betroffene Domain). Schema-Inject ist pro Response steuerbar — ein
|
|
79
|
+
* Public-Bundle leakt das Admin-Schema nicht, auch nicht in Dev. */
|
|
80
|
+
export type DevHostDispatchResult =
|
|
81
|
+
| {
|
|
82
|
+
readonly kind: "html";
|
|
83
|
+
readonly entryName: string;
|
|
84
|
+
/** Default: true. Setze `false` für Public-Routes — analog zu
|
|
85
|
+
* prod-`injectSchema:false` für Anonymous-Visitors. */
|
|
86
|
+
readonly injectSchema?: boolean;
|
|
87
|
+
}
|
|
88
|
+
| {
|
|
89
|
+
/** Static-HTML: liefert eine Datei wortwörtlich, kein Bundle-Inject,
|
|
90
|
+
* kein Schema-Inject. Pendant zu prod's `{ kind: "html", file: ...,
|
|
91
|
+
* injectSchema: false }` für Marketing-/Apex-Pages die kein React
|
|
92
|
+
* brauchen. Pfad relativ zum Server-CWD. */
|
|
93
|
+
readonly kind: "static-html";
|
|
94
|
+
readonly file: string;
|
|
95
|
+
}
|
|
96
|
+
| { readonly kind: "redirect"; readonly to: string; readonly status?: 301 | 302 }
|
|
97
|
+
| { readonly kind: "not-found" };
|
|
98
|
+
|
|
99
|
+
/** Picks an entry by inspecting the incoming request. Wird von
|
|
100
|
+
* Multi-Entry-Apps gesetzt; im Single-Entry-Mode irrelevant. */
|
|
101
|
+
export type DevHostDispatch = (req: Request) => DevHostDispatchResult;
|
|
102
|
+
|
|
103
|
+
export type CreateKumikoServerOptions = {
|
|
104
|
+
/** Features whose entities, handlers, and screens get wired into the
|
|
105
|
+
* dev stack. Pass every feature the app is supposed to run. */
|
|
106
|
+
readonly features: readonly FeatureDefinition[];
|
|
107
|
+
/** Absolute path to the browser entry module. The dev-server runs
|
|
108
|
+
* `Bun.build` on it and serves the output at `/client.js`. Omit to
|
|
109
|
+
* run a headless API-only dev-stack (rare — every sample has one).
|
|
110
|
+
* Mutually exclusive mit `clientEntries`. */
|
|
111
|
+
readonly clientEntry?: string;
|
|
112
|
+
/** Multi-Entry-Mode: mehrere getrennte Bundles, jeweils unter
|
|
113
|
+
* `/client-<name>.js`. Mutually exclusive mit `clientEntry`. Setze
|
|
114
|
+
* `hostDispatch` mit, sonst bleibt unklar welches Template zurück-
|
|
115
|
+
* geht. */
|
|
116
|
+
readonly clientEntries?: readonly DevClientEntry[];
|
|
117
|
+
/** Multi-Entry-Mode: Routing pro Request. Inspiziert `Host` (oder
|
|
118
|
+
* was auch immer) und liefert eine Discriminated-Union zurück
|
|
119
|
+
* (html → entry-bundle, redirect → 30x, not-found → 404).
|
|
120
|
+
* Symmetric zu `runProdApp.hostDispatch` damit dev/prod-Drift
|
|
121
|
+
* beim Routing unmöglich ist. */
|
|
122
|
+
readonly hostDispatch?: DevHostDispatch;
|
|
123
|
+
/** @internal — ersetzt `Bun.build` für Tests. Default ruft die echte
|
|
124
|
+
* Bun-Toolchain. Tests unter Node injizieren einen Stub damit der
|
|
125
|
+
* Routing-Pfad treibbar bleibt ohne Bun.build aufzurufen.
|
|
126
|
+
* KEIN Public-API-Surface — präfixiert mit `_` damit Konsumenten
|
|
127
|
+
* wissen dass das ein Test-Seam ist. */
|
|
128
|
+
readonly _buildBundle?: (sourceFile: string) => Promise<{
|
|
129
|
+
readonly js: string;
|
|
130
|
+
readonly map: string;
|
|
131
|
+
}>;
|
|
132
|
+
/** Absolute path to the CSS entry (typischerweise styles.css mit
|
|
133
|
+
* @import "tailwindcss"). Der dev-server startet dann den
|
|
134
|
+
* Tailwind-CLI als watcher und servt das kompilierte CSS unter
|
|
135
|
+
* /styles.css.
|
|
136
|
+
*
|
|
137
|
+
* Wenn `undefined` UND `clientEntry` gesetzt: resolve die
|
|
138
|
+
* `@cosmicdrift/kumiko-renderer-web/styles.css`-Default via Package-Exports.
|
|
139
|
+
* So muss kein Sample mehr den monorepo-relativen Pfad
|
|
140
|
+
* ../../packages/renderer-web/src/styles.css hardcoden.
|
|
141
|
+
*
|
|
142
|
+
* `stylesheet: false` → CSS-Pipeline explizit deaktivieren. */
|
|
143
|
+
readonly stylesheet?: string | false;
|
|
144
|
+
/** Optional HTML template served at `GET /`. The dev-server injects
|
|
145
|
+
* a `<script src="/client.js">` and a reload-listener snippet into
|
|
146
|
+
* `</body>` if those aren't already there. Defaults to a minimal
|
|
147
|
+
* empty-body document — enough to boot the client. */
|
|
148
|
+
readonly htmlPath?: string;
|
|
149
|
+
/** Port to listen on. Default 4173. Overridable via `PORT` env. */
|
|
150
|
+
readonly port?: number;
|
|
151
|
+
/** Extra directories to watch for reload triggers. The entry's
|
|
152
|
+
* directory is watched automatically. */
|
|
153
|
+
readonly watchDirs?: readonly string[];
|
|
154
|
+
/** When false, no SIGINT/SIGTERM handlers are installed. Tests set
|
|
155
|
+
* this so repeated `createKumikoServer` calls don't accumulate
|
|
156
|
+
* listeners on the process. Default true (dev-server behaviour). */
|
|
157
|
+
readonly installSignalHandlers?: boolean;
|
|
158
|
+
/** Auth-Route-Config (login, tenants, switch-tenant, logout). Wenn
|
|
159
|
+
* gesetzt wird die Auto-JWT-Mint auf GET / abgeschaltet — der
|
|
160
|
+
* Client ist dann selbst fürs Login zuständig. Zur echten Wirkung
|
|
161
|
+
* brauchen die dazugehörigen Features (user/tenant/auth-email-
|
|
162
|
+
* password) via `features` drin sein. */
|
|
163
|
+
readonly auth?: AuthRoutesConfig;
|
|
164
|
+
/** Extra-AppContext-Keys (z.B. configResolver für config-feature).
|
|
165
|
+
* Wird an setupTestStack weitergereicht. Siehe TestStackOptions
|
|
166
|
+
* für die erlaubten Shapes (object oder factory-function). */
|
|
167
|
+
readonly extraContext?: TestStackOptions["extraContext"];
|
|
168
|
+
/** Anonymous-Access aktivieren — Requests ohne JWT werden als
|
|
169
|
+
* Pseudo-User mit Rolle `anonymous` durchgelassen, sofern der
|
|
170
|
+
* Handler `roles: ["anonymous"]` deklariert. Tenant-Resolution per
|
|
171
|
+
* Header/Cookie/Default; siehe AnonymousAccessConfig. */
|
|
172
|
+
readonly anonymousAccess?: TestStackOptions["anonymousAccess"];
|
|
173
|
+
/** Wird nach dem Aufsetzen der Entity-Tabellen aufgerufen. Hook für
|
|
174
|
+
* non-entity-tables (pushTables) und Seeding (admin user, initial
|
|
175
|
+
* tenant, …). Muss idempotent sein — im persistent-DB-Modus läuft
|
|
176
|
+
* es bei jedem Boot. */
|
|
177
|
+
readonly onAfterSetup?: (stack: TestStack) => Promise<void>;
|
|
178
|
+
/** Mount-Point für app-eigene HTTP-Routes außerhalb des Dispatcher-
|
|
179
|
+
* Systems — symmetrisch zum runProdApp.extraRoutes. Wird VOR der
|
|
180
|
+
* Static/HTML-Auslieferung aufgerufen, sodass eigene GETs (/feed.xml,
|
|
181
|
+
* /og-image, …) Vorrang vor dem Dev-Asset-Pfad haben. `deps` statt
|
|
182
|
+
* `ctx` weil dies kein HandlerContext ist — kein user/tenant. */
|
|
183
|
+
readonly extraRoutes?: (
|
|
184
|
+
app: import("hono").Hono,
|
|
185
|
+
deps: { db: TestStack["db"]; redis: TestStack["redis"] },
|
|
186
|
+
) => void;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export type KumikoServerHandle = {
|
|
190
|
+
/** The fetch handler that routes a Request through the dev-server
|
|
191
|
+
* layer (HTML, /client.js, /_reload, SSE) and falls back to the
|
|
192
|
+
* underlying Kumiko stack. Tests call this directly to exercise
|
|
193
|
+
* the routing without going through real sockets. */
|
|
194
|
+
readonly fetch: (req: Request) => Promise<Response>;
|
|
195
|
+
/** Bun.serve instance. `undefined` when running outside Bun (e.g.
|
|
196
|
+
* in vitest under Node) — the handle still works via `.fetch`. */
|
|
197
|
+
readonly server: BunServer | undefined;
|
|
198
|
+
readonly stack: TestStack;
|
|
199
|
+
/** Stops the server and tears down the stack (DB + redis). */
|
|
200
|
+
readonly stop: () => Promise<void>;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const CSRF_COOKIE = "kumiko_csrf";
|
|
204
|
+
const AUTH_COOKIE = "kumiko_auth";
|
|
205
|
+
|
|
206
|
+
// Reload snippet injected into every page-load so the browser
|
|
207
|
+
// subscribes to /_reload without the HTML needing to hard-code it.
|
|
208
|
+
//
|
|
209
|
+
// Zwei reload-Trigger:
|
|
210
|
+
// - explizites `reload`-Event vom Server beim hot-reload (rebuild + send)
|
|
211
|
+
// - implizites: jede SSE-Connection bekommt beim Connect ein `boot`-Event
|
|
212
|
+
// mit der bootId des aktuellen Server-Process. Das Snippet merkt sich
|
|
213
|
+
// die erste bootId; wenn nach einem Reconnect (Server-Restart!) eine
|
|
214
|
+
// ANDERE bootId kommt, refresh — sonst bleibt der Browser ewig auf
|
|
215
|
+
// dem alten Bundle hängen wenn der Watcher classifyChange="restart"
|
|
216
|
+
// gewählt hat oder der User Ctrl-C/yarn dev gemacht hat.
|
|
217
|
+
const RELOAD_SNIPPET = `
|
|
218
|
+
<script>
|
|
219
|
+
(() => {
|
|
220
|
+
const es = new EventSource("/_reload");
|
|
221
|
+
let firstBootId = null;
|
|
222
|
+
es.addEventListener("boot", (e) => {
|
|
223
|
+
if (firstBootId === null) {
|
|
224
|
+
firstBootId = e.data;
|
|
225
|
+
} else if (firstBootId !== e.data) {
|
|
226
|
+
location.reload();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
es.addEventListener("reload", () => location.reload());
|
|
230
|
+
})();
|
|
231
|
+
</script>
|
|
232
|
+
`;
|
|
233
|
+
|
|
234
|
+
// Minimal HTML when the caller didn't hand one in. `#root` is the
|
|
235
|
+
// default mount target for `createKumikoApp`, so the one-line client
|
|
236
|
+
// can attach without the sample having to ship its own template.
|
|
237
|
+
const DEFAULT_HTML = `<!doctype html>
|
|
238
|
+
<html lang="en">
|
|
239
|
+
<head>
|
|
240
|
+
<meta charset="utf-8" />
|
|
241
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
242
|
+
<title>Kumiko</title>
|
|
243
|
+
</head>
|
|
244
|
+
<body>
|
|
245
|
+
<div id="root"></div>
|
|
246
|
+
<script src="/client.js"></script>
|
|
247
|
+
</body>
|
|
248
|
+
</html>
|
|
249
|
+
`;
|
|
250
|
+
|
|
251
|
+
type ClientBundle = { readonly js: string; readonly map: string };
|
|
252
|
+
|
|
253
|
+
async function buildClient(entry: string): Promise<ClientBundle> {
|
|
254
|
+
if (!hasBun) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
"[kumiko-server] clientEntry is only supported under Bun — Bun.build is unavailable in this runtime.",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
const unminified = process.env["KUMIKO_DEV_UNMINIFIED"] === "1";
|
|
260
|
+
const built = await Bun.build({
|
|
261
|
+
entrypoints: [entry],
|
|
262
|
+
target: "browser",
|
|
263
|
+
minify: !unminified,
|
|
264
|
+
sourcemap: "linked",
|
|
265
|
+
});
|
|
266
|
+
if (!built.success) {
|
|
267
|
+
logError("[kumiko-server] client bundle failed:");
|
|
268
|
+
for (const log of built.logs) logError(log);
|
|
269
|
+
throw new Error("client bundle failed");
|
|
270
|
+
}
|
|
271
|
+
const jsOutput = built.outputs.find((o) => o.path.endsWith(".js"));
|
|
272
|
+
const mapOutput = built.outputs.find((o) => o.path.endsWith(".js.map"));
|
|
273
|
+
if (!jsOutput) throw new Error("[kumiko-server] client bundle produced no .js output");
|
|
274
|
+
return {
|
|
275
|
+
js: await jsOutput.text(),
|
|
276
|
+
map: mapOutput ? await mapOutput.text() : "",
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
type ReloadClient = {
|
|
281
|
+
readonly controller: ReadableStreamDefaultController<Uint8Array>;
|
|
282
|
+
readonly encoder: TextEncoder;
|
|
283
|
+
closed: boolean;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
function injectReload(html: string): string {
|
|
287
|
+
if (html.includes("/_reload")) return html;
|
|
288
|
+
return html.includes("</body>")
|
|
289
|
+
? html.replace("</body>", `${RELOAD_SNIPPET}</body>`)
|
|
290
|
+
: html + RELOAD_SNIPPET;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Injiziert <link rel="stylesheet" href="/styles.css"> in den <head>,
|
|
294
|
+
// wenn es noch nicht da ist. Wird nur aufgerufen wenn die App das
|
|
295
|
+
// stylesheet-Option genutzt hat — andernfalls kommt keine CSS-Route.
|
|
296
|
+
function injectStylesheet(html: string): string {
|
|
297
|
+
if (html.includes('href="/styles.css"')) return html;
|
|
298
|
+
const link = '<link rel="stylesheet" href="/styles.css" />';
|
|
299
|
+
return html.includes("</head>")
|
|
300
|
+
? html.replace("</head>", ` ${link}\n</head>`)
|
|
301
|
+
: `${link}${html}`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// injectSchema lebt in `./inject-schema.ts` damit dev-server + prod-
|
|
305
|
+
// server denselben Inject-Pfad nutzen.
|
|
306
|
+
|
|
307
|
+
async function watchDir(
|
|
308
|
+
dir: string,
|
|
309
|
+
onChange: (filename: string) => void,
|
|
310
|
+
signal: AbortSignal,
|
|
311
|
+
): Promise<void> {
|
|
312
|
+
// AbortSignal wird vom Server-stop() ausgelöst: ohne den Abort liefe
|
|
313
|
+
// die for-await-Schleife bis zum Process-Exit weiter. Im Test-Setup
|
|
314
|
+
// (afterEach räumt tmpdir mit rmSync auf) sähe der Watcher dann das
|
|
315
|
+
// rmSync, klassifizierte's als "restart" und riefe process.exit(75) —
|
|
316
|
+
// bubbles als unhandled error in vitest hoch.
|
|
317
|
+
const watcher = watch(dir, { recursive: true, signal });
|
|
318
|
+
try {
|
|
319
|
+
for await (const ev of watcher) {
|
|
320
|
+
if (ev.filename) onChange(ev.filename);
|
|
321
|
+
}
|
|
322
|
+
} catch (err) {
|
|
323
|
+
// signal.abort() wirft AbortError aus dem async-iterator; das ist
|
|
324
|
+
// gewollt und kein Fehler. Andere Errors weiterreichen.
|
|
325
|
+
if ((err as { name?: string }).name === "AbortError") return;
|
|
326
|
+
throw err;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Klassifiziert eine geänderte Datei: `hot-reload` für Client-Side
|
|
331
|
+
// (Browser-Bundle rebuild + reload), `restart` für Server-Side (Bun-
|
|
332
|
+
// Module-Cache zwingt einen Process-Restart durch), `ignore` für
|
|
333
|
+
// alles was den Server nicht beeinflusst (Tests, .css, .json…).
|
|
334
|
+
//
|
|
335
|
+
// Heuristik:
|
|
336
|
+
// - Tests (`__tests__/` oder `*.test.ts(x)`) → ignore
|
|
337
|
+
// - `.ts` / `.tsx` außer Tests:
|
|
338
|
+
// - Client-side-Dirs (`/web/`, `/admin/`, `/public/`, `/client/`)
|
|
339
|
+
// oder die client-entry-Datei selbst → hot-reload
|
|
340
|
+
// - sonst → restart (könnte Schema/Feature-Definition sein)
|
|
341
|
+
// - andere Dateitypen → ignore (kein TS rebuild nötig)
|
|
342
|
+
//
|
|
343
|
+
// Warum mehrere Dirs für client-side: in Kumiko-Samples gibt's keine
|
|
344
|
+
// Convention. publicstatus splittet `/admin/` (Admin-Bundle) und
|
|
345
|
+
// `/public/` (Anonymous-Bundle); beammycar nutzt `/web/` für seine
|
|
346
|
+
// Feature-Web-Code; ältere Samples haben einfach `/client.tsx` neben
|
|
347
|
+
// dem Server. Der Watcher muss alle drei verstehen, sonst löst ein
|
|
348
|
+
// Edit der Bridge-Component einen kompletten Server-Restart aus —
|
|
349
|
+
// kostet 2-3s, droppt die Test-DB im ephemeral-Modus, reseed läuft
|
|
350
|
+
// erneut. Ineffektiv und für der User verwirrend.
|
|
351
|
+
//
|
|
352
|
+
// Exportiert für Tests; intern wird's von der Watcher-Loop gerufen.
|
|
353
|
+
export function classifyChange(filename: string): "restart" | "hot-reload" | "ignore" {
|
|
354
|
+
if (!filename.endsWith(".ts") && !filename.endsWith(".tsx")) return "ignore";
|
|
355
|
+
if (filename.includes("__tests__")) return "ignore";
|
|
356
|
+
if (filename.endsWith(".test.ts") || filename.endsWith(".test.tsx")) return "ignore";
|
|
357
|
+
if (filename.endsWith(".integration.ts") || filename.endsWith(".e2e.ts")) return "ignore";
|
|
358
|
+
// Plattformpfad-agnostisch: prüfen auf POSIX und Windows-Trenner.
|
|
359
|
+
// Wir matchen sowohl `<sep><dir><sep>` als auch trailing-`<sep><dir>`
|
|
360
|
+
// (für Watcher-Filenames die als relativer Pfad ankommen).
|
|
361
|
+
const clientSubdirs = ["web", "admin", "public", "client"];
|
|
362
|
+
for (const dir of clientSubdirs) {
|
|
363
|
+
if (
|
|
364
|
+
filename.includes(`/${dir}/`) ||
|
|
365
|
+
filename.includes(`\\${dir}\\`) ||
|
|
366
|
+
filename.startsWith(`${dir}/`) ||
|
|
367
|
+
filename.startsWith(`${dir}\\`)
|
|
368
|
+
) {
|
|
369
|
+
return "hot-reload";
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (filename.endsWith("/client.tsx") || filename.endsWith("/client.ts")) {
|
|
373
|
+
return "hot-reload";
|
|
374
|
+
}
|
|
375
|
+
return "restart";
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Expandiert watchDirs-Patterns auf konkrete Verzeichnisse. Ein Eintrag
|
|
379
|
+
// ohne `*` wird als gewöhnlicher Pfad resolved; mit `*` wird er per
|
|
380
|
+
// glob expanded und alle Treffer die Verzeichnisse sind übernommen.
|
|
381
|
+
// Erlaubt z.B. `"../../../packages/*/src"` statt vier hart-kodierte
|
|
382
|
+
// Pfade. Glob ist sync — wird einmal beim Boot ausgewertet, nicht
|
|
383
|
+
// während der Watcher läuft.
|
|
384
|
+
function expandWatchPatterns(patterns: readonly string[]): string[] {
|
|
385
|
+
const out: string[] = [];
|
|
386
|
+
for (const p of patterns) {
|
|
387
|
+
if (!p.includes("*")) {
|
|
388
|
+
out.push(resolve(p));
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
// expandWatchPatterns wird nur unter Bun aufgerufen (createKumikoServer
|
|
392
|
+
// ist Bun-only); der ?.! -dance ist nötig weil TS Bun nicht im
|
|
393
|
+
// globalThis-default sieht. Wenn Bun fehlt, ist der Aufrufstapel eh
|
|
394
|
+
// schon fail-fast unten in Bun.serve.
|
|
395
|
+
const BunRef = (
|
|
396
|
+
globalThis as {
|
|
397
|
+
Bun?: {
|
|
398
|
+
Glob: new (
|
|
399
|
+
p: string,
|
|
400
|
+
) => { scanSync: (opts: { onlyFiles: false; cwd: string }) => Iterable<string> };
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
).Bun;
|
|
404
|
+
if (!BunRef) throw new Error("expandWatchPatterns requires Bun.Glob");
|
|
405
|
+
const glob = new BunRef.Glob(p);
|
|
406
|
+
const matches = Array.from(glob.scanSync({ onlyFiles: false, cwd: process.cwd() }));
|
|
407
|
+
for (const m of matches) {
|
|
408
|
+
const abs = resolve(m);
|
|
409
|
+
try {
|
|
410
|
+
if (statSync(abs).isDirectory()) out.push(abs);
|
|
411
|
+
} catch {
|
|
412
|
+
// ignore unreadable matches — typisch defekte Symlinks
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return out;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Resolve den Pfad zur Tailwind-Entry-CSS. Mehrere Fälle:
|
|
420
|
+
// - Explicit string → den resolved'en absoluten Pfad verwenden
|
|
421
|
+
// - false → CSS-Pipeline aus (undefined zurück)
|
|
422
|
+
// - undefined + client(s):
|
|
423
|
+
// 1. App-eigenes src/styles.css (App-Theme-Override) wenn vorhanden
|
|
424
|
+
// 2. Sonst Default `@cosmicdrift/kumiko-renderer-web/styles.css` über Package-Exports
|
|
425
|
+
// - undefined + kein clientEntry/clientEntries: undefined (keine CSS nötig)
|
|
426
|
+
//
|
|
427
|
+
// Auto-Detection von src/styles.css spiegelt die Logik aus
|
|
428
|
+
// build-prod-bundle:resolveStylesheetEntry — damit dev und prod identisch
|
|
429
|
+
// resolven. Ohne diesen Check müsste jede App `stylesheet: "./src/styles.css"`
|
|
430
|
+
// setzen, sonst greift in dev der renderer-web-Default und Brand-Tokens
|
|
431
|
+
// werden ignoriert (DX-Falle).
|
|
432
|
+
//
|
|
433
|
+
// @internal — exportiert nur für Unit-Tests, nicht aus dem Package-Index
|
|
434
|
+
// re-exportiert. Konsumenten gehen ausschließlich über die `stylesheet`-
|
|
435
|
+
// Option der createKumikoServer-Aufrufstelle.
|
|
436
|
+
export function resolveStylesheet(options: CreateKumikoServerOptions): string | undefined {
|
|
437
|
+
if (options.stylesheet === false) return undefined;
|
|
438
|
+
if (typeof options.stylesheet === "string") return resolve(options.stylesheet);
|
|
439
|
+
const hasAnyEntry =
|
|
440
|
+
options.clientEntry !== undefined ||
|
|
441
|
+
(options.clientEntries !== undefined && options.clientEntries.length > 0);
|
|
442
|
+
if (!hasAnyEntry) return undefined;
|
|
443
|
+
|
|
444
|
+
// App-eigenes src/styles.css schlägt den renderer-web-Default — gleiche
|
|
445
|
+
// Logik wie kumiko-build, damit lokal/prod identisch bauen.
|
|
446
|
+
const local = resolve(process.cwd(), "src/styles.css");
|
|
447
|
+
if (existsSync(local)) return local;
|
|
448
|
+
|
|
449
|
+
// Bun.resolveSync folgt Package-Exports — "./styles.css" in renderer-web's
|
|
450
|
+
// package.json. Das Monorepo auflöst direkt auf den Workspace-File, eine
|
|
451
|
+
// installierte Fremd-App auf den node_modules-File. Kein `fileURLToPath`
|
|
452
|
+
// nötig, Bun gibt schon einen absoluten FS-Pfad zurück.
|
|
453
|
+
if (!hasBun) {
|
|
454
|
+
// Unit-Tests unter vitest/Node landen hier. Ohne Bun können wir die
|
|
455
|
+
// Package-Export-Resolution nicht machen — und im Test-Kontext gibt's
|
|
456
|
+
// keine echte Tailwind-Pipeline. Skip still, keine Fehlermeldung nötig.
|
|
457
|
+
return undefined;
|
|
458
|
+
}
|
|
459
|
+
try {
|
|
460
|
+
return (
|
|
461
|
+
globalThis as { Bun: { resolveSync: (id: string, from: string) => string } }
|
|
462
|
+
).Bun.resolveSync("@cosmicdrift/kumiko-renderer-web/styles.css", process.cwd());
|
|
463
|
+
} catch (err) {
|
|
464
|
+
logError(
|
|
465
|
+
"[kumiko-server] couldn't auto-resolve @cosmicdrift/kumiko-renderer-web/styles.css — " +
|
|
466
|
+
"pass `stylesheet: <path>` or `stylesheet: false` explicitly.",
|
|
467
|
+
err,
|
|
468
|
+
);
|
|
469
|
+
return undefined;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Startet den Tailwind-CLI als watch-Prozess. Failure-Mode ist
|
|
474
|
+
// non-fatal (return undefined): kann der CLI nicht resolved werden
|
|
475
|
+
// oder failt der initial-Build (z.B. flakiges Netz, fehlende
|
|
476
|
+
// Dependency), bootet der Server ohne CSS statt zu sterben.
|
|
477
|
+
async function startTailwindWatcher(
|
|
478
|
+
entryCss: string,
|
|
479
|
+
): Promise<{ outPath: string; kill: () => void } | undefined> {
|
|
480
|
+
const bunResolver = hasBun
|
|
481
|
+
? (globalThis as { Bun: { resolveSync: (id: string, from: string) => string } }).Bun
|
|
482
|
+
: undefined;
|
|
483
|
+
const cliPath = resolveTailwindCli({ bun: bunResolver, cwd: process.cwd() });
|
|
484
|
+
if (cliPath === undefined) {
|
|
485
|
+
logError(
|
|
486
|
+
"[kumiko-server] @tailwindcss/cli nicht auflösbar — booting ohne CSS-Pipeline. " +
|
|
487
|
+
"`bun install` und Restart, um Styles zu aktivieren.",
|
|
488
|
+
);
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
const outDir = mkdtempSync(join(tmpdir(), "kumiko-tw-"));
|
|
492
|
+
const outPath = join(outDir, "styles.css");
|
|
493
|
+
logInfo(`[kumiko-server] tailwind watcher → ${outPath}`);
|
|
494
|
+
const bunPath = process.argv[0] ?? "bun";
|
|
495
|
+
// Initial-Build blockend, damit der erste /styles.css-Request kein
|
|
496
|
+
// 404 bekommt. Dann den watcher im Hintergrund mit unref() — sonst
|
|
497
|
+
// hing er beim Parent-Crash als orphan-process.
|
|
498
|
+
try {
|
|
499
|
+
await new Promise<void>((resolvePromise, rejectPromise) => {
|
|
500
|
+
const child = spawn(bunPath, ["run", cliPath, "-i", entryCss, "-o", outPath], {
|
|
501
|
+
stdio: "inherit",
|
|
502
|
+
});
|
|
503
|
+
child.on("exit", (code) => {
|
|
504
|
+
if (code === 0) resolvePromise();
|
|
505
|
+
else rejectPromise(new Error(`tailwind one-shot-build exit ${code}`));
|
|
506
|
+
});
|
|
507
|
+
child.on("error", rejectPromise);
|
|
508
|
+
});
|
|
509
|
+
} catch (err) {
|
|
510
|
+
logError("[kumiko-server] tailwind one-shot-build fehlgeschlagen — booting ohne CSS:", err);
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
const watcher = spawn(bunPath, ["run", cliPath, "-i", entryCss, "-o", outPath, "--watch"], {
|
|
514
|
+
stdio: "inherit",
|
|
515
|
+
});
|
|
516
|
+
watcher.unref();
|
|
517
|
+
return {
|
|
518
|
+
outPath,
|
|
519
|
+
kill: () => {
|
|
520
|
+
try {
|
|
521
|
+
watcher.kill("SIGTERM");
|
|
522
|
+
} catch {
|
|
523
|
+
// schon tot oder nie gestartet — nicht weiter relevant
|
|
524
|
+
}
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Create all entity tables declared by the given features. Uses
|
|
530
|
+
// ensureEntityTable so a persistent DB (KUMIKO_DEV_DB_NAME) can
|
|
531
|
+
// reuse tables from the previous boot without the caller having to
|
|
532
|
+
// check.
|
|
533
|
+
async function createEntityTablesForFeatures(
|
|
534
|
+
stack: TestStack,
|
|
535
|
+
features: readonly FeatureDefinition[],
|
|
536
|
+
): Promise<void> {
|
|
537
|
+
for (const feature of features) {
|
|
538
|
+
for (const [entityName, entity] of Object.entries(feature.entities)) {
|
|
539
|
+
const created = await ensureEntityTable(stack.db, entity, entityName);
|
|
540
|
+
if (!created) {
|
|
541
|
+
logInfo(
|
|
542
|
+
`[kumiko-server] table ${entity.table ?? entityName} already exists — skipping create`,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** @internal — normalisierte Client-Entry-Form, einheitlich über
|
|
550
|
+
* Single-Mode (`clientEntry`) und Multi-Mode (`clientEntries`). */
|
|
551
|
+
type NormalizedEntry = {
|
|
552
|
+
readonly name: string;
|
|
553
|
+
readonly sourceFile: string;
|
|
554
|
+
readonly htmlPath: string | undefined;
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
/** URL-Pfad unter dem ein Entry ausgeliefert wird. "client" → /client.js
|
|
558
|
+
* (Single-Mode-Default), sonst "/client-<name>.js". Single-Source-of-Truth
|
|
559
|
+
* damit Routing + Logging dieselbe Konvention nutzen. */
|
|
560
|
+
function assetPathFor(entryName: string): string {
|
|
561
|
+
return entryName === "client" ? "/client.js" : `/client-${entryName}.js`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function normalizeEntries(options: CreateKumikoServerOptions): readonly NormalizedEntry[] {
|
|
565
|
+
if (options.clientEntries !== undefined && options.clientEntry !== undefined) {
|
|
566
|
+
throw new Error(
|
|
567
|
+
"[kumiko-server] clientEntry und clientEntries sind mutually exclusive — wähle eins",
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
if (options.clientEntries !== undefined && options.clientEntries.length > 0) {
|
|
571
|
+
if (options.hostDispatch === undefined) {
|
|
572
|
+
throw new Error(
|
|
573
|
+
"[kumiko-server] clientEntries braucht hostDispatch — sonst weiß der Server nicht welches Template er liefern soll",
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
return options.clientEntries.map((e) => ({
|
|
577
|
+
name: e.name,
|
|
578
|
+
sourceFile: resolve(e.sourceFile),
|
|
579
|
+
htmlPath: e.htmlPath,
|
|
580
|
+
}));
|
|
581
|
+
}
|
|
582
|
+
if (options.clientEntry !== undefined) {
|
|
583
|
+
return [{ name: "client", sourceFile: resolve(options.clientEntry), htmlPath: undefined }];
|
|
584
|
+
}
|
|
585
|
+
return [];
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
export async function createKumikoServer(
|
|
589
|
+
options: CreateKumikoServerOptions,
|
|
590
|
+
): Promise<KumikoServerHandle> {
|
|
591
|
+
const port = options.port ?? Number(process.env["PORT"] ?? 4173);
|
|
592
|
+
|
|
593
|
+
// --- client bundles (single-entry oder multi-entry über dieselbe Map) ---
|
|
594
|
+
const entries = normalizeEntries(options);
|
|
595
|
+
const buildBundle = options._buildBundle ?? buildClient;
|
|
596
|
+
const clientBundles = new Map<string, ClientBundle>();
|
|
597
|
+
for (const e of entries) {
|
|
598
|
+
const bundle = await buildBundle(e.sourceFile);
|
|
599
|
+
clientBundles.set(e.name, bundle);
|
|
600
|
+
logInfo(
|
|
601
|
+
`[kumiko-server] client bundle ${e.name}: ${bundle.js.length.toLocaleString()} bytes` +
|
|
602
|
+
(bundle.map ? ` (+ ${bundle.map.length.toLocaleString()} bytes sourcemap)` : ""),
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// --- Tailwind stylesheet (optional) ---
|
|
607
|
+
// Tailwind-CLI läuft im watch-mode, schreibt in ein temp-file, der
|
|
608
|
+
// dev-server liest es bei jedem /styles.css-Request frisch. Nicht
|
|
609
|
+
// Super-Performant, aber keine in-memory-Signal-Gymnastik nötig
|
|
610
|
+
// und der Browser-Reload kommt eh nur nach Bundle-Rebuild.
|
|
611
|
+
//
|
|
612
|
+
// Default-Resolution: wenn kein `stylesheet` übergeben und ein
|
|
613
|
+
// `clientEntry` existiert, resolve die styles.css aus
|
|
614
|
+
// `@cosmicdrift/kumiko-renderer-web` via Package-Exports. Bun.resolveSync liefert
|
|
615
|
+
// einen absoluten Pfad — funktioniert sowohl im Monorepo (Workspace-
|
|
616
|
+
// Link) als auch in einer installierten Fremd-App (node_modules).
|
|
617
|
+
let stylesheetPath: string | undefined;
|
|
618
|
+
let killTailwind: (() => void) | undefined;
|
|
619
|
+
const resolvedStylesheet = resolveStylesheet(options);
|
|
620
|
+
if (resolvedStylesheet !== undefined) {
|
|
621
|
+
const handle = await startTailwindWatcher(resolvedStylesheet);
|
|
622
|
+
if (handle !== undefined) {
|
|
623
|
+
stylesheetPath = handle.outPath;
|
|
624
|
+
killTailwind = handle.kill;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// --- HTML templates ---
|
|
629
|
+
// Single-Entry: ein Template (htmlPath oder DEFAULT_HTML) für alles.
|
|
630
|
+
// Multi-Entry: pro Entry ein Template (entry.htmlPath ?? options.htmlPath
|
|
631
|
+
// ?? DEFAULT_HTML). Der hostDispatch wählt zur Request-Zeit.
|
|
632
|
+
const defaultTemplate =
|
|
633
|
+
options.htmlPath !== undefined
|
|
634
|
+
? await readFile(resolve(options.htmlPath), "utf-8")
|
|
635
|
+
: DEFAULT_HTML;
|
|
636
|
+
const htmlTemplates = new Map<string, string>();
|
|
637
|
+
for (const e of entries) {
|
|
638
|
+
htmlTemplates.set(
|
|
639
|
+
e.name,
|
|
640
|
+
e.htmlPath !== undefined ? await readFile(resolve(e.htmlPath), "utf-8") : defaultTemplate,
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// --- Kumiko stack ---
|
|
645
|
+
// KUMIKO_DEV_DB_NAME switches the underlying testDb from ephemeral
|
|
646
|
+
// (fresh kumiko_test_<random>, dropped on cleanup) to persistent
|
|
647
|
+
// (reuses the named DB across restarts). The var is framework-scoped
|
|
648
|
+
// on purpose — every dev-server pattern benefits from the same
|
|
649
|
+
// toggle, not just one sample.
|
|
650
|
+
const devDbName = process.env["KUMIKO_DEV_DB_NAME"];
|
|
651
|
+
const persistentDb = devDbName !== undefined && devDbName !== "";
|
|
652
|
+
|
|
653
|
+
logInfo(
|
|
654
|
+
`[kumiko-server] booting Kumiko stack${
|
|
655
|
+
persistentDb ? ` — persistent DB "${devDbName}"` : " — ephemeral test DB"
|
|
656
|
+
}…`,
|
|
657
|
+
);
|
|
658
|
+
const stack = await setupTestStack({
|
|
659
|
+
features: options.features,
|
|
660
|
+
...(persistentDb && { dbName: devDbName, persistentDb: true }),
|
|
661
|
+
...(options.auth !== undefined && { authConfig: options.auth }),
|
|
662
|
+
...(options.extraContext !== undefined && { extraContext: options.extraContext }),
|
|
663
|
+
...(options.anonymousAccess !== undefined && { anonymousAccess: options.anonymousAccess }),
|
|
664
|
+
});
|
|
665
|
+
await createEventsTable(stack.db);
|
|
666
|
+
await createEntityTablesForFeatures(stack, options.features);
|
|
667
|
+
|
|
668
|
+
// Hook für Caller-spezifische Tables + Seed. Läuft nach den Entity-
|
|
669
|
+
// Tabellen damit das Sample auf `stack.db` / `stack.dispatcher`
|
|
670
|
+
// zugreifen kann, und VOR dem Server-Start damit der erste HTTP-Request
|
|
671
|
+
// bereits gegen einen gefüllten State läuft. Idempotenz ist Caller-
|
|
672
|
+
// Verantwortung (persistent-DB-Modus läuft es bei jedem Boot).
|
|
673
|
+
if (options.onAfterSetup !== undefined) {
|
|
674
|
+
await options.onAfterSetup(stack);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// App-eigene HTTP-Routes ans Hono-app hängen — symmetrisch zur
|
|
678
|
+
// gleichnamigen Option in runProdApp. Wird vor dem dev-fallback
|
|
679
|
+
// (HTML/JS/CSS-Serving via handleFetch unten) registriert, damit
|
|
680
|
+
// explizite Routen wie /feed.xml den Asset-Pfad schlagen.
|
|
681
|
+
if (options.extraRoutes !== undefined) {
|
|
682
|
+
options.extraRoutes(stack.app, { db: stack.db, redis: stack.redis });
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// setupTestStack konfiguriert den eventDispatcher, startet ihn aber
|
|
686
|
+
// NICHT — Integration-Tests drain'en deterministisch via runOnce().
|
|
687
|
+
// Ein Dev-Server will das laufende Polling, damit SSE-Broadcasts
|
|
688
|
+
// (system-hook sse, Priorität 1001) von selbst an connected Clients
|
|
689
|
+
// fließen. Ohne start() bleiben alle Events in der events-Tabelle
|
|
690
|
+
// liegen und die Tabs sehen nichts.
|
|
691
|
+
if (stack.eventDispatcher) {
|
|
692
|
+
await stack.eventDispatcher.start();
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Dev user = TestUsers.admin. Demo features are openToAll but the
|
|
696
|
+
// auth-middleware still needs a valid JWT to let the request past.
|
|
697
|
+
// Nicht genutzt wenn `options.auth` gesetzt ist — dann macht der Client
|
|
698
|
+
// selbst den Login.
|
|
699
|
+
const autoMintJwt = options.auth === undefined;
|
|
700
|
+
const devUser = TestUsers.admin;
|
|
701
|
+
|
|
702
|
+
// AppSchema einmal beim Boot bauen. Sample-clients ohne explizites
|
|
703
|
+
// schema-Argument lesen das via window.__KUMIKO_SCHEMA__ aus — der
|
|
704
|
+
// dev-server injiziert das in jede HTML-Response. Re-build NICHT
|
|
705
|
+
// bei Hot-Reload weil sich Feature-Defs nur über einen restart
|
|
706
|
+
// ändern.
|
|
707
|
+
const appSchemaJson = JSON.stringify(buildAppSchema(stack.registry));
|
|
708
|
+
|
|
709
|
+
// --- SSE reload ---
|
|
710
|
+
// bootId identifiziert diese spezifische Server-Process-Instanz. Wird
|
|
711
|
+
// beim Connect an jeden Browser geschickt; Browser merkt sich den
|
|
712
|
+
// ersten Wert und refresht wenn beim Reconnect ein anderer kommt
|
|
713
|
+
// (= Server wurde restartet, alter JS-Bundle ist stale). Siehe
|
|
714
|
+
// RELOAD_SNIPPET oben.
|
|
715
|
+
const bootId = String(Date.now());
|
|
716
|
+
const reloadClients = new Set<ReloadClient>();
|
|
717
|
+
const broadcastReload = (): void => {
|
|
718
|
+
const payload = "event: reload\ndata: now\n\n";
|
|
719
|
+
for (const client of reloadClients) {
|
|
720
|
+
if (client.closed) continue;
|
|
721
|
+
try {
|
|
722
|
+
client.controller.enqueue(client.encoder.encode(payload));
|
|
723
|
+
} catch {
|
|
724
|
+
client.closed = true;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
// Build a fresh HTML response. Im Auto-Mint-Modus (keine auth-Config)
|
|
730
|
+
// packen wir direkt ein gültiges JWT + CSRF-Cookie rein — Deep-Links
|
|
731
|
+
// funktionieren sofort ohne Login. Im Auth-Modus serven wir nur die
|
|
732
|
+
// nackte HTML; der Client geht dann durch /auth/login und bekommt die
|
|
733
|
+
// Cookies von dort.
|
|
734
|
+
//
|
|
735
|
+
// entryName + injectSchemaForEntry werden vom Caller (handleFetch)
|
|
736
|
+
// bestimmt nachdem er hostDispatch evaluiert hat. Ohne hostDispatch
|
|
737
|
+
// ist es immer "client" mit Schema-Inject true (Single-Entry-Default
|
|
738
|
+
// damit der Client TypeScript-Schemas findet).
|
|
739
|
+
const htmlResponse = async (entryName: string, doInjectSchema: boolean): Promise<Response> => {
|
|
740
|
+
const template = htmlTemplates.get(entryName) ?? defaultTemplate;
|
|
741
|
+
const headers = new Headers();
|
|
742
|
+
headers.set("Content-Type", "text/html; charset=utf-8");
|
|
743
|
+
if (autoMintJwt) {
|
|
744
|
+
const jwt = await stack.jwt.sign(devUser);
|
|
745
|
+
const csrf = generateToken();
|
|
746
|
+
headers.append("Set-Cookie", `${AUTH_COOKIE}=${jwt}; Path=/; HttpOnly; SameSite=Lax`);
|
|
747
|
+
headers.append("Set-Cookie", `${CSRF_COOKIE}=${csrf}; Path=/; SameSite=Lax`);
|
|
748
|
+
}
|
|
749
|
+
let html = injectReload(template);
|
|
750
|
+
if (stylesheetPath !== undefined) html = injectStylesheet(html);
|
|
751
|
+
if (doInjectSchema) html = injectSchema(html, appSchemaJson);
|
|
752
|
+
return new Response(html, { headers });
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
// --- Fetch handler (runtime-neutral) ---
|
|
756
|
+
// Bundle-Pfad-Lookup: für jede Entry serven wir
|
|
757
|
+
// GET /client[-name].js → JS-Bundle
|
|
758
|
+
// GET /client[-name].js.map → Sourcemap
|
|
759
|
+
// assetPathFor() ist die Single-Source-of-Truth für die URL-Form.
|
|
760
|
+
const bundleByAssetPath = new Map<string, string>();
|
|
761
|
+
for (const e of entries) bundleByAssetPath.set(assetPathFor(e.name), e.name);
|
|
762
|
+
|
|
763
|
+
const handleFetch = async (req: Request): Promise<Response> => {
|
|
764
|
+
const url = new URL(req.url);
|
|
765
|
+
|
|
766
|
+
// Specific routes first — assets, reload-SSE, API.
|
|
767
|
+
if (req.method === "GET") {
|
|
768
|
+
const bundleName = bundleByAssetPath.get(url.pathname);
|
|
769
|
+
if (bundleName !== undefined) {
|
|
770
|
+
const bundle = clientBundles.get(bundleName);
|
|
771
|
+
if (bundle === undefined) return new Response("no bundle", { status: 404 });
|
|
772
|
+
return new Response(bundle.js, {
|
|
773
|
+
headers: { "Content-Type": "application/javascript; charset=utf-8" },
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
// .js.map-Variante: gleicher Lookup mit /.map abgeschnitten.
|
|
777
|
+
if (url.pathname.endsWith(".js.map")) {
|
|
778
|
+
const jsPath = url.pathname.slice(0, -".map".length);
|
|
779
|
+
const mapName = bundleByAssetPath.get(jsPath);
|
|
780
|
+
if (mapName !== undefined) {
|
|
781
|
+
const bundle = clientBundles.get(mapName);
|
|
782
|
+
if (bundle === undefined || !bundle.map) {
|
|
783
|
+
return new Response("no map", { status: 404 });
|
|
784
|
+
}
|
|
785
|
+
return new Response(bundle.map, {
|
|
786
|
+
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
if (url.pathname === "/styles.css" && req.method === "GET") {
|
|
793
|
+
if (stylesheetPath === undefined) return new Response("no stylesheet", { status: 404 });
|
|
794
|
+
const css = await readFile(stylesheetPath, "utf-8");
|
|
795
|
+
return new Response(css, {
|
|
796
|
+
headers: { "Content-Type": "text/css; charset=utf-8" },
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
if (url.pathname === "/_reload" && req.method === "GET") {
|
|
801
|
+
const encoder = new TextEncoder();
|
|
802
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
803
|
+
start(controller) {
|
|
804
|
+
const entry: ReloadClient = { controller, encoder, closed: false };
|
|
805
|
+
reloadClients.add(entry);
|
|
806
|
+
controller.enqueue(encoder.encode(": connected\n\n"));
|
|
807
|
+
// boot-Event: Browser-Snippet vergleicht das mit der ersten
|
|
808
|
+
// bootId. Verschiedener Wert nach Reconnect = Server wurde
|
|
809
|
+
// restartet → location.reload().
|
|
810
|
+
controller.enqueue(encoder.encode(`event: boot\ndata: ${bootId}\n\n`));
|
|
811
|
+
},
|
|
812
|
+
cancel() {
|
|
813
|
+
for (const c of reloadClients) {
|
|
814
|
+
if (c.closed) reloadClients.delete(c);
|
|
815
|
+
}
|
|
816
|
+
},
|
|
817
|
+
});
|
|
818
|
+
return new Response(stream, {
|
|
819
|
+
headers: {
|
|
820
|
+
"Content-Type": "text/event-stream",
|
|
821
|
+
"Cache-Control": "no-cache",
|
|
822
|
+
Connection: "keep-alive",
|
|
823
|
+
},
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// SPA catch-all: any GET to a non-API, non-asset path returns the
|
|
828
|
+
// HTML shell. The client-side router then reads location.pathname
|
|
829
|
+
// and mounts the right screen. The "no dot" filter skips
|
|
830
|
+
// /favicon.ico etc. (let the stack's 404 handler respond).
|
|
831
|
+
//
|
|
832
|
+
// Backend routes that live outside /api (currently just /sse) have
|
|
833
|
+
// to be excluded explicitly, otherwise the catch-all would shadow
|
|
834
|
+
// the real Hono route with HTML and EventSource would never
|
|
835
|
+
// connect.
|
|
836
|
+
//
|
|
837
|
+
// Plus: r.httpRoute-deklarierte Feature-Routes (z.B. /legal/*) liegen
|
|
838
|
+
// ebenfalls außerhalb /api und matchen sonst diesen catch-all. Wir
|
|
839
|
+
// probieren daher ZUERST stack.app.fetch — wenn Hono eine matchende
|
|
840
|
+
// Route hat, gewinnt sie. 404 vom Hono-stack → SPA-fallback wie
|
|
841
|
+
// bisher. Das spiegelt runProdApp's doc-intent ("Hono matched VOR
|
|
842
|
+
// staticDir-fallback") und macht r.httpRoute mit non-/api paths im
|
|
843
|
+
// dev-server symmetrisch zu prod.
|
|
844
|
+
if (
|
|
845
|
+
req.method === "GET" &&
|
|
846
|
+
!url.pathname.startsWith("/api/") &&
|
|
847
|
+
!url.pathname.startsWith("/sse") &&
|
|
848
|
+
!url.pathname.includes(".")
|
|
849
|
+
) {
|
|
850
|
+
const honoTry = await tryHonoFirst(stack.app, req);
|
|
851
|
+
if (honoTry.matched) {
|
|
852
|
+
return honoTry.response;
|
|
853
|
+
}
|
|
854
|
+
// Discriminated-Dispatch — symmetric zu prod. Ohne hostDispatch
|
|
855
|
+
// landet das im Single-Entry-Default ("client" + Schema-Inject).
|
|
856
|
+
if (options.hostDispatch !== undefined) {
|
|
857
|
+
const dispatch = options.hostDispatch(req);
|
|
858
|
+
if (dispatch.kind === "redirect") {
|
|
859
|
+
return new Response(null, {
|
|
860
|
+
status: dispatch.status ?? 302,
|
|
861
|
+
headers: { Location: dispatch.to },
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
if (dispatch.kind === "not-found") {
|
|
865
|
+
return new Response("Not Found", { status: 404 });
|
|
866
|
+
}
|
|
867
|
+
if (dispatch.kind === "static-html") {
|
|
868
|
+
// Raw-File-Serve, kein Bundle-Inject, kein Schema-Inject.
|
|
869
|
+
// Pendant zu prod's `{ kind: "html", file: ..., injectSchema: false }`.
|
|
870
|
+
const file = await readFile(dispatch.file, "utf-8");
|
|
871
|
+
return new Response(file, {
|
|
872
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
return htmlResponse(dispatch.entryName, dispatch.injectSchema ?? true);
|
|
876
|
+
}
|
|
877
|
+
return htmlResponse("client", true);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
return stack.app.fetch(req);
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
// --- HTTP server (Bun only) ---
|
|
884
|
+
// Under Node/vitest we skip Bun.serve entirely — the handle's
|
|
885
|
+
// .fetch() is the test surface. Real dev runs under Bun, where
|
|
886
|
+
// Bun.serve wires the listener.
|
|
887
|
+
// Bun.serve-Options kommen aus buildBunServeOptions (run-prod-app.ts)
|
|
888
|
+
// damit Dev und Prod genau dieselben SSE-relevanten Defaults nutzen
|
|
889
|
+
// (idleTimeout: 0). Spec-Test in run-prod-app-spec.test.ts pinst das.
|
|
890
|
+
const server = hasBun
|
|
891
|
+
? (globalThis as { Bun: { serve: (opts: unknown) => BunServer } }).Bun.serve(
|
|
892
|
+
buildBunServeOptions(port, handleFetch),
|
|
893
|
+
)
|
|
894
|
+
: undefined;
|
|
895
|
+
|
|
896
|
+
// --- file watcher → rebundle + reload, oder process-restart bei Schema-Änderungen ---
|
|
897
|
+
// Heuristik: alles in `web/` oder `__tests__/` ist client-side oder
|
|
898
|
+
// test-only — Hot-Reload reicht (rebuild + broadcast reload). Alles
|
|
899
|
+
// andere ist server-side; Bun cached die Module-Imports, also würde ein
|
|
900
|
+
// Schema-Change in feature.ts nicht durchschlagen ohne process-restart.
|
|
901
|
+
// Wir exiten dann mit Code 75 (EX_TEMPFAIL) — `kumiko-dev` Wrapper
|
|
902
|
+
// detected das und respawnt.
|
|
903
|
+
//
|
|
904
|
+
// watcherAbort wird beim stop() ausgelöst → fs.watch beendet die
|
|
905
|
+
// async-iteration → kein Watcher überlebt einen Test-Teardown und
|
|
906
|
+
// klassifiziert ein rmSync(tmpdir) als "restart needed".
|
|
907
|
+
const watcherAbort = new AbortController();
|
|
908
|
+
if (entries.length > 0) {
|
|
909
|
+
// Watch-Dirs: alle entry-Verzeichnisse (deduped) plus die explizit
|
|
910
|
+
// angegebenen watchDirs. In Multi-Entry-Setups liegen die Entries
|
|
911
|
+
// oft im selben src/-Verzeichnis (`src/client-admin.tsx` +
|
|
912
|
+
// `src/client-public.tsx`) — der Set kollabiert das auf einen
|
|
913
|
+
// Watcher pro Verzeichnis.
|
|
914
|
+
const entryDirs = new Set<string>();
|
|
915
|
+
for (const e of entries) entryDirs.add(resolve(e.sourceFile, ".."));
|
|
916
|
+
const dirs = [...entryDirs, ...expandWatchPatterns(options.watchDirs ?? [])];
|
|
917
|
+
for (const dir of dirs) {
|
|
918
|
+
void watchDir(
|
|
919
|
+
dir,
|
|
920
|
+
async (filename) => {
|
|
921
|
+
const action = classifyChange(filename);
|
|
922
|
+
if (action === "ignore") return;
|
|
923
|
+
if (action === "restart") {
|
|
924
|
+
logInfo(
|
|
925
|
+
`[kumiko-server] schema change in ${filename} — restarting (Bun caches imports, hot-reload reicht hier nicht)`,
|
|
926
|
+
);
|
|
927
|
+
await stop();
|
|
928
|
+
process.exit(75);
|
|
929
|
+
}
|
|
930
|
+
try {
|
|
931
|
+
// Alle Entries rebuilden — auch wenn nur eine Datei sich
|
|
932
|
+
// ändert, wir wissen nicht welche Entries sie importieren.
|
|
933
|
+
// Bei zwei Entries mit shared Code triggert ein Edit der
|
|
934
|
+
// gemeinsamen Datei beide Bundles neu, das ist gewollt.
|
|
935
|
+
for (const e of entries) {
|
|
936
|
+
const rebuilt = await buildBundle(e.sourceFile);
|
|
937
|
+
clientBundles.set(e.name, rebuilt);
|
|
938
|
+
}
|
|
939
|
+
logInfo(`[kumiko-server] rebuilt on ${filename}, broadcasting reload`);
|
|
940
|
+
broadcastReload();
|
|
941
|
+
} catch {
|
|
942
|
+
// buildClient already logged the failure; keep serving the
|
|
943
|
+
// last good bundle until the next successful rebuild.
|
|
944
|
+
}
|
|
945
|
+
},
|
|
946
|
+
watcherAbort.signal,
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const stop = async (): Promise<void> => {
|
|
952
|
+
// Watcher zuerst stoppen damit kein onChange während des Teardowns
|
|
953
|
+
// mehr feuert (sonst können tmpdir-rmSync ein process.exit(75)
|
|
954
|
+
// auslösen).
|
|
955
|
+
watcherAbort.abort();
|
|
956
|
+
if (killTailwind) killTailwind();
|
|
957
|
+
if (server !== undefined) {
|
|
958
|
+
(server as { stop: (closeActive?: boolean) => void }).stop(true);
|
|
959
|
+
}
|
|
960
|
+
if (stack.eventDispatcher) {
|
|
961
|
+
await stack.eventDispatcher.stop();
|
|
962
|
+
}
|
|
963
|
+
await stack.cleanup();
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
// --- graceful shutdown ---
|
|
967
|
+
// Signal handlers fire on Ctrl-C / kill. Without them, repeated dev
|
|
968
|
+
// restarts leak Postgres pools, lassen Tailwind-Watcher als orphan
|
|
969
|
+
// hängen und (in persistent mode) hinterlassen temp Clients.
|
|
970
|
+
// uncaughtException + unhandledRejection: Crashes hatten den Tailwind-
|
|
971
|
+
// Watcher nicht gekillt, der lief munter weiter im Hintergrund. Jetzt
|
|
972
|
+
// räumen wir auch im Fehlerfall auf bevor wir mit non-zero exit'n.
|
|
973
|
+
const installHandlers = options.installSignalHandlers ?? true;
|
|
974
|
+
if (installHandlers) {
|
|
975
|
+
for (const sig of ["SIGINT", "SIGTERM"] as const) {
|
|
976
|
+
process.on(sig, async () => {
|
|
977
|
+
logInfo(`[kumiko-server] ${sig} — cleaning up…`);
|
|
978
|
+
await stop();
|
|
979
|
+
process.exit(0);
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
process.on("uncaughtException", async (err) => {
|
|
983
|
+
logError("[kumiko-server] uncaughtException — cleaning up…", err);
|
|
984
|
+
try {
|
|
985
|
+
await stop();
|
|
986
|
+
} finally {
|
|
987
|
+
process.exit(1);
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
process.on("unhandledRejection", async (err) => {
|
|
991
|
+
logError("[kumiko-server] unhandledRejection — cleaning up…", err);
|
|
992
|
+
try {
|
|
993
|
+
await stop();
|
|
994
|
+
} finally {
|
|
995
|
+
process.exit(1);
|
|
996
|
+
}
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
if (server !== undefined) {
|
|
1001
|
+
logInfo(
|
|
1002
|
+
`[kumiko-server] listening on http://localhost:${port}` +
|
|
1003
|
+
(entries.length > 0
|
|
1004
|
+
? ` (hot reload on ${entries.length === 1 ? "client entry" : `${entries.length} entries`})`
|
|
1005
|
+
: ""),
|
|
1006
|
+
);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
return { fetch: handleFetch, server, stack, stop };
|
|
1010
|
+
}
|