arcway 0.1.15 → 0.1.17

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.17",
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
 
@@ -91,6 +91,18 @@ async function cpAtomic(src, dst) {
91
91
  }
92
92
  }
93
93
 
94
+ // Best-effort LRU timestamp bump. File mtime is what enforceCacheBudget orders
95
+ // by, so this is how we record "recently used" for cache hits. Swallow errors —
96
+ // a concurrent evictor may have removed the file already; that's fine.
97
+ async function touchMeta(metaPath) {
98
+ const now = new Date();
99
+ try {
100
+ await fs.utimes(metaPath, now, now);
101
+ } catch {
102
+ // Ignore: entry may have been evicted concurrently.
103
+ }
104
+ }
105
+
94
106
  async function lookupBundle({ rootDir, entryPath, configHash, kind }) {
95
107
  const bucket = bucketDir(rootDir, kind);
96
108
  const key = entryKey(entryPath);
@@ -113,6 +125,7 @@ async function lookupBundle({ rootDir, entryPath, configHash, kind }) {
113
125
  } catch {
114
126
  return { hit: false, key, bucket, metaPath, jsPath, mapPath };
115
127
  }
128
+ await touchMeta(metaPath);
116
129
  return { hit: true, key, bucket, metaPath, jsPath, mapPath };
117
130
  }
118
131
 
@@ -194,10 +207,26 @@ async function buildWithCache({
194
207
  return { cacheHit: false, metafile: result.metafile };
195
208
  }
196
209
 
210
+ // Canonical, order-independent hash of a virtualInputs list so stored/current
211
+ // sets can be compared without worrying about caller ordering.
212
+ function hashVirtualInputs(virtualInputs) {
213
+ if (!virtualInputs || virtualInputs.length === 0) return '';
214
+ const sorted = [...virtualInputs]
215
+ .map((v) => ({ name: v.name, digest: v.digest }))
216
+ .sort((a, b) => a.name.localeCompare(b.name));
217
+ return sha256(JSON.stringify(sorted));
218
+ }
219
+
197
220
  // Multi-file cache for bundles with several output files (client build with
198
221
  // code-splitting). Each cache entry lives under <bucket>/<coarseKey>/ and the
199
222
  // index is a sidecar <bucket>/<coarseKey>.meta.json.
200
- async function lookupMultiFileCache({ rootDir, kind, coarseKey }) {
223
+ //
224
+ // `virtualInputs` (optional) are caller-computed digests that can't be
225
+ // represented as a file path — e.g. the set of class-name tokens extracted
226
+ // from a source tree. Each entry is `{ name, digest }`. The caller must
227
+ // re-compute and pass current values on every lookup; the stored and current
228
+ // digest sets must match exactly for a hit.
229
+ async function lookupMultiFileCache({ rootDir, kind, coarseKey, virtualInputs }) {
201
230
  const bucket = bucketDir(rootDir, kind);
202
231
  const metaPath = path.join(bucket, `${coarseKey}.meta.json`);
203
232
  const cacheDir = path.join(bucket, coarseKey);
@@ -207,6 +236,14 @@ async function lookupMultiFileCache({ rootDir, kind, coarseKey }) {
207
236
  if (currentHash === null || currentHash !== meta.inputsHash) {
208
237
  return { hit: false, bucket, cacheDir, metaPath };
209
238
  }
239
+ // Compare virtual-input digest sets. If the stored entry has virtualInputs
240
+ // but the caller omitted them, treat as a miss — a silent hit would serve
241
+ // stale output for whatever derived state the virtual digest tracks.
242
+ const storedVirtualHash = meta.virtualInputsHash ?? '';
243
+ const currentVirtualHash = hashVirtualInputs(virtualInputs);
244
+ if (storedVirtualHash !== currentVirtualHash) {
245
+ return { hit: false, bucket, cacheDir, metaPath };
246
+ }
210
247
  // Sanity-check every recorded output file still exists in the cache dir.
211
248
  for (const rel of meta.outputs) {
212
249
  try {
@@ -215,6 +252,7 @@ async function lookupMultiFileCache({ rootDir, kind, coarseKey }) {
215
252
  return { hit: false, bucket, cacheDir, metaPath };
216
253
  }
217
254
  }
255
+ await touchMeta(metaPath);
218
256
  return { hit: true, bucket, cacheDir, metaPath, meta };
219
257
  }
220
258
 
@@ -236,6 +274,7 @@ async function storeMultiFileCache({
236
274
  destDir,
237
275
  outputs,
238
276
  inputs,
277
+ virtualInputs,
239
278
  metadata,
240
279
  }) {
241
280
  const cacheDir = path.join(bucket, coarseKey);
@@ -250,25 +289,224 @@ async function storeMultiFileCache({
250
289
  }),
251
290
  );
252
291
  const inputsHash = await hashInputs(inputs);
292
+ const virtualInputsHash = hashVirtualInputs(virtualInputs);
253
293
  const metaPath = path.join(bucket, `${coarseKey}.meta.json`);
254
294
  await writeJsonAtomic(metaPath, {
255
295
  inputs,
256
296
  inputsHash,
297
+ virtualInputs: virtualInputs ?? [],
298
+ virtualInputsHash,
257
299
  outputs,
258
300
  metadata,
259
301
  mtime: Date.now(),
260
302
  });
261
303
  }
262
304
 
305
+ // Default cap for the on-disk cache. Override with ARCWAY_PAGES_CACHE_MAX_BYTES
306
+ // (bytes as an integer). Anything over the cap is LRU-evicted after each store.
307
+ const DEFAULT_CACHE_MAX_BYTES = 500 * 1024 * 1024;
308
+
309
+ // Newly-written entries are protected from eviction for this many milliseconds
310
+ // to avoid racing with in-flight stores from sibling forks. 5s comfortably
311
+ // exceeds a typical per-entry store (cpAtomic + writeJsonAtomic).
312
+ const DEFAULT_CACHE_GRACE_PERIOD_MS = 5000;
313
+
314
+ function cacheMaxBytes() {
315
+ const raw = Number(process.env.ARCWAY_PAGES_CACHE_MAX_BYTES);
316
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_CACHE_MAX_BYTES;
317
+ }
318
+
319
+ async function statOrNull(filePath) {
320
+ try {
321
+ return await fs.stat(filePath);
322
+ } catch {
323
+ return null;
324
+ }
325
+ }
326
+
327
+ // Recursively measure a directory's total size and the most-recent mtime of any
328
+ // file/subdir inside it. Used for multi-file cache entries whose body lives in a
329
+ // sibling dir. Returns {size: 0, mtime: 0} when the dir is missing.
330
+ async function measureDir(dir) {
331
+ let size = 0;
332
+ let mtime = 0;
333
+ let items;
334
+ try {
335
+ items = await fs.readdir(dir, { withFileTypes: true });
336
+ } catch {
337
+ return { size, mtime };
338
+ }
339
+ for (const item of items) {
340
+ const abs = path.join(dir, item.name);
341
+ if (item.isDirectory()) {
342
+ const sub = await measureDir(abs);
343
+ size += sub.size;
344
+ if (sub.mtime > mtime) mtime = sub.mtime;
345
+ } else {
346
+ const s = await statOrNull(abs);
347
+ if (s) {
348
+ size += s.size;
349
+ if (s.mtimeMs > mtime) mtime = s.mtimeMs;
350
+ }
351
+ }
352
+ }
353
+ return { size, mtime };
354
+ }
355
+
356
+ // Enumerate every cache entry across all kinds. Each entry is identified by its
357
+ // sidecar `<key>.meta.json` file. Single-file entries have an accompanying
358
+ // `<key>.js` (+ optional `.js.map`); multi-file entries have a `<coarseKey>/`
359
+ // dir. We collect paths for both shapes so removal is unconditional.
360
+ async function collectCacheEntries(rootDir) {
361
+ const root = cacheRoot(rootDir);
362
+ let kindDirs;
363
+ try {
364
+ kindDirs = await fs.readdir(root, { withFileTypes: true });
365
+ } catch {
366
+ return [];
367
+ }
368
+ const entries = [];
369
+ for (const kindEnt of kindDirs) {
370
+ if (!kindEnt.isDirectory()) continue;
371
+ const bucket = path.join(root, kindEnt.name);
372
+ let files;
373
+ try {
374
+ files = await fs.readdir(bucket);
375
+ } catch {
376
+ continue;
377
+ }
378
+ for (const file of files) {
379
+ if (!file.endsWith('.meta.json')) continue;
380
+ const baseKey = file.slice(0, -'.meta.json'.length);
381
+ const metaPath = path.join(bucket, file);
382
+ const jsPath = path.join(bucket, `${baseKey}.js`);
383
+ const mapPath = path.join(bucket, `${baseKey}.js.map`);
384
+ const dirPath = path.join(bucket, baseKey);
385
+
386
+ const metaStat = await statOrNull(metaPath);
387
+ if (!metaStat) continue;
388
+ const jsStat = await statOrNull(jsPath);
389
+ const mapStat = await statOrNull(mapPath);
390
+ const dirInfo = await measureDir(dirPath);
391
+
392
+ const size = metaStat.size + (jsStat?.size ?? 0) + (mapStat?.size ?? 0) + dirInfo.size;
393
+ // Use the most recent mtime across meta + dir contents. A concurrent
394
+ // multi-file store rewrites `.meta.json` last but populates the dir
395
+ // first, so dir mtime can be newer than meta mtime during a store.
396
+ const mtime = Math.max(metaStat.mtimeMs, dirInfo.mtime);
397
+ entries.push({ metaPath, jsPath, mapPath, dirPath, size, mtime });
398
+ }
399
+ }
400
+ return entries;
401
+ }
402
+
403
+ async function removeCacheEntry(entry) {
404
+ await Promise.allSettled([
405
+ fs.rm(entry.metaPath, { force: true }),
406
+ fs.rm(entry.jsPath, { force: true }),
407
+ fs.rm(entry.mapPath, { force: true }),
408
+ fs.rm(entry.dirPath, { recursive: true, force: true }),
409
+ ]);
410
+ }
411
+
412
+ // Enforce a byte-budget over the on-disk cache. Oldest (by filesystem mtime)
413
+ // entries are evicted first; entries whose mtime is within `gracePeriodMs` of
414
+ // `now` are protected so an in-flight concurrent store isn't yanked out from
415
+ // under a sibling fork. Lock-free and idempotent: multiple forks may run this
416
+ // concurrently and at worst slightly over-evict, which self-corrects on the
417
+ // next cold build.
418
+ async function enforceCacheBudget({
419
+ rootDir,
420
+ maxBytes = cacheMaxBytes(),
421
+ gracePeriodMs = DEFAULT_CACHE_GRACE_PERIOD_MS,
422
+ now = Date.now(),
423
+ } = {}) {
424
+ const entries = await collectCacheEntries(rootDir);
425
+ const totalSize = entries.reduce((s, e) => s + e.size, 0);
426
+ if (totalSize <= maxBytes) {
427
+ return { evicted: 0, kept: entries.length, bytesFreed: 0, bytesRemaining: totalSize };
428
+ }
429
+
430
+ const candidates = entries
431
+ .filter((e) => now - e.mtime >= gracePeriodMs)
432
+ .sort((a, b) => a.mtime - b.mtime);
433
+
434
+ let remaining = totalSize;
435
+ let freed = 0;
436
+ let evicted = 0;
437
+ for (const cand of candidates) {
438
+ if (remaining <= maxBytes) break;
439
+ await removeCacheEntry(cand);
440
+ remaining -= cand.size;
441
+ freed += cand.size;
442
+ evicted += 1;
443
+ }
444
+ return {
445
+ evicted,
446
+ kept: entries.length - evicted,
447
+ bytesFreed: freed,
448
+ bytesRemaining: remaining,
449
+ };
450
+ }
451
+
452
+ // Walk every cache entry in <rootDir>/node_modules/.cache/arcway-pages/<kind>
453
+ // and drop any whose recorded inputs no longer hash to the stored value (or
454
+ // whose inputs are gone). Used by tests that intentionally mutate fixture
455
+ // sources to keep cross-fork cache state consistent. Best-effort: missing
456
+ // bucket → returns {removed: 0, kept: 0}; individual unlink failures are
457
+ // swallowed since a concurrent worker may already be removing the same entry.
458
+ async function pruneStaleCache({ rootDir, kind }) {
459
+ const bucket = bucketDir(rootDir, kind);
460
+ let entries;
461
+ try {
462
+ entries = await fs.readdir(bucket);
463
+ } catch (err) {
464
+ if (err.code === 'ENOENT') return { removed: 0, kept: 0 };
465
+ throw err;
466
+ }
467
+
468
+ const metaFiles = entries.filter((f) => f.endsWith('.meta.json'));
469
+ let removed = 0;
470
+ let kept = 0;
471
+
472
+ for (const metaFile of metaFiles) {
473
+ const metaPath = path.join(bucket, metaFile);
474
+ const meta = await readJson(metaPath);
475
+ const stale =
476
+ !meta ||
477
+ !Array.isArray(meta.inputs) ||
478
+ (await hashInputs(meta.inputs)) !== meta.inputsHash;
479
+
480
+ if (!stale) {
481
+ kept += 1;
482
+ continue;
483
+ }
484
+
485
+ const baseKey = metaFile.slice(0, -'.meta.json'.length);
486
+ await Promise.allSettled([
487
+ fs.rm(metaPath, { force: true }),
488
+ fs.rm(path.join(bucket, `${baseKey}.js`), { force: true }),
489
+ fs.rm(path.join(bucket, `${baseKey}.js.map`), { force: true }),
490
+ fs.rm(path.join(bucket, baseKey), { recursive: true, force: true }),
491
+ ]);
492
+ removed += 1;
493
+ }
494
+
495
+ return { removed, kept };
496
+ }
497
+
263
498
  export {
264
499
  buildWithCache,
265
500
  computeConfigHash,
266
501
  cacheRoot,
502
+ DEFAULT_CACHE_MAX_BYTES,
503
+ enforceCacheBudget,
267
504
  entryKey,
268
505
  ESBUILD_VERSION,
269
506
  sha256,
270
507
  hashInputs,
271
508
  lookupMultiFileCache,
509
+ pruneStaleCache,
272
510
  restoreMultiFileCache,
273
511
  storeMultiFileCache,
274
512
  };