@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.
Files changed (56) hide show
  1. package/LICENSE +21 -57
  2. package/dist/build-server-bundle.d.ts +7 -7
  3. package/dist/build-server-bundle.js +53 -53
  4. package/dist/build.js +3 -3
  5. package/dist/compose-stacks.d.ts +2 -2
  6. package/dist/compose-stacks.js +1 -2
  7. package/dist/crash-tracker.d.ts +3 -7
  8. package/dist/crash-tracker.js +4 -13
  9. package/dist/create-kumiko-server-client-bundles.d.ts +21 -0
  10. package/dist/create-kumiko-server-client-bundles.js +97 -0
  11. package/dist/create-kumiko-server-html.d.ts +21 -0
  12. package/dist/create-kumiko-server-html.js +132 -0
  13. package/dist/create-kumiko-server-public-files.d.ts +6 -0
  14. package/dist/create-kumiko-server-public-files.js +96 -0
  15. package/dist/create-kumiko-server-reload.d.ts +14 -0
  16. package/dist/create-kumiko-server-reload.js +73 -0
  17. package/dist/create-kumiko-server-routes.d.ts +16 -0
  18. package/dist/create-kumiko-server-routes.js +206 -0
  19. package/dist/create-kumiko-server-shutdown.d.ts +14 -0
  20. package/dist/create-kumiko-server-shutdown.js +72 -0
  21. package/dist/create-kumiko-server-stack.d.ts +12 -0
  22. package/dist/create-kumiko-server-stack.js +101 -0
  23. package/dist/create-kumiko-server-stylesheet.d.ts +10 -0
  24. package/dist/create-kumiko-server-stylesheet.js +137 -0
  25. package/dist/create-kumiko-server-watcher.d.ts +22 -0
  26. package/dist/create-kumiko-server-watcher.js +155 -0
  27. package/dist/create-kumiko-server.d.ts +53 -71
  28. package/dist/create-kumiko-server.js +59 -1076
  29. package/dist/dev-server-runtime.d.ts +5 -0
  30. package/dist/dev-server-runtime.js +11 -0
  31. package/dist/env-schema.d.ts +1 -1
  32. package/dist/env-schema.js +4 -4
  33. package/dist/index.d.ts +3 -1
  34. package/dist/index.js +17 -15
  35. package/dist/managed-files/app-jobs.js +1 -1
  36. package/dist/managed-files/check.js +1 -1
  37. package/dist/run-dev-app-auth.d.ts +38 -0
  38. package/dist/run-dev-app-auth.js +155 -0
  39. package/dist/run-dev-app-config.d.ts +26 -0
  40. package/dist/run-dev-app-config.js +185 -0
  41. package/dist/run-dev-app-server-options.d.ts +13 -0
  42. package/dist/run-dev-app-server-options.js +49 -0
  43. package/dist/run-dev-app.d.ts +52 -50
  44. package/dist/run-dev-app.js +100 -471
  45. package/dist/scaffold-app-feature.js +5 -4
  46. package/dist/scaffold-app.js +5 -7
  47. package/dist/schema-apply.d.ts +3 -3
  48. package/dist/schema-apply.js +16 -16
  49. package/dist/schema-tables-auth-mode.js +16 -15
  50. package/dist/schema-tables-minimal.js +7 -7
  51. package/dist/setup-test-stack-from-features.js +1 -1
  52. package/dist/welcome-banner.js +2 -2
  53. package/dist/write-server-lockfile.js +23 -16
  54. package/package.json +10 -10
  55. package/src/changes.json +30 -0
  56. 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 { type AuthRoutesConfig, type ExtraRouteDefinition } from "@cosmicdrift/kumiko-framework/api";
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 { type PageHeadResolver, type PageHeadSystemQuery } from "@cosmicdrift/kumiko-renderer/headless/apex";
6
- import { type SystemWireDeps } from "@cosmicdrift/kumiko-server-runtime/extra-routes-deps";
7
- import { type TestStack, type TestStackOptions } from "@cosmicdrift/kumiko-testing/stack";
8
- type BunServer = typeof Bun extends undefined ? unknown : ReturnType<typeof Bun.serve>;
9
- export declare const logInfo: (msg: string) => void;
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
- /** Absoluter Pfad zur Browser-Entry-Datei. */
25
+ /** Absolute path to the browser entry file. */
26
26
  readonly sourceFile: string;
27
- /** Optional eigenes HTML-Template für diesen Entry. Wenn nicht gesetzt,
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. Wird von
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-server runs
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-stack (rare — every sample has one).
75
- * Mutually exclusive mit `clientEntries`. */
72
+ * run a headless API-only dev stack. Mutually exclusive with
73
+ * `clientEntries`. */
76
74
  readonly clientEntry?: string;
77
- /** Multi-Entry-Mode: mehrere getrennte Bundles, jeweils unter
78
- * `/client-<name>.js`. Mutually exclusive mit `clientEntry`. Setze
79
- * `hostDispatch` mit, sonst bleibt unklar welches Template zurück-
80
- * geht. */
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-Entry-Mode: Routing pro Request. Inspiziert `Host` (oder
83
- * was auch immer) und liefert eine Discriminated-Union zurück
84
- * (html → entry-bundle, redirect → 30x, not-found → 404).
85
- * Symmetric zu `runProdApp.hostDispatch` damit dev/prod-Drift
86
- * beim Routing unmöglich ist. */
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 — ersetzt `Bun.build` für Tests. Default ruft die echte
89
- * Bun-Toolchain. Tests unter Node injizieren einen Stub damit der
90
- * Routing-Pfad treibbar bleibt ohne Bun.build aufzurufen.
91
- * KEIN Public-API-Surface — präfixiert mit `_` damit Konsumenten
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-Route-Config (login, tenants, switch-tenant, logout). Wenn
132
- * gesetzt wird die Auto-JWT-Mint auf GET / abgeschaltet — der
133
- * Client ist dann selbst fürs Login zuständig. Zur echten Wirkung
134
- * brauchen die dazugehörigen Features (user/tenant/auth-email-
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
- /** Extra-AppContext-Keys (z.B. configResolver für config-feature).
138
- * Wird an setupTestStack weitergereicht. Siehe TestStackOptions
139
- * für die erlaubten Shapes (object oder factory-function). */
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
- /** Anonymous-Access aktivieren — Requests ohne JWT werden als
142
- * Pseudo-User mit Rolle `anonymous` durchgelassen, sofern der
143
- * Handler `roles: ["anonymous"]` deklariert. Tenant-Resolution per
144
- * Header/Cookie/Default; siehe AnonymousAccessConfig. */
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-Storage-Provider — durchgereicht an setupTestStack. Wenn gesetzt,
147
- * mountet der Stack die Upload-Routes (POST/GET/DELETE /api/files) +
148
- * `ctx.files` und legt die file_refs-Tabelle an. Für Demos:
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 — durchgereicht an setupTestStack. Wenn
152
- * gesetzt, konsultiert der dispatcher-feature-gate, hook-filter, MSP-
153
- * filter den callback; absent = alle features always-on. Erforderlich
154
- * für Tier-Composition (Sprint 8) wo per-Tenant unterschiedliche
155
- * features aktiv sein sollen. Die typische produktive Implementierung
156
- * ist `() => globalFeatureToggleRuntime.effectiveFeatures` post-boot
157
- * (createLateBoundHolder-pattern, weil runtime stack.db braucht). */
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
- /** Wird nach dem Aufsetzen der Entity-Tabellen aufgerufen. Hook für
160
- * non-entity-tables (unsafePushTables) und Seeding (admin user, initial
161
- * tenant, …). Muss idempotent sein — im persistent-DB-Modus läuft
162
- * es bei jedem Boot. */
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 (kumiko-framework#3050). Mounted
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
- * (kumiko-framework#3026). Consulted for every templated HTML response
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 {};