@webstir-io/webstir-backend 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +427 -0
- package/dist/build/artifacts.d.ts +113 -0
- package/dist/build/artifacts.js +53 -0
- package/dist/build/entries.d.ts +1 -0
- package/dist/build/entries.js +17 -0
- package/dist/build/pipeline.d.ts +31 -0
- package/dist/build/pipeline.js +424 -0
- package/dist/cache/diff.d.ts +4 -0
- package/dist/cache/diff.js +114 -0
- package/dist/cache/reporters.d.ts +12 -0
- package/dist/cache/reporters.js +23 -0
- package/dist/diagnostics/summary.d.ts +6 -0
- package/dist/diagnostics/summary.js +27 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/manifest/pipeline.d.ts +13 -0
- package/dist/manifest/pipeline.js +224 -0
- package/dist/provider.d.ts +2 -0
- package/dist/provider.js +101 -0
- package/dist/scaffold/assets.d.ts +2 -0
- package/dist/scaffold/assets.js +77 -0
- package/dist/testing/context.d.ts +3 -0
- package/dist/testing/context.js +14 -0
- package/dist/testing/index.d.ts +6 -0
- package/dist/testing/index.js +208 -0
- package/dist/testing/types.d.ts +28 -0
- package/dist/testing/types.js +1 -0
- package/dist/watch.d.ts +8 -0
- package/dist/watch.js +159 -0
- package/dist/workspace.d.ts +4 -0
- package/dist/workspace.js +15 -0
- package/package.json +74 -0
- package/scripts/publish.sh +99 -0
- package/scripts/smoke.mjs +241 -0
- package/scripts/update-contract.sh +122 -0
- package/src/build/artifacts.ts +67 -0
- package/src/build/entries.ts +19 -0
- package/src/build/pipeline.ts +507 -0
- package/src/cache/diff.ts +128 -0
- package/src/cache/reporters.ts +41 -0
- package/src/diagnostics/summary.ts +32 -0
- package/src/index.ts +2 -0
- package/src/manifest/pipeline.ts +270 -0
- package/src/provider.ts +124 -0
- package/src/scaffold/assets.ts +81 -0
- package/src/testing/context.d.ts +3 -0
- package/src/testing/context.js +14 -0
- package/src/testing/context.ts +17 -0
- package/src/testing/index.d.ts +6 -0
- package/src/testing/index.js +208 -0
- package/src/testing/index.ts +252 -0
- package/src/testing/types.d.ts +28 -0
- package/src/testing/types.js +1 -0
- package/src/testing/types.ts +32 -0
- package/src/watch.ts +177 -0
- package/src/workspace.ts +22 -0
- package/templates/backend/.env.example +13 -0
- package/templates/backend/auth/adapter.ts +160 -0
- package/templates/backend/db/connection.ts +99 -0
- package/templates/backend/db/migrate.ts +231 -0
- package/templates/backend/db/migrations/0001-example.ts +17 -0
- package/templates/backend/db/types.d.ts +2 -0
- package/templates/backend/env.ts +174 -0
- package/templates/backend/functions/hello/index.ts +29 -0
- package/templates/backend/index.ts +532 -0
- package/templates/backend/jobs/nightly/index.ts +28 -0
- package/templates/backend/jobs/runtime.ts +103 -0
- package/templates/backend/jobs/scheduler.ts +193 -0
- package/templates/backend/module.ts +87 -0
- package/templates/backend/observability/logger.ts +24 -0
- package/templates/backend/observability/metrics.ts +78 -0
- package/templates/backend/server/fastify.ts +288 -0
- package/templates/backend/tsconfig.json +19 -0
- package/tests/cacheReporter.test.js +89 -0
- package/tests/envLoader.test.js +64 -0
- package/tests/integration.test.js +108 -0
- package/tests/manifest.test.js +159 -0
- package/tests/watch.test.js +100 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { setInterval } from 'node:timers';
|
|
3
|
+
|
|
4
|
+
import { loadJobs } from './runtime.js';
|
|
5
|
+
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
|
|
8
|
+
async function main() {
|
|
9
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
10
|
+
printHelp();
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const jobs = await loadJobs();
|
|
15
|
+
if (jobs.length === 0) {
|
|
16
|
+
console.info('[jobs] no jobs registered in webstir.moduleManifest.jobs');
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (args.includes('--list')) {
|
|
21
|
+
listJobs(jobs);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const jobName = parseOption('--job');
|
|
26
|
+
const watch = args.includes('--watch');
|
|
27
|
+
const runAll = args.includes('--all') || (!jobName && !watch);
|
|
28
|
+
|
|
29
|
+
if (watch) {
|
|
30
|
+
await startWatch(jobs, jobName);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (jobName) {
|
|
35
|
+
await runNamedJob(jobs, jobName);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (runAll) {
|
|
40
|
+
for (const job of jobs) {
|
|
41
|
+
await runJob(job);
|
|
42
|
+
}
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function startWatch(jobs: Awaited<ReturnType<typeof loadJobs>>, jobName?: string) {
|
|
48
|
+
const filtered = jobName ? jobs.filter((job) => job.name === jobName) : jobs;
|
|
49
|
+
if (filtered.length === 0) {
|
|
50
|
+
console.error(jobName ? `[jobs] job '${jobName}' not found` : '[jobs] no jobs available to watch');
|
|
51
|
+
process.exitCode = 1;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const timers = filtered.map((job) => scheduleJob(job));
|
|
56
|
+
if (timers.every((timer) => timer === undefined)) {
|
|
57
|
+
console.warn('[jobs] no jobs have schedules compatible with the built-in watcher. Use an external scheduler.');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.info('[jobs] watching jobs:', filtered.map((job) => job.name).join(', '));
|
|
62
|
+
process.stdin.resume();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function scheduleJob(job: Awaited<ReturnType<typeof loadJobs>>[number]) {
|
|
66
|
+
const intervalMs = toInterval(job.schedule);
|
|
67
|
+
if (intervalMs === null) {
|
|
68
|
+
console.info(
|
|
69
|
+
`[jobs] schedule '${job.schedule ?? 'unspecified'}' is not supported by the built-in watcher. Run manually or use an external scheduler.`
|
|
70
|
+
);
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (intervalMs === 0) {
|
|
75
|
+
void runJob(job);
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
void runJob(job);
|
|
80
|
+
return setInterval(() => {
|
|
81
|
+
void runJob(job);
|
|
82
|
+
}, intervalMs);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function runNamedJob(jobs: Awaited<ReturnType<typeof loadJobs>>, jobName: string) {
|
|
86
|
+
const job = jobs.find((item) => item.name === jobName);
|
|
87
|
+
if (!job) {
|
|
88
|
+
console.error(`[jobs] job '${jobName}' not found`);
|
|
89
|
+
process.exitCode = 1;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
await runJob(job);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function runJob(job: Awaited<ReturnType<typeof loadJobs>>[number]) {
|
|
96
|
+
const startedAt = new Date();
|
|
97
|
+
console.info(`[jobs] running ${job.name} (schedule: ${job.schedule ?? 'manual'})`);
|
|
98
|
+
try {
|
|
99
|
+
await job.run();
|
|
100
|
+
console.info(`[jobs] ${job.name} completed in ${(Date.now() - startedAt.getTime()).toFixed(0)}ms`);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
console.error(`[jobs] ${job.name} failed: ${(error as Error).message}`);
|
|
103
|
+
process.exitCode = 1;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function listJobs(jobs: Awaited<ReturnType<typeof loadJobs>>) {
|
|
108
|
+
for (const job of jobs) {
|
|
109
|
+
console.info(`- ${job.name}${job.schedule ? ` (${job.schedule})` : ''}${job.description ? ` — ${job.description}` : ''}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseOption(flag: string): string | undefined {
|
|
114
|
+
const index = args.findIndex((arg) => arg === flag);
|
|
115
|
+
if (index !== -1 && args[index + 1] && !args[index + 1].startsWith('-')) {
|
|
116
|
+
return args[index + 1];
|
|
117
|
+
}
|
|
118
|
+
const prefix = `${flag}=`;
|
|
119
|
+
const inline = args.find((arg) => arg.startsWith(prefix));
|
|
120
|
+
if (inline) {
|
|
121
|
+
return inline.slice(prefix.length);
|
|
122
|
+
}
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function toInterval(schedule: string | undefined): number | null {
|
|
127
|
+
if (!schedule) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const trimmed = schedule.trim().toLowerCase();
|
|
131
|
+
if (!trimmed) return null;
|
|
132
|
+
|
|
133
|
+
if (trimmed.startsWith('@')) {
|
|
134
|
+
switch (trimmed.slice(1)) {
|
|
135
|
+
case 'hourly':
|
|
136
|
+
return 60 * 60 * 1000;
|
|
137
|
+
case 'daily':
|
|
138
|
+
case 'midnight':
|
|
139
|
+
return 24 * 60 * 60 * 1000;
|
|
140
|
+
case 'weekly':
|
|
141
|
+
return 7 * 24 * 60 * 60 * 1000;
|
|
142
|
+
case 'reboot':
|
|
143
|
+
return 0;
|
|
144
|
+
default:
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const rateMatch = /^rate\((\d+)\s+(second|seconds|minute|minutes|hour|hours)\)$/.exec(trimmed);
|
|
150
|
+
if (rateMatch) {
|
|
151
|
+
const value = Number(rateMatch[1]);
|
|
152
|
+
const unit = rateMatch[2];
|
|
153
|
+
const multiplier =
|
|
154
|
+
unit.startsWith('second') ? 1000 : unit.startsWith('minute') ? 60 * 1000 : unit.startsWith('hour') ? 60 * 60 * 1000 : 0;
|
|
155
|
+
return value > 0 && multiplier > 0 ? value * multiplier : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function printHelp() {
|
|
162
|
+
console.info(`Usage:
|
|
163
|
+
node build/backend/jobs/scheduler.js [--list]
|
|
164
|
+
node build/backend/jobs/scheduler.js --job <name>
|
|
165
|
+
node build/backend/jobs/scheduler.js --watch [--job <name>]
|
|
166
|
+
|
|
167
|
+
Options:
|
|
168
|
+
--list Show registered jobs and exit
|
|
169
|
+
--job <name> Run a specific job immediately (or watch a single job)
|
|
170
|
+
--all Run all jobs once (default when no options are provided)
|
|
171
|
+
--watch Run supported jobs on an interval (supports @hourly/@daily/@weekly/@reboot and rate(...) syntax)
|
|
172
|
+
--help Display this message
|
|
173
|
+
`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const isMain = (() => {
|
|
177
|
+
try {
|
|
178
|
+
const argv1 = process.argv?.[1];
|
|
179
|
+
if (!argv1) return false;
|
|
180
|
+
const here = new URL(import.meta.url);
|
|
181
|
+
const run = new URL(`file://${argv1}`);
|
|
182
|
+
return here.pathname === run.pathname;
|
|
183
|
+
} catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
})();
|
|
187
|
+
|
|
188
|
+
if (isMain) {
|
|
189
|
+
main().catch((error) => {
|
|
190
|
+
console.error('[jobs] scheduler failed:', error);
|
|
191
|
+
process.exitCode = 1;
|
|
192
|
+
});
|
|
193
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Example manifest + route definition. Update the values to match your backend.
|
|
2
|
+
// When this file is present, the server scaffold loads build/backend/module.js,
|
|
3
|
+
// announces the manifest, and mounts every route definition automatically.
|
|
4
|
+
|
|
5
|
+
interface RouteHandlerResult {
|
|
6
|
+
status?: number;
|
|
7
|
+
headers?: Record<string, string>;
|
|
8
|
+
body?: unknown;
|
|
9
|
+
errors?: { code: string; message: string; details?: unknown }[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type RouteHandler = (ctx: RouteContext) => Promise<RouteHandlerResult> | RouteHandlerResult;
|
|
13
|
+
|
|
14
|
+
interface RouteContext {
|
|
15
|
+
params: Record<string, string>;
|
|
16
|
+
query: Record<string, string>;
|
|
17
|
+
body: unknown;
|
|
18
|
+
auth?: {
|
|
19
|
+
userId?: string;
|
|
20
|
+
email?: string;
|
|
21
|
+
scopes: readonly string[];
|
|
22
|
+
roles: readonly string[];
|
|
23
|
+
};
|
|
24
|
+
requestId: string;
|
|
25
|
+
env: {
|
|
26
|
+
get: (name: string) => string | undefined;
|
|
27
|
+
require: (name: string) => string;
|
|
28
|
+
entries: () => Record<string, string | undefined>;
|
|
29
|
+
};
|
|
30
|
+
logger: {
|
|
31
|
+
info: (message: string, metadata?: Record<string, unknown>) => void;
|
|
32
|
+
warn: (message: string, metadata?: Record<string, unknown>) => void;
|
|
33
|
+
error: (message: string, metadata?: Record<string, unknown>) => void;
|
|
34
|
+
};
|
|
35
|
+
now: () => Date;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const routes = [
|
|
39
|
+
{
|
|
40
|
+
definition: {
|
|
41
|
+
name: 'helloRoute',
|
|
42
|
+
method: 'GET',
|
|
43
|
+
path: '/hello/:name',
|
|
44
|
+
summary: 'Simple hello route',
|
|
45
|
+
description: 'Demonstrates manifest wiring + request context metadata.'
|
|
46
|
+
},
|
|
47
|
+
handler: async (ctx: RouteContext) => {
|
|
48
|
+
if (!ctx.auth) {
|
|
49
|
+
return {
|
|
50
|
+
status: 401,
|
|
51
|
+
errors: [{ code: 'auth', message: 'Sign-in required to access /hello' }]
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const name = ctx.params.name ?? 'world';
|
|
55
|
+
ctx.logger.info('hello route invoked', { name, requestId: ctx.requestId, userId: ctx.auth.userId });
|
|
56
|
+
return {
|
|
57
|
+
status: 200,
|
|
58
|
+
body: {
|
|
59
|
+
message: `Hello ${name}`,
|
|
60
|
+
greetedAt: ctx.now().toISOString(),
|
|
61
|
+
user: ctx.auth.userId ?? 'anonymous'
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
const jobs = [
|
|
69
|
+
{
|
|
70
|
+
name: 'nightly',
|
|
71
|
+
schedule: '0 0 * * *',
|
|
72
|
+
description: 'Example nightly maintenance job metadata surfaced in the manifest.'
|
|
73
|
+
}
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
export const module = {
|
|
77
|
+
manifest: {
|
|
78
|
+
contractVersion: '1.0.0',
|
|
79
|
+
name: '@demo/backend',
|
|
80
|
+
version: '0.1.0',
|
|
81
|
+
kind: 'backend',
|
|
82
|
+
capabilities: ['http', 'auth', 'db'],
|
|
83
|
+
routes: routes.map((route) => route.definition),
|
|
84
|
+
jobs
|
|
85
|
+
},
|
|
86
|
+
routes
|
|
87
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type http from 'node:http';
|
|
2
|
+
import pino, { stdTimeFunctions, type Logger } from 'pino';
|
|
3
|
+
|
|
4
|
+
import type { AppEnv } from '../env.js';
|
|
5
|
+
|
|
6
|
+
export function createBaseLogger(env: AppEnv): Logger {
|
|
7
|
+
return pino({
|
|
8
|
+
level: env.logging.level,
|
|
9
|
+
base: {
|
|
10
|
+
service: env.logging.serviceName,
|
|
11
|
+
environment: env.NODE_ENV
|
|
12
|
+
},
|
|
13
|
+
timestamp: stdTimeFunctions.isoTime
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createRequestLogger(baseLogger: Logger, options: { requestId: string; req: http.IncomingMessage; route?: string }): Logger {
|
|
18
|
+
return baseLogger.child({
|
|
19
|
+
requestId: options.requestId,
|
|
20
|
+
method: options.req.method ?? 'GET',
|
|
21
|
+
path: options.req.url ?? '/',
|
|
22
|
+
route: options.route
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { MetricsConfig } from '../env.js';
|
|
2
|
+
|
|
3
|
+
export interface RequestMetricsEvent {
|
|
4
|
+
method: string;
|
|
5
|
+
route: string;
|
|
6
|
+
status: number;
|
|
7
|
+
durationMs: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface MetricsSnapshot {
|
|
11
|
+
enabled: true;
|
|
12
|
+
totalRequests: number;
|
|
13
|
+
errorCount: number;
|
|
14
|
+
averageDurationMs: number;
|
|
15
|
+
p95DurationMs: number;
|
|
16
|
+
byStatus: Record<string, number>;
|
|
17
|
+
windowSize: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface MetricsTracker {
|
|
21
|
+
record(event: RequestMetricsEvent): void;
|
|
22
|
+
snapshot(): MetricsSnapshot | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createMetricsTracker(config: MetricsConfig): MetricsTracker {
|
|
26
|
+
if (!config.enabled) {
|
|
27
|
+
return {
|
|
28
|
+
record() {
|
|
29
|
+
// no-op
|
|
30
|
+
},
|
|
31
|
+
snapshot() {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let totalRequests = 0;
|
|
38
|
+
let errorCount = 0;
|
|
39
|
+
const byStatus = new Map<number, number>();
|
|
40
|
+
const durations: number[] = [];
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
record(event) {
|
|
44
|
+
totalRequests++;
|
|
45
|
+
if (event.status >= 500) {
|
|
46
|
+
errorCount++;
|
|
47
|
+
}
|
|
48
|
+
byStatus.set(event.status, (byStatus.get(event.status) ?? 0) + 1);
|
|
49
|
+
durations.push(event.durationMs);
|
|
50
|
+
if (durations.length > config.windowSize) {
|
|
51
|
+
durations.shift();
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
snapshot() {
|
|
55
|
+
const counts = Object.fromEntries(
|
|
56
|
+
[...byStatus.entries()].map(([status, count]) => [String(status), count])
|
|
57
|
+
);
|
|
58
|
+
const averageDurationMs = durations.length > 0 ? durations.reduce((sum, value) => sum + value, 0) / durations.length : 0;
|
|
59
|
+
const p95DurationMs = durations.length > 0 ? percentile(durations, 0.95) : 0;
|
|
60
|
+
return {
|
|
61
|
+
enabled: true,
|
|
62
|
+
totalRequests,
|
|
63
|
+
errorCount,
|
|
64
|
+
averageDurationMs,
|
|
65
|
+
p95DurationMs,
|
|
66
|
+
byStatus: counts,
|
|
67
|
+
windowSize: config.windowSize
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function percentile(values: readonly number[], ratio: number): number {
|
|
74
|
+
if (values.length === 0) return 0;
|
|
75
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
76
|
+
const index = Math.min(sorted.length - 1, Math.floor(ratio * (sorted.length - 1)));
|
|
77
|
+
return sorted[index];
|
|
78
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// Optional Fastify server scaffold for richer routing
|
|
2
|
+
// Rename or import into your backend index to use.
|
|
3
|
+
import Fastify from 'fastify';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
import { loadEnv } from '../env.js';
|
|
7
|
+
|
|
8
|
+
type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error';
|
|
9
|
+
|
|
10
|
+
interface Logger {
|
|
11
|
+
readonly level: LogLevel;
|
|
12
|
+
log(level: LogLevel, message: string, metadata?: Record<string, unknown>): void;
|
|
13
|
+
debug(message: string, metadata?: Record<string, unknown>): void;
|
|
14
|
+
info(message: string, metadata?: Record<string, unknown>): void;
|
|
15
|
+
warn(message: string, metadata?: Record<string, unknown>): void;
|
|
16
|
+
error(message: string, metadata?: Record<string, unknown>): void;
|
|
17
|
+
with(bindings: Record<string, unknown>): Logger;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface EnvAccessor {
|
|
21
|
+
get(name: string): string | undefined;
|
|
22
|
+
require(name: string): string;
|
|
23
|
+
entries(): Record<string, string | undefined>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ModuleRouteDefinition {
|
|
27
|
+
name?: string;
|
|
28
|
+
method?: string;
|
|
29
|
+
path?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ModuleRoute {
|
|
33
|
+
definition?: ModuleRouteDefinition;
|
|
34
|
+
handler?: (ctx: Record<string, unknown>) => Promise<any> | any;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ModuleManifestLike {
|
|
38
|
+
name?: string;
|
|
39
|
+
version?: string;
|
|
40
|
+
capabilities?: string[];
|
|
41
|
+
routes?: ModuleRouteDefinition[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ModuleDefinitionLike {
|
|
45
|
+
manifest?: ModuleManifestLike;
|
|
46
|
+
routes?: ModuleRoute[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ManifestSummary {
|
|
50
|
+
name?: string;
|
|
51
|
+
version?: string;
|
|
52
|
+
routes: number;
|
|
53
|
+
capabilities?: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type ReadinessStatus = 'booting' | 'ready' | 'error';
|
|
57
|
+
type ReadinessTracker = ReturnType<typeof createReadinessTracker>;
|
|
58
|
+
|
|
59
|
+
export async function start(): Promise<void> {
|
|
60
|
+
const env = loadEnv();
|
|
61
|
+
const port = env.PORT;
|
|
62
|
+
const mode = env.NODE_ENV;
|
|
63
|
+
const readiness = createReadinessTracker();
|
|
64
|
+
readiness.booting();
|
|
65
|
+
|
|
66
|
+
const app = Fastify({ logger: false });
|
|
67
|
+
|
|
68
|
+
app.get('/api/health', async () => ({ ok: true, uptime: process.uptime() }));
|
|
69
|
+
app.get('/healthz', async () => ({ ok: true }));
|
|
70
|
+
|
|
71
|
+
let manifestSummary: ManifestSummary | undefined;
|
|
72
|
+
|
|
73
|
+
app.get('/readyz', async (_req, reply) => {
|
|
74
|
+
const snapshot = readiness.snapshot();
|
|
75
|
+
const statusCode = snapshot.status === 'ready' ? 200 : 503;
|
|
76
|
+
reply.code(statusCode);
|
|
77
|
+
return { status: snapshot.status, message: snapshot.message, manifest: manifestSummary };
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const definition = await tryLoadModuleDefinition();
|
|
82
|
+
if (definition) {
|
|
83
|
+
manifestSummary = summarizeManifest(definition.manifest, definition.routes);
|
|
84
|
+
logManifestSummary(definition.manifest, definition.routes);
|
|
85
|
+
mountRoutes(app, definition);
|
|
86
|
+
} else {
|
|
87
|
+
console.info('[fastify] no module definition found. Routes will be empty.');
|
|
88
|
+
}
|
|
89
|
+
} catch (error) {
|
|
90
|
+
readiness.error((error as Error).message ?? 'module load failed');
|
|
91
|
+
console.error('[fastify] failed to load module definition:', error);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
await app.listen({ port, host: '0.0.0.0' });
|
|
95
|
+
|
|
96
|
+
if (readiness.snapshot().status !== 'error') {
|
|
97
|
+
readiness.ready();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Dev runner watches for this readiness line
|
|
101
|
+
console.info('API server running');
|
|
102
|
+
console.info(`[webstir-backend] mode=${mode} port=${port}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function mountRoutes(app: import('fastify').FastifyInstance, definition: ModuleDefinitionLike) {
|
|
106
|
+
const routes = Array.isArray(definition?.routes) ? definition.routes : [];
|
|
107
|
+
for (const r of routes) {
|
|
108
|
+
try {
|
|
109
|
+
const method = String(r.definition?.method ?? 'GET').toUpperCase();
|
|
110
|
+
const url = String(r.definition?.path ?? '/');
|
|
111
|
+
const handler = r.handler;
|
|
112
|
+
if (typeof handler !== 'function') continue;
|
|
113
|
+
|
|
114
|
+
app.route({
|
|
115
|
+
method: method as any,
|
|
116
|
+
url,
|
|
117
|
+
handler: async (req, reply) => {
|
|
118
|
+
const requestId = extractRequestId(req);
|
|
119
|
+
reply.header('x-request-id', requestId);
|
|
120
|
+
const envAccessor = createEnvAccessor();
|
|
121
|
+
const ctx: Record<string, unknown> = {
|
|
122
|
+
request: req,
|
|
123
|
+
reply,
|
|
124
|
+
auth: undefined,
|
|
125
|
+
session: null,
|
|
126
|
+
db: {},
|
|
127
|
+
env: envAccessor,
|
|
128
|
+
logger: createRequestLogger(requestId),
|
|
129
|
+
requestId,
|
|
130
|
+
now: () => new Date(),
|
|
131
|
+
params: (req as any).params ?? {},
|
|
132
|
+
query: (req as any).query ?? {},
|
|
133
|
+
body: (req as any).body ?? {}
|
|
134
|
+
};
|
|
135
|
+
const result = await handler(ctx);
|
|
136
|
+
const status = result?.status ?? (result?.errors ? 400 : 200);
|
|
137
|
+
const headers = result?.headers ?? { 'content-type': 'application/json' };
|
|
138
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
139
|
+
reply.header(k, String(v));
|
|
140
|
+
}
|
|
141
|
+
if (result?.errors) {
|
|
142
|
+
reply.code(status).send({ errors: result.errors });
|
|
143
|
+
} else {
|
|
144
|
+
reply.code(status).send(result?.body ?? null);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
console.info(`[fastify] mounted ${method} ${url}`);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
console.warn('[fastify] failed to mount route', error);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function tryLoadModuleDefinition(): Promise<ModuleDefinitionLike | undefined> {
|
|
156
|
+
const candidates = ['../module.js', '../module/index.js'];
|
|
157
|
+
for (const rel of candidates) {
|
|
158
|
+
try {
|
|
159
|
+
const url = new URL(rel, import.meta.url);
|
|
160
|
+
const mod = await import(url.toString());
|
|
161
|
+
const def = (mod && (mod.module || mod.moduleDefinition || mod.default)) as ModuleDefinitionLike;
|
|
162
|
+
if (def && typeof def === 'object') return def;
|
|
163
|
+
} catch {
|
|
164
|
+
// ignore and try next
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function summarizeManifest(manifest?: ModuleManifestLike, routes?: ModuleRoute[]): ManifestSummary | undefined {
|
|
171
|
+
if (!manifest) return undefined;
|
|
172
|
+
const routeCount = Array.isArray(manifest.routes) ? manifest.routes.length : Array.isArray(routes) ? routes.length : 0;
|
|
173
|
+
return {
|
|
174
|
+
name: manifest.name,
|
|
175
|
+
version: manifest.version,
|
|
176
|
+
routes: routeCount,
|
|
177
|
+
capabilities: manifest.capabilities && manifest.capabilities.length > 0 ? manifest.capabilities : undefined
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function logManifestSummary(manifest: ModuleManifestLike | undefined, routes?: ModuleRoute[]): void {
|
|
182
|
+
if (!manifest) {
|
|
183
|
+
console.info('[fastify] manifest metadata not found.');
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const caps = manifest.capabilities?.length ? ` [${manifest.capabilities.join(', ')}]` : '';
|
|
187
|
+
const count = Array.isArray(manifest.routes) ? manifest.routes.length : Array.isArray(routes) ? routes.length : 0;
|
|
188
|
+
console.info(`[fastify] manifest name=${manifest.name ?? 'unknown'} routes=${count}${caps}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function createEnvAccessor(): EnvAccessor {
|
|
192
|
+
return {
|
|
193
|
+
get: (name) => process.env[name],
|
|
194
|
+
require: (name) => {
|
|
195
|
+
const value = process.env[name];
|
|
196
|
+
if (value === undefined) {
|
|
197
|
+
throw new Error(`Missing required env var ${name}`);
|
|
198
|
+
}
|
|
199
|
+
return value;
|
|
200
|
+
},
|
|
201
|
+
entries: () => ({ ...process.env })
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function createRequestLogger(requestId: string, bindings: Record<string, unknown> = {}): Logger {
|
|
206
|
+
const logWithLevel = (level: LogLevel, message: string, metadata?: Record<string, unknown>) => {
|
|
207
|
+
const bindingKeys = Object.keys(bindings);
|
|
208
|
+
const suffix = bindingKeys.length ? ` ${bindingKeys.map((k) => `${k}=${JSON.stringify(bindings[k])}`).join(' ')}` : '';
|
|
209
|
+
const writer = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log;
|
|
210
|
+
if (metadata) {
|
|
211
|
+
writer(`[${level}] [request ${requestId}] ${message}${suffix}`, metadata);
|
|
212
|
+
} else {
|
|
213
|
+
writer(`[${level}] [request ${requestId}] ${message}${suffix}`);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
level: 'info',
|
|
219
|
+
log: logWithLevel,
|
|
220
|
+
debug: (message, metadata) => logWithLevel('debug', message, metadata),
|
|
221
|
+
info: (message, metadata) => logWithLevel('info', message, metadata),
|
|
222
|
+
warn: (message, metadata) => logWithLevel('warn', message, metadata),
|
|
223
|
+
error: (message, metadata) => logWithLevel('error', message, metadata),
|
|
224
|
+
with(extra) {
|
|
225
|
+
return createRequestLogger(requestId, { ...bindings, ...extra });
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function extractRequestId(req: { id?: string; headers?: Record<string, unknown> }): string {
|
|
231
|
+
if (req && typeof req.id === 'string' && req.id.length > 0) {
|
|
232
|
+
return req.id;
|
|
233
|
+
}
|
|
234
|
+
const header = req?.headers?.['x-request-id'];
|
|
235
|
+
if (typeof header === 'string' && header.length > 0) {
|
|
236
|
+
return header;
|
|
237
|
+
}
|
|
238
|
+
if (Array.isArray(header) && header.length > 0) {
|
|
239
|
+
return header[0] as string;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
return randomUUID();
|
|
243
|
+
} catch {
|
|
244
|
+
return `${Date.now()}`;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function createReadinessTracker() {
|
|
249
|
+
let status: ReadinessStatus = 'booting';
|
|
250
|
+
let message: string | undefined;
|
|
251
|
+
return {
|
|
252
|
+
booting() {
|
|
253
|
+
status = 'booting';
|
|
254
|
+
message = undefined;
|
|
255
|
+
},
|
|
256
|
+
ready() {
|
|
257
|
+
status = 'ready';
|
|
258
|
+
message = undefined;
|
|
259
|
+
},
|
|
260
|
+
error(reason: string) {
|
|
261
|
+
status = 'error';
|
|
262
|
+
message = reason;
|
|
263
|
+
},
|
|
264
|
+
snapshot() {
|
|
265
|
+
return { status, message };
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Execute when launched directly
|
|
271
|
+
const isMain = (() => {
|
|
272
|
+
try {
|
|
273
|
+
const argv1 = process.argv?.[1];
|
|
274
|
+
if (!argv1) return false;
|
|
275
|
+
const here = new URL(import.meta.url);
|
|
276
|
+
const run = new URL(`file://${argv1}`);
|
|
277
|
+
return here.pathname === run.pathname;
|
|
278
|
+
} catch {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
})();
|
|
282
|
+
|
|
283
|
+
if (isMain) {
|
|
284
|
+
start().catch((err) => {
|
|
285
|
+
console.error(err);
|
|
286
|
+
process.exitCode = 1;
|
|
287
|
+
});
|
|
288
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"forceConsistentCasingInFileNames": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"outDir": "../../build/backend",
|
|
11
|
+
"rootDir": ".",
|
|
12
|
+
"declaration": false,
|
|
13
|
+
"sourceMap": true,
|
|
14
|
+
"types": ["node"],
|
|
15
|
+
"resolveJsonModule": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["**/*"],
|
|
18
|
+
"exclude": ["../../build", "../../dist", "node_modules"]
|
|
19
|
+
}
|