@cosmicdrift/kumiko-dev-server 0.327.0 → 0.328.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.
- package/package.json +5 -5
- package/src/__tests__/create-kumiko-server.integration.test.ts +50 -1
- package/src/__tests__/setup-test-stack-job-run-logger.integration.test.ts +72 -0
- package/src/changes.json +6 -0
- package/src/create-kumiko-server.ts +29 -0
- package/src/setup-test-stack-from-features.ts +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.328.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>",
|
|
@@ -60,10 +60,10 @@
|
|
|
60
60
|
"kumiko-upgrade": "./bin/kumiko-upgrade.ts"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
64
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
65
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
66
|
-
"@cosmicdrift/kumiko-server-runtime": "0.
|
|
63
|
+
"@cosmicdrift/kumiko-bundled-features": "0.328.0",
|
|
64
|
+
"@cosmicdrift/kumiko-framework": "0.328.0",
|
|
65
|
+
"@cosmicdrift/kumiko-headless": "0.328.0",
|
|
66
|
+
"@cosmicdrift/kumiko-server-runtime": "0.328.0",
|
|
67
67
|
"ts-morph": "^28.0.0"
|
|
68
68
|
},
|
|
69
69
|
"publishConfig": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import {
|
|
@@ -927,6 +927,55 @@ describe("createKumikoServer — public/ static files", () => {
|
|
|
927
927
|
});
|
|
928
928
|
});
|
|
929
929
|
|
|
930
|
+
describe("createKumikoServer — renderer-web fonts under /assets/kumiko/fonts", () => {
|
|
931
|
+
const FONTS_SOURCE_DIR = join(import.meta.dir, "../../../renderer-web/src/fonts");
|
|
932
|
+
|
|
933
|
+
async function withFixtureCwd(run: () => Promise<void>): Promise<void> {
|
|
934
|
+
const tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "kumiko-fonts-")));
|
|
935
|
+
const cwdBefore = process.cwd();
|
|
936
|
+
process.chdir(tmpDir);
|
|
937
|
+
try {
|
|
938
|
+
handle = await createKumikoServer({
|
|
939
|
+
features: [probeFeature],
|
|
940
|
+
port: 0,
|
|
941
|
+
installSignalHandlers: false,
|
|
942
|
+
});
|
|
943
|
+
await run();
|
|
944
|
+
} finally {
|
|
945
|
+
process.chdir(cwdBefore);
|
|
946
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
test("GET on a shipped font → 200, font/woff2, the file's bytes", async () => {
|
|
951
|
+
await withFixtureCwd(async () => {
|
|
952
|
+
const res = await handle?.fetch(
|
|
953
|
+
new Request("http://localhost/assets/kumiko/fonts/ibm-plex-sans-latin-400.woff2"),
|
|
954
|
+
);
|
|
955
|
+
expect(res?.status).toBe(200);
|
|
956
|
+
expect(res?.headers.get("content-type")).toBe("font/woff2");
|
|
957
|
+
const expected = readFileSync(join(FONTS_SOURCE_DIR, "ibm-plex-sans-latin-400.woff2"));
|
|
958
|
+
expect(Buffer.from(await (res as Response).arrayBuffer()).equals(expected)).toBe(true);
|
|
959
|
+
});
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
test("unknown names and traversal attempts → 404, never file content", async () => {
|
|
963
|
+
await withFixtureCwd(async () => {
|
|
964
|
+
for (const path of [
|
|
965
|
+
"/assets/kumiko/fonts/nope.woff2",
|
|
966
|
+
"/assets/kumiko/fonts/OFL.txt",
|
|
967
|
+
"/assets/kumiko/fonts/../styles.css",
|
|
968
|
+
"/assets/kumiko/fonts/..%2fstyles.css",
|
|
969
|
+
"/assets/kumiko/fonts/%2e%2e%2f%2e%2e%2fpackage.json",
|
|
970
|
+
"/assets/kumiko/fonts/ibm-plex-sans-latin-400.woff2/x",
|
|
971
|
+
]) {
|
|
972
|
+
const res = await handle?.fetch(new Request(`http://localhost${path}`));
|
|
973
|
+
expect(res?.status, path).toBe(404);
|
|
974
|
+
}
|
|
975
|
+
});
|
|
976
|
+
});
|
|
977
|
+
});
|
|
978
|
+
|
|
930
979
|
describe("createKumikoServer — real Bun.serve socket resolves the client IP (fw#3260 addendum)", () => {
|
|
931
980
|
// Every other test in this file drives h.fetch()/handle.fetch() directly,
|
|
932
981
|
// bypassing Bun.serve's real TCP socket entirely — none of them can catch
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// setupTestStackFromFeatures wires prod's job run-logger by default: a job
|
|
2
|
+
// declared with tenantVisibleFailure records its final failure per tenant, and
|
|
3
|
+
// drainJobs() only rejects after that row is written.
|
|
4
|
+
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
6
|
+
import { createJobsFeature, JobQueries } from "@cosmicdrift/kumiko-bundled-features/jobs";
|
|
7
|
+
import { defineFeature, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { createTestUser, type TestStack, testTenantId } from "@cosmicdrift/kumiko-framework/stack";
|
|
9
|
+
import * as z from "zod";
|
|
10
|
+
import { setupTestStackFromFeatures } from "../setup-test-stack-from-features";
|
|
11
|
+
|
|
12
|
+
const FAILURE_KEY = "app:errors.generationFailed";
|
|
13
|
+
const PROVIDER_MESSAGE = "provider 429: prompt with customer data rejected";
|
|
14
|
+
|
|
15
|
+
const tenantA = testTenantId(1);
|
|
16
|
+
const tenantB = testTenantId(2);
|
|
17
|
+
const userA = createTestUser({ id: 1, tenantId: tenantA, roles: ["Admin"] });
|
|
18
|
+
const userB = createTestUser({ id: 2, tenantId: tenantB, roles: ["Admin"] });
|
|
19
|
+
|
|
20
|
+
const appFeature = defineFeature("app", (r) => {
|
|
21
|
+
r.writeHandler(
|
|
22
|
+
defineWriteHandler({
|
|
23
|
+
name: "generate",
|
|
24
|
+
description: "Test-only: starts a job that always fails.",
|
|
25
|
+
schema: z.object({ campaignId: z.string() }),
|
|
26
|
+
access: { roles: ["Admin"] },
|
|
27
|
+
handler: async (event) => ({ isSuccess: true as const, data: { ...event.payload } }),
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
r.job(
|
|
31
|
+
"generateTexts",
|
|
32
|
+
{
|
|
33
|
+
trigger: { on: "app:write:generate" },
|
|
34
|
+
retries: 0,
|
|
35
|
+
tenantVisibleFailure: { messageKey: FAILURE_KEY, subjectFields: ["campaignId"] },
|
|
36
|
+
},
|
|
37
|
+
async () => {
|
|
38
|
+
throw new Error(PROVIDER_MESSAGE);
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
type FailureRow = { readonly jobName: string; readonly messageKey: string };
|
|
44
|
+
|
|
45
|
+
let stack: TestStack;
|
|
46
|
+
|
|
47
|
+
beforeAll(async () => {
|
|
48
|
+
stack = await setupTestStackFromFeatures([appFeature, createJobsFeature()], {
|
|
49
|
+
jobs: { consumerLane: "worker" },
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterAll(async () => {
|
|
54
|
+
await stack.cleanup();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("setupTestStackFromFeatures({ jobs }) run-logger default", () => {
|
|
58
|
+
test("a failed tenant-visible job is readable by its tenant only, once drainJobs() rejects", async () => {
|
|
59
|
+
await stack.http.writeOk("app:write:generate", { campaignId: "campaign-1" }, userA);
|
|
60
|
+
|
|
61
|
+
await expect(stack.drainJobs()).rejects.toThrow(/app:job:generate-texts/);
|
|
62
|
+
|
|
63
|
+
const own = await stack.http.queryOk<{ rows: FailureRow[] }>(JobQueries.failures, {}, userA);
|
|
64
|
+
expect(own.rows).toHaveLength(1);
|
|
65
|
+
expect(own.rows[0]?.jobName).toBe("app:job:generate-texts");
|
|
66
|
+
expect(own.rows[0]?.messageKey).toBe(FAILURE_KEY);
|
|
67
|
+
expect(JSON.stringify(own)).not.toContain(PROVIDER_MESSAGE);
|
|
68
|
+
|
|
69
|
+
const other = await stack.http.queryOk<{ rows: FailureRow[] }>(JobQueries.failures, {}, userB);
|
|
70
|
+
expect(other.rows).toHaveLength(0);
|
|
71
|
+
});
|
|
72
|
+
});
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.328.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Dev server serves the renderer-web fonts",
|
|
6
|
+
"detail": "GET `/assets/kumiko/fonts/<file>.woff2` streams the packaged IBM Plex files with `font/woff2` and a one day cache header. Names outside the allowlist answer 404."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.323.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -55,6 +55,11 @@ import {
|
|
|
55
55
|
makeDispatchSystemWrite,
|
|
56
56
|
type SystemWireDeps,
|
|
57
57
|
} from "@cosmicdrift/kumiko-server-runtime/extra-routes-deps";
|
|
58
|
+
import {
|
|
59
|
+
RENDERER_WEB_FONT_FILE_PATTERN,
|
|
60
|
+
RENDERER_WEB_FONTS_URL_PREFIX,
|
|
61
|
+
resolveRendererWebFontsDir,
|
|
62
|
+
} from "@cosmicdrift/kumiko-server-runtime/renderer-web-fonts";
|
|
58
63
|
import {
|
|
59
64
|
canResolveTailwindStylesheet,
|
|
60
65
|
resolveTailwindCli,
|
|
@@ -458,6 +463,7 @@ const PUBLIC_FILE_MIME_TYPES = new Map<string, string>([
|
|
|
458
463
|
["txt", "text/plain; charset=utf-8"],
|
|
459
464
|
["xml", "application/xml; charset=utf-8"],
|
|
460
465
|
["webmanifest", "application/manifest+json"],
|
|
466
|
+
["woff2", "font/woff2"],
|
|
461
467
|
]);
|
|
462
468
|
|
|
463
469
|
function publicFileMimeType(filePath: string): string {
|
|
@@ -1081,6 +1087,9 @@ export async function createKumikoServer(
|
|
|
1081
1087
|
// App-root convention (same as expandWatchPatterns/resolveStylesheet above):
|
|
1082
1088
|
// process.cwd() is the app workspace, so public/ is its static asset dir.
|
|
1083
1089
|
const publicDir = resolve(process.cwd(), "public");
|
|
1090
|
+
// The app's cwd first, this package's own location as fallback (the cwd may
|
|
1091
|
+
// not have renderer-web installed, e.g. a fixture dir).
|
|
1092
|
+
const rendererWebFontsDir = resolveRendererWebFontsDir([process.cwd(), import.meta.dir]);
|
|
1084
1093
|
|
|
1085
1094
|
const handleFetch = async (req: Request, socketAddress?: string): Promise<Response> => {
|
|
1086
1095
|
const url = new URL(req.url);
|
|
@@ -1122,6 +1131,26 @@ export async function createKumikoServer(
|
|
|
1122
1131
|
}
|
|
1123
1132
|
}
|
|
1124
1133
|
|
|
1134
|
+
if (url.pathname.startsWith(RENDERER_WEB_FONTS_URL_PREFIX) && req.method === "GET") {
|
|
1135
|
+
const fontFile = url.pathname.slice(RENDERER_WEB_FONTS_URL_PREFIX.length);
|
|
1136
|
+
// The allowlist pattern excludes "/", "." segments and encoded traversal.
|
|
1137
|
+
if (rendererWebFontsDir === undefined || !RENDERER_WEB_FONT_FILE_PATTERN.test(fontFile)) {
|
|
1138
|
+
return new Response("not found", { status: 404 });
|
|
1139
|
+
}
|
|
1140
|
+
try {
|
|
1141
|
+
const bytes = await readFile(join(rendererWebFontsDir, fontFile));
|
|
1142
|
+
// @cast-boundary Buffer satisfies BodyInit at runtime, bun-types just doesn't say so.
|
|
1143
|
+
return new Response(bytes as unknown as BodyInit, {
|
|
1144
|
+
headers: { "Content-Type": "font/woff2", "Cache-Control": "public, max-age=86400" },
|
|
1145
|
+
});
|
|
1146
|
+
} catch (err) {
|
|
1147
|
+
if ((err as { code?: string }).code === "ENOENT") {
|
|
1148
|
+
return new Response("not found", { status: 404 });
|
|
1149
|
+
}
|
|
1150
|
+
throw err;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1125
1154
|
if (url.pathname === "/styles.css" && req.method === "GET") {
|
|
1126
1155
|
if (stylesheetPath === undefined) return new Response("no stylesheet", { status: 404 });
|
|
1127
1156
|
const css = await readFile(stylesheetPath, "utf-8");
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type TestStack,
|
|
13
13
|
type TestStackOptions,
|
|
14
14
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
15
|
+
import { jobRunLoggerCallbacks } from "@cosmicdrift/kumiko-server-runtime/boot/job-run-logger";
|
|
15
16
|
import {
|
|
16
17
|
type ComposeFeaturesOptions,
|
|
17
18
|
composeFeatures,
|
|
@@ -59,8 +60,20 @@ export async function setupTestStackFromFeatures(
|
|
|
59
60
|
const features = composeFeatures(appFeatures, { includeBundled, authOptions });
|
|
60
61
|
const extraContext = mergeExtraContext(stackOptions.extraContext, presets);
|
|
61
62
|
|
|
63
|
+
// Same run-logger wiring as prod, so jobs that declare tenantVisibleFailure
|
|
64
|
+
// write their failure rows in tests too.
|
|
65
|
+
const defaultRunLogger: NonNullable<NonNullable<TestStackOptions["jobs"]>["runLogger"]> = ({
|
|
66
|
+
registry,
|
|
67
|
+
db,
|
|
68
|
+
}) => jobRunLoggerCallbacks(registry, db);
|
|
69
|
+
const jobs =
|
|
70
|
+
stackOptions.jobs === undefined
|
|
71
|
+
? undefined
|
|
72
|
+
: { runLogger: defaultRunLogger, ...stackOptions.jobs };
|
|
73
|
+
|
|
62
74
|
return setupTestStack({
|
|
63
75
|
...stackOptions,
|
|
76
|
+
...(jobs !== undefined && { jobs }),
|
|
64
77
|
features,
|
|
65
78
|
...(extraContext !== undefined && { extraContext }),
|
|
66
79
|
});
|