@stati/core 1.1.0 → 1.2.0
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/README.md +19 -7
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +24 -2
- package/dist/core/build.d.ts +2 -0
- package/dist/core/build.d.ts.map +1 -1
- package/dist/core/build.js +120 -27
- package/dist/core/dev.d.ts.map +1 -1
- package/dist/core/dev.js +84 -18
- package/dist/core/invalidate.d.ts +67 -1
- package/dist/core/invalidate.d.ts.map +1 -1
- package/dist/core/invalidate.js +321 -4
- package/dist/core/isg/build-lock.d.ts +116 -0
- package/dist/core/isg/build-lock.d.ts.map +1 -0
- package/dist/core/isg/build-lock.js +243 -0
- package/dist/core/isg/builder.d.ts +51 -0
- package/dist/core/isg/builder.d.ts.map +1 -0
- package/dist/core/isg/builder.js +321 -0
- package/dist/core/isg/deps.d.ts +63 -0
- package/dist/core/isg/deps.d.ts.map +1 -0
- package/dist/core/isg/deps.js +332 -0
- package/dist/core/isg/hash.d.ts +48 -0
- package/dist/core/isg/hash.d.ts.map +1 -0
- package/dist/core/isg/hash.js +82 -0
- package/dist/core/isg/manifest.d.ts +47 -0
- package/dist/core/isg/manifest.d.ts.map +1 -0
- package/dist/core/isg/manifest.js +233 -0
- package/dist/core/isg/ttl.d.ts +101 -0
- package/dist/core/isg/ttl.d.ts.map +1 -0
- package/dist/core/isg/ttl.js +222 -0
- package/dist/core/isg/validation.d.ts +71 -0
- package/dist/core/isg/validation.d.ts.map +1 -0
- package/dist/core/isg/validation.js +226 -0
- package/dist/core/templates.d.ts.map +1 -1
- package/dist/core/templates.js +3 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/types.d.ts +110 -20
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/core/invalidate.js
CHANGED
|
@@ -1,7 +1,324 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
import { loadCacheManifest, saveCacheManifest } from './isg/manifest.js';
|
|
3
|
+
/**
|
|
4
|
+
* Parses an invalidation query string into individual query terms.
|
|
5
|
+
* Supports space-separated values and quoted strings.
|
|
6
|
+
*
|
|
7
|
+
* @param query - The query string to parse
|
|
8
|
+
* @returns Array of parsed query terms
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* parseInvalidationQuery('tag:blog path:/posts') // ['tag:blog', 'path:/posts']
|
|
13
|
+
* parseInvalidationQuery('"tag:my tag" path:"/my path"') // ['tag:my tag', 'path:/my path']
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export function parseInvalidationQuery(query) {
|
|
17
|
+
const terms = [];
|
|
18
|
+
let current = '';
|
|
19
|
+
let inQuotes = false;
|
|
20
|
+
let quoteChar = '';
|
|
21
|
+
for (let i = 0; i < query.length; i++) {
|
|
22
|
+
const char = query[i];
|
|
23
|
+
if (char === '"' || char === "'") {
|
|
24
|
+
if (!inQuotes) {
|
|
25
|
+
inQuotes = true;
|
|
26
|
+
quoteChar = char;
|
|
27
|
+
}
|
|
28
|
+
else if (char === quoteChar) {
|
|
29
|
+
inQuotes = false;
|
|
30
|
+
quoteChar = '';
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
current += char;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
else if (char === ' ' && !inQuotes) {
|
|
37
|
+
if (current.trim()) {
|
|
38
|
+
terms.push(current.trim());
|
|
39
|
+
current = '';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
current += char;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (current.trim()) {
|
|
47
|
+
terms.push(current.trim());
|
|
48
|
+
}
|
|
49
|
+
return terms;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Checks if a cache entry matches a specific invalidation term.
|
|
53
|
+
*
|
|
54
|
+
* @param entry - Cache entry to check
|
|
55
|
+
* @param path - The page path for this entry
|
|
56
|
+
* @param term - Invalidation term to match against
|
|
57
|
+
* @returns True if the entry matches the term
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```typescript
|
|
61
|
+
* matchesInvalidationTerm(entry, '/blog/post-1', 'tag:blog') // true if entry has 'blog' tag
|
|
62
|
+
* matchesInvalidationTerm(entry, '/blog/post-1', 'path:/blog') // true (path prefix match)
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export function matchesInvalidationTerm(entry, path, term) {
|
|
66
|
+
// Parse term into type and value
|
|
67
|
+
if (term.includes(':')) {
|
|
68
|
+
const [type, value] = term.split(':', 2);
|
|
69
|
+
// Ensure both type and value exist
|
|
70
|
+
if (!type || !value) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
switch (type.toLowerCase()) {
|
|
74
|
+
case 'tag':
|
|
75
|
+
return entry.tags.includes(value);
|
|
76
|
+
case 'path':
|
|
77
|
+
// Support both exact path match and prefix match
|
|
78
|
+
return path === value || path.startsWith(value);
|
|
79
|
+
case 'glob':
|
|
80
|
+
// Simple glob pattern matching for paths
|
|
81
|
+
return matchesGlob(path, value);
|
|
82
|
+
case 'age':
|
|
83
|
+
// Time-based invalidation: age:3months, age:1week, age:30days
|
|
84
|
+
return matchesAge(entry, value);
|
|
85
|
+
default:
|
|
86
|
+
console.warn(`Unknown invalidation term type: ${type}`);
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
// Plain term - search in tags and path
|
|
92
|
+
return entry.tags.some((tag) => tag.includes(term)) || path.includes(term);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Simple glob pattern matching for paths.
|
|
97
|
+
* Supports * and ** wildcards.
|
|
98
|
+
*
|
|
99
|
+
* @param path - Path to test
|
|
100
|
+
* @param pattern - Glob pattern
|
|
101
|
+
* @returns True if path matches pattern
|
|
102
|
+
*/
|
|
103
|
+
function matchesGlob(path, pattern) {
|
|
104
|
+
try {
|
|
105
|
+
// Convert glob pattern to regex by processing character by character
|
|
106
|
+
// This avoids the magic string replacement issue
|
|
107
|
+
const regex = globToRegex(pattern);
|
|
108
|
+
return regex.test(path);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
console.warn(`Invalid glob pattern: ${pattern}`);
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Converts a glob pattern to a regular expression.
|
|
117
|
+
* Processes the pattern character by character to avoid placeholder conflicts.
|
|
118
|
+
*
|
|
119
|
+
* @param pattern - Glob pattern to convert
|
|
120
|
+
* @returns Regular expression that matches the glob pattern
|
|
121
|
+
*/
|
|
122
|
+
function globToRegex(pattern) {
|
|
123
|
+
let regexStr = '^';
|
|
124
|
+
let i = 0;
|
|
125
|
+
while (i < pattern.length) {
|
|
126
|
+
const char = pattern[i];
|
|
127
|
+
switch (char) {
|
|
128
|
+
case '*':
|
|
129
|
+
if (i + 1 < pattern.length && pattern[i + 1] === '*') {
|
|
130
|
+
// Handle ** (matches any path including subdirectories)
|
|
131
|
+
if (i + 2 < pattern.length && pattern[i + 2] === '/') {
|
|
132
|
+
// **/ pattern - matches zero or more directories
|
|
133
|
+
regexStr += '(?:.*/)?';
|
|
134
|
+
i += 3;
|
|
135
|
+
}
|
|
136
|
+
else if (i + 2 === pattern.length) {
|
|
137
|
+
// ** at end - matches everything
|
|
138
|
+
regexStr += '.*';
|
|
139
|
+
i += 2;
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
// ** not followed by / or end - treat as single *
|
|
143
|
+
regexStr += '[^/]*';
|
|
144
|
+
i += 1;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
// Single * matches any characters except path separator
|
|
149
|
+
regexStr += '[^/]*';
|
|
150
|
+
i += 1;
|
|
151
|
+
}
|
|
152
|
+
break;
|
|
153
|
+
case '?':
|
|
154
|
+
// ? matches any single character except path separator
|
|
155
|
+
regexStr += '[^/]';
|
|
156
|
+
i += 1;
|
|
157
|
+
break;
|
|
158
|
+
case '[': {
|
|
159
|
+
// Handle character classes - find the closing bracket
|
|
160
|
+
let closeIndex = i + 1;
|
|
161
|
+
while (closeIndex < pattern.length && pattern[closeIndex] !== ']') {
|
|
162
|
+
closeIndex++;
|
|
163
|
+
}
|
|
164
|
+
if (closeIndex >= pattern.length) {
|
|
165
|
+
// No closing bracket found - this creates an invalid regex
|
|
166
|
+
// Just add the character and let the regex constructor throw an error
|
|
167
|
+
regexStr += char;
|
|
168
|
+
i += 1;
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
// Valid character class - copy it as-is
|
|
172
|
+
regexStr += pattern.slice(i, closeIndex + 1);
|
|
173
|
+
i = closeIndex + 1;
|
|
174
|
+
}
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
case '.':
|
|
178
|
+
case '+':
|
|
179
|
+
case '^':
|
|
180
|
+
case '$':
|
|
181
|
+
case '(':
|
|
182
|
+
case ')':
|
|
183
|
+
case ']':
|
|
184
|
+
case '{':
|
|
185
|
+
case '}':
|
|
186
|
+
case '|':
|
|
187
|
+
case '\\':
|
|
188
|
+
// Escape regex special characters
|
|
189
|
+
regexStr += '\\' + char;
|
|
190
|
+
i += 1;
|
|
191
|
+
break;
|
|
192
|
+
default:
|
|
193
|
+
// Regular character
|
|
194
|
+
regexStr += char;
|
|
195
|
+
i += 1;
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
regexStr += '$';
|
|
200
|
+
return new RegExp(regexStr);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Checks if a cache entry matches an age-based invalidation term.
|
|
204
|
+
* Supports various time units: days, weeks, months, years.
|
|
205
|
+
*
|
|
206
|
+
* @param entry - Cache entry to check
|
|
207
|
+
* @param ageValue - Age specification (e.g., "3months", "1week", "30days")
|
|
208
|
+
* @returns True if the entry is younger than the specified age
|
|
209
|
+
*
|
|
210
|
+
* @example
|
|
211
|
+
* ```typescript
|
|
212
|
+
* matchesAge(entry, "3months") // true if rendered within the last 3 months
|
|
213
|
+
* matchesAge(entry, "1week") // true if rendered within the last 1 week
|
|
214
|
+
* ```
|
|
215
|
+
*/
|
|
216
|
+
function matchesAge(entry, ageValue) {
|
|
217
|
+
const now = new Date();
|
|
218
|
+
const renderedAt = new Date(entry.renderedAt);
|
|
219
|
+
// Parse age value (e.g., "3months", "1week", "30days")
|
|
220
|
+
const match = ageValue.match(/^(\d+)(days?|weeks?|months?|years?)$/i);
|
|
221
|
+
if (!match || !match[1] || !match[2]) {
|
|
222
|
+
console.warn(`Invalid age format: ${ageValue}. Use format like "3months", "1week", "30days"`);
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
const numStr = match[1];
|
|
226
|
+
const unit = match[2];
|
|
227
|
+
const num = parseInt(numStr, 10);
|
|
228
|
+
if (isNaN(num) || num <= 0) {
|
|
229
|
+
console.warn(`Invalid age number: ${numStr}`);
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
// Calculate cutoff date
|
|
233
|
+
const cutoffDate = new Date(now);
|
|
234
|
+
switch (unit.toLowerCase()) {
|
|
235
|
+
case 'day':
|
|
236
|
+
case 'days':
|
|
237
|
+
cutoffDate.setDate(cutoffDate.getDate() - num);
|
|
238
|
+
break;
|
|
239
|
+
case 'week':
|
|
240
|
+
case 'weeks':
|
|
241
|
+
cutoffDate.setDate(cutoffDate.getDate() - num * 7);
|
|
242
|
+
break;
|
|
243
|
+
case 'month':
|
|
244
|
+
case 'months':
|
|
245
|
+
cutoffDate.setMonth(cutoffDate.getMonth() - num);
|
|
246
|
+
break;
|
|
247
|
+
case 'year':
|
|
248
|
+
case 'years':
|
|
249
|
+
cutoffDate.setFullYear(cutoffDate.getFullYear() - num);
|
|
250
|
+
break;
|
|
251
|
+
default:
|
|
252
|
+
console.warn(`Unknown time unit: ${unit}`);
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
// Entry matches if it was rendered after the cutoff date (i.e., younger than specified age)
|
|
256
|
+
return renderedAt > cutoffDate;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Invalidates cache entries based on a query string.
|
|
260
|
+
* Supports tag-based, path-based, pattern-based, and time-based invalidation.
|
|
261
|
+
*
|
|
262
|
+
* @param query - Invalidation query string, or undefined to clear all cache
|
|
263
|
+
* @returns Promise resolving to invalidation result
|
|
264
|
+
*
|
|
265
|
+
* @example
|
|
266
|
+
* ```typescript
|
|
267
|
+
* // Invalidate all pages with 'blog' tag
|
|
268
|
+
* await invalidate('tag:blog');
|
|
269
|
+
*
|
|
270
|
+
* // Invalidate specific path
|
|
271
|
+
* await invalidate('path:/about');
|
|
272
|
+
*
|
|
273
|
+
* // Invalidate content younger than 3 months
|
|
274
|
+
* await invalidate('age:3months');
|
|
275
|
+
*
|
|
276
|
+
* // Invalidate multiple criteria
|
|
277
|
+
* await invalidate('tag:blog age:1week');
|
|
278
|
+
*
|
|
279
|
+
* // Clear entire cache
|
|
280
|
+
* await invalidate();
|
|
281
|
+
* ```
|
|
282
|
+
*/
|
|
1
283
|
export async function invalidate(query) {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
284
|
+
const cacheDir = join(process.cwd(), '.stati');
|
|
285
|
+
// Load existing cache manifest
|
|
286
|
+
let cacheManifest = await loadCacheManifest(cacheDir);
|
|
287
|
+
if (!cacheManifest) {
|
|
288
|
+
// No cache to invalidate
|
|
289
|
+
return {
|
|
290
|
+
invalidatedCount: 0,
|
|
291
|
+
invalidatedPaths: [],
|
|
292
|
+
clearedAll: false,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
const invalidatedPaths = [];
|
|
296
|
+
if (!query || query.trim() === '') {
|
|
297
|
+
// Clear entire cache
|
|
298
|
+
invalidatedPaths.push(...Object.keys(cacheManifest.entries));
|
|
299
|
+
cacheManifest.entries = {};
|
|
300
|
+
await saveCacheManifest(cacheDir, cacheManifest);
|
|
301
|
+
return {
|
|
302
|
+
invalidatedCount: invalidatedPaths.length,
|
|
303
|
+
invalidatedPaths,
|
|
304
|
+
clearedAll: true,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
// Parse query terms
|
|
308
|
+
const terms = parseInvalidationQuery(query.trim());
|
|
309
|
+
// Find entries that match any of the terms
|
|
310
|
+
for (const [path, entry] of Object.entries(cacheManifest.entries)) {
|
|
311
|
+
const shouldInvalidate = terms.some((term) => matchesInvalidationTerm(entry, path, term));
|
|
312
|
+
if (shouldInvalidate) {
|
|
313
|
+
delete cacheManifest.entries[path];
|
|
314
|
+
invalidatedPaths.push(path);
|
|
315
|
+
}
|
|
6
316
|
}
|
|
317
|
+
// Save updated cache manifest
|
|
318
|
+
await saveCacheManifest(cacheDir, cacheManifest);
|
|
319
|
+
return {
|
|
320
|
+
invalidatedCount: invalidatedPaths.length,
|
|
321
|
+
invalidatedPaths,
|
|
322
|
+
clearedAll: false,
|
|
323
|
+
};
|
|
7
324
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build lock information stored in the lock file.
|
|
3
|
+
*/
|
|
4
|
+
interface BuildLock {
|
|
5
|
+
pid: number;
|
|
6
|
+
timestamp: string;
|
|
7
|
+
hostname?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Manages build process locking to prevent concurrent Stati builds from corrupting cache.
|
|
11
|
+
* Uses a simple file-based locking mechanism with process ID tracking.
|
|
12
|
+
*/
|
|
13
|
+
export declare class BuildLockManager {
|
|
14
|
+
private lockPath;
|
|
15
|
+
private isLocked;
|
|
16
|
+
constructor(cacheDir: string);
|
|
17
|
+
/**
|
|
18
|
+
* Attempts to acquire a build lock.
|
|
19
|
+
* Throws an error if another build process is already running.
|
|
20
|
+
*
|
|
21
|
+
* @param options - Lock acquisition options
|
|
22
|
+
* @param options.force - Force acquire lock even if another process holds it
|
|
23
|
+
* @param options.timeout - Maximum time to wait for lock in milliseconds
|
|
24
|
+
* @throws {Error} When lock cannot be acquired
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* const lockManager = new BuildLockManager('.stati');
|
|
29
|
+
* try {
|
|
30
|
+
* await lockManager.acquireLock();
|
|
31
|
+
* // Proceed with build
|
|
32
|
+
* } finally {
|
|
33
|
+
* await lockManager.releaseLock();
|
|
34
|
+
* }
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
acquireLock(options?: {
|
|
38
|
+
force?: boolean;
|
|
39
|
+
timeout?: number;
|
|
40
|
+
}): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Releases the build lock if this process owns it.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```typescript
|
|
46
|
+
* await lockManager.releaseLock();
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
releaseLock(): Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* Checks if a build lock is currently held by any process.
|
|
52
|
+
*
|
|
53
|
+
* @returns True if a lock exists and the owning process is running
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```typescript
|
|
57
|
+
* if (await lockManager.isLocked()) {
|
|
58
|
+
* console.log('Another build is in progress');
|
|
59
|
+
* }
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
isLockHeld(): Promise<boolean>;
|
|
63
|
+
/**
|
|
64
|
+
* Gets information about the current lock holder.
|
|
65
|
+
*
|
|
66
|
+
* @returns Lock information or null if no lock exists
|
|
67
|
+
*/
|
|
68
|
+
getLockInfo(): Promise<BuildLock | null>;
|
|
69
|
+
/**
|
|
70
|
+
* Force removes the lock file without checking ownership.
|
|
71
|
+
* Should only be used in error recovery scenarios.
|
|
72
|
+
*/
|
|
73
|
+
private forceRemoveLock;
|
|
74
|
+
/**
|
|
75
|
+
* Creates a new lock file with current process information.
|
|
76
|
+
*/
|
|
77
|
+
private createLockFile;
|
|
78
|
+
/**
|
|
79
|
+
* Reads and parses the lock file.
|
|
80
|
+
*/
|
|
81
|
+
private readLockFile;
|
|
82
|
+
/**
|
|
83
|
+
* Checks if a process with the given PID is currently running.
|
|
84
|
+
*/
|
|
85
|
+
private isProcessRunning;
|
|
86
|
+
/**
|
|
87
|
+
* Gets the hostname for lock identification.
|
|
88
|
+
*/
|
|
89
|
+
private getHostname;
|
|
90
|
+
/**
|
|
91
|
+
* Simple sleep utility for polling delays.
|
|
92
|
+
*/
|
|
93
|
+
private sleep;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Convenience function to safely execute a build with automatic lock management.
|
|
97
|
+
*
|
|
98
|
+
* @param cacheDir - Path to the cache directory
|
|
99
|
+
* @param buildFn - Function to execute while holding the lock
|
|
100
|
+
* @param options - Lock options
|
|
101
|
+
* @returns Result of the build function
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* ```typescript
|
|
105
|
+
* const result = await withBuildLock('.stati', async () => {
|
|
106
|
+
* // Your build logic here
|
|
107
|
+
* return await performBuild();
|
|
108
|
+
* });
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
export declare function withBuildLock<T>(cacheDir: string, buildFn: () => Promise<T>, options?: {
|
|
112
|
+
force?: boolean;
|
|
113
|
+
timeout?: number;
|
|
114
|
+
}): Promise<T>;
|
|
115
|
+
export {};
|
|
116
|
+
//# sourceMappingURL=build-lock.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"build-lock.d.ts","sourceRoot":"","sources":["../../../src/core/isg/build-lock.ts"],"names":[],"mappings":"AAYA;;GAEG;AACH,UAAU,SAAS;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,QAAQ,CAAS;gBAEb,QAAQ,EAAE,MAAM;IAI5B;;;;;;;;;;;;;;;;;;;OAmBG;IACG,WAAW,CAAC,OAAO,GAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAoDrF;;;;;;;OAOG;IACG,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC;IAqBlC;;;;;;;;;;;OAWG;IACG,UAAU,IAAI,OAAO,CAAC,OAAO,CAAC;IAiBpC;;;;OAIG;IACG,WAAW,IAAI,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC;IAY9C;;;OAGG;YACW,eAAe;IAQ7B;;OAEG;YACW,cAAc;IAW5B;;OAEG;YACW,YAAY;IAS1B;;OAEG;YACW,gBAAgB;IAY9B;;OAEG;IACH,OAAO,CAAC,WAAW;IAQnB;;OAEG;IACH,OAAO,CAAC,KAAK;CAGd;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,aAAa,CAAC,CAAC,EACnC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EACzB,OAAO,GAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAO,GAClD,OAAO,CAAC,CAAC,CAAC,CASZ"}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import fse from 'fs-extra';
|
|
2
|
+
const { writeFile, readFile, pathExists, remove } = fse;
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { hostname } from 'os';
|
|
5
|
+
/**
|
|
6
|
+
* Manages build process locking to prevent concurrent Stati builds from corrupting cache.
|
|
7
|
+
* Uses a simple file-based locking mechanism with process ID tracking.
|
|
8
|
+
*/
|
|
9
|
+
export class BuildLockManager {
|
|
10
|
+
lockPath;
|
|
11
|
+
isLocked = false;
|
|
12
|
+
constructor(cacheDir) {
|
|
13
|
+
this.lockPath = join(cacheDir, '.build-lock');
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Attempts to acquire a build lock.
|
|
17
|
+
* Throws an error if another build process is already running.
|
|
18
|
+
*
|
|
19
|
+
* @param options - Lock acquisition options
|
|
20
|
+
* @param options.force - Force acquire lock even if another process holds it
|
|
21
|
+
* @param options.timeout - Maximum time to wait for lock in milliseconds
|
|
22
|
+
* @throws {Error} When lock cannot be acquired
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```typescript
|
|
26
|
+
* const lockManager = new BuildLockManager('.stati');
|
|
27
|
+
* try {
|
|
28
|
+
* await lockManager.acquireLock();
|
|
29
|
+
* // Proceed with build
|
|
30
|
+
* } finally {
|
|
31
|
+
* await lockManager.releaseLock();
|
|
32
|
+
* }
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
async acquireLock(options = {}) {
|
|
36
|
+
const { force = false, timeout = 30000 } = options;
|
|
37
|
+
const startTime = Date.now();
|
|
38
|
+
while (Date.now() - startTime < timeout) {
|
|
39
|
+
try {
|
|
40
|
+
// Check if lock file exists
|
|
41
|
+
if (await pathExists(this.lockPath)) {
|
|
42
|
+
const existingLock = await this.readLockFile();
|
|
43
|
+
if (existingLock && !force) {
|
|
44
|
+
// Check if the process is still running
|
|
45
|
+
if (await this.isProcessRunning(existingLock.pid)) {
|
|
46
|
+
// Wait a bit and try again
|
|
47
|
+
await this.sleep(1000);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
// Process is dead, remove stale lock
|
|
52
|
+
console.warn(`Removing stale build lock (PID ${existingLock.pid} no longer running)`);
|
|
53
|
+
await this.forceRemoveLock();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else if (force) {
|
|
57
|
+
console.warn('Force acquiring build lock, removing existing lock');
|
|
58
|
+
await this.forceRemoveLock();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Try to create the lock
|
|
62
|
+
await this.createLockFile();
|
|
63
|
+
this.isLocked = true;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
const nodeError = error;
|
|
68
|
+
if (nodeError.code === 'EEXIST') {
|
|
69
|
+
// Another process created the lock between our check and creation
|
|
70
|
+
await this.sleep(1000);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
throw new Error(`Failed to acquire build lock: ${error instanceof Error ? error.message : String(error)}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
throw new Error(`Build lock acquisition timed out after ${timeout}ms. ` +
|
|
77
|
+
`Another Stati build process may be running. Use --force to override.`);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Releases the build lock if this process owns it.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```typescript
|
|
84
|
+
* await lockManager.releaseLock();
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
async releaseLock() {
|
|
88
|
+
if (!this.isLocked) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
// Verify we still own the lock before removing it
|
|
93
|
+
const currentLock = await this.readLockFile();
|
|
94
|
+
if (currentLock && currentLock.pid === process.pid) {
|
|
95
|
+
await remove(this.lockPath);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
// Don't throw on release errors, just warn
|
|
100
|
+
console.warn(`Warning: Failed to release build lock: ${error instanceof Error ? error.message : String(error)}`);
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
this.isLocked = false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Checks if a build lock is currently held by any process.
|
|
108
|
+
*
|
|
109
|
+
* @returns True if a lock exists and the owning process is running
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```typescript
|
|
113
|
+
* if (await lockManager.isLocked()) {
|
|
114
|
+
* console.log('Another build is in progress');
|
|
115
|
+
* }
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
async isLockHeld() {
|
|
119
|
+
try {
|
|
120
|
+
if (!(await pathExists(this.lockPath))) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
const lock = await this.readLockFile();
|
|
124
|
+
if (!lock) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
return await this.isProcessRunning(lock.pid);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Gets information about the current lock holder.
|
|
135
|
+
*
|
|
136
|
+
* @returns Lock information or null if no lock exists
|
|
137
|
+
*/
|
|
138
|
+
async getLockInfo() {
|
|
139
|
+
try {
|
|
140
|
+
if (!(await pathExists(this.lockPath))) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
return await this.readLockFile();
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Force removes the lock file without checking ownership.
|
|
151
|
+
* Should only be used in error recovery scenarios.
|
|
152
|
+
*/
|
|
153
|
+
async forceRemoveLock() {
|
|
154
|
+
try {
|
|
155
|
+
await remove(this.lockPath);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// Ignore errors when force removing
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Creates a new lock file with current process information.
|
|
163
|
+
*/
|
|
164
|
+
async createLockFile() {
|
|
165
|
+
const lockInfo = {
|
|
166
|
+
pid: process.pid,
|
|
167
|
+
timestamp: new Date().toISOString(),
|
|
168
|
+
hostname: this.getHostname(),
|
|
169
|
+
};
|
|
170
|
+
// Use 'wx' flag to create file exclusively (fails if exists)
|
|
171
|
+
await writeFile(this.lockPath, JSON.stringify(lockInfo, null, 2), { flag: 'wx' });
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Reads and parses the lock file.
|
|
175
|
+
*/
|
|
176
|
+
async readLockFile() {
|
|
177
|
+
try {
|
|
178
|
+
const content = await readFile(this.lockPath, 'utf-8');
|
|
179
|
+
return JSON.parse(content);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Checks if a process with the given PID is currently running.
|
|
187
|
+
*/
|
|
188
|
+
async isProcessRunning(pid) {
|
|
189
|
+
try {
|
|
190
|
+
// On Unix systems, sending signal 0 checks if process exists without affecting it
|
|
191
|
+
process.kill(pid, 0);
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
const nodeError = error;
|
|
196
|
+
// ESRCH means process doesn't exist
|
|
197
|
+
return nodeError.code !== 'ESRCH';
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Gets the hostname for lock identification.
|
|
202
|
+
*/
|
|
203
|
+
getHostname() {
|
|
204
|
+
try {
|
|
205
|
+
return hostname();
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return 'unknown';
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Simple sleep utility for polling delays.
|
|
213
|
+
*/
|
|
214
|
+
sleep(ms) {
|
|
215
|
+
return new Promise((resolve) => global.setTimeout(resolve, ms));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Convenience function to safely execute a build with automatic lock management.
|
|
220
|
+
*
|
|
221
|
+
* @param cacheDir - Path to the cache directory
|
|
222
|
+
* @param buildFn - Function to execute while holding the lock
|
|
223
|
+
* @param options - Lock options
|
|
224
|
+
* @returns Result of the build function
|
|
225
|
+
*
|
|
226
|
+
* @example
|
|
227
|
+
* ```typescript
|
|
228
|
+
* const result = await withBuildLock('.stati', async () => {
|
|
229
|
+
* // Your build logic here
|
|
230
|
+
* return await performBuild();
|
|
231
|
+
* });
|
|
232
|
+
* ```
|
|
233
|
+
*/
|
|
234
|
+
export async function withBuildLock(cacheDir, buildFn, options = {}) {
|
|
235
|
+
const lockManager = new BuildLockManager(cacheDir);
|
|
236
|
+
try {
|
|
237
|
+
await lockManager.acquireLock(options);
|
|
238
|
+
return await buildFn();
|
|
239
|
+
}
|
|
240
|
+
finally {
|
|
241
|
+
await lockManager.releaseLock();
|
|
242
|
+
}
|
|
243
|
+
}
|