@webstir-io/webstir 0.1.1 → 0.1.2

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 (77) 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 +99 -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 +214 -143
  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 +28 -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 +5 -1
  75. package/src/workspace-watcher.ts +10 -6
  76. package/src/workspace.ts +4 -2
  77. package/src/watch-daemon-client.ts +0 -171
@@ -0,0 +1,29 @@
1
+ import { startBunGeneratedFrontendWatch } from './bun-generated-frontend-watch.ts';
2
+ import type { DevServerAddress } from './dev-server.ts';
3
+
4
+ export interface BunSpaFrontendWatchOptions {
5
+ readonly workspaceRoot: string;
6
+ readonly host?: string;
7
+ readonly port?: number;
8
+ }
9
+
10
+ export interface BunSpaFrontendWatchSession {
11
+ readonly address: DevServerAddress;
12
+ waitForExit(): Promise<number | null>;
13
+ stop(): Promise<void>;
14
+ }
15
+
16
+ /**
17
+ * Bun-first SPA watch assumptions:
18
+ * - The workspace exposes a `src/frontend/app/app.html` shell and at least one
19
+ * page fragment under `src/frontend/pages`.
20
+ * - Bun owns JavaScript/CSS dev serving and HMR through a generated full HTML
21
+ * entry document.
22
+ * - HTML fragment edits regenerate the Bun entry document and trigger Bun's own
23
+ * route-level reload behavior instead of the legacy daemon protocol.
24
+ */
25
+ export async function startBunSpaFrontendWatch(
26
+ options: BunSpaFrontendWatchOptions,
27
+ ): Promise<BunSpaFrontendWatchSession> {
28
+ return await startBunGeneratedFrontendWatch(options);
29
+ }
@@ -0,0 +1,304 @@
1
+ import path from 'node:path';
2
+
3
+ import { DevServer, type DevServerAddress } from './dev-server.ts';
4
+ import { ensureLocalPackageArtifacts } from './providers.ts';
5
+ import { WorkspaceWatcher } from './workspace-watcher.ts';
6
+ import type { HotUpdateAsset, HotUpdatePayload, HotUpdateTarget } from './watch-events.ts';
7
+
8
+ export interface BunSsgFrontendWatchOptions {
9
+ readonly workspaceRoot: string;
10
+ readonly host?: string;
11
+ readonly port?: number;
12
+ }
13
+
14
+ export interface BunSsgFrontendWatchSession {
15
+ readonly address: DevServerAddress;
16
+ waitForExit(): Promise<number | null>;
17
+ stop(): Promise<void>;
18
+ }
19
+
20
+ interface FrontendOperationsModule {
21
+ runBuild(options: {
22
+ readonly workspaceRoot: string;
23
+ readonly changedFile?: string;
24
+ }): Promise<void>;
25
+ runRebuild(options: {
26
+ readonly workspaceRoot: string;
27
+ readonly changedFile?: string;
28
+ }): Promise<void>;
29
+ }
30
+
31
+ export async function startBunSsgFrontendWatch(
32
+ options: BunSsgFrontendWatchOptions,
33
+ ): Promise<BunSsgFrontendWatchSession> {
34
+ const workspaceRoot = path.resolve(options.workspaceRoot);
35
+ const frontendSourceRoot = path.join(workspaceRoot, 'src', 'frontend');
36
+ const buildRoot = path.join(workspaceRoot, 'build', 'frontend');
37
+ const operations = await loadFrontendOperations();
38
+
39
+ await operations.runBuild({ workspaceRoot });
40
+
41
+ const server = new DevServer({
42
+ buildRoot,
43
+ host: options.host,
44
+ port: options.port,
45
+ });
46
+ const address = await server.start();
47
+
48
+ let stopping = false;
49
+ let stopPromise: Promise<void> | null = null;
50
+ let exitResolver: ((code: number | null) => void) | undefined;
51
+ const exitPromise = new Promise<number | null>((resolve) => {
52
+ exitResolver = resolve;
53
+ });
54
+
55
+ let queue: Promise<void> = Promise.resolve();
56
+ const enqueue = (task: () => Promise<void>): Promise<void> => {
57
+ const runTask = async () => {
58
+ if (stopping) {
59
+ return;
60
+ }
61
+
62
+ try {
63
+ await task();
64
+ } catch (error) {
65
+ await reportBuildFailure(server, error);
66
+ }
67
+ };
68
+
69
+ queue = queue.then(runTask, runTask);
70
+ return queue;
71
+ };
72
+
73
+ const watcher = new WorkspaceWatcher({
74
+ workspaceRoot,
75
+ onEvent(event) {
76
+ void enqueue(async () => {
77
+ await server.publishStatus('building');
78
+
79
+ let hotUpdate: HotUpdatePayload | null = null;
80
+ const changedPath =
81
+ event.type === 'change' || event.type === 'reload' ? event.path : undefined;
82
+ if (event.type === 'change') {
83
+ await operations.runRebuild({
84
+ workspaceRoot,
85
+ changedFile: event.path,
86
+ });
87
+ } else {
88
+ await operations.runBuild({ workspaceRoot });
89
+ }
90
+
91
+ if (changedPath) {
92
+ hotUpdate = createHotUpdatePayload({
93
+ workspaceRoot,
94
+ frontendSourceRoot,
95
+ buildRoot,
96
+ changedFile: changedPath,
97
+ });
98
+ }
99
+
100
+ if (hotUpdate) {
101
+ await server.publishHotUpdate(hotUpdate);
102
+ await server.publishStatus('success');
103
+ return;
104
+ }
105
+
106
+ await server.publishStatus('hmr-fallback');
107
+ await server.publishReload();
108
+ });
109
+ },
110
+ });
111
+
112
+ try {
113
+ await watcher.start();
114
+ } catch (error) {
115
+ stopping = true;
116
+ await queue.catch(() => undefined);
117
+ await server.stop();
118
+ exitResolver?.(1);
119
+ exitResolver = undefined;
120
+ throw error;
121
+ }
122
+
123
+ return {
124
+ address,
125
+ waitForExit() {
126
+ return exitPromise;
127
+ },
128
+ async stop() {
129
+ if (stopPromise) {
130
+ await stopPromise;
131
+ return;
132
+ }
133
+
134
+ stopPromise = (async () => {
135
+ stopping = true;
136
+ await watcher.stop();
137
+ await queue.catch(() => undefined);
138
+ await server.stop();
139
+ exitResolver?.(0);
140
+ exitResolver = undefined;
141
+ await exitPromise;
142
+ })();
143
+
144
+ await stopPromise;
145
+ },
146
+ };
147
+ }
148
+
149
+ async function loadFrontendOperations(): Promise<FrontendOperationsModule> {
150
+ await ensureLocalPackageArtifacts();
151
+ return (await import('@webstir-io/webstir-frontend')) as FrontendOperationsModule;
152
+ }
153
+
154
+ async function reportBuildFailure(server: DevServer, error: unknown): Promise<void> {
155
+ await server.publishStatus('error');
156
+ const message = error instanceof Error ? error.message : String(error);
157
+ console.error(`[webstir] frontend rebuild failed: ${message}`);
158
+ }
159
+
160
+ function createHotUpdatePayload(options: {
161
+ readonly workspaceRoot: string;
162
+ readonly frontendSourceRoot: string;
163
+ readonly buildRoot: string;
164
+ readonly changedFile: string;
165
+ }): HotUpdatePayload | null {
166
+ const changedFile = path.resolve(options.changedFile);
167
+ if (!isWithinDirectory(changedFile, options.frontendSourceRoot)) {
168
+ return null;
169
+ }
170
+
171
+ const relativeToFrontend = path.relative(options.frontendSourceRoot, changedFile);
172
+ const relativeParts = splitPathSegments(relativeToFrontend);
173
+ if (relativeParts.length === 0) {
174
+ return null;
175
+ }
176
+
177
+ if (relativeParts[0] === 'app' && isCssFile(changedFile)) {
178
+ return createCssHotUpdate({
179
+ buildRoot: options.buildRoot,
180
+ changedFile: normalizeForwardSlashes(path.relative(options.workspaceRoot, changedFile)),
181
+ assetRelativePath: path.posix.join('app', 'app.css'),
182
+ });
183
+ }
184
+
185
+ if (relativeParts[0] === 'pages' && relativeParts.length >= 3 && isCssFile(changedFile)) {
186
+ const pageName = relativeParts[1];
187
+ if (!pageName) {
188
+ return null;
189
+ }
190
+
191
+ return createCssHotUpdate({
192
+ buildRoot: options.buildRoot,
193
+ changedFile: normalizeForwardSlashes(path.relative(options.workspaceRoot, changedFile)),
194
+ assetRelativePath: path.posix.join('pages', pageName, 'index.css'),
195
+ });
196
+ }
197
+
198
+ if (
199
+ relativeParts[0] === 'pages' &&
200
+ relativeParts[1] === 'docs' &&
201
+ typeof relativeParts[2] === 'string' &&
202
+ relativeParts[2].startsWith('index.') &&
203
+ isJavaScriptFile(changedFile)
204
+ ) {
205
+ console.info(
206
+ `[webstir] docs sidebar hot update detected: ${normalizeForwardSlashes(path.relative(options.workspaceRoot, changedFile))}`,
207
+ );
208
+ return createJsHotUpdate({
209
+ buildRoot: options.buildRoot,
210
+ changedFile: normalizeForwardSlashes(path.relative(options.workspaceRoot, changedFile)),
211
+ assetRelativePath: path.posix.join('pages', 'docs', 'index.js'),
212
+ target: {
213
+ kind: 'boundary',
214
+ id: 'docs-sidebar',
215
+ },
216
+ });
217
+ }
218
+
219
+ if (
220
+ relativeParts[0] === 'content' &&
221
+ relativeParts[relativeParts.length - 1] === '_sidebar.json'
222
+ ) {
223
+ console.info(
224
+ `[webstir] docs sidebar manifest hot update detected: ${normalizeForwardSlashes(path.relative(options.workspaceRoot, changedFile))}`,
225
+ );
226
+ return createJsHotUpdate({
227
+ buildRoot: options.buildRoot,
228
+ changedFile: normalizeForwardSlashes(path.relative(options.workspaceRoot, changedFile)),
229
+ assetRelativePath: path.posix.join('pages', 'docs', 'index.js'),
230
+ target: {
231
+ kind: 'boundary',
232
+ id: 'docs-sidebar',
233
+ },
234
+ });
235
+ }
236
+
237
+ return null;
238
+ }
239
+
240
+ function createCssHotUpdate(options: {
241
+ readonly buildRoot: string;
242
+ readonly changedFile: string;
243
+ readonly assetRelativePath: string;
244
+ }): HotUpdatePayload {
245
+ const asset = createHotUpdateAsset(options.buildRoot, options.assetRelativePath, 'css');
246
+ return {
247
+ requiresReload: false,
248
+ modules: [],
249
+ styles: [asset],
250
+ changedFile: options.changedFile,
251
+ };
252
+ }
253
+
254
+ function createJsHotUpdate(options: {
255
+ readonly buildRoot: string;
256
+ readonly changedFile: string;
257
+ readonly assetRelativePath: string;
258
+ readonly target?: HotUpdateTarget;
259
+ }): HotUpdatePayload {
260
+ const asset = createHotUpdateAsset(options.buildRoot, options.assetRelativePath, 'js');
261
+ return {
262
+ requiresReload: false,
263
+ modules: [asset],
264
+ styles: [],
265
+ target: options.target,
266
+ changedFile: options.changedFile,
267
+ };
268
+ }
269
+
270
+ function createHotUpdateAsset(
271
+ buildRoot: string,
272
+ assetRelativePath: string,
273
+ type: HotUpdateAsset['type'],
274
+ ): HotUpdateAsset {
275
+ const normalizedRelativePath = normalizeForwardSlashes(assetRelativePath);
276
+ return {
277
+ type,
278
+ path: path.join(buildRoot, normalizedRelativePath),
279
+ relativePath: normalizedRelativePath,
280
+ url: `/${normalizedRelativePath}`,
281
+ };
282
+ }
283
+
284
+ function isCssFile(filePath: string): boolean {
285
+ return path.extname(filePath).toLowerCase() === '.css';
286
+ }
287
+
288
+ function isJavaScriptFile(filePath: string): boolean {
289
+ const extension = path.extname(filePath).toLowerCase();
290
+ return extension === '.js' || extension === '.jsx' || extension === '.ts' || extension === '.tsx';
291
+ }
292
+
293
+ function isWithinDirectory(filePath: string, directory: string): boolean {
294
+ const relative = path.relative(directory, filePath);
295
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
296
+ }
297
+
298
+ function splitPathSegments(value: string): readonly string[] {
299
+ return normalizeForwardSlashes(value).split('/').filter(Boolean);
300
+ }
301
+
302
+ function normalizeForwardSlashes(value: string): string {
303
+ return value.split(path.sep).join('/');
304
+ }