arcway 0.1.15 → 0.1.16
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/client/page-loader.js +5 -48
- package/client/route-pattern.js +48 -0
- package/package.json +1 -4
- package/server/docs/openapi.js +85 -84
- package/server/logger/index.js +23 -15
- package/server/pages/build-cache.js +47 -0
- package/server/pages/build-client.js +84 -60
- package/server/pages/pages-router.js +13 -2
- package/server/pages/watcher.js +7 -2
- package/server/router/api-router.js +2 -51
- package/server/router/http-helpers.js +107 -0
- package/server/router/routes.js +4 -42
- package/server/web-server.js +4 -59
package/client/page-loader.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { compileRoutePattern, extractRouteParams } from './route-pattern.js';
|
|
2
|
+
|
|
1
3
|
const moduleCache = new Map();
|
|
2
4
|
const patternCache = new Map();
|
|
3
5
|
|
|
@@ -34,43 +36,11 @@ function readClientManifest() {
|
|
|
34
36
|
}
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
function compilePattern(pattern) {
|
|
38
|
-
const paramNames = [];
|
|
39
|
-
let catchAllParam;
|
|
40
|
-
|
|
41
|
-
const regexStr = pattern.replace(
|
|
42
|
-
/\/:([^/]+)|\/\*(\w+)\?|\/\*(\w+)/g,
|
|
43
|
-
(match, singleParam, optCatchAll, reqCatchAll) => {
|
|
44
|
-
if (singleParam) {
|
|
45
|
-
paramNames.push(singleParam);
|
|
46
|
-
return '/([^/]+)';
|
|
47
|
-
}
|
|
48
|
-
if (optCatchAll) {
|
|
49
|
-
paramNames.push(optCatchAll);
|
|
50
|
-
catchAllParam = optCatchAll;
|
|
51
|
-
return '(?:/(.+))?';
|
|
52
|
-
}
|
|
53
|
-
if (reqCatchAll) {
|
|
54
|
-
paramNames.push(reqCatchAll);
|
|
55
|
-
catchAllParam = reqCatchAll;
|
|
56
|
-
return '/(.+)';
|
|
57
|
-
}
|
|
58
|
-
return match;
|
|
59
|
-
},
|
|
60
|
-
);
|
|
61
|
-
|
|
62
|
-
return {
|
|
63
|
-
regex: new RegExp(`^${regexStr}\\/?$`),
|
|
64
|
-
paramNames,
|
|
65
|
-
catchAllParam,
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
|
|
69
39
|
function matchClientRoute(manifest, pathname) {
|
|
70
40
|
for (const route of manifest.routes) {
|
|
71
41
|
let compiled = patternCache.get(route.pattern);
|
|
72
42
|
if (!compiled) {
|
|
73
|
-
compiled =
|
|
43
|
+
compiled = compileRoutePattern(route.pattern);
|
|
74
44
|
patternCache.set(route.pattern, compiled);
|
|
75
45
|
}
|
|
76
46
|
const { regex, paramNames, catchAllParam } = compiled;
|
|
@@ -78,20 +48,8 @@ function matchClientRoute(manifest, pathname) {
|
|
|
78
48
|
const match = regex.exec(pathname);
|
|
79
49
|
if (!match) continue;
|
|
80
50
|
|
|
81
|
-
const params =
|
|
82
|
-
|
|
83
|
-
for (let i = 0; i < paramNames.length; i++) {
|
|
84
|
-
const name = paramNames[i];
|
|
85
|
-
const raw = match[i + 1];
|
|
86
|
-
if (name === catchAll) {
|
|
87
|
-
params[name] = raw ? raw.split('/').map(decodeURIComponent) : [];
|
|
88
|
-
} else {
|
|
89
|
-
params[name] = decodeURIComponent(raw);
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
} catch {
|
|
93
|
-
continue;
|
|
94
|
-
}
|
|
51
|
+
const params = extractRouteParams(match, paramNames, catchAll);
|
|
52
|
+
if (!params) continue;
|
|
95
53
|
|
|
96
54
|
return { route, params };
|
|
97
55
|
}
|
|
@@ -146,7 +104,6 @@ async function loadPage(manifest, pathname) {
|
|
|
146
104
|
}
|
|
147
105
|
|
|
148
106
|
export {
|
|
149
|
-
compilePattern,
|
|
150
107
|
loadLoadingComponents,
|
|
151
108
|
loadPage,
|
|
152
109
|
matchClientRoute,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
function compileRoutePattern(pattern) {
|
|
2
|
+
const paramNames = [];
|
|
3
|
+
let catchAllParam;
|
|
4
|
+
let isOptionalCatchAll = false;
|
|
5
|
+
const regexStr = pattern.replace(
|
|
6
|
+
/\/:([^/]+)|\/\*(\w+)\?|\/\*(\w+)/g,
|
|
7
|
+
(match, singleParam, optCatchAll, reqCatchAll) => {
|
|
8
|
+
if (singleParam) {
|
|
9
|
+
paramNames.push(singleParam);
|
|
10
|
+
return '/([^/]+)';
|
|
11
|
+
}
|
|
12
|
+
if (optCatchAll) {
|
|
13
|
+
paramNames.push(optCatchAll);
|
|
14
|
+
catchAllParam = optCatchAll;
|
|
15
|
+
isOptionalCatchAll = true;
|
|
16
|
+
return '(?:/(.+))?';
|
|
17
|
+
}
|
|
18
|
+
if (reqCatchAll) {
|
|
19
|
+
paramNames.push(reqCatchAll);
|
|
20
|
+
catchAllParam = reqCatchAll;
|
|
21
|
+
return '/(.+)';
|
|
22
|
+
}
|
|
23
|
+
return match;
|
|
24
|
+
},
|
|
25
|
+
);
|
|
26
|
+
const regex = new RegExp(`^${regexStr}/?$`);
|
|
27
|
+
return { regex, paramNames, catchAllParam, isOptionalCatchAll };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function extractRouteParams(match, paramNames, catchAllParam) {
|
|
31
|
+
const params = {};
|
|
32
|
+
try {
|
|
33
|
+
for (let i = 0; i < paramNames.length; i++) {
|
|
34
|
+
const name = paramNames[i];
|
|
35
|
+
const raw = match[i + 1];
|
|
36
|
+
if (name === catchAllParam) {
|
|
37
|
+
params[name] = raw ? raw.split('/').map(decodeURIComponent) : [];
|
|
38
|
+
} else {
|
|
39
|
+
params[name] = decodeURIComponent(raw);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return params;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export { compileRoutePattern, extractRouteParams };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "arcway",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.16",
|
|
4
4
|
"description": "A convention-based framework for building modular monoliths with strict domain boundaries.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -96,7 +96,6 @@
|
|
|
96
96
|
"swr": "^2.3.3",
|
|
97
97
|
"tailwind-merge": "^3.4.1",
|
|
98
98
|
"tailwindcss": "^4.1.18",
|
|
99
|
-
"tailwindcss-animate": "^1.0.7",
|
|
100
99
|
"vaul": "^1.1.2",
|
|
101
100
|
"ws": "^8.19.0",
|
|
102
101
|
"zod": "^3.24.0"
|
|
@@ -114,10 +113,8 @@
|
|
|
114
113
|
}
|
|
115
114
|
},
|
|
116
115
|
"devDependencies": {
|
|
117
|
-
"@chromatic-com/storybook": "^5.0.1",
|
|
118
116
|
"@storybook/addon-a11y": "^10.2.8",
|
|
119
117
|
"@storybook/addon-docs": "^10.2.8",
|
|
120
|
-
"@storybook/addon-onboarding": "^10.2.8",
|
|
121
118
|
"@storybook/addon-vitest": "^10.2.8",
|
|
122
119
|
"@storybook/react-vite": "^10.2.8",
|
|
123
120
|
"@tailwindcss/vite": "^4.1.18",
|
package/server/docs/openapi.js
CHANGED
|
@@ -50,6 +50,89 @@ function buildQueryParameters(route) {
|
|
|
50
50
|
return [];
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
+
function buildOperationId(method, openApiPath) {
|
|
54
|
+
const path = openApiPath.replace(/[{}\/]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
|
|
55
|
+
return `${method}_${path}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function tryConvertSchema(schema) {
|
|
59
|
+
if (!schema) return undefined;
|
|
60
|
+
try {
|
|
61
|
+
return typeToOpenAPISchema(schema);
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function resolveTags(route) {
|
|
68
|
+
const tags = route.config.meta?.tags;
|
|
69
|
+
if (tags && tags.length > 0) return tags;
|
|
70
|
+
return [route.pattern.split('/')[1] || 'default'];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildRequestBody(bodySchema) {
|
|
74
|
+
const converted = tryConvertSchema(bodySchema);
|
|
75
|
+
if (!converted) return undefined;
|
|
76
|
+
return {
|
|
77
|
+
required: true,
|
|
78
|
+
content: { 'application/json': { schema: converted } },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const VALIDATION_ERROR_RESPONSE = {
|
|
83
|
+
description: 'Validation error',
|
|
84
|
+
content: {
|
|
85
|
+
'application/json': {
|
|
86
|
+
schema: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
properties: {
|
|
89
|
+
error: {
|
|
90
|
+
type: 'object',
|
|
91
|
+
properties: {
|
|
92
|
+
code: { type: 'string' },
|
|
93
|
+
message: { type: 'string' },
|
|
94
|
+
details: {},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const DEFAULT_SUCCESS_SCHEMA = { type: 'object', properties: { data: {} } };
|
|
104
|
+
|
|
105
|
+
function buildResponses(routeSchema) {
|
|
106
|
+
const successSchema = tryConvertSchema(routeSchema?.response) ?? DEFAULT_SUCCESS_SCHEMA;
|
|
107
|
+
const responses = {
|
|
108
|
+
200: {
|
|
109
|
+
description: 'Successful response',
|
|
110
|
+
content: { 'application/json': { schema: successSchema } },
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
if (routeSchema?.body || routeSchema?.query) {
|
|
114
|
+
responses[400] = VALIDATION_ERROR_RESPONSE;
|
|
115
|
+
}
|
|
116
|
+
return responses;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function buildOperation(route, method, openApiPath) {
|
|
120
|
+
const operation = { operationId: buildOperationId(method, openApiPath) };
|
|
121
|
+
const meta = route.config.meta;
|
|
122
|
+
if (meta?.summary) operation.summary = meta.summary;
|
|
123
|
+
if (meta?.description) operation.description = meta.description;
|
|
124
|
+
operation.tags = resolveTags(route);
|
|
125
|
+
|
|
126
|
+
const parameters = [...buildPathParameters(route), ...buildQueryParameters(route)];
|
|
127
|
+
if (parameters.length > 0) operation.parameters = parameters;
|
|
128
|
+
|
|
129
|
+
const requestBody = buildRequestBody(route.config.schema?.body);
|
|
130
|
+
if (requestBody) operation.requestBody = requestBody;
|
|
131
|
+
|
|
132
|
+
operation.responses = buildResponses(route.config.schema);
|
|
133
|
+
return operation;
|
|
134
|
+
}
|
|
135
|
+
|
|
53
136
|
function generateOpenAPISpec(routes, info) {
|
|
54
137
|
const spec = {
|
|
55
138
|
openapi: '3.0.3',
|
|
@@ -63,90 +146,8 @@ function generateOpenAPISpec(routes, info) {
|
|
|
63
146
|
for (const route of routes) {
|
|
64
147
|
const openApiPath = toOpenAPIPath(route.pattern);
|
|
65
148
|
const method = route.method.toLowerCase();
|
|
66
|
-
if (!spec.paths[openApiPath]) {
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
const operation = {
|
|
70
|
-
operationId: `${method}_${openApiPath
|
|
71
|
-
.replace(/[{}\/]/g, '_')
|
|
72
|
-
.replace(/_+/g, '_')
|
|
73
|
-
.replace(/^_|_$/g, '')}`,
|
|
74
|
-
};
|
|
75
|
-
const meta = route.config.meta;
|
|
76
|
-
if (meta?.summary) operation.summary = meta.summary;
|
|
77
|
-
if (meta?.description) operation.description = meta.description;
|
|
78
|
-
if (meta?.tags && meta.tags.length > 0) {
|
|
79
|
-
operation.tags = meta.tags;
|
|
80
|
-
} else {
|
|
81
|
-
const firstSegment = route.pattern.split('/')[1] || 'default';
|
|
82
|
-
operation.tags = [firstSegment];
|
|
83
|
-
}
|
|
84
|
-
const parameters = [...buildPathParameters(route), ...buildQueryParameters(route)];
|
|
85
|
-
if (parameters.length > 0) {
|
|
86
|
-
operation.parameters = parameters;
|
|
87
|
-
}
|
|
88
|
-
if (route.config.schema?.body) {
|
|
89
|
-
try {
|
|
90
|
-
const bodySchema = typeToOpenAPISchema(route.config.schema.body);
|
|
91
|
-
operation.requestBody = {
|
|
92
|
-
required: true,
|
|
93
|
-
content: {
|
|
94
|
-
'application/json': {
|
|
95
|
-
schema: bodySchema,
|
|
96
|
-
},
|
|
97
|
-
},
|
|
98
|
-
};
|
|
99
|
-
} catch {}
|
|
100
|
-
}
|
|
101
|
-
const responseSchema = route.config.schema?.response
|
|
102
|
-
? (() => {
|
|
103
|
-
try {
|
|
104
|
-
return typeToOpenAPISchema(route.config.schema.response);
|
|
105
|
-
} catch {
|
|
106
|
-
return void 0;
|
|
107
|
-
}
|
|
108
|
-
})()
|
|
109
|
-
: void 0;
|
|
110
|
-
operation.responses = {
|
|
111
|
-
200: {
|
|
112
|
-
description: 'Successful response',
|
|
113
|
-
content: {
|
|
114
|
-
'application/json': {
|
|
115
|
-
schema: responseSchema ?? {
|
|
116
|
-
type: 'object',
|
|
117
|
-
properties: {
|
|
118
|
-
data: {},
|
|
119
|
-
},
|
|
120
|
-
},
|
|
121
|
-
},
|
|
122
|
-
},
|
|
123
|
-
},
|
|
124
|
-
...(route.config.schema?.body || route.config.schema?.query
|
|
125
|
-
? {
|
|
126
|
-
400: {
|
|
127
|
-
description: 'Validation error',
|
|
128
|
-
content: {
|
|
129
|
-
'application/json': {
|
|
130
|
-
schema: {
|
|
131
|
-
type: 'object',
|
|
132
|
-
properties: {
|
|
133
|
-
error: {
|
|
134
|
-
type: 'object',
|
|
135
|
-
properties: {
|
|
136
|
-
code: { type: 'string' },
|
|
137
|
-
message: { type: 'string' },
|
|
138
|
-
details: {},
|
|
139
|
-
},
|
|
140
|
-
},
|
|
141
|
-
},
|
|
142
|
-
},
|
|
143
|
-
},
|
|
144
|
-
},
|
|
145
|
-
},
|
|
146
|
-
}
|
|
147
|
-
: {}),
|
|
148
|
-
};
|
|
149
|
-
spec.paths[openApiPath][method] = operation;
|
|
149
|
+
if (!spec.paths[openApiPath]) spec.paths[openApiPath] = {};
|
|
150
|
+
spec.paths[openApiPath][method] = buildOperation(route, method, openApiPath);
|
|
150
151
|
}
|
|
151
152
|
return spec;
|
|
152
153
|
}
|
package/server/logger/index.js
CHANGED
|
@@ -22,6 +22,23 @@ function wrapPino(p) {
|
|
|
22
22
|
};
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
const QUERY_FILTERS = {
|
|
26
|
+
level: (v) => (e) => e.level === v,
|
|
27
|
+
logger: (v) => (e) => e.logger === v,
|
|
28
|
+
requestId: (v) => (e) => e.data?.requestId === v,
|
|
29
|
+
message: (v) => (e) => e.message === v,
|
|
30
|
+
method: (v) => (e) => e.data?.method === v.toUpperCase(),
|
|
31
|
+
path: (v) => (e) => e.data?.path?.includes(v),
|
|
32
|
+
status: (v) => (e) => e.data?.status === v,
|
|
33
|
+
minDurationMs: (v) => (e) => e.data?.durationMs >= v,
|
|
34
|
+
eventName: (v) => (e) =>
|
|
35
|
+
e.data?.eventName === v || e.data?.eventName?.includes(v),
|
|
36
|
+
since: (v) => {
|
|
37
|
+
const sinceTs = new Date(v).getTime();
|
|
38
|
+
return (e) => new Date(e.timestamp).getTime() >= sinceTs;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
25
42
|
class Logger {
|
|
26
43
|
_base;
|
|
27
44
|
_ring;
|
|
@@ -89,22 +106,13 @@ class Logger {
|
|
|
89
106
|
query(filters) {
|
|
90
107
|
if (!this._ring) return [];
|
|
91
108
|
let entries = this._ring.toArray();
|
|
92
|
-
if (filters
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (filters?.path) entries = entries.filter((e) => e.data?.path?.includes(filters.path));
|
|
98
|
-
if (filters?.status) entries = entries.filter((e) => e.data?.status === filters.status);
|
|
99
|
-
if (filters?.minDurationMs) entries = entries.filter((e) => e.data?.durationMs >= filters.minDurationMs);
|
|
100
|
-
if (filters?.eventName) {
|
|
101
|
-
entries = entries.filter((e) => e.data?.eventName === filters.eventName || e.data?.eventName?.includes(filters.eventName));
|
|
102
|
-
}
|
|
103
|
-
if (filters?.since) {
|
|
104
|
-
const sinceTs = new Date(filters.since).getTime();
|
|
105
|
-
entries = entries.filter((e) => new Date(e.timestamp).getTime() >= sinceTs);
|
|
109
|
+
if (!filters) return entries;
|
|
110
|
+
for (const [key, makePredicate] of Object.entries(QUERY_FILTERS)) {
|
|
111
|
+
const value = filters[key];
|
|
112
|
+
if (value === undefined || value === null || value === '' || value === false) continue;
|
|
113
|
+
entries = entries.filter(makePredicate(value));
|
|
106
114
|
}
|
|
107
|
-
if (filters
|
|
115
|
+
if (filters.limit && filters.limit > 0) entries = entries.slice(-filters.limit);
|
|
108
116
|
return entries;
|
|
109
117
|
}
|
|
110
118
|
|
|
@@ -260,6 +260,52 @@ async function storeMultiFileCache({
|
|
|
260
260
|
});
|
|
261
261
|
}
|
|
262
262
|
|
|
263
|
+
// Walk every cache entry in <rootDir>/node_modules/.cache/arcway-pages/<kind>
|
|
264
|
+
// and drop any whose recorded inputs no longer hash to the stored value (or
|
|
265
|
+
// whose inputs are gone). Used by tests that intentionally mutate fixture
|
|
266
|
+
// sources to keep cross-fork cache state consistent. Best-effort: missing
|
|
267
|
+
// bucket → returns {removed: 0, kept: 0}; individual unlink failures are
|
|
268
|
+
// swallowed since a concurrent worker may already be removing the same entry.
|
|
269
|
+
async function pruneStaleCache({ rootDir, kind }) {
|
|
270
|
+
const bucket = bucketDir(rootDir, kind);
|
|
271
|
+
let entries;
|
|
272
|
+
try {
|
|
273
|
+
entries = await fs.readdir(bucket);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
if (err.code === 'ENOENT') return { removed: 0, kept: 0 };
|
|
276
|
+
throw err;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const metaFiles = entries.filter((f) => f.endsWith('.meta.json'));
|
|
280
|
+
let removed = 0;
|
|
281
|
+
let kept = 0;
|
|
282
|
+
|
|
283
|
+
for (const metaFile of metaFiles) {
|
|
284
|
+
const metaPath = path.join(bucket, metaFile);
|
|
285
|
+
const meta = await readJson(metaPath);
|
|
286
|
+
const stale =
|
|
287
|
+
!meta ||
|
|
288
|
+
!Array.isArray(meta.inputs) ||
|
|
289
|
+
(await hashInputs(meta.inputs)) !== meta.inputsHash;
|
|
290
|
+
|
|
291
|
+
if (!stale) {
|
|
292
|
+
kept += 1;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const baseKey = metaFile.slice(0, -'.meta.json'.length);
|
|
297
|
+
await Promise.allSettled([
|
|
298
|
+
fs.rm(metaPath, { force: true }),
|
|
299
|
+
fs.rm(path.join(bucket, `${baseKey}.js`), { force: true }),
|
|
300
|
+
fs.rm(path.join(bucket, `${baseKey}.js.map`), { force: true }),
|
|
301
|
+
fs.rm(path.join(bucket, baseKey), { recursive: true, force: true }),
|
|
302
|
+
]);
|
|
303
|
+
removed += 1;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return { removed, kept };
|
|
307
|
+
}
|
|
308
|
+
|
|
263
309
|
export {
|
|
264
310
|
buildWithCache,
|
|
265
311
|
computeConfigHash,
|
|
@@ -269,6 +315,7 @@ export {
|
|
|
269
315
|
sha256,
|
|
270
316
|
hashInputs,
|
|
271
317
|
lookupMultiFileCache,
|
|
318
|
+
pruneStaleCache,
|
|
272
319
|
restoreMultiFileCache,
|
|
273
320
|
storeMultiFileCache,
|
|
274
321
|
};
|
|
@@ -222,6 +222,52 @@ async function createClientBuildContext(
|
|
|
222
222
|
return { rebuild, dispose };
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
async function tryRestoreClientFromCache({ rootDir, clientDir, coarseKey }) {
|
|
226
|
+
const lookup = await lookupMultiFileCache({ rootDir, kind: 'client', coarseKey });
|
|
227
|
+
if (!lookup.hit) return { hit: false, bucket: lookup.bucket };
|
|
228
|
+
await restoreMultiFileCache({
|
|
229
|
+
cacheDir: lookup.cacheDir,
|
|
230
|
+
outputs: lookup.meta.outputs,
|
|
231
|
+
destDir: clientDir,
|
|
232
|
+
});
|
|
233
|
+
return { hit: true, bucket: lookup.bucket, metadata: deserializeMetadata(lookup.meta.metadata) };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function storeClientInCache({
|
|
237
|
+
bucket,
|
|
238
|
+
coarseKey,
|
|
239
|
+
clientDir,
|
|
240
|
+
tempDir,
|
|
241
|
+
result,
|
|
242
|
+
metadata,
|
|
243
|
+
}) {
|
|
244
|
+
const outputs = Object.keys(result.metafile.outputs)
|
|
245
|
+
.map((p) => path.relative(clientDir, path.resolve(p)).replace(/\\/g, '/'))
|
|
246
|
+
.filter((rel) => !rel.startsWith('..'));
|
|
247
|
+
// Track only inputs on the real filesystem — the ephemeral hydration entries
|
|
248
|
+
// inside `tempDir` are re-generated each build from a stable content plan,
|
|
249
|
+
// so their paths change every run and can't stably invalidate the cache.
|
|
250
|
+
const inputs = Object.keys(result.metafile.inputs)
|
|
251
|
+
.map((p) => (path.isAbsolute(p) ? p : path.resolve(p)))
|
|
252
|
+
.filter((p) => !p.startsWith(tempDir));
|
|
253
|
+
await storeMultiFileCache({
|
|
254
|
+
bucket,
|
|
255
|
+
coarseKey,
|
|
256
|
+
destDir: clientDir,
|
|
257
|
+
outputs,
|
|
258
|
+
inputs,
|
|
259
|
+
metadata: serializeMetadata(metadata),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function runClientEsbuild(plan, esbuildOptions, tempDir) {
|
|
264
|
+
await fs.mkdir(tempDir, { recursive: true });
|
|
265
|
+
await writeHydrationEntries(plan);
|
|
266
|
+
const result = await esbuild.build(esbuildOptions);
|
|
267
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
268
|
+
return result;
|
|
269
|
+
}
|
|
270
|
+
|
|
225
271
|
async function buildClientBundles(
|
|
226
272
|
pages,
|
|
227
273
|
layouts,
|
|
@@ -238,76 +284,54 @@ async function buildClientBundles(
|
|
|
238
284
|
const plan = planHydrationEntries(pages, layouts, loadings, tempDir);
|
|
239
285
|
const reactAlias = resolveReactAlias(rootDir);
|
|
240
286
|
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
// incremental rebuilds via esbuild.context() (Phase 4) and we also return
|
|
244
|
-
// the metafile directly in dev mode for HMR diffing.
|
|
287
|
+
// devMode skips the cache because the watcher owns incremental rebuilds via
|
|
288
|
+
// esbuild.context(); we also return the metafile directly in dev for HMR diffing.
|
|
245
289
|
const useCache = !devMode;
|
|
290
|
+
const coarseKey = useCache
|
|
291
|
+
? computeClientCoarseKey({
|
|
292
|
+
target,
|
|
293
|
+
minify,
|
|
294
|
+
devMode,
|
|
295
|
+
nodeEnv,
|
|
296
|
+
reactAlias,
|
|
297
|
+
pages,
|
|
298
|
+
layouts,
|
|
299
|
+
loadings,
|
|
300
|
+
hydrationContents: plan.files.map((f) => ({
|
|
301
|
+
name: path.basename(f.entryPath),
|
|
302
|
+
content: f.content,
|
|
303
|
+
})),
|
|
304
|
+
})
|
|
305
|
+
: null;
|
|
306
|
+
|
|
246
307
|
let cacheBucket;
|
|
247
|
-
let coarseKey;
|
|
248
308
|
if (useCache) {
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
devMode,
|
|
253
|
-
nodeEnv,
|
|
254
|
-
reactAlias,
|
|
255
|
-
pages,
|
|
256
|
-
layouts,
|
|
257
|
-
loadings,
|
|
258
|
-
hydrationContents: plan.files.map((f) => ({
|
|
259
|
-
name: path.basename(f.entryPath),
|
|
260
|
-
content: f.content,
|
|
261
|
-
})),
|
|
262
|
-
});
|
|
263
|
-
const lookup = await lookupMultiFileCache({ rootDir, kind: 'client', coarseKey });
|
|
264
|
-
cacheBucket = lookup.bucket;
|
|
265
|
-
if (lookup.hit) {
|
|
266
|
-
await restoreMultiFileCache({
|
|
267
|
-
cacheDir: lookup.cacheDir,
|
|
268
|
-
outputs: lookup.meta.outputs,
|
|
269
|
-
destDir: clientDir,
|
|
270
|
-
});
|
|
271
|
-
return deserializeMetadata(lookup.meta.metadata);
|
|
272
|
-
}
|
|
309
|
+
const restored = await tryRestoreClientFromCache({ rootDir, clientDir, coarseKey });
|
|
310
|
+
if (restored.hit) return restored.metadata;
|
|
311
|
+
cacheBucket = restored.bucket;
|
|
273
312
|
}
|
|
274
313
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
reactAlias,
|
|
287
|
-
rootDir,
|
|
288
|
-
}),
|
|
289
|
-
);
|
|
290
|
-
await fs.rm(tempDir, { recursive: true, force: true });
|
|
291
|
-
|
|
314
|
+
const esbuildOptions = buildClientEsbuildOptions({
|
|
315
|
+
entryPoints: plan.entryPoints,
|
|
316
|
+
clientDir,
|
|
317
|
+
target,
|
|
318
|
+
minify,
|
|
319
|
+
devMode,
|
|
320
|
+
nodeEnv,
|
|
321
|
+
reactAlias,
|
|
322
|
+
rootDir,
|
|
323
|
+
});
|
|
324
|
+
const result = await runClientEsbuild(plan, esbuildOptions, tempDir);
|
|
292
325
|
const metadata = extractMetadata(result, plan, outDir);
|
|
293
326
|
|
|
294
327
|
if (useCache) {
|
|
295
|
-
|
|
296
|
-
.map((p) => path.relative(clientDir, path.resolve(p)).replace(/\\/g, '/'))
|
|
297
|
-
.filter((rel) => !rel.startsWith('..'));
|
|
298
|
-
// Track only inputs on the real filesystem — the ephemeral hydration entries
|
|
299
|
-
// inside `tempDir` are re-generated each build from a stable content plan,
|
|
300
|
-
// so their paths change every run and can't stably invalidate the cache.
|
|
301
|
-
const inputs = Object.keys(result.metafile.inputs)
|
|
302
|
-
.map((p) => (path.isAbsolute(p) ? p : path.resolve(p)))
|
|
303
|
-
.filter((p) => !p.startsWith(tempDir));
|
|
304
|
-
await storeMultiFileCache({
|
|
328
|
+
await storeClientInCache({
|
|
305
329
|
bucket: cacheBucket,
|
|
306
330
|
coarseKey,
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
metadata
|
|
331
|
+
clientDir,
|
|
332
|
+
tempDir,
|
|
333
|
+
result,
|
|
334
|
+
metadata,
|
|
311
335
|
});
|
|
312
336
|
}
|
|
313
337
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createPagesBuildContext } from './build-context.js';
|
|
2
2
|
import { createPagesHandler } from './handler.js';
|
|
3
3
|
import { createPagesWatcher } from './watcher.js';
|
|
4
4
|
|
|
@@ -10,6 +10,7 @@ class PagesRouter {
|
|
|
10
10
|
fileWatcher;
|
|
11
11
|
handler = null;
|
|
12
12
|
watcher = null;
|
|
13
|
+
pagesBuildContext = null;
|
|
13
14
|
|
|
14
15
|
constructor(config, { rootDir, log, mode, fileWatcher, appContext }) {
|
|
15
16
|
this.config = config;
|
|
@@ -26,14 +27,19 @@ class PagesRouter {
|
|
|
26
27
|
|
|
27
28
|
let initialClientMetafile;
|
|
28
29
|
if (isDev) {
|
|
30
|
+
// Dev mode owns a long-lived `createPagesBuildContext()` so the watcher
|
|
31
|
+
// can do incremental `ctx.rebuild()` on file change instead of a cold
|
|
32
|
+
// `buildPages()`. The initial rebuild bootstraps the outDir and captures
|
|
33
|
+
// the client metafile used for HMR diffs.
|
|
29
34
|
try {
|
|
30
|
-
|
|
35
|
+
this.pagesBuildContext = await createPagesBuildContext({
|
|
31
36
|
rootDir,
|
|
32
37
|
pagesDir: config.pages.dir,
|
|
33
38
|
fonts: config.pages?.fonts,
|
|
34
39
|
minify: false,
|
|
35
40
|
devMode: true,
|
|
36
41
|
});
|
|
42
|
+
const result = await this.pagesBuildContext.rebuild();
|
|
37
43
|
initialClientMetafile = result.clientMetafile;
|
|
38
44
|
} catch (err) {
|
|
39
45
|
log.error('Pages build failed', { error: String(err) });
|
|
@@ -59,6 +65,7 @@ class PagesRouter {
|
|
|
59
65
|
fonts: config.pages?.fonts,
|
|
60
66
|
initialClientMetafile,
|
|
61
67
|
fileWatcher,
|
|
68
|
+
pagesBuildContext: this.pagesBuildContext,
|
|
62
69
|
});
|
|
63
70
|
log.info('Pages: watching for changes (HMR enabled)');
|
|
64
71
|
}
|
|
@@ -74,6 +81,10 @@ class PagesRouter {
|
|
|
74
81
|
await this.watcher.close();
|
|
75
82
|
this.watcher = null;
|
|
76
83
|
}
|
|
84
|
+
if (this.pagesBuildContext) {
|
|
85
|
+
await this.pagesBuildContext.dispose();
|
|
86
|
+
this.pagesBuildContext = null;
|
|
87
|
+
}
|
|
77
88
|
}
|
|
78
89
|
}
|
|
79
90
|
|
package/server/pages/watcher.js
CHANGED
|
@@ -3,13 +3,18 @@ import { buildPages } from './build.js';
|
|
|
3
3
|
import { diffClientMetafiles } from './hmr.js';
|
|
4
4
|
|
|
5
5
|
function createPagesWatcher(options) {
|
|
6
|
-
const { rootDir, handler, log, fonts, fileWatcher } = options;
|
|
6
|
+
const { rootDir, handler, log, fonts, fileWatcher, pagesBuildContext } = options;
|
|
7
7
|
let building = false;
|
|
8
8
|
let pendingRebuild = false;
|
|
9
9
|
let pendingStructuralChange = false;
|
|
10
10
|
let prevClientMetafile = options.initialClientMetafile;
|
|
11
11
|
const outDir = path.resolve(rootDir, '.build/pages');
|
|
12
12
|
|
|
13
|
+
async function runBuild() {
|
|
14
|
+
if (pagesBuildContext) return pagesBuildContext.rebuild();
|
|
15
|
+
return buildPages({ rootDir, fonts, minify: false, devMode: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
13
18
|
async function rebuild(isStructuralChange) {
|
|
14
19
|
if (building) {
|
|
15
20
|
pendingRebuild = true;
|
|
@@ -19,7 +24,7 @@ function createPagesWatcher(options) {
|
|
|
19
24
|
building = true;
|
|
20
25
|
try {
|
|
21
26
|
const start = Date.now();
|
|
22
|
-
const result = await
|
|
27
|
+
const result = await runBuild();
|
|
23
28
|
handler.reload();
|
|
24
29
|
const elapsed = Date.now() - start;
|
|
25
30
|
if (handler.emitEvent && prevClientMetafile && result.clientMetafile && !isStructuralChange) {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { Readable } from 'node:stream';
|
|
2
|
-
import { pipeline } from 'node:stream/promises';
|
|
3
1
|
import { discoverRoutes, matchRoute, compilePattern } from './routes.js';
|
|
4
2
|
import { discoverMiddleware, getMiddlewareForRoute, buildMiddlewareChain } from './middleware.js';
|
|
3
|
+
import { sendJson, serializeResponse } from './http-helpers.js';
|
|
5
4
|
import { ErrorCodes } from '../constants.js';
|
|
6
5
|
import { sealSession, buildSessionSetCookie, buildSessionClearCookie } from '../session/index.js';
|
|
7
6
|
import { flattenHeaders } from '../session/helpers.js';
|
|
@@ -31,54 +30,6 @@ function applyPrefix(routes, middleware, prefix) {
|
|
|
31
30
|
}
|
|
32
31
|
}
|
|
33
32
|
|
|
34
|
-
function sendJson(res, statusCode, body, headers) {
|
|
35
|
-
const json = JSON.stringify(body);
|
|
36
|
-
res.writeHead(statusCode, {
|
|
37
|
-
'Content-Type': 'application/json',
|
|
38
|
-
'Content-Length': Buffer.byteLength(json),
|
|
39
|
-
...headers,
|
|
40
|
-
});
|
|
41
|
-
res.end(json);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const validateRequest = validateRequestSchema;
|
|
45
|
-
|
|
46
|
-
async function serializeResponse(res, response, responseHeaders, statusCode) {
|
|
47
|
-
const customContentType = responseHeaders['Content-Type'] || responseHeaders['content-type'];
|
|
48
|
-
if (!customContentType || customContentType.includes('application/json')) {
|
|
49
|
-
const responseBody = response.error ? { error: response.error } : (response.data ?? null);
|
|
50
|
-
sendJson(res, statusCode, responseBody, responseHeaders);
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const body = response.error
|
|
55
|
-
? typeof response.error === 'string'
|
|
56
|
-
? response.error
|
|
57
|
-
: JSON.stringify(response.error)
|
|
58
|
-
: (response.data ?? '');
|
|
59
|
-
|
|
60
|
-
if (Buffer.isBuffer(body)) {
|
|
61
|
-
res.writeHead(statusCode, { 'Content-Length': body.length, ...responseHeaders });
|
|
62
|
-
res.end(body);
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
if (body instanceof Readable) {
|
|
66
|
-
res.writeHead(statusCode, responseHeaders);
|
|
67
|
-
await pipeline(body, res);
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
if (typeof body === 'object' && body !== null && typeof body.getReader === 'function') {
|
|
71
|
-
const nodeStream = Readable.fromWeb(body);
|
|
72
|
-
res.writeHead(statusCode, responseHeaders);
|
|
73
|
-
await pipeline(nodeStream, res);
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const raw = typeof body === 'string' ? body : String(body);
|
|
78
|
-
res.writeHead(statusCode, { 'Content-Length': Buffer.byteLength(raw), ...responseHeaders });
|
|
79
|
-
res.end(raw);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
33
|
class ApiRouter {
|
|
83
34
|
_config;
|
|
84
35
|
_log;
|
|
@@ -213,7 +164,7 @@ class ApiRouter {
|
|
|
213
164
|
|
|
214
165
|
// ── Validation ──
|
|
215
166
|
const mergedQuery = { ...(req.query ?? {}), ...params };
|
|
216
|
-
const validated =
|
|
167
|
+
const validated = validateRequestSchema(route.config.schema, mergedQuery, body);
|
|
217
168
|
if (validated.error) {
|
|
218
169
|
sendJson(res, 400, { error: validated.error });
|
|
219
170
|
return true;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Readable } from 'node:stream';
|
|
2
|
+
import { pipeline } from 'node:stream/promises';
|
|
3
|
+
import { ErrorCodes } from '../constants.js';
|
|
4
|
+
|
|
5
|
+
function readBody(req, maxBodySize) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const chunks = [];
|
|
8
|
+
let totalBytes = 0;
|
|
9
|
+
function cleanup() {
|
|
10
|
+
req.removeListener('data', onData);
|
|
11
|
+
req.removeListener('end', onEnd);
|
|
12
|
+
req.removeListener('error', onError);
|
|
13
|
+
}
|
|
14
|
+
function onData(chunk) {
|
|
15
|
+
totalBytes += chunk.length;
|
|
16
|
+
if (totalBytes > maxBodySize) {
|
|
17
|
+
cleanup();
|
|
18
|
+
req.destroy();
|
|
19
|
+
reject(new Error(ErrorCodes.BODY_TOO_LARGE));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
chunks.push(chunk);
|
|
23
|
+
}
|
|
24
|
+
function onEnd() {
|
|
25
|
+
cleanup();
|
|
26
|
+
resolve(Buffer.concat(chunks).toString('utf-8'));
|
|
27
|
+
}
|
|
28
|
+
function onError(err) {
|
|
29
|
+
cleanup();
|
|
30
|
+
reject(err);
|
|
31
|
+
}
|
|
32
|
+
req.on('data', onData);
|
|
33
|
+
req.on('end', onEnd);
|
|
34
|
+
req.on('error', onError);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseQuery(url) {
|
|
39
|
+
const idx = url.indexOf('?');
|
|
40
|
+
if (idx === -1) return {};
|
|
41
|
+
const params = new URLSearchParams(url.slice(idx + 1));
|
|
42
|
+
const result = {};
|
|
43
|
+
for (const [key, value] of params) {
|
|
44
|
+
result[key] = value;
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseBody(rawBody, contentType) {
|
|
50
|
+
if (rawBody.length === 0) return undefined;
|
|
51
|
+
if (contentType.includes('application/json')) {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(rawBody);
|
|
54
|
+
} catch {
|
|
55
|
+
throw new Error(ErrorCodes.INVALID_JSON);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return rawBody;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function sendJson(res, statusCode, body, headers) {
|
|
62
|
+
const json = JSON.stringify(body);
|
|
63
|
+
res.writeHead(statusCode, {
|
|
64
|
+
'Content-Type': 'application/json',
|
|
65
|
+
'Content-Length': Buffer.byteLength(json),
|
|
66
|
+
...headers,
|
|
67
|
+
});
|
|
68
|
+
res.end(json);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function serializeResponse(res, response, responseHeaders, statusCode) {
|
|
72
|
+
const customContentType = responseHeaders['Content-Type'] || responseHeaders['content-type'];
|
|
73
|
+
if (!customContentType || customContentType.includes('application/json')) {
|
|
74
|
+
const responseBody = response.error ? { error: response.error } : (response.data ?? null);
|
|
75
|
+
sendJson(res, statusCode, responseBody, responseHeaders);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const body = response.error
|
|
80
|
+
? typeof response.error === 'string'
|
|
81
|
+
? response.error
|
|
82
|
+
: JSON.stringify(response.error)
|
|
83
|
+
: (response.data ?? '');
|
|
84
|
+
|
|
85
|
+
if (Buffer.isBuffer(body)) {
|
|
86
|
+
res.writeHead(statusCode, { 'Content-Length': body.length, ...responseHeaders });
|
|
87
|
+
res.end(body);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (body instanceof Readable) {
|
|
91
|
+
res.writeHead(statusCode, responseHeaders);
|
|
92
|
+
await pipeline(body, res);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (typeof body === 'object' && body !== null && typeof body.getReader === 'function') {
|
|
96
|
+
const nodeStream = Readable.fromWeb(body);
|
|
97
|
+
res.writeHead(statusCode, responseHeaders);
|
|
98
|
+
await pipeline(nodeStream, res);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const raw = typeof body === 'string' ? body : String(body);
|
|
103
|
+
res.writeHead(statusCode, { 'Content-Length': Buffer.byteLength(raw), ...responseHeaders });
|
|
104
|
+
res.end(raw);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export { readBody, parseQuery, parseBody, sendJson, serializeResponse };
|
package/server/router/routes.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { compileRoutePattern, extractRouteParams } from '#client/route-pattern.js';
|
|
1
2
|
import { discoverModules } from '../discovery.js';
|
|
2
3
|
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
3
4
|
function filePathToPattern(relativePath) {
|
|
@@ -27,52 +28,13 @@ function matchPattern(items, pathname) {
|
|
|
27
28
|
for (const item of items) {
|
|
28
29
|
const m = item.regex.exec(pathname);
|
|
29
30
|
if (!m) continue;
|
|
30
|
-
const params =
|
|
31
|
-
|
|
32
|
-
for (let i = 0; i < item.paramNames.length; i++) {
|
|
33
|
-
const name = item.paramNames[i];
|
|
34
|
-
const raw = m[i + 1];
|
|
35
|
-
if (name === item.catchAllParam) {
|
|
36
|
-
params[name] = raw ? raw.split('/').map(decodeURIComponent) : [];
|
|
37
|
-
} else {
|
|
38
|
-
params[name] = decodeURIComponent(raw);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
} catch {
|
|
42
|
-
continue;
|
|
43
|
-
}
|
|
31
|
+
const params = extractRouteParams(m, item.paramNames, item.catchAllParam);
|
|
32
|
+
if (!params) continue;
|
|
44
33
|
return { match: item, params };
|
|
45
34
|
}
|
|
46
35
|
return null;
|
|
47
36
|
}
|
|
48
|
-
|
|
49
|
-
const paramNames = [];
|
|
50
|
-
let catchAllParam;
|
|
51
|
-
let isOptionalCatchAll = false;
|
|
52
|
-
const regexStr = pattern.replace(
|
|
53
|
-
/\/:([^/]+)|\/\*(\w+)\?|\/\*(\w+)/g,
|
|
54
|
-
(match, singleParam, optCatchAll, reqCatchAll) => {
|
|
55
|
-
if (singleParam) {
|
|
56
|
-
paramNames.push(singleParam);
|
|
57
|
-
return '/([^/]+)';
|
|
58
|
-
}
|
|
59
|
-
if (optCatchAll) {
|
|
60
|
-
paramNames.push(optCatchAll);
|
|
61
|
-
catchAllParam = optCatchAll;
|
|
62
|
-
isOptionalCatchAll = true;
|
|
63
|
-
return '(?:/(.+))?';
|
|
64
|
-
}
|
|
65
|
-
if (reqCatchAll) {
|
|
66
|
-
paramNames.push(reqCatchAll);
|
|
67
|
-
catchAllParam = reqCatchAll;
|
|
68
|
-
return '/(.+)';
|
|
69
|
-
}
|
|
70
|
-
return match;
|
|
71
|
-
},
|
|
72
|
-
);
|
|
73
|
-
const regex = new RegExp(`^${regexStr}/?$`);
|
|
74
|
-
return { regex, paramNames, catchAllParam, isOptionalCatchAll };
|
|
75
|
-
}
|
|
37
|
+
const compilePattern = compileRoutePattern;
|
|
76
38
|
async function discoverRoutes(apiDir) {
|
|
77
39
|
const entries = await discoverModules(apiDir, { recursive: true, label: 'route file' });
|
|
78
40
|
const routes = [];
|
package/server/web-server.js
CHANGED
|
@@ -1,55 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { createHttpServer, listen, closeServer } from './server.js';
|
|
3
3
|
import { buildCorsHeaders, buildPreflightHeaders } from './router/cors.js';
|
|
4
|
+
import { readBody, parseQuery, parseBody } from './router/http-helpers.js';
|
|
4
5
|
import { parseCookies, resolveSession, flattenHeaders } from './session/helpers.js';
|
|
5
|
-
import { sealSession, buildSessionSetCookie, buildSessionClearCookie } from './session/index.js';
|
|
6
6
|
import { ErrorCodes } from './constants.js';
|
|
7
7
|
import { toErrorMessage } from './helpers.js';
|
|
8
8
|
|
|
9
|
-
function readBody(req, maxBodySize) {
|
|
10
|
-
return new Promise((resolve, reject) => {
|
|
11
|
-
const chunks = [];
|
|
12
|
-
let totalBytes = 0;
|
|
13
|
-
function cleanup() {
|
|
14
|
-
req.removeListener('data', onData);
|
|
15
|
-
req.removeListener('end', onEnd);
|
|
16
|
-
req.removeListener('error', onError);
|
|
17
|
-
}
|
|
18
|
-
function onData(chunk) {
|
|
19
|
-
totalBytes += chunk.length;
|
|
20
|
-
if (totalBytes > maxBodySize) {
|
|
21
|
-
cleanup();
|
|
22
|
-
req.destroy();
|
|
23
|
-
reject(new Error(ErrorCodes.BODY_TOO_LARGE));
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
26
|
-
chunks.push(chunk);
|
|
27
|
-
}
|
|
28
|
-
function onEnd() {
|
|
29
|
-
cleanup();
|
|
30
|
-
resolve(Buffer.concat(chunks).toString('utf-8'));
|
|
31
|
-
}
|
|
32
|
-
function onError(err) {
|
|
33
|
-
cleanup();
|
|
34
|
-
reject(err);
|
|
35
|
-
}
|
|
36
|
-
req.on('data', onData);
|
|
37
|
-
req.on('end', onEnd);
|
|
38
|
-
req.on('error', onError);
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function parseQuery(url) {
|
|
43
|
-
const idx = url.indexOf('?');
|
|
44
|
-
if (idx === -1) return {};
|
|
45
|
-
const params = new URLSearchParams(url.slice(idx + 1));
|
|
46
|
-
const result = {};
|
|
47
|
-
for (const [key, value] of params) {
|
|
48
|
-
result[key] = value;
|
|
49
|
-
}
|
|
50
|
-
return result;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
9
|
class WebServer {
|
|
54
10
|
config;
|
|
55
11
|
log;
|
|
@@ -187,20 +143,9 @@ class WebServer {
|
|
|
187
143
|
|
|
188
144
|
// Body (POST/PUT/PATCH only)
|
|
189
145
|
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
|
190
|
-
|
|
191
|
-
req.rawBody
|
|
192
|
-
if (
|
|
193
|
-
const contentType = req.headers['content-type'] ?? '';
|
|
194
|
-
if (contentType.includes('application/json')) {
|
|
195
|
-
try {
|
|
196
|
-
req.body = JSON.parse(rawBody);
|
|
197
|
-
} catch {
|
|
198
|
-
throw new Error(ErrorCodes.INVALID_JSON);
|
|
199
|
-
}
|
|
200
|
-
} else {
|
|
201
|
-
req.body = rawBody;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
146
|
+
req.rawBody = await readBody(req, maxBodySize);
|
|
147
|
+
const parsed = parseBody(req.rawBody, req.headers['content-type'] ?? '');
|
|
148
|
+
if (parsed !== undefined) req.body = parsed;
|
|
204
149
|
}
|
|
205
150
|
}
|
|
206
151
|
|