@cosmicdrift/kumiko-dev-server 0.336.1 → 0.338.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.
@@ -48,7 +48,12 @@ export type DevHostDispatchResult = {
48
48
  readonly to: string;
49
49
  readonly status?: 301 | 302;
50
50
  } | {
51
+ /** Plain 404 by default. `file` serves a static page with status 404
52
+ * (like `static-html`), `entryName` serves that bundle's shell with 404;
53
+ * `file` wins when both are set. */
51
54
  readonly kind: "not-found";
55
+ readonly entryName?: string;
56
+ readonly file?: string;
52
57
  };
53
58
  /** Picks an entry by inspecting the incoming request. Wird von
54
59
  * Multi-Entry-Apps gesetzt; im Single-Entry-Mode irrelevant. */
@@ -185,6 +190,9 @@ export type KumikoServerHandle = {
185
190
  readonly stack: TestStack;
186
191
  /** Stops the server and tears down the stack (DB + redis). */
187
192
  readonly stop: () => Promise<void>;
193
+ /** BullMQ queue-name prefix of this boot: per-boot in ephemeral mode, so
194
+ * its `bull:<prefix>-*` keys belong to this server alone on a shared Redis. */
195
+ readonly jobQueueNamePrefix: string;
188
196
  };
189
197
  export declare const STYLESHEET_WATCH_ENV = "KUMIKO_DEV_STYLESHEET_WATCH";
190
198
  export declare const PROD_BUNDLES_ENV = "KUMIKO_DEV_PROD_BUNDLES";
@@ -15,7 +15,7 @@
15
15
  // The companion prod entry will land at `@cosmicdrift/kumiko-framework/server`
16
16
  // with a different options shape (clientDist, auth config, db url).
17
17
  import { spawn } from "node:child_process";
18
- import { existsSync, mkdtempSync, statSync } from "node:fs";
18
+ import { existsSync, mkdtempSync, realpathSync, statSync } from "node:fs";
19
19
  import { readFile, realpath, watch } from "node:fs/promises";
20
20
  import { tmpdir } from "node:os";
21
21
  import { join, resolve, sep } from "node:path";
@@ -228,13 +228,14 @@ async function servePublicFile(pathname, publicDir) {
228
228
  if (filePath === undefined)
229
229
  return undefined;
230
230
  try {
231
- const bytes = await readFile(filePath);
232
231
  // Lexical containment (above) only catches "..", not a symlink inside
233
232
  // publicDir that points outside it on disk — realpath resolves the
234
- // actual target and the same containment check runs against it.
233
+ // actual target and the same containment check runs against it. Read
234
+ // from the resolved path so the checked target is the one read.
235
235
  const real = await realpath(filePath);
236
236
  if (real !== publicDir && !real.startsWith(publicDir + sep))
237
237
  return undefined;
238
+ const bytes = await readFile(real);
238
239
  return { bytes, mime: publicFileMimeType(filePath) };
239
240
  }
240
241
  catch (err) {
@@ -722,7 +723,7 @@ export async function createKumikoServer(options) {
722
723
  // resolvePageHead goes through the same headless resolveAndInjectPageHead
723
724
  // that runProdApp uses, so dev and e2e exercise the one timeout and
724
725
  // fallback path instead of a second copy of it (#3026).
725
- const htmlResponse = async (entryName, req, socketAddress) => {
726
+ const htmlResponse = async (entryName, req, socketAddress, status = 200) => {
726
727
  const template = htmlTemplates.get(entryName) ?? defaultTemplate;
727
728
  const headers = new Headers();
728
729
  headers.set("Content-Type", "text/html; charset=utf-8");
@@ -744,7 +745,23 @@ export async function createKumikoServer(options) {
744
745
  systemQuery: buildDevSystemQuery(req, socketAddress),
745
746
  });
746
747
  }
747
- return new Response(html, { headers });
748
+ return new Response(html, { status, headers });
749
+ };
750
+ // Raw file serve, no bundle injection — mirrors prod's `{ kind: "html", file }`.
751
+ const staticHtmlResponse = async (file, status) => {
752
+ let content;
753
+ try {
754
+ content = await readFile(file, "utf-8");
755
+ }
756
+ catch (error) {
757
+ if (error.code !== "ENOENT")
758
+ throw error;
759
+ return new Response(`hostDispatch: file not found: ${file}`, { status: 500 });
760
+ }
761
+ return new Response(content, {
762
+ status,
763
+ headers: { "Content-Type": "text/html; charset=utf-8" },
764
+ });
748
765
  };
749
766
  // --- Fetch handler (runtime-neutral) ---
750
767
  // Bundle-Pfad-Lookup: für jede Entry serven wir
@@ -756,7 +773,12 @@ export async function createKumikoServer(options) {
756
773
  bundleByAssetPath.set(assetPathFor(e.name), e.name);
757
774
  // App-root convention (same as expandWatchPatterns/resolveStylesheet above):
758
775
  // process.cwd() is the app workspace, so public/ is its static asset dir.
759
- const publicDir = resolve(process.cwd(), "public");
776
+ // Symlink-resolved once: servePublicFile compares realpath()s against it, so
777
+ // a symlinked public/ would otherwise 404 every file.
778
+ const lexicalPublicDir = resolve(process.cwd(), "public");
779
+ const publicDir = existsSync(lexicalPublicDir)
780
+ ? realpathSync(lexicalPublicDir)
781
+ : lexicalPublicDir;
760
782
  // The app's cwd first, this package's own location as fallback (the cwd may
761
783
  // not have renderer-web installed, e.g. a fixture dir).
762
784
  const rendererWebFontsDir = resolveRendererWebFontsDir([process.cwd(), import.meta.dir]);
@@ -892,15 +914,15 @@ export async function createKumikoServer(options) {
892
914
  });
893
915
  }
894
916
  if (dispatch.kind === "not-found") {
917
+ if (dispatch.file !== undefined)
918
+ return staticHtmlResponse(dispatch.file, 404);
919
+ if (dispatch.entryName !== undefined) {
920
+ return htmlResponse(dispatch.entryName, req, socketAddress, 404);
921
+ }
895
922
  return new Response("Not Found", { status: 404 });
896
923
  }
897
924
  if (dispatch.kind === "static-html") {
898
- // Raw file serve, no bundle injection — mirrors prod's
899
- // `{ kind: "html", file: ... }` static-html branch.
900
- const file = await readFile(dispatch.file, "utf-8");
901
- return new Response(file, {
902
- headers: { "Content-Type": "text/html; charset=utf-8" },
903
- });
925
+ return staticHtmlResponse(dispatch.file, 200);
904
926
  }
905
927
  return htmlResponse(dispatch.entryName, req, socketAddress);
906
928
  }
@@ -984,6 +1006,29 @@ export async function createKumikoServer(options) {
984
1006
  }, watcherAbort.signal);
985
1007
  }
986
1008
  }
1009
+ // The per-boot prefix is never reused, so its BullMQ keys (queues,
1010
+ // repeatables, delayed jobs) would pile up in the shared Redis forever.
1011
+ // A prefix-less duplicate: SCAN returns full key names, which the stack
1012
+ // client's keyPrefix would otherwise prepend a second time.
1013
+ // Takes the connection as a parameter: it must be opened before
1014
+ // stack.cleanup() closes the stack client, yet used only after it, because
1015
+ // closing the stack's enqueuer queues re-writes their `meta` keys.
1016
+ const deleteEphemeralJobQueues = async (raw) => {
1017
+ try {
1018
+ const keys = [];
1019
+ for await (const batch of raw.scanStream({
1020
+ match: `bull:${jobQueueNamePrefix}-*`,
1021
+ count: 500,
1022
+ })) {
1023
+ keys.push(...batch);
1024
+ }
1025
+ if (keys.length > 0)
1026
+ await raw.del(...keys);
1027
+ }
1028
+ finally {
1029
+ raw.disconnect();
1030
+ }
1031
+ };
987
1032
  const stop = async () => {
988
1033
  // Watcher zuerst stoppen damit kein onChange während des Teardowns
989
1034
  // mehr feuert (sonst können tmpdir-rmSync ein process.exit(75)
@@ -998,7 +1043,10 @@ export async function createKumikoServer(options) {
998
1043
  await stack.eventDispatcher.stop();
999
1044
  }
1000
1045
  await devJobRunners.stop();
1046
+ const rawRedis = persistentDb ? undefined : stack.redis.redis.duplicate({ keyPrefix: "" });
1001
1047
  await stack.cleanup();
1048
+ if (rawRedis)
1049
+ await deleteEphemeralJobQueues(rawRedis);
1002
1050
  };
1003
1051
  // --- graceful shutdown ---
1004
1052
  // Signal handlers fire on Ctrl-C / kill. Without them, repeated dev
@@ -1041,5 +1089,5 @@ export async function createKumikoServer(options) {
1041
1089
  ? ` (hot reload on ${entries.length === 1 ? "client entry" : `${entries.length} entries`})`
1042
1090
  : ""));
1043
1091
  }
1044
- return { fetch: handleFetch, server, stack, stop };
1092
+ return { fetch: handleFetch, server, stack, stop, jobQueueNamePrefix };
1045
1093
  }
@@ -84,7 +84,8 @@ export async function runInitDeployCli({ argv, cwd, out, }) {
84
84
  out.err("");
85
85
  return 2;
86
86
  }
87
- const appName = getStringFlag(args, "app") ?? defaultAppName(cwd);
87
+ const outDir = getStringFlag(args, "out") ?? cwd;
88
+ const appName = getStringFlag(args, "app") ?? defaultAppName(outDir);
88
89
  if (!appName) {
89
90
  out.err("");
90
91
  out.err(' --app <name> is required (no package.json "name" found to default from).');
@@ -96,7 +97,7 @@ export async function runInitDeployCli({ argv, cwd, out, }) {
96
97
  appName,
97
98
  port: getNumberFlag(args, "port"),
98
99
  githubOrg: getStringFlag(args, "github-org"),
99
- destination: getStringFlag(args, "out") ?? cwd,
100
+ destination: outDir,
100
101
  };
101
102
  try {
102
103
  return check ? reportDrift(target, out) : reportScaffold(target, force, out);
@@ -6,6 +6,18 @@ import { setupTestStack, } from "@cosmicdrift/kumiko-framework/stack";
6
6
  import { jobRunLoggerCallbacks } from "@cosmicdrift/kumiko-server-runtime/boot/job-run-logger";
7
7
  import { composeFeatures, } from "@cosmicdrift/kumiko-server-runtime/compose-features";
8
8
  import { addConfigAccessorFactory } from "@cosmicdrift/kumiko-server-runtime/run-prod-app";
9
+ function isConfigResolver(value) {
10
+ return (typeof value === "object" && value !== null && "get" in value && typeof value.get === "function");
11
+ }
12
+ function resolveBaseConfigResolver(fromBase) {
13
+ const supplied = fromBase["configResolver"];
14
+ if (supplied === undefined)
15
+ return undefined;
16
+ if (!isConfigResolver(supplied)) {
17
+ throw new Error("extraContext.configResolver must be a ConfigResolver (an object with get()), e.g. from createConfigResolver() — got a different value; pass the instance, not a factory.");
18
+ }
19
+ return supplied;
20
+ }
9
21
  export function mergeExtraContext(base, presets) {
10
22
  if (presets.length === 0)
11
23
  return base;
@@ -14,7 +26,7 @@ export function mergeExtraContext(base, presets) {
14
26
  const merged = { ...fromBase };
15
27
  if (presets.includes("config")) {
16
28
  // Same precedence as mergeConfigResolverDefault (run-dev-app.ts) — a base-supplied resolver wins, factory always derives from it (fw#3313).
17
- const configResolver = fromBase["configResolver"] ?? createConfigResolver();
29
+ const configResolver = resolveBaseConfigResolver(fromBase) ?? createConfigResolver();
18
30
  Object.assign(merged, addConfigAccessorFactory({ configResolver }, deps.registry));
19
31
  }
20
32
  if (presets.includes("template-resolver")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dev-server",
3
- "version": "0.336.1",
3
+ "version": "0.338.0",
4
4
  "description": "Dev-tooling for Kumiko apps: local dev-server bootstrap (runDevApp), scaffolding, codegen. 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": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -64,11 +64,11 @@
64
64
  "kumiko-upgrade": "./bin/kumiko-upgrade.ts"
65
65
  },
66
66
  "dependencies": {
67
- "@cosmicdrift/kumiko-bundled-features": "0.336.1",
68
- "@cosmicdrift/kumiko-framework": "0.336.1",
69
- "@cosmicdrift/kumiko-headless": "0.336.1",
70
- "@cosmicdrift/kumiko-renderer-web": "0.336.1",
71
- "@cosmicdrift/kumiko-server-runtime": "0.336.1",
67
+ "@cosmicdrift/kumiko-bundled-features": "0.338.0",
68
+ "@cosmicdrift/kumiko-framework": "0.338.0",
69
+ "@cosmicdrift/kumiko-headless": "0.338.0",
70
+ "@cosmicdrift/kumiko-renderer-web": "0.338.0",
71
+ "@cosmicdrift/kumiko-server-runtime": "0.338.0",
72
72
  "bun-types": "^1.3.13",
73
73
  "ts-morph": "^28.0.0",
74
74
  "zod": "^4.4.3"
package/src/changes.json CHANGED
@@ -1,4 +1,36 @@
1
1
  [
2
+ {
3
+ "version": "0.338.0",
4
+ "type": "fix",
5
+ "title": "Scaffolded migrate step percent-encodes DB_PASSWORD",
6
+ "detail": "dev-server: the scaffolded `deploy/migrate-step.sh` percent-encodes `DB_PASSWORD` in `DATABASE_URL`, so passwords with `@`, `:`, `/`, `#`, `%` or non-ASCII characters no longer break the URL. Re-run `kumiko init-deploy` to pick it up; apps with a hand-written `urlencode` should take the new version, which also encodes bytes >= 0x80 correctly."
7
+ },
8
+ {
9
+ "version": "0.338.0",
10
+ "type": "improvement",
11
+ "title": "dev hostDispatch not-found can serve a custom 404 page",
12
+ "detail": "`DevHostDispatchResult` `{ kind: \"not-found\" }` accepts `file` (served like `static-html`) or `entryName` (that entry's shell), both with status 404. A missing file answers 500 with `hostDispatch: file not found`. Without fields the plain \"Not Found\" 404 stays.",
13
+ "migration": "No code change needed."
14
+ },
15
+ {
16
+ "version": "0.338.0",
17
+ "type": "improvement",
18
+ "title": "New package @cosmicdrift/kumiko-samples with the corpus sources",
19
+ "detail": "New package with the sample recipes, sample apps and bundled-features sources\n`@cosmicdrift/kumiko-samples` ships the sample recipes, sample apps and bundled-features sources in the repository layout. Tools such as the few-shot corpus build can run against a pinned version of it and get the same entry ids and source paths as in the monorepo."
20
+ },
21
+ {
22
+ "version": "0.337.1",
23
+ "type": "improvement",
24
+ "title": "createKumikoServer handle exposes the boot's jobQueueNamePrefix",
25
+ "detail": "dev-server: `createKumikoServer()` returns the boot's `jobQueueNamePrefix` on its handle, so callers sharing a Redis can address exactly that boot's `bull:<prefix>-*` keys.",
26
+ "migration": "No action needed: purely additive handle field."
27
+ },
28
+ {
29
+ "version": "0.337.0",
30
+ "type": "fix",
31
+ "title": "public/ served via realpath incl. symlinked public/, ephemeral dev queues cleaned on stop, init-deploy --out app name, compose-style migrate-step network name, config preset validates configResolver; guards report correct isolation lines and time out kumiko-upgrade",
32
+ "detail": "The dev server serves `public/` files from the symlink-resolved path (realpath check before the read) and works when `public/` itself is a symlink. Ephemeral dev boots delete their per-boot BullMQ keys from the shared Redis on `stop()`. `kumiko-init-deploy --out <dir>` defaults the app name from that directory's `package.json`. The generated `migrate-step.sh` with `stackNetwork: \"directory\"` derives the network name like Docker Compose (lowercased, sanitised, `COMPOSE_PROJECT_NAME` wins). `setupTestStackFromFeatures` with the `config` preset throws a clear error when `extraContext.configResolver` is not a resolver. The runtime-isolation guard reports each regression at its own import line, and the upgrade-state guard no longer hangs on a stuck `kumiko-upgrade` (stdin ignored, timeout)."
33
+ },
2
34
  {
3
35
  "version": "0.335.0",
4
36
  "type": "fix",
@@ -2,14 +2,14 @@
2
2
  #
3
3
  # Production-Image for {{appName}}.
4
4
  #
5
- # Server-Bundle variant — the runtime image doesn't know about the
6
- # monorepo any more, only:
5
+ # Server-Bundle variant — the runtime image doesn't carry the source tree
6
+ # or its node_modules any more, only:
7
7
  #
8
8
  # dist/ ← Client build (kumiko-build)
9
9
  # dist-server/ ← Server bundle (bun build) + minimal package.json
10
10
  # kumiko/migrations/ ← Checked-in SQL migrations applied by `kumiko schema apply`
11
11
  #
12
- # Build context: repository root (monorepo is used in the build stage).
12
+ # Build context: repository root (the full source tree is only used in the build stage).
13
13
 
14
14
  ARG BUN_VERSION=1.4.0
15
15
  {{#hasPrivateGhPackages}}
@@ -33,9 +33,11 @@ if [ -z "$STACK_NETWORK" ]; then
33
33
  fi
34
34
  {{/stackNetworkDiscover}}
35
35
  {{#stackNetworkDirectory}}
36
- STACK_NETWORK="$(basename "$PWD")_stack"
36
+ # Compose lowercases the project name and drops characters outside [a-z0-9_-].
37
+ COMPOSE_PROJECT="${COMPOSE_PROJECT_NAME:-$(basename "$PWD" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9_-')}"
38
+ STACK_NETWORK="${COMPOSE_PROJECT}_stack"
37
39
  if ! docker network inspect "$STACK_NETWORK" >/dev/null 2>&1; then
38
- echo "Stack network '$STACK_NETWORK' not found — compose project not running?" >&2
40
+ echo "Stack network '$STACK_NETWORK' not found (derived from COMPOSE_PROJECT_NAME or the directory name '$(basename "$PWD")') — compose project not running?" >&2
39
41
  exit 1
40
42
  fi
41
43
  {{/stackNetworkDirectory}}
@@ -47,6 +49,21 @@ set -a
47
49
  . ./.env
48
50
  set +a
49
51
 
52
+ # Percent-encode so '@', ':', '/', '#', '%' in the password cannot change how
53
+ # the URL is parsed. LC_ALL=C makes the loop walk bytes, not characters;
54
+ # bash sign-extends bytes >= 0x80 in "'$c", hence the & 255.
55
+ urlencode() {
56
+ local LC_ALL=C s="$1" out="" c i byte
57
+ for ((i = 0; i < ${#s}; i++)); do
58
+ c="${s:i:1}"
59
+ case "$c" in
60
+ [A-Za-z0-9._~-]) out+="$c" ;;
61
+ *) printf -v byte '%d' "'$c"; printf -v c '%%%02X' $((byte & 255)); out+="$c" ;;
62
+ esac
63
+ done
64
+ printf '%s' "$out"
65
+ }
66
+
50
67
  # DATABASE_URL assumes: db user = appName, db name = appName, host "db"
51
68
  # (compose-service-name), port 5432. Adjust to your stack if different.
52
69
  {{#customDbUser}}
@@ -58,7 +75,7 @@ set +a
58
75
  # NAME (`-e DATABASE_URL`, no value) so the expanded password never appears
59
76
  # in `docker run`'s argv — otherwise it would be visible in `ps auxe` for
60
77
  # the duration of the migrate run.
61
- export DATABASE_URL="postgresql://{{dbUser}}:${DB_PASSWORD}@db:5432/{{appName}}"
78
+ export DATABASE_URL="postgresql://{{dbUser}}:$(urlencode "$DB_PASSWORD")@db:5432/{{appName}}"
62
79
  docker run --rm \
63
80
  --network "$STACK_NETWORK" \
64
81
  -e DATABASE_URL \