@mandujs/core 0.54.1 → 0.54.3
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 +4 -2
- package/scripts/postinstall-lock.ts +153 -0
- package/src/a11y/run-audit.ts +15 -15
- package/src/brain/doctor/analyzer.ts +7 -7
- package/src/bundler/__tests__/cold-start.test.ts +35 -7
- package/src/bundler/analyzer.ts +15 -7
- package/src/bundler/build.test.ts +13 -6
- package/src/bundler/build.ts +429 -182
- package/src/bundler/manifest-schema.ts +21 -14
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -9
- package/src/bundler/plugins/block-generated-imports.ts +13 -12
- package/src/bundler/types.ts +31 -14
- package/src/client/island.ts +79 -29
- 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/guard/config-guard.ts +13 -7
- package/src/guard/fs-routes-policy.ts +51 -0
- package/src/guard/index.ts +11 -6
- 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 +103 -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 +106 -0
- package/src/runtime/request-middleware.ts +31 -0
- package/src/runtime/server.ts +228 -944
- package/src/runtime/ssr.ts +59 -37
- package/src/runtime/static-files.ts +289 -0
- package/src/runtime/streaming-ssr.ts +22 -13
|
@@ -229,14 +229,17 @@ export class FileAPI {
|
|
|
229
229
|
for (const line of stdout.split("\n")) {
|
|
230
230
|
if (!line.trim()) continue;
|
|
231
231
|
|
|
232
|
-
const statusCode = line.substring(0, 2);
|
|
233
|
-
const gitFilePath = line.substring(3).trim();
|
|
234
|
-
const filePath = this.getProjectRelativePath(gitFilePath, gitRoot);
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
232
|
+
const statusCode = line.substring(0, 2);
|
|
233
|
+
const gitFilePath = line.substring(3).trim();
|
|
234
|
+
const filePath = this.getProjectRelativePath(gitFilePath, gitRoot);
|
|
235
|
+
if (!filePath || filePath === ".") {
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
changes.push({
|
|
240
|
+
filePath,
|
|
241
|
+
status: parseGitStatus(statusCode),
|
|
242
|
+
});
|
|
240
243
|
}
|
|
241
244
|
|
|
242
245
|
return changes;
|
|
@@ -108,15 +108,20 @@ describe("validateResourceDefinition", () => {
|
|
|
108
108
|
});
|
|
109
109
|
|
|
110
110
|
describe("getPluralName", () => {
|
|
111
|
-
test("should add 's' for simple pluralization", () => {
|
|
112
|
-
const result = getPluralName(userResourceFixture);
|
|
113
|
-
expect(result).toBe("users");
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test("should use
|
|
117
|
-
|
|
118
|
-
expect(
|
|
119
|
-
});
|
|
111
|
+
test("should add 's' for simple pluralization", () => {
|
|
112
|
+
const result = getPluralName(userResourceFixture);
|
|
113
|
+
expect(result).toBe("users");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("should use conservative English pluralization for common resource names", () => {
|
|
117
|
+
expect(getPluralName({ ...minimalResourceFixture, name: "party" })).toBe("parties");
|
|
118
|
+
expect(getPluralName({ ...minimalResourceFixture, name: "box" })).toBe("boxes");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("should use custom plural name if provided", () => {
|
|
122
|
+
const result = getPluralName(productResourceFixture);
|
|
123
|
+
expect(result).toBe("inventory");
|
|
124
|
+
});
|
|
120
125
|
|
|
121
126
|
test("should respect autoPlural: false", () => {
|
|
122
127
|
const definition = {
|
|
@@ -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,103 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { renderPageResponse } from "../page-render-response";
|
|
4
|
+
import type { BundleManifest } from "../../bundler/types";
|
|
5
|
+
|
|
6
|
+
const HYDRATED_MANIFEST: BundleManifest = {
|
|
7
|
+
version: 1,
|
|
8
|
+
buildTime: "2026-05-19T00:00:00.000Z",
|
|
9
|
+
env: "production",
|
|
10
|
+
bundles: {
|
|
11
|
+
home: {
|
|
12
|
+
js: "/.mandu/client/home.island.js",
|
|
13
|
+
dependencies: ["_runtime", "_react"],
|
|
14
|
+
priority: "visible",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
shared: {
|
|
18
|
+
runtime: "/.mandu/client/_runtime.js",
|
|
19
|
+
vendor: "/.mandu/client/_react.js",
|
|
20
|
+
router: "/.mandu/client/_router.js",
|
|
21
|
+
},
|
|
22
|
+
importMap: {
|
|
23
|
+
imports: {
|
|
24
|
+
react: "/.mandu/client/_react.js",
|
|
25
|
+
"react-dom": "/.mandu/client/_react-dom.js",
|
|
26
|
+
"react-dom/client": "/.mandu/client/_react-dom-client.js",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe("runtime page render response orchestration", () => {
|
|
32
|
+
it("pre-resolves async components on the non-streaming path", async () => {
|
|
33
|
+
async function AsyncPage() {
|
|
34
|
+
return React.createElement("main", null, "resolved-page");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const response = await renderPageResponse({
|
|
38
|
+
app: React.createElement(AsyncPage),
|
|
39
|
+
useStreaming: false,
|
|
40
|
+
title: "Async Page",
|
|
41
|
+
headTags: "",
|
|
42
|
+
isDev: false,
|
|
43
|
+
routeId: "page/async",
|
|
44
|
+
routePattern: "/async",
|
|
45
|
+
loaderData: { ok: true },
|
|
46
|
+
transitions: false,
|
|
47
|
+
prefetch: false,
|
|
48
|
+
spa: false,
|
|
49
|
+
devtools: false,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(response.status).toBe(200);
|
|
53
|
+
const html = await response.text();
|
|
54
|
+
expect(html).toContain("resolved-page");
|
|
55
|
+
expect(html).toContain("Async Page");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("uses the streaming renderer when requested", async () => {
|
|
59
|
+
const response = await renderPageResponse({
|
|
60
|
+
app: React.createElement("main", null, "stream-page"),
|
|
61
|
+
useStreaming: true,
|
|
62
|
+
title: "Stream Page",
|
|
63
|
+
headTags: "",
|
|
64
|
+
isDev: false,
|
|
65
|
+
routeId: "page/stream",
|
|
66
|
+
routePattern: "/stream",
|
|
67
|
+
loaderData: { ok: true },
|
|
68
|
+
transitions: false,
|
|
69
|
+
prefetch: false,
|
|
70
|
+
spa: false,
|
|
71
|
+
devtools: false,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(response.status).toBe(200);
|
|
75
|
+
expect(response.headers.get("X-Accel-Buffering")).toBe("no");
|
|
76
|
+
const html = await response.text();
|
|
77
|
+
expect(html).toContain("stream-page");
|
|
78
|
+
expect(html).toContain("Stream Page");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("serializes non-streaming loaderData as the route server data exactly once", async () => {
|
|
82
|
+
const response = await renderPageResponse({
|
|
83
|
+
app: React.createElement("main", null, "hydrated-page"),
|
|
84
|
+
useStreaming: false,
|
|
85
|
+
title: "Hydrated Page",
|
|
86
|
+
headTags: "",
|
|
87
|
+
isDev: false,
|
|
88
|
+
routeId: "home",
|
|
89
|
+
routePattern: "/",
|
|
90
|
+
loaderData: { items: ["a", "b"] },
|
|
91
|
+
hydration: { strategy: "island", priority: "visible", preload: false },
|
|
92
|
+
bundleManifest: HYDRATED_MANIFEST,
|
|
93
|
+
transitions: false,
|
|
94
|
+
prefetch: false,
|
|
95
|
+
spa: false,
|
|
96
|
+
devtools: false,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const html = await response.text();
|
|
100
|
+
expect(html).toContain('"home":{"serverData":{"items":["a","b"]}');
|
|
101
|
+
expect(html).not.toContain('"serverData":{"home"');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -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
|
+
});
|