@cosmicdrift/kumiko-dev-server 0.387.0 → 0.388.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/dist/build-server-bundle.d.ts +1 -1
- package/dist/build-server-bundle.js +2 -2
- package/dist/create-kumiko-server-client-bundles.d.ts +21 -0
- package/dist/create-kumiko-server-client-bundles.js +97 -0
- package/dist/create-kumiko-server-html.d.ts +21 -0
- package/dist/create-kumiko-server-html.js +132 -0
- package/dist/create-kumiko-server-public-files.d.ts +6 -0
- package/dist/create-kumiko-server-public-files.js +96 -0
- package/dist/create-kumiko-server-reload.d.ts +14 -0
- package/dist/create-kumiko-server-reload.js +73 -0
- package/dist/create-kumiko-server-routes.d.ts +16 -0
- package/dist/create-kumiko-server-routes.js +206 -0
- package/dist/create-kumiko-server-shutdown.d.ts +14 -0
- package/dist/create-kumiko-server-shutdown.js +72 -0
- package/dist/create-kumiko-server-stack.d.ts +12 -0
- package/dist/create-kumiko-server-stack.js +101 -0
- package/dist/create-kumiko-server-stylesheet.d.ts +10 -0
- package/dist/create-kumiko-server-stylesheet.js +137 -0
- package/dist/create-kumiko-server-watcher.d.ts +22 -0
- package/dist/create-kumiko-server-watcher.js +155 -0
- package/dist/create-kumiko-server.d.ts +6 -20
- package/dist/create-kumiko-server.js +59 -1046
- package/dist/dev-server-runtime.d.ts +5 -0
- package/dist/dev-server-runtime.js +11 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/managed-files/app-jobs.js +1 -1
- package/dist/managed-files/check.js +1 -1
- package/dist/run-dev-app-auth.d.ts +38 -0
- package/dist/run-dev-app-auth.js +155 -0
- package/dist/run-dev-app-config.d.ts +26 -0
- package/dist/run-dev-app-config.js +185 -0
- package/dist/run-dev-app-server-options.d.ts +13 -0
- package/dist/run-dev-app-server-options.js +49 -0
- package/dist/run-dev-app.d.ts +4 -4
- package/dist/run-dev-app.js +86 -459
- package/dist/schema-apply.d.ts +1 -1
- package/dist/schema-apply.js +4 -4
- package/dist/write-server-lockfile.js +23 -16
- package/package.json +9 -9
- package/src/changes.json +30 -0
- package/templates/managed/ci-public.yml.template +4 -0
|
@@ -14,1070 +14,83 @@
|
|
|
14
14
|
//
|
|
15
15
|
// The companion prod entry will land at `@cosmicdrift/kumiko-framework/server`
|
|
16
16
|
// with a different options shape (clientDist, auth config, db url).
|
|
17
|
-
import { spawn } from "node:child_process";
|
|
18
|
-
import { existsSync, mkdtempSync, realpathSync, 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 { AUTH_COOKIE_BASE, CSRF_COOKIE_BASE, createClientIpResolver, generateToken, sessionCookieName, } from "@cosmicdrift/kumiko-framework/api";
|
|
24
17
|
import { buildAppSchema } from "@cosmicdrift/kumiko-framework/engine";
|
|
25
|
-
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
26
|
-
import { rankPreferredLocales } from "@cosmicdrift/kumiko-framework/i18n";
|
|
27
|
-
import { createMetricsForFactory } from "@cosmicdrift/kumiko-framework/observability";
|
|
28
|
-
import { TestUsers } from "@cosmicdrift/kumiko-framework/seeding";
|
|
29
|
-
import { resolveAndInjectPageHead, } from "@cosmicdrift/kumiko-renderer/headless/apex";
|
|
30
|
-
import { startDevJobRunners } from "@cosmicdrift/kumiko-server-runtime/boot/job-run-logger";
|
|
31
18
|
import { buildBunServeOptions, resolveDerivedMaxRequestBodySize, } from "@cosmicdrift/kumiko-server-runtime/bun-serve-options";
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
export const logInfo = (msg) => console.log(msg);
|
|
49
|
-
// @wrapper-known semantic-alias
|
|
50
|
-
// biome-ignore lint/suspicious/noConsole: dev-server error logging
|
|
51
|
-
const logError = (...args) => console.error(...args);
|
|
52
|
-
// The dev server speaks plain http, so its minted cookies are never Secure and keep the unprefixed names.
|
|
53
|
-
const CSRF_COOKIE = sessionCookieName(CSRF_COOKIE_BASE, { secure: false });
|
|
54
|
-
const AUTH_COOKIE = sessionCookieName(AUTH_COOKIE_BASE, { secure: false });
|
|
55
|
-
// Duplicated in packages/testing/src/e2e/constants.ts: the Playwright config
|
|
56
|
-
// runs under Node and cannot import this module.
|
|
57
|
-
export const STYLESHEET_WATCH_ENV = "KUMIKO_DEV_STYLESHEET_WATCH";
|
|
58
|
-
// Duplicated in packages/testing/src/e2e/constants.ts: the Playwright config
|
|
59
|
-
// runs under Node and cannot import this module.
|
|
60
|
-
export const PROD_BUNDLES_ENV = "KUMIKO_DEV_PROD_BUNDLES";
|
|
61
|
-
// Reload snippet injected into every page-load so the browser
|
|
62
|
-
// subscribes to /_reload without the HTML needing to hard-code it.
|
|
63
|
-
//
|
|
64
|
-
// Two reload triggers:
|
|
65
|
-
// - explicit `reload` event from the server after hot reload (rebuild + send)
|
|
66
|
-
// - implicit `boot` event on every SSE connection, containing the current
|
|
67
|
-
// server process's bootId. The snippet remembers the first bootId; if a
|
|
68
|
-
// reconnect receives a different one, it refreshes. Otherwise the
|
|
69
|
-
// browser could remain on a stale bundle after a server restart.
|
|
70
|
-
const RELOAD_SNIPPET = `
|
|
71
|
-
<script>
|
|
72
|
-
(() => {
|
|
73
|
-
const es = new EventSource("/_reload");
|
|
74
|
-
let firstBootId = null;
|
|
75
|
-
es.addEventListener("boot", (e) => {
|
|
76
|
-
if (firstBootId === null) {
|
|
77
|
-
firstBootId = e.data;
|
|
78
|
-
} else if (firstBootId !== e.data) {
|
|
79
|
-
location.reload();
|
|
80
|
-
}
|
|
81
|
-
});
|
|
82
|
-
es.addEventListener("reload", () => location.reload());
|
|
83
|
-
})();
|
|
84
|
-
</script>
|
|
85
|
-
`;
|
|
86
|
-
// Minimal HTML when the caller didn't hand one in. `#root` is the
|
|
87
|
-
// default mount target for `createKumikoApp`, so the one-line client
|
|
88
|
-
// can attach without the sample having to ship its own template.
|
|
89
|
-
//
|
|
90
|
-
// `type="module"` is load-bearing: a classic script turns the bundle's
|
|
91
|
-
// top-level declarations into window properties, so a dependency exporting
|
|
92
|
-
// `function history()` (prosemirror-history, pulled in by tiptap) silently
|
|
93
|
-
// replaces `window.history` and every pushState afterwards throws. Prod
|
|
94
|
-
// (build-prod-bundle) has always emitted the module form.
|
|
95
|
-
//
|
|
96
|
-
// `interactive-widget=resizes-content`: without it, a mobile
|
|
97
|
-
// keyboard shrinks only the visual viewport, not the layout viewport that
|
|
98
|
-
// `position: fixed` anchors to — a fixed bottom action bar would stay
|
|
99
|
-
// pinned behind the keyboard instead of above it. iOS Safari ignores the
|
|
100
|
-
// key; renderer/web's useKeyboardInset covers it via window.visualViewport.
|
|
101
|
-
export const DEFAULT_HTML = `<!doctype html>
|
|
102
|
-
<html lang="en">
|
|
103
|
-
<head>
|
|
104
|
-
<meta charset="utf-8" />
|
|
105
|
-
<meta name="viewport" content="width=device-width,initial-scale=1,interactive-widget=resizes-content" />
|
|
106
|
-
<title>Kumiko</title>
|
|
107
|
-
</head>
|
|
108
|
-
<body>
|
|
109
|
-
<div id="root"></div>
|
|
110
|
-
<script type="module" src="/client.js"></script>
|
|
111
|
-
</body>
|
|
112
|
-
</html>
|
|
113
|
-
`;
|
|
114
|
-
async function buildClient(entry) {
|
|
115
|
-
if (!hasBun) {
|
|
116
|
-
throw new Error("[kumiko-server] clientEntry is only supported under Bun — Bun.build is unavailable in this runtime.");
|
|
117
|
-
}
|
|
118
|
-
const unminified = process.env["KUMIKO_DEV_UNMINIFIED"] === "1";
|
|
119
|
-
// Same splitting/sourcemap/NODE_ENV as build-prod-bundle.ts, opt-in via
|
|
120
|
-
// env so `bun dev` stays unchanged.
|
|
121
|
-
const prodShaped = process.env[PROD_BUNDLES_ENV] === "1";
|
|
122
|
-
const built = await Bun.build({
|
|
123
|
-
entrypoints: [entry],
|
|
124
|
-
target: "browser",
|
|
125
|
-
minify: !unminified,
|
|
126
|
-
sourcemap: prodShaped ? "none" : "linked",
|
|
127
|
-
splitting: prodShaped,
|
|
128
|
-
...(prodShaped ? { define: { "process.env.NODE_ENV": JSON.stringify("production") } } : {}),
|
|
129
|
-
});
|
|
130
|
-
if (!built.success) {
|
|
131
|
-
logError("[kumiko-server] client bundle failed:");
|
|
132
|
-
for (const log of built.logs)
|
|
133
|
-
logError(log);
|
|
134
|
-
throw new Error("client bundle failed");
|
|
135
|
-
}
|
|
136
|
-
const jsOutput = built.outputs.find((o) => o.kind === "entry-point");
|
|
137
|
-
const mapOutput = built.outputs.find((o) => o.path.endsWith(".js.map"));
|
|
138
|
-
if (!jsOutput)
|
|
139
|
-
throw new Error("[kumiko-server] client bundle produced no .js output");
|
|
140
|
-
const chunks = new Map();
|
|
141
|
-
for (const output of built.outputs) {
|
|
142
|
-
if (output.kind !== "chunk")
|
|
143
|
-
continue;
|
|
144
|
-
const chunkPath = `/${output.path.replace(/^\.\//, "")}`;
|
|
145
|
-
chunks.set(chunkPath, await output.text());
|
|
146
|
-
}
|
|
147
|
-
return {
|
|
148
|
-
js: await jsOutput.text(),
|
|
149
|
-
map: mapOutput ? await mapOutput.text() : "",
|
|
150
|
-
chunks,
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
function injectReload(html) {
|
|
154
|
-
if (html.includes("/_reload"))
|
|
155
|
-
return html;
|
|
156
|
-
return html.includes("</body>")
|
|
157
|
-
? html.replace("</body>", `${RELOAD_SNIPPET}</body>`)
|
|
158
|
-
: html + RELOAD_SNIPPET;
|
|
159
|
-
}
|
|
160
|
-
// Injects <link rel="stylesheet" href="/styles.css"> into <head> if it
|
|
161
|
-
// is not already present. Called only when the stylesheet option is used;
|
|
162
|
-
// otherwise no CSS route is provided.
|
|
163
|
-
function injectStylesheet(html) {
|
|
164
|
-
if (html.includes('href="/styles.css"'))
|
|
165
|
-
return html;
|
|
166
|
-
const link = '<link rel="stylesheet" href="/styles.css" />';
|
|
167
|
-
return html.includes("</head>")
|
|
168
|
-
? html.replace("</head>", ` ${link}\n</head>`)
|
|
169
|
-
: `${link}${html}`;
|
|
170
|
-
}
|
|
171
|
-
// GET/HEAD to a non-API, non-SSE path — the dispatch-worthy set shared by
|
|
172
|
-
// the SPA catch-all and the public/-file lookup below (they split on
|
|
173
|
-
// whether the path has a dot).
|
|
174
|
-
function isRoutableGetOrHead(req, pathname) {
|
|
175
|
-
return ((req.method === "GET" || req.method === "HEAD") &&
|
|
176
|
-
!pathname.startsWith("/api/") &&
|
|
177
|
-
!pathname.startsWith("/sse"));
|
|
178
|
-
}
|
|
179
|
-
// Resolves a request pathname to a file inside publicDir, or undefined if
|
|
180
|
-
// it isn't one. Two traversal vectors, both must be blocked:
|
|
181
|
-
// - literal ".." segments (new URL() already collapses these in
|
|
182
|
-
// `pathname`, but resolve()+containment is checked regardless — no
|
|
183
|
-
// path-safety may depend on caller behavior upstream).
|
|
184
|
-
// - percent-encoded slashes (e.g. "%2e%2e%2f"): WHATWG URL parsing only
|
|
185
|
-
// normalizes dot-segments that are delimited by a literal "/", so an
|
|
186
|
-
// encoded slash survives into `pathname` untouched. decodeURIComponent
|
|
187
|
-
// resolves it to "../" before the containment check runs.
|
|
188
|
-
function resolvePublicFilePath(pathname, publicDir) {
|
|
189
|
-
let decoded;
|
|
190
|
-
try {
|
|
191
|
-
decoded = decodeURIComponent(pathname);
|
|
192
|
-
}
|
|
193
|
-
catch {
|
|
194
|
-
return undefined;
|
|
195
|
-
}
|
|
196
|
-
// A NUL byte makes readFile throw ERR_INVALID_ARG_VALUE (not ENOENT), which
|
|
197
|
-
// would surface as a 500 instead of a router miss.
|
|
198
|
-
if (decoded.includes("\0"))
|
|
199
|
-
return undefined;
|
|
200
|
-
const resolved = resolve(publicDir, `.${decoded}`);
|
|
201
|
-
return resolved === publicDir || resolved.startsWith(publicDir + sep) ? resolved : undefined;
|
|
202
|
-
}
|
|
203
|
-
// Same extension → content-type mapping as prod's
|
|
204
|
-
// run-prod-app-static-files.ts#mimeTypeFor — that function isn't part of
|
|
205
|
-
// server-runtime's public exports (its own package.json "exports" map),
|
|
206
|
-
// so this is a small self-contained copy rather than a new cross-package
|
|
207
|
-
// export for one call site.
|
|
208
|
-
const PUBLIC_FILE_MIME_TYPES = new Map([
|
|
209
|
-
["html", "text/html; charset=utf-8"],
|
|
210
|
-
["js", "text/javascript; charset=utf-8"],
|
|
211
|
-
["mjs", "text/javascript; charset=utf-8"],
|
|
212
|
-
["css", "text/css; charset=utf-8"],
|
|
213
|
-
["json", "application/json; charset=utf-8"],
|
|
214
|
-
["svg", "image/svg+xml"],
|
|
215
|
-
["png", "image/png"],
|
|
216
|
-
["jpg", "image/jpeg"],
|
|
217
|
-
["jpeg", "image/jpeg"],
|
|
218
|
-
["ico", "image/x-icon"],
|
|
219
|
-
["txt", "text/plain; charset=utf-8"],
|
|
220
|
-
["xml", "application/xml; charset=utf-8"],
|
|
221
|
-
["webmanifest", "application/manifest+json"],
|
|
222
|
-
["woff2", "font/woff2"],
|
|
223
|
-
]);
|
|
224
|
-
function publicFileMimeType(filePath) {
|
|
225
|
-
const ext = filePath.toLowerCase().split(".").pop() ?? "";
|
|
226
|
-
return PUBLIC_FILE_MIME_TYPES.get(ext) ?? "application/octet-stream";
|
|
227
|
-
}
|
|
228
|
-
// Reads a file under publicDir for the dev-server's static-asset fallback
|
|
229
|
-
// (dev-parity with prod's disk lookup in buildStaticFallback). undefined
|
|
230
|
-
// means "not a servable file here" — caller treats that as a router miss,
|
|
231
|
-
// same as ENOENT/EISDIR/ENOTDIR further down.
|
|
232
|
-
async function servePublicFile(pathname, publicDir) {
|
|
233
|
-
const filePath = resolvePublicFilePath(pathname, publicDir);
|
|
234
|
-
if (filePath === undefined)
|
|
235
|
-
return undefined;
|
|
236
|
-
try {
|
|
237
|
-
// Lexical containment (above) only catches "..", not a symlink inside
|
|
238
|
-
// publicDir that points outside it on disk — realpath resolves the
|
|
239
|
-
// actual target and the same containment check runs against it. Read
|
|
240
|
-
// from the resolved path so the checked target is the one read.
|
|
241
|
-
const real = await realpath(filePath);
|
|
242
|
-
if (real !== publicDir && !real.startsWith(publicDir + sep))
|
|
243
|
-
return undefined;
|
|
244
|
-
const bytes = await readFile(real);
|
|
245
|
-
return { bytes, mime: publicFileMimeType(filePath) };
|
|
246
|
-
}
|
|
247
|
-
catch (err) {
|
|
248
|
-
const code = errorCodeOf(err);
|
|
249
|
-
if (code === "ENOENT" || code === "EISDIR" || code === "ENOTDIR" || code === "ENAMETOOLONG") {
|
|
250
|
-
return undefined;
|
|
251
|
-
}
|
|
252
|
-
throw err;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
// Static assets under public/ — prod serves these via buildStaticFallback's
|
|
256
|
-
// disk lookup (run-prod-app-static-files.ts), dev previously had no
|
|
257
|
-
// equivalent: a dotted path (e.g. /marketing/hero.png) fell straight
|
|
258
|
-
// through to the API stack and 404ed. Hono still goes first (an
|
|
259
|
-
// r.httpRoute could itself own a dotted path), then the file on disk,
|
|
260
|
-
// then the router-miss 404 — mirrors handleFetch's SPA branch. undefined
|
|
261
|
-
// means "not a static-asset request", caller falls through to the next
|
|
262
|
-
// route.
|
|
263
|
-
async function tryServePublicAsset(req, pathname, app, publicDir, socketAddress) {
|
|
264
|
-
if (!isRoutableGetOrHead(req, pathname) || !pathname.includes("."))
|
|
265
|
-
return undefined;
|
|
266
|
-
const honoTry = await tryHonoFirst(app, req, socketAddress);
|
|
267
|
-
if (honoTry.matched) {
|
|
268
|
-
return honoTry.response;
|
|
269
|
-
}
|
|
270
|
-
const file = await servePublicFile(pathname, publicDir);
|
|
271
|
-
if (file !== undefined) {
|
|
272
|
-
// @cast-boundary Buffer satisfies BodyInit at runtime, bun-types
|
|
273
|
-
// just doesn't say so — same cast run-prod-app-static-files.ts uses.
|
|
274
|
-
return new Response(file.bytes, {
|
|
275
|
-
headers: { "Content-Type": file.mime },
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
return honoTry.response;
|
|
279
|
-
}
|
|
280
|
-
async function watchDir(dir, onChange, signal, watchEvents) {
|
|
281
|
-
// The server's stop() aborts the signal; without it, the for-await loop
|
|
282
|
-
// would continue until process exit. During test teardown, removing the
|
|
283
|
-
// temporary directory could then be classified as a restart and call
|
|
284
|
-
// process.exit(75), surfacing as an unhandled Vitest error.
|
|
285
|
-
const watcher = watchEvents(dir, { recursive: true, signal });
|
|
286
|
-
try {
|
|
287
|
-
for await (const ev of watcher) {
|
|
288
|
-
if (ev.filename)
|
|
289
|
-
onChange(ev.filename);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
catch (err) {
|
|
293
|
-
// signal.abort() causes the async iterator to throw AbortError during
|
|
294
|
-
// expected teardown; other errors must propagate.
|
|
295
|
-
// skip: AbortSignal fired the abort, this is expected teardown not a real error
|
|
296
|
-
if (typeof err === "object" && err !== null && "name" in err && err.name === "AbortError")
|
|
297
|
-
return;
|
|
298
|
-
throw err;
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
// Classifies a changed file as `hot-reload` for client-side changes,
|
|
302
|
-
// `restart` for server-side changes whose cached Bun modules must be
|
|
303
|
-
// reloaded, or `ignore` when the server is unaffected.
|
|
304
|
-
//
|
|
305
|
-
// Tests and non-TypeScript files are ignored. TypeScript files under
|
|
306
|
-
// `/web/`, `/admin/`, `/public/`, or `/client/`, as well as the client
|
|
307
|
-
// entry itself, trigger hot reload; other TypeScript files trigger a
|
|
308
|
-
// restart because they may contain schema or feature definitions.
|
|
309
|
-
//
|
|
310
|
-
// The multiple client directories are intentional: samples use different
|
|
311
|
-
// layouts. Restarting for a client-only edit would be slower, drop the
|
|
312
|
-
// ephemeral test database, and reseed it unnecessarily.
|
|
313
|
-
//
|
|
314
|
-
// Exported for tests; internally called by the watcher loop.
|
|
315
|
-
export function classifyChange(filename) {
|
|
316
|
-
if (!filename.endsWith(".ts") && !filename.endsWith(".tsx"))
|
|
317
|
-
return "ignore";
|
|
318
|
-
if (filename.includes("__tests__"))
|
|
319
|
-
return "ignore";
|
|
320
|
-
if (filename.endsWith(".test.ts") || filename.endsWith(".test.tsx"))
|
|
321
|
-
return "ignore";
|
|
322
|
-
if (filename.endsWith(".integration.ts") ||
|
|
323
|
-
filename.endsWith(".integration.test.ts") ||
|
|
324
|
-
filename.endsWith(".e2e.ts")) {
|
|
325
|
-
return "ignore";
|
|
326
|
-
}
|
|
327
|
-
// Platform-independent: checks both POSIX and Windows separators.
|
|
328
|
-
// Matches `<sep><dir><sep>` and trailing-`<sep><dir>` for watcher
|
|
329
|
-
// filenames supplied as relative paths.
|
|
330
|
-
const clientSubdirs = ["web", "admin", "public", "client"];
|
|
331
|
-
for (const dir of clientSubdirs) {
|
|
332
|
-
if (filename.includes(`/${dir}/`) ||
|
|
333
|
-
filename.includes(`\\${dir}\\`) ||
|
|
334
|
-
filename.startsWith(`${dir}/`) ||
|
|
335
|
-
filename.startsWith(`${dir}\\`)) {
|
|
336
|
-
return "hot-reload";
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
if (filename.endsWith("/client.tsx") || filename.endsWith("/client.ts")) {
|
|
340
|
-
return "hot-reload";
|
|
341
|
-
}
|
|
342
|
-
return "restart";
|
|
343
|
-
}
|
|
344
|
-
// The watcher reports paths relative to the watched directory, so an entry arrives as a bare
|
|
345
|
-
// "client.tsx" (or a custom name like "client-admin.tsx") that classifyChange alone reads as a
|
|
346
|
-
// server file and answers with a restart.
|
|
347
|
-
export function classifyWatchedChange(watchedDir, filename, entrySourceFiles) {
|
|
348
|
-
return entrySourceFiles.has(resolve(watchedDir, filename))
|
|
349
|
-
? "hot-reload"
|
|
350
|
-
: classifyChange(filename);
|
|
351
|
-
}
|
|
352
|
-
// Expands watchDirs patterns into concrete directories. Entries without
|
|
353
|
-
// `*` are resolved as ordinary paths; entries with `*` are glob-expanded
|
|
354
|
-
// and only directory matches are retained, so `"../../../packages/*/src"`
|
|
355
|
-
// replaces a list of hard-coded paths. Expansion runs once at boot,
|
|
356
|
-
// not while the watcher is running.
|
|
357
|
-
function expandWatchPatterns(patterns) {
|
|
358
|
-
const out = [];
|
|
359
|
-
for (const p of patterns) {
|
|
360
|
-
if (!p.includes("*")) {
|
|
361
|
-
out.push(resolve(p));
|
|
362
|
-
continue;
|
|
363
|
-
}
|
|
364
|
-
// Only reachable under Bun (createKumikoServer is Bun-only); without Bun the
|
|
365
|
-
// call stack fails fast in Bun.serve anyway.
|
|
366
|
-
if (typeof Bun === "undefined")
|
|
367
|
-
throw new Error("expandWatchPatterns requires Bun.Glob");
|
|
368
|
-
const glob = new Bun.Glob(p);
|
|
369
|
-
const matches = Array.from(glob.scanSync({ onlyFiles: false, cwd: process.cwd() }));
|
|
370
|
-
for (const m of matches) {
|
|
371
|
-
const abs = resolve(m);
|
|
372
|
-
try {
|
|
373
|
-
if (statSync(abs).isDirectory())
|
|
374
|
-
out.push(abs);
|
|
375
|
-
}
|
|
376
|
-
catch {
|
|
377
|
-
// ignore unreadable matches — typisch defekte Symlinks
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
return out;
|
|
382
|
-
}
|
|
383
|
-
// Resolves the path to the Tailwind entry CSS:
|
|
384
|
-
// - explicit string → the resolved absolute path
|
|
385
|
-
// - false → CSS pipeline off (undefined)
|
|
386
|
-
// - undefined + client entries:
|
|
387
|
-
// 1. the app's own src/styles.css (theme override) if present
|
|
388
|
-
// 2. else the default `@cosmicdrift/kumiko-renderer/web/styles.css` via package exports
|
|
389
|
-
// - undefined + no client entry: undefined (no CSS needed)
|
|
390
|
-
//
|
|
391
|
-
// The src/styles.css auto-detection mirrors build-prod-bundle:resolveStylesheetEntry
|
|
392
|
-
// so dev and prod resolve identically. Without it every app would have to set
|
|
393
|
-
// `stylesheet: "./src/styles.css"`, or dev would fall back to the default and
|
|
394
|
-
// ignore the brand tokens.
|
|
395
|
-
//
|
|
396
|
-
// @internal — exported for unit tests only, not re-exported from the package index.
|
|
397
|
-
export function resolveStylesheet(options) {
|
|
398
|
-
if (options.stylesheet === false)
|
|
399
|
-
return undefined;
|
|
400
|
-
if (typeof options.stylesheet === "string")
|
|
401
|
-
return resolve(options.stylesheet);
|
|
402
|
-
const hasAnyEntry = options.clientEntry !== undefined ||
|
|
403
|
-
(options.clientEntries !== undefined && options.clientEntries.length > 0);
|
|
404
|
-
if (!hasAnyEntry)
|
|
19
|
+
import { stripNoRouteMatchHeader } from "@cosmicdrift/kumiko-server-runtime/try-hono-first";
|
|
20
|
+
import { buildClient, buildClientBundles, normalizeEntries, } from "./create-kumiko-server-client-bundles.js";
|
|
21
|
+
import { loadHtmlTemplates } from "./create-kumiko-server-html.js";
|
|
22
|
+
import { createReloadHub } from "./create-kumiko-server-reload.js";
|
|
23
|
+
import { createDevFetchHandler } from "./create-kumiko-server-routes.js";
|
|
24
|
+
import { createDevServerStop, installDevSignalHandlers } from "./create-kumiko-server-shutdown.js";
|
|
25
|
+
import { bootDevStack, resolveDevDbMode, startDevBackgroundRunners, } from "./create-kumiko-server-stack.js";
|
|
26
|
+
import { startDevStylesheet } from "./create-kumiko-server-stylesheet.js";
|
|
27
|
+
import { startDevFileWatchers } from "./create-kumiko-server-watcher.js";
|
|
28
|
+
import { hasBun, logInfo } from "./dev-server-runtime.js";
|
|
29
|
+
// Under Node the handle's .fetch() is the test surface. Bun.serve options come
|
|
30
|
+
// from buildBunServeOptions (run-prod-app.ts) so development and production
|
|
31
|
+
// share the same SSE-relevant defaults (idleTimeout: 0);
|
|
32
|
+
// run-prod-app-spec.test.ts pins that.
|
|
33
|
+
function startDevHttpServer(port, handleFetch, stack) {
|
|
34
|
+
if (!hasBun)
|
|
405
35
|
return undefined;
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
if (existsSync(local))
|
|
410
|
-
return local;
|
|
411
|
-
// Bun.resolveSync follows package exports: `"./styles.css"` in
|
|
412
|
-
// renderer/web's package.json resolves to the workspace file in the
|
|
413
|
-
// monorepo or the node_modules file in an installed app. Bun already
|
|
414
|
-
// returns an absolute filesystem path, so `fileURLToPath` is unnecessary.
|
|
415
|
-
if (!hasBun) {
|
|
416
|
-
// Node-based unit tests reach this branch. Without Bun, package-export
|
|
417
|
-
// resolution and a real Tailwind pipeline are unavailable, so skip
|
|
418
|
-
// silently rather than reporting an error.
|
|
419
|
-
return undefined;
|
|
420
|
-
}
|
|
421
|
-
try {
|
|
422
|
-
const resolved = Bun.resolveSync("@cosmicdrift/kumiko-renderer/web/styles.css", process.cwd());
|
|
423
|
-
if (!canResolveTailwindStylesheet(resolved, { bun: Bun, cwd: process.cwd() })) {
|
|
424
|
-
return undefined;
|
|
425
|
-
}
|
|
426
|
-
return resolved;
|
|
427
|
-
}
|
|
428
|
-
catch (err) {
|
|
429
|
-
logError("[kumiko-server] couldn't auto-resolve @cosmicdrift/kumiko-renderer/web/styles.css — " +
|
|
430
|
-
"pass `stylesheet: <path>` or `stylesheet: false` explicitly.", err);
|
|
431
|
-
return undefined;
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
// The E2E template can only reach an app's own server entry through its env,
|
|
435
|
-
// so the env has to count whenever the option is left undefined.
|
|
436
|
-
//
|
|
437
|
-
// @internal — exported for unit tests only, not re-exported from the index.
|
|
438
|
-
export function resolveStylesheetWatch(options, env = process.env) {
|
|
439
|
-
if (options.stylesheetWatch !== undefined)
|
|
440
|
-
return options.stylesheetWatch;
|
|
441
|
-
return env[STYLESHEET_WATCH_ENV] !== "0";
|
|
442
|
-
}
|
|
443
|
-
// Non-fatal by design (returns undefined): if the CLI can't be resolved or
|
|
444
|
-
// the initial build fails (flaky network, missing dependency), the server
|
|
445
|
-
// boots without CSS instead of dying.
|
|
446
|
-
async function startTailwindPipeline(entryCss, { watch }) {
|
|
447
|
-
const bunResolver = hasBun ? Bun : undefined;
|
|
448
|
-
const cliPath = resolveTailwindCli({ bun: bunResolver, cwd: process.cwd() });
|
|
449
|
-
if (cliPath === undefined) {
|
|
450
|
-
logError("[kumiko-server] @tailwindcss/cli nicht auflösbar — booting ohne CSS-Pipeline. " +
|
|
451
|
-
"`bun install` und Restart, um Styles zu aktivieren.");
|
|
452
|
-
return undefined;
|
|
453
|
-
}
|
|
454
|
-
const outDir = mkdtempSync(join(tmpdir(), "kumiko-tw-"));
|
|
455
|
-
const outPath = join(outDir, "styles.css");
|
|
456
|
-
logInfo(watch
|
|
457
|
-
? `[kumiko-server] tailwind watcher → ${outPath}`
|
|
458
|
-
: `[kumiko-server] tailwind one-shot build → ${outPath}`);
|
|
459
|
-
const bunPath = process.argv[0] ?? "bun";
|
|
460
|
-
// Wait for the initial build so the first /styles.css request does not
|
|
461
|
-
// return 404. Then run the watcher in the background with unref();
|
|
462
|
-
// otherwise it could remain as an orphan after the parent crashes.
|
|
463
|
-
try {
|
|
464
|
-
await new Promise((resolvePromise, rejectPromise) => {
|
|
465
|
-
const child = spawn(bunPath, ["run", cliPath, "-i", entryCss, "-o", outPath], {
|
|
466
|
-
stdio: "inherit",
|
|
467
|
-
});
|
|
468
|
-
child.on("exit", (code) => {
|
|
469
|
-
if (code === 0)
|
|
470
|
-
resolvePromise();
|
|
471
|
-
else
|
|
472
|
-
rejectPromise(new Error(`tailwind one-shot-build exit ${code}`));
|
|
473
|
-
});
|
|
474
|
-
child.on("error", rejectPromise);
|
|
475
|
-
});
|
|
476
|
-
}
|
|
477
|
-
catch (err) {
|
|
478
|
-
logError("[kumiko-server] tailwind one-shot-build fehlgeschlagen — booting ohne CSS:", err);
|
|
479
|
-
return undefined;
|
|
480
|
-
}
|
|
481
|
-
if (!watch) {
|
|
482
|
-
return { outPath, kill: () => { } };
|
|
483
|
-
}
|
|
484
|
-
const watcher = spawn(bunPath, ["run", cliPath, "-i", entryCss, "-o", outPath, "--watch"], {
|
|
485
|
-
stdio: "inherit",
|
|
486
|
-
});
|
|
487
|
-
watcher.unref();
|
|
488
|
-
return {
|
|
489
|
-
outPath,
|
|
490
|
-
kill: () => {
|
|
491
|
-
try {
|
|
492
|
-
watcher.kill("SIGTERM");
|
|
493
|
-
}
|
|
494
|
-
catch {
|
|
495
|
-
// Already stopped or never started; no further action is needed.
|
|
496
|
-
}
|
|
497
|
-
},
|
|
498
|
-
};
|
|
499
|
-
}
|
|
500
|
-
/** URL path used to serve an entry: `"client"` → `/client.js`
|
|
501
|
-
* (the single-entry default), otherwise `/client-<name>.js`. Keeping
|
|
502
|
-
* this in one place ensures routing and logging use the same convention. */
|
|
503
|
-
function assetPathFor(entryName) {
|
|
504
|
-
return entryName === "client" ? "/client.js" : `/client-${entryName}.js`;
|
|
505
|
-
}
|
|
506
|
-
function normalizeEntries(options) {
|
|
507
|
-
if (options.clientEntries !== undefined && options.clientEntry !== undefined) {
|
|
508
|
-
throw new Error("[kumiko-server] clientEntry und clientEntries sind mutually exclusive — wähle eins");
|
|
509
|
-
}
|
|
510
|
-
if (options.clientEntries !== undefined && options.clientEntries.length > 0) {
|
|
511
|
-
if (options.hostDispatch === undefined) {
|
|
512
|
-
throw new Error("[kumiko-server] clientEntries braucht hostDispatch — sonst weiß der Server nicht welches Template er liefern soll");
|
|
513
|
-
}
|
|
514
|
-
return options.clientEntries.map((e) => ({
|
|
515
|
-
name: e.name,
|
|
516
|
-
sourceFile: resolve(e.sourceFile),
|
|
517
|
-
htmlPath: e.htmlPath,
|
|
518
|
-
}));
|
|
519
|
-
}
|
|
520
|
-
if (options.clientEntry !== undefined) {
|
|
521
|
-
return [{ name: "client", sourceFile: resolve(options.clientEntry), htmlPath: undefined }];
|
|
522
|
-
}
|
|
523
|
-
return [];
|
|
36
|
+
return Bun.serve(buildBunServeOptions(port, handleFetch, resolveDerivedMaxRequestBodySize(stack.registry), {
|
|
37
|
+
upgradeFetch: async (req, env) => stripNoRouteMatchHeader(await stack.app.fetch(req, env)),
|
|
38
|
+
}));
|
|
524
39
|
}
|
|
525
40
|
export async function createKumikoServer(options) {
|
|
526
41
|
const port = options.port ?? Number(process.env["PORT"] ?? 4173);
|
|
527
|
-
// --- client bundles (single-entry or multi-entry through the same map) ---
|
|
528
42
|
const entries = normalizeEntries(options);
|
|
529
43
|
const buildBundle = options._buildBundle ?? buildClient;
|
|
530
|
-
const clientBundles =
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
// reads fresh: no in-memory signalling needed, and the browser only
|
|
540
|
-
// reloads after a bundle rebuild anyway.
|
|
541
|
-
//
|
|
542
|
-
// Without an explicit `stylesheet`, the default resolves through the
|
|
543
|
-
// package exports of `@cosmicdrift/kumiko-renderer/web`, which works both
|
|
544
|
-
// for a workspace link and for an installed app (node_modules).
|
|
545
|
-
let stylesheetPath;
|
|
546
|
-
let killTailwind;
|
|
547
|
-
const resolvedStylesheet = resolveStylesheet(options);
|
|
548
|
-
if (resolvedStylesheet !== undefined) {
|
|
549
|
-
const handle = await startTailwindPipeline(resolvedStylesheet, {
|
|
550
|
-
watch: resolveStylesheetWatch(options),
|
|
551
|
-
});
|
|
552
|
-
if (handle !== undefined) {
|
|
553
|
-
stylesheetPath = handle.outPath;
|
|
554
|
-
killTailwind = handle.kill;
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
// --- HTML templates ---
|
|
558
|
-
// Single-entry uses one template (`htmlPath` or DEFAULT_HTML).
|
|
559
|
-
// Multi-entry uses one per entry (`entry.htmlPath ?? options.htmlPath
|
|
560
|
-
// ?? DEFAULT_HTML`); hostDispatch selects it per request.
|
|
561
|
-
const defaultTemplate = options.htmlPath !== undefined
|
|
562
|
-
? await readFile(resolve(options.htmlPath), "utf-8")
|
|
563
|
-
: DEFAULT_HTML;
|
|
564
|
-
const htmlTemplates = new Map();
|
|
565
|
-
for (const e of entries) {
|
|
566
|
-
htmlTemplates.set(e.name, e.htmlPath !== undefined ? await readFile(resolve(e.htmlPath), "utf-8") : defaultTemplate);
|
|
567
|
-
}
|
|
568
|
-
// --- Kumiko stack ---
|
|
569
|
-
// KUMIKO_DEV_DB_NAME switches the underlying testDb from ephemeral
|
|
570
|
-
// (fresh kumiko_test_<random>, dropped on cleanup) to persistent
|
|
571
|
-
// (reuses the named DB across restarts). The var is framework-scoped
|
|
572
|
-
// on purpose — every dev-server pattern benefits from the same
|
|
573
|
-
// toggle, not just one sample.
|
|
574
|
-
const devDbName = process.env["KUMIKO_DEV_DB_NAME"];
|
|
575
|
-
const persistentDb = isPersistentDevDb(process.env);
|
|
576
|
-
// Redis is shared across boots AND across apps (unlike the per-boot test
|
|
577
|
-
// DB, which is ephemeral or persistent per KUMIKO_DEV_DB_NAME), so every
|
|
578
|
-
// boot needs its own queue-name prefix — otherwise two dev/e2e servers
|
|
579
|
-
// running in parallel drain each other's queues ("Unknown job: ..."). A
|
|
580
|
-
// persistent DB implies a stable prefix so jobs survive a restart; an
|
|
581
|
-
// ephemeral DB gets a fresh prefix per boot, matching the fresh DB.
|
|
582
|
-
const jobQueueNamePrefix = persistentDb
|
|
583
|
-
? `kumiko-dev-${devDbName}`
|
|
584
|
-
: `kumiko-dev-${crypto.randomUUID()}`;
|
|
585
|
-
logInfo(`[kumiko-server] booting Kumiko stack${persistentDb ? ` — persistent DB "${devDbName}"` : " — ephemeral test DB"}…`);
|
|
586
|
-
const stack = await setupTestStack({
|
|
587
|
-
features: options.features,
|
|
588
|
-
...(persistentDb && { dbName: devDbName, persistentDb: true }),
|
|
589
|
-
...(options.auth !== undefined && { authConfig: options.auth }),
|
|
590
|
-
...(options.extraContext !== undefined && { extraContext: options.extraContext }),
|
|
591
|
-
...(options.anonymousAccess !== undefined && { anonymousAccess: options.anonymousAccess }),
|
|
592
|
-
enrichAnonymousAccess: (base, deps) => resolveAnonymousAccessFromRegistry(base, deps),
|
|
593
|
-
...(options.files !== undefined && { files: options.files }),
|
|
594
|
-
...(options.effectiveFeatures !== undefined && {
|
|
595
|
-
effectiveFeatures: options.effectiveFeatures,
|
|
596
|
-
}),
|
|
597
|
-
...(options.extraRoutes !== undefined && { extraRoutes: options.extraRoutes }),
|
|
598
|
-
...(options.trustedProxyHops !== undefined && { trustedProxyHops: options.trustedProxyHops }),
|
|
599
|
-
...(options.errorDocs && { errorDocs: options.errorDocs }),
|
|
600
|
-
// jobs.consumerLane unset = enqueuer-only; startDevJobRunners below is
|
|
601
|
-
// the sole consumer/cron-scheduler per lane, so runOnBoot/cron jobs
|
|
602
|
-
// don't double-fire. queueNamePrefix keeps this boot's queues isolated
|
|
603
|
-
// from every other dev/e2e server sharing the same Redis.
|
|
604
|
-
jobs: { queueNamePrefix: jobQueueNamePrefix },
|
|
605
|
-
});
|
|
606
|
-
await createEventsTable(stack.db);
|
|
607
|
-
await pushEntityProjectionTables(stack, stack.registry);
|
|
608
|
-
// `extraRoutes` is already mounted — setupTestStack's buildServer() call
|
|
609
|
-
// wired it above, before this function ever runs. `wire` is the
|
|
610
|
-
// remaining escape-hatch for app-wired co-running components that need
|
|
611
|
-
// the system-write dispatcher but don't declare a route.
|
|
612
|
-
if (options.wire !== undefined) {
|
|
613
|
-
await options.wire({
|
|
614
|
-
db: stack.db,
|
|
615
|
-
// The raw ioredis client, not the TestRedis wrapper, matching runProdApp
|
|
616
|
-
// so application code sees the same interface in development and production.
|
|
617
|
-
redis: stack.redis.redis,
|
|
618
|
-
registry: stack.registry,
|
|
619
|
-
metricsFor: createMetricsForFactory(stack.observability.meter),
|
|
620
|
-
dispatchSystemWrite: makeDispatchSystemWrite(stack.dispatcher),
|
|
621
|
-
dispatchSystemQuery: makeDispatchSystemQuery(stack.dispatcher),
|
|
622
|
-
});
|
|
623
|
-
}
|
|
624
|
-
// Hook for caller-specific tables and seeding. It runs after entity tables
|
|
625
|
-
// are created, so the hook can use `stack.db` / `stack.dispatcher`, and
|
|
626
|
-
// before the server starts, so the first request sees initialized state. Idempotence is the caller's responsibility because
|
|
627
|
-
// persistent-DB mode runs the hook on every boot.
|
|
628
|
-
if (options.onAfterSetup !== undefined) {
|
|
629
|
-
await options.onAfterSetup(stack);
|
|
630
|
-
}
|
|
631
|
-
// setupTestStack configures but does not start the event dispatcher so
|
|
632
|
-
// integration tests can drain it deterministically with runOnce(). The
|
|
633
|
-
// dev server starts polling so SSE broadcasts reach connected clients;
|
|
634
|
-
// otherwise events remain in the events table.
|
|
635
|
-
if (stack.eventDispatcher) {
|
|
636
|
-
await stack.eventDispatcher.start();
|
|
637
|
-
}
|
|
638
|
-
const redisUrl = `redis://${stack.redis.redis.options.host}:${stack.redis.redis.options.port}/${stack.redis.redis.options.db}`;
|
|
639
|
-
const devJobRunners = await startDevJobRunners({
|
|
640
|
-
registry: stack.registry,
|
|
641
|
-
db: stack.db,
|
|
642
|
-
// The stack's own AppContext, not a `{ db, registry }` literal: these
|
|
643
|
-
// runners consume the lanes, and a job reaching for ctx.files /
|
|
644
|
-
// ctx.notify / ctx.config would otherwise die here while the very same
|
|
645
|
-
// handler works on the request path.
|
|
646
|
-
context: stack.context,
|
|
647
|
-
redisUrl,
|
|
648
|
-
// Same dispatcher the request path writes through — without this,
|
|
649
|
-
// ctx.write/ctx.writeAs/ctx.queryAs inside a dev-run job throw on their
|
|
650
|
-
// first call.
|
|
651
|
-
dispatcher: stack.dispatcher,
|
|
652
|
-
// Same isolation reasoning as setupTestStack's jobs.queueNamePrefix
|
|
653
|
-
// above — both must use the same prefix so enqueuer and consumer agree
|
|
654
|
-
// on queue names.
|
|
655
|
-
queueNamePrefix: jobQueueNamePrefix,
|
|
656
|
-
});
|
|
657
|
-
// Dev user = TestUsers.admin. Demo features are openToAll, but the
|
|
658
|
-
// auth middleware still requires a valid JWT. When `options.auth` is set,
|
|
659
|
-
// the client performs login instead.
|
|
660
|
-
const autoMintJwt = options.auth === undefined;
|
|
661
|
-
const devUser = TestUsers.admin;
|
|
662
|
-
// Build once at boot purely to surface authoring warnings (settings-hub
|
|
663
|
-
// placement etc.) at the same point prod would — clients fetch the actual
|
|
664
|
-
// schema themselves from GET /api/schema, so the result itself is
|
|
665
|
-
// discarded here.
|
|
44
|
+
const clientBundles = await buildClientBundles(entries, buildBundle);
|
|
45
|
+
const stylesheet = await startDevStylesheet(options);
|
|
46
|
+
const templates = await loadHtmlTemplates(entries, options.htmlPath);
|
|
47
|
+
const dbMode = resolveDevDbMode(process.env);
|
|
48
|
+
const stack = await bootDevStack(options, dbMode);
|
|
49
|
+
const devJobRunners = await startDevBackgroundRunners(stack, dbMode.jobQueueNamePrefix);
|
|
50
|
+
// Built once purely to surface authoring warnings (settings-hub placement
|
|
51
|
+
// etc.) at the same point prod would; clients fetch the schema themselves
|
|
52
|
+
// from GET /api/schema, so the result is discarded.
|
|
666
53
|
buildAppSchema(stack.registry, {
|
|
667
54
|
authoringWarnings: true,
|
|
668
55
|
searchAdapterMissing: !stack.context.searchAdapter,
|
|
669
56
|
});
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
const payload = `event: reload\ndata: ${JSON.stringify(changedFile)}\n\n`;
|
|
680
|
-
for (const client of reloadClients) {
|
|
681
|
-
if (client.closed)
|
|
682
|
-
continue;
|
|
683
|
-
try {
|
|
684
|
-
client.controller.enqueue(client.encoder.encode(payload));
|
|
685
|
-
}
|
|
686
|
-
catch {
|
|
687
|
-
client.closed = true;
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
};
|
|
691
|
-
// Build a fresh HTML response. In auto-mint mode (no auth config) we
|
|
692
|
-
// pack a valid JWT + CSRF cookie right in — deep-links work immediately
|
|
693
|
-
// without login (and that auth cookie is what a client-side fetch to
|
|
694
|
-
// GET /api/schema authenticates with). In auth mode we serve the bare
|
|
695
|
-
// HTML; the client then goes through /auth/login and gets its cookies
|
|
696
|
-
// from there.
|
|
697
|
-
//
|
|
698
|
-
// entryName is decided by the caller (handleFetch) after evaluating
|
|
699
|
-
// hostDispatch. Without hostDispatch it's always "client" (single-entry
|
|
700
|
-
// default).
|
|
701
|
-
//
|
|
702
|
-
// Anonymous-role systemQuery, shared by resolvePageHead and hostDispatch —
|
|
703
|
-
// mirrors runProdApp's HostDispatchFn deps so dev/prod stay symmetric.
|
|
704
|
-
// Own resolver mirroring the same effective hops as setupTestStack's
|
|
705
|
-
// internal buildServer — this path runs for
|
|
706
|
-
// page-head/hostDispatch resolution, ahead of requestIdMiddleware's
|
|
707
|
-
// /api/* mount, so it can't reuse that instance. Two independent
|
|
708
|
-
// warn-once flags in dev is an accepted, low-risk gap (dev normally
|
|
709
|
-
// runs unproxied — see CreateKumikoServerOptions.trustedProxyHops).
|
|
710
|
-
const pageHeadClientIpResolver = createClientIpResolver(options.trustedProxyHops ?? 0, "createKumikoServer(pageHead)");
|
|
711
|
-
const buildDevSystemQuery = (req, socketAddress) => buildRequestBoundSystemQuery({
|
|
712
|
-
req,
|
|
713
|
-
dispatcher: stack.dispatcher,
|
|
714
|
-
resolver: pageHeadClientIpResolver,
|
|
715
|
-
socketAddress,
|
|
57
|
+
const reloadHub = createReloadHub();
|
|
58
|
+
const handleFetch = createDevFetchHandler({
|
|
59
|
+
options,
|
|
60
|
+
stack,
|
|
61
|
+
entries,
|
|
62
|
+
clientBundles,
|
|
63
|
+
templates,
|
|
64
|
+
stylesheetPath: stylesheet?.outPath,
|
|
65
|
+
reloadHub,
|
|
716
66
|
});
|
|
717
|
-
|
|
718
|
-
// that runProdApp uses, so dev and e2e exercise the one timeout and
|
|
719
|
-
// fallback path instead of a second copy of it.
|
|
720
|
-
const htmlResponse = async (entryName, req, socketAddress, status = 200) => {
|
|
721
|
-
const template = htmlTemplates.get(entryName) ?? defaultTemplate;
|
|
722
|
-
const headers = new Headers();
|
|
723
|
-
headers.set("Content-Type", "text/html; charset=utf-8");
|
|
724
|
-
if (autoMintJwt) {
|
|
725
|
-
const jwt = await stack.jwt.sign(devUser);
|
|
726
|
-
const csrf = generateToken();
|
|
727
|
-
headers.append("Set-Cookie", `${AUTH_COOKIE}=${jwt}; Path=/; HttpOnly; SameSite=Lax`);
|
|
728
|
-
headers.append("Set-Cookie", `${CSRF_COOKIE}=${csrf}; Path=/; SameSite=Lax`);
|
|
729
|
-
}
|
|
730
|
-
let html = injectReload(template);
|
|
731
|
-
if (stylesheetPath !== undefined)
|
|
732
|
-
html = injectStylesheet(html);
|
|
733
|
-
if (options.resolvePageHead !== undefined) {
|
|
734
|
-
const url = new URL(req.url);
|
|
735
|
-
const host = req.headers.get("host") ?? url.host;
|
|
736
|
-
html = await resolveAndInjectPageHead(html, options.resolvePageHead, {
|
|
737
|
-
path: url.pathname,
|
|
738
|
-
host,
|
|
739
|
-
preferredLocales: rankPreferredLocales({
|
|
740
|
-
queryLang: url.searchParams.get("lang"),
|
|
741
|
-
acceptLanguage: req.headers.get("accept-language"),
|
|
742
|
-
}),
|
|
743
|
-
systemQuery: buildDevSystemQuery(req, socketAddress),
|
|
744
|
-
requestSignal: req.signal,
|
|
745
|
-
});
|
|
746
|
-
}
|
|
747
|
-
return new Response(html, { status, headers });
|
|
748
|
-
};
|
|
749
|
-
// Raw file serve, no bundle injection — mirrors prod's `{ kind: "html", file }`.
|
|
750
|
-
const staticHtmlResponse = async (file, status) => {
|
|
751
|
-
let content;
|
|
752
|
-
try {
|
|
753
|
-
content = await readFile(file, "utf-8");
|
|
754
|
-
}
|
|
755
|
-
catch (error) {
|
|
756
|
-
if (errorCodeOf(error) !== "ENOENT")
|
|
757
|
-
throw error;
|
|
758
|
-
return new Response(`hostDispatch: file not found: ${file}`, { status: 500 });
|
|
759
|
-
}
|
|
760
|
-
return new Response(content, {
|
|
761
|
-
status,
|
|
762
|
-
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
763
|
-
});
|
|
764
|
-
};
|
|
765
|
-
// --- Fetch handler (runtime-neutral) ---
|
|
766
|
-
// Bundle path lookup: each entry is served at
|
|
767
|
-
// GET /client[-name].js → JS bundle
|
|
768
|
-
// GET /client[-name].js.map → sourcemap
|
|
769
|
-
// assetPathFor() is the single source of truth for the URL form.
|
|
770
|
-
const bundleByAssetPath = new Map();
|
|
771
|
-
for (const e of entries)
|
|
772
|
-
bundleByAssetPath.set(assetPathFor(e.name), e.name);
|
|
773
|
-
// App-root convention (same as expandWatchPatterns/resolveStylesheet above):
|
|
774
|
-
// process.cwd() is the app workspace, so public/ is its static asset dir.
|
|
775
|
-
// Symlink-resolved once: servePublicFile compares realpath()s against it, so
|
|
776
|
-
// a symlinked public/ would otherwise 404 every file.
|
|
777
|
-
const lexicalPublicDir = resolve(process.cwd(), "public");
|
|
778
|
-
const publicDir = existsSync(lexicalPublicDir)
|
|
779
|
-
? realpathSync(lexicalPublicDir)
|
|
780
|
-
: lexicalPublicDir;
|
|
781
|
-
// The app's cwd first, this package's own location as fallback (the cwd may
|
|
782
|
-
// not have renderer/web installed, e.g. a fixture dir).
|
|
783
|
-
const rendererWebFontsDir = resolveRendererWebFontsDir([process.cwd(), import.meta.dir]);
|
|
784
|
-
const routeFetch = async (req, socketAddress) => {
|
|
785
|
-
const url = new URL(req.url);
|
|
786
|
-
// Specific routes first — assets, reload-SSE, API.
|
|
787
|
-
if (req.method === "GET") {
|
|
788
|
-
const bundleName = bundleByAssetPath.get(url.pathname);
|
|
789
|
-
if (bundleName !== undefined) {
|
|
790
|
-
const bundle = clientBundles.get(bundleName);
|
|
791
|
-
if (bundle === undefined)
|
|
792
|
-
return new Response("no bundle", { status: 404 });
|
|
793
|
-
return new Response(bundle.js, {
|
|
794
|
-
headers: { "Content-Type": "application/javascript; charset=utf-8" },
|
|
795
|
-
});
|
|
796
|
-
}
|
|
797
|
-
// Looked up in the live bundles instead of a separate index, which a
|
|
798
|
-
// hot-reload rebuild would leave stale.
|
|
799
|
-
for (const bundle of clientBundles.values()) {
|
|
800
|
-
const chunk = bundle.chunks?.get(url.pathname);
|
|
801
|
-
if (chunk !== undefined) {
|
|
802
|
-
return new Response(chunk, {
|
|
803
|
-
headers: { "Content-Type": "application/javascript; charset=utf-8" },
|
|
804
|
-
});
|
|
805
|
-
}
|
|
806
|
-
}
|
|
807
|
-
// .js.map variant: perform the same lookup after removing /.map.
|
|
808
|
-
if (url.pathname.endsWith(".js.map")) {
|
|
809
|
-
const jsPath = url.pathname.slice(0, -".map".length);
|
|
810
|
-
const mapName = bundleByAssetPath.get(jsPath);
|
|
811
|
-
if (mapName !== undefined) {
|
|
812
|
-
const bundle = clientBundles.get(mapName);
|
|
813
|
-
if (bundle === undefined || !bundle.map) {
|
|
814
|
-
return new Response("no map", { status: 404 });
|
|
815
|
-
}
|
|
816
|
-
return new Response(bundle.map, {
|
|
817
|
-
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
818
|
-
});
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
if (url.pathname.startsWith(RENDERER_WEB_FONTS_URL_PREFIX) && req.method === "GET") {
|
|
823
|
-
const fontFile = url.pathname.slice(RENDERER_WEB_FONTS_URL_PREFIX.length);
|
|
824
|
-
// The allowlist pattern excludes "/", "." segments and encoded traversal.
|
|
825
|
-
if (rendererWebFontsDir === undefined || !RENDERER_WEB_FONT_FILE_PATTERN.test(fontFile)) {
|
|
826
|
-
return new Response("not found", { status: 404 });
|
|
827
|
-
}
|
|
828
|
-
try {
|
|
829
|
-
const bytes = await readFile(join(rendererWebFontsDir, fontFile));
|
|
830
|
-
// @cast-boundary Buffer satisfies BodyInit at runtime, bun-types just doesn't say so.
|
|
831
|
-
return new Response(bytes, {
|
|
832
|
-
headers: { "Content-Type": "font/woff2", "Cache-Control": "public, max-age=86400" },
|
|
833
|
-
});
|
|
834
|
-
}
|
|
835
|
-
catch (err) {
|
|
836
|
-
if (errorCodeOf(err) === "ENOENT") {
|
|
837
|
-
return new Response("not found", { status: 404 });
|
|
838
|
-
}
|
|
839
|
-
throw err;
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
if (url.pathname === "/styles.css" && req.method === "GET") {
|
|
843
|
-
if (stylesheetPath === undefined)
|
|
844
|
-
return new Response("no stylesheet", { status: 404 });
|
|
845
|
-
const css = await readFile(stylesheetPath, "utf-8");
|
|
846
|
-
return new Response(css, {
|
|
847
|
-
headers: { "Content-Type": "text/css; charset=utf-8" },
|
|
848
|
-
});
|
|
849
|
-
}
|
|
850
|
-
if (url.pathname === "/_reload" && req.method === "GET") {
|
|
851
|
-
const encoder = new TextEncoder();
|
|
852
|
-
const stream = new ReadableStream({
|
|
853
|
-
start(controller) {
|
|
854
|
-
const entry = { controller, encoder, closed: false };
|
|
855
|
-
reloadClients.add(entry);
|
|
856
|
-
controller.enqueue(encoder.encode(": connected\n\n"));
|
|
857
|
-
// boot event: the browser snippet compares this with the first
|
|
858
|
-
// bootId. A different value after reconnect means the server
|
|
859
|
-
// restarted, so location.reload() is called.
|
|
860
|
-
controller.enqueue(encoder.encode(`event: boot\ndata: ${bootId}\n\n`));
|
|
861
|
-
},
|
|
862
|
-
cancel() {
|
|
863
|
-
for (const c of reloadClients) {
|
|
864
|
-
if (c.closed)
|
|
865
|
-
reloadClients.delete(c);
|
|
866
|
-
}
|
|
867
|
-
},
|
|
868
|
-
});
|
|
869
|
-
return new Response(stream, {
|
|
870
|
-
headers: {
|
|
871
|
-
"Content-Type": "text/event-stream",
|
|
872
|
-
"Cache-Control": "no-cache",
|
|
873
|
-
Connection: "keep-alive",
|
|
874
|
-
},
|
|
875
|
-
});
|
|
876
|
-
}
|
|
877
|
-
// SPA catch-all: GET or HEAD requests to non-API, non-asset paths return
|
|
878
|
-
// the HTML shell so the client-side router can mount the screen for
|
|
879
|
-
// location.pathname. Paths containing a dot are left to the stack's
|
|
880
|
-
// 404 handling.
|
|
881
|
-
//
|
|
882
|
-
// Backend routes outside /api, currently including /sse, must be
|
|
883
|
-
// excluded so the catch-all does not shadow Hono routes.
|
|
884
|
-
//
|
|
885
|
-
// Feature routes declared with r.httpRoute can also be outside /api.
|
|
886
|
-
// Try stack.app.fetch first so a matching Hono route wins; only a Hono
|
|
887
|
-
// 404 proceeds to the SPA fallback, matching production behavior.
|
|
888
|
-
if (
|
|
889
|
-
// Include HEAD: runProdApp falls back to the SPA for both GET and HEAD;
|
|
890
|
-
// omitting it would make development return 404 where production returns 200.
|
|
891
|
-
isRoutableGetOrHead(req, url.pathname) &&
|
|
892
|
-
!url.pathname.includes(".")) {
|
|
893
|
-
const honoTry = await tryHonoFirst(stack.app, req, socketAddress);
|
|
894
|
-
if (honoTry.matched) {
|
|
895
|
-
return honoTry.response;
|
|
896
|
-
}
|
|
897
|
-
// Discriminated dispatch mirrors production. Without hostDispatch, use
|
|
898
|
-
// the single-entry default ("client").
|
|
899
|
-
if (options.hostDispatch !== undefined) {
|
|
900
|
-
const dispatch = await options.hostDispatch(req, {
|
|
901
|
-
systemQuery: buildDevSystemQuery(req, socketAddress),
|
|
902
|
-
cspScriptHashes: [],
|
|
903
|
-
});
|
|
904
|
-
if (dispatch.kind === "redirect") {
|
|
905
|
-
return new Response(null, {
|
|
906
|
-
status: dispatch.status ?? 302,
|
|
907
|
-
headers: { Location: dispatch.to },
|
|
908
|
-
});
|
|
909
|
-
}
|
|
910
|
-
if (dispatch.kind === "not-found") {
|
|
911
|
-
if (dispatch.file !== undefined)
|
|
912
|
-
return staticHtmlResponse(dispatch.file, 404);
|
|
913
|
-
if (dispatch.entryName !== undefined) {
|
|
914
|
-
return htmlResponse(dispatch.entryName, req, socketAddress, 404);
|
|
915
|
-
}
|
|
916
|
-
return new Response("Not Found", { status: 404 });
|
|
917
|
-
}
|
|
918
|
-
if (dispatch.kind === "static-html") {
|
|
919
|
-
return staticHtmlResponse(dispatch.file, 200);
|
|
920
|
-
}
|
|
921
|
-
return htmlResponse(dispatch.entryName, req, socketAddress);
|
|
922
|
-
}
|
|
923
|
-
return htmlResponse("client", req, socketAddress);
|
|
924
|
-
}
|
|
925
|
-
// Static assets under public/ — see tryServePublicAsset's own comment
|
|
926
|
-
// for the Hono → file → router-miss ordering.
|
|
927
|
-
const staticAsset = await tryServePublicAsset(req, url.pathname, stack.app, publicDir, socketAddress);
|
|
928
|
-
if (staticAsset !== undefined)
|
|
929
|
-
return staticAsset;
|
|
930
|
-
// Bypasses tryHonoFirst entirely (API paths, /sse, non-GET/HEAD), so the
|
|
931
|
-
// router-miss marker must be stripped here too — otherwise an unmatched
|
|
932
|
-
// path would leak it straight to the client (see try-hono-first.ts's
|
|
933
|
-
// header-hygiene note).
|
|
934
|
-
return stripNoRouteMatchHeader(await stack.app.fetch(req, socketAddress));
|
|
935
|
-
};
|
|
936
|
-
const handleFetch = options.wrapFetch ? options.wrapFetch(routeFetch) : routeFetch;
|
|
937
|
-
// --- HTTP server (Bun only) ---
|
|
938
|
-
// Under Node/vitest, skip Bun.serve; the handle's .fetch() is the test
|
|
939
|
-
// surface. Under Bun, Bun.serve wires the listener.
|
|
940
|
-
// Bun.serve options come from buildBunServeOptions (run-prod-app.ts) so
|
|
941
|
-
// development and production share the same SSE-relevant defaults
|
|
942
|
-
// (idleTimeout: 0); run-prod-app-spec.test.ts pins that.
|
|
943
|
-
const server = hasBun
|
|
944
|
-
? Bun.serve(buildBunServeOptions(port, handleFetch, resolveDerivedMaxRequestBodySize(stack.registry), {
|
|
945
|
-
upgradeFetch: async (req, env) => stripNoRouteMatchHeader(await stack.app.fetch(req, env)),
|
|
946
|
-
}))
|
|
947
|
-
: undefined;
|
|
948
|
-
// --- file watcher → rebundle + reload, or process restart for schema changes ---
|
|
949
|
-
// Heuristic: files in `web/` or `__tests__/` are client-side or test-only,
|
|
950
|
-
// so hot reload is sufficient. Other files are server-side; because Bun
|
|
951
|
-
// caches module imports, a schema change in feature.ts requires a process
|
|
952
|
-
// restart. Exit with code 75 (EX_TEMPFAIL) so the `kumiko-dev` wrapper
|
|
953
|
-
// can respawn the process.
|
|
954
|
-
//
|
|
955
|
-
// stop() aborts watcherAbort, which ends fs.watch's async iteration. This
|
|
956
|
-
// prevents a watcher from surviving test teardown and classifying an
|
|
957
|
-
// rmSync(tmpdir) as "restart needed".
|
|
67
|
+
const server = startDevHttpServer(port, handleFetch, stack);
|
|
958
68
|
const watcherAbort = new AbortController();
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
"directly (not via kumiko-dev) exits here without restarting.");
|
|
980
|
-
await stop();
|
|
981
|
-
process.exit(75);
|
|
982
|
-
}
|
|
983
|
-
try {
|
|
984
|
-
// Rebuild all entries—even when only one file changes, because we do not
|
|
985
|
-
// know which entries import it. With shared code, an edit triggers
|
|
986
|
-
// both bundles; this is intentional.
|
|
987
|
-
for (const e of entries) {
|
|
988
|
-
const rebuilt = await buildBundle(e.sourceFile);
|
|
989
|
-
clientBundles.set(e.name, rebuilt);
|
|
990
|
-
}
|
|
991
|
-
logInfo(`[kumiko-server] rebuilt on ${filename}, broadcasting reload`);
|
|
992
|
-
broadcastReload(filename);
|
|
993
|
-
}
|
|
994
|
-
catch {
|
|
995
|
-
// buildClient already logged the failure; keep serving the
|
|
996
|
-
// last good bundle until the next successful rebuild.
|
|
997
|
-
}
|
|
998
|
-
}, watcherAbort.signal, options._watchDirectory ?? watch);
|
|
999
|
-
}
|
|
1000
|
-
}
|
|
1001
|
-
// The per-boot prefix is never reused, so its BullMQ keys (queues,
|
|
1002
|
-
// repeatables, delayed jobs) would pile up in the shared Redis forever.
|
|
1003
|
-
// A prefix-less duplicate: SCAN returns full key names, which the stack
|
|
1004
|
-
// client's keyPrefix would otherwise prepend a second time.
|
|
1005
|
-
// Takes the connection as a parameter: it must be opened before
|
|
1006
|
-
// stack.cleanup() closes the stack client, yet used only after it, because
|
|
1007
|
-
// closing the stack's enqueuer queues re-writes their `meta` keys.
|
|
1008
|
-
const deleteEphemeralJobQueues = async (raw) => {
|
|
1009
|
-
try {
|
|
1010
|
-
const keys = [];
|
|
1011
|
-
for await (const batch of raw.scanStream({
|
|
1012
|
-
match: `bull:${jobQueueNamePrefix}-*`,
|
|
1013
|
-
count: 500,
|
|
1014
|
-
})) {
|
|
1015
|
-
keys.push(...batch);
|
|
1016
|
-
}
|
|
1017
|
-
if (keys.length > 0)
|
|
1018
|
-
await raw.del(...keys);
|
|
1019
|
-
}
|
|
1020
|
-
finally {
|
|
1021
|
-
raw.disconnect();
|
|
1022
|
-
}
|
|
1023
|
-
};
|
|
1024
|
-
const stop = async () => {
|
|
1025
|
-
// Stop the watcher first so no onChange callback runs during teardown;
|
|
1026
|
-
// otherwise removing the temporary directory could trigger process.exit(75).
|
|
1027
|
-
watcherAbort.abort();
|
|
1028
|
-
if (killTailwind)
|
|
1029
|
-
killTailwind();
|
|
1030
|
-
if (server !== undefined) {
|
|
1031
|
-
server.stop(true);
|
|
1032
|
-
}
|
|
1033
|
-
if (stack.eventDispatcher) {
|
|
1034
|
-
await stack.eventDispatcher.stop();
|
|
1035
|
-
}
|
|
1036
|
-
await devJobRunners.stop();
|
|
1037
|
-
const rawRedis = persistentDb ? undefined : stack.redis.redis.duplicate({ keyPrefix: "" });
|
|
1038
|
-
await stack.cleanup();
|
|
1039
|
-
if (rawRedis)
|
|
1040
|
-
await deleteEphemeralJobQueues(rawRedis);
|
|
1041
|
-
};
|
|
1042
|
-
// --- graceful shutdown ---
|
|
1043
|
-
// Signal handlers run on Ctrl-C / kill. Without them, repeated dev
|
|
1044
|
-
// restarts leak Postgres pools, leave the Tailwind watcher as an orphan,
|
|
1045
|
-
// and leave temporary clients behind in persistent mode.
|
|
1046
|
-
// uncaughtException and unhandledRejection previously left the Tailwind
|
|
1047
|
-
// watcher running in the background; clean it up before exiting nonzero.
|
|
1048
|
-
const installHandlers = options.installSignalHandlers ?? true;
|
|
1049
|
-
if (installHandlers) {
|
|
1050
|
-
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
1051
|
-
process.on(sig, async () => {
|
|
1052
|
-
logInfo(`[kumiko-server] ${sig} — cleaning up…`);
|
|
1053
|
-
await stop();
|
|
1054
|
-
process.exit(0);
|
|
1055
|
-
});
|
|
1056
|
-
}
|
|
1057
|
-
process.on("uncaughtException", async (err) => {
|
|
1058
|
-
logError("[kumiko-server] uncaughtException — cleaning up…", err);
|
|
1059
|
-
try {
|
|
1060
|
-
await stop();
|
|
1061
|
-
}
|
|
1062
|
-
finally {
|
|
1063
|
-
process.exit(1);
|
|
1064
|
-
}
|
|
1065
|
-
});
|
|
1066
|
-
process.on("unhandledRejection", async (err) => {
|
|
1067
|
-
logError("[kumiko-server] unhandledRejection — cleaning up…", err);
|
|
1068
|
-
try {
|
|
1069
|
-
await stop();
|
|
1070
|
-
}
|
|
1071
|
-
finally {
|
|
1072
|
-
process.exit(1);
|
|
1073
|
-
}
|
|
1074
|
-
});
|
|
1075
|
-
}
|
|
69
|
+
const stop = createDevServerStop({
|
|
70
|
+
watcherAbort,
|
|
71
|
+
killTailwind: stylesheet?.kill,
|
|
72
|
+
server,
|
|
73
|
+
stack,
|
|
74
|
+
devJobRunners,
|
|
75
|
+
dbMode,
|
|
76
|
+
});
|
|
77
|
+
startDevFileWatchers({
|
|
78
|
+
entries,
|
|
79
|
+
watchDirs: options.watchDirs,
|
|
80
|
+
watchDirectory: options._watchDirectory,
|
|
81
|
+
signal: watcherAbort.signal,
|
|
82
|
+
buildBundle,
|
|
83
|
+
clientBundles,
|
|
84
|
+
reloadHub,
|
|
85
|
+
stop,
|
|
86
|
+
});
|
|
87
|
+
if (options.installSignalHandlers ?? true)
|
|
88
|
+
installDevSignalHandlers(stop);
|
|
1076
89
|
if (server !== undefined) {
|
|
1077
90
|
logInfo(`[kumiko-server] listening on http://localhost:${port}` +
|
|
1078
91
|
(entries.length > 0
|
|
1079
92
|
? ` (hot reload on ${entries.length === 1 ? "client entry" : `${entries.length} entries`})`
|
|
1080
93
|
: ""));
|
|
1081
94
|
}
|
|
1082
|
-
return { fetch: handleFetch, server, stack, stop, jobQueueNamePrefix };
|
|
95
|
+
return { fetch: handleFetch, server, stack, stop, jobQueueNamePrefix: dbMode.jobQueueNamePrefix };
|
|
1083
96
|
}
|