@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.
|
|
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.
|
|
63
|
-
"@cosmicdrift/kumiko-framework": "0.243.
|
|
64
|
-
"@cosmicdrift/kumiko-server-runtime": "0.243.
|
|
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
|
|
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
|
-
//
|
|
952
|
-
//
|
|
953
|
-
|
|
954
|
-
|
|
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
|
|