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.
- 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 +239 -1
- package/server/pages/build-client.js +84 -60
- package/server/pages/build-css.js +30 -15
- package/server/pages/build.js +13 -2
- package/server/pages/class-token-scan.js +71 -0
- 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.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",
|
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
|
|
|
@@ -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
|
-
|
|
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
|
};
|