@cosmicdrift/kumiko-dev-server 0.243.2 → 0.243.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dev-server",
3
- "version": "0.243.2",
3
+ "version": "0.243.4",
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>",
@@ -59,9 +59,9 @@
59
59
  "kumiko-upgrade": "./bin/kumiko-upgrade.ts"
60
60
  },
61
61
  "dependencies": {
62
- "@cosmicdrift/kumiko-bundled-features": "0.243.2",
63
- "@cosmicdrift/kumiko-framework": "0.243.2",
64
- "@cosmicdrift/kumiko-server-runtime": "0.243.2",
62
+ "@cosmicdrift/kumiko-bundled-features": "0.243.4",
63
+ "@cosmicdrift/kumiko-framework": "0.243.4",
64
+ "@cosmicdrift/kumiko-server-runtime": "0.243.4",
65
65
  "ts-morph": "^28.0.0"
66
66
  },
67
67
  "publishConfig": {
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { NO_ROUTE_MATCH_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api";
@@ -571,3 +571,96 @@ describe("createKumikoServer — tryHonoFirst 404-vs-router-miss (#2435)", () =>
571
571
  expect(deniedRes.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
572
572
  });
573
573
  });
574
+
575
+ // A dotted GET path (e.g. /marketing/hero.png) used to fall through the SPA
576
+ // catch-all's "no dot" filter straight to the API stack and 404 — works in
577
+ // prod (buildStaticFallback's disk lookup under staticDir) but not dev,
578
+ // which had no public/-serving at all. publicDir is process.cwd()-relative
579
+ // (same App-Root convention as resolveStylesheet's src/styles.css lookup),
580
+ // so these tests chdir into a fixture directory for the boot.
581
+ describe("createKumikoServer — public/ static files", () => {
582
+ test("GET on an existing file under public/ → 200, correct content-type + content", async () => {
583
+ const tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "kumiko-public-")));
584
+ const publicDir = join(tmpDir, "public");
585
+ mkdirSync(join(publicDir, "marketing"), { recursive: true });
586
+ writeFileSync(join(publicDir, "marketing", "hero.png"), "PNGDATA");
587
+ const cwdBefore = process.cwd();
588
+ process.chdir(tmpDir);
589
+ try {
590
+ handle = await createKumikoServer({
591
+ features: [probeFeature],
592
+ port: 0,
593
+ installSignalHandlers: false,
594
+ });
595
+ const res = await handle.fetch(new Request("http://localhost/marketing/hero.png"));
596
+ expect(res.status).toBe(200);
597
+ expect(res.headers.get("content-type")).toBe("image/png");
598
+ expect(await res.text()).toBe("PNGDATA");
599
+ } finally {
600
+ process.chdir(cwdBefore);
601
+ rmSync(tmpDir, { recursive: true, force: true });
602
+ }
603
+ });
604
+
605
+ test("traversal attempts never serve a file from outside public/", async () => {
606
+ const tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "kumiko-public-traversal-")));
607
+ const publicDir = join(tmpDir, "public");
608
+ mkdirSync(publicDir, { recursive: true });
609
+ // Secret sits as a SIBLING of public/ — a traversal that escapes
610
+ // containment would read this instead of 404ing.
611
+ writeFileSync(join(tmpDir, "secret.txt"), "TOP-SECRET");
612
+ const cwdBefore = process.cwd();
613
+ process.chdir(tmpDir);
614
+ try {
615
+ handle = await createKumikoServer({
616
+ features: [probeFeature],
617
+ port: 0,
618
+ installSignalHandlers: false,
619
+ });
620
+
621
+ // WHATWG URL parsing already collapses a literal ".." segment (it's
622
+ // delimited by a real "/"), so this pins the composed behavior —
623
+ // request never reaches secret.txt — rather than the guard itself.
624
+ const literal = await handle.fetch(new Request("http://localhost/../secret.txt"));
625
+ expect(literal.status).not.toBe(200);
626
+
627
+ // %2e%2e%2f is the actual vector the decode+resolve+containment guard
628
+ // exists for: URL path-parsing only normalizes dot-segments split by a
629
+ // literal "/", so an encoded slash survives untouched into pathname —
630
+ // without the explicit decode in resolvePublicFilePath this would
631
+ // resolve straight to tmpDir/secret.txt.
632
+ const encoded = await handle.fetch(new Request("http://localhost/%2e%2e%2fsecret.txt"));
633
+ expect(encoded.status).not.toBe(200);
634
+
635
+ // Neither attempt leaked the secret's content through any other path
636
+ // (e.g. as an error body).
637
+ expect(await literal.clone().text()).not.toContain("TOP-SECRET");
638
+ expect(await encoded.clone().text()).not.toContain("TOP-SECRET");
639
+ } finally {
640
+ process.chdir(cwdBefore);
641
+ rmSync(tmpDir, { recursive: true, force: true });
642
+ }
643
+ });
644
+
645
+ test("a dot-less path still hits the SPA catch-all, not the public/-file lookup", async () => {
646
+ const tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "kumiko-public-spa-")));
647
+ const publicDir = join(tmpDir, "public");
648
+ mkdirSync(publicDir, { recursive: true });
649
+ const cwdBefore = process.cwd();
650
+ process.chdir(tmpDir);
651
+ try {
652
+ handle = await createKumikoServer({
653
+ features: [probeFeature],
654
+ port: 0,
655
+ installSignalHandlers: false,
656
+ });
657
+ const res = await handle.fetch(new Request("http://localhost/some/client-route"));
658
+ expect(res.status).toBe(200);
659
+ expect(res.headers.get("content-type")).toMatch(/text\/html/);
660
+ expect(await res.text()).toMatch(/<div id="root">/);
661
+ } finally {
662
+ process.chdir(cwdBefore);
663
+ rmSync(tmpDir, { recursive: true, force: true });
664
+ }
665
+ });
666
+ });
@@ -16,9 +16,9 @@
16
16
 
17
17
  import { spawn } from "node:child_process";
18
18
  import { existsSync, mkdtempSync, statSync } from "node:fs";
19
- import { readFile, watch } from "node:fs/promises";
19
+ import { readFile, realpath, watch } from "node:fs/promises";
20
20
  import { tmpdir } from "node:os";
21
- import { join, resolve } from "node:path";
21
+ import { join, resolve, sep } from "node:path";
22
22
  import { resolveAnonymousAccessFromRegistry } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
23
23
  import { type AuthRoutesConfig, generateToken } from "@cosmicdrift/kumiko-framework/api";
24
24
  import { buildAppSchema, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
@@ -42,6 +42,7 @@ import {
42
42
  resolveTailwindCli,
43
43
  } from "@cosmicdrift/kumiko-server-runtime/resolve-tailwind-cli";
44
44
  import {
45
+ type HonoLikeApp,
45
46
  stripNoRouteMatchHeader,
46
47
  tryHonoFirst,
47
48
  } from "@cosmicdrift/kumiko-server-runtime/try-hono-first";
@@ -336,6 +337,118 @@ function injectStylesheet(html: string): string {
336
337
  : `${link}${html}`;
337
338
  }
338
339
 
340
+ // GET/HEAD to a non-API, non-SSE path — the dispatch-worthy set shared by
341
+ // the SPA catch-all and the public/-file lookup below (they split on
342
+ // whether the path has a dot).
343
+ function isRoutableGetOrHead(req: Request, pathname: string): boolean {
344
+ return (
345
+ (req.method === "GET" || req.method === "HEAD") &&
346
+ !pathname.startsWith("/api/") &&
347
+ !pathname.startsWith("/sse")
348
+ );
349
+ }
350
+
351
+ // Resolves a request pathname to a file inside publicDir, or undefined if
352
+ // it isn't one. Two traversal vectors, both must be blocked:
353
+ // - literal ".." segments (new URL() already collapses these in
354
+ // `pathname`, but resolve()+containment is checked regardless — no
355
+ // path-safety may depend on caller behavior upstream).
356
+ // - percent-encoded slashes (e.g. "%2e%2e%2f"): WHATWG URL parsing only
357
+ // normalizes dot-segments that are delimited by a literal "/", so an
358
+ // encoded slash survives into `pathname` untouched. decodeURIComponent
359
+ // resolves it to "../" before the containment check runs.
360
+ function resolvePublicFilePath(pathname: string, publicDir: string): string | undefined {
361
+ let decoded: string;
362
+ try {
363
+ decoded = decodeURIComponent(pathname);
364
+ } catch {
365
+ return undefined;
366
+ }
367
+ const resolved = resolve(publicDir, `.${decoded}`);
368
+ return resolved === publicDir || resolved.startsWith(publicDir + sep) ? resolved : undefined;
369
+ }
370
+
371
+ // Same extension → content-type mapping as prod's
372
+ // run-prod-app-static-files.ts#mimeTypeFor — that function isn't part of
373
+ // server-runtime's public exports (its own package.json "exports" map),
374
+ // so this is a small self-contained copy rather than a new cross-package
375
+ // export for one call site.
376
+ const PUBLIC_FILE_MIME_TYPES = new Map<string, string>([
377
+ ["html", "text/html; charset=utf-8"],
378
+ ["js", "text/javascript; charset=utf-8"],
379
+ ["mjs", "text/javascript; charset=utf-8"],
380
+ ["css", "text/css; charset=utf-8"],
381
+ ["json", "application/json; charset=utf-8"],
382
+ ["svg", "image/svg+xml"],
383
+ ["png", "image/png"],
384
+ ["jpg", "image/jpeg"],
385
+ ["jpeg", "image/jpeg"],
386
+ ["ico", "image/x-icon"],
387
+ ["txt", "text/plain; charset=utf-8"],
388
+ ["xml", "application/xml; charset=utf-8"],
389
+ ["webmanifest", "application/manifest+json"],
390
+ ]);
391
+
392
+ function publicFileMimeType(filePath: string): string {
393
+ const ext = filePath.toLowerCase().split(".").pop() ?? "";
394
+ return PUBLIC_FILE_MIME_TYPES.get(ext) ?? "application/octet-stream";
395
+ }
396
+
397
+ // Reads a file under publicDir for the dev-server's static-asset fallback
398
+ // (dev-parity with prod's disk lookup in buildStaticFallback). undefined
399
+ // means "not a servable file here" — caller treats that as a router miss,
400
+ // same as ENOENT/EISDIR/ENOTDIR further down.
401
+ async function servePublicFile(
402
+ pathname: string,
403
+ publicDir: string,
404
+ ): Promise<{ readonly bytes: Uint8Array; readonly mime: string } | undefined> {
405
+ const filePath = resolvePublicFilePath(pathname, publicDir);
406
+ if (filePath === undefined) return undefined;
407
+ try {
408
+ const bytes = await readFile(filePath);
409
+ // Lexical containment (above) only catches "..", not a symlink inside
410
+ // publicDir that points outside it on disk — realpath resolves the
411
+ // actual target and the same containment check runs against it.
412
+ const real = await realpath(filePath);
413
+ if (real !== publicDir && !real.startsWith(publicDir + sep)) return undefined;
414
+ return { bytes, mime: publicFileMimeType(filePath) };
415
+ } catch (err) {
416
+ const code = (err as { code?: string }).code;
417
+ if (code === "ENOENT" || code === "EISDIR" || code === "ENOTDIR") return undefined;
418
+ throw err;
419
+ }
420
+ }
421
+
422
+ // Static assets under public/ — prod serves these via buildStaticFallback's
423
+ // disk lookup (run-prod-app-static-files.ts), dev previously had no
424
+ // equivalent: a dotted path (e.g. /marketing/hero.png) fell straight
425
+ // through to the API stack and 404ed. Hono still goes first (an
426
+ // r.httpRoute could itself own a dotted path), then the file on disk,
427
+ // then the router-miss 404 — mirrors handleFetch's SPA branch. undefined
428
+ // means "not a static-asset request", caller falls through to the next
429
+ // route.
430
+ async function tryServePublicAsset(
431
+ req: Request,
432
+ pathname: string,
433
+ app: HonoLikeApp,
434
+ publicDir: string,
435
+ ): Promise<Response | undefined> {
436
+ if (!isRoutableGetOrHead(req, pathname) || !pathname.includes(".")) return undefined;
437
+ const honoTry = await tryHonoFirst(app, req);
438
+ if (honoTry.matched) {
439
+ return honoTry.response;
440
+ }
441
+ const file = await servePublicFile(pathname, publicDir);
442
+ if (file !== undefined) {
443
+ // @cast-boundary Buffer satisfies BodyInit at runtime, bun-types
444
+ // just doesn't say so — same cast run-prod-app-static-files.ts uses.
445
+ return new Response(file.bytes as unknown as BodyInit, {
446
+ headers: { "Content-Type": file.mime },
447
+ });
448
+ }
449
+ return honoTry.response;
450
+ }
451
+
339
452
  // injectSchema lebt in `./inject-schema.ts` damit dev-server + prod-
340
453
  // server denselben Inject-Pfad nutzen.
341
454
 
@@ -829,6 +942,10 @@ export async function createKumikoServer(
829
942
  const bundleByAssetPath = new Map<string, string>();
830
943
  for (const e of entries) bundleByAssetPath.set(assetPathFor(e.name), e.name);
831
944
 
945
+ // App-root convention (same as expandWatchPatterns/resolveStylesheet above):
946
+ // process.cwd() is the app workspace, so public/ is its static asset dir.
947
+ const publicDir = resolve(process.cwd(), "public");
948
+
832
949
  const handleFetch = async (req: Request): Promise<Response> => {
833
950
  const url = new URL(req.url);
834
951
 
@@ -913,9 +1030,7 @@ export async function createKumikoServer(
913
1030
  if (
914
1031
  // HEAD mitnehmen — prod (runProdApp) fällt für GET UND HEAD auf die
915
1032
  // SPA zurück; ohne das liefert dev 404 wo prod 200 liefert.
916
- (req.method === "GET" || req.method === "HEAD") &&
917
- !url.pathname.startsWith("/api/") &&
918
- !url.pathname.startsWith("/sse") &&
1033
+ isRoutableGetOrHead(req, url.pathname) &&
919
1034
  !url.pathname.includes(".")
920
1035
  ) {
921
1036
  const honoTry = await tryHonoFirst(stack.app, req);
@@ -948,10 +1063,15 @@ export async function createKumikoServer(
948
1063
  return htmlResponse("client", true);
949
1064
  }
950
1065
 
951
- // Bypasses tryHonoFirst entirely (API paths, dotted paths, /sse,
952
- // non-GET/HEAD), so the router-miss marker must be stripped here too —
953
- // otherwise an unmatched path would leak it straight to the client
954
- // (see try-hono-first.ts's header-hygiene note).
1066
+ // Static assets under public/ see tryServePublicAsset's own comment
1067
+ // for the Hono file router-miss ordering.
1068
+ const staticAsset = await tryServePublicAsset(req, url.pathname, stack.app, publicDir);
1069
+ if (staticAsset !== undefined) return staticAsset;
1070
+
1071
+ // Bypasses tryHonoFirst entirely (API paths, /sse, non-GET/HEAD), so the
1072
+ // router-miss marker must be stripped here too — otherwise an unmatched
1073
+ // path would leak it straight to the client (see try-hono-first.ts's
1074
+ // header-hygiene note).
955
1075
  return stripNoRouteMatchHeader(await stack.app.fetch(req));
956
1076
  };
957
1077