@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.
- package/README.md +106 -79
- package/dist/add.d.ts +59 -0
- package/dist/add.js +626 -0
- package/dist/build/artifacts.d.ts +115 -1
- package/dist/build/artifacts.js +4 -4
- package/dist/build/entries.js +1 -1
- package/dist/build/pipeline.d.ts +33 -1
- package/dist/build/pipeline.js +307 -65
- package/dist/cache/diff.js +9 -8
- package/dist/cache/reporters.js +1 -1
- package/dist/deploy-cli.d.ts +2 -0
- package/dist/deploy-cli.js +86 -0
- package/dist/diagnostics/summary.js +2 -2
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/manifest/pipeline.js +103 -32
- package/dist/provider.js +35 -17
- package/dist/runtime/bun.d.ts +51 -0
- package/dist/runtime/bun.js +499 -0
- package/dist/runtime/core.d.ts +141 -0
- package/dist/runtime/core.js +316 -0
- package/dist/runtime/deploy-backend.d.ts +20 -0
- package/dist/runtime/deploy-backend.js +175 -0
- package/dist/runtime/deploy-shared.d.ts +43 -0
- package/dist/runtime/deploy-shared.js +75 -0
- package/dist/runtime/deploy-static.d.ts +2 -0
- package/dist/runtime/deploy-static.js +161 -0
- package/dist/runtime/deploy.d.ts +3 -0
- package/dist/runtime/deploy.js +91 -0
- package/dist/runtime/forms.d.ts +73 -0
- package/dist/runtime/forms.js +236 -0
- package/dist/runtime/request-hooks.d.ts +47 -0
- package/dist/runtime/request-hooks.js +102 -0
- package/dist/runtime/session-metadata.d.ts +13 -0
- package/dist/runtime/session-metadata.js +98 -0
- package/dist/runtime/session-runtime.d.ts +28 -0
- package/dist/runtime/session-runtime.js +180 -0
- package/dist/runtime/session.d.ts +83 -0
- package/dist/runtime/session.js +396 -0
- package/dist/runtime/views.d.ts +74 -0
- package/dist/runtime/views.js +221 -0
- package/dist/scaffold/assets.js +25 -21
- package/dist/testing/context.js +1 -1
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +100 -56
- package/dist/utils/bun.d.ts +2 -0
- package/dist/utils/bun.js +13 -0
- package/dist/watch.d.ts +13 -1
- package/dist/watch.js +345 -97
- package/dist/workspace.d.ts +8 -0
- package/dist/workspace.js +44 -3
- package/package.json +49 -14
- package/scripts/publish.sh +2 -92
- package/scripts/smoke.mjs +282 -107
- package/scripts/update-contract.sh +12 -10
- package/src/add.ts +964 -0
- package/src/build/artifacts.ts +49 -46
- package/src/build/entries.ts +12 -12
- package/src/build/pipeline.ts +779 -403
- package/src/cache/diff.ts +111 -105
- package/src/cache/reporters.ts +26 -26
- package/src/deploy-cli.ts +111 -0
- package/src/diagnostics/summary.ts +28 -22
- package/src/index.ts +11 -0
- package/src/manifest/pipeline.ts +328 -215
- package/src/provider.ts +115 -98
- package/src/runtime/bun.ts +793 -0
- package/src/runtime/core.ts +598 -0
- package/src/runtime/deploy-backend.ts +239 -0
- package/src/runtime/deploy-shared.ts +136 -0
- package/src/runtime/deploy-static.ts +191 -0
- package/src/runtime/deploy.ts +143 -0
- package/src/runtime/forms.ts +364 -0
- package/src/runtime/request-hooks.ts +165 -0
- package/src/runtime/session-metadata.ts +135 -0
- package/src/runtime/session-runtime.ts +267 -0
- package/src/runtime/session.ts +642 -0
- package/src/runtime/views.ts +385 -0
- package/src/scaffold/assets.ts +77 -73
- package/src/testing/context.js +8 -9
- package/src/testing/context.ts +9 -9
- package/src/testing/index.d.ts +14 -3
- package/src/testing/index.js +254 -175
- package/src/testing/index.ts +298 -195
- package/src/testing/types.d.ts +18 -19
- package/src/testing/types.ts +18 -18
- package/src/utils/bun.ts +26 -0
- package/src/watch.ts +503 -99
- package/src/workspace.ts +59 -3
- package/templates/backend/.env.example +15 -0
- package/templates/backend/auth/adapter.ts +335 -36
- package/templates/backend/db/connection.ts +190 -65
- package/templates/backend/db/migrate.ts +149 -43
- package/templates/backend/db/types.d.ts +1 -1
- package/templates/backend/env.ts +132 -20
- package/templates/backend/functions/hello/index.ts +1 -2
- package/templates/backend/index.ts +15 -508
- package/templates/backend/jobs/nightly/index.ts +1 -1
- package/templates/backend/jobs/runtime.ts +24 -11
- package/templates/backend/jobs/scheduler.ts +208 -46
- package/templates/backend/module.ts +227 -13
- package/templates/backend/observability/logger.ts +2 -12
- package/templates/backend/observability/metrics.ts +8 -5
- package/templates/backend/session/sqlite.ts +152 -0
- package/templates/backend/session/store.ts +45 -0
- package/templates/backend/tsconfig.json +1 -1
- package/tests/add.test.js +327 -0
- package/tests/authAdapter.test.js +315 -0
- package/tests/bundlerParity.test.js +217 -0
- package/tests/cacheReporter.test.js +10 -10
- package/tests/dbConnection.test.js +209 -0
- package/tests/deploy.test.js +357 -0
- package/tests/envLoader.test.js +271 -17
- package/tests/integration.test.js +2432 -3
- package/tests/jobsScheduler.test.js +253 -0
- package/tests/manifest.test.js +287 -12
- package/tests/migrationRunner.test.js +249 -0
- package/tests/sessionScaffoldStore.test.js +752 -0
- package/tests/sessionStore.test.js +490 -0
- package/tests/testing.test.js +252 -0
- package/tests/watch.test.js +192 -32
- package/tsconfig.json +3 -10
- package/templates/backend/server/fastify.ts +0 -288
|
@@ -1,515 +1,22 @@
|
|
|
1
|
-
import
|
|
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
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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: `
|
|
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,
|
|
43
|
-
const
|
|
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'
|
|
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
|
|
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
|
}
|