@mandujs/core 0.54.1 β 0.54.2
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 +1 -1
- package/src/a11y/run-audit.ts +15 -15
- package/src/brain/doctor/analyzer.ts +7 -7
- package/src/bundler/build.ts +26 -19
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -9
- package/src/bundler/plugins/block-generated-imports.ts +13 -12
- package/src/config/validate.ts +1 -1
- package/src/deploy/inference/context.ts +82 -15
- package/src/filling/context.ts +17 -4
- package/src/guard/check.ts +9 -9
- package/src/kitchen/api/file-api.ts +11 -8
- package/src/resource/__tests__/schema.test.ts +14 -9
- package/src/resource/generators/slot.ts +72 -71
- package/src/resource/schema.ts +21 -13
- package/src/runtime/__tests__/devtools-adapter.test.ts +68 -0
- package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -0
- package/src/runtime/__tests__/page-render-response.test.ts +54 -0
- package/src/runtime/__tests__/request-middleware.test.ts +70 -0
- package/src/runtime/devtools-adapter.ts +68 -0
- package/src/runtime/escape.ts +34 -6
- package/src/runtime/observability-lifecycle.ts +290 -0
- package/src/runtime/page-render-response.ts +110 -0
- package/src/runtime/request-middleware.ts +31 -0
- package/src/runtime/server.ts +228 -944
- package/src/runtime/ssr.ts +20 -7
- package/src/runtime/static-files.ts +289 -0
|
@@ -19,7 +19,7 @@ export function generateResourceSlot(definition: ResourceDefinition): string {
|
|
|
19
19
|
const endpoints = getEnabledEndpoints(definition);
|
|
20
20
|
|
|
21
21
|
// Generate endpoint handlers
|
|
22
|
-
const handlers = generateHandlers(definition, endpoints, pascalName);
|
|
22
|
+
const handlers = generateHandlers(definition, endpoints, pascalName, pluralName);
|
|
23
23
|
|
|
24
24
|
return `// π₯ Mandu Filling - ${resourceName} Resource
|
|
25
25
|
// Pattern: /api/${pluralName}
|
|
@@ -46,32 +46,33 @@ ${handlers}
|
|
|
46
46
|
/**
|
|
47
47
|
* Generate handlers for enabled endpoints
|
|
48
48
|
*/
|
|
49
|
-
function generateHandlers(
|
|
50
|
-
definition: ResourceDefinition,
|
|
51
|
-
endpoints: string[],
|
|
52
|
-
pascalName: string
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
49
|
+
function generateHandlers(
|
|
50
|
+
definition: ResourceDefinition,
|
|
51
|
+
endpoints: string[],
|
|
52
|
+
pascalName: string,
|
|
53
|
+
pluralName: string
|
|
54
|
+
): string {
|
|
55
|
+
const handlers: string[] = [];
|
|
56
|
+
|
|
57
|
+
if (endpoints.includes("list")) {
|
|
58
|
+
handlers.push(generateListHandler(pascalName, pluralName));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (endpoints.includes("get")) {
|
|
62
|
+
handlers.push(generateGetHandler(pascalName, pluralName));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (endpoints.includes("create")) {
|
|
66
|
+
handlers.push(generateCreateHandler(pascalName, pluralName));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (endpoints.includes("update")) {
|
|
70
|
+
handlers.push(generateUpdateHandler(pascalName, pluralName));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (endpoints.includes("delete")) {
|
|
74
|
+
handlers.push(generateDeleteHandler(pascalName, pluralName));
|
|
75
|
+
}
|
|
75
76
|
|
|
76
77
|
return handlers.join("\n\n");
|
|
77
78
|
}
|
|
@@ -79,16 +80,16 @@ function generateHandlers(
|
|
|
79
80
|
/**
|
|
80
81
|
* Generate LIST handler (GET /api/resources)
|
|
81
82
|
*/
|
|
82
|
-
function generateListHandler(
|
|
83
|
-
return ` // π List ${pascalName}s
|
|
84
|
-
.get(async (ctx) => {
|
|
85
|
-
const input = await ctx.input(contract, "GET", ctx.params);
|
|
86
|
-
const { page, limit } = input;
|
|
87
|
-
|
|
88
|
-
// TODO: Implement database query
|
|
89
|
-
// const offset = (page - 1) * limit;
|
|
90
|
-
// const items = await db.select().from(${
|
|
91
|
-
// const total = await db.select({ count: count() }).from(${
|
|
83
|
+
function generateListHandler(pascalName: string, pluralName: string): string {
|
|
84
|
+
return ` // π List ${pascalName}s
|
|
85
|
+
.get(async (ctx) => {
|
|
86
|
+
const input = await ctx.input(contract, "GET", ctx.params);
|
|
87
|
+
const { page, limit } = input;
|
|
88
|
+
|
|
89
|
+
// TODO: Implement database query
|
|
90
|
+
// const offset = (page - 1) * limit;
|
|
91
|
+
// const items = await db.select().from(${pluralName}).limit(limit).offset(offset);
|
|
92
|
+
// const total = await db.select({ count: count() }).from(${pluralName});
|
|
92
93
|
|
|
93
94
|
const mockData = {
|
|
94
95
|
data: [], // Replace with actual data
|
|
@@ -106,14 +107,14 @@ function generateListHandler(definition: ResourceDefinition, pascalName: string)
|
|
|
106
107
|
/**
|
|
107
108
|
* Generate GET handler (GET /api/resources/:id)
|
|
108
109
|
*/
|
|
109
|
-
function generateGetHandler(
|
|
110
|
-
return ` // π Get Single ${pascalName}
|
|
111
|
-
.get(async (ctx) => {
|
|
112
|
-
const { id } = ctx.params;
|
|
113
|
-
|
|
114
|
-
// TODO: Implement database query
|
|
115
|
-
// const item = await db.select().from(${
|
|
116
|
-
// if (!item) return ctx.notFound("${pascalName} not found");
|
|
110
|
+
function generateGetHandler(pascalName: string, pluralName: string): string {
|
|
111
|
+
return ` // π Get Single ${pascalName}
|
|
112
|
+
.get(async (ctx) => {
|
|
113
|
+
const { id } = ctx.params;
|
|
114
|
+
|
|
115
|
+
// TODO: Implement database query
|
|
116
|
+
// const item = await db.select().from(${pluralName}).where(eq(${pluralName}.id, id)).limit(1);
|
|
117
|
+
// if (!item) return ctx.notFound("${pascalName} not found");
|
|
117
118
|
|
|
118
119
|
const mockData = {
|
|
119
120
|
data: { id, message: "${pascalName} details" }, // Replace with actual data
|
|
@@ -126,13 +127,13 @@ function generateGetHandler(definition: ResourceDefinition, pascalName: string):
|
|
|
126
127
|
/**
|
|
127
128
|
* Generate CREATE handler (POST /api/resources)
|
|
128
129
|
*/
|
|
129
|
-
function generateCreateHandler(
|
|
130
|
-
return ` // β Create ${pascalName}
|
|
131
|
-
.post(async (ctx) => {
|
|
132
|
-
const input = await ctx.input(contract, "POST", ctx.params);
|
|
133
|
-
|
|
134
|
-
// TODO: Implement database insertion
|
|
135
|
-
// const [created] = await db.insert(${
|
|
130
|
+
function generateCreateHandler(pascalName: string, pluralName: string): string {
|
|
131
|
+
return ` // β Create ${pascalName}
|
|
132
|
+
.post(async (ctx) => {
|
|
133
|
+
const input = await ctx.input(contract, "POST", ctx.params);
|
|
134
|
+
|
|
135
|
+
// TODO: Implement database insertion
|
|
136
|
+
// const [created] = await db.insert(${pluralName}).values(input).returning();
|
|
136
137
|
|
|
137
138
|
const mockData = {
|
|
138
139
|
data: { id: "new-id", ...input }, // Replace with actual created data
|
|
@@ -145,17 +146,17 @@ function generateCreateHandler(definition: ResourceDefinition, pascalName: strin
|
|
|
145
146
|
/**
|
|
146
147
|
* Generate UPDATE handler (PUT /api/resources/:id)
|
|
147
148
|
*/
|
|
148
|
-
function generateUpdateHandler(
|
|
149
|
-
return ` // βοΈ Update ${pascalName}
|
|
150
|
-
.put(async (ctx) => {
|
|
151
|
-
const { id } = ctx.params;
|
|
152
|
-
const input = await ctx.input(contract, "PUT", ctx.params);
|
|
153
|
-
|
|
154
|
-
// TODO: Implement database update
|
|
155
|
-
// const [updated] = await db.update(${
|
|
156
|
-
// .set(input)
|
|
157
|
-
// .where(eq(${
|
|
158
|
-
// .returning();
|
|
149
|
+
function generateUpdateHandler(pascalName: string, pluralName: string): string {
|
|
150
|
+
return ` // βοΈ Update ${pascalName}
|
|
151
|
+
.put(async (ctx) => {
|
|
152
|
+
const { id } = ctx.params;
|
|
153
|
+
const input = await ctx.input(contract, "PUT", ctx.params);
|
|
154
|
+
|
|
155
|
+
// TODO: Implement database update
|
|
156
|
+
// const [updated] = await db.update(${pluralName})
|
|
157
|
+
// .set(input)
|
|
158
|
+
// .where(eq(${pluralName}.id, id))
|
|
159
|
+
// .returning();
|
|
159
160
|
// if (!updated) return ctx.notFound("${pascalName} not found");
|
|
160
161
|
|
|
161
162
|
const mockData = {
|
|
@@ -169,14 +170,14 @@ function generateUpdateHandler(definition: ResourceDefinition, pascalName: strin
|
|
|
169
170
|
/**
|
|
170
171
|
* Generate DELETE handler (DELETE /api/resources/:id)
|
|
171
172
|
*/
|
|
172
|
-
function generateDeleteHandler(
|
|
173
|
-
return ` // ποΈ Delete ${pascalName}
|
|
174
|
-
.delete(async (ctx) => {
|
|
175
|
-
const { id } = ctx.params;
|
|
176
|
-
|
|
177
|
-
// TODO: Implement database deletion
|
|
178
|
-
// const deleted = await db.delete(${
|
|
179
|
-
// if (!deleted) return ctx.notFound("${pascalName} not found");
|
|
173
|
+
function generateDeleteHandler(pascalName: string, pluralName: string): string {
|
|
174
|
+
return ` // ποΈ Delete ${pascalName}
|
|
175
|
+
.delete(async (ctx) => {
|
|
176
|
+
const { id } = ctx.params;
|
|
177
|
+
|
|
178
|
+
// TODO: Implement database deletion
|
|
179
|
+
// const deleted = await db.delete(${pluralName}).where(eq(${pluralName}.id, id));
|
|
180
|
+
// if (!deleted) return ctx.notFound("${pascalName} not found");
|
|
180
181
|
|
|
181
182
|
return ctx.output(contract, 200, { data: { message: "${pascalName} deleted" } });
|
|
182
183
|
})`;
|
package/src/resource/schema.ts
CHANGED
|
@@ -237,19 +237,27 @@ function validateField(resourceName: string, fieldName: string, field: ResourceF
|
|
|
237
237
|
/**
|
|
238
238
|
* Get plural name for resource
|
|
239
239
|
*/
|
|
240
|
-
export function getPluralName(definition: ResourceDefinition): string {
|
|
241
|
-
if (definition.options?.pluralName) {
|
|
242
|
-
return definition.options.pluralName;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
if (definition.options?.autoPlural === false) {
|
|
246
|
-
return definition.name;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
240
|
+
export function getPluralName(definition: ResourceDefinition): string {
|
|
241
|
+
if (definition.options?.pluralName) {
|
|
242
|
+
return definition.options.pluralName;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (definition.options?.autoPlural === false) {
|
|
246
|
+
return definition.name;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return pluralize(definition.name);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function pluralize(singular: string): string {
|
|
253
|
+
if (/[^aeiou]y$/i.test(singular)) {
|
|
254
|
+
return singular.slice(0, -1) + "ies";
|
|
255
|
+
}
|
|
256
|
+
if (/(?:s|x|z|ch|sh)$/i.test(singular)) {
|
|
257
|
+
return singular + "es";
|
|
258
|
+
}
|
|
259
|
+
return singular + "s";
|
|
260
|
+
}
|
|
253
261
|
|
|
254
262
|
/**
|
|
255
263
|
* Get enabled endpoints
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import type { RoutesManifest } from "../../spec/schema";
|
|
3
|
+
import {
|
|
4
|
+
createRuntimeDevtoolsAdapter,
|
|
5
|
+
shouldRecordRuntimeRequest,
|
|
6
|
+
type RuntimeDevtoolsAdapter,
|
|
7
|
+
} from "../devtools-adapter";
|
|
8
|
+
|
|
9
|
+
const manifest: RoutesManifest = {
|
|
10
|
+
version: 1,
|
|
11
|
+
routes: [],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("runtime devtools adapter", () => {
|
|
15
|
+
let adapter: RuntimeDevtoolsAdapter | null = null;
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
adapter?.stop();
|
|
19
|
+
adapter = null;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("stays disabled outside dev mode", async () => {
|
|
23
|
+
adapter = createRuntimeDevtoolsAdapter({
|
|
24
|
+
isDev: false,
|
|
25
|
+
rootDir: process.cwd(),
|
|
26
|
+
manifest,
|
|
27
|
+
guardConfig: null,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(adapter.kitchen).toBeNull();
|
|
31
|
+
expect(adapter.dashboardPath).toBeNull();
|
|
32
|
+
const response = await adapter.handleRequest(
|
|
33
|
+
new Request("http://localhost:3000/__kitchen"),
|
|
34
|
+
"/__kitchen"
|
|
35
|
+
);
|
|
36
|
+
expect(response).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("dispatches Kitchen requests in dev mode", async () => {
|
|
40
|
+
adapter = createRuntimeDevtoolsAdapter({
|
|
41
|
+
isDev: true,
|
|
42
|
+
rootDir: process.cwd(),
|
|
43
|
+
manifest,
|
|
44
|
+
guardConfig: null,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(adapter.kitchen).not.toBeNull();
|
|
48
|
+
expect(adapter.dashboardPath).toBe("/__kitchen");
|
|
49
|
+
await expect(
|
|
50
|
+
adapter.handleRequest(new Request("http://localhost:3000/api/ping"), "/api/ping")
|
|
51
|
+
).resolves.toBeNull();
|
|
52
|
+
|
|
53
|
+
const response = await adapter.handleRequest(
|
|
54
|
+
new Request("http://localhost:3000/__kitchen"),
|
|
55
|
+
"/__kitchen"
|
|
56
|
+
);
|
|
57
|
+
expect(response?.status).toBe(200);
|
|
58
|
+
expect(await response?.text()).toContain("Mandu Kitchen");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("keeps framework-internal request paths out of the Kitchen request log", () => {
|
|
62
|
+
expect(shouldRecordRuntimeRequest("/api/ping")).toBe(true);
|
|
63
|
+
expect(shouldRecordRuntimeRequest("/_mandu/heap")).toBe(true);
|
|
64
|
+
expect(shouldRecordRuntimeRequest("/.mandu/client/runtime.js")).toBe(false);
|
|
65
|
+
expect(shouldRecordRuntimeRequest("/__kitchen/api/events")).toBe(false);
|
|
66
|
+
expect(shouldRecordRuntimeRequest("/__mandu/events/recent")).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
INTERNAL_EVENTS_ENDPOINT,
|
|
4
|
+
createRuntimeObservabilityLifecycle,
|
|
5
|
+
} from "../observability-lifecycle";
|
|
6
|
+
import { eventBus } from "../../observability/event-bus";
|
|
7
|
+
import {
|
|
8
|
+
resetTracer,
|
|
9
|
+
type Span,
|
|
10
|
+
type SpanExporter,
|
|
11
|
+
} from "../../observability/tracing";
|
|
12
|
+
|
|
13
|
+
class CaptureExporter implements SpanExporter {
|
|
14
|
+
readonly spans: Span[] = [];
|
|
15
|
+
export(spans: Span[]): void {
|
|
16
|
+
this.spans.push(...spans);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
resetTracer();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("runtime observability lifecycle", () => {
|
|
25
|
+
test("serves heap snapshots with perf data when exposed", async () => {
|
|
26
|
+
const lifecycle = createRuntimeObservabilityLifecycle({ isDev: true });
|
|
27
|
+
const response = lifecycle.handleEndpoint(
|
|
28
|
+
new Request("http://localhost/_mandu/heap"),
|
|
29
|
+
"/_mandu/heap"
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
expect(response?.status).toBe(200);
|
|
33
|
+
const body = await response!.json() as {
|
|
34
|
+
process: { heapUsed: number };
|
|
35
|
+
perf: unknown;
|
|
36
|
+
};
|
|
37
|
+
expect(body.process.heapUsed).toBeGreaterThan(0);
|
|
38
|
+
expect(body.perf).toBeDefined();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("keeps metrics hidden in production unless explicitly enabled", () => {
|
|
42
|
+
const hidden = createRuntimeObservabilityLifecycle({ isDev: false });
|
|
43
|
+
const shown = createRuntimeObservabilityLifecycle({
|
|
44
|
+
isDev: false,
|
|
45
|
+
metricsEndpoint: true,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(hidden.handleEndpoint(new Request("http://localhost/_mandu/metrics"), "/_mandu/metrics")).toBeNull();
|
|
49
|
+
expect(shown.handleEndpoint(new Request("http://localhost/_mandu/metrics"), "/_mandu/metrics")?.status).toBe(200);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("serves recent EventBus snapshots from the lifecycle endpoint", async () => {
|
|
53
|
+
const lifecycle = createRuntimeObservabilityLifecycle({ isDev: true });
|
|
54
|
+
const source = `observability-lifecycle-${Date.now()}`;
|
|
55
|
+
eventBus.emit({
|
|
56
|
+
type: "http",
|
|
57
|
+
severity: "info",
|
|
58
|
+
source,
|
|
59
|
+
message: "GET /observed 200",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const response = lifecycle.handleEndpoint(
|
|
63
|
+
new Request(`http://localhost${INTERNAL_EVENTS_ENDPOINT}/recent?source=${source}`),
|
|
64
|
+
`${INTERNAL_EVENTS_ENDPOINT}/recent`
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(response?.status).toBe(200);
|
|
68
|
+
const body = await response!.json() as {
|
|
69
|
+
events: Array<{ source: string; message: string }>;
|
|
70
|
+
};
|
|
71
|
+
expect(body.events.some((event) => event.source === source)).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("wraps requests in a root tracing span", async () => {
|
|
75
|
+
const exporter = new CaptureExporter();
|
|
76
|
+
const lifecycle = createRuntimeObservabilityLifecycle({
|
|
77
|
+
isDev: false,
|
|
78
|
+
tracing: {
|
|
79
|
+
enabled: true,
|
|
80
|
+
customExporter: exporter,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
const incomingTraceId = "4bf92f3577b34da6a3ce929d0e0e4736";
|
|
84
|
+
const incomingSpanId = "00f067aa0ba902b7";
|
|
85
|
+
const response = await lifecycle.runRequest(
|
|
86
|
+
new Request("https://example.test/users", {
|
|
87
|
+
headers: {
|
|
88
|
+
traceparent: `00-${incomingTraceId}-${incomingSpanId}-01`,
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
Date.now(),
|
|
92
|
+
"corr-1",
|
|
93
|
+
async () => new Response("created", { status: 201 })
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(response.status).toBe(201);
|
|
97
|
+
expect(exporter.spans).toHaveLength(1);
|
|
98
|
+
expect(exporter.spans[0].traceId).toBe(incomingTraceId);
|
|
99
|
+
expect(exporter.spans[0].parentSpanId).toBe(incomingSpanId);
|
|
100
|
+
expect(exporter.spans[0].attributes["http.status_code"]).toBe(201);
|
|
101
|
+
expect(exporter.spans[0].status).toBe("ok");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { renderPageResponse } from "../page-render-response";
|
|
4
|
+
|
|
5
|
+
describe("runtime page render response orchestration", () => {
|
|
6
|
+
it("pre-resolves async components on the non-streaming path", async () => {
|
|
7
|
+
async function AsyncPage() {
|
|
8
|
+
return React.createElement("main", null, "resolved-page");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const response = await renderPageResponse({
|
|
12
|
+
app: React.createElement(AsyncPage),
|
|
13
|
+
useStreaming: false,
|
|
14
|
+
title: "Async Page",
|
|
15
|
+
headTags: "",
|
|
16
|
+
isDev: false,
|
|
17
|
+
routeId: "page/async",
|
|
18
|
+
routePattern: "/async",
|
|
19
|
+
loaderData: { ok: true },
|
|
20
|
+
transitions: false,
|
|
21
|
+
prefetch: false,
|
|
22
|
+
spa: false,
|
|
23
|
+
devtools: false,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
expect(response.status).toBe(200);
|
|
27
|
+
const html = await response.text();
|
|
28
|
+
expect(html).toContain("resolved-page");
|
|
29
|
+
expect(html).toContain("Async Page");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("uses the streaming renderer when requested", async () => {
|
|
33
|
+
const response = await renderPageResponse({
|
|
34
|
+
app: React.createElement("main", null, "stream-page"),
|
|
35
|
+
useStreaming: true,
|
|
36
|
+
title: "Stream Page",
|
|
37
|
+
headTags: "",
|
|
38
|
+
isDev: false,
|
|
39
|
+
routeId: "page/stream",
|
|
40
|
+
routePattern: "/stream",
|
|
41
|
+
loaderData: { ok: true },
|
|
42
|
+
transitions: false,
|
|
43
|
+
prefetch: false,
|
|
44
|
+
spa: false,
|
|
45
|
+
devtools: false,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(response.status).toBe(200);
|
|
49
|
+
expect(response.headers.get("X-Accel-Buffering")).toBe("no");
|
|
50
|
+
const html = await response.text();
|
|
51
|
+
expect(html).toContain("stream-page");
|
|
52
|
+
expect(html).toContain("Stream Page");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { defineMiddleware } from "../../middleware/define";
|
|
3
|
+
import {
|
|
4
|
+
buildRequestMiddlewareChain,
|
|
5
|
+
runRequestMiddleware,
|
|
6
|
+
} from "../request-middleware";
|
|
7
|
+
|
|
8
|
+
const req = new Request("https://example.test/api/echo");
|
|
9
|
+
|
|
10
|
+
describe("runtime request middleware wiring", () => {
|
|
11
|
+
it("keeps the no-middleware path unset for hot-path passthrough", () => {
|
|
12
|
+
expect(buildRequestMiddlewareChain(undefined)).toBeUndefined();
|
|
13
|
+
expect(buildRequestMiddlewareChain([])).toBeUndefined();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("returns undefined when there is no chain or middleware is skipped", async () => {
|
|
17
|
+
let finalHits = 0;
|
|
18
|
+
const finalHandler = async (): Promise<Response> => {
|
|
19
|
+
finalHits++;
|
|
20
|
+
return new Response("ok");
|
|
21
|
+
};
|
|
22
|
+
const middlewareChain = buildRequestMiddlewareChain([
|
|
23
|
+
defineMiddleware({
|
|
24
|
+
name: "marker",
|
|
25
|
+
handler: async (_req, next) => next(),
|
|
26
|
+
}),
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
await expect(
|
|
30
|
+
runRequestMiddleware({ req, middlewareChain: undefined, finalHandler })
|
|
31
|
+
).resolves.toBeUndefined();
|
|
32
|
+
await expect(
|
|
33
|
+
runRequestMiddleware({
|
|
34
|
+
req,
|
|
35
|
+
middlewareChain,
|
|
36
|
+
finalHandler,
|
|
37
|
+
skipMiddleware: true,
|
|
38
|
+
})
|
|
39
|
+
).resolves.toBeUndefined();
|
|
40
|
+
expect(finalHits).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("dispatches through the composed chain when configured", async () => {
|
|
44
|
+
const trace: string[] = [];
|
|
45
|
+
const middlewareChain = buildRequestMiddlewareChain([
|
|
46
|
+
defineMiddleware({
|
|
47
|
+
name: "outer",
|
|
48
|
+
handler: async (_req, next) => {
|
|
49
|
+
trace.push("before");
|
|
50
|
+
const response = await next();
|
|
51
|
+
trace.push("after");
|
|
52
|
+
return response;
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const response = await runRequestMiddleware({
|
|
58
|
+
req,
|
|
59
|
+
middlewareChain,
|
|
60
|
+
finalHandler: async () => {
|
|
61
|
+
trace.push("final");
|
|
62
|
+
return new Response("ok");
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(response?.status).toBe(200);
|
|
67
|
+
expect(await response?.text()).toBe("ok");
|
|
68
|
+
expect(trace).toEqual(["before", "final", "after"]);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { RoutesManifest } from "../spec/schema";
|
|
2
|
+
import type { GuardConfig } from "../guard/types";
|
|
3
|
+
import {
|
|
4
|
+
KITCHEN_PREFIX,
|
|
5
|
+
KitchenHandler,
|
|
6
|
+
recordRequest,
|
|
7
|
+
type RequestEntry,
|
|
8
|
+
} from "../kitchen/kitchen-handler";
|
|
9
|
+
|
|
10
|
+
export type RuntimeKitchenHandler = KitchenHandler;
|
|
11
|
+
|
|
12
|
+
export interface RuntimeDevtoolsAdapter {
|
|
13
|
+
readonly kitchen: RuntimeKitchenHandler | null;
|
|
14
|
+
readonly dashboardPath: string | null;
|
|
15
|
+
start(): void;
|
|
16
|
+
stop(): void;
|
|
17
|
+
updateManifest(manifest: RoutesManifest): void;
|
|
18
|
+
handleRequest(req: Request, pathname: string): Promise<Response | null>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CreateRuntimeDevtoolsAdapterOptions {
|
|
22
|
+
isDev: boolean;
|
|
23
|
+
rootDir: string;
|
|
24
|
+
manifest: RoutesManifest;
|
|
25
|
+
guardConfig: GuardConfig | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createRuntimeDevtoolsAdapter(
|
|
29
|
+
options: CreateRuntimeDevtoolsAdapterOptions
|
|
30
|
+
): RuntimeDevtoolsAdapter {
|
|
31
|
+
const kitchen = options.isDev
|
|
32
|
+
? new KitchenHandler({
|
|
33
|
+
rootDir: options.rootDir,
|
|
34
|
+
manifest: options.manifest,
|
|
35
|
+
guardConfig: options.guardConfig,
|
|
36
|
+
})
|
|
37
|
+
: null;
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
kitchen,
|
|
41
|
+
dashboardPath: kitchen ? KITCHEN_PREFIX : null,
|
|
42
|
+
start() {
|
|
43
|
+
if (kitchen) void kitchen.start();
|
|
44
|
+
},
|
|
45
|
+
stop() {
|
|
46
|
+
kitchen?.stop();
|
|
47
|
+
},
|
|
48
|
+
updateManifest(manifest: RoutesManifest) {
|
|
49
|
+
kitchen?.updateManifest(manifest);
|
|
50
|
+
},
|
|
51
|
+
async handleRequest(req: Request, pathname: string): Promise<Response | null> {
|
|
52
|
+
if (!kitchen || !pathname.startsWith(KITCHEN_PREFIX)) return null;
|
|
53
|
+
return await kitchen.handle(req, pathname);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function shouldRecordRuntimeRequest(pathname: string): boolean {
|
|
59
|
+
return (
|
|
60
|
+
!pathname.startsWith("/.mandu/") &&
|
|
61
|
+
!pathname.startsWith(KITCHEN_PREFIX) &&
|
|
62
|
+
!pathname.startsWith("/__mandu/")
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function recordRuntimeRequest(entry: RequestEntry): void {
|
|
67
|
+
recordRequest(entry);
|
|
68
|
+
}
|
package/src/runtime/escape.ts
CHANGED
|
@@ -3,12 +3,40 @@
|
|
|
3
3
|
* <title>, <p> λ± ν
μ€νΈ λ
Έλμ λ€μ΄κ° λ¬Έμμ΄μ μμ νκ² μ²λ¦¬.
|
|
4
4
|
* μμ±κ°κ³Ό λ¬λ¦¬ " ' λ μ΄μ€μΌμ΄ν λΆνμ.
|
|
5
5
|
*/
|
|
6
|
-
export function escapeHtmlText(value: string): string {
|
|
7
|
-
return value
|
|
8
|
-
.replace(/&/g, "&")
|
|
9
|
-
.replace(/</g, "<")
|
|
10
|
-
.replace(/>/g, ">");
|
|
11
|
-
}
|
|
6
|
+
export function escapeHtmlText(value: string): string {
|
|
7
|
+
return value
|
|
8
|
+
.replace(/&/g, "&")
|
|
9
|
+
.replace(/</g, "<")
|
|
10
|
+
.replace(/>/g, ">");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Decode the small HTML entity set React can emit inside text metadata.
|
|
15
|
+
* Callers should still escape the returned string before writing HTML.
|
|
16
|
+
*/
|
|
17
|
+
export function decodeHtmlText(value: string): string {
|
|
18
|
+
return value.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|#39);/gi, (match, entity: string) => {
|
|
19
|
+
const normalized = entity.toLowerCase();
|
|
20
|
+
if (normalized === "amp") return "&";
|
|
21
|
+
if (normalized === "lt") return "<";
|
|
22
|
+
if (normalized === "gt") return ">";
|
|
23
|
+
if (normalized === "quot") return '"';
|
|
24
|
+
if (normalized === "apos" || normalized === "#39") return "'";
|
|
25
|
+
if (normalized.startsWith("#x")) {
|
|
26
|
+
const codePoint = Number.parseInt(normalized.slice(2), 16);
|
|
27
|
+
return isValidCodePoint(codePoint) ? String.fromCodePoint(codePoint) : match;
|
|
28
|
+
}
|
|
29
|
+
if (normalized.startsWith("#")) {
|
|
30
|
+
const codePoint = Number.parseInt(normalized.slice(1), 10);
|
|
31
|
+
return isValidCodePoint(codePoint) ? String.fromCodePoint(codePoint) : match;
|
|
32
|
+
}
|
|
33
|
+
return match;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isValidCodePoint(value: number): boolean {
|
|
38
|
+
return Number.isInteger(value) && value >= 0 && value <= 0x10ffff;
|
|
39
|
+
}
|
|
12
40
|
|
|
13
41
|
/**
|
|
14
42
|
* HTML μμ±κ° μ΄μ€μΌμ΄ν
|