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.
@@ -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 = compilePattern(route.pattern);
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
- try {
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.15",
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",
@@ -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
- spec.paths[openApiPath] = {};
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
  }
@@ -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?.level) entries = entries.filter((e) => e.level === filters.level);
93
- if (filters?.logger) entries = entries.filter((e) => e.logger === filters.logger);
94
- if (filters?.requestId) entries = entries.filter((e) => e.data?.requestId === filters.requestId);
95
- if (filters?.message) entries = entries.filter((e) => e.message === filters.message);
96
- if (filters?.method) entries = entries.filter((e) => e.data?.method === filters.method.toUpperCase());
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?.limit && filters.limit > 0) entries = entries.slice(-filters.limit);
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
- // Cache is keyed on the full build intent (including exact hydration-entry
242
- // contents). devMode skips the cache because the watcher will eventually own
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
- coarseKey = computeClientCoarseKey({
250
- target,
251
- minify,
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
- await fs.mkdir(tempDir, { recursive: true });
276
- await writeHydrationEntries(plan);
277
-
278
- const result = await esbuild.build(
279
- buildClientEsbuildOptions({
280
- entryPoints: plan.entryPoints,
281
- clientDir,
282
- target,
283
- minify,
284
- devMode,
285
- nodeEnv,
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
- const outputs = Object.keys(result.metafile.outputs)
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
- destDir: clientDir,
308
- outputs,
309
- inputs,
310
- metadata: serializeMetadata(metadata),
331
+ clientDir,
332
+ tempDir,
333
+ result,
334
+ metadata,
311
335
  });
312
336
  }
313
337
 
@@ -1,4 +1,4 @@
1
- import { buildPages } from './build.js';
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
- const result = await buildPages({
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
 
@@ -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 buildPages({ rootDir, fonts, minify: false, devMode: true });
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 = validateRequest(route.config.schema, mergedQuery, body);
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 };
@@ -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
- try {
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
- function compilePattern(pattern) {
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 = [];
@@ -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
- const rawBody = await readBody(req, maxBodySize);
191
- req.rawBody = rawBody;
192
- if (rawBody.length > 0) {
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