@webstir-io/webstir 0.1.2 → 0.1.3
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/package.json +1 -1
- package/scripts/run-tests.mjs +15 -11
- package/src/dev-server.ts +1 -1
- package/src/execute.ts +2 -0
- package/src/watch.ts +23 -16
- package/src/workspace-lock.ts +207 -0
package/package.json
CHANGED
package/scripts/run-tests.mjs
CHANGED
|
@@ -17,6 +17,13 @@ const watchBrowserTestFiles = [
|
|
|
17
17
|
const publishModeFilter = 'publish mode';
|
|
18
18
|
const watchModeFilter = 'watch mode';
|
|
19
19
|
|
|
20
|
+
function buildSingleFileTestSteps(labelPrefix, files) {
|
|
21
|
+
return files.map((file) => ({
|
|
22
|
+
label: `${labelPrefix}: ${path.basename(file)}`,
|
|
23
|
+
args: ['test', '--bail=1', file],
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
|
|
20
27
|
export function listCoreTestFiles() {
|
|
21
28
|
return readdirSync(testsDir)
|
|
22
29
|
.filter((file) => file.endsWith('.ts'))
|
|
@@ -27,26 +34,23 @@ export function listCoreTestFiles() {
|
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
export function buildTestPlan(mode) {
|
|
30
|
-
const coreTests =
|
|
31
|
-
label: 'core orchestrator tests',
|
|
32
|
-
args: ['test', '--bail=1', ...listCoreTestFiles()],
|
|
33
|
-
};
|
|
37
|
+
const coreTests = buildSingleFileTestSteps('core orchestrator test', listCoreTestFiles());
|
|
34
38
|
const publishBrowserTests = {
|
|
35
39
|
label: 'browser publish proofs',
|
|
36
40
|
args: ['test', '--bail=1', browserTestFile, '-t', publishModeFilter],
|
|
37
41
|
};
|
|
38
|
-
const integrationWatchBrowserTests =
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
const integrationWatchBrowserTests = buildSingleFileTestSteps(
|
|
43
|
+
'browser watch integration proof',
|
|
44
|
+
watchBrowserTestFiles,
|
|
45
|
+
);
|
|
42
46
|
const watchBrowserTests = {
|
|
43
47
|
label: 'browser watch proofs',
|
|
44
48
|
args: ['test', '--bail=1', browserTestFile, '-t', watchModeFilter],
|
|
45
49
|
};
|
|
46
50
|
const requiredPlan = [
|
|
47
|
-
coreTests,
|
|
51
|
+
...coreTests,
|
|
48
52
|
publishBrowserTests,
|
|
49
|
-
integrationWatchBrowserTests,
|
|
53
|
+
...integrationWatchBrowserTests,
|
|
50
54
|
watchBrowserTests,
|
|
51
55
|
];
|
|
52
56
|
|
|
@@ -56,7 +60,7 @@ export function buildTestPlan(mode) {
|
|
|
56
60
|
case 'publish-browser':
|
|
57
61
|
return [publishBrowserTests];
|
|
58
62
|
case 'watch-browser':
|
|
59
|
-
return [integrationWatchBrowserTests, watchBrowserTests];
|
|
63
|
+
return [...integrationWatchBrowserTests, watchBrowserTests];
|
|
60
64
|
case 'all':
|
|
61
65
|
case 'with-watch-browser':
|
|
62
66
|
return requiredPlan;
|
package/src/dev-server.ts
CHANGED
|
@@ -434,7 +434,7 @@ function setCacheHeaders(headers: Headers, relativePath: string): void {
|
|
|
434
434
|
}
|
|
435
435
|
|
|
436
436
|
const extension = path.extname(relativePath).toLowerCase();
|
|
437
|
-
if (extension === '.html' || extension === '') {
|
|
437
|
+
if (extension === '.html' || extension === '' || extension === '.json') {
|
|
438
438
|
setNoCacheHeaders(headers);
|
|
439
439
|
return;
|
|
440
440
|
}
|
package/src/execute.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
CommandMode,
|
|
11
11
|
} from './types.ts';
|
|
12
12
|
import { readWorkspaceDescriptor } from './workspace.ts';
|
|
13
|
+
import { assertNoActiveWorkspaceWatch } from './workspace-lock.ts';
|
|
13
14
|
|
|
14
15
|
export interface RunCommandOptions {
|
|
15
16
|
readonly workspaceRoot: string;
|
|
@@ -22,6 +23,7 @@ export async function runCommand(
|
|
|
22
23
|
options: RunCommandOptions,
|
|
23
24
|
): Promise<CommandExecutionResult> {
|
|
24
25
|
const workspace = await readWorkspaceDescriptor(options.workspaceRoot);
|
|
26
|
+
await assertNoActiveWorkspaceWatch(workspace.root, mode);
|
|
25
27
|
const providerLoader = options.loadProvider ?? loadProvider;
|
|
26
28
|
const targets = [];
|
|
27
29
|
|
package/src/watch.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { runFrontendWatch } from './frontend-watch.ts';
|
|
|
4
4
|
import { runFullWatch } from './full-watch.ts';
|
|
5
5
|
import type { WorkspaceDescriptor } from './types.ts';
|
|
6
6
|
import { readWorkspaceDescriptor } from './workspace.ts';
|
|
7
|
+
import { acquireWorkspaceWatchLock } from './workspace-lock.ts';
|
|
7
8
|
|
|
8
9
|
interface WatchStream {
|
|
9
10
|
write(message: string): void;
|
|
@@ -42,24 +43,30 @@ const defaultIo: WatchIo = {
|
|
|
42
43
|
|
|
43
44
|
export async function runWatch(options: RunWatchOptions): Promise<void> {
|
|
44
45
|
const io = options.io ?? defaultIo;
|
|
45
|
-
await materializeRepoLocalWorkspaceDependencies(options.workspaceRoot);
|
|
46
46
|
const workspace = await readWorkspaceDescriptor(options.workspaceRoot);
|
|
47
|
+
const watchLock = await acquireWorkspaceWatchLock(workspace.root);
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
49
|
+
try {
|
|
50
|
+
await materializeRepoLocalWorkspaceDependencies(options.workspaceRoot);
|
|
51
|
+
|
|
52
|
+
switch (workspace.mode) {
|
|
53
|
+
case 'spa':
|
|
54
|
+
await runFrontendWatch(workspace, options, io);
|
|
55
|
+
return;
|
|
56
|
+
case 'ssg':
|
|
57
|
+
await runFrontendWatch(workspace, options, io);
|
|
58
|
+
return;
|
|
59
|
+
case 'api':
|
|
60
|
+
await runApiWatch(workspace, options, io);
|
|
61
|
+
return;
|
|
62
|
+
case 'full':
|
|
63
|
+
await runFullWatch(workspace, options, io);
|
|
64
|
+
return;
|
|
65
|
+
default:
|
|
66
|
+
throwUnsupportedWatchMode(workspace);
|
|
67
|
+
}
|
|
68
|
+
} finally {
|
|
69
|
+
await watchLock.release();
|
|
63
70
|
}
|
|
64
71
|
}
|
|
65
72
|
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
export interface WorkspaceWatchLockHandle {
|
|
5
|
+
readonly path: string;
|
|
6
|
+
release(): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface WorkspaceWatchLockOwner {
|
|
10
|
+
readonly kind: 'watch';
|
|
11
|
+
readonly pid: number;
|
|
12
|
+
readonly workspaceRoot: string;
|
|
13
|
+
readonly createdAt: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface WorkspaceWatchLockState {
|
|
17
|
+
readonly exists: boolean;
|
|
18
|
+
readonly active: boolean;
|
|
19
|
+
readonly lockPath: string;
|
|
20
|
+
readonly owner?: WorkspaceWatchLockOwner;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const WEBSTIR_DIR = '.webstir';
|
|
24
|
+
const WATCH_LOCK_DIR = 'watch.lock';
|
|
25
|
+
const OWNER_FILE = 'owner.json';
|
|
26
|
+
const UNKNOWN_OWNER_GRACE_MS = 30_000;
|
|
27
|
+
|
|
28
|
+
export class WorkspaceWatchLockConflictError extends Error {
|
|
29
|
+
public constructor(
|
|
30
|
+
workspaceRoot: string,
|
|
31
|
+
command: 'build' | 'publish' | 'watch',
|
|
32
|
+
owner?: WorkspaceWatchLockOwner,
|
|
33
|
+
) {
|
|
34
|
+
const ownerDetails = owner ? ` (pid ${owner.pid})` : '';
|
|
35
|
+
const action = command === 'watch' ? 'start webstir watch' : `run webstir ${command}`;
|
|
36
|
+
super(
|
|
37
|
+
`Cannot ${action} because webstir watch is active for this workspace${ownerDetails}. Stop the watch process before running another build pipeline against ${workspaceRoot}.`,
|
|
38
|
+
);
|
|
39
|
+
this.name = 'WorkspaceWatchLockConflictError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function acquireWorkspaceWatchLock(
|
|
44
|
+
workspaceRoot: string,
|
|
45
|
+
): Promise<WorkspaceWatchLockHandle> {
|
|
46
|
+
const resolvedRoot = path.resolve(workspaceRoot);
|
|
47
|
+
const lockPath = getWorkspaceWatchLockPath(resolvedRoot);
|
|
48
|
+
await mkdir(path.dirname(lockPath), { recursive: true });
|
|
49
|
+
|
|
50
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
51
|
+
try {
|
|
52
|
+
await mkdir(lockPath);
|
|
53
|
+
try {
|
|
54
|
+
await writeOwner(lockPath, resolvedRoot);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
await removeLockDirectory(lockPath);
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
return createLockHandle(lockPath);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (!isErrno(error, 'EEXIST')) {
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const state = await readWorkspaceWatchLockState(resolvedRoot);
|
|
66
|
+
if (state.active) {
|
|
67
|
+
throw new WorkspaceWatchLockConflictError(resolvedRoot, 'watch', state.owner);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
await removeLockDirectory(state.lockPath);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const state = await readWorkspaceWatchLockState(resolvedRoot);
|
|
75
|
+
throw new WorkspaceWatchLockConflictError(resolvedRoot, 'watch', state.owner);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function assertNoActiveWorkspaceWatch(
|
|
79
|
+
workspaceRoot: string,
|
|
80
|
+
command: 'build' | 'publish',
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
const resolvedRoot = path.resolve(workspaceRoot);
|
|
83
|
+
const state = await readWorkspaceWatchLockState(resolvedRoot);
|
|
84
|
+
if (!state.exists) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (state.active) {
|
|
89
|
+
throw new WorkspaceWatchLockConflictError(resolvedRoot, command, state.owner);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
await removeLockDirectory(state.lockPath);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function getWorkspaceWatchLockPath(workspaceRoot: string): string {
|
|
96
|
+
return path.join(workspaceRoot, WEBSTIR_DIR, WATCH_LOCK_DIR);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function createLockHandle(lockPath: string): WorkspaceWatchLockHandle {
|
|
100
|
+
let released = false;
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
path: lockPath,
|
|
104
|
+
async release() {
|
|
105
|
+
if (released) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
released = true;
|
|
110
|
+
const owner = await readOwner(lockPath);
|
|
111
|
+
if (owner?.pid !== process.pid) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
await removeLockDirectory(lockPath);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function readWorkspaceWatchLockState(
|
|
121
|
+
workspaceRoot: string,
|
|
122
|
+
): Promise<WorkspaceWatchLockState> {
|
|
123
|
+
const lockPath = getWorkspaceWatchLockPath(workspaceRoot);
|
|
124
|
+
let modifiedAtMs = 0;
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const stats = await lstat(lockPath);
|
|
128
|
+
modifiedAtMs = stats.mtimeMs;
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (isErrno(error, 'ENOENT')) {
|
|
131
|
+
return { exists: false, active: false, lockPath };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const owner = await readOwner(lockPath);
|
|
138
|
+
if (!owner) {
|
|
139
|
+
return {
|
|
140
|
+
exists: true,
|
|
141
|
+
active: Date.now() - modifiedAtMs < UNKNOWN_OWNER_GRACE_MS,
|
|
142
|
+
lockPath,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
exists: true,
|
|
148
|
+
active: isProcessActive(owner.pid),
|
|
149
|
+
lockPath,
|
|
150
|
+
owner,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function writeOwner(lockPath: string, workspaceRoot: string): Promise<void> {
|
|
155
|
+
const owner: WorkspaceWatchLockOwner = {
|
|
156
|
+
kind: 'watch',
|
|
157
|
+
pid: process.pid,
|
|
158
|
+
workspaceRoot,
|
|
159
|
+
createdAt: new Date().toISOString(),
|
|
160
|
+
};
|
|
161
|
+
await writeFile(path.join(lockPath, OWNER_FILE), `${JSON.stringify(owner, null, 2)}\n`, 'utf8');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function readOwner(lockPath: string): Promise<WorkspaceWatchLockOwner | undefined> {
|
|
165
|
+
try {
|
|
166
|
+
const raw = await readFile(path.join(lockPath, OWNER_FILE), 'utf8');
|
|
167
|
+
const parsed = JSON.parse(raw) as Partial<WorkspaceWatchLockOwner>;
|
|
168
|
+
if (
|
|
169
|
+
parsed.kind !== 'watch' ||
|
|
170
|
+
typeof parsed.pid !== 'number' ||
|
|
171
|
+
typeof parsed.workspaceRoot !== 'string' ||
|
|
172
|
+
typeof parsed.createdAt !== 'string'
|
|
173
|
+
) {
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
kind: 'watch',
|
|
179
|
+
pid: parsed.pid,
|
|
180
|
+
workspaceRoot: parsed.workspaceRoot,
|
|
181
|
+
createdAt: parsed.createdAt,
|
|
182
|
+
};
|
|
183
|
+
} catch {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isProcessActive(pid: number): boolean {
|
|
189
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
process.kill(pid, 0);
|
|
195
|
+
return true;
|
|
196
|
+
} catch (error) {
|
|
197
|
+
return !isErrno(error, 'ESRCH');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function removeLockDirectory(lockPath: string): Promise<void> {
|
|
202
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isErrno(error: unknown, code: string): boolean {
|
|
206
|
+
return Boolean(error && typeof error === 'object' && 'code' in error && error.code === code);
|
|
207
|
+
}
|