@cosmicdrift/kumiko-dev-server 0.413.0 → 0.414.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.
@@ -283,7 +283,7 @@ export function readExtraRuntimeExternals(cwd) {
283
283
  // (require becomes __require(...)).
284
284
  function importsBarePackage(code, pkg) {
285
285
  const escaped = pkg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
286
- return new RegExp(`(?:\\bfrom\\s*|\\bimport\\s*\\(?\\s*|\\w*[rR]equire\\w*\\s*\\(\\s*)["'\`]${escaped}["'\`]`).test(code);
286
+ return new RegExp(`(?:\\bfrom\\s*|\\bimport\\s*\\(?\\s*|\\w*[rR]equire\\w*\\s*\\(\\s*)["'\`]${escaped}(?:/[^"'\`]*)?["'\`]`).test(code);
287
287
  }
288
288
  function readDeclaredAppDependencies(cwd) {
289
289
  const appPkg = join(cwd, "package.json");
@@ -364,7 +364,6 @@ export function formatServerBuildResult(result, durationMs) {
364
364
  const dim = "\x1b[2m";
365
365
  const green = "\x1b[32m";
366
366
  const reset = "\x1b[0m";
367
- // @wrapper-known semantic-alias
368
367
  const mb = (bytes) => (bytes / 1024 / 1024).toFixed(2);
369
368
  const lines = [];
370
369
  lines.push("");
@@ -1,4 +1,5 @@
1
1
  import { type PageHeadResolver, type PageHeadSystemQuery } from "@cosmicdrift/kumiko-renderer/headless/apex";
2
+ import type { PwaShell } from "@cosmicdrift/kumiko-server-runtime/pwa";
2
3
  import type { TestStack } from "@cosmicdrift/kumiko-testing/stack";
3
4
  import type { NormalizedEntry } from "./create-kumiko-server-client-bundles.ts";
4
5
  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";
@@ -16,6 +17,7 @@ export type DevHtmlDeps = {
16
17
  readonly stylesheetPath: string | undefined;
17
18
  readonly resolvePageHead: PageHeadResolver | undefined;
18
19
  readonly buildSystemQuery: DevSystemQueryBuilder;
20
+ readonly pwa: PwaShell | undefined;
19
21
  };
20
22
  export declare function respondWithHtmlShell(deps: DevHtmlDeps, entryName: string, req: Request, socketAddress?: string, status?: number): Promise<Response>;
21
23
  export declare function respondWithStaticHtmlFile(file: string, status: number): Promise<Response>;
@@ -109,6 +109,8 @@ export async function respondWithHtmlShell(deps, entryName, req, socketAddress,
109
109
  let html = injectReload(template);
110
110
  if (deps.stylesheetPath !== undefined)
111
111
  html = injectStylesheet(html);
112
+ if (deps.pwa !== undefined)
113
+ html = deps.pwa.injectHeadTags(html);
112
114
  if (deps.resolvePageHead !== undefined) {
113
115
  html = await injectDevPageHead(html, deps.resolvePageHead, req, socketAddress, deps.buildSystemQuery);
114
116
  }
@@ -49,14 +49,19 @@ export function broadcastReload(hub, changedFile) {
49
49
  }
50
50
  export function openReloadStream(hub) {
51
51
  const encoder = new TextEncoder();
52
+ let entry;
52
53
  const stream = new ReadableStream({
53
54
  start(controller) {
54
- const entry = { controller, encoder, closed: false };
55
+ entry = { controller, encoder, closed: false };
55
56
  hub.clients.add(entry);
56
57
  controller.enqueue(encoder.encode(": connected\n\n"));
57
58
  controller.enqueue(encoder.encode(`event: boot\ndata: ${hub.bootId}\n\n`));
58
59
  },
59
60
  cancel() {
61
+ if (entry !== undefined) {
62
+ entry.closed = true;
63
+ hub.clients.delete(entry);
64
+ }
60
65
  for (const c of hub.clients) {
61
66
  if (c.closed)
62
67
  hub.clients.delete(c);
@@ -1,6 +1,7 @@
1
1
  /// <reference types="bun-types" preserve="true" />
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
+ import { createPwaShell } from "@cosmicdrift/kumiko-server-runtime/pwa";
4
5
  import { RENDERER_WEB_FONT_FILE_PATTERN, RENDERER_WEB_FONTS_URL_PREFIX, resolveRendererWebFontsDir, } from "@cosmicdrift/kumiko-server-runtime/renderer-web-fonts";
5
6
  import { stripNoRouteMatchHeader, tryHonoFirst, } from "@cosmicdrift/kumiko-server-runtime/try-hono-first";
6
7
  import { errorCodeOf } from "@cosmicdrift/kumiko-types/type-guards";
@@ -77,6 +78,9 @@ async function serveStylesheet({ req, url }, deps) {
77
78
  const css = await readFile(stylesheetPath, "utf-8");
78
79
  return new Response(css, { headers: { "Content-Type": "text/css; charset=utf-8" } });
79
80
  }
81
+ function servePwaFile({ req, url }, deps) {
82
+ return deps.html.pwa?.respond(req, url.pathname);
83
+ }
80
84
  function serveReloadStream({ req, url }, deps) {
81
85
  if (url.pathname !== "/_reload" || req.method !== "GET")
82
86
  return undefined;
@@ -161,6 +165,7 @@ const DEV_ROUTES = [
161
165
  serveBundleSourceMap,
162
166
  serveRendererWebFont,
163
167
  serveStylesheet,
168
+ servePwaFile,
164
169
  serveReloadStream,
165
170
  serveSpaShell,
166
171
  servePublicAsset,
@@ -180,13 +185,23 @@ function createDevRouteFetch(deps) {
180
185
  return stripNoRouteMatchHeader(await deps.app.fetch(req, socketAddress));
181
186
  };
182
187
  }
188
+ // Dev bundles keep their names across rebuilds, so the token changes per boot instead.
189
+ function createDevPwaShell(options, bundleByAssetPath, stylesheetPath) {
190
+ if (options.pwa === undefined)
191
+ return undefined;
192
+ return createPwaShell(options.pwa, {
193
+ appShellUrls: [...bundleByAssetPath.keys(), ...(stylesheetPath ? ["/styles.css"] : [])],
194
+ buildToken: `dev-${Date.now()}`,
195
+ });
196
+ }
183
197
  export function createDevFetchHandler(input) {
184
198
  const { options, stack } = input;
185
199
  const buildSystemQuery = createDevSystemQueryBuilder(stack.dispatcher, options.trustedProxyHops);
200
+ const bundleByAssetPath = indexBundlesByAssetPath(input.entries);
186
201
  const routeFetch = createDevRouteFetch({
187
202
  app: stack.app,
188
203
  clientBundles: input.clientBundles,
189
- bundleByAssetPath: indexBundlesByAssetPath(input.entries),
204
+ bundleByAssetPath,
190
205
  publicDir: resolveDevPublicDir(),
191
206
  // The app's cwd first, this package's own location as fallback (the cwd
192
207
  // may not have renderer/web installed, e.g. a fixture dir).
@@ -200,6 +215,7 @@ export function createDevFetchHandler(input) {
200
215
  stylesheetPath: input.stylesheetPath,
201
216
  resolvePageHead: options.resolvePageHead,
202
217
  buildSystemQuery,
218
+ pwa: createDevPwaShell(options, bundleByAssetPath, input.stylesheetPath),
203
219
  },
204
220
  });
205
221
  return options.wrapFetch ? options.wrapFetch(routeFetch) : routeFetch;
@@ -25,7 +25,11 @@ export function resolveDevDbMode(processEnv) {
25
25
  : `kumiko-dev-${crypto.randomUUID()}`;
26
26
  // Pub/Sub ignores the DB index, so the SSE channels need the same per-boot
27
27
  // isolation as the queues; an explicit env value wins so a worker can be pointed at it.
28
- const sseChannelPrefix = processEnv["KUMIKO_REDIS_CHANNEL_PREFIX"]?.trim() || jobQueueNamePrefix;
28
+ // KUMIKO_REDIS_PREFIX (per-app ACL namespace) is next: redisNamespaceFromEnv throws when a channel
29
+ // prefix differs from it, so the per-boot default would stop every dev boot in such an env.
30
+ const sseChannelPrefix = processEnv["KUMIKO_REDIS_CHANNEL_PREFIX"]?.trim() ||
31
+ processEnv["KUMIKO_REDIS_PREFIX"]?.trim() ||
32
+ jobQueueNamePrefix;
29
33
  return { devDbName, persistentDb, jobQueueNamePrefix, sseChannelPrefix };
30
34
  }
31
35
  function buildDevStackOptions(options, dbMode, sseBroker) {
@@ -78,7 +78,8 @@ function runTailwindOneShotBuild(bunPath, cliPath, entryCss, outPath) {
78
78
  child.on("error", rejectPromise);
79
79
  });
80
80
  }
81
- // unref() so the watcher cannot outlive a crashed parent as an orphan.
81
+ // unref() only keeps the child from holding the parent's event loop open; it does not end it,
82
+ // so a failed boot must call the returned kill.
82
83
  function spawnTailwindWatcher(bunPath, cliPath, entryCss, outPath) {
83
84
  const watcher = spawn(bunPath, ["run", cliPath, "-i", entryCss, "-o", outPath, "--watch"], {
84
85
  stdio: "inherit",
@@ -19,5 +19,6 @@ export type DevFileWatcherDeps = {
19
19
  readonly reloadHub: DevReloadHub;
20
20
  readonly stop: () => Promise<void>;
21
21
  };
22
+ export declare function probeFilesystemClockMs(probeParentDir?: string): number;
22
23
  export declare function isUnchangedSinceWatchStart(absolutePath: string, watchStartedAtMs: number): boolean;
23
24
  export declare function startDevFileWatchers(deps: DevFileWatcherDeps): void;
@@ -1,7 +1,8 @@
1
1
  /// <reference types="bun-types" preserve="true" />
2
- import { statSync } from "node:fs";
2
+ import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { watch } from "node:fs/promises";
4
- import { resolve } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { join, resolve } from "node:path";
5
6
  import { rebuildClientBundles, } from "./create-kumiko-server-client-bundles.js";
6
7
  import { broadcastReload } from "./create-kumiko-server-reload.js";
7
8
  import { logInfo } from "./dev-server-runtime.js";
@@ -112,6 +113,19 @@ function expandWatchPatterns(patterns) {
112
113
  }
113
114
  return out;
114
115
  }
116
+ // mtimes come from the filesystem clock at its granularity, not from Date.now(); a baseline taken
117
+ // from the same clock keeps a coarse-mtime or skewed filesystem from swallowing real edits.
118
+ export function probeFilesystemClockMs(probeParentDir = tmpdir()) {
119
+ const probeDir = mkdtempSync(join(probeParentDir, "kumiko-watch-start-"));
120
+ try {
121
+ const probeFile = join(probeDir, "probe");
122
+ writeFileSync(probeFile, "");
123
+ return statSync(probeFile).mtimeMs;
124
+ }
125
+ finally {
126
+ rmSync(probeDir, { recursive: true, force: true });
127
+ }
128
+ }
115
129
  // A deleted or renamed file is a real change, so a failing stat counts as changed.
116
130
  export function isUnchangedSinceWatchStart(absolutePath, watchStartedAtMs) {
117
131
  try {
@@ -161,9 +175,13 @@ export function startDevFileWatchers(deps) {
161
175
  for (const e of deps.entries)
162
176
  entryDirs.add(resolve(e.sourceFile, ".."));
163
177
  const entrySourceFiles = new Set(deps.entries.map((e) => resolve(e.sourceFile)));
164
- const watchStartedAtMs = Date.now();
165
178
  const dirs = [...entryDirs, ...expandWatchPatterns(deps.watchDirs ?? [])];
179
+ // The probe sits on the watched filesystem: tmpdir() may be another mount with another clock.
180
+ // It is removed before the watchers start, so it raises no change event.
181
+ const watchStartedAtMs = probeFilesystemClockMs(dirs[0]);
166
182
  for (const dir of dirs) {
167
- void watchDir(dir, (filename) => handleWatchedChange(deps, dir, filename, entrySourceFiles, watchStartedAtMs), deps.signal, deps.watchDirectory ?? watch);
183
+ void watchDir(dir, (filename) => {
184
+ void handleWatchedChange(deps, dir, filename, entrySourceFiles, watchStartedAtMs);
185
+ }, deps.signal, deps.watchDirectory ?? watch);
168
186
  }
169
187
  }
@@ -5,7 +5,9 @@ import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import type { ErrorDocsConfig } from "@cosmicdrift/kumiko-framework/errors";
6
6
  import type { PageHeadResolver, PageHeadSystemQuery } from "@cosmicdrift/kumiko-renderer/headless/apex";
7
7
  import type { SystemWireDeps } from "@cosmicdrift/kumiko-server-runtime/extra-routes-deps";
8
+ import type { PwaOptions } from "@cosmicdrift/kumiko-server-runtime/pwa";
8
9
  import type { TestStack, TestStackOptions } from "@cosmicdrift/kumiko-testing/stack";
10
+ import { startDevStylesheet } from "./create-kumiko-server-stylesheet.ts";
9
11
  import { type DirectoryWatchEvents } from "./create-kumiko-server-watcher.ts";
10
12
  import { type BunServer } from "./dev-server-runtime.ts";
11
13
  /** Multi-entry mode for apps that ship several separate bundles (e.g.
@@ -96,6 +98,8 @@ export type CreateKumikoServerOptions = {
96
98
  }>;
97
99
  /** @internal — replaces the native directory watcher in tests. */
98
100
  readonly _watchDirectory?: DirectoryWatchEvents;
101
+ /** @internal — replaces the Tailwind pipeline in tests. */
102
+ readonly _startStylesheet?: typeof startDevStylesheet;
99
103
  /** Absolute path to the CSS entry (typically styles.css with
100
104
  * @import "tailwindcss"). The dev-server builds it with the Tailwind
101
105
  * CLI and serves the result at /styles.css; `stylesheetWatch` decides
@@ -185,6 +189,8 @@ export type CreateKumikoServerOptions = {
185
189
  * ships unchanged with status 200 — never a 500. `systemQuery` inside
186
190
  * the resolver runs as the anonymous role, same as prod. */
187
191
  readonly resolvePageHead?: PageHeadResolver;
192
+ /** Installable web app, same option and same served files as `runProdApp`'s `pwa`. */
193
+ readonly pwa?: PwaOptions;
188
194
  /** Wraps the request handler before it reaches Bun.serve and `handle.fetch`, e.g. the demo read-only gate. */
189
195
  readonly wrapFetch?: (handler: (req: Request, socketAddress?: string) => Promise<Response>) => (req: Request, socketAddress?: string) => Promise<Response>;
190
196
  };
@@ -44,63 +44,75 @@ export async function createKumikoServer(options) {
44
44
  const entries = normalizeEntries(options);
45
45
  const buildBundle = options._buildBundle ?? buildClient;
46
46
  const clientBundles = await buildClientBundles(entries, buildBundle);
47
- const stylesheet = await startDevStylesheet(options);
48
- const templates = await loadHtmlTemplates(entries, options.htmlPath);
49
- const dbMode = resolveDevDbMode(process.env);
50
- const { stack, ownedRedisSseBroker } = await bootDevStack(options, dbMode);
51
- const devJobRunners = await startDevBackgroundRunners(stack, dbMode.jobQueueNamePrefix);
52
- // Built once purely to surface authoring warnings (settings-hub placement
53
- // etc.) at the same point prod would; clients fetch the schema themselves
54
- // from GET /api/schema, so the result is discarded.
55
- buildAppSchema(stack.registry, {
56
- authoringWarnings: true,
57
- searchAdapterMissing: !stack.context.searchAdapter,
58
- });
59
- const reloadHub = createReloadHub();
60
- const handleFetch = createDevFetchHandler({
61
- options,
62
- stack,
63
- entries,
64
- clientBundles,
65
- templates,
66
- stylesheetPath: stylesheet?.outPath,
67
- reloadHub,
68
- });
69
- const server = startDevHttpServer(port, handleFetch, stack);
70
- const watcherAbort = new AbortController();
71
- const stop = createDevServerStop({
72
- watcherAbort,
73
- killTailwind: stylesheet?.kill,
74
- server,
75
- stack,
76
- devJobRunners,
77
- dbMode,
78
- ownedRedisSseBroker,
79
- });
80
- startDevFileWatchers({
81
- entries,
82
- watchDirs: options.watchDirs,
83
- watchDirectory: options._watchDirectory,
84
- signal: watcherAbort.signal,
85
- buildBundle,
86
- clientBundles,
87
- reloadHub,
88
- stop,
89
- });
90
- if (options.installSignalHandlers ?? true)
91
- installDevSignalHandlers(stop);
92
- if (server !== undefined) {
93
- logInfo(`[kumiko-server] listening on http://localhost:${port}` +
94
- (entries.length > 0
95
- ? ` (hot reload on ${entries.length === 1 ? "client entry" : `${entries.length} entries`})`
96
- : ""));
47
+ const stylesheet = await (options._startStylesheet ?? startDevStylesheet)(options);
48
+ let stop;
49
+ try {
50
+ const templates = await loadHtmlTemplates(entries, options.htmlPath);
51
+ const dbMode = resolveDevDbMode(process.env);
52
+ const { stack, ownedRedisSseBroker } = await bootDevStack(options, dbMode);
53
+ const devJobRunners = await startDevBackgroundRunners(stack, dbMode.jobQueueNamePrefix);
54
+ // Built once purely to surface authoring warnings (settings-hub placement
55
+ // etc.) at the same point prod would; clients fetch the schema themselves
56
+ // from GET /api/schema, so the result is discarded.
57
+ buildAppSchema(stack.registry, {
58
+ authoringWarnings: true,
59
+ searchAdapterMissing: !stack.context.searchAdapter,
60
+ });
61
+ const reloadHub = createReloadHub();
62
+ const handleFetch = createDevFetchHandler({
63
+ options,
64
+ stack,
65
+ entries,
66
+ clientBundles,
67
+ templates,
68
+ stylesheetPath: stylesheet?.outPath,
69
+ reloadHub,
70
+ });
71
+ const server = startDevHttpServer(port, handleFetch, stack);
72
+ const watcherAbort = new AbortController();
73
+ stop = createDevServerStop({
74
+ watcherAbort,
75
+ killTailwind: stylesheet?.kill,
76
+ server,
77
+ stack,
78
+ devJobRunners,
79
+ dbMode,
80
+ ownedRedisSseBroker,
81
+ });
82
+ startDevFileWatchers({
83
+ entries,
84
+ watchDirs: options.watchDirs,
85
+ watchDirectory: options._watchDirectory,
86
+ signal: watcherAbort.signal,
87
+ buildBundle,
88
+ clientBundles,
89
+ reloadHub,
90
+ stop,
91
+ });
92
+ if (options.installSignalHandlers ?? true)
93
+ installDevSignalHandlers(stop);
94
+ if (server !== undefined) {
95
+ logInfo(`[kumiko-server] listening on http://localhost:${port}` +
96
+ (entries.length > 0
97
+ ? ` (hot reload on ${entries.length === 1 ? "client entry" : `${entries.length} entries`})`
98
+ : ""));
99
+ }
100
+ return {
101
+ fetch: handleFetch,
102
+ server,
103
+ stack,
104
+ stop,
105
+ jobQueueNamePrefix: dbMode.jobQueueNamePrefix,
106
+ sseChannelPrefix: dbMode.sseChannelPrefix,
107
+ };
108
+ }
109
+ catch (err) {
110
+ // Before stop() exists only the --watch child needs killing; once it exists, it tears down
111
+ // the server, stack, runners and watcher that a late failure would otherwise leak.
112
+ if (stop)
113
+ await stop();
114
+ else
115
+ stylesheet?.kill();
116
+ throw err;
97
117
  }
98
- return {
99
- fetch: handleFetch,
100
- server,
101
- stack,
102
- stop,
103
- jobQueueNamePrefix: dbMode.jobQueueNamePrefix,
104
- sseChannelPrefix: dbMode.sseChannelPrefix,
105
- };
106
118
  }
@@ -3,9 +3,7 @@
3
3
  // load without it: every Bun.* call is gated, and tests drive the fetch
4
4
  // handler directly instead of going through Bun.serve and real sockets.
5
5
  export const hasBun = typeof Bun !== "undefined";
6
- // @wrapper-known semantic-alias
7
6
  // biome-ignore lint/suspicious/noConsole: dev-server status logging
8
7
  export const logInfo = (msg) => console.log(msg);
9
- // @wrapper-known semantic-alias
10
8
  // biome-ignore lint/suspicious/noConsole: dev-server error logging
11
9
  export const logError = (...args) => console.error(...args);
package/dist/kebab.js CHANGED
@@ -1,7 +1,6 @@
1
1
  // Segment-strict: rejects trailing/double hyphen so the name is a valid
2
2
  // package-name + folder (`my-shop`, not `my-` or `my--shop`).
3
3
  const KEBAB_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
4
- // @wrapper-known semantic-alias
5
4
  export function isKebabSegment(value) {
6
5
  return KEBAB_RE.test(value);
7
6
  }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * `bundledLists` lives in kumiko.config.ts for the schema tooling and in the runner options for
3
+ * the running app. A runner call in bin/ that leaves it out would serve other screens than
4
+ * `kumiko check` validates.
5
+ */
6
+ export declare function findBundledListsWiringErrors(appRoot: string): readonly string[];
@@ -0,0 +1,67 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { APP_CONFIG_FILE } from "@cosmicdrift/kumiko-framework/config";
4
+ import { Project, SyntaxKind } from "ts-morph";
5
+ const RUNNER_NAMES = new Set(["runProdApp", "runDevApp", "runWorkerApp"]);
6
+ const BUNDLED_LISTS = "bundledLists";
7
+ function parseSource(fileName, source) {
8
+ const project = new Project({ useInMemoryFileSystem: true, skipFileDependencyResolution: true });
9
+ return project.createSourceFile(fileName, source);
10
+ }
11
+ function declaresProperty(object, name) {
12
+ return object.getProperties().some((property) => {
13
+ if (property.isKind(SyntaxKind.ShorthandPropertyAssignment))
14
+ return property.getName() === name;
15
+ if (property.isKind(SyntaxKind.PropertyAssignment)) {
16
+ return property.getName().replace(/^["']|["']$/g, "") === name;
17
+ }
18
+ return false;
19
+ });
20
+ }
21
+ function hasSpread(object) {
22
+ return object.getProperties().some((property) => property.isKind(SyntaxKind.SpreadAssignment));
23
+ }
24
+ function firstObjectArgument(args) {
25
+ const first = args[0];
26
+ return first?.isKind(SyntaxKind.ObjectLiteralExpression) ? first : undefined;
27
+ }
28
+ function configDeclaresBundledLists(source) {
29
+ const call = parseSource(APP_CONFIG_FILE, source)
30
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
31
+ .find((candidate) => candidate.getExpression().getText() === "defineAppConfig");
32
+ const config = call === undefined ? undefined : firstObjectArgument(call.getArguments());
33
+ return config !== undefined && declaresProperty(config, BUNDLED_LISTS);
34
+ }
35
+ function runnersWithoutBundledLists(fileName, source) {
36
+ return parseSource(fileName, source)
37
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
38
+ .flatMap((call) => {
39
+ const runner = call.getExpression().getText();
40
+ if (!RUNNER_NAMES.has(runner))
41
+ return [];
42
+ const options = firstObjectArgument(call.getArguments());
43
+ // A spread may carry the key; the static check cannot see into it.
44
+ if (options === undefined || declaresProperty(options, BUNDLED_LISTS) || hasSpread(options)) {
45
+ return [];
46
+ }
47
+ return [runner];
48
+ });
49
+ }
50
+ /**
51
+ * `bundledLists` lives in kumiko.config.ts for the schema tooling and in the runner options for
52
+ * the running app. A runner call in bin/ that leaves it out would serve other screens than
53
+ * `kumiko check` validates.
54
+ */
55
+ export function findBundledListsWiringErrors(appRoot) {
56
+ const configPath = join(appRoot, APP_CONFIG_FILE);
57
+ if (!existsSync(configPath) || !configDeclaresBundledLists(readFileSync(configPath, "utf-8"))) {
58
+ return [];
59
+ }
60
+ const binDir = join(appRoot, "bin");
61
+ if (!existsSync(binDir))
62
+ return [];
63
+ return readdirSync(binDir)
64
+ .filter((name) => name.endsWith(".ts"))
65
+ .sort()
66
+ .flatMap((name) => runnersWithoutBundledLists(name, readFileSync(join(binDir, name), "utf-8")).map((runner) => `bin/${name}: ${runner}() does not pass bundledLists although ${APP_CONFIG_FILE} sets it. Add \`bundledLists: appConfig.bundledLists\` to the runner options, otherwise the app serves other screens than \`kumiko check\` validates.`));
67
+ }
@@ -9,6 +9,7 @@ import { readStaticAppConfig, } from "@cosmicdrift/kumiko-guards/repo-manifest/a
9
9
  import { BUNFIG_FILES, renderBunfig } from "@cosmicdrift/kumiko-testing/bunfig";
10
10
  import { isKebabSegment } from "../kebab.js";
11
11
  import { readReleaseTarget, render, renderDeployFiles, } from "../scaffold-deploy.js";
12
+ import { findBundledListsWiringErrors } from "./bundled-lists-wiring.js";
12
13
  import { addManagedMarker } from "./marker.js";
13
14
  import { findKumikoPinErrors } from "./pins.js";
14
15
  export const BIOME_PRESET = "@cosmicdrift/kumiko-framework/biome";
@@ -60,6 +61,7 @@ export function listManagedErrors(facts) {
60
61
  ".");
61
62
  }
62
63
  errors.push(...findKumikoPinErrors(readPackageJsonOrEmpty(facts.appRoot)));
64
+ errors.push(...findBundledListsWiringErrors(facts.appRoot));
63
65
  return errors;
64
66
  }
65
67
  function marked(path, body, style, executable = false) {
@@ -22,6 +22,7 @@ function pickDevServiceOptions(options) {
22
22
  ...(options.anonymousAccess !== undefined && { anonymousAccess: options.anonymousAccess }),
23
23
  ...(options.files !== undefined && { files: options.files }),
24
24
  ...(options.wire !== undefined && { wire: options.wire }),
25
+ ...(options.pwa !== undefined && { pwa: options.pwa }),
25
26
  };
26
27
  }
27
28
  export function buildDevServerOptions(deps) {
@@ -102,6 +102,8 @@ export type RunDevAppOptions = {
102
102
  * through `runDevApp` show the same resolved head prod does. See
103
103
  * `CreateKumikoServerOptions.resolvePageHead` for the full contract. */
104
104
  readonly resolvePageHead?: PageHeadResolver;
105
+ /** Installable web app, same option as `runProdApp`'s `pwa`; pass the same value to both. */
106
+ readonly pwa?: CreateKumikoServerOptions["pwa"];
105
107
  /** CSS entry. Defaults to the package export
106
108
  * `@cosmicdrift/kumiko-renderer/web/styles.css` when a client entry is set.
107
109
  * `false` disables the CSS pipeline. */
@@ -146,7 +146,6 @@ export async function scaffoldApp(options) {
146
146
  files.push("CLAUDE.md");
147
147
  return { destination, files, appName: options.name };
148
148
  }
149
- // @wrapper-known semantic-alias
150
149
  function write(path, content) {
151
150
  writeFileSync(path, content);
152
151
  }
@@ -156,7 +155,7 @@ function appScripts(testSetup) {
156
155
  build: "bun kumiko-build",
157
156
  start: "bun run bin/main.ts",
158
157
  boot: "KUMIKO_DRY_RUN_ENV=boot bun bin/main.ts",
159
- typecheck: "tsc --noEmit",
158
+ typecheck: "bun check --no-script",
160
159
  lint: "biome check .",
161
160
  check: "kumiko check",
162
161
  ...(testSetup?.scripts ?? { test: "bun --config=bunfig.toml test --dots" }),
@@ -171,6 +170,8 @@ function renderPackageJson(name, version, testSetup, infraRef) {
171
170
  version: "0.1.0",
172
171
  private: true,
173
172
  type: "module",
173
+ // `bun check` is built in from 1.4.4; older Bun resolves it to the app's `check` script.
174
+ engines: { bun: ">=1.4.4" },
174
175
  kumiko: { deploy: { infraRef } },
175
176
  scripts: appScripts(testSetup),
176
177
  dependencies: {
@@ -355,7 +356,8 @@ function renderMain(appName) {
355
356
  ]);
356
357
  // Subject-key KMS is optional locally (plaintext + loud warning) but required
357
358
  // under NODE_ENV=production.
358
- return `// Production-bootstrap. KUMIKO_DRY_RUN_ENV=boot exits after
359
+ return `// biome-ignore-all lint/style/noProcessEnv: KMS wiring and the demo admin password are read before runProdApp parses the env contract, and the scaffold has no bin/env.ts
360
+ // Production-bootstrap. KUMIKO_DRY_RUN_ENV=boot exits after
359
361
  // composeFeatures + validateBoot + createRegistry without DB/Redis-connect
360
362
  // (see @cosmicdrift/kumiko-server-runtime runProdApp).
361
363
 
@@ -45,7 +45,8 @@ export function renderBlankMain(appName) {
45
45
  { specifier: "../kumiko.config", names: [], defaultName: "appConfig" },
46
46
  { specifier: "./env-schema", names: ["envSchema"] },
47
47
  ]);
48
- return `// Production-bootstrap. KUMIKO_DRY_RUN_ENV=boot exits after
48
+ return `// biome-ignore-all lint/style/noProcessEnv: KMS wiring and the demo admin password are read before runProdApp parses the env contract, and the scaffold has no bin/env.ts
49
+ // Production-bootstrap. KUMIKO_DRY_RUN_ENV=boot exits after
49
50
  // composeFeatures + validateBoot + createRegistry without DB/Redis-connect
50
51
  // (see @cosmicdrift/kumiko-server-runtime runProdApp).
51
52
 
@@ -3,6 +3,7 @@
3
3
  // functions emit the same shape into the user's repo.
4
4
  import { createBooleanField, createEntity, createNumberField, createTextField, defineEntityCreateHandler, defineEntityDeleteHandler, defineEntityDetailHandler, defineEntityListHandler, defineEntityUpdateHandler, defineFeature, } from "@cosmicdrift/kumiko-framework/engine";
5
5
  const taskEntity = createEntity({
6
+ description: "A to-do item with a title, status, priority and urgency flag.",
6
7
  fields: {
7
8
  title: createTextField({
8
9
  required: true,
@@ -38,6 +39,7 @@ const open = {
38
39
  },
39
40
  },
40
41
  };
42
+ const described = (description) => ({ ...open, description });
41
43
  const TASKS_I18N = {
42
44
  "screen:task-list.title": { de: "Aufgaben", en: "Tasks" },
43
45
  "screen:task-edit.title": { de: "Aufgabe", en: "Task" },
@@ -53,11 +55,11 @@ export function createDemoTasksFeature() {
53
55
  return defineFeature("tasks", (r) => {
54
56
  r.translations({ keys: TASKS_I18N });
55
57
  r.entity({ name: "task", ...taskEntity });
56
- r.writeHandler(defineEntityCreateHandler("task", taskEntity, open));
57
- r.writeHandler(defineEntityUpdateHandler("task", taskEntity, open));
58
- r.writeHandler(defineEntityDeleteHandler("task", taskEntity, open));
59
- r.queryHandler(defineEntityListHandler("task", taskEntity, open));
60
- r.queryHandler(defineEntityDetailHandler("task", taskEntity, open));
58
+ r.writeHandler(defineEntityCreateHandler("task", taskEntity, described("Creates a task.")));
59
+ r.writeHandler(defineEntityUpdateHandler("task", taskEntity, described("Updates a task.")));
60
+ r.writeHandler(defineEntityDeleteHandler("task", taskEntity, described("Deletes a task.")));
61
+ r.queryHandler(defineEntityListHandler("task", taskEntity, described("Lists all tasks.")));
62
+ r.queryHandler(defineEntityDetailHandler("task", taskEntity, described("Returns one task.")));
61
63
  r.screen(listScreen);
62
64
  r.screen(editScreen);
63
65
  r.nav({ id: "tasks", label: "tasks:nav.tasks", order: 10, screen: "tasks:screen:task-list" });
@@ -93,6 +95,7 @@ import type {
93
95
  import { tasksTranslationKeys } from "./i18n";
94
96
 
95
97
  const taskEntity = createEntity({
98
+ description: "A to-do item with a title, status, priority and urgency flag.",
96
99
  fields: {
97
100
  title: createTextField({
98
101
  required: true,
@@ -133,14 +136,16 @@ const open = {
133
136
  },
134
137
  } as const;
135
138
 
139
+ const described = (description: string) => ({ ...open, description });
140
+
136
141
  export const tasksFeature = defineFeature("tasks", (r) => {
137
142
  r.translations({ keys: tasksTranslationKeys });
138
143
  r.entity({ name: "task", ...taskEntity });
139
- r.writeHandler(defineEntityCreateHandler("task", taskEntity, open));
140
- r.writeHandler(defineEntityUpdateHandler("task", taskEntity, open));
141
- r.writeHandler(defineEntityDeleteHandler("task", taskEntity, open));
142
- r.queryHandler(defineEntityListHandler("task", taskEntity, open));
143
- r.queryHandler(defineEntityDetailHandler("task", taskEntity, open));
144
+ r.writeHandler(defineEntityCreateHandler("task", taskEntity, described("Creates a task.")));
145
+ r.writeHandler(defineEntityUpdateHandler("task", taskEntity, described("Updates a task.")));
146
+ r.writeHandler(defineEntityDeleteHandler("task", taskEntity, described("Deletes a task.")));
147
+ r.queryHandler(defineEntityListHandler("task", taskEntity, described("Lists all tasks.")));
148
+ r.queryHandler(defineEntityDetailHandler("task", taskEntity, described("Returns one task.")));
144
149
  r.screen(listScreen);
145
150
  r.screen(editScreen);
146
151
  r.nav({ id: "tasks", label: "tasks:nav.tasks", order: 10, screen: "tasks:screen:task-list" });
@@ -72,7 +72,6 @@ export function deriveTenantId(name) {
72
72
  state ^= ch.charCodeAt(0);
73
73
  state = Math.imul(state, 16777619) >>> 0;
74
74
  }
75
- // @wrapper-known semantic-alias
76
75
  const hex = (n, len) => n.toString(16).padStart(len, "0").slice(0, len);
77
76
  const a = hex(state, 8);
78
77
  state ^= state << 13;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dev-server",
3
- "version": "0.413.0",
3
+ "version": "0.414.0",
4
4
  "description": "Dev-tooling for Kumiko apps: local dev-server bootstrap (runDevApp), scaffolding. Its compose-stacks/env-schema subpaths are consumed at prod boot too — see @cosmicdrift/kumiko-server-runtime for the prod runner itself.",
5
5
  "license": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -77,15 +77,15 @@
77
77
  "kumiko-upgrade": "./bin/kumiko-upgrade.ts"
78
78
  },
79
79
  "dependencies": {
80
- "@cosmicdrift/kumiko-bundled-features": "0.413.0",
81
- "@cosmicdrift/kumiko-feature-ast": "0.413.0",
82
- "@cosmicdrift/kumiko-framework": "0.413.0",
83
- "@cosmicdrift/kumiko-renderer": "0.413.0",
84
- "@cosmicdrift/kumiko-renderer-mail-html": "0.413.0",
85
- "@cosmicdrift/kumiko-guards": "0.413.0",
86
- "@cosmicdrift/kumiko-server-runtime": "0.413.0",
87
- "@cosmicdrift/kumiko-testing": "0.413.0",
88
- "@cosmicdrift/kumiko-types": "0.413.0",
80
+ "@cosmicdrift/kumiko-bundled-features": "0.414.0",
81
+ "@cosmicdrift/kumiko-feature-ast": "0.414.0",
82
+ "@cosmicdrift/kumiko-framework": "0.414.0",
83
+ "@cosmicdrift/kumiko-renderer": "0.414.0",
84
+ "@cosmicdrift/kumiko-renderer-mail-html": "0.414.0",
85
+ "@cosmicdrift/kumiko-guards": "0.414.0",
86
+ "@cosmicdrift/kumiko-server-runtime": "0.414.0",
87
+ "@cosmicdrift/kumiko-testing": "0.414.0",
88
+ "@cosmicdrift/kumiko-types": "0.414.0",
89
89
  "ts-morph": "^28.0.0",
90
90
  "zod": "^4.6.7"
91
91
  },
package/src/changes.json CHANGED
@@ -1,4 +1,34 @@
1
1
  [
2
+ {
3
+ "version": "0.414.0",
4
+ "type": "fix",
5
+ "title": "Scaffolded apps typecheck with bun check",
6
+ "detail": "The typecheck script of newly scaffolded apps and of the framework workspace now runs `bun check --no-script` instead of `tsc --noEmit`, which needs Bun 1.4.4 or newer. Existing apps keep their script until they change it."
7
+ },
8
+ {
9
+ "version": "0.414.0",
10
+ "type": "fix",
11
+ "title": "Deploy Dockerfile template no longer mentions migrate-step.sh",
12
+ "detail": "The comment in `deploy/Dockerfile.template` named a compose-host migrate step that the scaffold does not generate. It now only names the migrate-db initContainer of the k3s deployment."
13
+ },
14
+ {
15
+ "version": "0.414.0",
16
+ "type": "fix",
17
+ "title": "managed check reports a bin entry that does not pass bundledLists",
18
+ "detail": "When `kumiko.config.ts` sets `bundledLists`, every `runProdApp`, `runDevApp` and `runWorkerApp` call in `bin/*.ts` must pass `bundledLists` too; otherwise the managed-files check reports an error instead of the app drifting silently from its config."
19
+ },
20
+ {
21
+ "version": "0.414.0",
22
+ "type": "fix",
23
+ "title": "subpath imports of an optional runtime external count as an import",
24
+ "detail": "The server bundle only detected `import \"meilisearch\"`, so an app that imported just `meilisearch/token` and did not list `meilisearch` in its dependencies shipped a `dist-server/package.json` without it and crashed in production with `Cannot find package 'meilisearch'`. Subpath imports are detected now."
25
+ },
26
+ {
27
+ "version": "0.414.0",
28
+ "type": "fix",
29
+ "title": "dev server boots when KUMIKO_REDIS_PREFIX is set",
30
+ "detail": "The SSE channel prefix defaulted to a per-boot value that differs from `KUMIKO_REDIS_PREFIX`, which `redisNamespaceFromEnv` rejects. The prefix now falls back to `KUMIKO_REDIS_PREFIX` before the per-boot default."
31
+ },
2
32
  {
3
33
  "version": "0.411.0",
4
34
  "type": "improvement",
@@ -64,7 +64,7 @@ RUN bun install --production --frozen-lockfile
64
64
  COPY --from=build --chown=app:app /app/dist ./dist
65
65
 
66
66
  # Checked-in SQL migrations for the pre-deploy `kumiko schema apply` step
67
- # (migrate-db initContainer of the infra _app-deploy.yml on k3s, or migrate-step.sh on compose hosts).
67
+ # (migrate-db initContainer of the infra _app-deploy.yml on k3s).
68
68
  # The shipped `kumiko-schema` bin reads `${INIT_CWD}/kumiko/migrations/*.sql`
69
69
  # (see ENV INIT_CWD=/app below). kumiko/schema.ts is NOT needed at runtime
70
70
  # (only generate reads it; apply is checksum-tracked SQL only).