@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.
- package/README.md +427 -0
- package/dist/build/artifacts.d.ts +113 -0
- package/dist/build/artifacts.js +53 -0
- package/dist/build/entries.d.ts +1 -0
- package/dist/build/entries.js +17 -0
- package/dist/build/pipeline.d.ts +31 -0
- package/dist/build/pipeline.js +424 -0
- package/dist/cache/diff.d.ts +4 -0
- package/dist/cache/diff.js +114 -0
- package/dist/cache/reporters.d.ts +12 -0
- package/dist/cache/reporters.js +23 -0
- package/dist/diagnostics/summary.d.ts +6 -0
- package/dist/diagnostics/summary.js +27 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/manifest/pipeline.d.ts +13 -0
- package/dist/manifest/pipeline.js +224 -0
- package/dist/provider.d.ts +2 -0
- package/dist/provider.js +101 -0
- package/dist/scaffold/assets.d.ts +2 -0
- package/dist/scaffold/assets.js +77 -0
- package/dist/testing/context.d.ts +3 -0
- package/dist/testing/context.js +14 -0
- package/dist/testing/index.d.ts +6 -0
- package/dist/testing/index.js +208 -0
- package/dist/testing/types.d.ts +28 -0
- package/dist/testing/types.js +1 -0
- package/dist/watch.d.ts +8 -0
- package/dist/watch.js +159 -0
- package/dist/workspace.d.ts +4 -0
- package/dist/workspace.js +15 -0
- package/package.json +74 -0
- package/scripts/publish.sh +99 -0
- package/scripts/smoke.mjs +241 -0
- package/scripts/update-contract.sh +122 -0
- package/src/build/artifacts.ts +67 -0
- package/src/build/entries.ts +19 -0
- package/src/build/pipeline.ts +507 -0
- package/src/cache/diff.ts +128 -0
- package/src/cache/reporters.ts +41 -0
- package/src/diagnostics/summary.ts +32 -0
- package/src/index.ts +2 -0
- package/src/manifest/pipeline.ts +270 -0
- package/src/provider.ts +124 -0
- package/src/scaffold/assets.ts +81 -0
- package/src/testing/context.d.ts +3 -0
- package/src/testing/context.js +14 -0
- package/src/testing/context.ts +17 -0
- package/src/testing/index.d.ts +6 -0
- package/src/testing/index.js +208 -0
- package/src/testing/index.ts +252 -0
- package/src/testing/types.d.ts +28 -0
- package/src/testing/types.js +1 -0
- package/src/testing/types.ts +32 -0
- package/src/watch.ts +177 -0
- package/src/workspace.ts +22 -0
- package/templates/backend/.env.example +13 -0
- package/templates/backend/auth/adapter.ts +160 -0
- package/templates/backend/db/connection.ts +99 -0
- package/templates/backend/db/migrate.ts +231 -0
- package/templates/backend/db/migrations/0001-example.ts +17 -0
- package/templates/backend/db/types.d.ts +2 -0
- package/templates/backend/env.ts +174 -0
- package/templates/backend/functions/hello/index.ts +29 -0
- package/templates/backend/index.ts +532 -0
- package/templates/backend/jobs/nightly/index.ts +28 -0
- package/templates/backend/jobs/runtime.ts +103 -0
- package/templates/backend/jobs/scheduler.ts +193 -0
- package/templates/backend/module.ts +87 -0
- package/templates/backend/observability/logger.ts +24 -0
- package/templates/backend/observability/metrics.ts +78 -0
- package/templates/backend/server/fastify.ts +288 -0
- package/templates/backend/tsconfig.json +19 -0
- package/tests/cacheReporter.test.js +89 -0
- package/tests/envLoader.test.js +64 -0
- package/tests/integration.test.js +108 -0
- package/tests/manifest.test.js +159 -0
- package/tests/watch.test.js +100 -0
- 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
|
+
}
|