@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,315 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs/promises';
5
+ import http from 'node:http';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { fileURLToPath, pathToFileURL } from 'node:url';
9
+ import { build as esbuild } from 'esbuild';
10
+
11
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
+ const adapterTemplate = path.join(packageRoot, 'templates', 'backend', 'auth', 'adapter.ts');
13
+
14
+ async function importAuthAdapter() {
15
+ const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'webstir-auth-adapter-'));
16
+ const outfile = path.join(workspace, 'adapter.mjs');
17
+ await esbuild({
18
+ entryPoints: [adapterTemplate],
19
+ outfile,
20
+ bundle: true,
21
+ format: 'esm',
22
+ platform: 'node',
23
+ target: 'node20',
24
+ logLevel: 'silent',
25
+ });
26
+ return await import(`${pathToFileURL(outfile).href}?t=${Date.now()}-${Math.random()}`);
27
+ }
28
+
29
+ function defaultSecrets(overrides = {}) {
30
+ return {
31
+ jwtSecret: 'jwt-secret',
32
+ jwtPublicKey: undefined,
33
+ jwksUrl: undefined,
34
+ jwtIssuer: 'https://issuer.example.com/',
35
+ jwtAudience: 'webstir-tests',
36
+ serviceTokens: [],
37
+ ...overrides,
38
+ };
39
+ }
40
+
41
+ function makeRequest(headers) {
42
+ return new Request('http://webstir.test/auth', { headers });
43
+ }
44
+
45
+ async function resolveBearer(resolveRequestAuth, token, secrets, logger) {
46
+ return await resolveRequestAuth(
47
+ makeRequest({ authorization: `Bearer ${token}` }),
48
+ secrets,
49
+ logger,
50
+ );
51
+ }
52
+
53
+ function signJwtToken(payload, key, options = {}) {
54
+ const encodedHeader = encodeJwtSegment({
55
+ alg: options.alg ?? 'HS256',
56
+ typ: 'JWT',
57
+ ...(options.kid ? { kid: options.kid } : {}),
58
+ });
59
+ const encodedPayload = encodeJwtSegment(payload);
60
+ return signJwtSegments(encodedHeader, encodedPayload, key, options);
61
+ }
62
+
63
+ function signJwtSegments(encodedHeader, encodedPayload, key, options = {}) {
64
+ const alg = options.alg ?? 'HS256';
65
+ const signedContent = `${encodedHeader}.${encodedPayload}`;
66
+ const signature =
67
+ alg === 'HS256'
68
+ ? crypto.createHmac('sha256', key).update(signedContent).digest('base64url')
69
+ : crypto.sign('RSA-SHA256', Buffer.from(signedContent), key).toString('base64url');
70
+ return `${signedContent}.${signature}`;
71
+ }
72
+
73
+ function encodeJwtSegment(value) {
74
+ return Buffer.from(JSON.stringify(value)).toString('base64url');
75
+ }
76
+
77
+ function validPayload(overrides = {}) {
78
+ const now = Math.floor(Date.now() / 1000);
79
+ return {
80
+ sub: 'user-123',
81
+ iss: 'https://issuer.example.com/',
82
+ aud: 'webstir-tests',
83
+ nbf: now - 60,
84
+ exp: now + 60,
85
+ ...overrides,
86
+ };
87
+ }
88
+
89
+ function publicJwk(keyPair, kid) {
90
+ return {
91
+ ...keyPair.publicKey.export({ format: 'jwk' }),
92
+ kid,
93
+ alg: 'RS256',
94
+ use: 'sig',
95
+ };
96
+ }
97
+
98
+ async function startJwksServer(handler) {
99
+ const server = http.createServer((req, res) => {
100
+ if ((req.url ?? '/') !== '/.well-known/jwks.json') {
101
+ res.statusCode = 404;
102
+ res.end('not found');
103
+ return;
104
+ }
105
+ handler(req, res);
106
+ });
107
+
108
+ await new Promise((resolve, reject) => {
109
+ server.once('error', reject);
110
+ server.listen(0, '127.0.0.1', () => resolve());
111
+ });
112
+
113
+ const address = server.address();
114
+ if (!address || typeof address === 'string') {
115
+ throw new Error('Failed to read JWKS test server address.');
116
+ }
117
+
118
+ return {
119
+ url: `http://127.0.0.1:${address.port}/.well-known/jwks.json`,
120
+ async stop() {
121
+ await new Promise((resolve, reject) => {
122
+ server.close((error) => (error ? reject(error) : resolve()));
123
+ });
124
+ },
125
+ };
126
+ }
127
+
128
+ test('auth adapter fails closed for invalid jwt algorithms, claims, and signatures', async () => {
129
+ const { resolveRequestAuth } = await importAuthAdapter();
130
+ const secret = 'jwt-secret';
131
+ const secrets = defaultSecrets({ jwtSecret: secret });
132
+ const unsupportedAlgToken = signJwtSegments(
133
+ encodeJwtSegment({ alg: 'HS512', typ: 'JWT' }),
134
+ encodeJwtSegment(validPayload()),
135
+ secret,
136
+ );
137
+
138
+ const cases = [
139
+ unsupportedAlgToken,
140
+ signJwtToken(validPayload({ iss: 'https://wrong.example.com/' }), secret),
141
+ signJwtToken(validPayload({ aud: 'wrong-audience' }), secret),
142
+ signJwtToken(validPayload({ aud: ['other-audience'] }), secret),
143
+ signJwtToken(validPayload({ exp: 'not-a-number' }), secret),
144
+ signJwtToken(validPayload({ nbf: 'not-a-number' }), secret),
145
+ signJwtToken(validPayload({ iat: 'not-a-number' }), secret),
146
+ signJwtToken(validPayload(), 'wrong-secret'),
147
+ `${encodeJwtSegment({ alg: 'HS256', typ: 'JWT' })}.${encodeJwtSegment(validPayload())}.`,
148
+ ];
149
+
150
+ for (const token of cases) {
151
+ assert.equal(await resolveBearer(resolveRequestAuth, token, secrets), undefined);
152
+ }
153
+ });
154
+
155
+ test('auth adapter rejects malformed compact jwt segments before verification', async () => {
156
+ const { resolveRequestAuth } = await importAuthAdapter();
157
+ const secret = 'jwt-secret';
158
+ const secrets = defaultSecrets({ jwtSecret: secret });
159
+
160
+ const malformedHeader = `${encodeJwtSegment({ alg: 'HS256', typ: 'JWT' })}@`;
161
+ const validPayloadSegment = encodeJwtSegment(validPayload());
162
+ const signedMalformedHeaderToken = signJwtSegments(malformedHeader, validPayloadSegment, secret);
163
+ const malformedJsonPayload = signJwtSegments(
164
+ encodeJwtSegment({ alg: 'HS256', typ: 'JWT' }),
165
+ Buffer.from('not-json').toString('base64url'),
166
+ secret,
167
+ );
168
+
169
+ assert.equal(
170
+ await resolveBearer(resolveRequestAuth, signedMalformedHeaderToken, secrets),
171
+ undefined,
172
+ );
173
+ assert.equal(await resolveBearer(resolveRequestAuth, malformedJsonPayload, secrets), undefined);
174
+ assert.equal(await resolveBearer(resolveRequestAuth, 'not-a-jwt', secrets), undefined);
175
+ });
176
+
177
+ test('auth adapter refreshes jwks on unknown kid without accepting wrong keys', async () => {
178
+ const { resolveRequestAuth } = await importAuthAdapter();
179
+ const firstKeyPair = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
180
+ const refreshedKeyPair = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
181
+ let requests = 0;
182
+
183
+ const jwks = await startJwksServer((_req, res) => {
184
+ requests += 1;
185
+ res.setHeader('content-type', 'application/json');
186
+ res.setHeader('cache-control', 'public, max-age=60');
187
+ const keys =
188
+ requests === 1
189
+ ? [publicJwk(firstKeyPair, 'initial-key')]
190
+ : [publicJwk(firstKeyPair, 'initial-key'), publicJwk(refreshedKeyPair, 'refreshed-key')];
191
+ res.end(JSON.stringify({ keys }));
192
+ });
193
+
194
+ try {
195
+ const secrets = defaultSecrets({
196
+ jwtSecret: undefined,
197
+ jwksUrl: jwks.url,
198
+ });
199
+ const refreshedToken = signJwtToken(
200
+ validPayload({ sub: 'refreshed-user' }),
201
+ refreshedKeyPair.privateKey,
202
+ { alg: 'RS256', kid: 'refreshed-key' },
203
+ );
204
+ const context = await resolveBearer(resolveRequestAuth, refreshedToken, secrets);
205
+
206
+ assert.equal(context?.source, 'jwt');
207
+ assert.equal(context?.userId, 'refreshed-user');
208
+ assert.equal(requests, 2);
209
+
210
+ const wrongKeyPair = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
211
+ const wrongKidToken = signJwtToken(validPayload(), wrongKeyPair.privateKey, {
212
+ alg: 'RS256',
213
+ kid: 'missing-key',
214
+ });
215
+
216
+ assert.equal(await resolveBearer(resolveRequestAuth, wrongKidToken, secrets), undefined);
217
+ assert.equal(requests, 3);
218
+ } finally {
219
+ await jwks.stop();
220
+ }
221
+ });
222
+
223
+ test('auth adapter fails closed when jwks fetch fails', async () => {
224
+ const { resolveRequestAuth } = await importAuthAdapter();
225
+ const keyPair = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
226
+ const jwks = await startJwksServer((_req, res) => {
227
+ res.statusCode = 500;
228
+ res.end('not available');
229
+ });
230
+
231
+ try {
232
+ const token = signJwtToken(validPayload(), keyPair.privateKey, {
233
+ alg: 'RS256',
234
+ kid: 'unavailable-key',
235
+ });
236
+ const context = await resolveBearer(
237
+ resolveRequestAuth,
238
+ token,
239
+ defaultSecrets({ jwtSecret: undefined, jwksUrl: jwks.url }),
240
+ );
241
+ assert.equal(context, undefined);
242
+ } finally {
243
+ await jwks.stop();
244
+ }
245
+ });
246
+
247
+ test('auth adapter fails closed when jwks fetch times out', async () => {
248
+ const { resolveRequestAuth } = await importAuthAdapter();
249
+ const keyPair = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
250
+ const originalFetch = globalThis.fetch;
251
+ const originalSetTimeout = globalThis.setTimeout;
252
+
253
+ globalThis.fetch = async (_url, options = {}) => {
254
+ return await new Promise((_resolve, reject) => {
255
+ options.signal?.addEventListener(
256
+ 'abort',
257
+ () => {
258
+ const error = new Error('aborted');
259
+ error.name = 'AbortError';
260
+ reject(error);
261
+ },
262
+ { once: true },
263
+ );
264
+ });
265
+ };
266
+ globalThis.setTimeout = (callback, delay, ...args) =>
267
+ originalSetTimeout(callback, delay === 5_000 ? 1 : delay, ...args);
268
+
269
+ try {
270
+ const token = signJwtToken(validPayload(), keyPair.privateKey, {
271
+ alg: 'RS256',
272
+ kid: 'timeout-key',
273
+ });
274
+ const context = await resolveBearer(
275
+ resolveRequestAuth,
276
+ token,
277
+ defaultSecrets({ jwtSecret: undefined, jwksUrl: 'http://jwks.test/keys' }),
278
+ );
279
+ assert.equal(context, undefined);
280
+ } finally {
281
+ globalThis.fetch = originalFetch;
282
+ globalThis.setTimeout = originalSetTimeout;
283
+ }
284
+ });
285
+
286
+ test('auth adapter keeps service token precedence explicit and redacts diagnostics', async () => {
287
+ const { resolveRequestAuth } = await importAuthAdapter();
288
+ const messages = [];
289
+ const token = signJwtToken(validPayload(), 'wrong-secret');
290
+ const request = makeRequest({
291
+ authorization: `Bearer ${token}`,
292
+ 'x-service-token': 'service-secret',
293
+ });
294
+ const logger = {
295
+ warn(message, metadata) {
296
+ messages.push({ message, metadata });
297
+ },
298
+ };
299
+
300
+ const context = await resolveRequestAuth(
301
+ request,
302
+ defaultSecrets({ jwtSecret: 'jwt-secret', serviceTokens: ['service-secret'] }),
303
+ logger,
304
+ );
305
+
306
+ assert.equal(context?.source, 'service-token');
307
+ assert.equal(context?.token, 'service-secret');
308
+ assert.deepEqual(messages, [
309
+ {
310
+ message: 'Bearer token validation failed',
311
+ metadata: { reason: 'invalid_token' },
312
+ },
313
+ ]);
314
+ assert.doesNotMatch(JSON.stringify(messages), /service-secret|wrong-secret|eyJ/);
315
+ });
@@ -0,0 +1,217 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import { spawn } from 'node:child_process';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
+
9
+ import { backendProvider } from '../dist/index.js';
10
+
11
+ async function createTempWorkspace(prefix = 'webstir-backend-bundler-parity-') {
12
+ return await fs.mkdtemp(path.join(os.tmpdir(), prefix));
13
+ }
14
+
15
+ async function ensureDir(dir) {
16
+ await fs.mkdir(dir, { recursive: true });
17
+ }
18
+
19
+ async function copyFile(src, dest) {
20
+ await ensureDir(path.dirname(dest));
21
+ await fs.copyFile(src, dest);
22
+ }
23
+
24
+ function getPackageRoot() {
25
+ const here = path.dirname(fileURLToPath(import.meta.url));
26
+ return path.resolve(here, '..');
27
+ }
28
+
29
+ async function hydrateBackendScaffold(workspace) {
30
+ const assets = await backendProvider.getScaffoldAssets();
31
+
32
+ for (const asset of assets) {
33
+ const normalized = asset.targetPath.replace(/\\/g, '/');
34
+ if (!normalized.includes('src/backend/')) {
35
+ continue;
36
+ }
37
+
38
+ const target = path.join(workspace, asset.targetPath);
39
+ await copyFile(asset.sourcePath, target);
40
+ }
41
+ }
42
+
43
+ async function listRelativeFiles(root) {
44
+ const collected = [];
45
+
46
+ async function walk(current, prefix = '') {
47
+ let entries = [];
48
+ try {
49
+ entries = await fs.readdir(current, { withFileTypes: true });
50
+ } catch (error) {
51
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
52
+ return;
53
+ }
54
+ throw error;
55
+ }
56
+
57
+ entries.sort((a, b) => a.name.localeCompare(b.name));
58
+ for (const entry of entries) {
59
+ const relativePath = prefix ? path.posix.join(prefix, entry.name) : entry.name;
60
+ const absolutePath = path.join(current, entry.name);
61
+ if (entry.isDirectory()) {
62
+ await walk(absolutePath, relativePath);
63
+ continue;
64
+ }
65
+ collected.push(relativePath);
66
+ }
67
+ }
68
+
69
+ await walk(root);
70
+ return collected;
71
+ }
72
+
73
+ async function readCachedOutputPaths(workspace) {
74
+ const cachePath = path.join(workspace, '.webstir', 'backend-outputs.json');
75
+ try {
76
+ const raw = await fs.readFile(cachePath, 'utf8');
77
+ const parsed = JSON.parse(raw);
78
+ return Object.keys(parsed).sort();
79
+ } catch (error) {
80
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
81
+ return [];
82
+ }
83
+ throw error;
84
+ }
85
+ }
86
+
87
+ function selectPrimaryArtifactPaths(paths) {
88
+ return paths.filter((relativePath) => {
89
+ return (
90
+ /(^|\/)index\.js(\.map)?$/.test(relativePath) ||
91
+ relativePath === 'module.js' ||
92
+ relativePath === 'module.js.map' ||
93
+ relativePath === 'env.js' ||
94
+ relativePath === 'env.js.map'
95
+ );
96
+ });
97
+ }
98
+
99
+ async function snapshotWorkspaceBuild(workspace, entryPoints) {
100
+ const buildRoot = path.join(workspace, 'build', 'backend');
101
+ const artifactPaths = await listRelativeFiles(buildRoot);
102
+
103
+ return {
104
+ entryPoints: [...entryPoints].sort(),
105
+ artifactPaths,
106
+ primaryArtifactPaths: selectPrimaryArtifactPaths(artifactPaths).sort(),
107
+ cachedOutputPaths: await readCachedOutputPaths(workspace),
108
+ };
109
+ }
110
+
111
+ async function buildWithNodeProvider(workspace, env) {
112
+ const result = await backendProvider.build({
113
+ workspaceRoot: workspace,
114
+ env,
115
+ incremental: false,
116
+ });
117
+ return await snapshotWorkspaceBuild(workspace, result.manifest.entryPoints);
118
+ }
119
+
120
+ async function buildWithBunProvider(workspace, env) {
121
+ const packageRoot = getPackageRoot();
122
+ const moduleUrl = pathToFileURL(path.join(packageRoot, 'dist', 'index.js')).href;
123
+ const script = `
124
+ const workspace = process.argv[1];
125
+ const env = JSON.parse(process.argv[2]);
126
+ const { backendProvider } = await import(${JSON.stringify(moduleUrl)});
127
+ const result = await backendProvider.build({ workspaceRoot: workspace, env, incremental: false });
128
+ const errors = result.manifest.diagnostics.filter((entry) => entry.severity === 'error');
129
+ if (errors.length > 0) {
130
+ console.error('__ERROR__' + JSON.stringify(errors));
131
+ process.exit(1);
132
+ }
133
+ console.log('__RESULT__' + JSON.stringify({ entryPoints: result.manifest.entryPoints }));
134
+ `;
135
+
136
+ const child = spawn('bun', ['--eval', script, workspace, JSON.stringify(env)], {
137
+ cwd: packageRoot,
138
+ stdio: ['ignore', 'pipe', 'pipe'],
139
+ env: process.env,
140
+ });
141
+
142
+ let stdout = '';
143
+ let stderr = '';
144
+ child.stdout.setEncoding('utf8');
145
+ child.stderr.setEncoding('utf8');
146
+ child.stdout.on('data', (chunk) => {
147
+ stdout += chunk;
148
+ });
149
+ child.stderr.on('data', (chunk) => {
150
+ stderr += chunk;
151
+ });
152
+
153
+ const exitCode = await new Promise((resolve, reject) => {
154
+ child.once('error', reject);
155
+ child.once('close', resolve);
156
+ });
157
+
158
+ const resultLine = stdout
159
+ .split(/\r?\n/)
160
+ .map((line) => line.trim())
161
+ .filter((line) => line.startsWith('__RESULT__'))
162
+ .at(-1);
163
+
164
+ if (exitCode !== 0 || !resultLine) {
165
+ throw new Error(
166
+ `bun build parity harness failed (exit=${exitCode ?? 'null'})\nstdout:\n${stdout}\nstderr:\n${stderr}`,
167
+ );
168
+ }
169
+
170
+ const payload = JSON.parse(resultLine.slice('__RESULT__'.length));
171
+ return await snapshotWorkspaceBuild(workspace, payload.entryPoints ?? []);
172
+ }
173
+
174
+ async function compareBundlerSnapshots(mode, extraEnv = {}) {
175
+ const esbuildWorkspace = await createTempWorkspace(`${mode}-esbuild-`);
176
+ const bunWorkspace = await createTempWorkspace(`${mode}-bun-`);
177
+ await hydrateBackendScaffold(esbuildWorkspace);
178
+ await hydrateBackendScaffold(bunWorkspace);
179
+
180
+ const baseEnv = {
181
+ WEBSTIR_MODULE_MODE: mode,
182
+ WEBSTIR_BACKEND_TYPECHECK: 'skip',
183
+ ...extraEnv,
184
+ };
185
+
186
+ const esbuildSnapshot = await buildWithNodeProvider(esbuildWorkspace, baseEnv);
187
+ const bunSnapshot = await buildWithBunProvider(bunWorkspace, {
188
+ ...baseEnv,
189
+ WEBSTIR_BACKEND_BUNDLER: 'bun',
190
+ });
191
+
192
+ assert.deepEqual(
193
+ bunSnapshot.entryPoints,
194
+ esbuildSnapshot.entryPoints,
195
+ `${mode}: entry points should stay aligned`,
196
+ );
197
+ assert.deepEqual(
198
+ bunSnapshot.cachedOutputPaths,
199
+ esbuildSnapshot.cachedOutputPaths,
200
+ `${mode}: cached output accounting should stay aligned`,
201
+ );
202
+ assert.deepEqual(
203
+ bunSnapshot.primaryArtifactPaths,
204
+ esbuildSnapshot.primaryArtifactPaths,
205
+ `${mode}: primary emitted artifacts should stay aligned`,
206
+ );
207
+ }
208
+
209
+ test('build mode Bun bundler preserves artifact accounting parity', async () => {
210
+ await compareBundlerSnapshots('build');
211
+ });
212
+
213
+ test('publish mode Bun bundler preserves artifact accounting parity with sourcemaps enabled', async () => {
214
+ await compareBundlerSnapshots('publish', {
215
+ WEBSTIR_BACKEND_SOURCEMAPS: 'on',
216
+ });
217
+ });
@@ -22,7 +22,7 @@ function makeManifest(overrides = {}) {
22
22
  jobs: [],
23
23
  events: [],
24
24
  services: [],
25
- ...overrides
25
+ ...overrides,
26
26
  };
27
27
  }
28
28
 
@@ -33,13 +33,13 @@ test('cache reporter emits diagnostics for output and manifest diffs', async ()
33
33
  workspaceRoot,
34
34
  buildRoot: path.join(workspaceRoot, 'build', 'backend'),
35
35
  env: {},
36
- diagnostics
36
+ diagnostics,
37
37
  });
38
38
 
39
39
  await reporter.diffOutputs({ 'index.js': 128 }, 'build');
40
40
  assert.ok(
41
41
  diagnostics.some((d) => d.message.includes('changed 1 file')),
42
- 'expected first diff to report changed files'
42
+ 'expected first diff to report changed files',
43
43
  );
44
44
 
45
45
  diagnostics.length = 0;
@@ -47,7 +47,7 @@ test('cache reporter emits diagnostics for output and manifest diffs', async ()
47
47
  await reporter.diffOutputs({ 'index.js': 256 }, 'build');
48
48
  assert.ok(
49
49
  diagnostics.some((d) => d.message.includes('changed 1 file')),
50
- 'expected subsequent diffs to report changed files'
50
+ 'expected subsequent diffs to report changed files',
51
51
  );
52
52
 
53
53
  diagnostics.length = 0;
@@ -57,12 +57,12 @@ test('cache reporter emits diagnostics for output and manifest diffs', async ()
57
57
 
58
58
  await reporter.diffManifest(
59
59
  makeManifest({
60
- routes: [{ method: 'GET', path: '/accounts' }]
61
- })
60
+ routes: [{ method: 'GET', path: '/accounts' }],
61
+ }),
62
62
  );
63
63
  assert.ok(
64
64
  diagnostics.some((d) => d.message.includes('manifest changed')),
65
- 'expected manifest changes to produce diagnostics'
65
+ 'expected manifest changes to produce diagnostics',
66
66
  );
67
67
  });
68
68
 
@@ -73,7 +73,7 @@ test('cache reporter can silence diagnostics via env', async () => {
73
73
  workspaceRoot,
74
74
  buildRoot: path.join(workspaceRoot, 'build', 'backend'),
75
75
  env: { WEBSTIR_BACKEND_CACHE_LOG: 'off' },
76
- diagnostics
76
+ diagnostics,
77
77
  });
78
78
 
79
79
  await reporter.diffOutputs({ 'index.js': 128 }, 'build');
@@ -81,8 +81,8 @@ test('cache reporter can silence diagnostics via env', async () => {
81
81
  await reporter.diffManifest(makeManifest());
82
82
  await reporter.diffManifest(
83
83
  makeManifest({
84
- routes: [{ method: 'POST', path: '/silent' }]
85
- })
84
+ routes: [{ method: 'POST', path: '/silent' }],
85
+ }),
86
86
  );
87
87
 
88
88
  assert.equal(diagnostics.length, 0, 'expected diagnostics to stay empty when logging disabled');