@cosmicdrift/kumiko-dev-server 0.387.0 → 0.389.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 +102 -0
- package/dist/create-kumiko-server-stylesheet.d.ts +10 -0
- package/dist/create-kumiko-server-stylesheet.js +135 -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/framework-version.js +2 -1
- 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/managed-files/registry.js +5 -1
- package/dist/run-dev-app-auth.d.ts +38 -0
- package/dist/run-dev-app-auth.js +156 -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 +11 -7
- package/dist/run-dev-app.js +90 -463
- package/dist/scaffold-app.d.ts +2 -1
- package/dist/scaffold-app.js +29 -2
- package/dist/schema-apply.d.ts +1 -1
- package/dist/schema-apply.js +4 -4
- package/dist/setup-test-stack-from-features.d.ts +1 -0
- package/dist/setup-test-stack-from-features.js +6 -2
- package/dist/write-server-lockfile.js +23 -16
- package/package.json +15 -10
- package/src/changes.json +61 -0
- package/templates/deploy/Dockerfile.template +3 -2
- package/templates/managed/ci-public.yml.template +6 -2
- package/templates/managed/kumiko-schema.ts.template +1 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/// <reference types="bun-types" preserve="true" />
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { RENDERER_WEB_FONT_FILE_PATTERN, RENDERER_WEB_FONTS_URL_PREFIX, resolveRendererWebFontsDir, } from "@cosmicdrift/kumiko-server-runtime/renderer-web-fonts";
|
|
5
|
+
import { stripNoRouteMatchHeader, tryHonoFirst, } from "@cosmicdrift/kumiko-server-runtime/try-hono-first";
|
|
6
|
+
import { errorCodeOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
7
|
+
import { indexBundlesByAssetPath, } from "./create-kumiko-server-client-bundles.js";
|
|
8
|
+
import { createDevSystemQueryBuilder, respondWithHtmlShell, respondWithStaticHtmlFile, } from "./create-kumiko-server-html.js";
|
|
9
|
+
import { isRoutableGetOrHead, resolveDevPublicDir, servePublicFile, } from "./create-kumiko-server-public-files.js";
|
|
10
|
+
import { openReloadStream } from "./create-kumiko-server-reload.js";
|
|
11
|
+
const JAVASCRIPT_HEADERS = { "Content-Type": "application/javascript; charset=utf-8" };
|
|
12
|
+
function serveClientBundle({ req, url }, deps) {
|
|
13
|
+
if (req.method !== "GET")
|
|
14
|
+
return undefined;
|
|
15
|
+
const bundleName = deps.bundleByAssetPath.get(url.pathname);
|
|
16
|
+
if (bundleName === undefined)
|
|
17
|
+
return undefined;
|
|
18
|
+
const bundle = deps.clientBundles.get(bundleName);
|
|
19
|
+
if (bundle === undefined)
|
|
20
|
+
return new Response("no bundle", { status: 404 });
|
|
21
|
+
return new Response(bundle.js, { headers: JAVASCRIPT_HEADERS });
|
|
22
|
+
}
|
|
23
|
+
// Looked up in the live bundles instead of a separate index, which a
|
|
24
|
+
// hot-reload rebuild would leave stale.
|
|
25
|
+
function serveBundleChunk({ req, url }, deps) {
|
|
26
|
+
if (req.method !== "GET")
|
|
27
|
+
return undefined;
|
|
28
|
+
for (const bundle of deps.clientBundles.values()) {
|
|
29
|
+
const chunk = bundle.chunks?.get(url.pathname);
|
|
30
|
+
if (chunk !== undefined)
|
|
31
|
+
return new Response(chunk, { headers: JAVASCRIPT_HEADERS });
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
function serveBundleSourceMap({ req, url }, deps) {
|
|
36
|
+
if (req.method !== "GET" || !url.pathname.endsWith(".js.map"))
|
|
37
|
+
return undefined;
|
|
38
|
+
const mapName = deps.bundleByAssetPath.get(url.pathname.slice(0, -".map".length));
|
|
39
|
+
if (mapName === undefined)
|
|
40
|
+
return undefined;
|
|
41
|
+
const bundle = deps.clientBundles.get(mapName);
|
|
42
|
+
if (bundle === undefined || !bundle.map)
|
|
43
|
+
return new Response("no map", { status: 404 });
|
|
44
|
+
return new Response(bundle.map, {
|
|
45
|
+
headers: { "Content-Type": "application/json; charset=utf-8" },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
async function serveRendererWebFont({ req, url }, deps) {
|
|
49
|
+
if (!url.pathname.startsWith(RENDERER_WEB_FONTS_URL_PREFIX) || req.method !== "GET") {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
const fontFile = url.pathname.slice(RENDERER_WEB_FONTS_URL_PREFIX.length);
|
|
53
|
+
// The allowlist pattern excludes "/", "." segments and encoded traversal.
|
|
54
|
+
if (deps.rendererWebFontsDir === undefined || !RENDERER_WEB_FONT_FILE_PATTERN.test(fontFile)) {
|
|
55
|
+
return new Response("not found", { status: 404 });
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const bytes = await readFile(join(deps.rendererWebFontsDir, fontFile));
|
|
59
|
+
// @cast-boundary Buffer satisfies BodyInit at runtime, bun-types just doesn't say so.
|
|
60
|
+
return new Response(bytes, {
|
|
61
|
+
headers: { "Content-Type": "font/woff2", "Cache-Control": "public, max-age=86400" },
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
if (errorCodeOf(err) === "ENOENT") {
|
|
66
|
+
return new Response("not found", { status: 404 });
|
|
67
|
+
}
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function serveStylesheet({ req, url }, deps) {
|
|
72
|
+
if (url.pathname !== "/styles.css" || req.method !== "GET")
|
|
73
|
+
return undefined;
|
|
74
|
+
const { stylesheetPath } = deps.html;
|
|
75
|
+
if (stylesheetPath === undefined)
|
|
76
|
+
return new Response("no stylesheet", { status: 404 });
|
|
77
|
+
const css = await readFile(stylesheetPath, "utf-8");
|
|
78
|
+
return new Response(css, { headers: { "Content-Type": "text/css; charset=utf-8" } });
|
|
79
|
+
}
|
|
80
|
+
function serveReloadStream({ req, url }, deps) {
|
|
81
|
+
if (url.pathname !== "/_reload" || req.method !== "GET")
|
|
82
|
+
return undefined;
|
|
83
|
+
return openReloadStream(deps.reloadHub);
|
|
84
|
+
}
|
|
85
|
+
function respondToNotFoundDispatch(dispatch, { req, socketAddress }, html) {
|
|
86
|
+
if (dispatch.file !== undefined)
|
|
87
|
+
return respondWithStaticHtmlFile(dispatch.file, 404);
|
|
88
|
+
if (dispatch.entryName !== undefined) {
|
|
89
|
+
return respondWithHtmlShell(html, dispatch.entryName, req, socketAddress, 404);
|
|
90
|
+
}
|
|
91
|
+
return new Response("Not Found", { status: 404 });
|
|
92
|
+
}
|
|
93
|
+
// Discriminated dispatch mirrors production; anything that is not a
|
|
94
|
+
// redirect, not-found or static-html result serves its entry's shell.
|
|
95
|
+
function respondToHostDispatch(dispatch, request, html) {
|
|
96
|
+
switch (dispatch.kind) {
|
|
97
|
+
case "redirect":
|
|
98
|
+
return new Response(null, {
|
|
99
|
+
status: dispatch.status ?? 302,
|
|
100
|
+
headers: { Location: dispatch.to },
|
|
101
|
+
});
|
|
102
|
+
case "not-found":
|
|
103
|
+
return respondToNotFoundDispatch(dispatch, request, html);
|
|
104
|
+
case "static-html":
|
|
105
|
+
return respondWithStaticHtmlFile(dispatch.file, 200);
|
|
106
|
+
default:
|
|
107
|
+
return respondWithHtmlShell(html, dispatch.entryName, request.req, request.socketAddress);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// SPA catch-all: GET or HEAD requests to non-API, non-asset paths return
|
|
111
|
+
// the HTML shell so the client-side router can mount the screen for
|
|
112
|
+
// location.pathname. Paths containing a dot are left to the public/ lookup.
|
|
113
|
+
//
|
|
114
|
+
// HEAD is included because runProdApp falls back to the SPA for both GET and
|
|
115
|
+
// HEAD. Backend routes outside /api (currently /sse) are excluded, and an
|
|
116
|
+
// r.httpRoute outside /api is tried first, so only a Hono router miss falls
|
|
117
|
+
// back to the shell, matching production.
|
|
118
|
+
async function serveSpaShell(request, deps) {
|
|
119
|
+
const { req, url, socketAddress } = request;
|
|
120
|
+
if (!isRoutableGetOrHead(req, url.pathname) || url.pathname.includes("."))
|
|
121
|
+
return undefined;
|
|
122
|
+
const honoTry = await tryHonoFirst(deps.app, req, socketAddress);
|
|
123
|
+
if (honoTry.matched) {
|
|
124
|
+
return honoTry.response;
|
|
125
|
+
}
|
|
126
|
+
if (deps.hostDispatch === undefined) {
|
|
127
|
+
return respondWithHtmlShell(deps.html, "client", req, socketAddress);
|
|
128
|
+
}
|
|
129
|
+
const dispatch = await deps.hostDispatch(req, {
|
|
130
|
+
systemQuery: deps.html.buildSystemQuery(req, socketAddress),
|
|
131
|
+
cspScriptHashes: [],
|
|
132
|
+
});
|
|
133
|
+
return respondToHostDispatch(dispatch, request, deps.html);
|
|
134
|
+
}
|
|
135
|
+
// Static assets under public/ — prod serves these via buildStaticFallback's
|
|
136
|
+
// disk lookup (run-prod-app-static-files.ts). Hono still goes first (an
|
|
137
|
+
// r.httpRoute could itself own a dotted path), then the file on disk, then
|
|
138
|
+
// the router-miss 404, mirroring the SPA branch.
|
|
139
|
+
async function servePublicAsset({ req, url, socketAddress }, deps) {
|
|
140
|
+
if (!isRoutableGetOrHead(req, url.pathname) || !url.pathname.includes("."))
|
|
141
|
+
return undefined;
|
|
142
|
+
const honoTry = await tryHonoFirst(deps.app, req, socketAddress);
|
|
143
|
+
if (honoTry.matched) {
|
|
144
|
+
return honoTry.response;
|
|
145
|
+
}
|
|
146
|
+
const file = await servePublicFile(url.pathname, deps.publicDir);
|
|
147
|
+
if (file !== undefined) {
|
|
148
|
+
// @cast-boundary Buffer satisfies BodyInit at runtime, bun-types
|
|
149
|
+
// just doesn't say so — same cast run-prod-app-static-files.ts uses.
|
|
150
|
+
return new Response(file.bytes, {
|
|
151
|
+
headers: { "Content-Type": file.mime },
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return honoTry.response;
|
|
155
|
+
}
|
|
156
|
+
// Specific routes first (assets, reload SSE), then the SPA shell and
|
|
157
|
+
// public/ files; whatever is left goes to the Kumiko stack.
|
|
158
|
+
const DEV_ROUTES = [
|
|
159
|
+
serveClientBundle,
|
|
160
|
+
serveBundleChunk,
|
|
161
|
+
serveBundleSourceMap,
|
|
162
|
+
serveRendererWebFont,
|
|
163
|
+
serveStylesheet,
|
|
164
|
+
serveReloadStream,
|
|
165
|
+
serveSpaShell,
|
|
166
|
+
servePublicAsset,
|
|
167
|
+
];
|
|
168
|
+
function createDevRouteFetch(deps) {
|
|
169
|
+
return async (req, socketAddress) => {
|
|
170
|
+
const request = { req, url: new URL(req.url), socketAddress };
|
|
171
|
+
for (const route of DEV_ROUTES) {
|
|
172
|
+
const response = await route(request, deps);
|
|
173
|
+
if (response !== undefined)
|
|
174
|
+
return response;
|
|
175
|
+
}
|
|
176
|
+
// Bypasses tryHonoFirst entirely (API paths, /sse, non-GET/HEAD), so the
|
|
177
|
+
// router-miss marker must be stripped here too — otherwise an unmatched
|
|
178
|
+
// path would leak it straight to the client (see try-hono-first.ts's
|
|
179
|
+
// header-hygiene note).
|
|
180
|
+
return stripNoRouteMatchHeader(await deps.app.fetch(req, socketAddress));
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
export function createDevFetchHandler(input) {
|
|
184
|
+
const { options, stack } = input;
|
|
185
|
+
const buildSystemQuery = createDevSystemQueryBuilder(stack.dispatcher, options.trustedProxyHops);
|
|
186
|
+
const routeFetch = createDevRouteFetch({
|
|
187
|
+
app: stack.app,
|
|
188
|
+
clientBundles: input.clientBundles,
|
|
189
|
+
bundleByAssetPath: indexBundlesByAssetPath(input.entries),
|
|
190
|
+
publicDir: resolveDevPublicDir(),
|
|
191
|
+
// The app's cwd first, this package's own location as fallback (the cwd
|
|
192
|
+
// may not have renderer/web installed, e.g. a fixture dir).
|
|
193
|
+
rendererWebFontsDir: resolveRendererWebFontsDir([process.cwd(), import.meta.dir]),
|
|
194
|
+
reloadHub: input.reloadHub,
|
|
195
|
+
hostDispatch: options.hostDispatch,
|
|
196
|
+
html: {
|
|
197
|
+
templates: input.templates,
|
|
198
|
+
jwt: stack.jwt,
|
|
199
|
+
autoMintJwt: options.auth === undefined,
|
|
200
|
+
stylesheetPath: input.stylesheetPath,
|
|
201
|
+
resolvePageHead: options.resolvePageHead,
|
|
202
|
+
buildSystemQuery,
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
return options.wrapFetch ? options.wrapFetch(routeFetch) : routeFetch;
|
|
206
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/// <reference types="bun-types" preserve="true" />
|
|
2
|
+
import type { TestStack } from "@cosmicdrift/kumiko-testing/stack";
|
|
3
|
+
import type { DevDbMode, DevJobRunners } from "./create-kumiko-server-stack.ts";
|
|
4
|
+
import { type BunServer } from "./dev-server-runtime.ts";
|
|
5
|
+
export type DevServerStopDeps = {
|
|
6
|
+
readonly watcherAbort: AbortController;
|
|
7
|
+
readonly killTailwind: (() => void) | undefined;
|
|
8
|
+
readonly server: BunServer | undefined;
|
|
9
|
+
readonly stack: TestStack;
|
|
10
|
+
readonly devJobRunners: DevJobRunners;
|
|
11
|
+
readonly dbMode: DevDbMode;
|
|
12
|
+
};
|
|
13
|
+
export declare function createDevServerStop(deps: DevServerStopDeps): () => Promise<void>;
|
|
14
|
+
export declare function installDevSignalHandlers(stop: () => Promise<void>): void;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { logError, logInfo } from "./dev-server-runtime.js";
|
|
2
|
+
// The per-boot prefix is never reused, so its BullMQ keys (queues,
|
|
3
|
+
// repeatables, delayed jobs) would pile up in the shared Redis forever.
|
|
4
|
+
// Takes a prefix-less duplicate connection: SCAN returns full key names,
|
|
5
|
+
// which the stack client's keyPrefix would otherwise prepend a second time.
|
|
6
|
+
// The connection must be opened before stack.cleanup() closes the stack
|
|
7
|
+
// client, yet used only after it, because closing the stack's enqueuer
|
|
8
|
+
// queues re-writes their `meta` keys.
|
|
9
|
+
async function deleteEphemeralJobQueues(raw, jobQueueNamePrefix) {
|
|
10
|
+
try {
|
|
11
|
+
const keys = [];
|
|
12
|
+
for await (const batch of raw.scanStream({
|
|
13
|
+
match: `bull:${jobQueueNamePrefix}-*`,
|
|
14
|
+
count: 500,
|
|
15
|
+
})) {
|
|
16
|
+
keys.push(...batch);
|
|
17
|
+
}
|
|
18
|
+
if (keys.length > 0)
|
|
19
|
+
await raw.del(...keys);
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
raw.disconnect();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function createDevServerStop(deps) {
|
|
26
|
+
const { watcherAbort, killTailwind, server, stack, devJobRunners, dbMode } = deps;
|
|
27
|
+
return async () => {
|
|
28
|
+
// Stop the watcher first so no onChange callback runs during teardown;
|
|
29
|
+
// otherwise removing the temporary directory could trigger process.exit(75).
|
|
30
|
+
watcherAbort.abort();
|
|
31
|
+
if (killTailwind)
|
|
32
|
+
killTailwind();
|
|
33
|
+
if (server !== undefined) {
|
|
34
|
+
server.stop(true);
|
|
35
|
+
}
|
|
36
|
+
if (stack.eventDispatcher) {
|
|
37
|
+
await stack.eventDispatcher.stop();
|
|
38
|
+
}
|
|
39
|
+
await devJobRunners.stop();
|
|
40
|
+
const rawRedis = dbMode.persistentDb
|
|
41
|
+
? undefined
|
|
42
|
+
: stack.redis.redis.duplicate({ keyPrefix: "" });
|
|
43
|
+
await stack.cleanup();
|
|
44
|
+
if (rawRedis)
|
|
45
|
+
await deleteEphemeralJobQueues(rawRedis, dbMode.jobQueueNamePrefix);
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function stopAndExitOnCrash(event, stop) {
|
|
49
|
+
return async (err) => {
|
|
50
|
+
logError(`[kumiko-server] ${event} — cleaning up…`, err);
|
|
51
|
+
try {
|
|
52
|
+
await stop();
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
// Without these handlers, repeated dev restarts leak Postgres pools, leave
|
|
60
|
+
// the Tailwind watcher as an orphan, and leave temporary clients behind in
|
|
61
|
+
// persistent mode; a crash cleans up the same way before exiting nonzero.
|
|
62
|
+
export function installDevSignalHandlers(stop) {
|
|
63
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
64
|
+
process.on(sig, async () => {
|
|
65
|
+
logInfo(`[kumiko-server] ${sig} — cleaning up…`);
|
|
66
|
+
await stop();
|
|
67
|
+
process.exit(0);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
process.on("uncaughtException", stopAndExitOnCrash("uncaughtException", stop));
|
|
71
|
+
process.on("unhandledRejection", stopAndExitOnCrash("unhandledRejection", stop));
|
|
72
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { startDevJobRunners } from "@cosmicdrift/kumiko-server-runtime/boot/job-run-logger";
|
|
2
|
+
import { type TestStack } from "@cosmicdrift/kumiko-testing/stack";
|
|
3
|
+
import type { CreateKumikoServerOptions } from "./create-kumiko-server.ts";
|
|
4
|
+
export type DevDbMode = {
|
|
5
|
+
readonly devDbName: string | undefined;
|
|
6
|
+
readonly persistentDb: boolean;
|
|
7
|
+
readonly jobQueueNamePrefix: string;
|
|
8
|
+
};
|
|
9
|
+
export type DevJobRunners = Awaited<ReturnType<typeof startDevJobRunners>>;
|
|
10
|
+
export declare function resolveDevDbMode(processEnv: Record<string, string | undefined>): DevDbMode;
|
|
11
|
+
export declare function bootDevStack(options: CreateKumikoServerOptions, dbMode: DevDbMode): Promise<TestStack>;
|
|
12
|
+
export declare function startDevBackgroundRunners(stack: TestStack, jobQueueNamePrefix: string): Promise<DevJobRunners>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { resolveAnonymousAccessFromRegistry } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
2
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
3
|
+
import { createMetricsForFactory } from "@cosmicdrift/kumiko-framework/observability";
|
|
4
|
+
import { startDevJobRunners } from "@cosmicdrift/kumiko-server-runtime/boot/job-run-logger";
|
|
5
|
+
import { makeDispatchSystemQuery, makeDispatchSystemWrite, makeFilesForTenant, } from "@cosmicdrift/kumiko-server-runtime/extra-routes-deps";
|
|
6
|
+
import { pushEntityProjectionTables, setupTestStack, } from "@cosmicdrift/kumiko-testing/stack";
|
|
7
|
+
import { logInfo } from "./dev-server-runtime.js";
|
|
8
|
+
import { isPersistentDevDb } from "./is-persistent-dev-db.js";
|
|
9
|
+
// KUMIKO_DEV_DB_NAME switches the underlying testDb from ephemeral (fresh
|
|
10
|
+
// kumiko_test_<random>, dropped on cleanup) to persistent (reuses the named
|
|
11
|
+
// DB across restarts). The var is framework-scoped on purpose — every
|
|
12
|
+
// dev-server pattern benefits from the same toggle, not just one sample.
|
|
13
|
+
//
|
|
14
|
+
// Redis is shared across boots AND across apps, so every boot needs its own
|
|
15
|
+
// queue-name prefix — otherwise two dev/e2e servers running in parallel
|
|
16
|
+
// drain each other's queues ("Unknown job: ..."). A persistent DB implies a
|
|
17
|
+
// stable prefix so jobs survive a restart; an ephemeral DB gets a fresh
|
|
18
|
+
// prefix per boot, matching the fresh DB.
|
|
19
|
+
export function resolveDevDbMode(processEnv) {
|
|
20
|
+
const devDbName = processEnv["KUMIKO_DEV_DB_NAME"];
|
|
21
|
+
const persistentDb = isPersistentDevDb(processEnv);
|
|
22
|
+
const jobQueueNamePrefix = persistentDb
|
|
23
|
+
? `kumiko-dev-${devDbName}`
|
|
24
|
+
: `kumiko-dev-${crypto.randomUUID()}`;
|
|
25
|
+
return { devDbName, persistentDb, jobQueueNamePrefix };
|
|
26
|
+
}
|
|
27
|
+
function buildDevStackOptions(options, dbMode) {
|
|
28
|
+
return {
|
|
29
|
+
features: options.features,
|
|
30
|
+
...(dbMode.persistentDb && { dbName: dbMode.devDbName, persistentDb: true }),
|
|
31
|
+
...(options.auth !== undefined && { authConfig: options.auth }),
|
|
32
|
+
...(options.extraContext !== undefined && { extraContext: options.extraContext }),
|
|
33
|
+
...(options.anonymousAccess !== undefined && { anonymousAccess: options.anonymousAccess }),
|
|
34
|
+
enrichAnonymousAccess: (base, deps) => resolveAnonymousAccessFromRegistry(base, deps),
|
|
35
|
+
...(options.files !== undefined && { files: options.files }),
|
|
36
|
+
...(options.effectiveFeatures !== undefined && {
|
|
37
|
+
effectiveFeatures: options.effectiveFeatures,
|
|
38
|
+
}),
|
|
39
|
+
...(options.extraRoutes !== undefined && { extraRoutes: options.extraRoutes }),
|
|
40
|
+
...(options.trustedProxyHops !== undefined && { trustedProxyHops: options.trustedProxyHops }),
|
|
41
|
+
...(options.errorDocs && { errorDocs: options.errorDocs }),
|
|
42
|
+
// jobs.consumerLane unset = enqueuer-only; startDevJobRunners is the sole
|
|
43
|
+
// consumer/cron-scheduler per lane, so runOnBoot/cron jobs don't
|
|
44
|
+
// double-fire.
|
|
45
|
+
jobs: { queueNamePrefix: dbMode.jobQueueNamePrefix },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export async function bootDevStack(options, dbMode) {
|
|
49
|
+
logInfo(`[kumiko-server] booting Kumiko stack${dbMode.persistentDb ? ` — persistent DB "${dbMode.devDbName}"` : " — ephemeral test DB"}…`);
|
|
50
|
+
const stack = await setupTestStack(buildDevStackOptions(options, dbMode));
|
|
51
|
+
await createEventsTable(stack.db);
|
|
52
|
+
await pushEntityProjectionTables(stack, stack.registry);
|
|
53
|
+
// `extraRoutes` is already mounted by setupTestStack's buildServer();
|
|
54
|
+
// `wire` is for co-running components that need the system-write
|
|
55
|
+
// dispatcher but don't declare a route.
|
|
56
|
+
if (options.wire !== undefined) {
|
|
57
|
+
await options.wire({
|
|
58
|
+
db: stack.db,
|
|
59
|
+
// The raw ioredis client, not the TestRedis wrapper, matching runProdApp
|
|
60
|
+
// so application code sees the same interface in development and production.
|
|
61
|
+
redis: stack.redis.redis,
|
|
62
|
+
registry: stack.registry,
|
|
63
|
+
filesForTenant: makeFilesForTenant(stack.context),
|
|
64
|
+
metricsFor: createMetricsForFactory(stack.observability.meter),
|
|
65
|
+
dispatchSystemWrite: makeDispatchSystemWrite(stack.dispatcher),
|
|
66
|
+
dispatchSystemQuery: makeDispatchSystemQuery(stack.dispatcher),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
// Runs after the entity tables exist and before the server starts, so the
|
|
70
|
+
// first request sees seeded state. Persistent-DB mode runs it on every
|
|
71
|
+
// boot, so idempotence is the caller's responsibility.
|
|
72
|
+
if (options.onAfterSetup !== undefined) {
|
|
73
|
+
await options.onAfterSetup(stack);
|
|
74
|
+
}
|
|
75
|
+
return stack;
|
|
76
|
+
}
|
|
77
|
+
// setupTestStack configures but does not start the event dispatcher so
|
|
78
|
+
// integration tests can drain it deterministically with runOnce(). The dev
|
|
79
|
+
// server starts polling so SSE broadcasts reach connected clients.
|
|
80
|
+
export async function startDevBackgroundRunners(stack, jobQueueNamePrefix) {
|
|
81
|
+
if (stack.eventDispatcher) {
|
|
82
|
+
await stack.eventDispatcher.start();
|
|
83
|
+
}
|
|
84
|
+
const redisUrl = `redis://${stack.redis.redis.options.host}:${stack.redis.redis.options.port}/${stack.redis.redis.options.db}`;
|
|
85
|
+
return startDevJobRunners({
|
|
86
|
+
registry: stack.registry,
|
|
87
|
+
db: stack.db,
|
|
88
|
+
// The stack's own AppContext, not a `{ db, registry }` literal: these
|
|
89
|
+
// runners consume the lanes, and a job reaching for ctx.files /
|
|
90
|
+
// ctx.notify / ctx.config would otherwise die here while the very same
|
|
91
|
+
// handler works on the request path.
|
|
92
|
+
context: stack.context,
|
|
93
|
+
redisUrl,
|
|
94
|
+
// Same dispatcher the request path writes through — without this,
|
|
95
|
+
// ctx.write/ctx.writeAs/ctx.queryAs inside a dev-run job throw on their
|
|
96
|
+
// first call.
|
|
97
|
+
dispatcher: stack.dispatcher,
|
|
98
|
+
// Must match setupTestStack's jobs.queueNamePrefix so enqueuer and
|
|
99
|
+
// consumer agree on queue names.
|
|
100
|
+
queueNamePrefix: jobQueueNamePrefix,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/// <reference types="bun-types" preserve="true" />
|
|
2
|
+
import type { CreateKumikoServerOptions } from "./create-kumiko-server.ts";
|
|
3
|
+
export declare const STYLESHEET_WATCH_ENV = "KUMIKO_DEV_STYLESHEET_WATCH";
|
|
4
|
+
export type TailwindPipeline = {
|
|
5
|
+
readonly outPath: string;
|
|
6
|
+
readonly kill: () => void;
|
|
7
|
+
};
|
|
8
|
+
export declare function resolveStylesheet(options: CreateKumikoServerOptions): string | undefined;
|
|
9
|
+
export declare function resolveStylesheetWatch(options: Pick<CreateKumikoServerOptions, "stylesheetWatch">, env?: Readonly<Record<string, string | undefined>>): boolean;
|
|
10
|
+
export declare function startDevStylesheet(options: CreateKumikoServerOptions): Promise<TailwindPipeline | undefined>;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/// <reference types="bun-types" preserve="true" />
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdtempSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { resolveInstalledSync } from "@cosmicdrift/kumiko-server-runtime/resolve-installed-package";
|
|
7
|
+
import { canResolveTailwindStylesheet, resolveTailwindCli, } from "@cosmicdrift/kumiko-server-runtime/resolve-tailwind-cli";
|
|
8
|
+
import { hasBun, logError, logInfo } from "./dev-server-runtime.js";
|
|
9
|
+
// Duplicated in packages/testing/src/e2e/constants.ts: the Playwright config
|
|
10
|
+
// runs under Node and cannot import this module.
|
|
11
|
+
export const STYLESHEET_WATCH_ENV = "KUMIKO_DEV_STYLESHEET_WATCH";
|
|
12
|
+
// Resolves the path to the Tailwind entry CSS:
|
|
13
|
+
// - explicit string → the resolved absolute path
|
|
14
|
+
// - false → CSS pipeline off (undefined)
|
|
15
|
+
// - undefined + client entries:
|
|
16
|
+
// 1. the app's own src/styles.css (theme override) if present
|
|
17
|
+
// 2. else the default `@cosmicdrift/kumiko-renderer/web/styles.css` via package exports
|
|
18
|
+
// - undefined + no client entry: undefined (no CSS needed)
|
|
19
|
+
//
|
|
20
|
+
// The src/styles.css auto-detection mirrors build-prod-bundle:resolveStylesheetEntry
|
|
21
|
+
// so dev and prod resolve identically. Without it every app would have to set
|
|
22
|
+
// `stylesheet: "./src/styles.css"`, or dev would fall back to the default and
|
|
23
|
+
// ignore the brand tokens.
|
|
24
|
+
export function resolveStylesheet(options) {
|
|
25
|
+
if (options.stylesheet === false)
|
|
26
|
+
return undefined;
|
|
27
|
+
if (typeof options.stylesheet === "string")
|
|
28
|
+
return resolve(options.stylesheet);
|
|
29
|
+
const hasAnyEntry = options.clientEntry !== undefined ||
|
|
30
|
+
(options.clientEntries !== undefined && options.clientEntries.length > 0);
|
|
31
|
+
if (!hasAnyEntry)
|
|
32
|
+
return undefined;
|
|
33
|
+
// The app's own src/styles.css beats the renderer/web default, same as
|
|
34
|
+
// kumiko-build, so local and prod builds match.
|
|
35
|
+
const local = resolve(process.cwd(), "src/styles.css");
|
|
36
|
+
if (existsSync(local))
|
|
37
|
+
return local;
|
|
38
|
+
// Bun.resolveSync follows package exports: `"./styles.css"` in
|
|
39
|
+
// renderer/web's package.json resolves to the workspace file in the
|
|
40
|
+
// monorepo or the node_modules file in an installed app. Bun already
|
|
41
|
+
// returns an absolute filesystem path, so `fileURLToPath` is unnecessary.
|
|
42
|
+
if (!hasBun) {
|
|
43
|
+
// Node-based unit tests reach this branch. Without Bun, package-export
|
|
44
|
+
// resolution and a real Tailwind pipeline are unavailable, so skip
|
|
45
|
+
// silently rather than reporting an error.
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
const resolved = resolveInstalledSync("@cosmicdrift/kumiko-renderer/web/styles.css", process.cwd());
|
|
49
|
+
if (resolved === undefined) {
|
|
50
|
+
logError("[kumiko-server] couldn't auto-resolve @cosmicdrift/kumiko-renderer/web/styles.css — " +
|
|
51
|
+
"pass `stylesheet: <path>` or `stylesheet: false` explicitly.");
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
return canResolveTailwindStylesheet(resolved, { bun: Bun, cwd: process.cwd() })
|
|
55
|
+
? resolved
|
|
56
|
+
: undefined;
|
|
57
|
+
}
|
|
58
|
+
// The E2E template can only reach an app's own server entry through its env,
|
|
59
|
+
// so the env has to count whenever the option is left undefined.
|
|
60
|
+
//
|
|
61
|
+
// @internal — exported for unit tests only, not re-exported from the index.
|
|
62
|
+
export function resolveStylesheetWatch(options, env = process.env) {
|
|
63
|
+
if (options.stylesheetWatch !== undefined)
|
|
64
|
+
return options.stylesheetWatch;
|
|
65
|
+
return env[STYLESHEET_WATCH_ENV] !== "0";
|
|
66
|
+
}
|
|
67
|
+
function runTailwindOneShotBuild(bunPath, cliPath, entryCss, outPath) {
|
|
68
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
69
|
+
const child = spawn(bunPath, ["run", cliPath, "-i", entryCss, "-o", outPath], {
|
|
70
|
+
stdio: "inherit",
|
|
71
|
+
});
|
|
72
|
+
child.on("exit", (code) => {
|
|
73
|
+
if (code === 0)
|
|
74
|
+
resolvePromise();
|
|
75
|
+
else
|
|
76
|
+
rejectPromise(new Error(`tailwind one-shot-build exit ${code}`));
|
|
77
|
+
});
|
|
78
|
+
child.on("error", rejectPromise);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
// unref() so the watcher cannot outlive a crashed parent as an orphan.
|
|
82
|
+
function spawnTailwindWatcher(bunPath, cliPath, entryCss, outPath) {
|
|
83
|
+
const watcher = spawn(bunPath, ["run", cliPath, "-i", entryCss, "-o", outPath, "--watch"], {
|
|
84
|
+
stdio: "inherit",
|
|
85
|
+
});
|
|
86
|
+
watcher.unref();
|
|
87
|
+
return () => {
|
|
88
|
+
try {
|
|
89
|
+
watcher.kill("SIGTERM");
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Already stopped or never started; no further action is needed.
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
// Non-fatal by design (returns undefined): if the CLI can't be resolved or
|
|
97
|
+
// the initial build fails (flaky network, missing dependency), the server
|
|
98
|
+
// boots without CSS instead of dying.
|
|
99
|
+
async function startTailwindPipeline(entryCss, { watch }) {
|
|
100
|
+
const bunResolver = hasBun ? Bun : undefined;
|
|
101
|
+
const cliPath = resolveTailwindCli({ bun: bunResolver, cwd: process.cwd() });
|
|
102
|
+
if (cliPath === undefined) {
|
|
103
|
+
logError("[kumiko-server] @tailwindcss/cli nicht auflösbar — booting ohne CSS-Pipeline. " +
|
|
104
|
+
"`bun install` und Restart, um Styles zu aktivieren.");
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
const outDir = mkdtempSync(join(tmpdir(), "kumiko-tw-"));
|
|
108
|
+
const outPath = join(outDir, "styles.css");
|
|
109
|
+
logInfo(watch
|
|
110
|
+
? `[kumiko-server] tailwind watcher → ${outPath}`
|
|
111
|
+
: `[kumiko-server] tailwind one-shot build → ${outPath}`);
|
|
112
|
+
const bunPath = process.argv[0] ?? "bun";
|
|
113
|
+
// Wait for the initial build so the first /styles.css request does not
|
|
114
|
+
// return 404; only then does the watcher take over.
|
|
115
|
+
try {
|
|
116
|
+
await runTailwindOneShotBuild(bunPath, cliPath, entryCss, outPath);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
logError("[kumiko-server] tailwind one-shot-build fehlgeschlagen — booting ohne CSS:", err);
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
if (!watch) {
|
|
123
|
+
return { outPath, kill: () => { } };
|
|
124
|
+
}
|
|
125
|
+
return { outPath, kill: spawnTailwindWatcher(bunPath, cliPath, entryCss, outPath) };
|
|
126
|
+
}
|
|
127
|
+
// The Tailwind CLI writes to a temp file that every /styles.css request reads
|
|
128
|
+
// fresh: no in-memory signalling needed, and the browser only reloads after a
|
|
129
|
+
// bundle rebuild anyway.
|
|
130
|
+
export async function startDevStylesheet(options) {
|
|
131
|
+
const resolvedStylesheet = resolveStylesheet(options);
|
|
132
|
+
if (resolvedStylesheet === undefined)
|
|
133
|
+
return undefined;
|
|
134
|
+
return startTailwindPipeline(resolvedStylesheet, { watch: resolveStylesheetWatch(options) });
|
|
135
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/// <reference types="bun-types" preserve="true" />
|
|
2
|
+
import { type ClientBundle, type ClientBundleBuilder, type NormalizedEntry } from "./create-kumiko-server-client-bundles.ts";
|
|
3
|
+
import { type DevReloadHub } from "./create-kumiko-server-reload.ts";
|
|
4
|
+
export type DirectoryWatchEvents = (dir: string, options: {
|
|
5
|
+
readonly recursive: true;
|
|
6
|
+
readonly signal: AbortSignal;
|
|
7
|
+
}) => AsyncIterable<{
|
|
8
|
+
readonly filename: string | null;
|
|
9
|
+
}>;
|
|
10
|
+
export declare function classifyChange(filename: string): "restart" | "hot-reload" | "ignore";
|
|
11
|
+
export declare function classifyWatchedChange(watchedDir: string, filename: string, entrySourceFiles: ReadonlySet<string>): ReturnType<typeof classifyChange>;
|
|
12
|
+
export type DevFileWatcherDeps = {
|
|
13
|
+
readonly entries: readonly NormalizedEntry[];
|
|
14
|
+
readonly watchDirs: readonly string[] | undefined;
|
|
15
|
+
readonly watchDirectory: DirectoryWatchEvents | undefined;
|
|
16
|
+
readonly signal: AbortSignal;
|
|
17
|
+
readonly buildBundle: ClientBundleBuilder;
|
|
18
|
+
readonly clientBundles: Map<string, ClientBundle>;
|
|
19
|
+
readonly reloadHub: DevReloadHub;
|
|
20
|
+
readonly stop: () => Promise<void>;
|
|
21
|
+
};
|
|
22
|
+
export declare function startDevFileWatchers(deps: DevFileWatcherDeps): void;
|