@webstir-io/webstir 0.1.1 → 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.
Files changed (78) hide show
  1. package/README.md +13 -0
  2. package/assets/deployment/docker/.dockerignore +7 -0
  3. package/assets/deployment/docker/Dockerfile +17 -0
  4. package/assets/deployment/docker/README.md +44 -0
  5. package/assets/deployment/docker/example.env +3 -0
  6. package/assets/features/client_nav/client_nav.ts +369 -264
  7. package/assets/features/client_nav/document_navigation.ts +344 -0
  8. package/assets/features/client_nav/form_enhancement.ts +275 -0
  9. package/assets/templates/api/src/backend/index.ts +71 -10
  10. package/assets/templates/api/src/backend/tsconfig.json +6 -1
  11. package/assets/templates/full/src/backend/index.ts +71 -10
  12. package/assets/templates/full/src/backend/module.ts +515 -0
  13. package/assets/templates/full/src/backend/tests/progressive-enhancement.test.ts +180 -0
  14. package/assets/templates/full/src/backend/tsconfig.json +6 -1
  15. package/assets/templates/full/src/frontend/app/scripts/features/client-nav.ts +574 -0
  16. package/assets/templates/full/src/frontend/app/scripts/features/document-navigation.ts +344 -0
  17. package/assets/templates/full/src/frontend/app/scripts/features/form-enhancement.ts +275 -0
  18. package/assets/templates/full/src/frontend/pages/home/index.css +8 -0
  19. package/assets/templates/full/src/frontend/pages/home/index.html +6 -1
  20. package/assets/templates/full/src/frontend/pages/home/tests/home.test.ts +12 -2
  21. package/assets/templates/spa/src/frontend/pages/home/tests/home.test.ts +10 -2
  22. package/package.json +31 -13
  23. package/scripts/check-feature-projections.mjs +87 -0
  24. package/scripts/check-full-demo-sync.mjs +89 -0
  25. package/scripts/check-package-install.mjs +537 -0
  26. package/scripts/check-standalone-install.mjs +221 -0
  27. package/scripts/pack-standalone.mjs +52 -28
  28. package/scripts/publish.sh +9 -0
  29. package/scripts/run-tests.mjs +103 -0
  30. package/scripts/sync-assets.mjs +175 -17
  31. package/src/add-backend-compat.ts +628 -0
  32. package/src/add-backend.ts +155 -27
  33. package/src/add.ts +111 -4
  34. package/src/agent.ts +393 -0
  35. package/src/api-watch.ts +7 -4
  36. package/src/backend-inspect.ts +70 -2
  37. package/src/backend-runtime.ts +22 -14
  38. package/src/build.ts +1 -3
  39. package/src/bun-generated-frontend-watch.ts +209 -0
  40. package/src/bun-globals.d.ts +23 -0
  41. package/src/bun-spa-document.ts +310 -0
  42. package/src/bun-spa-routes.ts +159 -0
  43. package/src/bun-spa-watch.ts +29 -0
  44. package/src/bun-ssg-watch.ts +304 -0
  45. package/src/cli.ts +381 -50
  46. package/src/compile-tests.ts +37 -29
  47. package/src/dev-server.ts +215 -144
  48. package/src/doctor.ts +164 -0
  49. package/src/enable-assets.ts +18 -1
  50. package/src/enable.ts +133 -41
  51. package/src/execute.ts +30 -4
  52. package/src/external-workspace.ts +178 -0
  53. package/src/format.ts +296 -17
  54. package/src/frontend-inspect.ts +32 -0
  55. package/src/frontend-watch.ts +27 -102
  56. package/src/full-watch.ts +13 -18
  57. package/src/index.ts +7 -0
  58. package/src/init-assets.ts +41 -11
  59. package/src/init.ts +85 -71
  60. package/src/inspect.ts +112 -0
  61. package/src/mcp/run-cli-json.ts +46 -0
  62. package/src/mcp/server.ts +307 -0
  63. package/src/operations.ts +176 -0
  64. package/src/providers.ts +20 -18
  65. package/src/refresh.ts +29 -3
  66. package/src/repair.ts +110 -43
  67. package/src/runtime-filter.ts +41 -0
  68. package/src/runtime.ts +1 -1
  69. package/src/smoke.ts +48 -16
  70. package/src/test.ts +54 -16
  71. package/src/testing-runtime.ts +273 -0
  72. package/src/types.ts +1 -4
  73. package/src/watch-events.ts +46 -17
  74. package/src/watch.ts +25 -14
  75. package/src/workspace-lock.ts +207 -0
  76. package/src/workspace-watcher.ts +10 -6
  77. package/src/workspace.ts +4 -2
  78. package/src/watch-daemon-client.ts +0 -171
@@ -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
+ }
@@ -30,10 +30,7 @@ export class WorkspaceWatcher {
30
30
 
31
31
  public constructor(options: WorkspaceWatcherOptions) {
32
32
  this.workspaceRoot = path.resolve(options.workspaceRoot);
33
- this.treeRoots = [
34
- path.join(this.workspaceRoot, 'src'),
35
- path.join(this.workspaceRoot, 'types'),
36
- ];
33
+ this.treeRoots = [path.join(this.workspaceRoot, 'src'), path.join(this.workspaceRoot, 'types')];
37
34
  this.onEvent = options.onEvent;
38
35
  this.debounceMs = options.debounceMs ?? 75;
39
36
  }
@@ -112,7 +109,11 @@ export class WorkspaceWatcher {
112
109
  }
113
110
 
114
111
  for (const watchedDirectory of Array.from(this.treeWatchers.keys())) {
115
- if (watchedDirectory !== root && watchedDirectory.startsWith(`${root}${path.sep}`) && !current.has(watchedDirectory)) {
112
+ if (
113
+ watchedDirectory !== root &&
114
+ watchedDirectory.startsWith(`${root}${path.sep}`) &&
115
+ !current.has(watchedDirectory)
116
+ ) {
116
117
  this.treeWatchers.get(watchedDirectory)?.close();
117
118
  this.treeWatchers.delete(watchedDirectory);
118
119
  }
@@ -234,7 +235,10 @@ async function collectDirectories(root: string): Promise<readonly string[]> {
234
235
  const stack = [path.resolve(root)];
235
236
 
236
237
  while (stack.length > 0) {
237
- const current = stack.pop()!;
238
+ const current = stack.pop();
239
+ if (!current) {
240
+ continue;
241
+ }
238
242
  directories.push(current);
239
243
 
240
244
  const entries = await readdir(current, { withFileTypes: true });
package/src/workspace.ts CHANGED
@@ -25,7 +25,7 @@ export function parseWorkspaceMode(value: unknown): WorkspaceMode {
25
25
  }
26
26
 
27
27
  throw new Error(
28
- `Unsupported webstir.mode "${value}". Expected one of: ${SUPPORTED_WORKSPACE_MODES.join(', ')}.`
28
+ `Unsupported webstir.mode "${value}". Expected one of: ${SUPPORTED_WORKSPACE_MODES.join(', ')}.`,
29
29
  );
30
30
  }
31
31
 
@@ -44,7 +44,9 @@ export async function readWorkspaceDescriptor(workspacePath: string): Promise<Wo
44
44
  try {
45
45
  packageJson = JSON.parse(rawPackageJson) as WorkspacePackageJson;
46
46
  } catch (error) {
47
- throw new Error(`Workspace package.json at ${packageJsonPath} is not valid JSON.`, { cause: error });
47
+ throw new Error(`Workspace package.json at ${packageJsonPath} is not valid JSON.`, {
48
+ cause: error,
49
+ });
48
50
  }
49
51
 
50
52
  return {
@@ -1,171 +0,0 @@
1
- import path from 'node:path';
2
- import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
3
- import { createInterface, type Interface as ReadLineInterface } from 'node:readline';
4
- import { fileURLToPath } from 'node:url';
5
-
6
- import { createWorkspaceRuntimeEnv, resolveRuntimeCommand } from './runtime.ts';
7
- import { parseStructuredDiagnosticLine, type StructuredDiagnosticPayload } from './watch-events.ts';
8
- import { ensureLocalPackageArtifacts } from './providers.ts';
9
-
10
- type WatchDaemonCommand =
11
- | { readonly type: 'start' }
12
- | { readonly type: 'change'; readonly path: string }
13
- | { readonly type: 'reload' }
14
- | { readonly type: 'shutdown' };
15
-
16
- export interface FrontendWatchDaemonClientOptions {
17
- readonly workspaceRoot: string;
18
- readonly verbose?: boolean;
19
- readonly hmrVerbose?: boolean;
20
- readonly env?: Record<string, string | undefined>;
21
- readonly onLine?: (line: string) => void;
22
- readonly onErrorLine?: (line: string) => void;
23
- readonly onDiagnostic?: (payload: StructuredDiagnosticPayload) => void;
24
- }
25
-
26
- export class FrontendWatchDaemonClient {
27
- private readonly workspaceRoot: string;
28
- private readonly verbose: boolean;
29
- private readonly hmrVerbose: boolean;
30
- private readonly env?: Record<string, string | undefined>;
31
- private readonly onLine?: (line: string) => void;
32
- private readonly onErrorLine?: (line: string) => void;
33
- private readonly onDiagnostic?: (payload: StructuredDiagnosticPayload) => void;
34
- private child?: ChildProcessWithoutNullStreams;
35
- private stdoutReader?: ReadLineInterface;
36
- private stderrReader?: ReadLineInterface;
37
- private exitPromise?: Promise<number | null>;
38
- private isStopping = false;
39
-
40
- public constructor(options: FrontendWatchDaemonClientOptions) {
41
- this.workspaceRoot = path.resolve(options.workspaceRoot);
42
- this.verbose = options.verbose ?? false;
43
- this.hmrVerbose = options.hmrVerbose ?? false;
44
- this.env = options.env;
45
- this.onLine = options.onLine;
46
- this.onErrorLine = options.onErrorLine;
47
- this.onDiagnostic = options.onDiagnostic;
48
- }
49
-
50
- public async start(): Promise<void> {
51
- if (this.child) {
52
- return;
53
- }
54
-
55
- await ensureLocalPackageArtifacts();
56
- const frontendCliPath = fileURLToPath(import.meta.resolve('@webstir-io/webstir-frontend/cli'));
57
- const args = [
58
- frontendCliPath,
59
- 'watch-daemon',
60
- '--workspace',
61
- this.workspaceRoot,
62
- '--no-auto-start',
63
- ];
64
-
65
- if (this.verbose) {
66
- args.push('--verbose');
67
- }
68
-
69
- if (this.hmrVerbose) {
70
- args.push('--hmr-verbose');
71
- }
72
-
73
- const child = spawn(resolveRuntimeCommand(), args, {
74
- cwd: this.workspaceRoot,
75
- env: createWorkspaceRuntimeEnv(this.workspaceRoot, 'build', this.env),
76
- stdio: 'pipe',
77
- });
78
-
79
- this.child = child;
80
- this.exitPromise = new Promise<number | null>((resolve, reject) => {
81
- child.once('error', reject);
82
- child.once('close', (code) => resolve(code));
83
- });
84
-
85
- this.stdoutReader = createInterface({ input: child.stdout, crlfDelay: Infinity });
86
- this.stdoutReader.on('line', (line) => {
87
- const diagnostic = parseStructuredDiagnosticLine(line);
88
- if (diagnostic) {
89
- this.onDiagnostic?.(diagnostic);
90
- return;
91
- }
92
-
93
- this.onLine?.(line);
94
- });
95
-
96
- this.stderrReader = createInterface({ input: child.stderr, crlfDelay: Infinity });
97
- this.stderrReader.on('line', (line) => {
98
- this.onErrorLine?.(line);
99
- });
100
- }
101
-
102
- public async sendStart(): Promise<void> {
103
- await this.send({ type: 'start' });
104
- }
105
-
106
- public async sendChange(filePath: string): Promise<void> {
107
- await this.send({ type: 'change', path: filePath });
108
- }
109
-
110
- public async sendReload(): Promise<void> {
111
- await this.send({ type: 'reload' });
112
- }
113
-
114
- public async stop(): Promise<number | null> {
115
- if (!this.child || !this.exitPromise) {
116
- return 0;
117
- }
118
-
119
- if (!this.isStopping) {
120
- this.isStopping = true;
121
- try {
122
- await this.send({ type: 'shutdown' });
123
- } catch {
124
- // Fall through to best-effort teardown.
125
- }
126
-
127
- this.child.stdin.end();
128
- }
129
-
130
- const code = await this.exitPromise;
131
- this.cleanup();
132
- return code;
133
- }
134
-
135
- public async waitForExit(): Promise<number | null> {
136
- if (!this.exitPromise) {
137
- throw new Error('Frontend watch daemon has not started.');
138
- }
139
-
140
- const code = await this.exitPromise;
141
- this.cleanup();
142
- return code;
143
- }
144
-
145
- private async send(command: WatchDaemonCommand): Promise<void> {
146
- if (!this.child || !this.child.stdin.writable) {
147
- throw new Error('Frontend watch daemon is not running.');
148
- }
149
-
150
- await new Promise<void>((resolve, reject) => {
151
- this.child!.stdin.write(`${JSON.stringify(command)}\n`, (error) => {
152
- if (error) {
153
- reject(error);
154
- return;
155
- }
156
-
157
- resolve();
158
- });
159
- });
160
- }
161
-
162
- private cleanup(): void {
163
- this.stdoutReader?.close();
164
- this.stderrReader?.close();
165
- this.stdoutReader = undefined;
166
- this.stderrReader = undefined;
167
- this.child = undefined;
168
- this.exitPromise = undefined;
169
- this.isStopping = false;
170
- }
171
- }