@cosmicdrift/kumiko-dev-server 0.318.0 → 0.320.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 +72 -23
- package/src/__tests__/merge-extra-context.test.ts +13 -2
- package/src/changes.json +19 -0
- package/src/create-kumiko-server.ts +81 -54
- package/src/run-dev-app.ts +21 -1
- package/src/scaffold-app.ts +3 -3
- package/src/setup-test-stack-from-features.ts +9 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.320.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>",
|
|
@@ -59,10 +59,10 @@
|
|
|
59
59
|
"kumiko-upgrade": "./bin/kumiko-upgrade.ts"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
63
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
64
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
65
|
-
"@cosmicdrift/kumiko-server-runtime": "0.
|
|
62
|
+
"@cosmicdrift/kumiko-bundled-features": "0.320.0",
|
|
63
|
+
"@cosmicdrift/kumiko-framework": "0.320.0",
|
|
64
|
+
"@cosmicdrift/kumiko-headless": "0.320.0",
|
|
65
|
+
"@cosmicdrift/kumiko-server-runtime": "0.320.0",
|
|
66
66
|
"ts-morph": "^28.0.0"
|
|
67
67
|
},
|
|
68
68
|
"publishConfig": {
|
|
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import {
|
|
6
6
|
ExtraRouteRejection,
|
|
7
7
|
NO_ROUTE_MATCH_HEADER_NAME,
|
|
8
|
+
requestContext,
|
|
8
9
|
signatureRoute,
|
|
9
10
|
} from "@cosmicdrift/kumiko-framework/api";
|
|
10
11
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
@@ -16,6 +17,7 @@ import {
|
|
|
16
17
|
type TenantId,
|
|
17
18
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
18
19
|
import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
20
|
+
import { getSetCookieValue } from "@cosmicdrift/kumiko-framework/testing";
|
|
19
21
|
import * as z from "zod";
|
|
20
22
|
import {
|
|
21
23
|
createKumikoServer,
|
|
@@ -50,6 +52,16 @@ const probeFeature = defineFeature("dev-server-probe", (r) => {
|
|
|
50
52
|
access: { roles: ["anonymous"] },
|
|
51
53
|
handler: async () => ({ pong: true }),
|
|
52
54
|
});
|
|
55
|
+
// Echoes the resolved client IP — proves a real Bun.serve socket address
|
|
56
|
+
// reaches requestContext.ip through handleFetch (fw#3260 addendum: every
|
|
57
|
+
// other test drives h.fetch()/handle.fetch() directly, never Bun.serve's
|
|
58
|
+
// real socket).
|
|
59
|
+
r.queryHandler({
|
|
60
|
+
name: "whoami",
|
|
61
|
+
schema: z.object({}),
|
|
62
|
+
access: { roles: ["anonymous"] },
|
|
63
|
+
handler: async () => ({ ip: requestContext.get()?.ip }),
|
|
64
|
+
});
|
|
53
65
|
// SystemAdmin-gated write — Ziel der entry:"signature" / `wire`-Tests
|
|
54
66
|
// (252/2): Echo von user.tenantId + roles beweist Dispatch durch
|
|
55
67
|
// den echten Dispatcher (Zod + Access-Check) mit Ziel-Tenant-SystemUser.
|
|
@@ -245,9 +257,9 @@ describe("createKumikoServer (Multi-Entry)", () => {
|
|
|
245
257
|
if (host === "apex.test") return { kind: "not-found" };
|
|
246
258
|
if (host === "old.test") return { kind: "redirect", to: "https://new.test/" };
|
|
247
259
|
if (host.startsWith("admin.")) {
|
|
248
|
-
return { kind: "html", entryName: "admin"
|
|
260
|
+
return { kind: "html", entryName: "admin" };
|
|
249
261
|
}
|
|
250
|
-
return { kind: "html", entryName: "public"
|
|
262
|
+
return { kind: "html", entryName: "public" };
|
|
251
263
|
},
|
|
252
264
|
});
|
|
253
265
|
}
|
|
@@ -288,35 +300,42 @@ describe("createKumikoServer (Multi-Entry)", () => {
|
|
|
288
300
|
expect(noFallback.status).toBe(404);
|
|
289
301
|
});
|
|
290
302
|
|
|
291
|
-
test("
|
|
303
|
+
test("HTML never carries the schema; GET /api/schema needs the auto-minted cookie (kumiko-framework#3314)", async () => {
|
|
292
304
|
handle = await bootMultiEntry();
|
|
293
305
|
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
306
|
+
const publicRes = await handle.fetch(
|
|
307
|
+
new Request("http://status.localhost/", { headers: { host: "status.localhost" } }),
|
|
308
|
+
);
|
|
309
|
+
expect(await publicRes.text()).not.toMatch(/__KUMIKO_SCHEMA__/);
|
|
310
|
+
|
|
311
|
+
const adminRes = await handle.fetch(
|
|
312
|
+
new Request("http://admin.localhost/", { headers: { host: "admin.localhost" } }),
|
|
313
|
+
);
|
|
314
|
+
expect(await adminRes.text()).not.toMatch(/__KUMIKO_SCHEMA__/);
|
|
315
|
+
|
|
316
|
+
const authCookie = getSetCookieValue(adminRes, "kumiko_auth");
|
|
317
|
+
expect(authCookie).toBeDefined();
|
|
318
|
+
|
|
319
|
+
const unauthenticated = await handle.fetch(new Request("http://admin.localhost/api/schema"));
|
|
320
|
+
expect(unauthenticated.status).toBe(401);
|
|
300
321
|
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
)
|
|
305
|
-
)
|
|
306
|
-
expect(
|
|
322
|
+
const authenticatedRes = await handle.fetch(
|
|
323
|
+
new Request("http://admin.localhost/api/schema", {
|
|
324
|
+
headers: { cookie: `kumiko_auth=${authCookie}` },
|
|
325
|
+
}),
|
|
326
|
+
);
|
|
327
|
+
expect(authenticatedRes.status).toBe(200);
|
|
328
|
+
const schema = await authenticatedRes.json();
|
|
329
|
+
// Guards against an empty features array making the #2062 `.some(...)`
|
|
330
|
+
// assertion below vacuously false.
|
|
331
|
+
expect(schema.features.length).toBeGreaterThan(0);
|
|
307
332
|
|
|
308
333
|
// #2062: setupTestStack always wires an in-memory SearchAdapter
|
|
309
334
|
// (test-stack.ts), so the dev-server call site's
|
|
310
335
|
// `searchAdapterMissing: !stack.context.searchAdapter` must read false —
|
|
311
336
|
// proving the flag actually flows from stack.context through to the
|
|
312
|
-
//
|
|
313
|
-
const
|
|
314
|
-
expect(schemaMatch).not.toBeNull();
|
|
315
|
-
const injectedSchema = JSON.parse(schemaMatch?.[1] ?? "{}");
|
|
316
|
-
// Guards against a regex miss silently degrading to `?? "{}"` — an empty
|
|
317
|
-
// features array would make `.some(...)` below vacuously false too.
|
|
318
|
-
expect(injectedSchema.features.length).toBeGreaterThan(0);
|
|
319
|
-
const anyFeatureMissingAdapter = injectedSchema.features.some(
|
|
337
|
+
// schema served by GET /api/schema.
|
|
338
|
+
const anyFeatureMissingAdapter = schema.features.some(
|
|
320
339
|
(f: { searchAdapterMissing?: boolean }) => f.searchAdapterMissing === true,
|
|
321
340
|
);
|
|
322
341
|
expect(anyFeatureMissingAdapter).toBe(false);
|
|
@@ -907,3 +926,33 @@ describe("createKumikoServer — public/ static files", () => {
|
|
|
907
926
|
}
|
|
908
927
|
});
|
|
909
928
|
});
|
|
929
|
+
|
|
930
|
+
describe("createKumikoServer — real Bun.serve socket resolves the client IP (fw#3260 addendum)", () => {
|
|
931
|
+
// Every other test in this file drives h.fetch()/handle.fetch() directly,
|
|
932
|
+
// bypassing Bun.serve's real TCP socket entirely — none of them can catch
|
|
933
|
+
// a regression where handleFetch loses the socket address before it
|
|
934
|
+
// reaches requestContext.ip (see client-ip.ts: falls back to "unknown"
|
|
935
|
+
// rather than throwing, so a wiring bug here stays silent otherwise).
|
|
936
|
+
test("a real loopback connection resolves to the actual socket address, not 'unknown'", async () => {
|
|
937
|
+
handle = await createKumikoServer({
|
|
938
|
+
features: [probeFeature],
|
|
939
|
+
port: 0,
|
|
940
|
+
installSignalHandlers: false,
|
|
941
|
+
anonymousAccess: { defaultTenantId: TENANT_ID },
|
|
942
|
+
});
|
|
943
|
+
const port = handle.server?.port;
|
|
944
|
+
if (port === undefined) throw new Error("expected handle.server to be listening");
|
|
945
|
+
|
|
946
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/query`, {
|
|
947
|
+
method: "POST",
|
|
948
|
+
headers: { "content-type": "application/json" },
|
|
949
|
+
body: JSON.stringify({ type: "dev-server-probe:query:whoami", payload: {} }),
|
|
950
|
+
});
|
|
951
|
+
expect(res.status).toBe(200);
|
|
952
|
+
const body = (await res.json()) as { data?: { ip?: string } };
|
|
953
|
+
// See the matching prod-side test (run-prod-app.integration.test.ts)
|
|
954
|
+
// for why the assertion matches the loopback family instead of a
|
|
955
|
+
// fixed literal (IPv4 vs. IPv4-mapped IPv6 is a platform detail).
|
|
956
|
+
expect(body.data?.ip).toMatch(/^(::ffff:)?127\.0\.0\.1$/);
|
|
957
|
+
});
|
|
958
|
+
});
|
|
@@ -26,19 +26,30 @@ describe("mergeExtraContext", () => {
|
|
|
26
26
|
expect(merged).toBe(base);
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
test("config preset merges configResolver, keeping base-object fields", () => {
|
|
29
|
+
test("config preset merges configResolver + _configAccessorFactory, keeping base-object fields", () => {
|
|
30
30
|
const merged = mergeExtraContext({ foo: "bar" }, ["config"]);
|
|
31
31
|
expect(typeof merged).toBe("function");
|
|
32
32
|
const result = (merged as (deps: typeof fakeDeps) => Record<string, unknown>)(fakeDeps);
|
|
33
33
|
expect(result["foo"]).toBe("bar");
|
|
34
34
|
expect(result["configResolver"]).toBeDefined();
|
|
35
|
+
// buildHandlerContext derives ctx.config from _configAccessorFactory, not configResolver (fw#3313).
|
|
36
|
+
expect(result["_configAccessorFactory"]).toBeDefined();
|
|
35
37
|
});
|
|
36
38
|
|
|
37
|
-
test("config preset merges configResolver, keeping base-fn fields", () => {
|
|
39
|
+
test("config preset merges configResolver + _configAccessorFactory, keeping base-fn fields", () => {
|
|
38
40
|
const merged = mergeExtraContext(() => ({ fromFn: 1 }), ["config"]);
|
|
39
41
|
const result = (merged as (deps: typeof fakeDeps) => Record<string, unknown>)(fakeDeps);
|
|
40
42
|
expect(result["fromFn"]).toBe(1);
|
|
41
43
|
expect(result["configResolver"]).toBeDefined();
|
|
44
|
+
expect(result["_configAccessorFactory"]).toBeDefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("config preset keeps a base-supplied configResolver and derives the factory from it", () => {
|
|
48
|
+
const ownResolver = { get: async () => undefined } as never;
|
|
49
|
+
const merged = mergeExtraContext({ configResolver: ownResolver }, ["config"]);
|
|
50
|
+
const result = (merged as (deps: typeof fakeDeps) => Record<string, unknown>)(fakeDeps);
|
|
51
|
+
expect(result["configResolver"]).toBe(ownResolver);
|
|
52
|
+
expect(result["_configAccessorFactory"]).toBeDefined();
|
|
42
53
|
});
|
|
43
54
|
|
|
44
55
|
test("template-resolver preset merges templateResolver, built from deps.db", () => {
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.320.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "createKumikoServer/runDevApp gained a trustedProxyHops option",
|
|
6
|
+
"detail": "`CreateKumikoServerOptions`/`RunDevAppOptions` gained a top-level\n`trustedProxyHops`, forwarded into `setupTestStack`'s `buildServer` call.\nDev normally runs unproxied, so this is usually left unset (default 0).",
|
|
7
|
+
"migration": "No action needed for the default (unproxied) dev setup. Set\n`trustedProxyHops` only if you run the dev server behind a reverse proxy."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.320.0",
|
|
11
|
+
"type": "fix",
|
|
12
|
+
"title": "setupTestStackFromFeatures' config preset now also derives _configAccessorFactory",
|
|
13
|
+
"detail": "buildHandlerContext derives ctx.config from _configAccessorFactory, not configResolver directly, so a write handler under presets: [\"config\"] saw ctx.config as undefined even with a configResolver present. mergeExtraContext now also runs addConfigAccessorFactory (the same helper runProdApp/runDevApp use), keeping a base-supplied configResolver winning over the preset default. Closes #3313."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"version": "0.320.0",
|
|
17
|
+
"type": "breaking",
|
|
18
|
+
"title": "Dev-server HTML no longer injects __KUMIKO_SCHEMA__ either",
|
|
19
|
+
"migration": "Same semantics as the prod change: `DevHostDispatchResult.injectSchema`\nis deprecated and ignored. The dev-server's auto-mint mode still sets\nthe `kumiko_auth`/`kumiko_csrf` cookies on the HTML response, so a\nclient-side fetch to `/api/schema` is authenticated immediately without\na real login round-trip."
|
|
20
|
+
},
|
|
2
21
|
{
|
|
3
22
|
"version": "0.308.0",
|
|
4
23
|
"type": "improvement",
|
|
@@ -23,6 +23,7 @@ import { resolveAnonymousAccessFromRegistry } from "@cosmicdrift/kumiko-bundled-
|
|
|
23
23
|
import {
|
|
24
24
|
type AuthRoutesConfig,
|
|
25
25
|
buildRequestContextDataFromRequest,
|
|
26
|
+
createClientIpResolver,
|
|
26
27
|
type ExtraRouteDefinition,
|
|
27
28
|
generateToken,
|
|
28
29
|
requestContext,
|
|
@@ -46,12 +47,14 @@ import {
|
|
|
46
47
|
resolveAndInjectPageHead,
|
|
47
48
|
} from "@cosmicdrift/kumiko-headless/apex";
|
|
48
49
|
import { startDevJobRunners } from "@cosmicdrift/kumiko-server-runtime/boot/job-run-logger";
|
|
49
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
buildBunServeOptions,
|
|
52
|
+
resolveDerivedMaxRequestBodySize,
|
|
53
|
+
} from "@cosmicdrift/kumiko-server-runtime/bun-serve-options";
|
|
50
54
|
import {
|
|
51
55
|
makeDispatchSystemWrite,
|
|
52
56
|
type SystemWireDeps,
|
|
53
57
|
} from "@cosmicdrift/kumiko-server-runtime/extra-routes-deps";
|
|
54
|
-
import { injectSchema } from "@cosmicdrift/kumiko-server-runtime/inject-schema";
|
|
55
58
|
import {
|
|
56
59
|
canResolveTailwindStylesheet,
|
|
57
60
|
resolveTailwindCli,
|
|
@@ -102,24 +105,23 @@ export type DevClientEntry = {
|
|
|
102
105
|
readonly htmlPath?: string;
|
|
103
106
|
};
|
|
104
107
|
|
|
105
|
-
/** Discriminated
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
108
|
+
/** Discriminated union, identical in shape to `runProdApp.hostDispatch`, so
|
|
109
|
+
* dev/prod routing mirrors 1:1 — an apex 404 in prod is an apex 404 in dev
|
|
110
|
+
* (with an `/etc/hosts` entry for the affected domain). HTML never carries
|
|
111
|
+
* a schema, in dev or prod — createKumikoApp fetches it itself from the
|
|
112
|
+
* authenticated GET /api/schema. */
|
|
110
113
|
export type DevHostDispatchResult =
|
|
111
114
|
| {
|
|
112
115
|
readonly kind: "html";
|
|
113
116
|
readonly entryName: string;
|
|
114
|
-
/**
|
|
115
|
-
*
|
|
117
|
+
/** @deprecated Ignored — HTML never carries the schema; createKumikoApp
|
|
118
|
+
* fetches it from the authenticated GET /api/schema. */
|
|
116
119
|
readonly injectSchema?: boolean;
|
|
117
120
|
}
|
|
118
121
|
| {
|
|
119
|
-
/** Static
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* brauchen. Pfad relativ zum Server-CWD. */
|
|
122
|
+
/** Static HTML: serves a file verbatim, no bundle injection. Mirrors
|
|
123
|
+
* prod's `{ kind: "html", file: ... }` for marketing/apex pages that
|
|
124
|
+
* don't need React. Path relative to the server CWD. */
|
|
123
125
|
readonly kind: "static-html";
|
|
124
126
|
readonly file: string;
|
|
125
127
|
}
|
|
@@ -232,6 +234,10 @@ export type CreateKumikoServerOptions = {
|
|
|
232
234
|
* inside buildServer (setupTestStack), before the Static/HTML-fallback,
|
|
233
235
|
* so an own GET (/feed.xml, /og-image, …) wins over the dev-asset path. */
|
|
234
236
|
readonly extraRoutes?: readonly ExtraRouteDefinition[];
|
|
237
|
+
/** Forwarded to setupTestStack → buildServer's top-level
|
|
238
|
+
* `ServerOptions.trustedProxyHops`. Dev usually runs unproxied, so this
|
|
239
|
+
* is normally left unset (default 0). */
|
|
240
|
+
readonly trustedProxyHops?: number;
|
|
235
241
|
/** Hook for app-wired co-running components that need the system-write
|
|
236
242
|
* dispatcher — runs after buildServer, before onAfterSetup (seeds), with
|
|
237
243
|
* NO `app` (routes are declared via `extraRoutes`, not wired here). */
|
|
@@ -495,9 +501,10 @@ async function tryServePublicAsset(
|
|
|
495
501
|
pathname: string,
|
|
496
502
|
app: HonoLikeApp,
|
|
497
503
|
publicDir: string,
|
|
504
|
+
socketAddress?: string,
|
|
498
505
|
): Promise<Response | undefined> {
|
|
499
506
|
if (!isRoutableGetOrHead(req, pathname) || !pathname.includes(".")) return undefined;
|
|
500
|
-
const honoTry = await tryHonoFirst(app, req);
|
|
507
|
+
const honoTry = await tryHonoFirst(app, req, socketAddress);
|
|
501
508
|
if (honoTry.matched) {
|
|
502
509
|
return honoTry.response;
|
|
503
510
|
}
|
|
@@ -512,9 +519,6 @@ async function tryServePublicAsset(
|
|
|
512
519
|
return honoTry.response;
|
|
513
520
|
}
|
|
514
521
|
|
|
515
|
-
// injectSchema lebt in `./inject-schema.ts` damit dev-server + prod-
|
|
516
|
-
// server denselben Inject-Pfad nutzen.
|
|
517
|
-
|
|
518
522
|
async function watchDir(
|
|
519
523
|
dir: string,
|
|
520
524
|
onChange: (filename: string) => void,
|
|
@@ -898,6 +902,7 @@ export async function createKumikoServer(
|
|
|
898
902
|
effectiveFeatures: options.effectiveFeatures,
|
|
899
903
|
}),
|
|
900
904
|
...(options.extraRoutes !== undefined && { extraRoutes: options.extraRoutes }),
|
|
905
|
+
...(options.trustedProxyHops !== undefined && { trustedProxyHops: options.trustedProxyHops }),
|
|
901
906
|
// jobs.consumerLane unset = enqueuer-only; startDevJobRunners below is
|
|
902
907
|
// the sole consumer/cron-scheduler per lane, so runOnBoot/cron jobs
|
|
903
908
|
// don't double-fire. queueNamePrefix keeps this boot's queues isolated
|
|
@@ -968,17 +973,14 @@ export async function createKumikoServer(
|
|
|
968
973
|
const autoMintJwt = options.auth === undefined;
|
|
969
974
|
const devUser = TestUsers.admin;
|
|
970
975
|
|
|
971
|
-
//
|
|
972
|
-
//
|
|
973
|
-
//
|
|
974
|
-
//
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
searchAdapterMissing: !stack.context.searchAdapter,
|
|
980
|
-
}),
|
|
981
|
-
);
|
|
976
|
+
// Build once at boot purely to surface authoring warnings (settings-hub
|
|
977
|
+
// placement etc.) at the same point prod would — clients fetch the actual
|
|
978
|
+
// schema themselves from GET /api/schema, so the result itself is
|
|
979
|
+
// discarded here.
|
|
980
|
+
buildAppSchema(stack.registry, {
|
|
981
|
+
authoringWarnings: true,
|
|
982
|
+
searchAdapterMissing: !stack.context.searchAdapter,
|
|
983
|
+
});
|
|
982
984
|
|
|
983
985
|
// --- SSE reload ---
|
|
984
986
|
// bootId identifiziert diese spezifische Server-Process-Instanz. Wird
|
|
@@ -1000,24 +1002,39 @@ export async function createKumikoServer(
|
|
|
1000
1002
|
}
|
|
1001
1003
|
};
|
|
1002
1004
|
|
|
1003
|
-
// Build a fresh HTML response.
|
|
1004
|
-
//
|
|
1005
|
-
//
|
|
1006
|
-
//
|
|
1007
|
-
//
|
|
1005
|
+
// Build a fresh HTML response. In auto-mint mode (no auth config) we
|
|
1006
|
+
// pack a valid JWT + CSRF cookie right in — deep-links work immediately
|
|
1007
|
+
// without login (and that auth cookie is what a client-side fetch to
|
|
1008
|
+
// GET /api/schema authenticates with). In auth mode we serve the bare
|
|
1009
|
+
// HTML; the client then goes through /auth/login and gets its cookies
|
|
1010
|
+
// from there.
|
|
1008
1011
|
//
|
|
1009
|
-
// entryName
|
|
1010
|
-
//
|
|
1011
|
-
//
|
|
1012
|
-
// damit der Client TypeScript-Schemas findet).
|
|
1012
|
+
// entryName is decided by the caller (handleFetch) after evaluating
|
|
1013
|
+
// hostDispatch. Without hostDispatch it's always "client" (single-entry
|
|
1014
|
+
// default).
|
|
1013
1015
|
//
|
|
1014
1016
|
// Anonymous-role systemQuery, shared by resolvePageHead and hostDispatch —
|
|
1015
1017
|
// mirrors runProdApp's HostDispatchFn deps so dev/prod stay symmetric.
|
|
1018
|
+
// Own resolver mirroring the same effective hops as setupTestStack's
|
|
1019
|
+
// internal buildServer — this path runs for
|
|
1020
|
+
// page-head/hostDispatch resolution, ahead of requestIdMiddleware's
|
|
1021
|
+
// /api/* mount, so it can't reuse that instance. Two independent
|
|
1022
|
+
// warn-once flags in dev is an accepted, low-risk gap (dev normally
|
|
1023
|
+
// runs unproxied — see CreateKumikoServerOptions.trustedProxyHops).
|
|
1024
|
+
const pageHeadClientIpResolver = createClientIpResolver(
|
|
1025
|
+
options.trustedProxyHops ?? 0,
|
|
1026
|
+
"createKumikoServer(pageHead)",
|
|
1027
|
+
);
|
|
1016
1028
|
const buildDevSystemQuery =
|
|
1017
|
-
(req: Request): PageHeadSystemQuery =>
|
|
1029
|
+
(req: Request, socketAddress?: string): PageHeadSystemQuery =>
|
|
1018
1030
|
(type, payload, tenantId) =>
|
|
1019
|
-
requestContext.run(
|
|
1020
|
-
|
|
1031
|
+
requestContext.run(
|
|
1032
|
+
requestContext.get() ??
|
|
1033
|
+
buildRequestContextDataFromRequest(req, {
|
|
1034
|
+
resolver: pageHeadClientIpResolver,
|
|
1035
|
+
socketAddress,
|
|
1036
|
+
}),
|
|
1037
|
+
() => stack.dispatcher.query(type, payload, createAnonymousUser(tenantId)),
|
|
1021
1038
|
);
|
|
1022
1039
|
|
|
1023
1040
|
// resolvePageHead goes through the same headless resolveAndInjectPageHead
|
|
@@ -1025,8 +1042,8 @@ export async function createKumikoServer(
|
|
|
1025
1042
|
// fallback path instead of a second copy of it (#3026).
|
|
1026
1043
|
const htmlResponse = async (
|
|
1027
1044
|
entryName: string,
|
|
1028
|
-
doInjectSchema: boolean,
|
|
1029
1045
|
req: Request,
|
|
1046
|
+
socketAddress?: string,
|
|
1030
1047
|
): Promise<Response> => {
|
|
1031
1048
|
const template = htmlTemplates.get(entryName) ?? defaultTemplate;
|
|
1032
1049
|
const headers = new Headers();
|
|
@@ -1039,14 +1056,13 @@ export async function createKumikoServer(
|
|
|
1039
1056
|
}
|
|
1040
1057
|
let html = injectReload(template);
|
|
1041
1058
|
if (stylesheetPath !== undefined) html = injectStylesheet(html);
|
|
1042
|
-
if (doInjectSchema) html = injectSchema(html, appSchemaJson);
|
|
1043
1059
|
if (options.resolvePageHead !== undefined) {
|
|
1044
1060
|
const url = new URL(req.url);
|
|
1045
1061
|
const host = req.headers.get("host") ?? url.host;
|
|
1046
1062
|
html = await resolveAndInjectPageHead(html, options.resolvePageHead, {
|
|
1047
1063
|
path: url.pathname,
|
|
1048
1064
|
host,
|
|
1049
|
-
systemQuery: buildDevSystemQuery(req),
|
|
1065
|
+
systemQuery: buildDevSystemQuery(req, socketAddress),
|
|
1050
1066
|
});
|
|
1051
1067
|
}
|
|
1052
1068
|
return new Response(html, { headers });
|
|
@@ -1064,7 +1080,7 @@ export async function createKumikoServer(
|
|
|
1064
1080
|
// process.cwd() is the app workspace, so public/ is its static asset dir.
|
|
1065
1081
|
const publicDir = resolve(process.cwd(), "public");
|
|
1066
1082
|
|
|
1067
|
-
const handleFetch = async (req: Request): Promise<Response> => {
|
|
1083
|
+
const handleFetch = async (req: Request, socketAddress?: string): Promise<Response> => {
|
|
1068
1084
|
const url = new URL(req.url);
|
|
1069
1085
|
|
|
1070
1086
|
// Specific routes first — assets, reload-SSE, API.
|
|
@@ -1162,14 +1178,16 @@ export async function createKumikoServer(
|
|
|
1162
1178
|
isRoutableGetOrHead(req, url.pathname) &&
|
|
1163
1179
|
!url.pathname.includes(".")
|
|
1164
1180
|
) {
|
|
1165
|
-
const honoTry = await tryHonoFirst(stack.app, req);
|
|
1181
|
+
const honoTry = await tryHonoFirst(stack.app, req, socketAddress);
|
|
1166
1182
|
if (honoTry.matched) {
|
|
1167
1183
|
return honoTry.response;
|
|
1168
1184
|
}
|
|
1169
1185
|
// Discriminated-Dispatch — symmetric zu prod. Ohne hostDispatch
|
|
1170
1186
|
// landet das im Single-Entry-Default ("client" + Schema-Inject).
|
|
1171
1187
|
if (options.hostDispatch !== undefined) {
|
|
1172
|
-
const dispatch = await options.hostDispatch(req, {
|
|
1188
|
+
const dispatch = await options.hostDispatch(req, {
|
|
1189
|
+
systemQuery: buildDevSystemQuery(req, socketAddress),
|
|
1190
|
+
});
|
|
1173
1191
|
if (dispatch.kind === "redirect") {
|
|
1174
1192
|
return new Response(null, {
|
|
1175
1193
|
status: dispatch.status ?? 302,
|
|
@@ -1180,28 +1198,34 @@ export async function createKumikoServer(
|
|
|
1180
1198
|
return new Response("Not Found", { status: 404 });
|
|
1181
1199
|
}
|
|
1182
1200
|
if (dispatch.kind === "static-html") {
|
|
1183
|
-
// Raw
|
|
1184
|
-
//
|
|
1201
|
+
// Raw file serve, no bundle injection — mirrors prod's
|
|
1202
|
+
// `{ kind: "html", file: ... }` static-html branch.
|
|
1185
1203
|
const file = await readFile(dispatch.file, "utf-8");
|
|
1186
1204
|
return new Response(file, {
|
|
1187
1205
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
1188
1206
|
});
|
|
1189
1207
|
}
|
|
1190
|
-
return htmlResponse(dispatch.entryName,
|
|
1208
|
+
return htmlResponse(dispatch.entryName, req, socketAddress);
|
|
1191
1209
|
}
|
|
1192
|
-
return htmlResponse("client",
|
|
1210
|
+
return htmlResponse("client", req, socketAddress);
|
|
1193
1211
|
}
|
|
1194
1212
|
|
|
1195
1213
|
// Static assets under public/ — see tryServePublicAsset's own comment
|
|
1196
1214
|
// for the Hono → file → router-miss ordering.
|
|
1197
|
-
const staticAsset = await tryServePublicAsset(
|
|
1215
|
+
const staticAsset = await tryServePublicAsset(
|
|
1216
|
+
req,
|
|
1217
|
+
url.pathname,
|
|
1218
|
+
stack.app,
|
|
1219
|
+
publicDir,
|
|
1220
|
+
socketAddress,
|
|
1221
|
+
);
|
|
1198
1222
|
if (staticAsset !== undefined) return staticAsset;
|
|
1199
1223
|
|
|
1200
1224
|
// Bypasses tryHonoFirst entirely (API paths, /sse, non-GET/HEAD), so the
|
|
1201
1225
|
// router-miss marker must be stripped here too — otherwise an unmatched
|
|
1202
1226
|
// path would leak it straight to the client (see try-hono-first.ts's
|
|
1203
1227
|
// header-hygiene note).
|
|
1204
|
-
return stripNoRouteMatchHeader(await stack.app.fetch(req));
|
|
1228
|
+
return stripNoRouteMatchHeader(await stack.app.fetch(req, socketAddress));
|
|
1205
1229
|
};
|
|
1206
1230
|
|
|
1207
1231
|
// --- HTTP server (Bun only) ---
|
|
@@ -1213,7 +1237,7 @@ export async function createKumikoServer(
|
|
|
1213
1237
|
// (idleTimeout: 0). Spec-Test in run-prod-app-spec.test.ts pinst das.
|
|
1214
1238
|
const server = hasBun
|
|
1215
1239
|
? (globalThis as { Bun: { serve: (opts: unknown) => BunServer } }).Bun.serve(
|
|
1216
|
-
buildBunServeOptions(port, handleFetch),
|
|
1240
|
+
buildBunServeOptions(port, handleFetch, resolveDerivedMaxRequestBodySize(stack.registry)),
|
|
1217
1241
|
)
|
|
1218
1242
|
: undefined;
|
|
1219
1243
|
|
|
@@ -1247,7 +1271,10 @@ export async function createKumikoServer(
|
|
|
1247
1271
|
if (action === "ignore") return;
|
|
1248
1272
|
if (action === "restart") {
|
|
1249
1273
|
logInfo(
|
|
1250
|
-
`[kumiko-server] schema change in ${filename} — restarting (Bun caches imports, hot-reload reicht hier nicht)
|
|
1274
|
+
`[kumiko-server] schema change in ${filename} — restarting (Bun caches imports, hot-reload reicht hier nicht). ` +
|
|
1275
|
+
"The kumiko-dev wrapper (package.json's `dev` script) respawns automatically " +
|
|
1276
|
+
"and re-syncs new r.entity() columns on the new boot; running this entry " +
|
|
1277
|
+
"directly (not via kumiko-dev) exits here without restarting.",
|
|
1251
1278
|
);
|
|
1252
1279
|
await stop();
|
|
1253
1280
|
process.exit(75);
|
package/src/run-dev-app.ts
CHANGED
|
@@ -44,7 +44,11 @@ import {
|
|
|
44
44
|
SESSIONS_FEATURE,
|
|
45
45
|
} from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
46
46
|
import { TenantQueries } from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
47
|
-
import type {
|
|
47
|
+
import type {
|
|
48
|
+
PostAuthLandingResolver,
|
|
49
|
+
SessionMetadata,
|
|
50
|
+
TokenVerifier,
|
|
51
|
+
} from "@cosmicdrift/kumiko-framework/api";
|
|
48
52
|
import { createInMemoryLoginRateLimiter } from "@cosmicdrift/kumiko-framework/api";
|
|
49
53
|
import {
|
|
50
54
|
configureBlindIndexKey,
|
|
@@ -162,6 +166,10 @@ export type RunDevAppAuthOptions = {
|
|
|
162
166
|
* RunProdAppAuthOptions — dev usually runs unproxied, so this is normally
|
|
163
167
|
* left unset (default 0). */
|
|
164
168
|
readonly trustedProxyHops?: number;
|
|
169
|
+
/** Server-computed post-auth redirect (see AuthRoutesConfig.postAuthLanding).
|
|
170
|
+
* Result lands in the login/signup/invite response as `landingPath`; an
|
|
171
|
+
* invalid path or a throwing resolver just omits the field. */
|
|
172
|
+
readonly postAuthLanding?: PostAuthLandingResolver;
|
|
165
173
|
};
|
|
166
174
|
|
|
167
175
|
/** Hook for app-specific seeding (demo data, fixtures). Runs after the
|
|
@@ -268,6 +276,12 @@ export type RunDevAppOptions = {
|
|
|
268
276
|
* something the user can click. Pass an object to override the
|
|
269
277
|
* features-dir hint or the docs URL. */
|
|
270
278
|
readonly welcomeBanner?: boolean | { readonly featuresDir?: string; readonly docsUrl?: string };
|
|
279
|
+
/** Forwarded to createKumikoServer → setupTestStack's top-level
|
|
280
|
+
* `trustedProxyHops` — symmetric to
|
|
281
|
+
* RunProdAppOptions.trustedProxyHops. Wins over the deprecated
|
|
282
|
+
* `auth.trustedProxyHops`. Dev usually runs unproxied, so this is
|
|
283
|
+
* normally left unset (default 0). */
|
|
284
|
+
readonly trustedProxyHops?: number;
|
|
271
285
|
};
|
|
272
286
|
|
|
273
287
|
export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServerHandle> {
|
|
@@ -491,6 +505,9 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
491
505
|
...(options.files !== undefined && { files: options.files }),
|
|
492
506
|
...(options.extraRoutes !== undefined && { extraRoutes: options.extraRoutes }),
|
|
493
507
|
...(options.wire !== undefined && { wire: options.wire }),
|
|
508
|
+
...((options.trustedProxyHops ?? effectiveAuth?.trustedProxyHops) !== undefined && {
|
|
509
|
+
trustedProxyHops: options.trustedProxyHops ?? effectiveAuth?.trustedProxyHops,
|
|
510
|
+
}),
|
|
494
511
|
...(finalEffectiveFeatures !== undefined && {
|
|
495
512
|
effectiveFeatures: finalEffectiveFeatures,
|
|
496
513
|
}),
|
|
@@ -514,6 +531,9 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
514
531
|
...(effectiveAuth.trustedProxyHops !== undefined && {
|
|
515
532
|
trustedProxyHops: effectiveAuth.trustedProxyHops,
|
|
516
533
|
}),
|
|
534
|
+
...(effectiveAuth.postAuthLanding !== undefined && {
|
|
535
|
+
postAuthLanding: effectiveAuth.postAuthLanding,
|
|
536
|
+
}),
|
|
517
537
|
...sessionAuthFragment,
|
|
518
538
|
...patAuthFragment,
|
|
519
539
|
...(mfaFeature && {
|
package/src/scaffold-app.ts
CHANGED
|
@@ -705,9 +705,9 @@ function renderDev(appName: string): string {
|
|
|
705
705
|
function renderClient(appName: string): string {
|
|
706
706
|
return [
|
|
707
707
|
"// Browser entry. runDevApp's clientEntry option bundles this file to",
|
|
708
|
-
"// /client.js and the default HTML loads it. createKumikoApp
|
|
709
|
-
"//
|
|
710
|
-
"//
|
|
708
|
+
"// /client.js and the default HTML loads it. createKumikoApp fetches the",
|
|
709
|
+
"// AppSchema itself from the authenticated GET /api/schema and mounts",
|
|
710
|
+
"// the routes.",
|
|
711
711
|
"//",
|
|
712
712
|
"// AppShell wraps DefaultAppShell to supply `brand` — createKumikoApp's",
|
|
713
713
|
"// shell option only injects schema + children. Without `shell` the",
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// Test helper: composeFeatures + setupTestStack in one call.
|
|
2
2
|
// Apps pass the same feature list as run-config (APP_FEATURES / buildAppFeatures).
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
type ConfigResolver,
|
|
6
|
+
createConfigResolver,
|
|
7
|
+
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
5
8
|
import { createTemplateResolverApi } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
6
9
|
import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
10
|
import {
|
|
@@ -13,6 +16,7 @@ import {
|
|
|
13
16
|
type ComposeFeaturesOptions,
|
|
14
17
|
composeFeatures,
|
|
15
18
|
} from "@cosmicdrift/kumiko-server-runtime/compose-features";
|
|
19
|
+
import { addConfigAccessorFactory } from "@cosmicdrift/kumiko-server-runtime/run-prod-app";
|
|
16
20
|
|
|
17
21
|
export type TestStackPreset = "config" | "template-resolver";
|
|
18
22
|
|
|
@@ -34,8 +38,10 @@ export function mergeExtraContext(
|
|
|
34
38
|
const merged: Record<string, unknown> = { ...fromBase };
|
|
35
39
|
|
|
36
40
|
if (presets.includes("config")) {
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
// Same precedence as mergeConfigResolverDefault (run-dev-app.ts) — a base-supplied resolver wins, factory always derives from it (fw#3313).
|
|
42
|
+
const configResolver =
|
|
43
|
+
(fromBase["configResolver"] as ConfigResolver | undefined) ?? createConfigResolver();
|
|
44
|
+
Object.assign(merged, addConfigAccessorFactory({ configResolver }, deps.registry));
|
|
39
45
|
}
|
|
40
46
|
if (presets.includes("template-resolver")) {
|
|
41
47
|
merged["templateResolver"] = createTemplateResolverApi(deps.db);
|