@cosmicdrift/kumiko-dev-server 0.336.0 → 0.337.1

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.
@@ -185,6 +185,9 @@ export type KumikoServerHandle = {
185
185
  readonly stack: TestStack;
186
186
  /** Stops the server and tears down the stack (DB + redis). */
187
187
  readonly stop: () => Promise<void>;
188
+ /** BullMQ queue-name prefix of this boot: per-boot in ephemeral mode, so
189
+ * its `bull:<prefix>-*` keys belong to this server alone on a shared Redis. */
190
+ readonly jobQueueNamePrefix: string;
188
191
  };
189
192
  export declare const STYLESHEET_WATCH_ENV = "KUMIKO_DEV_STYLESHEET_WATCH";
190
193
  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) {
@@ -756,7 +757,12 @@ export async function createKumikoServer(options) {
756
757
  bundleByAssetPath.set(assetPathFor(e.name), e.name);
757
758
  // App-root convention (same as expandWatchPatterns/resolveStylesheet above):
758
759
  // process.cwd() is the app workspace, so public/ is its static asset dir.
759
- const publicDir = resolve(process.cwd(), "public");
760
+ // Symlink-resolved once: servePublicFile compares realpath()s against it, so
761
+ // a symlinked public/ would otherwise 404 every file.
762
+ const lexicalPublicDir = resolve(process.cwd(), "public");
763
+ const publicDir = existsSync(lexicalPublicDir)
764
+ ? realpathSync(lexicalPublicDir)
765
+ : lexicalPublicDir;
760
766
  // The app's cwd first, this package's own location as fallback (the cwd may
761
767
  // not have renderer-web installed, e.g. a fixture dir).
762
768
  const rendererWebFontsDir = resolveRendererWebFontsDir([process.cwd(), import.meta.dir]);
@@ -984,6 +990,29 @@ export async function createKumikoServer(options) {
984
990
  }, watcherAbort.signal);
985
991
  }
986
992
  }
993
+ // The per-boot prefix is never reused, so its BullMQ keys (queues,
994
+ // repeatables, delayed jobs) would pile up in the shared Redis forever.
995
+ // A prefix-less duplicate: SCAN returns full key names, which the stack
996
+ // client's keyPrefix would otherwise prepend a second time.
997
+ // Takes the connection as a parameter: it must be opened before
998
+ // stack.cleanup() closes the stack client, yet used only after it, because
999
+ // closing the stack's enqueuer queues re-writes their `meta` keys.
1000
+ const deleteEphemeralJobQueues = async (raw) => {
1001
+ try {
1002
+ const keys = [];
1003
+ for await (const batch of raw.scanStream({
1004
+ match: `bull:${jobQueueNamePrefix}-*`,
1005
+ count: 500,
1006
+ })) {
1007
+ keys.push(...batch);
1008
+ }
1009
+ if (keys.length > 0)
1010
+ await raw.del(...keys);
1011
+ }
1012
+ finally {
1013
+ raw.disconnect();
1014
+ }
1015
+ };
987
1016
  const stop = async () => {
988
1017
  // Watcher zuerst stoppen damit kein onChange während des Teardowns
989
1018
  // mehr feuert (sonst können tmpdir-rmSync ein process.exit(75)
@@ -998,7 +1027,10 @@ export async function createKumikoServer(options) {
998
1027
  await stack.eventDispatcher.stop();
999
1028
  }
1000
1029
  await devJobRunners.stop();
1030
+ const rawRedis = persistentDb ? undefined : stack.redis.redis.duplicate({ keyPrefix: "" });
1001
1031
  await stack.cleanup();
1032
+ if (rawRedis)
1033
+ await deleteEphemeralJobQueues(rawRedis);
1002
1034
  };
1003
1035
  // --- graceful shutdown ---
1004
1036
  // Signal handlers fire on Ctrl-C / kill. Without them, repeated dev
@@ -1041,5 +1073,5 @@ export async function createKumikoServer(options) {
1041
1073
  ? ` (hot reload on ${entries.length === 1 ? "client entry" : `${entries.length} entries`})`
1042
1074
  : ""));
1043
1075
  }
1044
- return { fetch: handleFetch, server, stack, stop };
1076
+ return { fetch: handleFetch, server, stack, stop, jobQueueNamePrefix };
1045
1077
  }
@@ -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.0",
3
+ "version": "0.337.1",
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.0",
68
- "@cosmicdrift/kumiko-framework": "0.336.0",
69
- "@cosmicdrift/kumiko-headless": "0.336.0",
70
- "@cosmicdrift/kumiko-renderer-web": "0.336.0",
71
- "@cosmicdrift/kumiko-server-runtime": "0.336.0",
67
+ "@cosmicdrift/kumiko-bundled-features": "0.337.1",
68
+ "@cosmicdrift/kumiko-framework": "0.337.1",
69
+ "@cosmicdrift/kumiko-headless": "0.337.1",
70
+ "@cosmicdrift/kumiko-renderer-web": "0.337.1",
71
+ "@cosmicdrift/kumiko-server-runtime": "0.337.1",
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,17 @@
1
1
  [
2
+ {
3
+ "version": "0.337.1",
4
+ "type": "improvement",
5
+ "title": "createKumikoServer handle exposes the boot's jobQueueNamePrefix",
6
+ "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.",
7
+ "migration": "No action needed: purely additive handle field."
8
+ },
9
+ {
10
+ "version": "0.337.0",
11
+ "type": "fix",
12
+ "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",
13
+ "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)."
14
+ },
2
15
  {
3
16
  "version": "0.335.0",
4
17
  "type": "fix",
@@ -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}}