arcway 0.1.14 → 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.14",
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",
@@ -59,8 +59,8 @@ async function boot(options) {
59
59
  });
60
60
  await jobRunner.init();
61
61
 
62
- const fileWatcher = new FileWatcher(rootDir, { log });
63
- await fileWatcher.start();
62
+ const fileWatcher = mode === 'development' ? new FileWatcher(rootDir, { log }) : null;
63
+ if (fileWatcher) await fileWatcher.start();
64
64
 
65
65
  const appContext = { db, redis, events, queue, cache, files, mail, log, fileWatcher };
66
66
 
@@ -68,13 +68,14 @@ async function boot(options) {
68
68
 
69
69
  const apiRouter = new ApiRouter(config.api, {
70
70
  log,
71
+ mode,
71
72
  fileWatcher,
72
73
  appContext,
73
74
  sessionConfig: config.session,
74
75
  });
75
76
  await apiRouter.init();
76
77
 
77
- const pagesRouter = new PagesRouter(config, { rootDir, log, fileWatcher, appContext });
78
+ const pagesRouter = new PagesRouter(config, { rootDir, log, mode, fileWatcher, appContext });
78
79
  await pagesRouter.init();
79
80
 
80
81
  const healthDeps = {
@@ -117,7 +118,7 @@ async function boot(options) {
117
118
  await pagesRouter.close();
118
119
  await apiRouter.close();
119
120
  await webServer.close();
120
- await fileWatcher.close();
121
+ if (fileWatcher) await fileWatcher.close();
121
122
  await destroyInfrastructure(infrastructure);
122
123
  await mcpRouter.cleanup(rootDir);
123
124
  };
@@ -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
 
@@ -194,10 +194,128 @@ async function buildWithCache({
194
194
  return { cacheHit: false, metafile: result.metafile };
195
195
  }
196
196
 
197
+ // Multi-file cache for bundles with several output files (client build with
198
+ // code-splitting). Each cache entry lives under <bucket>/<coarseKey>/ and the
199
+ // index is a sidecar <bucket>/<coarseKey>.meta.json.
200
+ async function lookupMultiFileCache({ rootDir, kind, coarseKey }) {
201
+ const bucket = bucketDir(rootDir, kind);
202
+ const metaPath = path.join(bucket, `${coarseKey}.meta.json`);
203
+ const cacheDir = path.join(bucket, coarseKey);
204
+ const meta = await readJson(metaPath);
205
+ if (!meta) return { hit: false, bucket, cacheDir, metaPath };
206
+ const currentHash = await hashInputs(meta.inputs);
207
+ if (currentHash === null || currentHash !== meta.inputsHash) {
208
+ return { hit: false, bucket, cacheDir, metaPath };
209
+ }
210
+ // Sanity-check every recorded output file still exists in the cache dir.
211
+ for (const rel of meta.outputs) {
212
+ try {
213
+ await fs.access(path.join(cacheDir, rel));
214
+ } catch {
215
+ return { hit: false, bucket, cacheDir, metaPath };
216
+ }
217
+ }
218
+ return { hit: true, bucket, cacheDir, metaPath, meta };
219
+ }
220
+
221
+ async function restoreMultiFileCache({ cacheDir, outputs, destDir }) {
222
+ await fs.mkdir(destDir, { recursive: true });
223
+ await Promise.all(
224
+ outputs.map(async (rel) => {
225
+ const src = path.join(cacheDir, rel);
226
+ const dst = path.join(destDir, rel);
227
+ await fs.mkdir(path.dirname(dst), { recursive: true });
228
+ await fs.cp(src, dst);
229
+ }),
230
+ );
231
+ }
232
+
233
+ async function storeMultiFileCache({
234
+ bucket,
235
+ coarseKey,
236
+ destDir,
237
+ outputs,
238
+ inputs,
239
+ metadata,
240
+ }) {
241
+ const cacheDir = path.join(bucket, coarseKey);
242
+ await fs.rm(cacheDir, { recursive: true, force: true });
243
+ await fs.mkdir(cacheDir, { recursive: true });
244
+ await Promise.all(
245
+ outputs.map(async (rel) => {
246
+ const src = path.join(destDir, rel);
247
+ const dst = path.join(cacheDir, rel);
248
+ await fs.mkdir(path.dirname(dst), { recursive: true });
249
+ await cpAtomic(src, dst);
250
+ }),
251
+ );
252
+ const inputsHash = await hashInputs(inputs);
253
+ const metaPath = path.join(bucket, `${coarseKey}.meta.json`);
254
+ await writeJsonAtomic(metaPath, {
255
+ inputs,
256
+ inputsHash,
257
+ outputs,
258
+ metadata,
259
+ mtime: Date.now(),
260
+ });
261
+ }
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
+
197
309
  export {
198
310
  buildWithCache,
199
311
  computeConfigHash,
200
312
  cacheRoot,
201
313
  entryKey,
202
314
  ESBUILD_VERSION,
315
+ sha256,
316
+ hashInputs,
317
+ lookupMultiFileCache,
318
+ pruneStaleCache,
319
+ restoreMultiFileCache,
320
+ storeMultiFileCache,
203
321
  };