@webstir-io/webstir-backend 0.1.15

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 (79) hide show
  1. package/README.md +427 -0
  2. package/dist/build/artifacts.d.ts +113 -0
  3. package/dist/build/artifacts.js +53 -0
  4. package/dist/build/entries.d.ts +1 -0
  5. package/dist/build/entries.js +17 -0
  6. package/dist/build/pipeline.d.ts +31 -0
  7. package/dist/build/pipeline.js +424 -0
  8. package/dist/cache/diff.d.ts +4 -0
  9. package/dist/cache/diff.js +114 -0
  10. package/dist/cache/reporters.d.ts +12 -0
  11. package/dist/cache/reporters.js +23 -0
  12. package/dist/diagnostics/summary.d.ts +6 -0
  13. package/dist/diagnostics/summary.js +27 -0
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +2 -0
  16. package/dist/manifest/pipeline.d.ts +13 -0
  17. package/dist/manifest/pipeline.js +224 -0
  18. package/dist/provider.d.ts +2 -0
  19. package/dist/provider.js +101 -0
  20. package/dist/scaffold/assets.d.ts +2 -0
  21. package/dist/scaffold/assets.js +77 -0
  22. package/dist/testing/context.d.ts +3 -0
  23. package/dist/testing/context.js +14 -0
  24. package/dist/testing/index.d.ts +6 -0
  25. package/dist/testing/index.js +208 -0
  26. package/dist/testing/types.d.ts +28 -0
  27. package/dist/testing/types.js +1 -0
  28. package/dist/watch.d.ts +8 -0
  29. package/dist/watch.js +159 -0
  30. package/dist/workspace.d.ts +4 -0
  31. package/dist/workspace.js +15 -0
  32. package/package.json +74 -0
  33. package/scripts/publish.sh +99 -0
  34. package/scripts/smoke.mjs +241 -0
  35. package/scripts/update-contract.sh +122 -0
  36. package/src/build/artifacts.ts +67 -0
  37. package/src/build/entries.ts +19 -0
  38. package/src/build/pipeline.ts +507 -0
  39. package/src/cache/diff.ts +128 -0
  40. package/src/cache/reporters.ts +41 -0
  41. package/src/diagnostics/summary.ts +32 -0
  42. package/src/index.ts +2 -0
  43. package/src/manifest/pipeline.ts +270 -0
  44. package/src/provider.ts +124 -0
  45. package/src/scaffold/assets.ts +81 -0
  46. package/src/testing/context.d.ts +3 -0
  47. package/src/testing/context.js +14 -0
  48. package/src/testing/context.ts +17 -0
  49. package/src/testing/index.d.ts +6 -0
  50. package/src/testing/index.js +208 -0
  51. package/src/testing/index.ts +252 -0
  52. package/src/testing/types.d.ts +28 -0
  53. package/src/testing/types.js +1 -0
  54. package/src/testing/types.ts +32 -0
  55. package/src/watch.ts +177 -0
  56. package/src/workspace.ts +22 -0
  57. package/templates/backend/.env.example +13 -0
  58. package/templates/backend/auth/adapter.ts +160 -0
  59. package/templates/backend/db/connection.ts +99 -0
  60. package/templates/backend/db/migrate.ts +231 -0
  61. package/templates/backend/db/migrations/0001-example.ts +17 -0
  62. package/templates/backend/db/types.d.ts +2 -0
  63. package/templates/backend/env.ts +174 -0
  64. package/templates/backend/functions/hello/index.ts +29 -0
  65. package/templates/backend/index.ts +532 -0
  66. package/templates/backend/jobs/nightly/index.ts +28 -0
  67. package/templates/backend/jobs/runtime.ts +103 -0
  68. package/templates/backend/jobs/scheduler.ts +193 -0
  69. package/templates/backend/module.ts +87 -0
  70. package/templates/backend/observability/logger.ts +24 -0
  71. package/templates/backend/observability/metrics.ts +78 -0
  72. package/templates/backend/server/fastify.ts +288 -0
  73. package/templates/backend/tsconfig.json +19 -0
  74. package/tests/cacheReporter.test.js +89 -0
  75. package/tests/envLoader.test.js +64 -0
  76. package/tests/integration.test.js +108 -0
  77. package/tests/manifest.test.js +159 -0
  78. package/tests/watch.test.js +100 -0
  79. package/tsconfig.json +27 -0
@@ -0,0 +1,507 @@
1
+ import path from 'node:path';
2
+ import { existsSync } from 'node:fs';
3
+ import { spawn } from 'node:child_process';
4
+ import { performance } from 'node:perf_hooks';
5
+
6
+ import { build as esbuild, context as esbuildContext } from 'esbuild';
7
+ import { glob } from 'glob';
8
+ import type { BuildContext as EsbuildContext } from 'esbuild';
9
+
10
+ import type { ModuleDiagnostic } from '@webstir-io/module-contract';
11
+
12
+ import type { BackendBuildMode } from '../workspace.js';
13
+ import { discoverEntryPoints } from './entries.js';
14
+
15
+ export interface BackendBuildPipelineOptions {
16
+ readonly sourceRoot: string;
17
+ readonly buildRoot: string;
18
+ readonly tsconfigPath: string;
19
+ readonly mode: BackendBuildMode;
20
+ readonly env: Record<string, string | undefined>;
21
+ readonly incremental: boolean;
22
+ readonly diagnostics: ModuleDiagnostic[];
23
+ }
24
+
25
+ export interface BackendBuildPipelineResult {
26
+ readonly entryPoints: readonly string[];
27
+ readonly outputs?: Record<string, number>;
28
+ readonly includePublishSourcemaps: boolean;
29
+ }
30
+
31
+ interface IncrementalBuildEntry {
32
+ entrySignature: string;
33
+ context: EsbuildContext;
34
+ }
35
+
36
+ const incrementalBuildCache = new Map<string, IncrementalBuildEntry>();
37
+
38
+ if (typeof process !== 'undefined' && typeof process.once === 'function') {
39
+ process.once('exit', () => {
40
+ clearIncrementalCache();
41
+ });
42
+ }
43
+
44
+ export async function runBackendBuildPipeline(options: BackendBuildPipelineOptions): Promise<BackendBuildPipelineResult> {
45
+ const { sourceRoot, buildRoot, tsconfigPath, diagnostics, incremental, mode } = options;
46
+ const env = options.env ?? {};
47
+ console.info(`[webstir-backend] ${mode}:tsc start`);
48
+ if (shouldTypeCheck(mode, env)) {
49
+ await runTypeCheck(tsconfigPath, env, diagnostics);
50
+ } else {
51
+ diagnostics.push({ severity: 'info', message: '[webstir-backend] type-check skipped by WEBSTIR_BACKEND_TYPECHECK' });
52
+ }
53
+ console.info(`[webstir-backend] ${mode}:tsc done`);
54
+
55
+ const entryPoints = await discoverEntryPoints(sourceRoot);
56
+ if (entryPoints.length === 0) {
57
+ diagnostics.push({
58
+ severity: 'warn',
59
+ message: `No backend entry points found under ${sourceRoot} (expected index.* or functions/*/index.* or jobs/*/index.*).`
60
+ });
61
+ }
62
+
63
+ console.info(`[webstir-backend] ${mode}:esbuild start`);
64
+ const outputs = await runEsbuild({
65
+ sourceRoot,
66
+ buildRoot,
67
+ tsconfigPath,
68
+ mode,
69
+ env,
70
+ incremental,
71
+ diagnostics,
72
+ entryPoints
73
+ });
74
+ console.info(`[webstir-backend] ${mode}:esbuild done`);
75
+
76
+ const moduleSource = await discoverModuleDefinitionSource(sourceRoot);
77
+ if (moduleSource) {
78
+ await buildModuleDefinition({
79
+ sourceFile: moduleSource,
80
+ sourceRoot,
81
+ buildRoot,
82
+ tsconfigPath,
83
+ mode,
84
+ env,
85
+ diagnostics
86
+ });
87
+ }
88
+
89
+ const includePublishSourcemaps = mode === 'publish' && shouldEmitPublishSourcemaps(env);
90
+
91
+ return {
92
+ entryPoints,
93
+ outputs,
94
+ includePublishSourcemaps
95
+ };
96
+ }
97
+
98
+ async function runTypeCheck(tsconfigPath: string, env: Record<string, string | undefined>, diagnostics: ModuleDiagnostic[]): Promise<void> {
99
+ if (!existsSync(tsconfigPath)) {
100
+ diagnostics.push({
101
+ severity: 'warn',
102
+ message: `TypeScript config not found at ${tsconfigPath}; skipping type-check.`
103
+ });
104
+ return;
105
+ }
106
+
107
+ await new Promise<void>((resolve, reject) => {
108
+ const child = spawn('tsc', ['-p', tsconfigPath, '--noEmit'], {
109
+ stdio: 'pipe',
110
+ env: {
111
+ ...process.env,
112
+ ...env
113
+ }
114
+ });
115
+
116
+ let stdout = '';
117
+ let stderr = '';
118
+
119
+ child.stdout?.on('data', (chunk) => {
120
+ stdout += chunk.toString();
121
+ });
122
+
123
+ child.stderr?.on('data', (chunk) => {
124
+ stderr += chunk.toString();
125
+ });
126
+
127
+ child.on('error', (err: any) => {
128
+ const code = (err && typeof err === 'object') ? (err.code as string | undefined) : undefined;
129
+ if (code === 'ENOENT') {
130
+ diagnostics.push({ severity: 'warn', message: 'TypeScript compiler (tsc) not found in PATH; skipping type-check.' });
131
+ resolve();
132
+ return;
133
+ }
134
+ reject(err);
135
+ });
136
+ child.on('close', (code) => {
137
+ if (code === 0) {
138
+ resolve();
139
+ } else {
140
+ diagnostics.push({
141
+ severity: 'error',
142
+ message: `Type checking failed (exit code ${code}).`,
143
+ file: tsconfigPath
144
+ });
145
+ if (stderr.trim()) {
146
+ diagnostics.push({ severity: 'error', message: stderr.trim() });
147
+ }
148
+ if (stdout.trim()) {
149
+ diagnostics.push({ severity: 'info', message: stdout.trim() });
150
+ }
151
+ reject(new Error('Type checking failed.'));
152
+ }
153
+ });
154
+ });
155
+ }
156
+
157
+ export function shouldTypeCheck(mode: BackendBuildMode, env: Record<string, string | undefined>): boolean {
158
+ const flag = env?.WEBSTIR_BACKEND_TYPECHECK;
159
+ if (typeof flag === 'string' && flag.toLowerCase() === 'skip') {
160
+ return false;
161
+ }
162
+ if (mode === 'publish') {
163
+ return true;
164
+ }
165
+ return true;
166
+ }
167
+
168
+ function shouldEmitPublishSourcemaps(env: Record<string, string | undefined>): boolean {
169
+ const flag = env?.WEBSTIR_BACKEND_SOURCEMAPS;
170
+ if (typeof flag !== 'string') {
171
+ return false;
172
+ }
173
+ const normalized = flag.trim().toLowerCase();
174
+ return normalized === 'on' || normalized === 'true' || normalized === '1' || normalized === 'yes';
175
+ }
176
+
177
+ async function discoverModuleDefinitionSource(sourceRoot: string): Promise<string | undefined> {
178
+ const patterns = ['module.{ts,tsx,js,mjs}', 'module/index.{ts,tsx,js,mjs}'];
179
+
180
+ for (const pattern of patterns) {
181
+ const matches = await glob(pattern, {
182
+ cwd: sourceRoot,
183
+ absolute: true,
184
+ nodir: true,
185
+ dot: false
186
+ });
187
+
188
+ if (matches.length > 0) {
189
+ return matches[0];
190
+ }
191
+ }
192
+
193
+ return undefined;
194
+ }
195
+
196
+ interface ModuleDefinitionBuildOptions {
197
+ readonly sourceFile: string;
198
+ readonly sourceRoot: string;
199
+ readonly buildRoot: string;
200
+ readonly tsconfigPath: string;
201
+ readonly mode: BackendBuildMode;
202
+ readonly env: Record<string, string | undefined>;
203
+ readonly diagnostics: ModuleDiagnostic[];
204
+ }
205
+
206
+ async function buildModuleDefinition(options: ModuleDefinitionBuildOptions): Promise<void> {
207
+ const { sourceFile, sourceRoot, buildRoot, tsconfigPath, mode, env, diagnostics } = options;
208
+
209
+ const isProduction = mode === 'publish';
210
+ const nodeEnv = env?.NODE_ENV ?? (isProduction ? 'production' : 'development');
211
+ const emitPublishSourcemaps = isProduction && shouldEmitPublishSourcemaps(env);
212
+ const define: Record<string, string> = {
213
+ 'process.env.NODE_ENV': JSON.stringify(nodeEnv)
214
+ };
215
+
216
+ try {
217
+ await esbuild({
218
+ entryPoints: [sourceFile],
219
+ bundle: false,
220
+ platform: 'node',
221
+ target: 'node20',
222
+ format: 'esm',
223
+ sourcemap: isProduction ? emitPublishSourcemaps : true,
224
+ outdir: buildRoot,
225
+ outbase: sourceRoot,
226
+ entryNames: '[dir]/[name]',
227
+ tsconfig: existsSync(tsconfigPath) ? tsconfigPath : undefined,
228
+ define,
229
+ logLevel: 'silent'
230
+ });
231
+ } catch (error) {
232
+ if (isEsbuildFailure(error)) {
233
+ for (const e of error.errors ?? []) {
234
+ diagnostics.push({ severity: 'error', message: formatEsbuildMessage(e) });
235
+ }
236
+ for (const w of error.warnings ?? []) {
237
+ diagnostics.push({ severity: 'warn', message: formatEsbuildMessage(w) });
238
+ }
239
+ } else if (error instanceof Error) {
240
+ diagnostics.push({ severity: 'error', message: error.message });
241
+ } else {
242
+ diagnostics.push({ severity: 'error', message: String(error) });
243
+ }
244
+ }
245
+ }
246
+
247
+ interface SupportFileBuildOptions {
248
+ readonly sourceFile: string;
249
+ readonly sourceRoot: string;
250
+ readonly buildRoot: string;
251
+ readonly tsconfigPath: string;
252
+ readonly mode: BackendBuildMode;
253
+ readonly env: Record<string, string | undefined>;
254
+ readonly diagnostics: ModuleDiagnostic[];
255
+ }
256
+
257
+ export async function buildSupportFile(options: SupportFileBuildOptions): Promise<void> {
258
+ const { sourceFile, sourceRoot, buildRoot, tsconfigPath, mode, env, diagnostics } = options;
259
+ const isProduction = mode === 'publish';
260
+ const nodeEnv = env?.NODE_ENV ?? (isProduction ? 'production' : 'development');
261
+ const emitPublishSourcemaps = isProduction && shouldEmitPublishSourcemaps(env);
262
+ const define: Record<string, string> = {
263
+ 'process.env.NODE_ENV': JSON.stringify(nodeEnv)
264
+ };
265
+
266
+ try {
267
+ await esbuild({
268
+ entryPoints: [sourceFile],
269
+ bundle: false,
270
+ platform: 'node',
271
+ target: 'node20',
272
+ format: 'esm',
273
+ sourcemap: isProduction ? emitPublishSourcemaps : true,
274
+ outdir: buildRoot,
275
+ outbase: sourceRoot,
276
+ tsconfig: existsSync(tsconfigPath) ? tsconfigPath : undefined,
277
+ define,
278
+ logLevel: 'silent'
279
+ });
280
+ } catch (error) {
281
+ if (error instanceof Error) {
282
+ diagnostics.push({ severity: 'error', message: error.message });
283
+ } else {
284
+ diagnostics.push({ severity: 'error', message: String(error) });
285
+ }
286
+ throw error;
287
+ }
288
+ }
289
+
290
+ interface BuildOptions {
291
+ readonly sourceRoot: string;
292
+ readonly buildRoot: string;
293
+ readonly tsconfigPath: string;
294
+ readonly mode: BackendBuildMode;
295
+ readonly env: Record<string, string | undefined>;
296
+ readonly incremental: boolean;
297
+ readonly diagnostics: ModuleDiagnostic[];
298
+ readonly entryPoints: readonly string[];
299
+ }
300
+
301
+ async function runEsbuild(options: BuildOptions): Promise<Record<string, number> | undefined> {
302
+ const { sourceRoot, buildRoot, tsconfigPath, mode, env, diagnostics, entryPoints } = options;
303
+ const isProduction = mode === 'publish';
304
+ const useIncremental = !isProduction && options.incremental === true;
305
+ const incrementalKey = useIncremental ? createIncrementalKey(mode, buildRoot) : undefined;
306
+
307
+ if (!entryPoints || entryPoints.length === 0) {
308
+ if (incrementalKey) {
309
+ await disposeIncrementalBuild(incrementalKey);
310
+ }
311
+ return undefined;
312
+ }
313
+
314
+ const entrySignature = useIncremental ? createEntrySignature(entryPoints) : undefined;
315
+ const nodeEnv = env?.NODE_ENV ?? (isProduction ? 'production' : 'development');
316
+ const diagMax = (() => {
317
+ const raw = env?.WEBSTIR_BACKEND_DIAG_MAX;
318
+ const n = typeof raw === 'string' ? parseInt(raw, 10) : NaN;
319
+ return Number.isFinite(n) && n > 0 ? n : 50;
320
+ })();
321
+
322
+ const define: Record<string, string> = {
323
+ 'process.env.NODE_ENV': JSON.stringify(nodeEnv)
324
+ };
325
+
326
+ const emitPublishSourcemaps = isProduction && shouldEmitPublishSourcemaps(env);
327
+ const start = performance.now();
328
+ try {
329
+ let reusedIncremental = false;
330
+ let result: Awaited<ReturnType<typeof esbuild>>;
331
+
332
+ if (isProduction) {
333
+ if (incrementalKey) {
334
+ await disposeIncrementalBuild(incrementalKey);
335
+ }
336
+ result = await esbuild({
337
+ entryPoints: entryPoints as string[],
338
+ bundle: true,
339
+ packages: 'external',
340
+ platform: 'node',
341
+ target: 'node20',
342
+ format: 'esm',
343
+ minify: true,
344
+ sourcemap: emitPublishSourcemaps,
345
+ legalComments: 'none',
346
+ outdir: buildRoot,
347
+ outbase: sourceRoot,
348
+ entryNames: '[dir]/[name]',
349
+ tsconfig: existsSync(tsconfigPath) ? tsconfigPath : undefined,
350
+ define,
351
+ logLevel: 'silent',
352
+ metafile: true
353
+ });
354
+ } else if (useIncremental && incrementalKey && entrySignature) {
355
+ const cached = incrementalBuildCache.get(incrementalKey);
356
+ if (cached && cached.entrySignature === entrySignature) {
357
+ reusedIncremental = true;
358
+ result = await cached.context.rebuild();
359
+ } else {
360
+ if (cached) {
361
+ await disposeIncrementalBuild(incrementalKey);
362
+ }
363
+ const ctx = await esbuildContext({
364
+ entryPoints: entryPoints as string[],
365
+ bundle: false,
366
+ platform: 'node',
367
+ target: 'node20',
368
+ format: 'esm',
369
+ sourcemap: true,
370
+ outdir: buildRoot,
371
+ outbase: sourceRoot,
372
+ tsconfig: existsSync(tsconfigPath) ? tsconfigPath : undefined,
373
+ define,
374
+ logLevel: 'silent',
375
+ metafile: true
376
+ });
377
+ incrementalBuildCache.set(incrementalKey, {
378
+ entrySignature,
379
+ context: ctx
380
+ });
381
+ result = await ctx.rebuild();
382
+ }
383
+ } else {
384
+ if (incrementalKey) {
385
+ await disposeIncrementalBuild(incrementalKey);
386
+ }
387
+ result = await esbuild({
388
+ entryPoints: entryPoints as string[],
389
+ bundle: false,
390
+ platform: 'node',
391
+ target: 'node20',
392
+ format: 'esm',
393
+ sourcemap: true,
394
+ outdir: buildRoot,
395
+ outbase: sourceRoot,
396
+ tsconfig: existsSync(tsconfigPath) ? tsconfigPath : undefined,
397
+ define,
398
+ logLevel: 'silent',
399
+ metafile: true
400
+ });
401
+ }
402
+
403
+ const warnCount = result.warnings?.length ?? 0;
404
+ for (const w of (result.warnings ?? []).slice(0, diagMax)) {
405
+ diagnostics.push({ severity: 'warn', message: formatEsbuildMessage(w) });
406
+ }
407
+ if (warnCount > diagMax) {
408
+ diagnostics.push({
409
+ severity: 'info',
410
+ message: `[webstir-backend] ${isProduction ? 'publish:esbuild' : `${mode}:esbuild`} ... ${warnCount - diagMax} more warning(s) omitted`
411
+ });
412
+ }
413
+ const end = performance.now();
414
+ const reuseSuffix = reusedIncremental ? ' (incremental)' : '';
415
+ diagnostics.push({
416
+ severity: 'info',
417
+ message: `[webstir-backend] ${isProduction ? 'publish:esbuild' : `${mode}:esbuild`} 0 error(s), ${warnCount} warning(s) in ${(end - start).toFixed(1)}ms${reuseSuffix}`
418
+ });
419
+
420
+ return collectOutputSizes((result as any).metafile, buildRoot);
421
+ } catch (error) {
422
+ const end = performance.now();
423
+ if (incrementalKey) {
424
+ await disposeIncrementalBuild(incrementalKey);
425
+ }
426
+ if (isEsbuildFailure(error)) {
427
+ const errs = error.errors ?? [];
428
+ const warns = error.warnings ?? [];
429
+ for (const e of errs.slice(0, diagMax)) {
430
+ diagnostics.push({ severity: 'error', message: formatEsbuildMessage(e) });
431
+ }
432
+ for (const w of warns.slice(0, diagMax)) {
433
+ diagnostics.push({ severity: 'warn', message: formatEsbuildMessage(w) });
434
+ }
435
+ if (errs.length > diagMax) {
436
+ diagnostics.push({ severity: 'info', message: `[webstir-backend] ${mode}:esbuild ... ${errs.length - diagMax} more error(s) omitted` });
437
+ }
438
+ if (warns.length > diagMax) {
439
+ diagnostics.push({ severity: 'info', message: `[webstir-backend] ${mode}:esbuild ... ${warns.length - diagMax} more warning(s) omitted` });
440
+ }
441
+ diagnostics.push({ severity: 'info', message: `[webstir-backend] ${mode}:esbuild ${errs.length} error(s), ${warns.length} warning(s) in ${(end - start).toFixed(1)}ms` });
442
+ } else if (error instanceof Error) {
443
+ diagnostics.push({ severity: 'error', message: error.message });
444
+ } else {
445
+ diagnostics.push({ severity: 'error', message: String(error) });
446
+ }
447
+ throw new Error('esbuild failed.');
448
+ }
449
+ }
450
+
451
+ function createIncrementalKey(mode: BackendBuildMode, buildRoot: string): string {
452
+ return `${mode}:${path.resolve(buildRoot)}`;
453
+ }
454
+
455
+ async function disposeIncrementalBuild(key: string): Promise<void> {
456
+ const cached = incrementalBuildCache.get(key);
457
+ if (cached) {
458
+ try {
459
+ await cached.context.dispose();
460
+ } catch {
461
+ // ignore
462
+ }
463
+ incrementalBuildCache.delete(key);
464
+ }
465
+ }
466
+
467
+ function clearIncrementalCache(): void {
468
+ for (const [key, entry] of incrementalBuildCache.entries()) {
469
+ try {
470
+ entry.context.dispose();
471
+ } catch {
472
+ // ignore
473
+ }
474
+ incrementalBuildCache.delete(key);
475
+ }
476
+ }
477
+
478
+ function createEntrySignature(entryPoints: readonly string[]): string {
479
+ return Array.from(entryPoints).sort().join('|');
480
+ }
481
+
482
+ export function collectOutputSizes(metafile: unknown, buildRoot: string): Record<string, number> {
483
+ const outputs: Record<string, number> = {};
484
+ if (!metafile || typeof metafile !== 'object') {
485
+ return outputs;
486
+ }
487
+ const mf = metafile as { outputs?: Record<string, { bytes?: number }> };
488
+ for (const [outPath, info] of Object.entries(mf.outputs ?? {})) {
489
+ const rel = path.relative(buildRoot, outPath);
490
+ outputs[rel] = typeof info.bytes === 'number' ? info.bytes : 0;
491
+ }
492
+ return outputs;
493
+ }
494
+
495
+ function isEsbuildFailure(error: unknown): error is { errors?: readonly any[]; warnings?: readonly any[] } {
496
+ return typeof error === 'object' && error !== null && ('errors' in (error as any) || 'warnings' in (error as any));
497
+ }
498
+
499
+ export function formatEsbuildMessage(msg: any): string {
500
+ const text = typeof msg?.text === 'string' ? msg.text : String(msg);
501
+ const loc = msg?.location;
502
+ if (loc && typeof loc.file === 'string') {
503
+ const position = typeof loc.line === 'number' ? `${loc.line}:${loc.column ?? 1}` : '1:1';
504
+ return `${loc.file}:${position} ${text}`;
505
+ }
506
+ return text;
507
+ }
@@ -0,0 +1,128 @@
1
+ import path from 'node:path';
2
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
3
+
4
+ import type { ModuleManifest, ModuleDiagnostic } from '@webstir-io/module-contract';
5
+
6
+ import type { BackendBuildMode } from '../workspace.js';
7
+
8
+ export async function persistAndDiffOutputs(
9
+ workspaceRoot: string,
10
+ _buildRoot: string,
11
+ outputs: Record<string, number> | undefined,
12
+ env: Record<string, string | undefined>,
13
+ diagnostics: ModuleDiagnostic[],
14
+ mode: BackendBuildMode
15
+ ): Promise<void> {
16
+ if (!outputs) return;
17
+ try {
18
+ const diagMax = resolveDiagMax(env);
19
+ const webstirDir = path.join(workspaceRoot, '.webstir');
20
+ const cachePath = path.join(webstirDir, 'backend-outputs.json');
21
+ await mkdir(webstirDir, { recursive: true });
22
+
23
+ let previous: Record<string, number> = {};
24
+ try {
25
+ const raw = await readFile(cachePath, 'utf8');
26
+ previous = JSON.parse(raw) as Record<string, number>;
27
+ } catch {
28
+ // first run or unreadable cache
29
+ }
30
+
31
+ const changed: string[] = [];
32
+ for (const [rel, bytes] of Object.entries(outputs)) {
33
+ if (previous[rel] !== bytes) {
34
+ changed.push(rel);
35
+ }
36
+ }
37
+ const removed = Object.keys(previous).filter((rel) => outputs[rel] === undefined);
38
+
39
+ if (changed.length + removed.length > 0) {
40
+ const list = changed.slice(0, diagMax).join(', ');
41
+ const omitted = changed.length > diagMax ? ` (+${changed.length - diagMax} more)` : '';
42
+ const removedInfo = removed.length > 0 ? `, removed=${removed.length}` : '';
43
+ diagnostics.push({
44
+ severity: 'info',
45
+ message: `[webstir-backend] ${mode}:changed ${changed.length} file(s): ${list}${omitted}${removedInfo}`
46
+ });
47
+ }
48
+
49
+ await writeFile(cachePath, JSON.stringify(outputs, null, 2), 'utf8');
50
+ } catch {
51
+ // ignore cache errors
52
+ }
53
+ }
54
+
55
+ export async function persistAndDiffManifest(
56
+ workspaceRoot: string,
57
+ manifest: ModuleManifest,
58
+ env: Record<string, string | undefined>,
59
+ diagnostics: ModuleDiagnostic[]
60
+ ): Promise<void> {
61
+ try {
62
+ const diagMax = resolveDiagMax(env);
63
+ const webstirDir = path.join(workspaceRoot, '.webstir');
64
+ const cachePath = path.join(webstirDir, 'backend-manifest-digest.json');
65
+ await mkdir(webstirDir, { recursive: true });
66
+
67
+ const routeKeys = Array.isArray(manifest.routes)
68
+ ? (manifest.routes as any[]).map((r) => `${(r.method ?? '').toUpperCase()} ${r.path ?? ''}`)
69
+ : [];
70
+ const viewPaths = Array.isArray(manifest.views)
71
+ ? (manifest.views as any[]).map((v) => `${v.path ?? ''}`)
72
+ : [];
73
+ const caps = Array.isArray(manifest.capabilities) ? manifest.capabilities : [];
74
+
75
+ type Digest = { routes: string[]; views: string[]; capabilities: string[] };
76
+ let previous: Digest | undefined;
77
+ try {
78
+ const raw = await readFile(cachePath, 'utf8');
79
+ previous = JSON.parse(raw) as Digest;
80
+ } catch {
81
+ // first run; no diff
82
+ }
83
+
84
+ if (previous) {
85
+ const prevRoutes = new Set(previous.routes);
86
+ const prevViews = new Set(previous.views);
87
+ const nextRoutes = new Set(routeKeys);
88
+ const nextViews = new Set(viewPaths);
89
+
90
+ const addedRoutes: string[] = [];
91
+ const removedRoutes: string[] = [];
92
+ const addedViews: string[] = [];
93
+ const removedViews: string[] = [];
94
+
95
+ for (const r of nextRoutes) if (!prevRoutes.has(r)) addedRoutes.push(r);
96
+ for (const r of prevRoutes) if (!nextRoutes.has(r)) removedRoutes.push(r);
97
+ for (const v of nextViews) if (!prevViews.has(v)) addedViews.push(v);
98
+ for (const v of prevViews) if (!nextViews.has(v)) removedViews.push(v);
99
+
100
+ if (addedRoutes.length + removedRoutes.length + addedViews.length + removedViews.length > 0) {
101
+ const list = (items: string[]) => items.slice(0, diagMax).join(', ');
102
+ const routeDelta = `routes +${addedRoutes.length}/-${removedRoutes.length}`;
103
+ const viewDelta = `views +${addedViews.length}/-${removedViews.length}`;
104
+ let msg = `[webstir-backend] manifest changed: ${routeDelta}; ${viewDelta}`;
105
+ const details: string[] = [];
106
+ if (addedRoutes.length > 0) details.push(`added routes: ${list(addedRoutes)}`);
107
+ if (removedRoutes.length > 0) details.push(`removed routes: ${list(removedRoutes)}`);
108
+ if (addedViews.length > 0) details.push(`added views: ${list(addedViews)}`);
109
+ if (removedViews.length > 0) details.push(`removed views: ${list(removedViews)}`);
110
+ if (details.length > 0) {
111
+ msg += ` — ${details.join(' | ')}`;
112
+ }
113
+ diagnostics.push({ severity: 'info', message: msg });
114
+ }
115
+ }
116
+
117
+ const digest: Digest = { routes: routeKeys, views: viewPaths, capabilities: caps };
118
+ await writeFile(cachePath, JSON.stringify(digest, null, 2), 'utf8');
119
+ } catch {
120
+ // ignore cache errors
121
+ }
122
+ }
123
+
124
+ function resolveDiagMax(env: Record<string, string | undefined>, fallback = 50): number {
125
+ const raw = env?.WEBSTIR_BACKEND_DIAG_MAX;
126
+ const n = typeof raw === 'string' ? parseInt(raw, 10) : NaN;
127
+ return Number.isFinite(n) && n > 0 ? n : fallback;
128
+ }
@@ -0,0 +1,41 @@
1
+ import type { ModuleDiagnostic, ModuleManifest } from '@webstir-io/module-contract';
2
+
3
+ import type { BackendBuildMode } from '../workspace.js';
4
+ import { persistAndDiffManifest, persistAndDiffOutputs } from './diff.js';
5
+
6
+ export interface CacheReporter {
7
+ readonly diffOutputs: (
8
+ outputs: Record<string, number> | undefined,
9
+ mode: BackendBuildMode
10
+ ) => Promise<void>;
11
+ readonly diffManifest: (manifest: ModuleManifest) => Promise<void>;
12
+ }
13
+
14
+ export function createCacheReporter(options: {
15
+ readonly workspaceRoot: string;
16
+ readonly buildRoot: string;
17
+ readonly env: Record<string, string | undefined>;
18
+ readonly diagnostics: ModuleDiagnostic[];
19
+ }): CacheReporter {
20
+ const { workspaceRoot, buildRoot, env, diagnostics } = options;
21
+ const diagnosticsTarget = shouldLogCacheDiffs(env) ? diagnostics : [];
22
+
23
+ return {
24
+ async diffOutputs(outputs, mode) {
25
+ await persistAndDiffOutputs(workspaceRoot, buildRoot, outputs, env, diagnosticsTarget, mode);
26
+ },
27
+ async diffManifest(manifest) {
28
+ await persistAndDiffManifest(workspaceRoot, manifest, env, diagnosticsTarget);
29
+ }
30
+ };
31
+ }
32
+
33
+ function shouldLogCacheDiffs(env: Record<string, string | undefined>): boolean {
34
+ const raw = env?.WEBSTIR_BACKEND_CACHE_LOG;
35
+ if (!raw) return true;
36
+ const normalized = raw.trim().toLowerCase();
37
+ if (['off', '0', 'false', 'quiet', 'silent', 'skip'].includes(normalized)) {
38
+ return false;
39
+ }
40
+ return true;
41
+ }