@webstir-io/webstir-backend 0.1.15 → 0.1.16

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 +106 -79
  2. package/dist/add.d.ts +59 -0
  3. package/dist/add.js +626 -0
  4. package/dist/build/artifacts.d.ts +115 -1
  5. package/dist/build/artifacts.js +4 -4
  6. package/dist/build/entries.js +1 -1
  7. package/dist/build/pipeline.d.ts +33 -1
  8. package/dist/build/pipeline.js +307 -65
  9. package/dist/cache/diff.js +9 -8
  10. package/dist/cache/reporters.js +1 -1
  11. package/dist/deploy-cli.d.ts +2 -0
  12. package/dist/deploy-cli.js +86 -0
  13. package/dist/diagnostics/summary.js +2 -2
  14. package/dist/index.d.ts +6 -0
  15. package/dist/index.js +4 -0
  16. package/dist/manifest/pipeline.js +103 -32
  17. package/dist/provider.js +35 -17
  18. package/dist/runtime/bun.d.ts +51 -0
  19. package/dist/runtime/bun.js +499 -0
  20. package/dist/runtime/core.d.ts +141 -0
  21. package/dist/runtime/core.js +316 -0
  22. package/dist/runtime/deploy-backend.d.ts +20 -0
  23. package/dist/runtime/deploy-backend.js +175 -0
  24. package/dist/runtime/deploy-shared.d.ts +43 -0
  25. package/dist/runtime/deploy-shared.js +75 -0
  26. package/dist/runtime/deploy-static.d.ts +2 -0
  27. package/dist/runtime/deploy-static.js +161 -0
  28. package/dist/runtime/deploy.d.ts +3 -0
  29. package/dist/runtime/deploy.js +91 -0
  30. package/dist/runtime/forms.d.ts +73 -0
  31. package/dist/runtime/forms.js +236 -0
  32. package/dist/runtime/request-hooks.d.ts +47 -0
  33. package/dist/runtime/request-hooks.js +102 -0
  34. package/dist/runtime/session-metadata.d.ts +13 -0
  35. package/dist/runtime/session-metadata.js +98 -0
  36. package/dist/runtime/session-runtime.d.ts +28 -0
  37. package/dist/runtime/session-runtime.js +180 -0
  38. package/dist/runtime/session.d.ts +83 -0
  39. package/dist/runtime/session.js +396 -0
  40. package/dist/runtime/views.d.ts +74 -0
  41. package/dist/runtime/views.js +221 -0
  42. package/dist/scaffold/assets.js +25 -21
  43. package/dist/testing/context.js +1 -1
  44. package/dist/testing/index.d.ts +1 -1
  45. package/dist/testing/index.js +100 -56
  46. package/dist/utils/bun.d.ts +2 -0
  47. package/dist/utils/bun.js +13 -0
  48. package/dist/watch.d.ts +13 -1
  49. package/dist/watch.js +345 -97
  50. package/dist/workspace.d.ts +8 -0
  51. package/dist/workspace.js +44 -3
  52. package/package.json +49 -14
  53. package/scripts/publish.sh +2 -92
  54. package/scripts/smoke.mjs +282 -107
  55. package/scripts/update-contract.sh +12 -10
  56. package/src/add.ts +964 -0
  57. package/src/build/artifacts.ts +49 -46
  58. package/src/build/entries.ts +12 -12
  59. package/src/build/pipeline.ts +779 -403
  60. package/src/cache/diff.ts +111 -105
  61. package/src/cache/reporters.ts +26 -26
  62. package/src/deploy-cli.ts +111 -0
  63. package/src/diagnostics/summary.ts +28 -22
  64. package/src/index.ts +11 -0
  65. package/src/manifest/pipeline.ts +328 -215
  66. package/src/provider.ts +115 -98
  67. package/src/runtime/bun.ts +793 -0
  68. package/src/runtime/core.ts +598 -0
  69. package/src/runtime/deploy-backend.ts +239 -0
  70. package/src/runtime/deploy-shared.ts +136 -0
  71. package/src/runtime/deploy-static.ts +191 -0
  72. package/src/runtime/deploy.ts +143 -0
  73. package/src/runtime/forms.ts +364 -0
  74. package/src/runtime/request-hooks.ts +165 -0
  75. package/src/runtime/session-metadata.ts +135 -0
  76. package/src/runtime/session-runtime.ts +267 -0
  77. package/src/runtime/session.ts +642 -0
  78. package/src/runtime/views.ts +385 -0
  79. package/src/scaffold/assets.ts +77 -73
  80. package/src/testing/context.js +8 -9
  81. package/src/testing/context.ts +9 -9
  82. package/src/testing/index.d.ts +14 -3
  83. package/src/testing/index.js +254 -175
  84. package/src/testing/index.ts +298 -195
  85. package/src/testing/types.d.ts +18 -19
  86. package/src/testing/types.ts +18 -18
  87. package/src/utils/bun.ts +26 -0
  88. package/src/watch.ts +503 -99
  89. package/src/workspace.ts +59 -3
  90. package/templates/backend/.env.example +15 -0
  91. package/templates/backend/auth/adapter.ts +335 -36
  92. package/templates/backend/db/connection.ts +190 -65
  93. package/templates/backend/db/migrate.ts +149 -43
  94. package/templates/backend/db/types.d.ts +1 -1
  95. package/templates/backend/env.ts +132 -20
  96. package/templates/backend/functions/hello/index.ts +1 -2
  97. package/templates/backend/index.ts +15 -508
  98. package/templates/backend/jobs/nightly/index.ts +1 -1
  99. package/templates/backend/jobs/runtime.ts +24 -11
  100. package/templates/backend/jobs/scheduler.ts +208 -46
  101. package/templates/backend/module.ts +227 -13
  102. package/templates/backend/observability/logger.ts +2 -12
  103. package/templates/backend/observability/metrics.ts +8 -5
  104. package/templates/backend/session/sqlite.ts +152 -0
  105. package/templates/backend/session/store.ts +45 -0
  106. package/templates/backend/tsconfig.json +1 -1
  107. package/tests/add.test.js +327 -0
  108. package/tests/authAdapter.test.js +315 -0
  109. package/tests/bundlerParity.test.js +217 -0
  110. package/tests/cacheReporter.test.js +10 -10
  111. package/tests/dbConnection.test.js +209 -0
  112. package/tests/deploy.test.js +357 -0
  113. package/tests/envLoader.test.js +271 -17
  114. package/tests/integration.test.js +2432 -3
  115. package/tests/jobsScheduler.test.js +253 -0
  116. package/tests/manifest.test.js +287 -12
  117. package/tests/migrationRunner.test.js +249 -0
  118. package/tests/sessionScaffoldStore.test.js +752 -0
  119. package/tests/sessionStore.test.js +490 -0
  120. package/tests/testing.test.js +252 -0
  121. package/tests/watch.test.js +192 -32
  122. package/tsconfig.json +3 -10
  123. package/templates/backend/server/fastify.ts +0 -288
@@ -0,0 +1,26 @@
1
+ type BunFileLike = {
2
+ text(): Promise<string>;
3
+ arrayBuffer(): Promise<ArrayBuffer>;
4
+ };
5
+
6
+ interface BunLike {
7
+ file(path: string): BunFileLike;
8
+ write(path: string, data: string | ArrayBufferView | Blob | BunFileLike): Promise<number>;
9
+ }
10
+
11
+ function getBunRuntime(): BunLike {
12
+ const runtime = globalThis as typeof globalThis & { Bun?: BunLike };
13
+ if (typeof runtime.Bun?.file === 'function' && typeof runtime.Bun?.write === 'function') {
14
+ return runtime.Bun;
15
+ }
16
+
17
+ throw new Error('[webstir-backend] Bun runtime is required for package-level IO.');
18
+ }
19
+
20
+ export async function readTextFile(filePath: string): Promise<string> {
21
+ return await getBunRuntime().file(filePath).text();
22
+ }
23
+
24
+ export async function writeTextFile(filePath: string, contents: string): Promise<void> {
25
+ await getBunRuntime().write(filePath, contents);
26
+ }
package/src/watch.ts CHANGED
@@ -1,40 +1,95 @@
1
1
  import path from 'node:path';
2
2
  import { existsSync } from 'node:fs';
3
+ import { mkdir, rm, stat } from 'node:fs/promises';
3
4
  import { spawn, type ChildProcess } from 'node:child_process';
4
5
  import { performance } from 'node:perf_hooks';
5
- import { context as createEsbuildContext, type BuildContext, type BuildResult, type Plugin } from 'esbuild';
6
+
7
+ import { glob } from 'glob';
6
8
 
7
9
  import type { ModuleDiagnostic } from '@webstir-io/module-contract';
8
10
 
9
- import { collectOutputSizes, formatEsbuildMessage, shouldTypeCheck } from './build/pipeline.js';
11
+ import {
12
+ ensureModuleDefinitionBuild,
13
+ formatEsbuildMessage,
14
+ shouldTypeCheck,
15
+ } from './build/pipeline.js';
10
16
  import { discoverEntryPoints } from './build/entries.js';
11
17
  import { loadBackendModuleManifest } from './manifest/pipeline.js';
12
18
  import { createCacheReporter } from './cache/reporters.js';
13
- import { normalizeMode, resolveWorkspacePaths } from './workspace.js';
19
+ import { normalizeMode, resolveWorkspacePaths, resolveWorkspaceRoot } from './workspace.js';
14
20
 
15
21
  export interface WatchHandle {
16
22
  stop(): Promise<void>;
17
23
  }
18
24
 
25
+ export interface BackendWatchEvent {
26
+ readonly type: 'build-start' | 'build-complete';
27
+ readonly succeeded?: boolean;
28
+ readonly errorCount?: number;
29
+ readonly warningCount?: number;
30
+ readonly durationMs?: number;
31
+ readonly bunBenchmarkSucceeded?: boolean;
32
+ readonly bunBenchmarkErrorCount?: number;
33
+ readonly bunBenchmarkWarningCount?: number;
34
+ readonly bunBenchmarkDurationMs?: number;
35
+ }
36
+
19
37
  export interface StartWatchOptions {
20
- readonly workspaceRoot: string;
38
+ readonly workspaceRoot?: string;
21
39
  readonly env?: Record<string, string | undefined>;
40
+ readonly onEvent?: (event: BackendWatchEvent) => void | Promise<void>;
41
+ }
42
+
43
+ interface BunBuildOutputFile {
44
+ readonly path: string;
45
+ readonly size?: number;
46
+ }
47
+
48
+ interface BunBuildLog {
49
+ readonly level?: string;
50
+ readonly message?: string;
51
+ readonly text?: string;
52
+ readonly position?: {
53
+ readonly file?: string;
54
+ readonly line?: number;
55
+ readonly column?: number;
56
+ } | null;
57
+ }
58
+
59
+ interface BunBuildOutput {
60
+ readonly success: boolean;
61
+ readonly outputs?: readonly BunBuildOutputFile[];
62
+ readonly logs?: readonly BunBuildLog[];
22
63
  }
23
64
 
65
+ type BunBuildFunction = (config: Record<string, unknown>) => Promise<BunBuildOutput>;
66
+
67
+ interface WatchBuildResult {
68
+ readonly succeeded: boolean;
69
+ readonly errorCount: number;
70
+ readonly warningCount: number;
71
+ }
72
+
73
+ const WATCH_POLL_INTERVAL_MS = 250;
74
+
24
75
  export async function startBackendWatch(options: StartWatchOptions): Promise<WatchHandle> {
25
- const { workspaceRoot } = options;
26
- const env = options.env ?? {};
76
+ const env = { ...process.env, ...(options.env ?? {}) };
77
+ const workspaceRoot = resolveWorkspaceRoot({
78
+ workspaceRoot: options.workspaceRoot,
79
+ env,
80
+ });
27
81
  const paths = resolveWorkspacePaths(workspaceRoot);
28
82
  const tsconfigPath = path.join(paths.sourceRoot, 'tsconfig.json');
29
83
  const mode = normalizeMode(env.WEBSTIR_MODULE_MODE);
30
84
 
31
- const entryPoints = await discoverEntryPoints(paths.sourceRoot);
32
- if (entryPoints.length === 0) {
85
+ const initialEntryPoints = await discoverEntryPoints(paths.sourceRoot);
86
+ if (initialEntryPoints.length === 0) {
33
87
  console.warn(`[webstir-backend] watch: no entry found under ${paths.sourceRoot} (index.ts/js)`);
34
88
  throw new Error('No backend entry point found.');
35
89
  }
36
90
 
37
91
  const nodeEnv = env.NODE_ENV ?? (mode === 'publish' ? 'production' : 'development');
92
+ const shouldReportBunBenchmark = isEnabled(env.WEBSTIR_BACKEND_WATCH_BUN_BENCHMARK);
38
93
  const diagMax = (() => {
39
94
  const raw = env.WEBSTIR_BACKEND_DIAG_MAX;
40
95
  const n = typeof raw === 'string' ? parseInt(raw, 10) : NaN;
@@ -43,14 +98,12 @@ export async function startBackendWatch(options: StartWatchOptions): Promise<Wat
43
98
 
44
99
  console.info(`[webstir-backend] watch:start (${mode})`);
45
100
 
46
- // Start type-checker in watch mode (no emit) unless explicitly skipped for DX.
47
- const shouldRunTypecheck = shouldTypeCheck(mode, env);
48
101
  let tscProc: ChildProcess | undefined;
49
- if (shouldRunTypecheck) {
102
+ if (shouldTypeCheck(mode, env)) {
50
103
  const tscArgs = ['-p', tsconfigPath, '--noEmit', '--watch'];
51
104
  tscProc = spawn('tsc', tscArgs, {
52
105
  stdio: ['ignore', 'pipe', 'pipe'],
53
- env: { ...process.env, ...env, NODE_ENV: nodeEnv },
106
+ env: { ...env, NODE_ENV: nodeEnv },
54
107
  cwd: workspaceRoot,
55
108
  });
56
109
 
@@ -70,101 +123,95 @@ export async function startBackendWatch(options: StartWatchOptions): Promise<Wat
70
123
  console.info('[webstir-backend] watch: type-check skipped by WEBSTIR_BACKEND_TYPECHECK');
71
124
  }
72
125
 
73
- const timingPlugin: Plugin = {
74
- name: 'webstir-watch-logger',
75
- setup(build) {
76
- let start = 0;
77
- build.onStart(() => {
78
- start = performance.now();
79
- });
80
- build.onEnd(async (result: BuildResult) => {
81
- const end = performance.now();
82
- const warnCount = result.warnings?.length ?? 0;
83
- // errors is not in the typed result, but present at runtime
84
- const errorList = (result as any).errors ?? [];
85
- const errorCount = Array.isArray(errorList) ? errorList.length : 0;
86
- // Print detailed diagnostics with file:line when available (capped for readability)
87
- if (errorCount > 0) {
88
- for (const msg of errorList.slice(0, diagMax)) {
89
- const text = formatEsbuildMessage(msg);
90
- console.error(`[webstir-backend][esbuild] ${text}`);
91
- }
92
- if (errorCount > diagMax) {
93
- console.error(`[webstir-backend][esbuild] ... ${errorCount - diagMax} more error(s) omitted`);
94
- }
95
- }
96
- if (warnCount > 0) {
97
- for (const msg of result.warnings.slice(0, diagMax)) {
98
- const text = formatEsbuildMessage(msg as any);
99
- console.warn(`[webstir-backend][esbuild] ${text}`);
100
- }
101
- if (warnCount > diagMax) {
102
- console.warn(`[webstir-backend][esbuild] ... ${warnCount - diagMax} more warning(s) omitted`);
103
- }
104
- }
105
- console.info(`[webstir-backend] watch:esbuild ${errorCount} error(s), ${warnCount} warning(s) in ${(end - start).toFixed(1)}ms`);
106
-
107
- if (errorCount === 0) {
108
- const diagBuffer: ModuleDiagnostic[] = [];
109
- const cacheReporter = createCacheReporter({
110
- workspaceRoot,
111
- buildRoot: paths.buildRoot,
112
- env,
113
- diagnostics: diagBuffer
114
- });
115
- try {
116
- const metafile: any = (result as any).metafile;
117
- if (metafile && metafile.outputs) {
118
- const outputs = collectOutputSizes(metafile, paths.buildRoot);
119
- await cacheReporter.diffOutputs(outputs, mode);
120
- }
121
- const manifest = await loadBackendModuleManifest({
122
- workspaceRoot,
123
- buildRoot: paths.buildRoot,
124
- entryPoints,
125
- diagnostics: diagBuffer
126
- });
127
- await cacheReporter.diffManifest(manifest);
128
- } catch {
129
- // cache or manifest diff failure should not break watch
130
- } finally {
131
- for (const diag of diagBuffer) {
132
- const logger =
133
- diag.severity === 'error' ? console.error : diag.severity === 'warn' ? console.warn : console.info;
134
- logger(diag.message);
135
- }
136
- }
126
+ if (shouldReportBunBenchmark) {
127
+ console.info(
128
+ '[webstir-backend] watch: reporting primary Bun build timings via bunBenchmark* event fields.',
129
+ );
130
+ }
131
+
132
+ let stopping = false;
133
+ let watchTimer: ReturnType<typeof setTimeout> | undefined;
134
+ let currentSnapshot = await takeWatchSnapshot(workspaceRoot, paths.sourceRoot, tsconfigPath);
135
+ let buildInFlight = false;
136
+ let pendingBuild = false;
137
+ let buildFailure: Error | undefined;
138
+
139
+ const runBuild = async (): Promise<void> => {
140
+ if (stopping) {
141
+ return;
142
+ }
143
+
144
+ if (buildInFlight) {
145
+ pendingBuild = true;
146
+ return;
147
+ }
148
+
149
+ buildInFlight = true;
150
+ try {
151
+ do {
152
+ pendingBuild = false;
153
+ const nextSnapshot = await takeWatchSnapshot(workspaceRoot, paths.sourceRoot, tsconfigPath);
154
+ currentSnapshot = nextSnapshot;
155
+ const result = await performWatchBuild({
156
+ workspaceRoot,
157
+ sourceRoot: paths.sourceRoot,
158
+ buildRoot: paths.buildRoot,
159
+ tsconfigPath,
160
+ mode,
161
+ env,
162
+ nodeEnv,
163
+ diagMax,
164
+ shouldReportBunBenchmark,
165
+ onEvent: options.onEvent,
166
+ });
167
+
168
+ if (!result.succeeded) {
169
+ buildFailure = new Error('Backend watch build failed.');
170
+ } else {
171
+ buildFailure = undefined;
137
172
  }
138
- });
139
- },
173
+ } while (pendingBuild && !stopping);
174
+ } finally {
175
+ buildInFlight = false;
176
+ }
140
177
  };
141
178
 
142
- const ctx: BuildContext = await createEsbuildContext({
143
- entryPoints,
144
- bundle: false,
145
- platform: 'node',
146
- target: 'node20',
147
- format: 'esm',
148
- sourcemap: true,
149
- outdir: paths.buildRoot,
150
- outbase: paths.sourceRoot,
151
- metafile: true,
152
- tsconfig: existsSync(tsconfigPath) ? tsconfigPath : undefined,
153
- define: { 'process.env.NODE_ENV': JSON.stringify(nodeEnv) },
154
- logLevel: 'silent',
155
- plugins: [timingPlugin],
156
- });
157
-
158
- await ctx.watch();
179
+ await runBuild();
159
180
 
160
181
  console.info('[webstir-backend] watch:ready');
161
182
 
183
+ const poll = async (): Promise<void> => {
184
+ if (stopping) {
185
+ return;
186
+ }
187
+
188
+ try {
189
+ const nextSnapshot = await takeWatchSnapshot(workspaceRoot, paths.sourceRoot, tsconfigPath);
190
+ if (nextSnapshot !== currentSnapshot) {
191
+ currentSnapshot = nextSnapshot;
192
+ await runBuild();
193
+ }
194
+ } catch (error) {
195
+ const message = error instanceof Error ? error.message : String(error);
196
+ console.warn(`[webstir-backend] watch:poll failed: ${message}`);
197
+ } finally {
198
+ if (!stopping) {
199
+ watchTimer = setTimeout(() => {
200
+ void poll();
201
+ }, WATCH_POLL_INTERVAL_MS);
202
+ }
203
+ }
204
+ };
205
+
206
+ watchTimer = setTimeout(() => {
207
+ void poll();
208
+ }, WATCH_POLL_INTERVAL_MS);
209
+
162
210
  return {
163
211
  async stop() {
164
- try {
165
- await ctx.dispose();
166
- } catch {
167
- // ignore
212
+ stopping = true;
213
+ if (watchTimer) {
214
+ clearTimeout(watchTimer);
168
215
  }
169
216
  try {
170
217
  tscProc?.kill('SIGINT');
@@ -172,6 +219,363 @@ export async function startBackendWatch(options: StartWatchOptions): Promise<Wat
172
219
  // ignore
173
220
  }
174
221
  console.info('[webstir-backend] watch:stopped');
222
+
223
+ if (buildFailure) {
224
+ buildFailure = undefined;
225
+ }
226
+ },
227
+ };
228
+ }
229
+
230
+ interface PerformWatchBuildOptions {
231
+ readonly workspaceRoot: string;
232
+ readonly sourceRoot: string;
233
+ readonly buildRoot: string;
234
+ readonly tsconfigPath: string;
235
+ readonly mode: ReturnType<typeof normalizeMode>;
236
+ readonly env: Record<string, string | undefined>;
237
+ readonly nodeEnv: string;
238
+ readonly diagMax: number;
239
+ readonly shouldReportBunBenchmark: boolean;
240
+ readonly onEvent?: StartWatchOptions['onEvent'];
241
+ }
242
+
243
+ async function performWatchBuild(options: PerformWatchBuildOptions): Promise<WatchBuildResult> {
244
+ const start = performance.now();
245
+ await emitWatchEvent(options.onEvent, {
246
+ type: 'build-start',
247
+ });
248
+
249
+ const diagnostics: ModuleDiagnostic[] = [];
250
+ const entryPoints = await discoverEntryPoints(options.sourceRoot);
251
+ if (entryPoints.length === 0) {
252
+ diagnostics.push({
253
+ severity: 'error',
254
+ message: `No backend entry points found under ${options.sourceRoot}.`,
255
+ });
256
+ flushDiagnostics(diagnostics);
257
+ const end = performance.now();
258
+ await emitWatchEvent(options.onEvent, {
259
+ type: 'build-complete',
260
+ succeeded: false,
261
+ errorCount: 1,
262
+ warningCount: 0,
263
+ durationMs: end - start,
264
+ });
265
+ return {
266
+ succeeded: false,
267
+ errorCount: 1,
268
+ warningCount: 0,
269
+ };
270
+ }
271
+
272
+ const buildResult = await runPrimaryBunWatchBuild({
273
+ entryPoints,
274
+ sourceRoot: options.sourceRoot,
275
+ buildRoot: options.buildRoot,
276
+ tsconfigPath: options.tsconfigPath,
277
+ nodeEnv: options.nodeEnv,
278
+ diagMax: options.diagMax,
279
+ });
280
+
281
+ console.info(
282
+ `[webstir-backend] watch:bun ${buildResult.errorCount} error(s), ${buildResult.warningCount} warning(s) in ${buildResult.durationMs.toFixed(1)}ms`,
283
+ );
284
+
285
+ if (buildResult.succeeded) {
286
+ const cacheReporter = createCacheReporter({
287
+ workspaceRoot: options.workspaceRoot,
288
+ buildRoot: options.buildRoot,
289
+ env: options.env,
290
+ diagnostics,
291
+ });
292
+
293
+ try {
294
+ await ensureModuleDefinitionBuild({
295
+ sourceRoot: options.sourceRoot,
296
+ buildRoot: options.buildRoot,
297
+ tsconfigPath: options.tsconfigPath,
298
+ mode: options.mode,
299
+ env: options.env,
300
+ diagnostics,
301
+ });
302
+ await cacheReporter.diffOutputs(
303
+ collectBunOutputSizes(buildResult.outputs, options.buildRoot),
304
+ options.mode,
305
+ );
306
+ const manifest = await loadBackendModuleManifest({
307
+ workspaceRoot: options.workspaceRoot,
308
+ buildRoot: options.buildRoot,
309
+ entryPoints,
310
+ diagnostics,
311
+ });
312
+ await cacheReporter.diffManifest(manifest);
313
+ } catch {
314
+ // cache or manifest diff failure should not break watch
315
+ }
316
+ }
317
+
318
+ flushDiagnostics(diagnostics);
319
+ const end = performance.now();
320
+ const bunBenchmark = options.shouldReportBunBenchmark
321
+ ? {
322
+ succeeded: buildResult.succeeded,
323
+ errorCount: buildResult.errorCount,
324
+ warningCount: buildResult.warningCount,
325
+ durationMs: buildResult.durationMs,
326
+ }
327
+ : undefined;
328
+
329
+ await emitWatchEvent(options.onEvent, {
330
+ type: 'build-complete',
331
+ succeeded: buildResult.succeeded,
332
+ errorCount: buildResult.errorCount,
333
+ warningCount: buildResult.warningCount,
334
+ durationMs: end - start,
335
+ bunBenchmarkSucceeded: bunBenchmark?.succeeded,
336
+ bunBenchmarkErrorCount: bunBenchmark?.errorCount,
337
+ bunBenchmarkWarningCount: bunBenchmark?.warningCount,
338
+ bunBenchmarkDurationMs: bunBenchmark?.durationMs,
339
+ });
340
+
341
+ return {
342
+ succeeded: buildResult.succeeded,
343
+ errorCount: buildResult.errorCount,
344
+ warningCount: buildResult.warningCount,
345
+ };
346
+ }
347
+
348
+ interface RunPrimaryBunWatchBuildOptions {
349
+ readonly entryPoints: readonly string[];
350
+ readonly sourceRoot: string;
351
+ readonly buildRoot: string;
352
+ readonly tsconfigPath: string;
353
+ readonly nodeEnv: string;
354
+ readonly diagMax: number;
355
+ }
356
+
357
+ interface RunPrimaryBunWatchBuildResult {
358
+ readonly succeeded: boolean;
359
+ readonly errorCount: number;
360
+ readonly warningCount: number;
361
+ readonly durationMs: number;
362
+ readonly outputs?: readonly BunBuildOutputFile[];
363
+ }
364
+
365
+ async function runPrimaryBunWatchBuild(
366
+ options: RunPrimaryBunWatchBuildOptions,
367
+ ): Promise<RunPrimaryBunWatchBuildResult> {
368
+ const build = getBunBuild();
369
+ if (!build) {
370
+ throw new Error('Bun.build() is not available in the current runtime.');
371
+ }
372
+
373
+ await rm(options.buildRoot, { recursive: true, force: true });
374
+ await mkdir(options.buildRoot, { recursive: true });
375
+
376
+ const start = performance.now();
377
+ const result = await build({
378
+ entrypoints: [...options.entryPoints],
379
+ root: options.sourceRoot,
380
+ outdir: options.buildRoot,
381
+ target: 'node',
382
+ format: 'esm',
383
+ splitting: false,
384
+ packages: 'external',
385
+ sourcemap: 'linked',
386
+ tsconfig: existsSync(options.tsconfigPath) ? options.tsconfigPath : undefined,
387
+ define: {
388
+ 'process.env.NODE_ENV': JSON.stringify(options.nodeEnv),
389
+ },
390
+ // Preserve the old esbuild watch behavior: transpile entries without requiring
391
+ // every relative import target to exist in minimal seeded workspaces.
392
+ plugins: [createRelativeImportPassthroughPlugin()],
393
+ throw: false,
394
+ });
395
+ const end = performance.now();
396
+
397
+ const { errorCount, warningCount } = logBunBuildResult(result, options.diagMax);
398
+
399
+ return {
400
+ succeeded: result.success && errorCount === 0,
401
+ errorCount,
402
+ warningCount,
403
+ durationMs: end - start,
404
+ outputs: result.outputs,
405
+ };
406
+ }
407
+
408
+ function logBunBuildResult(
409
+ result: BunBuildOutput,
410
+ diagMax: number,
411
+ ): {
412
+ errorCount: number;
413
+ warningCount: number;
414
+ } {
415
+ const logs = Array.isArray(result.logs) ? result.logs : [];
416
+ const errorLogs = logs.filter((log) => log.level === 'error');
417
+ const warningLogs = logs.filter((log) => log.level === 'warning');
418
+
419
+ for (const log of errorLogs.slice(0, diagMax)) {
420
+ console.error(`[webstir-backend][bun] ${formatEsbuildMessage(log)}`);
421
+ }
422
+ if (errorLogs.length > diagMax) {
423
+ console.error(`[webstir-backend][bun] ... ${errorLogs.length - diagMax} more error(s) omitted`);
424
+ }
425
+
426
+ for (const log of warningLogs.slice(0, diagMax)) {
427
+ console.warn(`[webstir-backend][bun] ${formatEsbuildMessage(log)}`);
428
+ }
429
+ if (warningLogs.length > diagMax) {
430
+ console.warn(
431
+ `[webstir-backend][bun] ... ${warningLogs.length - diagMax} more warning(s) omitted`,
432
+ );
433
+ }
434
+
435
+ return {
436
+ errorCount: errorLogs.length,
437
+ warningCount: warningLogs.length,
438
+ };
439
+ }
440
+
441
+ function collectBunOutputSizes(
442
+ outputs: readonly BunBuildOutputFile[] | undefined,
443
+ buildRoot: string,
444
+ ): Record<string, number> {
445
+ const collected: Record<string, number> = {};
446
+ for (const output of outputs ?? []) {
447
+ const rel = path.relative(buildRoot, output.path);
448
+ collected[rel] = typeof output.size === 'number' ? output.size : 0;
449
+ }
450
+ return collected;
451
+ }
452
+
453
+ async function takeWatchSnapshot(
454
+ workspaceRoot: string,
455
+ sourceRoot: string,
456
+ tsconfigPath: string,
457
+ ): Promise<string> {
458
+ const watchFiles = new Set<string>();
459
+
460
+ const packageJsonPath = path.join(workspaceRoot, 'package.json');
461
+ if (existsSync(packageJsonPath)) {
462
+ watchFiles.add(packageJsonPath);
463
+ }
464
+ if (existsSync(tsconfigPath)) {
465
+ watchFiles.add(tsconfigPath);
466
+ }
467
+
468
+ const typesRoot = path.join(workspaceRoot, 'types');
469
+ const directoryRoots = [sourceRoot];
470
+ if (existsSync(typesRoot)) {
471
+ directoryRoots.push(typesRoot);
472
+ }
473
+
474
+ for (const directoryRoot of directoryRoots) {
475
+ for (const filePath of await listWatchFiles(directoryRoot)) {
476
+ watchFiles.add(filePath);
477
+ }
478
+ }
479
+
480
+ const entries = await Promise.all(
481
+ Array.from(watchFiles)
482
+ .sort()
483
+ .map(async (filePath) => {
484
+ const fileStat = await stat(filePath);
485
+ return `${filePath}:${fileStat.size}:${fileStat.mtimeMs}`;
486
+ }),
487
+ );
488
+
489
+ return entries.join('|');
490
+ }
491
+
492
+ async function listWatchFiles(root: string): Promise<string[]> {
493
+ if (!existsSync(root)) {
494
+ return [];
495
+ }
496
+
497
+ const entries = await glob('**/*', {
498
+ cwd: root,
499
+ absolute: true,
500
+ dot: false,
501
+ nodir: false,
502
+ });
503
+
504
+ const files: string[] = [];
505
+ for (const entry of entries) {
506
+ try {
507
+ const entryStat = await stat(entry);
508
+ if (entryStat.isFile()) {
509
+ files.push(entry);
510
+ }
511
+ } catch {
512
+ // Ignore files deleted between glob and stat.
513
+ }
514
+ }
515
+
516
+ return files;
517
+ }
518
+
519
+ function flushDiagnostics(diagnostics: readonly ModuleDiagnostic[]): void {
520
+ for (const diag of diagnostics) {
521
+ const logger =
522
+ diag.severity === 'error'
523
+ ? console.error
524
+ : diag.severity === 'warn'
525
+ ? console.warn
526
+ : console.info;
527
+ logger(diag.message);
528
+ }
529
+ }
530
+
531
+ async function emitWatchEvent(
532
+ onEvent: StartWatchOptions['onEvent'],
533
+ event: BackendWatchEvent,
534
+ ): Promise<void> {
535
+ if (!onEvent) {
536
+ return;
537
+ }
538
+
539
+ try {
540
+ await onEvent(event);
541
+ } catch (error) {
542
+ const message = error instanceof Error ? error.message : String(error);
543
+ console.warn(`[webstir-backend] watch:event failed: ${message}`);
544
+ }
545
+ }
546
+
547
+ function getBunBuild(): BunBuildFunction | undefined {
548
+ const runtime = globalThis as typeof globalThis & {
549
+ Bun?: {
550
+ build?: BunBuildFunction;
551
+ };
552
+ };
553
+ const build = runtime.Bun?.build;
554
+ return typeof build === 'function' ? build.bind(runtime.Bun) : undefined;
555
+ }
556
+
557
+ function createRelativeImportPassthroughPlugin(): Record<string, unknown> {
558
+ return {
559
+ name: 'webstir-backend-watch-relative-imports',
560
+ setup(build: {
561
+ onResolve(
562
+ options: { filter: RegExp },
563
+ callback: (args: { path: string }) => { path: string; external: boolean },
564
+ ): void;
565
+ }) {
566
+ build.onResolve({ filter: /^\.\.?\// }, (args: { path: string }) => ({
567
+ path: args.path,
568
+ external: true,
569
+ }));
175
570
  },
176
571
  };
177
572
  }
573
+
574
+ function isEnabled(value: string | undefined): boolean {
575
+ if (typeof value !== 'string') {
576
+ return false;
577
+ }
578
+
579
+ const normalized = value.trim().toLowerCase();
580
+ return normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'yes';
581
+ }