@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.
- package/README.md +17 -0
- package/bin/kumiko-build.ts +3 -2
- package/bin/kumiko-dev.ts +1 -1
- package/bin/kumiko-init-deploy.ts +1 -1
- package/bin/kumiko-schema-check.ts +4 -1
- package/dist/build-server-bundle.d.ts +31 -0
- package/dist/build-server-bundle.js +282 -0
- package/dist/build.d.ts +2 -0
- package/dist/build.js +5 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +9 -0
- package/dist/codegen/index.d.ts +4 -0
- package/dist/codegen/index.js +4 -0
- package/dist/codegen/render.d.ts +55 -0
- package/dist/codegen/render.js +273 -0
- package/dist/codegen/run-codegen.d.ts +20 -0
- package/dist/codegen/run-codegen.js +219 -0
- package/dist/codegen/scan-events.d.ts +76 -0
- package/dist/codegen/scan-events.js +447 -0
- package/dist/codegen/watch.d.ts +33 -0
- package/dist/codegen/watch.js +112 -0
- package/dist/compose-stacks.d.ts +60 -0
- package/dist/compose-stacks.js +127 -0
- package/dist/crash-tracker.d.ts +17 -0
- package/dist/crash-tracker.js +37 -0
- package/dist/create-kumiko-server.d.ts +195 -0
- package/dist/create-kumiko-server.js +1040 -0
- package/dist/env-schema.d.ts +15 -0
- package/dist/env-schema.js +60 -0
- package/dist/few-shot-corpus.d.ts +72 -0
- package/dist/few-shot-corpus.js +262 -0
- package/dist/framework-version.d.ts +1 -0
- package/dist/framework-version.js +31 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +33 -0
- package/dist/init-deploy-cli.d.ts +10 -0
- package/dist/init-deploy-cli.js +111 -0
- package/dist/kebab.d.ts +1 -0
- package/{src/kebab.ts → dist/kebab.js} +2 -3
- package/dist/run-dev-app.d.ts +174 -0
- package/dist/run-dev-app.js +403 -0
- package/dist/scaffold-app-feature.d.ts +16 -0
- package/dist/scaffold-app-feature.js +240 -0
- package/dist/scaffold-app.d.ts +45 -0
- package/dist/scaffold-app.js +852 -0
- package/dist/scaffold-demo-tasks.d.ts +8 -0
- package/{src/scaffold-demo-tasks.ts → dist/scaffold-demo-tasks.js} +69 -98
- package/dist/scaffold-deploy.d.ts +92 -0
- package/dist/scaffold-deploy.js +254 -0
- package/dist/scaffold-feature.d.ts +26 -0
- package/dist/scaffold-feature.js +163 -0
- package/dist/schema-apply.d.ts +11 -0
- package/dist/schema-apply.js +95 -0
- package/dist/schema-check-core.d.ts +2 -0
- package/{src/schema-check-core.ts → dist/schema-check-core.js} +8 -10
- package/dist/schema-tables-auth-mode.d.ts +2 -0
- package/{src/schema-tables-auth-mode.ts → dist/schema-tables-auth-mode.js} +2 -10
- package/dist/schema-tables-minimal.d.ts +2 -0
- package/{src/schema-tables-minimal.ts → dist/schema-tables-minimal.js} +2 -11
- package/dist/setup-test-stack-from-features.d.ts +11 -0
- package/dist/setup-test-stack-from-features.js +42 -0
- package/dist/welcome-banner.d.ts +15 -0
- package/dist/welcome-banner.js +27 -0
- package/package.json +71 -24
- package/src/changes.json +6 -0
- package/src/__tests__/build-prod-bundle.integration.test.ts +0 -282
- package/src/__tests__/build-server-bundle.test.ts +0 -228
- package/src/__tests__/classify-change.test.ts +0 -87
- package/src/__tests__/compose-stacks.test.ts +0 -273
- package/src/__tests__/config-resolver-default.integration.test.ts +0 -165
- package/src/__tests__/crash-tracker.test.ts +0 -89
- package/src/__tests__/create-kumiko-server-errors.test.ts +0 -54
- package/src/__tests__/create-kumiko-server-options.test.ts +0 -33
- package/src/__tests__/create-kumiko-server.integration.test.ts +0 -1007
- package/src/__tests__/discover-format.test.ts +0 -32
- package/src/__tests__/env-schema.integration.test.ts +0 -248
- package/src/__tests__/env-schema.test.ts +0 -132
- package/src/__tests__/few-shot-corpus.test.ts +0 -316
- package/src/__tests__/framework-version.test.ts +0 -45
- package/src/__tests__/init-deploy-cli.test.ts +0 -113
- package/src/__tests__/kumiko-schema-check.test.ts +0 -56
- package/src/__tests__/merge-extra-context.test.ts +0 -67
- package/src/__tests__/resolve-stylesheet.test.ts +0 -132
- package/src/__tests__/run-dev-app-boot-validation.test.ts +0 -38
- package/src/__tests__/run-dev-app.integration.test.ts +0 -290
- package/src/__tests__/runtime-classification.test.ts +0 -26
- package/src/__tests__/saas-identity-wire.integration.test.ts +0 -427
- package/src/__tests__/scaffold-app-feature.test.ts +0 -216
- package/src/__tests__/scaffold-app.test.ts +0 -630
- package/src/__tests__/scaffold-demo-tasks.test.ts +0 -47
- package/src/__tests__/scaffold-deploy.test.ts +0 -464
- package/src/__tests__/scaffold-dev-cycle.integration.test.ts +0 -109
- package/src/__tests__/scaffold-feature.test.ts +0 -143
- package/src/__tests__/schema-apply.integration.test.ts +0 -191
- package/src/__tests__/setup-test-stack-from-features.integration.test.ts +0 -30
- package/src/__tests__/setup-test-stack-job-run-logger.integration.test.ts +0 -72
- package/src/__tests__/walkthrough.integration.test.ts +0 -140
- package/src/__tests__/welcome-banner.test.ts +0 -42
- package/src/build-server-bundle.ts +0 -346
- package/src/build.ts +0 -28
- package/src/codegen/__tests__/render-codegen.test.ts +0 -100
- package/src/codegen/__tests__/run-codegen.test.ts +0 -665
- package/src/codegen/__tests__/strict-mode-diagnostics.test.ts +0 -347
- package/src/codegen/__tests__/watch.test.ts +0 -222
- package/src/codegen/index.ts +0 -19
- package/src/codegen/render.ts +0 -305
- package/src/codegen/run-codegen.ts +0 -278
- package/src/codegen/scan-events.ts +0 -565
- package/src/codegen/watch.ts +0 -140
- package/src/compose-stacks.ts +0 -193
- package/src/crash-tracker.ts +0 -56
- package/src/create-kumiko-server.ts +0 -1397
- package/src/env-schema.ts +0 -72
- package/src/few-shot-corpus.ts +0 -370
- package/src/framework-version.ts +0 -39
- package/src/index.ts +0 -105
- package/src/init-deploy-cli.ts +0 -148
- package/src/run-dev-app.ts +0 -694
- package/src/scaffold-app-feature.ts +0 -283
- package/src/scaffold-app.ts +0 -1033
- package/src/scaffold-deploy.ts +0 -393
- package/src/scaffold-feature.ts +0 -226
- package/src/schema-apply.ts +0 -123
- package/src/setup-test-stack-from-features.ts +0 -80
- 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
|
-
}
|