@cosmicdrift/kumiko-dev-server 0.386.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/LICENSE +21 -57
- package/dist/build-server-bundle.d.ts +7 -7
- package/dist/build-server-bundle.js +53 -53
- package/dist/build.js +3 -3
- package/dist/compose-stacks.d.ts +2 -2
- package/dist/compose-stacks.js +1 -2
- package/dist/crash-tracker.d.ts +3 -7
- package/dist/crash-tracker.js +4 -13
- 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 +53 -71
- package/dist/create-kumiko-server.js +59 -1076
- package/dist/dev-server-runtime.d.ts +5 -0
- package/dist/dev-server-runtime.js +11 -0
- package/dist/env-schema.d.ts +1 -1
- package/dist/env-schema.js +4 -4
- package/dist/index.d.ts +3 -1
- package/dist/index.js +17 -15
- 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 +52 -50
- package/dist/run-dev-app.js +100 -471
- package/dist/scaffold-app-feature.js +5 -4
- package/dist/scaffold-app.js +5 -7
- package/dist/schema-apply.d.ts +3 -3
- package/dist/schema-apply.js +16 -16
- package/dist/schema-tables-auth-mode.js +16 -15
- package/dist/schema-tables-minimal.js +7 -7
- package/dist/setup-test-stack-from-features.js +1 -1
- package/dist/welcome-banner.js +2 -2
- package/dist/write-server-lockfile.js +23 -16
- package/package.json +10 -10
- package/src/changes.json +30 -0
- package/templates/managed/ci-public.yml.template +4 -0
|
@@ -0,0 +1,101 @@
|
|
|
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, } 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
|
+
metricsFor: createMetricsForFactory(stack.observability.meter),
|
|
64
|
+
dispatchSystemWrite: makeDispatchSystemWrite(stack.dispatcher),
|
|
65
|
+
dispatchSystemQuery: makeDispatchSystemQuery(stack.dispatcher),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
// Runs after the entity tables exist and before the server starts, so the
|
|
69
|
+
// first request sees seeded state. Persistent-DB mode runs it on every
|
|
70
|
+
// boot, so idempotence is the caller's responsibility.
|
|
71
|
+
if (options.onAfterSetup !== undefined) {
|
|
72
|
+
await options.onAfterSetup(stack);
|
|
73
|
+
}
|
|
74
|
+
return stack;
|
|
75
|
+
}
|
|
76
|
+
// setupTestStack configures but does not start the event dispatcher so
|
|
77
|
+
// integration tests can drain it deterministically with runOnce(). The dev
|
|
78
|
+
// server starts polling so SSE broadcasts reach connected clients.
|
|
79
|
+
export async function startDevBackgroundRunners(stack, jobQueueNamePrefix) {
|
|
80
|
+
if (stack.eventDispatcher) {
|
|
81
|
+
await stack.eventDispatcher.start();
|
|
82
|
+
}
|
|
83
|
+
const redisUrl = `redis://${stack.redis.redis.options.host}:${stack.redis.redis.options.port}/${stack.redis.redis.options.db}`;
|
|
84
|
+
return startDevJobRunners({
|
|
85
|
+
registry: stack.registry,
|
|
86
|
+
db: stack.db,
|
|
87
|
+
// The stack's own AppContext, not a `{ db, registry }` literal: these
|
|
88
|
+
// runners consume the lanes, and a job reaching for ctx.files /
|
|
89
|
+
// ctx.notify / ctx.config would otherwise die here while the very same
|
|
90
|
+
// handler works on the request path.
|
|
91
|
+
context: stack.context,
|
|
92
|
+
redisUrl,
|
|
93
|
+
// Same dispatcher the request path writes through — without this,
|
|
94
|
+
// ctx.write/ctx.writeAs/ctx.queryAs inside a dev-run job throw on their
|
|
95
|
+
// first call.
|
|
96
|
+
dispatcher: stack.dispatcher,
|
|
97
|
+
// Must match setupTestStack's jobs.queueNamePrefix so enqueuer and
|
|
98
|
+
// consumer agree on queue names.
|
|
99
|
+
queueNamePrefix: jobQueueNamePrefix,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -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,137 @@
|
|
|
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 { canResolveTailwindStylesheet, resolveTailwindCli, } from "@cosmicdrift/kumiko-server-runtime/resolve-tailwind-cli";
|
|
7
|
+
import { hasBun, logError, logInfo } from "./dev-server-runtime.js";
|
|
8
|
+
// Duplicated in packages/testing/src/e2e/constants.ts: the Playwright config
|
|
9
|
+
// runs under Node and cannot import this module.
|
|
10
|
+
export const STYLESHEET_WATCH_ENV = "KUMIKO_DEV_STYLESHEET_WATCH";
|
|
11
|
+
// Resolves the path to the Tailwind entry CSS:
|
|
12
|
+
// - explicit string → the resolved absolute path
|
|
13
|
+
// - false → CSS pipeline off (undefined)
|
|
14
|
+
// - undefined + client entries:
|
|
15
|
+
// 1. the app's own src/styles.css (theme override) if present
|
|
16
|
+
// 2. else the default `@cosmicdrift/kumiko-renderer/web/styles.css` via package exports
|
|
17
|
+
// - undefined + no client entry: undefined (no CSS needed)
|
|
18
|
+
//
|
|
19
|
+
// The src/styles.css auto-detection mirrors build-prod-bundle:resolveStylesheetEntry
|
|
20
|
+
// so dev and prod resolve identically. Without it every app would have to set
|
|
21
|
+
// `stylesheet: "./src/styles.css"`, or dev would fall back to the default and
|
|
22
|
+
// ignore the brand tokens.
|
|
23
|
+
export function resolveStylesheet(options) {
|
|
24
|
+
if (options.stylesheet === false)
|
|
25
|
+
return undefined;
|
|
26
|
+
if (typeof options.stylesheet === "string")
|
|
27
|
+
return resolve(options.stylesheet);
|
|
28
|
+
const hasAnyEntry = options.clientEntry !== undefined ||
|
|
29
|
+
(options.clientEntries !== undefined && options.clientEntries.length > 0);
|
|
30
|
+
if (!hasAnyEntry)
|
|
31
|
+
return undefined;
|
|
32
|
+
// The app's own src/styles.css beats the renderer/web default, same as
|
|
33
|
+
// kumiko-build, so local and prod builds match.
|
|
34
|
+
const local = resolve(process.cwd(), "src/styles.css");
|
|
35
|
+
if (existsSync(local))
|
|
36
|
+
return local;
|
|
37
|
+
// Bun.resolveSync follows package exports: `"./styles.css"` in
|
|
38
|
+
// renderer/web's package.json resolves to the workspace file in the
|
|
39
|
+
// monorepo or the node_modules file in an installed app. Bun already
|
|
40
|
+
// returns an absolute filesystem path, so `fileURLToPath` is unnecessary.
|
|
41
|
+
if (!hasBun) {
|
|
42
|
+
// Node-based unit tests reach this branch. Without Bun, package-export
|
|
43
|
+
// resolution and a real Tailwind pipeline are unavailable, so skip
|
|
44
|
+
// silently rather than reporting an error.
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const resolved = Bun.resolveSync("@cosmicdrift/kumiko-renderer/web/styles.css", process.cwd());
|
|
49
|
+
if (!canResolveTailwindStylesheet(resolved, { bun: Bun, cwd: process.cwd() })) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
return resolved;
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
logError("[kumiko-server] couldn't auto-resolve @cosmicdrift/kumiko-renderer/web/styles.css — " +
|
|
56
|
+
"pass `stylesheet: <path>` or `stylesheet: false` explicitly.", err);
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// The E2E template can only reach an app's own server entry through its env,
|
|
61
|
+
// so the env has to count whenever the option is left undefined.
|
|
62
|
+
//
|
|
63
|
+
// @internal — exported for unit tests only, not re-exported from the index.
|
|
64
|
+
export function resolveStylesheetWatch(options, env = process.env) {
|
|
65
|
+
if (options.stylesheetWatch !== undefined)
|
|
66
|
+
return options.stylesheetWatch;
|
|
67
|
+
return env[STYLESHEET_WATCH_ENV] !== "0";
|
|
68
|
+
}
|
|
69
|
+
function runTailwindOneShotBuild(bunPath, cliPath, entryCss, outPath) {
|
|
70
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
71
|
+
const child = spawn(bunPath, ["run", cliPath, "-i", entryCss, "-o", outPath], {
|
|
72
|
+
stdio: "inherit",
|
|
73
|
+
});
|
|
74
|
+
child.on("exit", (code) => {
|
|
75
|
+
if (code === 0)
|
|
76
|
+
resolvePromise();
|
|
77
|
+
else
|
|
78
|
+
rejectPromise(new Error(`tailwind one-shot-build exit ${code}`));
|
|
79
|
+
});
|
|
80
|
+
child.on("error", rejectPromise);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
// unref() so the watcher cannot outlive a crashed parent as an orphan.
|
|
84
|
+
function spawnTailwindWatcher(bunPath, cliPath, entryCss, outPath) {
|
|
85
|
+
const watcher = spawn(bunPath, ["run", cliPath, "-i", entryCss, "-o", outPath, "--watch"], {
|
|
86
|
+
stdio: "inherit",
|
|
87
|
+
});
|
|
88
|
+
watcher.unref();
|
|
89
|
+
return () => {
|
|
90
|
+
try {
|
|
91
|
+
watcher.kill("SIGTERM");
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Already stopped or never started; no further action is needed.
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
// Non-fatal by design (returns undefined): if the CLI can't be resolved or
|
|
99
|
+
// the initial build fails (flaky network, missing dependency), the server
|
|
100
|
+
// boots without CSS instead of dying.
|
|
101
|
+
async function startTailwindPipeline(entryCss, { watch }) {
|
|
102
|
+
const bunResolver = hasBun ? Bun : undefined;
|
|
103
|
+
const cliPath = resolveTailwindCli({ bun: bunResolver, cwd: process.cwd() });
|
|
104
|
+
if (cliPath === undefined) {
|
|
105
|
+
logError("[kumiko-server] @tailwindcss/cli nicht auflösbar — booting ohne CSS-Pipeline. " +
|
|
106
|
+
"`bun install` und Restart, um Styles zu aktivieren.");
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
const outDir = mkdtempSync(join(tmpdir(), "kumiko-tw-"));
|
|
110
|
+
const outPath = join(outDir, "styles.css");
|
|
111
|
+
logInfo(watch
|
|
112
|
+
? `[kumiko-server] tailwind watcher → ${outPath}`
|
|
113
|
+
: `[kumiko-server] tailwind one-shot build → ${outPath}`);
|
|
114
|
+
const bunPath = process.argv[0] ?? "bun";
|
|
115
|
+
// Wait for the initial build so the first /styles.css request does not
|
|
116
|
+
// return 404; only then does the watcher take over.
|
|
117
|
+
try {
|
|
118
|
+
await runTailwindOneShotBuild(bunPath, cliPath, entryCss, outPath);
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
logError("[kumiko-server] tailwind one-shot-build fehlgeschlagen — booting ohne CSS:", err);
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
if (!watch) {
|
|
125
|
+
return { outPath, kill: () => { } };
|
|
126
|
+
}
|
|
127
|
+
return { outPath, kill: spawnTailwindWatcher(bunPath, cliPath, entryCss, outPath) };
|
|
128
|
+
}
|
|
129
|
+
// The Tailwind CLI writes to a temp file that every /styles.css request reads
|
|
130
|
+
// fresh: no in-memory signalling needed, and the browser only reloads after a
|
|
131
|
+
// bundle rebuild anyway.
|
|
132
|
+
export async function startDevStylesheet(options) {
|
|
133
|
+
const resolvedStylesheet = resolveStylesheet(options);
|
|
134
|
+
if (resolvedStylesheet === undefined)
|
|
135
|
+
return undefined;
|
|
136
|
+
return startTailwindPipeline(resolvedStylesheet, { watch: resolveStylesheetWatch(options) });
|
|
137
|
+
}
|
|
@@ -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;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/// <reference types="bun-types" preserve="true" />
|
|
2
|
+
import { statSync } from "node:fs";
|
|
3
|
+
import { watch } from "node:fs/promises";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { rebuildClientBundles, } from "./create-kumiko-server-client-bundles.js";
|
|
6
|
+
import { broadcastReload } from "./create-kumiko-server-reload.js";
|
|
7
|
+
import { logInfo } from "./dev-server-runtime.js";
|
|
8
|
+
async function watchDir(dir, onChange, signal, watchEvents) {
|
|
9
|
+
// The server's stop() aborts the signal; without it, the for-await loop
|
|
10
|
+
// would continue until process exit. During test teardown, removing the
|
|
11
|
+
// temporary directory could then be classified as a restart and call
|
|
12
|
+
// process.exit(75), surfacing as an unhandled Vitest error.
|
|
13
|
+
const watcher = watchEvents(dir, { recursive: true, signal });
|
|
14
|
+
try {
|
|
15
|
+
for await (const ev of watcher) {
|
|
16
|
+
if (ev.filename)
|
|
17
|
+
onChange(ev.filename);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
// signal.abort() causes the async iterator to throw AbortError during
|
|
22
|
+
// expected teardown; other errors must propagate.
|
|
23
|
+
// skip: AbortSignal fired the abort, this is expected teardown not a real error
|
|
24
|
+
if (typeof err === "object" && err !== null && "name" in err && err.name === "AbortError")
|
|
25
|
+
return;
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const WATCHED_SOURCE_SUFFIXES = [".ts", ".tsx"];
|
|
30
|
+
const TEST_ONLY_SUFFIXES = [
|
|
31
|
+
".test.ts",
|
|
32
|
+
".test.tsx",
|
|
33
|
+
".integration.ts",
|
|
34
|
+
".integration.test.ts",
|
|
35
|
+
".e2e.ts",
|
|
36
|
+
];
|
|
37
|
+
const CLIENT_SUBDIRS = ["web", "admin", "public", "client"];
|
|
38
|
+
const CLIENT_ENTRY_SUFFIXES = ["/client.tsx", "/client.ts"];
|
|
39
|
+
function endsWithAny(filename, suffixes) {
|
|
40
|
+
return suffixes.some((suffix) => filename.endsWith(suffix));
|
|
41
|
+
}
|
|
42
|
+
function isTestOnlyFile(filename) {
|
|
43
|
+
return filename.includes("__tests__") || endsWithAny(filename, TEST_ONLY_SUFFIXES);
|
|
44
|
+
}
|
|
45
|
+
// Platform-independent: checks both POSIX and Windows separators. Matches
|
|
46
|
+
// `<sep><dir><sep>` and a leading `<dir><sep>` for watcher filenames
|
|
47
|
+
// supplied as relative paths.
|
|
48
|
+
function isInClientSubdir(filename) {
|
|
49
|
+
return CLIENT_SUBDIRS.some((dir) => filename.includes(`/${dir}/`) ||
|
|
50
|
+
filename.includes(`\\${dir}\\`) ||
|
|
51
|
+
filename.startsWith(`${dir}/`) ||
|
|
52
|
+
filename.startsWith(`${dir}\\`));
|
|
53
|
+
}
|
|
54
|
+
// Classifies a changed file as `hot-reload` for client-side changes,
|
|
55
|
+
// `restart` for server-side changes whose cached Bun modules must be
|
|
56
|
+
// reloaded, or `ignore` when the server is unaffected.
|
|
57
|
+
//
|
|
58
|
+
// Tests and non-TypeScript files are ignored. TypeScript files under
|
|
59
|
+
// `/web/`, `/admin/`, `/public/`, or `/client/`, as well as the client
|
|
60
|
+
// entry itself, trigger hot reload; other TypeScript files trigger a
|
|
61
|
+
// restart because they may contain schema or feature definitions.
|
|
62
|
+
//
|
|
63
|
+
// The multiple client directories are intentional: samples use different
|
|
64
|
+
// layouts. Restarting for a client-only edit would be slower, drop the
|
|
65
|
+
// ephemeral test database, and reseed it unnecessarily.
|
|
66
|
+
export function classifyChange(filename) {
|
|
67
|
+
if (!endsWithAny(filename, WATCHED_SOURCE_SUFFIXES))
|
|
68
|
+
return "ignore";
|
|
69
|
+
if (isTestOnlyFile(filename))
|
|
70
|
+
return "ignore";
|
|
71
|
+
if (isInClientSubdir(filename) || endsWithAny(filename, CLIENT_ENTRY_SUFFIXES)) {
|
|
72
|
+
return "hot-reload";
|
|
73
|
+
}
|
|
74
|
+
return "restart";
|
|
75
|
+
}
|
|
76
|
+
// The watcher reports paths relative to the watched directory, so an entry arrives as a bare
|
|
77
|
+
// "client.tsx" (or a custom name like "client-admin.tsx") that classifyChange alone reads as a
|
|
78
|
+
// server file and answers with a restart.
|
|
79
|
+
export function classifyWatchedChange(watchedDir, filename, entrySourceFiles) {
|
|
80
|
+
return entrySourceFiles.has(resolve(watchedDir, filename))
|
|
81
|
+
? "hot-reload"
|
|
82
|
+
: classifyChange(filename);
|
|
83
|
+
}
|
|
84
|
+
// Expands watchDirs patterns into concrete directories. Entries without
|
|
85
|
+
// `*` are resolved as ordinary paths; entries with `*` are glob-expanded
|
|
86
|
+
// and only directory matches are retained, so `"../../../packages/*/src"`
|
|
87
|
+
// replaces a list of hard-coded paths. Expansion runs once at boot,
|
|
88
|
+
// not while the watcher is running.
|
|
89
|
+
function expandWatchPatterns(patterns) {
|
|
90
|
+
const out = [];
|
|
91
|
+
for (const p of patterns) {
|
|
92
|
+
if (!p.includes("*")) {
|
|
93
|
+
out.push(resolve(p));
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
// Only reachable under Bun (createKumikoServer is Bun-only); without Bun the
|
|
97
|
+
// call stack fails fast in Bun.serve anyway.
|
|
98
|
+
if (typeof Bun === "undefined")
|
|
99
|
+
throw new Error("expandWatchPatterns requires Bun.Glob");
|
|
100
|
+
const glob = new Bun.Glob(p);
|
|
101
|
+
const matches = Array.from(glob.scanSync({ onlyFiles: false, cwd: process.cwd() }));
|
|
102
|
+
for (const m of matches) {
|
|
103
|
+
const abs = resolve(m);
|
|
104
|
+
try {
|
|
105
|
+
if (statSync(abs).isDirectory())
|
|
106
|
+
out.push(abs);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// ignore unreadable matches, typically broken symlinks
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
// Because Bun caches module imports, a server-side change needs a process
|
|
116
|
+
// restart: exit code 75 (EX_TEMPFAIL) lets the `kumiko-dev` wrapper respawn.
|
|
117
|
+
async function handleWatchedChange(deps, dir, filename, entrySourceFiles) {
|
|
118
|
+
const action = classifyWatchedChange(dir, filename, entrySourceFiles);
|
|
119
|
+
// skip: file change classified as ignore (test/css/json), nothing to rebuild
|
|
120
|
+
if (action === "ignore")
|
|
121
|
+
return;
|
|
122
|
+
if (action === "restart") {
|
|
123
|
+
logInfo(`[kumiko-server] schema change in ${filename} — restarting (Bun caches imports, hot-reload reicht hier nicht). ` +
|
|
124
|
+
"The kumiko-dev wrapper (package.json's `dev` script) respawns automatically " +
|
|
125
|
+
"and re-syncs new r.entity() columns on the new boot; running this entry " +
|
|
126
|
+
"directly (not via kumiko-dev) exits here without restarting.");
|
|
127
|
+
await deps.stop();
|
|
128
|
+
process.exit(75);
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
await rebuildClientBundles(deps.entries, deps.buildBundle, deps.clientBundles);
|
|
132
|
+
logInfo(`[kumiko-server] rebuilt on ${filename}, broadcasting reload`);
|
|
133
|
+
broadcastReload(deps.reloadHub, filename);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// buildClient already logged the failure; keep serving the
|
|
137
|
+
// last good bundle until the next successful rebuild.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Watches every entry directory plus the explicit watchDirs. Multi-entry
|
|
141
|
+
// setups often share one src/ directory, so the Set keeps one watcher per
|
|
142
|
+
// directory.
|
|
143
|
+
export function startDevFileWatchers(deps) {
|
|
144
|
+
// skip: without a client entry there is no bundle to rebuild, so watchDirs stay unwatched too
|
|
145
|
+
if (deps.entries.length === 0)
|
|
146
|
+
return;
|
|
147
|
+
const entryDirs = new Set();
|
|
148
|
+
for (const e of deps.entries)
|
|
149
|
+
entryDirs.add(resolve(e.sourceFile, ".."));
|
|
150
|
+
const entrySourceFiles = new Set(deps.entries.map((e) => resolve(e.sourceFile)));
|
|
151
|
+
const dirs = [...entryDirs, ...expandWatchPatterns(deps.watchDirs ?? [])];
|
|
152
|
+
for (const dir of dirs) {
|
|
153
|
+
void watchDir(dir, (filename) => handleWatchedChange(deps, dir, filename, entrySourceFiles), deps.signal, deps.watchDirectory ?? watch);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/// <reference types="bun-types" preserve="true" />
|
|
2
|
-
import {
|
|
2
|
+
import type { AuthRoutesConfig, ExtraRouteDefinition } from "@cosmicdrift/kumiko-framework/api";
|
|
3
3
|
import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
4
|
import type { ErrorDocsConfig } from "@cosmicdrift/kumiko-framework/errors";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
import type { PageHeadResolver, PageHeadSystemQuery } from "@cosmicdrift/kumiko-renderer/headless/apex";
|
|
6
|
+
import type { SystemWireDeps } from "@cosmicdrift/kumiko-server-runtime/extra-routes-deps";
|
|
7
|
+
import type { TestStack, TestStackOptions } from "@cosmicdrift/kumiko-testing/stack";
|
|
8
|
+
import { type DirectoryWatchEvents } from "./create-kumiko-server-watcher.ts";
|
|
9
|
+
import { type BunServer } from "./dev-server-runtime.ts";
|
|
10
10
|
/** Multi-entry mode for apps that ship several separate bundles (e.g.
|
|
11
11
|
* publicstatus: `admin.<base>` loads the admin UI, otherwise the public
|
|
12
12
|
* page).
|
|
@@ -22,10 +22,9 @@ export type DevClientEntry = {
|
|
|
22
22
|
* `kumiko.clientEntries[].name` so dev and prod produce identical
|
|
23
23
|
* asset URLs (`/client-<name>.js`). */
|
|
24
24
|
readonly name: string;
|
|
25
|
-
/**
|
|
25
|
+
/** Absolute path to the browser entry file. */
|
|
26
26
|
readonly sourceFile: string;
|
|
27
|
-
/** Optional
|
|
28
|
-
* wird `htmlPath` (das default-Template) für alle Entries genutzt. */
|
|
27
|
+
/** Optional HTML template for this entry. If omitted, `htmlPath` (the default template) is used for all entries. */
|
|
29
28
|
readonly htmlPath?: string;
|
|
30
29
|
};
|
|
31
30
|
/** Dev counterpart of `runProdApp.hostDispatch`. Not the same shape: dev names
|
|
@@ -58,8 +57,7 @@ export type DevHostDispatchResult = {
|
|
|
58
57
|
readonly entryName?: string;
|
|
59
58
|
readonly file?: string;
|
|
60
59
|
};
|
|
61
|
-
/** Picks an entry by inspecting the incoming request.
|
|
62
|
-
* Multi-Entry-Apps gesetzt; im Single-Entry-Mode irrelevant. */
|
|
60
|
+
/** Picks an entry by inspecting the incoming request. Set by multi-entry apps; irrelevant in single-entry mode. */
|
|
63
61
|
export type DevHostDispatch = (req: Request, deps: {
|
|
64
62
|
readonly systemQuery: PageHeadSystemQuery;
|
|
65
63
|
/** Always empty: dev serves no per-host CSP. */
|
|
@@ -69,27 +67,26 @@ export type CreateKumikoServerOptions = {
|
|
|
69
67
|
/** Features whose entities, handlers, and screens get wired into the
|
|
70
68
|
* dev stack. Pass every feature the app is supposed to run. */
|
|
71
69
|
readonly features: readonly FeatureDefinition[];
|
|
72
|
-
/** Absolute path to the browser entry module. The dev
|
|
70
|
+
/** Absolute path to the browser entry module. The dev server runs
|
|
73
71
|
* `Bun.build` on it and serves the output at `/client.js`. Omit to
|
|
74
|
-
* run a headless API-only dev
|
|
75
|
-
*
|
|
72
|
+
* run a headless API-only dev stack. Mutually exclusive with
|
|
73
|
+
* `clientEntries`. */
|
|
76
74
|
readonly clientEntry?: string;
|
|
77
|
-
/** Multi-
|
|
78
|
-
* `/client-<name>.js`. Mutually exclusive
|
|
79
|
-
* `hostDispatch`
|
|
80
|
-
*
|
|
75
|
+
/** Multi-entry mode: separate bundles served at
|
|
76
|
+
* `/client-<name>.js`. Mutually exclusive with `clientEntry`. Set
|
|
77
|
+
* `hostDispatch` as well; otherwise the server cannot determine which
|
|
78
|
+
* template to return. */
|
|
81
79
|
readonly clientEntries?: readonly DevClientEntry[];
|
|
82
|
-
/** Multi-
|
|
83
|
-
*
|
|
84
|
-
* (html → entry
|
|
85
|
-
*
|
|
86
|
-
*
|
|
80
|
+
/** Multi-entry request routing. Inspects `Host` (or another request
|
|
81
|
+
* property) and returns a discriminated union
|
|
82
|
+
* (`html` → entry bundle, `redirect` → 30x, `not-found` → 404).
|
|
83
|
+
* Mirrors `runProdApp.hostDispatch` so dev and production routing
|
|
84
|
+
* remain aligned. */
|
|
87
85
|
readonly hostDispatch?: DevHostDispatch;
|
|
88
|
-
/** @internal —
|
|
89
|
-
* Bun-
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* wissen dass das ein Test-Seam ist. */
|
|
86
|
+
/** @internal — replaces `Bun.build` in tests. The default uses the real
|
|
87
|
+
* Bun toolchain; Node-based tests inject a stub so the routing path can
|
|
88
|
+
* run without calling Bun.build.
|
|
89
|
+
* Not part of the public API; the `_` prefix identifies this test seam. */
|
|
93
90
|
readonly _buildBundle?: (sourceFile: string) => Promise<{
|
|
94
91
|
readonly js: string;
|
|
95
92
|
readonly map: string;
|
|
@@ -128,41 +125,40 @@ export type CreateKumikoServerOptions = {
|
|
|
128
125
|
* this so repeated `createKumikoServer` calls don't accumulate
|
|
129
126
|
* listeners on the process. Default true (dev-server behaviour). */
|
|
130
127
|
readonly installSignalHandlers?: boolean;
|
|
131
|
-
/** Auth
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
* password) via `features` drin sein. */
|
|
128
|
+
/** Auth route configuration (login, tenants, switch-tenant, logout). When
|
|
129
|
+
* set, disables automatic JWT minting on GET /; the client is responsible
|
|
130
|
+
* for login. The corresponding features (user/tenant/auth-email-password)
|
|
131
|
+
* must also be included through `features` for these routes to work. */
|
|
136
132
|
readonly auth?: AuthRoutesConfig;
|
|
137
|
-
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
133
|
+
/** Additional AppContext keys, such as `configResolver` for the config
|
|
134
|
+
* feature. Forwarded to `setupTestStack`; see `TestStackOptions` for
|
|
135
|
+
* the accepted shapes (object or factory function). */
|
|
140
136
|
readonly extraContext?: TestStackOptions["extraContext"];
|
|
141
|
-
/**
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
137
|
+
/** Enables anonymous access: requests without a JWT proceed as a
|
|
138
|
+
* pseudo-user with the `anonymous` role when the handler declares
|
|
139
|
+
* `roles: ["anonymous"]`. Tenant resolution uses a header, cookie, or
|
|
140
|
+
* default; see `AnonymousAccessConfig`. */
|
|
145
141
|
readonly anonymousAccess?: TestStackOptions["anonymousAccess"];
|
|
146
|
-
/** File-
|
|
147
|
-
*
|
|
148
|
-
* `ctx.files
|
|
142
|
+
/** File-storage provider forwarded to `setupTestStack`. When set, the
|
|
143
|
+
* stack mounts the upload routes (POST/GET/DELETE /api/files), exposes
|
|
144
|
+
* `ctx.files`, and creates the `file_refs` table. Example:
|
|
149
145
|
* `{ storageProvider: createInMemoryFileProvider() }`. */
|
|
150
146
|
readonly files?: TestStackOptions["files"];
|
|
151
|
-
/** Feature-toggle resolver
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
147
|
+
/** Feature-toggle resolver forwarded to `setupTestStack`. When set, the
|
|
148
|
+
* dispatcher feature gate, hook filter, and MSP filter consult it;
|
|
149
|
+
* absent means all features remain enabled. Required for per-tenant
|
|
150
|
+
* feature composition. The typical production implementation is
|
|
151
|
+
* `() => globalFeatureToggleRuntime.effectiveFeatures` after boot,
|
|
152
|
+
* using the createLateBoundHolder pattern because the runtime needs
|
|
153
|
+
* `stack.db`. */
|
|
158
154
|
readonly effectiveFeatures?: TestStackOptions["effectiveFeatures"];
|
|
159
|
-
/**
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
155
|
+
/** Called after entity tables are set up. Use this hook for non-entity
|
|
156
|
+
* tables (`unsafePushTables`) and seeding (admin user, initial tenant,
|
|
157
|
+
* and so on). Must be idempotent because persistent-DB mode runs it on
|
|
158
|
+
* every boot. */
|
|
163
159
|
readonly onAfterSetup?: (stack: TestStack) => Promise<void>;
|
|
164
160
|
/** Declarative HTTP-routes outside the /api/write|query|batch pipeline —
|
|
165
|
-
* symmetric to runProdApp.extraRoutes
|
|
161
|
+
* symmetric to runProdApp.extraRoutes. Mounted
|
|
166
162
|
* inside buildServer (setupTestStack), before the Static/HTML-fallback,
|
|
167
163
|
* so an own GET (/feed.xml, /og-image, …) wins over the dev-asset path. */
|
|
168
164
|
readonly extraRoutes?: readonly ExtraRouteDefinition[];
|
|
@@ -179,8 +175,8 @@ export type CreateKumikoServerOptions = {
|
|
|
179
175
|
/** Per-request head-metadata resolver (Open-Graph/title/description) —
|
|
180
176
|
* same option, same `PageHeadResolver` signature, and same shared
|
|
181
177
|
* resolve+inject call (`resolveAndInjectPageHead`) as `runProdApp`'s
|
|
182
|
-
* `resolvePageHead`, so dev and e2e exercise the exact code prod does
|
|
183
|
-
*
|
|
178
|
+
* `resolvePageHead`, so dev and e2e exercise the exact code prod does.
|
|
179
|
+
* Consulted for every templated HTML response
|
|
184
180
|
* (the default single-entry shell and a host-dispatched `"html"` entry),
|
|
185
181
|
* right before it's served. On error, `null`, or a timeout, the shell
|
|
186
182
|
* ships unchanged with status 200 — never a 500. `systemQuery` inside
|
|
@@ -205,18 +201,4 @@ export type KumikoServerHandle = {
|
|
|
205
201
|
* its `bull:<prefix>-*` keys belong to this server alone on a shared Redis. */
|
|
206
202
|
readonly jobQueueNamePrefix: string;
|
|
207
203
|
};
|
|
208
|
-
export declare const STYLESHEET_WATCH_ENV = "KUMIKO_DEV_STYLESHEET_WATCH";
|
|
209
|
-
export declare const PROD_BUNDLES_ENV = "KUMIKO_DEV_PROD_BUNDLES";
|
|
210
|
-
export declare const DEFAULT_HTML = "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width,initial-scale=1,interactive-widget=resizes-content\" />\n <title>Kumiko</title>\n </head>\n <body>\n <div id=\"root\"></div>\n <script type=\"module\" src=\"/client.js\"></script>\n </body>\n</html>\n";
|
|
211
|
-
type DirectoryWatchEvents = (dir: string, options: {
|
|
212
|
-
readonly recursive: true;
|
|
213
|
-
readonly signal: AbortSignal;
|
|
214
|
-
}) => AsyncIterable<{
|
|
215
|
-
readonly filename: string | null;
|
|
216
|
-
}>;
|
|
217
|
-
export declare function classifyChange(filename: string): "restart" | "hot-reload" | "ignore";
|
|
218
|
-
export declare function classifyWatchedChange(watchedDir: string, filename: string, entrySourceFiles: ReadonlySet<string>): ReturnType<typeof classifyChange>;
|
|
219
|
-
export declare function resolveStylesheet(options: CreateKumikoServerOptions): string | undefined;
|
|
220
|
-
export declare function resolveStylesheetWatch(options: Pick<CreateKumikoServerOptions, "stylesheetWatch">, env?: Readonly<Record<string, string | undefined>>): boolean;
|
|
221
204
|
export declare function createKumikoServer(options: CreateKumikoServerOptions): Promise<KumikoServerHandle>;
|
|
222
|
-
export {};
|