@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
@@ -1,515 +1,22 @@
1
- import http from 'node:http';
2
- import path from 'node:path';
3
- import { randomUUID } from 'node:crypto';
4
- import { fileURLToPath, pathToFileURL } from 'node:url';
1
+ import { createDefaultBunBackendBootstrap, startBunBackend } from '@webstir-io/webstir-backend';
5
2
 
6
- import type { Logger } from 'pino';
7
-
8
- import { loadEnv, type AppEnv } from './env.js';
9
- import { resolveRequestAuth, type AuthContext } from './auth/adapter.js';
10
- import { createBaseLogger, createRequestLogger } from './observability/logger.js';
11
- import { createMetricsTracker, type MetricsTracker } from './observability/metrics.js';
12
-
13
- interface EnvAccessor {
14
- get(name: string): string | undefined;
15
- require(name: string): string;
16
- entries(): Record<string, string | undefined>;
17
- }
18
-
19
- type RouteHandlerResult = {
20
- status?: number;
21
- headers?: Record<string, string>;
22
- body?: unknown;
23
- errors?: { code: string; message: string; details?: unknown }[];
24
- };
25
-
26
- interface RouteContext {
27
- request: http.IncomingMessage;
28
- reply: http.ServerResponse;
29
- params: Record<string, string>;
30
- query: Record<string, string>;
31
- body: unknown;
32
- auth: AuthContext | undefined;
33
- session: null;
34
- db: Record<string, unknown>;
35
- env: EnvAccessor;
36
- logger: Logger;
37
- requestId: string;
38
- now: () => Date;
39
- }
40
-
41
- type RouteHandler = (ctx: RouteContext) => Promise<RouteHandlerResult> | RouteHandlerResult;
42
-
43
- interface ModuleRouteDefinition {
44
- name?: string;
45
- method?: string;
46
- path?: string;
47
- }
48
-
49
- interface ModuleRoute {
50
- definition?: ModuleRouteDefinition;
51
- handler?: RouteHandler;
52
- }
53
-
54
- interface ModuleManifestLike {
55
- name?: string;
56
- version?: string;
57
- capabilities?: string[];
58
- routes?: ModuleRouteDefinition[];
59
- }
60
-
61
- type LifecycleHook = (context: { env: EnvAccessor; logger: Logger }) => Promise<void> | void;
62
-
63
- interface ModuleDefinitionLike {
64
- manifest?: ModuleManifestLike;
65
- routes?: ModuleRoute[];
66
- init?: LifecycleHook;
67
- dispose?: LifecycleHook;
68
- }
69
-
70
- interface CompiledRoute {
71
- method: string;
72
- name: string;
73
- match: (pathname: string) => { matched: boolean; params: Record<string, string> };
74
- handler: RouteHandler;
75
- definition?: ModuleRouteDefinition;
76
- }
77
-
78
- interface ModuleRuntime {
79
- definition?: ModuleDefinitionLike;
80
- manifest?: ModuleManifestLike;
81
- routes: CompiledRoute[];
82
- source?: string;
83
- }
84
-
85
- type ReadinessStatus = 'booting' | 'ready' | 'error';
86
-
87
- interface ReadinessState {
88
- status: ReadinessStatus;
89
- message?: string;
90
- }
91
-
92
- type ReadinessTracker = ReturnType<typeof createReadinessTracker>;
93
-
94
- interface ManifestSummary {
95
- name?: string;
96
- version?: string;
97
- routes: number;
98
- capabilities?: string[];
99
- }
3
+ import { resolveRequestAuth } from './auth/adapter.js';
4
+ import { loadEnv } from './env.js';
5
+ import { createBaseLogger } from './observability/logger.js';
6
+ import { createMetricsTracker } from './observability/metrics.js';
7
+ import { sessionStore } from './session/store.js';
100
8
 
101
9
  export async function start(): Promise<void> {
102
- const env = loadEnv();
103
- const logger = createBaseLogger(env);
104
- const metrics = createMetricsTracker(env.metrics);
105
- const readiness = createReadinessTracker();
106
- readiness.booting();
107
-
108
- let runtime: ModuleRuntime;
109
- let loadError: string | undefined;
110
-
111
- try {
112
- runtime = await loadModuleRuntime();
113
- } catch (error) {
114
- loadError = (error as Error).message ?? 'Failed to load module definition';
115
- logger.error({ err: error }, '[webstir-backend] module load failed');
116
- readiness.error(loadError);
117
- runtime = { routes: [] };
118
- }
119
-
120
- if (runtime.source) {
121
- logger.info(`[webstir-backend] loaded module definition from ${runtime.source}`);
122
- } else {
123
- logger.warn('[webstir-backend] no module definition found. Add src/backend/module.ts to describe routes.');
124
- }
125
-
126
- logManifestSummary(logger, runtime.manifest, runtime.routes.length);
127
- const manifestSummary = summarizeManifest(runtime.manifest);
128
-
129
- const server = http.createServer((req, res) => {
130
- void handleRequest({ req, res, runtime, readiness, manifestSummary, env, logger, metrics });
131
- });
132
-
133
- await new Promise<void>((resolve, reject) => {
134
- server.once('error', reject);
135
- server.listen(env.PORT, '0.0.0.0', () => resolve());
136
- });
137
-
138
- if (!loadError) {
139
- readiness.ready();
140
- }
141
-
142
- logger.info({ port: env.PORT, mode: env.NODE_ENV }, 'API server running');
143
- }
144
-
145
- async function handleRequest(options: {
146
- req: http.IncomingMessage;
147
- res: http.ServerResponse;
148
- runtime: ModuleRuntime;
149
- readiness: ReadinessTracker;
150
- env: AppEnv;
151
- logger: Logger;
152
- metrics: MetricsTracker;
153
- manifestSummary?: ManifestSummary;
154
- }): Promise<void> {
155
- const { req, res, runtime, readiness, manifestSummary, env, logger, metrics } = options;
156
- try {
157
- if (!req.url) {
158
- respondJson(res, 400, { error: 'bad_request' });
159
- return;
160
- }
161
-
162
- const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
163
- const pathname = normalizePath(url.pathname);
164
- const method = (req.method ?? 'GET').toUpperCase();
165
-
166
- if (isHealthPath(pathname)) {
167
- respondJson(res, 200, { ok: true, uptime: process.uptime() });
168
- return;
169
- }
170
-
171
- if (isReadyPath(pathname)) {
172
- const snapshot = readiness.snapshot();
173
- const statusCode = snapshot.status === 'ready' ? 200 : 503;
174
- respondJson(res, statusCode, {
175
- status: snapshot.status,
176
- message: snapshot.message,
177
- manifest: manifestSummary,
178
- metrics: metrics.snapshot()
179
- });
180
- return;
181
- }
182
-
183
- if (isMetricsPath(pathname)) {
184
- const snapshot = metrics.snapshot();
185
- respondJson(res, 200, snapshot ?? { enabled: false });
186
- return;
187
- }
188
-
189
- if (method === 'OPTIONS') {
190
- res.statusCode = 204;
191
- res.setHeader('Access-Control-Allow-Origin', req.headers.origin ?? '*');
192
- res.setHeader('Access-Control-Allow-Methods', 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS');
193
- res.setHeader('Access-Control-Allow-Headers', req.headers['access-control-request-headers'] ?? 'content-type');
194
- res.end('');
195
- return;
196
- }
197
-
198
- const matched = matchRoute(runtime.routes, method, pathname);
199
- if (!matched) {
200
- respondJson(res, 404, { error: 'not_found', path: pathname });
201
- metrics.record({ method, route: pathname, status: 404, durationMs: 0 });
202
- return;
203
- }
204
-
205
- const routeName = matched.route.name ?? matched.route.definition?.path ?? pathname;
206
- const startTime = process.hrtime.bigint();
207
- const body = await readRequestBody(req);
208
- const requestId = extractRequestId(req);
209
- res.setHeader('x-request-id', requestId);
210
-
211
- const requestLogger = createRequestLogger(logger, { requestId, req, route: routeName });
212
- const envAccessor = createEnvAccessor();
213
- const auth = resolveRequestAuth(req, env.auth, requestLogger);
214
- const db: Record<string, unknown> = Object.create(null);
215
- const ctx: RouteContext = {
216
- request: req,
217
- reply: res,
218
- params: matched.params,
219
- query: Object.fromEntries(url.searchParams.entries()),
220
- body,
221
- auth,
222
- session: null,
223
- db,
224
- env: envAccessor,
225
- logger: requestLogger,
226
- requestId,
227
- now: () => new Date()
228
- };
229
-
230
- let handlerFailed = false;
231
- try {
232
- const result = await matched.route.handler(ctx);
233
- sendRouteResponse(res, result);
234
- } catch (error) {
235
- handlerFailed = true;
236
- requestLogger.error({ err: error }, 'route handler failed');
237
- throw error;
238
- } finally {
239
- const durationMs = Number(process.hrtime.bigint() - startTime) / 1_000_000;
240
- const statusCode = handlerFailed ? 500 : res.statusCode ?? 200;
241
- metrics.record({ method, route: routeName, status: statusCode, durationMs });
242
- requestLogger.info({ status: statusCode, durationMs }, 'request.completed');
243
- }
244
- } catch (error) {
245
- logger.error({ err: error }, '[webstir-backend] request failed');
246
- if (!res.headersSent) {
247
- respondJson(res, 500, { error: 'internal_error', message: (error as Error).message });
248
- } else {
249
- res.end();
250
- }
251
- }
252
- }
253
-
254
- function sendRouteResponse(res: http.ServerResponse, result: RouteHandlerResult): void {
255
- const status = result.status ?? (result.errors ? 400 : 200);
256
- const headers = result.headers ?? { 'content-type': 'application/json' };
257
- res.statusCode = status;
258
- for (const [key, value] of Object.entries(headers)) {
259
- res.setHeader(key, value);
260
- }
261
-
262
- if (result.errors) {
263
- respondJson(res, status, { errors: result.errors });
264
- return;
265
- }
266
-
267
- if (result.body === undefined || result.body === null) {
268
- res.end('');
269
- return;
270
- }
271
-
272
- if (typeof result.body === 'string' || Buffer.isBuffer(result.body)) {
273
- res.end(result.body);
274
- return;
275
- }
276
-
277
- respondJson(res, status, result.body);
278
- }
279
-
280
- function createEnvAccessor(): EnvAccessor {
281
- return {
282
- get: (name) => process.env[name],
283
- require: (name) => {
284
- const value = process.env[name];
285
- if (value === undefined) {
286
- throw new Error(`Missing required env var ${name}`);
287
- }
288
- return value;
289
- },
290
- entries: () => ({ ...process.env })
291
- };
292
- }
293
-
294
- function extractRequestId(req: http.IncomingMessage): string {
295
- const header = req.headers['x-request-id'];
296
- if (typeof header === 'string' && header.length > 0) {
297
- return header;
298
- }
299
- if (Array.isArray(header) && header.length > 0) {
300
- return header[0];
301
- }
302
- try {
303
- return randomUUID();
304
- } catch {
305
- return `${Date.now()}`;
306
- }
307
- }
308
-
309
- function createReadinessTracker() {
310
- let status: ReadinessStatus = 'booting';
311
- let message: string | undefined;
312
-
313
- return {
314
- booting() {
315
- status = 'booting';
316
- message = undefined;
317
- },
318
- ready() {
319
- status = 'ready';
320
- message = undefined;
321
- },
322
- error(reason: string) {
323
- status = 'error';
324
- message = reason;
325
- },
326
- snapshot(): ReadinessState {
327
- return { status, message };
328
- }
329
- };
330
- }
331
-
332
- function isHealthPath(pathname: string): boolean {
333
- return pathname === '/api/health' || pathname === '/healthz';
334
- }
335
-
336
- function isReadyPath(pathname: string): boolean {
337
- return pathname === '/readyz';
338
- }
339
-
340
- function isMetricsPath(pathname: string): boolean {
341
- return pathname === '/metrics';
342
- }
343
-
344
- function respondJson(res: http.ServerResponse, status: number, payload: unknown): void {
345
- if (!res.headersSent) {
346
- if (!res.hasHeader('content-type')) {
347
- res.setHeader('Content-Type', 'application/json');
348
- }
349
- res.statusCode = status;
350
- }
351
- res.end(JSON.stringify(payload));
352
- }
353
-
354
- function matchRoute(routes: CompiledRoute[], method: string, pathname: string): { route: CompiledRoute; params: Record<string, string> } | undefined {
355
- const normalizedMethod = (method ?? 'GET').toUpperCase();
356
- for (const route of routes) {
357
- if (route.method !== normalizedMethod) continue;
358
- const { matched, params } = route.match(pathname);
359
- if (matched) {
360
- return { route, params };
361
- }
362
- }
363
- return undefined;
364
- }
365
-
366
- async function loadModuleRuntime(): Promise<ModuleRuntime> {
367
- const loaded = await tryLoadModuleDefinition();
368
- if (!loaded) {
369
- return { routes: [] };
370
- }
371
- const manifest = sanitizeManifest(loaded.definition.manifest);
372
- const routes = compileRoutes(loaded.definition.routes ?? []);
373
- return {
374
- definition: loaded.definition,
375
- manifest,
376
- routes,
377
- source: loaded.source
378
- };
379
- }
380
-
381
- function sanitizeManifest(manifest?: ModuleManifestLike): ModuleManifestLike | undefined {
382
- if (!manifest || typeof manifest !== 'object') {
383
- return undefined;
384
- }
385
- return {
386
- ...manifest,
387
- routes: Array.isArray(manifest.routes) ? manifest.routes : [],
388
- capabilities: Array.isArray(manifest.capabilities) ? manifest.capabilities : undefined
389
- };
390
- }
391
-
392
- function summarizeManifest(manifest?: ModuleManifestLike): ManifestSummary | undefined {
393
- if (!manifest) {
394
- return undefined;
395
- }
396
- return {
397
- name: manifest.name,
398
- version: manifest.version,
399
- routes: Array.isArray(manifest.routes) ? manifest.routes.length : 0,
400
- capabilities: manifest.capabilities && manifest.capabilities.length > 0 ? manifest.capabilities : undefined
401
- };
402
- }
403
-
404
- function logManifestSummary(logger: Logger, manifest: ModuleManifestLike | undefined, routeCount: number): void {
405
- if (!manifest) {
406
- logger.info(`[webstir-backend] manifest routes=${routeCount} (no manifest metadata found)`);
407
- return;
408
- }
409
- const caps = manifest.capabilities?.length ? ` [${manifest.capabilities.join(', ')}]` : '';
410
- const routes = Array.isArray(manifest.routes) ? manifest.routes.length : routeCount;
411
- logger.info(`[webstir-backend] manifest name=${manifest.name ?? 'unknown'} routes=${routes}${caps}`);
412
- }
413
-
414
- function compileRoutes(routes: ModuleRoute[]): CompiledRoute[] {
415
- const compiled: CompiledRoute[] = [];
416
- for (const route of routes) {
417
- if (typeof route.handler !== 'function') {
418
- continue;
419
- }
420
- const method = (route.definition?.method ?? 'GET').toUpperCase();
421
- const pathPattern = normalizePath(route.definition?.path ?? '/');
422
- compiled.push({
423
- method,
424
- name: route.definition?.name ?? pathPattern,
425
- match: createPathMatcher(pathPattern),
426
- handler: route.handler,
427
- definition: route.definition
428
- });
429
- }
430
- return compiled;
431
- }
432
-
433
- function createPathMatcher(pattern: string) {
434
- const normalized = normalizePath(pattern);
435
- const paramRegex = /:([A-Za-z0-9_]+)/g;
436
- const regex = new RegExp(
437
- '^' +
438
- normalized
439
- .replace(/\//g, '\\/')
440
- .replace(paramRegex, (_segment, name) => `(?<${name}>[^/]+)`) +
441
- '$'
10
+ await startBunBackend(
11
+ createDefaultBunBackendBootstrap({
12
+ importMetaUrl: import.meta.url,
13
+ loadEnv,
14
+ resolveRequestAuth,
15
+ createBaseLogger,
16
+ createMetricsTracker,
17
+ sessionStore,
18
+ }),
442
19
  );
443
- return (pathname: string) => {
444
- const pathToTest = normalizePath(pathname);
445
- const match = regex.exec(pathToTest);
446
- if (!match) {
447
- return { matched: false, params: {} };
448
- }
449
- const params = (match.groups ?? {}) as Record<string, string>;
450
- return { matched: true, params };
451
- };
452
- }
453
-
454
- async function tryLoadModuleDefinition(): Promise<{ definition: ModuleDefinitionLike; source: string } | undefined> {
455
- const here = path.dirname(fileURLToPath(import.meta.url));
456
- const candidates = ['module.js', 'module.mjs', 'module/index.js', 'module/index.mjs'];
457
- for (const rel of candidates) {
458
- const full = path.join(here, rel);
459
- try {
460
- const imported = await import(`${pathToFileURL(full).href}?t=${Date.now()}`);
461
- const definition = extractModuleDefinition(imported);
462
- if (definition) {
463
- return { definition, source: rel };
464
- }
465
- } catch {
466
- // ignore and continue
467
- }
468
- }
469
- return undefined;
470
- }
471
-
472
- function extractModuleDefinition(exports: Record<string, unknown>): ModuleDefinitionLike | undefined {
473
- const keys = ['module', 'moduleDefinition', 'default', 'backendModule'];
474
- for (const key of keys) {
475
- if (key in exports) {
476
- const value = exports[key as keyof typeof exports];
477
- if (value && typeof value === 'object') {
478
- return value as ModuleDefinitionLike;
479
- }
480
- }
481
- }
482
- return undefined;
483
- }
484
-
485
- async function readRequestBody(req: http.IncomingMessage): Promise<unknown> {
486
- const method = (req.method ?? 'GET').toUpperCase();
487
- if (method === 'GET' || method === 'HEAD') {
488
- return undefined;
489
- }
490
- const chunks: Uint8Array[] = [];
491
- for await (const chunk of req) {
492
- chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
493
- }
494
- if (chunks.length === 0) {
495
- return undefined;
496
- }
497
- const buffer = Buffer.concat(chunks);
498
- const contentType = String(req.headers['content-type'] ?? '');
499
- if (contentType.includes('application/json')) {
500
- try {
501
- return JSON.parse(buffer.toString('utf8'));
502
- } catch {
503
- return undefined;
504
- }
505
- }
506
- return buffer.toString('utf8');
507
- }
508
-
509
- function normalizePath(value: string | undefined): string {
510
- if (!value || value === '/') return '/';
511
- const trimmed = value.endsWith('/') ? value.slice(0, -1) : value;
512
- return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
513
20
  }
514
21
 
515
22
  const isMain = (() => {
@@ -7,7 +7,7 @@ export async function run(): Promise<void> {
7
7
  console.info('[job:nightly] ran at', new Date().toISOString());
8
8
  }
9
9
 
10
- // Execute when launched directly: `node build/backend/jobs/nightly/index.js`
10
+ // Execute when launched directly: `bun build/backend/jobs/nightly/index.js`
11
11
  const isMain = (() => {
12
12
  try {
13
13
  const argv1 = process.argv?.[1];
@@ -22,7 +22,7 @@ export async function loadJobs(): Promise<RegisteredJob[]> {
22
22
  async run() {
23
23
  const runner = await loadJobRunner(job.name);
24
24
  await runner();
25
- }
25
+ },
26
26
  }));
27
27
  }
28
28
 
@@ -39,8 +39,10 @@ async function readManifestJobs(): Promise<ManifestJobMetadata[]> {
39
39
  const pkgPath = path.join(root, 'package.json');
40
40
  try {
41
41
  const raw = await readFile(pkgPath, 'utf8');
42
- const pkg = JSON.parse(raw) as Record<string, any>;
43
- const jobs = pkg?.webstir?.moduleManifest?.jobs;
42
+ const pkg = JSON.parse(raw) as Record<string, unknown>;
43
+ const webstir = asRecord(pkg.webstir);
44
+ const moduleManifest = asRecord(webstir?.moduleManifest);
45
+ const jobs = moduleManifest?.jobs;
44
46
  if (!Array.isArray(jobs)) {
45
47
  return [];
46
48
  }
@@ -61,13 +63,22 @@ function normalizeManifestJob(job: unknown): ManifestJobMetadata | undefined {
61
63
  const schedule = typeof record.schedule === 'string' ? record.schedule : undefined;
62
64
  const description = typeof record.description === 'string' ? record.description : undefined;
63
65
  const priority =
64
- typeof record.priority === 'number' || typeof record.priority === 'string' ? record.priority : undefined;
66
+ typeof record.priority === 'number' || typeof record.priority === 'string'
67
+ ? record.priority
68
+ : undefined;
65
69
  return { name, schedule, description, priority };
66
70
  }
67
71
 
72
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
73
+ return typeof value === 'object' && value !== null
74
+ ? (value as Record<string, unknown>)
75
+ : undefined;
76
+ }
77
+
68
78
  async function loadJobRunner(jobName: string): Promise<() => Promise<void>> {
69
79
  const candidates = buildJobModuleCandidates(jobName);
70
80
  let lastError: unknown;
81
+ let loadedWithoutRunner = false;
71
82
  for (const candidate of candidates) {
72
83
  try {
73
84
  const imported = await import(candidate);
@@ -75,10 +86,16 @@ async function loadJobRunner(jobName: string): Promise<() => Promise<void>> {
75
86
  if (typeof fn === 'function') {
76
87
  return fn;
77
88
  }
89
+ loadedWithoutRunner = true;
78
90
  } catch (error) {
79
91
  lastError = error;
80
92
  }
81
93
  }
94
+ if (loadedWithoutRunner) {
95
+ throw new Error(
96
+ `[jobs] unable to load job '${jobName}': job module must export a run() or default function`,
97
+ );
98
+ }
82
99
  const reason = lastError instanceof Error ? lastError.message : 'unknown';
83
100
  throw new Error(`[jobs] unable to load job '${jobName}': ${reason}`);
84
101
  }
@@ -89,15 +106,11 @@ function buildJobModuleCandidates(jobName: string): string[] {
89
106
  `./${normalized}/index.js`,
90
107
  `./${normalized}/index.mjs`,
91
108
  `./${normalized}/index.ts`,
92
- `./${normalized}/index.mts`
109
+ `./${normalized}/index.mts`,
93
110
  ];
94
- return relPaths.map((rel) => new URL(rel, import.meta.url).href + `?t=${Date.now()}`);
111
+ return relPaths.map((rel) => `${new URL(rel, import.meta.url).href}?t=${Date.now()}`);
95
112
  }
96
113
 
97
114
  function normalizeJobSpecifier(name: string): string {
98
- return name
99
- .replace(/\\/g, '/')
100
- .replace(/\.\./g, '')
101
- .replace(/^\//, '')
102
- .replace(/\/$/, '');
115
+ return name.replace(/\\/g, '/').replace(/\.\./g, '').replace(/^\//, '').replace(/\/$/, '');
103
116
  }