@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
package/src/test.ts CHANGED
@@ -2,18 +2,27 @@ import path from 'node:path';
2
2
  import { mkdir, writeFile } from 'node:fs/promises';
3
3
 
4
4
  import type { ModuleManifest } from '@webstir-io/module-contract';
5
- import type { TestModule, RunnerSummary, TestRunResult, RuntimeFilter } from '@webstir-io/webstir-testing';
5
+ import type {
6
+ TestModule,
7
+ RunnerSummary,
8
+ TestRunResult,
9
+ RuntimeFilter,
10
+ } from '@webstir-io/webstir-testing';
6
11
  import type { BuildTargetKind, WorkspaceDescriptor } from './types.ts';
7
12
 
8
13
  import { compileTestModules } from './compile-tests.ts';
14
+ import { materializeRepoLocalWorkspaceDependencies } from './external-workspace.ts';
9
15
  import { loadProvider } from './providers.ts';
10
- import { createWorkspaceRuntimeEnv } from './runtime.ts';
11
- import { readWorkspaceDescriptor } from './workspace.ts';
12
16
  import {
13
17
  applyRuntimeFilter,
14
18
  describeRuntimeFilter,
15
19
  normalizeRuntimeFilter,
16
- createDefaultProviderRegistry,
20
+ } from './runtime-filter.ts';
21
+ import { createWorkspaceRuntimeEnv } from './runtime.ts';
22
+ import { run as runFrontendTests } from './testing-runtime.ts';
23
+ import { readWorkspaceDescriptor } from './workspace.ts';
24
+ import {
25
+ createDefaultProviderRegistry as createPublishedProviderRegistry,
17
26
  discoverTestManifest,
18
27
  } from '@webstir-io/webstir-testing';
19
28
 
@@ -21,6 +30,7 @@ export interface RunTestOptions {
21
30
  readonly workspaceRoot: string;
22
31
  readonly rawArgs: readonly string[];
23
32
  readonly env?: Record<string, string | undefined>;
33
+ readonly quietInstall?: boolean;
24
34
  }
25
35
 
26
36
  export interface TestCommandResult {
@@ -33,6 +43,9 @@ export interface TestCommandResult {
33
43
  }
34
44
 
35
45
  export async function runTest(options: RunTestOptions): Promise<TestCommandResult> {
46
+ await materializeRepoLocalWorkspaceDependencies(options.workspaceRoot, {
47
+ installStdio: options.quietInstall ? 'pipe' : 'inherit',
48
+ });
36
49
  const runtime = parseRuntimeFlag(options.rawArgs, options.env);
37
50
  const workspace = await readWorkspaceDescriptor(options.workspaceRoot);
38
51
  const builtTargets = selectBuildTargets(workspace.mode, runtime);
@@ -41,7 +54,11 @@ export async function runTest(options: RunTestOptions): Promise<TestCommandResul
41
54
  const provider = await loadProvider(target);
42
55
  const result = await provider.build({
43
56
  workspaceRoot: workspace.root,
44
- env: createWorkspaceRuntimeEnv(workspace.root, target === 'backend' ? 'test' : 'build', options.env),
57
+ env: createWorkspaceRuntimeEnv(
58
+ workspace.root,
59
+ target === 'backend' ? 'test' : 'build',
60
+ options.env,
61
+ ),
45
62
  incremental: false,
46
63
  });
47
64
 
@@ -52,7 +69,9 @@ export async function runTest(options: RunTestOptions): Promise<TestCommandResul
52
69
 
53
70
  const manifest = await discoverTestManifest(workspace.root);
54
71
  const filteredManifest = applyRuntimeFilter(manifest, runtime);
55
- const filterMessage = describeRuntimeFilter(runtime, manifest.modules.length, filteredManifest.modules.length) ?? undefined;
72
+ const filterMessage =
73
+ describeRuntimeFilter(runtime, manifest.modules.length, filteredManifest.modules.length) ??
74
+ undefined;
56
75
  await compileTestModules(workspace.root, filteredManifest.modules);
57
76
  const summary = await executeTestRun(filteredManifest.modules, workspace.root);
58
77
 
@@ -69,11 +88,17 @@ export async function runTest(options: RunTestOptions): Promise<TestCommandResul
69
88
  export function formatFailedTests(results: readonly TestRunResult[]): string[] {
70
89
  return results
71
90
  .filter((result) => !result.passed)
72
- .map((result) => `${result.file}: ${result.name}${result.message ? ` — ${firstLine(result.message)}` : ''}`);
91
+ .map(
92
+ (result) =>
93
+ `${result.file}: ${result.name}${result.message ? ` — ${firstLine(result.message)}` : ''}`,
94
+ );
73
95
  }
74
96
 
75
- async function executeTestRun(modules: readonly TestModule[], workspaceRoot: string): Promise<RunnerSummary> {
76
- const registry = createDefaultProviderRegistry();
97
+ async function executeTestRun(
98
+ modules: readonly TestModule[],
99
+ workspaceRoot: string,
100
+ ): Promise<RunnerSummary> {
101
+ const registry = createPublishedProviderRegistry();
77
102
  const grouped = new Map<TestModule['runtime'], TestModule[]>();
78
103
 
79
104
  for (const module of modules) {
@@ -88,7 +113,10 @@ async function executeTestRun(modules: readonly TestModule[], workspaceRoot: str
88
113
  let summary = createEmptySummary();
89
114
 
90
115
  for (const [runtime, runtimeModules] of grouped) {
91
- const provider = registry.get(runtime);
116
+ const provider =
117
+ runtime === 'frontend'
118
+ ? { id: '@webstir-io/webstir/frontend-runtime', runTests: runFrontendTests }
119
+ : registry.get(runtime);
92
120
  if (!provider) {
93
121
  continue;
94
122
  }
@@ -96,7 +124,11 @@ async function executeTestRun(modules: readonly TestModule[], workspaceRoot: str
96
124
  const files = runtimeModules
97
125
  .map((module) => module.compiledPath)
98
126
  .filter((compiledPath): compiledPath is string => typeof compiledPath === 'string');
99
- const runtimeSummary = await withRuntimeEnv(runtime, workspaceRoot, async () => await provider.runTests(files));
127
+ const runtimeSummary = await withRuntimeEnv(
128
+ runtime,
129
+ workspaceRoot,
130
+ async () => await provider.runTests(files),
131
+ );
100
132
  summary = {
101
133
  passed: summary.passed + runtimeSummary.passed,
102
134
  failed: summary.failed + runtimeSummary.failed,
@@ -112,7 +144,7 @@ async function executeTestRun(modules: readonly TestModule[], workspaceRoot: str
112
144
  async function withRuntimeEnv<T>(
113
145
  runtime: TestModule['runtime'],
114
146
  workspaceRoot: string,
115
- callback: () => Promise<T>
147
+ callback: () => Promise<T>,
116
148
  ): Promise<T> {
117
149
  if (runtime !== 'backend') {
118
150
  return await callback();
@@ -154,7 +186,10 @@ function createEmptySummary(): RunnerSummary {
154
186
  };
155
187
  }
156
188
 
157
- function selectBuildTargets(mode: WorkspaceDescriptor['mode'], runtime: RuntimeFilter): BuildTargetKind[] {
189
+ function selectBuildTargets(
190
+ mode: WorkspaceDescriptor['mode'],
191
+ runtime: RuntimeFilter,
192
+ ): BuildTargetKind[] {
158
193
  if (runtime === 'frontend') {
159
194
  if (mode === 'spa' || mode === 'ssg' || mode === 'full') {
160
195
  return ['frontend'];
@@ -184,7 +219,7 @@ function selectBuildTargets(mode: WorkspaceDescriptor['mode'], runtime: RuntimeF
184
219
 
185
220
  function parseRuntimeFlag(
186
221
  rawArgs: readonly string[],
187
- env: Record<string, string | undefined> = process.env
222
+ env: Record<string, string | undefined> = process.env,
188
223
  ): RuntimeFilter {
189
224
  for (let index = 0; index < rawArgs.length; index += 1) {
190
225
  const arg = rawArgs[index];
@@ -200,13 +235,16 @@ function parseRuntimeFlag(
200
235
  return normalizeRuntimeFilter(env.WEBSTIR_TEST_RUNTIME);
201
236
  }
202
237
 
203
- async function persistBackendManifest(workspaceRoot: string, manifest: ModuleManifest): Promise<void> {
238
+ async function persistBackendManifest(
239
+ workspaceRoot: string,
240
+ manifest: ModuleManifest,
241
+ ): Promise<void> {
204
242
  const webstirDir = path.join(workspaceRoot, '.webstir');
205
243
  await mkdir(webstirDir, { recursive: true });
206
244
  await writeFile(
207
245
  path.join(webstirDir, 'backend-manifest.json'),
208
246
  `${JSON.stringify(manifest, null, 2)}\n`,
209
- 'utf8'
247
+ 'utf8',
210
248
  );
211
249
  }
212
250
 
@@ -0,0 +1,273 @@
1
+ import fs from 'node:fs';
2
+ import Module from 'node:module';
3
+ import path from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import vm from 'node:vm';
6
+
7
+ import { assert } from '@webstir-io/webstir-testing';
8
+ import type { RunnerSummary, TestCallback, TestRunResult } from '@webstir-io/webstir-testing';
9
+
10
+ interface RegisteredTest {
11
+ readonly name: string;
12
+ readonly fn: TestCallback;
13
+ }
14
+
15
+ type RequireFn = NodeJS.Require;
16
+ type RunnerGlobal = typeof globalThis & {
17
+ __currentFile?: string;
18
+ test?: (name: string, callback?: TestCallback) => void;
19
+ assert?: typeof assert;
20
+ };
21
+
22
+ const registry = new Map<string, RegisteredTest[]>();
23
+ const moduleExports = Object.freeze({
24
+ get test() {
25
+ return test;
26
+ },
27
+ get assert() {
28
+ return assert;
29
+ },
30
+ });
31
+ const runnerGlobal = globalThis as RunnerGlobal;
32
+ const esmFallback = Symbol('esm-fallback');
33
+
34
+ export function test(name: string, callback?: TestCallback): void {
35
+ const currentFile = runnerGlobal.__currentFile;
36
+ if (!currentFile) {
37
+ throw new Error('No current file set');
38
+ }
39
+
40
+ const safeCallback: TestCallback = callback ?? (async () => undefined);
41
+ const tests = registry.get(currentFile);
42
+ if (tests) {
43
+ tests.push({
44
+ name: String(name),
45
+ fn: safeCallback,
46
+ });
47
+ return;
48
+ }
49
+
50
+ registry.set(currentFile, [
51
+ {
52
+ name: String(name),
53
+ fn: safeCallback,
54
+ },
55
+ ]);
56
+ }
57
+
58
+ runnerGlobal.test = test;
59
+ runnerGlobal.assert = assert;
60
+ export { assert };
61
+
62
+ export async function run(files: readonly string[]): Promise<RunnerSummary> {
63
+ const allResults: TestRunResult[] = [];
64
+ const start = Date.now();
65
+
66
+ for (const file of files) {
67
+ if (!fs.existsSync(file)) {
68
+ allResults.push({
69
+ name: '[missing compiled file]',
70
+ file,
71
+ passed: false,
72
+ message: 'Compiled file not found',
73
+ durationMs: 0,
74
+ });
75
+ continue;
76
+ }
77
+
78
+ const evalError = await evaluateModule(file);
79
+ if (evalError) {
80
+ allResults.push({
81
+ name: '[module evaluation]',
82
+ file,
83
+ passed: false,
84
+ message: evalError,
85
+ durationMs: 0,
86
+ });
87
+ continue;
88
+ }
89
+
90
+ const tests = registry.get(file) ?? [];
91
+ for (const entry of tests) {
92
+ const outcome = await runSingleTest(entry);
93
+ allResults.push({
94
+ name: entry.name,
95
+ file,
96
+ passed: outcome.passed,
97
+ message: outcome.message,
98
+ durationMs: outcome.durationMs,
99
+ });
100
+ }
101
+ }
102
+
103
+ let passed = 0;
104
+ let failed = 0;
105
+ for (const result of allResults) {
106
+ if (result.passed) {
107
+ passed += 1;
108
+ } else {
109
+ failed += 1;
110
+ }
111
+ }
112
+
113
+ return {
114
+ passed,
115
+ failed,
116
+ total: allResults.length,
117
+ durationMs: Date.now() - start,
118
+ results: allResults,
119
+ };
120
+ }
121
+
122
+ function createRuntimeRequire(file: string): RequireFn {
123
+ const baseRequire = Module.createRequire(file);
124
+ const runtimeRequire = ((specifier: string) => {
125
+ if (specifier === '@webstir-io/webstir-testing') {
126
+ return moduleExports;
127
+ }
128
+
129
+ return baseRequire(specifier);
130
+ }) as RequireFn;
131
+
132
+ const resolve = ((specifier: string, options?: Parameters<typeof baseRequire.resolve>[1]) => {
133
+ if (specifier === '@webstir-io/webstir-testing') {
134
+ return specifier;
135
+ }
136
+
137
+ return baseRequire.resolve(specifier, options);
138
+ }) as NodeJS.RequireResolve;
139
+
140
+ resolve.paths = baseRequire.resolve.paths;
141
+ runtimeRequire.resolve = resolve;
142
+ runtimeRequire.cache = baseRequire.cache;
143
+ runtimeRequire.main = baseRequire.main;
144
+ runtimeRequire.extensions = baseRequire.extensions;
145
+
146
+ return runtimeRequire;
147
+ }
148
+
149
+ async function evaluateModule(file: string): Promise<string | null> {
150
+ const code = fs.readFileSync(file, 'utf8');
151
+ const commonJsResult = evaluateCommonJsModule(file, code);
152
+
153
+ if (commonJsResult === null) {
154
+ return null;
155
+ }
156
+
157
+ if (commonJsResult === esmFallback) {
158
+ return await evaluateEsmModule(file);
159
+ }
160
+
161
+ return commonJsResult;
162
+ }
163
+
164
+ function evaluateCommonJsModule(file: string, code: string): string | typeof esmFallback | null {
165
+ const runtimeRequire = createRuntimeRequire(file);
166
+ const context = vm.createContext({
167
+ test,
168
+ assert,
169
+ globalThis,
170
+ console,
171
+ setTimeout,
172
+ clearTimeout,
173
+ require: runtimeRequire,
174
+ __dirname: path.dirname(file),
175
+ __filename: file,
176
+ });
177
+
178
+ runnerGlobal.__currentFile = file;
179
+ registry.set(file, []);
180
+
181
+ try {
182
+ const script = new vm.Script(code, { filename: file });
183
+ script.runInContext(context, { displayErrors: true });
184
+ return null;
185
+ } catch (error) {
186
+ if (isEsModuleSyntaxError(error)) {
187
+ return esmFallback;
188
+ }
189
+
190
+ return formatError(error);
191
+ } finally {
192
+ delete runnerGlobal.__currentFile;
193
+ }
194
+ }
195
+
196
+ async function evaluateEsmModule(file: string): Promise<string | null> {
197
+ const moduleUrl = pathToFileURL(file);
198
+ moduleUrl.searchParams.set('ts', Date.now().toString());
199
+
200
+ try {
201
+ runnerGlobal.__currentFile = file;
202
+ registry.set(file, []);
203
+ await import(moduleUrl.href);
204
+ return null;
205
+ } catch (error) {
206
+ return formatError(error);
207
+ } finally {
208
+ delete runnerGlobal.__currentFile;
209
+ }
210
+ }
211
+
212
+ function isEsModuleSyntaxError(error: unknown): boolean {
213
+ const name =
214
+ typeof error === 'object' && error !== null && 'name' in error
215
+ ? String((error as { name?: unknown }).name)
216
+ : '';
217
+ const message =
218
+ typeof error === 'object' && error !== null && 'message' in error
219
+ ? String((error as { message?: unknown }).message)
220
+ : '';
221
+
222
+ if (name !== 'SyntaxError' || message.length === 0) {
223
+ return false;
224
+ }
225
+
226
+ return (
227
+ message.includes('Cannot use import statement outside a module') ||
228
+ message.includes('Unexpected token') ||
229
+ message.includes('export') ||
230
+ message.includes('import call expects one or two arguments')
231
+ );
232
+ }
233
+
234
+ async function runSingleTest(
235
+ testCase: RegisteredTest,
236
+ ): Promise<{ passed: boolean; message: string | null; durationMs: number }> {
237
+ const start = Date.now();
238
+ try {
239
+ const result = testCase.fn();
240
+ if (isPromiseLike(result)) {
241
+ await result;
242
+ }
243
+
244
+ return {
245
+ passed: true,
246
+ message: null,
247
+ durationMs: Date.now() - start,
248
+ };
249
+ } catch (error) {
250
+ return {
251
+ passed: false,
252
+ message: formatError(error),
253
+ durationMs: Date.now() - start,
254
+ };
255
+ }
256
+ }
257
+
258
+ function isPromiseLike(value: unknown): value is Promise<unknown> {
259
+ return (
260
+ typeof value === 'object' &&
261
+ value !== null &&
262
+ 'then' in value &&
263
+ typeof (value as { then?: unknown }).then === 'function'
264
+ );
265
+ }
266
+
267
+ function formatError(error: unknown): string {
268
+ if (error instanceof Error) {
269
+ return error.stack ?? error.message;
270
+ }
271
+
272
+ return String(error);
273
+ }
package/src/types.ts CHANGED
@@ -1,7 +1,4 @@
1
- import type {
2
- ModuleBuildResult,
3
- ModuleProvider,
4
- } from '@webstir-io/module-contract';
1
+ import type { ModuleBuildResult, ModuleProvider } from '@webstir-io/module-contract';
5
2
 
6
3
  export const SUPPORTED_WORKSPACE_MODES = ['spa', 'ssg', 'api', 'full'] as const;
7
4
 
@@ -19,10 +19,16 @@ export interface HotUpdateAsset {
19
19
  readonly url: string;
20
20
  }
21
21
 
22
+ export interface HotUpdateTarget {
23
+ readonly kind: 'boundary';
24
+ readonly id: string;
25
+ }
26
+
22
27
  export interface HotUpdatePayload {
23
28
  readonly requiresReload: boolean;
24
29
  readonly modules: readonly HotUpdateAsset[];
25
30
  readonly styles: readonly HotUpdateAsset[];
31
+ readonly target?: HotUpdateTarget;
26
32
  readonly changedFile?: string;
27
33
  readonly fallbackReasons?: readonly string[];
28
34
  readonly stats?: {
@@ -74,16 +80,14 @@ export function collectWatchActions(payload: StructuredDiagnosticPayload): reado
74
80
  }
75
81
 
76
82
  const hotUpdate = readHotUpdatePayload(payload.data);
77
- const changedFile = typeof hotUpdate?.changedFile === 'string' ? hotUpdate.changedFile : undefined;
83
+ const changedFile =
84
+ typeof hotUpdate?.changedFile === 'string' ? hotUpdate.changedFile : undefined;
78
85
  if (!hotUpdate || !changedFile) {
79
86
  return [{ type: 'status', status: 'success' }];
80
87
  }
81
88
 
82
89
  if (hotUpdate.requiresReload) {
83
- return [
84
- { type: 'status', status: 'hmr-fallback' },
85
- { type: 'reload' },
86
- ];
90
+ return [{ type: 'status', status: 'hmr-fallback' }, { type: 'reload' }];
87
91
  }
88
92
 
89
93
  if (hotUpdate.modules.length === 0 && hotUpdate.styles.length === 0) {
@@ -97,15 +101,19 @@ export function collectWatchActions(payload: StructuredDiagnosticPayload): reado
97
101
  }
98
102
 
99
103
  function isBuildStartDiagnostic(code: string): boolean {
100
- return code === 'frontend.watch.starting' ||
104
+ return (
105
+ code === 'frontend.watch.starting' ||
101
106
  code === 'frontend.watch.reload' ||
102
- code.endsWith('.build.start');
107
+ code.endsWith('.build.start')
108
+ );
103
109
  }
104
110
 
105
111
  function isBuildFailureDiagnostic(code: string): boolean {
106
- return code === 'frontend.watch.unexpected' ||
112
+ return (
113
+ code === 'frontend.watch.unexpected' ||
107
114
  code === 'frontend.watch.command.failure' ||
108
- code.endsWith('.build.failure');
115
+ code.endsWith('.build.failure')
116
+ );
109
117
  }
110
118
 
111
119
  function readHotUpdatePayload(data: Record<string, unknown> | undefined): HotUpdatePayload | null {
@@ -127,6 +135,7 @@ function readHotUpdatePayload(data: Record<string, unknown> | undefined): HotUpd
127
135
  requiresReload: payload.requiresReload,
128
136
  modules: readAssets(payload.modules),
129
137
  styles: readAssets(payload.styles),
138
+ target: readTarget(payload.target),
130
139
  changedFile: typeof payload.changedFile === 'string' ? payload.changedFile : undefined,
131
140
  fallbackReasons: Array.isArray(payload.fallbackReasons)
132
141
  ? payload.fallbackReasons.filter((value): value is string => typeof value === 'string')
@@ -155,12 +164,14 @@ function readAssets(value: unknown): readonly HotUpdateAsset[] {
155
164
  return [];
156
165
  }
157
166
 
158
- return [{
159
- type: asset.type,
160
- path: asset.path,
161
- relativePath: asset.relativePath,
162
- url: asset.url,
163
- }];
167
+ return [
168
+ {
169
+ type: asset.type,
170
+ path: asset.path,
171
+ relativePath: asset.relativePath,
172
+ url: asset.url,
173
+ },
174
+ ];
164
175
  });
165
176
  }
166
177
 
@@ -180,16 +191,34 @@ function readStats(value: unknown): HotUpdatePayload['stats'] | undefined {
180
191
  };
181
192
  }
182
193
 
194
+ function readTarget(value: unknown): HotUpdateTarget | undefined {
195
+ if (typeof value !== 'object' || value === null) {
196
+ return undefined;
197
+ }
198
+
199
+ const target = value as Record<string, unknown>;
200
+ if (target.kind !== 'boundary' || typeof target.id !== 'string' || target.id.trim() === '') {
201
+ return undefined;
202
+ }
203
+
204
+ return {
205
+ kind: 'boundary',
206
+ id: target.id,
207
+ };
208
+ }
209
+
183
210
  function isStructuredDiagnosticPayload(value: unknown): value is StructuredDiagnosticPayload {
184
211
  if (typeof value !== 'object' || value === null) {
185
212
  return false;
186
213
  }
187
214
 
188
215
  const payload = value as Record<string, unknown>;
189
- return payload.type === 'diagnostic' &&
216
+ return (
217
+ payload.type === 'diagnostic' &&
190
218
  typeof payload.code === 'string' &&
191
219
  typeof payload.kind === 'string' &&
192
220
  typeof payload.stage === 'string' &&
193
221
  typeof payload.severity === 'string' &&
194
- typeof payload.message === 'string';
222
+ typeof payload.message === 'string'
223
+ );
195
224
  }
package/src/watch.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { runApiWatch } from './api-watch.ts';
2
+ import { materializeRepoLocalWorkspaceDependencies } from './external-workspace.ts';
2
3
  import { runFrontendWatch } from './frontend-watch.ts';
3
4
  import { runFullWatch } from './full-watch.ts';
4
5
  import type { WorkspaceDescriptor } from './types.ts';
5
6
  import { readWorkspaceDescriptor } from './workspace.ts';
7
+ import { acquireWorkspaceWatchLock } from './workspace-lock.ts';
6
8
 
7
9
  interface WatchStream {
8
10
  write(message: string): void;
@@ -42,25 +44,34 @@ const defaultIo: WatchIo = {
42
44
  export async function runWatch(options: RunWatchOptions): Promise<void> {
43
45
  const io = options.io ?? defaultIo;
44
46
  const workspace = await readWorkspaceDescriptor(options.workspaceRoot);
47
+ const watchLock = await acquireWorkspaceWatchLock(workspace.root);
45
48
 
46
- switch (workspace.mode) {
47
- case 'spa':
48
- case 'ssg':
49
- await runFrontendWatch(workspace, options, io);
50
- return;
51
- case 'api':
52
- await runApiWatch(workspace, options, io);
53
- return;
54
- case 'full':
55
- await runFullWatch(workspace, options, io);
56
- return;
57
- default:
58
- throwUnsupportedWatchMode(workspace);
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();
59
70
  }
60
71
  }
61
72
 
62
73
  function throwUnsupportedWatchMode(workspace: WorkspaceDescriptor): never {
63
74
  throw new Error(
64
- `Watch currently supports spa, ssg, api, and full workspaces only. "${workspace.name}" is ${workspace.mode}.`
75
+ `Watch currently supports spa, ssg, api, and full workspaces only. "${workspace.name}" is ${workspace.mode}.`,
65
76
  );
66
77
  }