@webstir-io/webstir 0.1.2 → 0.1.4
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/assets/features/search/search.ts +0 -9
- package/assets/templates/api/src/backend/index.ts +4 -13
- package/assets/templates/full/src/backend/index.ts +4 -13
- package/assets/templates/full/src/backend/module.ts +2 -2
- package/assets/templates/full/src/backend/tests/progressive-enhancement.test.ts +11 -11
- package/package.json +3 -3
- package/scripts/run-tests.mjs +15 -11
- package/src/add-backend.ts +10 -72
- package/src/bun-spa-routes.ts +5 -18
- package/src/dev-server.ts +6 -19
- package/src/execute.ts +2 -0
- package/src/watch.ts +23 -16
- package/src/workspace-lock.ts +207 -0
- package/src/add-backend-compat.ts +0 -628
|
@@ -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
|
+
}
|