@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,532 @@
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';
5
+
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
+ }
100
+
101
+ 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
+ '$'
442
+ );
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
+ }
514
+
515
+ const isMain = (() => {
516
+ try {
517
+ const argv1 = process.argv?.[1];
518
+ if (!argv1) return false;
519
+ const here = new URL(import.meta.url);
520
+ const run = new URL(`file://${argv1}`);
521
+ return here.pathname === run.pathname;
522
+ } catch {
523
+ return false;
524
+ }
525
+ })();
526
+
527
+ if (isMain) {
528
+ start().catch((err) => {
529
+ console.error(err);
530
+ process.exitCode = 1;
531
+ });
532
+ }
@@ -0,0 +1,28 @@
1
+ // Example job entry (scheduled by your orchestrator)
2
+ // Update `webstir.moduleManifest.jobs` in package.json to point to this job with a schedule, e.g.:
3
+ // { "name": "nightly", "schedule": "0 0 * * *", "description": "Nightly maintenance" }
4
+
5
+ export async function run(): Promise<void> {
6
+ // Do some nightly maintenance work here
7
+ console.info('[job:nightly] ran at', new Date().toISOString());
8
+ }
9
+
10
+ // Execute when launched directly: `node build/backend/jobs/nightly/index.js`
11
+ const isMain = (() => {
12
+ try {
13
+ const argv1 = process.argv?.[1];
14
+ if (!argv1) return false;
15
+ const here = new URL(import.meta.url);
16
+ const run = new URL(`file://${argv1}`);
17
+ return here.pathname === run.pathname;
18
+ } catch {
19
+ return false;
20
+ }
21
+ })();
22
+
23
+ if (isMain) {
24
+ run().catch((err) => {
25
+ console.error(err);
26
+ process.exitCode = 1;
27
+ });
28
+ }
@@ -0,0 +1,103 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { resolveWorkspaceRoot, loadEnv } from '../env.js';
5
+
6
+ export interface ManifestJobMetadata {
7
+ readonly name: string;
8
+ readonly schedule?: string;
9
+ readonly description?: string;
10
+ readonly priority?: number | string;
11
+ }
12
+
13
+ export interface RegisteredJob extends ManifestJobMetadata {
14
+ run(): Promise<void>;
15
+ }
16
+
17
+ export async function loadJobs(): Promise<RegisteredJob[]> {
18
+ await ensureEnvLoaded();
19
+ const manifestJobs = await readManifestJobs();
20
+ return manifestJobs.map((job) => ({
21
+ ...job,
22
+ async run() {
23
+ const runner = await loadJobRunner(job.name);
24
+ await runner();
25
+ }
26
+ }));
27
+ }
28
+
29
+ async function ensureEnvLoaded(): Promise<void> {
30
+ try {
31
+ loadEnv();
32
+ } catch {
33
+ // env loading is best-effort for job runs
34
+ }
35
+ }
36
+
37
+ async function readManifestJobs(): Promise<ManifestJobMetadata[]> {
38
+ const root = resolveWorkspaceRoot();
39
+ const pkgPath = path.join(root, 'package.json');
40
+ try {
41
+ const raw = await readFile(pkgPath, 'utf8');
42
+ const pkg = JSON.parse(raw) as Record<string, any>;
43
+ const jobs = pkg?.webstir?.moduleManifest?.jobs;
44
+ if (!Array.isArray(jobs)) {
45
+ return [];
46
+ }
47
+ return jobs
48
+ .map((job) => normalizeManifestJob(job))
49
+ .filter((job): job is ManifestJobMetadata => job !== undefined);
50
+ } catch (error) {
51
+ console.warn('[jobs] unable to read package.json for job metadata:', (error as Error).message);
52
+ return [];
53
+ }
54
+ }
55
+
56
+ function normalizeManifestJob(job: unknown): ManifestJobMetadata | undefined {
57
+ if (!job || typeof job !== 'object') return undefined;
58
+ const record = job as Record<string, unknown>;
59
+ const name = typeof record.name === 'string' ? record.name.trim() : '';
60
+ if (!name) return undefined;
61
+ const schedule = typeof record.schedule === 'string' ? record.schedule : undefined;
62
+ const description = typeof record.description === 'string' ? record.description : undefined;
63
+ const priority =
64
+ typeof record.priority === 'number' || typeof record.priority === 'string' ? record.priority : undefined;
65
+ return { name, schedule, description, priority };
66
+ }
67
+
68
+ async function loadJobRunner(jobName: string): Promise<() => Promise<void>> {
69
+ const candidates = buildJobModuleCandidates(jobName);
70
+ let lastError: unknown;
71
+ for (const candidate of candidates) {
72
+ try {
73
+ const imported = await import(candidate);
74
+ const fn = (imported.run ?? imported.default) as (() => Promise<void>) | undefined;
75
+ if (typeof fn === 'function') {
76
+ return fn;
77
+ }
78
+ } catch (error) {
79
+ lastError = error;
80
+ }
81
+ }
82
+ const reason = lastError instanceof Error ? lastError.message : 'unknown';
83
+ throw new Error(`[jobs] unable to load job '${jobName}': ${reason}`);
84
+ }
85
+
86
+ function buildJobModuleCandidates(jobName: string): string[] {
87
+ const normalized = normalizeJobSpecifier(jobName);
88
+ const relPaths = [
89
+ `./${normalized}/index.js`,
90
+ `./${normalized}/index.mjs`,
91
+ `./${normalized}/index.ts`,
92
+ `./${normalized}/index.mts`
93
+ ];
94
+ return relPaths.map((rel) => new URL(rel, import.meta.url).href + `?t=${Date.now()}`);
95
+ }
96
+
97
+ function normalizeJobSpecifier(name: string): string {
98
+ return name
99
+ .replace(/\\/g, '/')
100
+ .replace(/\.\./g, '')
101
+ .replace(/^\//, '')
102
+ .replace(/\/$/, '');
103
+ }