@webstir-io/webstir 0.1.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.
Files changed (123) hide show
  1. package/README.md +69 -0
  2. package/assets/features/client_nav/client_nav.ts +469 -0
  3. package/assets/features/content_nav/content_nav.css +170 -0
  4. package/assets/features/content_nav/content_nav.ts +358 -0
  5. package/assets/features/router/router-types.ts +6 -0
  6. package/assets/features/router/router.ts +118 -0
  7. package/assets/features/search/search.css +204 -0
  8. package/assets/features/search/search.ts +627 -0
  9. package/assets/templates/api/src/backend/index.ts +13 -0
  10. package/assets/templates/api/src/backend/tsconfig.json +15 -0
  11. package/assets/templates/api/src/shared/router-types.ts +23 -0
  12. package/assets/templates/api/src/shared/tsconfig.json +10 -0
  13. package/assets/templates/api/src/shared/types/index.ts +4 -0
  14. package/assets/templates/full/src/backend/index.ts +13 -0
  15. package/assets/templates/full/src/backend/tsconfig.json +15 -0
  16. package/assets/templates/full/src/frontend/app/app.css +65 -0
  17. package/assets/templates/full/src/frontend/app/app.html +13 -0
  18. package/assets/templates/full/src/frontend/app/app.ts +188 -0
  19. package/assets/templates/full/src/frontend/app/error.ts +127 -0
  20. package/assets/templates/full/src/frontend/app/hmr.js +355 -0
  21. package/assets/templates/full/src/frontend/app/navigation.ts +8 -0
  22. package/assets/templates/full/src/frontend/app/refresh.js +114 -0
  23. package/assets/templates/full/src/frontend/app/router.ts +126 -0
  24. package/assets/templates/full/src/frontend/app/styles/base.css +2 -0
  25. package/assets/templates/full/src/frontend/app/styles/reset.css +48 -0
  26. package/assets/templates/full/src/frontend/pages/home/index.css +21 -0
  27. package/assets/templates/full/src/frontend/pages/home/index.html +10 -0
  28. package/assets/templates/full/src/frontend/pages/home/index.ts +18 -0
  29. package/assets/templates/full/src/frontend/pages/home/tests/home.test.ts +21 -0
  30. package/assets/templates/full/src/frontend/tsconfig.json +20 -0
  31. package/assets/templates/full/src/shared/router-types.ts +23 -0
  32. package/assets/templates/full/src/shared/tsconfig.json +10 -0
  33. package/assets/templates/full/src/shared/types/index.ts +4 -0
  34. package/assets/templates/shared/Errors.404.html +23 -0
  35. package/assets/templates/shared/Errors.500.html +23 -0
  36. package/assets/templates/shared/Errors.default.html +23 -0
  37. package/assets/templates/shared/types/global.d.ts +32 -0
  38. package/assets/templates/shared/types.global.d.ts +32 -0
  39. package/assets/templates/spa/src/frontend/app/app.css +65 -0
  40. package/assets/templates/spa/src/frontend/app/app.html +13 -0
  41. package/assets/templates/spa/src/frontend/app/app.ts +188 -0
  42. package/assets/templates/spa/src/frontend/app/error.ts +127 -0
  43. package/assets/templates/spa/src/frontend/app/hmr.js +355 -0
  44. package/assets/templates/spa/src/frontend/app/navigation.ts +8 -0
  45. package/assets/templates/spa/src/frontend/app/refresh.js +114 -0
  46. package/assets/templates/spa/src/frontend/app/router.ts +126 -0
  47. package/assets/templates/spa/src/frontend/app/styles/base.css +2 -0
  48. package/assets/templates/spa/src/frontend/app/styles/reset.css +48 -0
  49. package/assets/templates/spa/src/frontend/pages/home/index.css +21 -0
  50. package/assets/templates/spa/src/frontend/pages/home/index.html +10 -0
  51. package/assets/templates/spa/src/frontend/pages/home/index.ts +18 -0
  52. package/assets/templates/spa/src/frontend/pages/home/tests/home.test.ts +21 -0
  53. package/assets/templates/spa/src/frontend/tsconfig.json +20 -0
  54. package/assets/templates/spa/src/shared/router-types.ts +23 -0
  55. package/assets/templates/spa/src/shared/tsconfig.json +10 -0
  56. package/assets/templates/spa/src/shared/types/index.ts +4 -0
  57. package/assets/templates/ssg/src/frontend/app/app.css +12 -0
  58. package/assets/templates/ssg/src/frontend/app/app.html +43 -0
  59. package/assets/templates/ssg/src/frontend/app/app.ts +190 -0
  60. package/assets/templates/ssg/src/frontend/app/error.ts +127 -0
  61. package/assets/templates/ssg/src/frontend/app/hmr.js +370 -0
  62. package/assets/templates/ssg/src/frontend/app/refresh.js +163 -0
  63. package/assets/templates/ssg/src/frontend/app/scripts/components/drawer.ts +183 -0
  64. package/assets/templates/ssg/src/frontend/app/scripts/components/menu.ts +75 -0
  65. package/assets/templates/ssg/src/frontend/app/styles/base.css +77 -0
  66. package/assets/templates/ssg/src/frontend/app/styles/components/buttons.css +108 -0
  67. package/assets/templates/ssg/src/frontend/app/styles/components/drawer.css +12 -0
  68. package/assets/templates/ssg/src/frontend/app/styles/components/header.css +164 -0
  69. package/assets/templates/ssg/src/frontend/app/styles/components/markdown.css +25 -0
  70. package/assets/templates/ssg/src/frontend/app/styles/layout.css +41 -0
  71. package/assets/templates/ssg/src/frontend/app/styles/reset.css +56 -0
  72. package/assets/templates/ssg/src/frontend/app/styles/tokens.css +72 -0
  73. package/assets/templates/ssg/src/frontend/app/styles/utilities.css +14 -0
  74. package/assets/templates/ssg/src/frontend/content/_sidebar.json +14 -0
  75. package/assets/templates/ssg/src/frontend/content/content-pipeline.md +82 -0
  76. package/assets/templates/ssg/src/frontend/content/css-playbook.md +68 -0
  77. package/assets/templates/ssg/src/frontend/content/hosting.md +48 -0
  78. package/assets/templates/ssg/src/frontend/pages/about/index.css +33 -0
  79. package/assets/templates/ssg/src/frontend/pages/about/index.html +60 -0
  80. package/assets/templates/ssg/src/frontend/pages/docs/index.css +505 -0
  81. package/assets/templates/ssg/src/frontend/pages/docs/index.html +52 -0
  82. package/assets/templates/ssg/src/frontend/pages/docs/index.ts +495 -0
  83. package/assets/templates/ssg/src/frontend/pages/home/index.css +91 -0
  84. package/assets/templates/ssg/src/frontend/pages/home/index.html +38 -0
  85. package/assets/templates/ssg/src/frontend/pages/home/tests/home.test.ts +24 -0
  86. package/assets/templates/ssg/src/frontend/tsconfig.json +13 -0
  87. package/package.json +41 -0
  88. package/scripts/pack-standalone.mjs +127 -0
  89. package/scripts/sync-assets.mjs +87 -0
  90. package/src/add-backend.ts +164 -0
  91. package/src/add.ts +112 -0
  92. package/src/api-watch.ts +84 -0
  93. package/src/backend-inspect.ts +45 -0
  94. package/src/backend-runtime.ts +286 -0
  95. package/src/build-plan.ts +12 -0
  96. package/src/build.ts +10 -0
  97. package/src/cli.ts +569 -0
  98. package/src/compile-tests.ts +61 -0
  99. package/src/dev-server.ts +393 -0
  100. package/src/enable-assets.ts +196 -0
  101. package/src/enable.ts +477 -0
  102. package/src/execute.ts +85 -0
  103. package/src/format.ts +254 -0
  104. package/src/frontend-watch.ts +145 -0
  105. package/src/full-watch.ts +80 -0
  106. package/src/index.ts +20 -0
  107. package/src/init-assets.ts +96 -0
  108. package/src/init.ts +339 -0
  109. package/src/paths.ts +26 -0
  110. package/src/providers.ts +88 -0
  111. package/src/publish.ts +8 -0
  112. package/src/refresh.ts +56 -0
  113. package/src/repair.ts +414 -0
  114. package/src/runtime.ts +48 -0
  115. package/src/smoke.ts +161 -0
  116. package/src/stop-signal.ts +26 -0
  117. package/src/test.ts +215 -0
  118. package/src/types.ts +29 -0
  119. package/src/watch-daemon-client.ts +171 -0
  120. package/src/watch-events.ts +195 -0
  121. package/src/watch.ts +66 -0
  122. package/src/workspace-watcher.ts +251 -0
  123. package/src/workspace.ts +55 -0
package/src/test.ts ADDED
@@ -0,0 +1,215 @@
1
+ import path from 'node:path';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+
4
+ import type { ModuleManifest } from '@webstir-io/module-contract';
5
+ import type { TestModule, RunnerSummary, TestRunResult, RuntimeFilter } from '@webstir-io/webstir-testing';
6
+ import type { BuildTargetKind, WorkspaceDescriptor } from './types.ts';
7
+
8
+ import { compileTestModules } from './compile-tests.ts';
9
+ import { loadProvider } from './providers.ts';
10
+ import { createWorkspaceRuntimeEnv } from './runtime.ts';
11
+ import { readWorkspaceDescriptor } from './workspace.ts';
12
+ import {
13
+ applyRuntimeFilter,
14
+ describeRuntimeFilter,
15
+ normalizeRuntimeFilter,
16
+ createDefaultProviderRegistry,
17
+ discoverTestManifest,
18
+ } from '@webstir-io/webstir-testing';
19
+
20
+ export interface RunTestOptions {
21
+ readonly workspaceRoot: string;
22
+ readonly rawArgs: readonly string[];
23
+ readonly env?: Record<string, string | undefined>;
24
+ }
25
+
26
+ export interface TestCommandResult {
27
+ readonly workspace: WorkspaceDescriptor;
28
+ readonly runtime: 'all' | 'frontend' | 'backend';
29
+ readonly builtTargets: readonly BuildTargetKind[];
30
+ readonly summary: RunnerSummary;
31
+ readonly filterMessage?: string;
32
+ readonly hadFailures: boolean;
33
+ }
34
+
35
+ export async function runTest(options: RunTestOptions): Promise<TestCommandResult> {
36
+ const runtime = parseRuntimeFlag(options.rawArgs, options.env);
37
+ const workspace = await readWorkspaceDescriptor(options.workspaceRoot);
38
+ const builtTargets = selectBuildTargets(workspace.mode, runtime);
39
+
40
+ for (const target of builtTargets) {
41
+ const provider = await loadProvider(target);
42
+ const result = await provider.build({
43
+ workspaceRoot: workspace.root,
44
+ env: createWorkspaceRuntimeEnv(workspace.root, target === 'backend' ? 'test' : 'build', options.env),
45
+ incremental: false,
46
+ });
47
+
48
+ if (target === 'backend' && result.manifest.module) {
49
+ await persistBackendManifest(workspace.root, result.manifest.module);
50
+ }
51
+ }
52
+
53
+ const manifest = await discoverTestManifest(workspace.root);
54
+ const filteredManifest = applyRuntimeFilter(manifest, runtime);
55
+ const filterMessage = describeRuntimeFilter(runtime, manifest.modules.length, filteredManifest.modules.length) ?? undefined;
56
+ await compileTestModules(workspace.root, filteredManifest.modules);
57
+ const summary = await executeTestRun(filteredManifest.modules, workspace.root);
58
+
59
+ return {
60
+ workspace,
61
+ runtime: runtime ?? 'all',
62
+ builtTargets,
63
+ summary,
64
+ filterMessage,
65
+ hadFailures: summary.failed > 0,
66
+ };
67
+ }
68
+
69
+ export function formatFailedTests(results: readonly TestRunResult[]): string[] {
70
+ return results
71
+ .filter((result) => !result.passed)
72
+ .map((result) => `${result.file}: ${result.name}${result.message ? ` — ${firstLine(result.message)}` : ''}`);
73
+ }
74
+
75
+ async function executeTestRun(modules: readonly TestModule[], workspaceRoot: string): Promise<RunnerSummary> {
76
+ const registry = createDefaultProviderRegistry();
77
+ const grouped = new Map<TestModule['runtime'], TestModule[]>();
78
+
79
+ for (const module of modules) {
80
+ const list = grouped.get(module.runtime);
81
+ if (list) {
82
+ list.push(module);
83
+ } else {
84
+ grouped.set(module.runtime, [module]);
85
+ }
86
+ }
87
+
88
+ let summary = createEmptySummary();
89
+
90
+ for (const [runtime, runtimeModules] of grouped) {
91
+ const provider = registry.get(runtime);
92
+ if (!provider) {
93
+ continue;
94
+ }
95
+
96
+ const files = runtimeModules
97
+ .map((module) => module.compiledPath)
98
+ .filter((compiledPath): compiledPath is string => typeof compiledPath === 'string');
99
+ const runtimeSummary = await withRuntimeEnv(runtime, workspaceRoot, async () => await provider.runTests(files));
100
+ summary = {
101
+ passed: summary.passed + runtimeSummary.passed,
102
+ failed: summary.failed + runtimeSummary.failed,
103
+ total: summary.total + runtimeSummary.total,
104
+ durationMs: summary.durationMs + runtimeSummary.durationMs,
105
+ results: [...summary.results, ...runtimeSummary.results],
106
+ };
107
+ }
108
+
109
+ return summary;
110
+ }
111
+
112
+ async function withRuntimeEnv<T>(
113
+ runtime: TestModule['runtime'],
114
+ workspaceRoot: string,
115
+ callback: () => Promise<T>
116
+ ): Promise<T> {
117
+ if (runtime !== 'backend') {
118
+ return await callback();
119
+ }
120
+
121
+ const previous = new Map<string, string | undefined>();
122
+ const overrides: Record<string, string> = {
123
+ WEBSTIR_WORKSPACE_ROOT: workspaceRoot,
124
+ WEBSTIR_BACKEND_BUILD_ROOT: path.join(workspaceRoot, 'build', 'backend'),
125
+ WEBSTIR_BACKEND_TEST_ENTRY: path.join(workspaceRoot, 'build', 'backend', 'index.js'),
126
+ WEBSTIR_BACKEND_TEST_MANIFEST: path.join(workspaceRoot, '.webstir', 'backend-manifest.json'),
127
+ };
128
+
129
+ for (const [key, value] of Object.entries(overrides)) {
130
+ previous.set(key, process.env[key]);
131
+ process.env[key] = value;
132
+ }
133
+
134
+ try {
135
+ return await callback();
136
+ } finally {
137
+ for (const [key, value] of previous) {
138
+ if (value === undefined) {
139
+ delete process.env[key];
140
+ } else {
141
+ process.env[key] = value;
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ function createEmptySummary(): RunnerSummary {
148
+ return {
149
+ passed: 0,
150
+ failed: 0,
151
+ total: 0,
152
+ durationMs: 0,
153
+ results: [],
154
+ };
155
+ }
156
+
157
+ function selectBuildTargets(mode: WorkspaceDescriptor['mode'], runtime: RuntimeFilter): BuildTargetKind[] {
158
+ if (runtime === 'frontend') {
159
+ if (mode === 'spa' || mode === 'ssg' || mode === 'full') {
160
+ return ['frontend'];
161
+ }
162
+
163
+ return [];
164
+ }
165
+
166
+ if (runtime === 'backend') {
167
+ if (mode === 'api' || mode === 'full') {
168
+ return ['backend'];
169
+ }
170
+
171
+ return [];
172
+ }
173
+
174
+ if (mode === 'spa' || mode === 'ssg') {
175
+ return ['frontend'];
176
+ }
177
+
178
+ if (mode === 'api') {
179
+ return ['backend'];
180
+ }
181
+
182
+ return ['frontend', 'backend'];
183
+ }
184
+
185
+ function parseRuntimeFlag(
186
+ rawArgs: readonly string[],
187
+ env: Record<string, string | undefined> = process.env
188
+ ): RuntimeFilter {
189
+ for (let index = 0; index < rawArgs.length; index += 1) {
190
+ const arg = rawArgs[index];
191
+ if (arg === '--runtime' || arg === '-r') {
192
+ return normalizeRuntimeFilter(rawArgs[index + 1] ?? null);
193
+ }
194
+
195
+ if (arg.startsWith('--runtime=')) {
196
+ return normalizeRuntimeFilter(arg.slice('--runtime='.length));
197
+ }
198
+ }
199
+
200
+ return normalizeRuntimeFilter(env.WEBSTIR_TEST_RUNTIME);
201
+ }
202
+
203
+ async function persistBackendManifest(workspaceRoot: string, manifest: ModuleManifest): Promise<void> {
204
+ const webstirDir = path.join(workspaceRoot, '.webstir');
205
+ await mkdir(webstirDir, { recursive: true });
206
+ await writeFile(
207
+ path.join(webstirDir, 'backend-manifest.json'),
208
+ `${JSON.stringify(manifest, null, 2)}\n`,
209
+ 'utf8'
210
+ );
211
+ }
212
+
213
+ function firstLine(message: string): string {
214
+ return message.split(/\r?\n/, 1)[0] ?? message;
215
+ }
package/src/types.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type {
2
+ ModuleBuildResult,
3
+ ModuleProvider,
4
+ } from '@webstir-io/module-contract';
5
+
6
+ export const SUPPORTED_WORKSPACE_MODES = ['spa', 'ssg', 'api', 'full'] as const;
7
+
8
+ export type WorkspaceMode = (typeof SUPPORTED_WORKSPACE_MODES)[number];
9
+ export type CommandMode = 'build' | 'publish';
10
+ export type BuildTargetKind = 'frontend' | 'backend';
11
+ export type BuildProvider = Pick<ModuleProvider, 'build' | 'resolveWorkspace'>;
12
+
13
+ export interface WorkspaceDescriptor {
14
+ readonly root: string;
15
+ readonly name: string;
16
+ readonly mode: WorkspaceMode;
17
+ }
18
+
19
+ export interface CommandTargetResult {
20
+ readonly kind: BuildTargetKind;
21
+ readonly outputRoot: string;
22
+ readonly result: ModuleBuildResult;
23
+ }
24
+
25
+ export interface CommandExecutionResult {
26
+ readonly mode: CommandMode;
27
+ readonly workspace: WorkspaceDescriptor;
28
+ readonly targets: readonly CommandTargetResult[];
29
+ }
@@ -0,0 +1,171 @@
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
+ }
@@ -0,0 +1,195 @@
1
+ export const STRUCTURED_DIAGNOSTIC_PREFIX = 'WEBSTIR_DIAGNOSTIC ';
2
+
3
+ export type WatchStatus = 'building' | 'success' | 'error' | 'hmr-fallback';
4
+
5
+ export interface StructuredDiagnosticPayload {
6
+ readonly type: 'diagnostic';
7
+ readonly code: string;
8
+ readonly kind: string;
9
+ readonly stage: string;
10
+ readonly severity: 'info' | 'warning' | 'error';
11
+ readonly message: string;
12
+ readonly data?: Record<string, unknown>;
13
+ }
14
+
15
+ export interface HotUpdateAsset {
16
+ readonly type: 'js' | 'css';
17
+ readonly path: string;
18
+ readonly relativePath: string;
19
+ readonly url: string;
20
+ }
21
+
22
+ export interface HotUpdatePayload {
23
+ readonly requiresReload: boolean;
24
+ readonly modules: readonly HotUpdateAsset[];
25
+ readonly styles: readonly HotUpdateAsset[];
26
+ readonly changedFile?: string;
27
+ readonly fallbackReasons?: readonly string[];
28
+ readonly stats?: {
29
+ readonly hotUpdates: number;
30
+ readonly reloadFallbacks: number;
31
+ };
32
+ }
33
+
34
+ export type WatchAction =
35
+ | { readonly type: 'status'; readonly status: WatchStatus }
36
+ | { readonly type: 'hmr'; readonly payload: HotUpdatePayload }
37
+ | { readonly type: 'reload' };
38
+
39
+ export function parseStructuredDiagnosticLine(line: string): StructuredDiagnosticPayload | null {
40
+ if (!line.startsWith(STRUCTURED_DIAGNOSTIC_PREFIX)) {
41
+ return null;
42
+ }
43
+
44
+ const rawPayload = line.slice(STRUCTURED_DIAGNOSTIC_PREFIX.length);
45
+ let parsed: unknown;
46
+ try {
47
+ parsed = JSON.parse(rawPayload);
48
+ } catch {
49
+ return null;
50
+ }
51
+
52
+ if (!isStructuredDiagnosticPayload(parsed)) {
53
+ return null;
54
+ }
55
+
56
+ return parsed;
57
+ }
58
+
59
+ export function collectWatchActions(payload: StructuredDiagnosticPayload): readonly WatchAction[] {
60
+ if (isBuildStartDiagnostic(payload.code)) {
61
+ return [{ type: 'status', status: 'building' }];
62
+ }
63
+
64
+ if (isBuildFailureDiagnostic(payload.code)) {
65
+ return [{ type: 'status', status: 'error' }];
66
+ }
67
+
68
+ if (payload.code === 'frontend.watch.pipeline.hmrfallback') {
69
+ return [{ type: 'status', status: 'hmr-fallback' }];
70
+ }
71
+
72
+ if (payload.code !== 'frontend.watch.pipeline.success') {
73
+ return [];
74
+ }
75
+
76
+ const hotUpdate = readHotUpdatePayload(payload.data);
77
+ const changedFile = typeof hotUpdate?.changedFile === 'string' ? hotUpdate.changedFile : undefined;
78
+ if (!hotUpdate || !changedFile) {
79
+ return [{ type: 'status', status: 'success' }];
80
+ }
81
+
82
+ if (hotUpdate.requiresReload) {
83
+ return [
84
+ { type: 'status', status: 'hmr-fallback' },
85
+ { type: 'reload' },
86
+ ];
87
+ }
88
+
89
+ if (hotUpdate.modules.length === 0 && hotUpdate.styles.length === 0) {
90
+ return [{ type: 'status', status: 'success' }];
91
+ }
92
+
93
+ return [
94
+ { type: 'hmr', payload: hotUpdate },
95
+ { type: 'status', status: 'success' },
96
+ ];
97
+ }
98
+
99
+ function isBuildStartDiagnostic(code: string): boolean {
100
+ return code === 'frontend.watch.starting' ||
101
+ code === 'frontend.watch.reload' ||
102
+ code.endsWith('.build.start');
103
+ }
104
+
105
+ function isBuildFailureDiagnostic(code: string): boolean {
106
+ return code === 'frontend.watch.unexpected' ||
107
+ code === 'frontend.watch.command.failure' ||
108
+ code.endsWith('.build.failure');
109
+ }
110
+
111
+ function readHotUpdatePayload(data: Record<string, unknown> | undefined): HotUpdatePayload | null {
112
+ if (!data) {
113
+ return null;
114
+ }
115
+
116
+ const candidate = data.hotUpdate;
117
+ if (typeof candidate !== 'object' || candidate === null) {
118
+ return null;
119
+ }
120
+
121
+ const payload = candidate as Record<string, unknown>;
122
+ if (typeof payload.requiresReload !== 'boolean') {
123
+ return null;
124
+ }
125
+
126
+ return {
127
+ requiresReload: payload.requiresReload,
128
+ modules: readAssets(payload.modules),
129
+ styles: readAssets(payload.styles),
130
+ changedFile: typeof payload.changedFile === 'string' ? payload.changedFile : undefined,
131
+ fallbackReasons: Array.isArray(payload.fallbackReasons)
132
+ ? payload.fallbackReasons.filter((value): value is string => typeof value === 'string')
133
+ : undefined,
134
+ stats: readStats(payload.stats),
135
+ };
136
+ }
137
+
138
+ function readAssets(value: unknown): readonly HotUpdateAsset[] {
139
+ if (!Array.isArray(value)) {
140
+ return [];
141
+ }
142
+
143
+ return value.flatMap((candidate) => {
144
+ if (typeof candidate !== 'object' || candidate === null) {
145
+ return [];
146
+ }
147
+
148
+ const asset = candidate as Record<string, unknown>;
149
+ if (
150
+ (asset.type !== 'js' && asset.type !== 'css') ||
151
+ typeof asset.path !== 'string' ||
152
+ typeof asset.relativePath !== 'string' ||
153
+ typeof asset.url !== 'string'
154
+ ) {
155
+ return [];
156
+ }
157
+
158
+ return [{
159
+ type: asset.type,
160
+ path: asset.path,
161
+ relativePath: asset.relativePath,
162
+ url: asset.url,
163
+ }];
164
+ });
165
+ }
166
+
167
+ function readStats(value: unknown): HotUpdatePayload['stats'] | undefined {
168
+ if (typeof value !== 'object' || value === null) {
169
+ return undefined;
170
+ }
171
+
172
+ const stats = value as Record<string, unknown>;
173
+ if (typeof stats.hotUpdates !== 'number' || typeof stats.reloadFallbacks !== 'number') {
174
+ return undefined;
175
+ }
176
+
177
+ return {
178
+ hotUpdates: stats.hotUpdates,
179
+ reloadFallbacks: stats.reloadFallbacks,
180
+ };
181
+ }
182
+
183
+ function isStructuredDiagnosticPayload(value: unknown): value is StructuredDiagnosticPayload {
184
+ if (typeof value !== 'object' || value === null) {
185
+ return false;
186
+ }
187
+
188
+ const payload = value as Record<string, unknown>;
189
+ return payload.type === 'diagnostic' &&
190
+ typeof payload.code === 'string' &&
191
+ typeof payload.kind === 'string' &&
192
+ typeof payload.stage === 'string' &&
193
+ typeof payload.severity === 'string' &&
194
+ typeof payload.message === 'string';
195
+ }
package/src/watch.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { runApiWatch } from './api-watch.ts';
2
+ import { runFrontendWatch } from './frontend-watch.ts';
3
+ import { runFullWatch } from './full-watch.ts';
4
+ import type { WorkspaceDescriptor } from './types.ts';
5
+ import { readWorkspaceDescriptor } from './workspace.ts';
6
+
7
+ interface WatchStream {
8
+ write(message: string): void;
9
+ }
10
+
11
+ export interface WatchIo {
12
+ readonly stdout: WatchStream;
13
+ readonly stderr: WatchStream;
14
+ }
15
+
16
+ export interface WatchOptions {
17
+ readonly host?: string;
18
+ readonly port?: number;
19
+ readonly verbose?: boolean;
20
+ readonly hmrVerbose?: boolean;
21
+ readonly env?: Record<string, string | undefined>;
22
+ }
23
+
24
+ export interface RunWatchOptions extends WatchOptions {
25
+ readonly workspaceRoot: string;
26
+ readonly io?: WatchIo;
27
+ }
28
+
29
+ const defaultIo: WatchIo = {
30
+ stdout: {
31
+ write(message) {
32
+ process.stdout.write(message);
33
+ },
34
+ },
35
+ stderr: {
36
+ write(message) {
37
+ process.stderr.write(message);
38
+ },
39
+ },
40
+ };
41
+
42
+ export async function runWatch(options: RunWatchOptions): Promise<void> {
43
+ const io = options.io ?? defaultIo;
44
+ const workspace = await readWorkspaceDescriptor(options.workspaceRoot);
45
+
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);
59
+ }
60
+ }
61
+
62
+ function throwUnsupportedWatchMode(workspace: WorkspaceDescriptor): never {
63
+ throw new Error(
64
+ `Watch currently supports spa, ssg, api, and full workspaces only. "${workspace.name}" is ${workspace.mode}.`
65
+ );
66
+ }