@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,9 +1,20 @@
|
|
|
1
|
-
#!/usr/bin/env
|
|
2
|
-
import { setInterval } from 'node:timers';
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { clearInterval, clearTimeout, setInterval, setTimeout } from 'node:timers';
|
|
3
3
|
|
|
4
4
|
import { loadJobs } from './runtime.js';
|
|
5
5
|
|
|
6
6
|
const args = process.argv.slice(2);
|
|
7
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
8
|
+
|
|
9
|
+
type ScheduledJobHandle =
|
|
10
|
+
| {
|
|
11
|
+
kind: 'timer';
|
|
12
|
+
[Symbol.dispose](): void;
|
|
13
|
+
}
|
|
14
|
+
| {
|
|
15
|
+
kind: 'reboot';
|
|
16
|
+
done: Promise<void>;
|
|
17
|
+
};
|
|
7
18
|
|
|
8
19
|
async function main() {
|
|
9
20
|
if (args.includes('--help') || args.includes('-h')) {
|
|
@@ -17,8 +28,9 @@ async function main() {
|
|
|
17
28
|
return;
|
|
18
29
|
}
|
|
19
30
|
|
|
20
|
-
|
|
21
|
-
|
|
31
|
+
const asJson = args.includes('--json');
|
|
32
|
+
if (args.includes('--list') || asJson) {
|
|
33
|
+
listJobs(jobs, { asJson });
|
|
22
34
|
return;
|
|
23
35
|
}
|
|
24
36
|
|
|
@@ -47,39 +59,88 @@ async function main() {
|
|
|
47
59
|
async function startWatch(jobs: Awaited<ReturnType<typeof loadJobs>>, jobName?: string) {
|
|
48
60
|
const filtered = jobName ? jobs.filter((job) => job.name === jobName) : jobs;
|
|
49
61
|
if (filtered.length === 0) {
|
|
50
|
-
console.error(
|
|
62
|
+
console.error(
|
|
63
|
+
jobName ? `[jobs] job '${jobName}' not found` : '[jobs] no jobs available to watch',
|
|
64
|
+
);
|
|
51
65
|
process.exitCode = 1;
|
|
52
66
|
return;
|
|
53
67
|
}
|
|
54
68
|
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
69
|
+
const scheduled = filtered.map((job) => scheduleJob(job));
|
|
70
|
+
const timers = scheduled.filter(
|
|
71
|
+
(timer): timer is Extract<ScheduledJobHandle, { kind: 'timer' }> => timer?.kind === 'timer',
|
|
72
|
+
);
|
|
73
|
+
const rebootRuns = scheduled.filter(
|
|
74
|
+
(timer): timer is Extract<ScheduledJobHandle, { kind: 'reboot' }> => timer?.kind === 'reboot',
|
|
75
|
+
);
|
|
76
|
+
const hasActiveWatch = timers.length > 0;
|
|
77
|
+
if (!hasActiveWatch) {
|
|
78
|
+
if (rebootRuns.length > 0) {
|
|
79
|
+
await Promise.all(rebootRuns.map((run) => run.done));
|
|
80
|
+
console.info('[jobs] completed @reboot jobs and exiting.');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
console.warn(
|
|
84
|
+
'[jobs] no jobs have schedules compatible with the built-in watcher. Use --json to export job metadata for an external scheduler.',
|
|
85
|
+
);
|
|
58
86
|
return;
|
|
59
87
|
}
|
|
60
88
|
|
|
61
89
|
console.info('[jobs] watching jobs:', filtered.map((job) => job.name).join(', '));
|
|
90
|
+
registerShutdown(timers);
|
|
62
91
|
process.stdin.resume();
|
|
63
92
|
}
|
|
64
93
|
|
|
65
|
-
function scheduleJob(
|
|
66
|
-
|
|
67
|
-
|
|
94
|
+
function scheduleJob(
|
|
95
|
+
job: Awaited<ReturnType<typeof loadJobs>>[number],
|
|
96
|
+
): ScheduledJobHandle | undefined {
|
|
97
|
+
const schedule = normalizeSchedule(job.schedule);
|
|
98
|
+
if (!schedule) {
|
|
68
99
|
console.info(
|
|
69
|
-
`[jobs] schedule '${job.schedule ?? 'unspecified'}'
|
|
100
|
+
`[jobs] unsupported schedule '${job.schedule ?? 'unspecified'}'. Run manually or use --json with an external scheduler.`,
|
|
70
101
|
);
|
|
71
102
|
return undefined;
|
|
72
103
|
}
|
|
73
104
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
105
|
+
const runScheduledJob = createNonOverlappingRunner(job);
|
|
106
|
+
|
|
107
|
+
if (schedule.kind === 'reboot') {
|
|
108
|
+
return { kind: 'reboot', done: runScheduledJob() };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (schedule.kind === 'rate') {
|
|
112
|
+
void runScheduledJob();
|
|
113
|
+
const timer = setInterval(() => {
|
|
114
|
+
void runScheduledJob();
|
|
115
|
+
}, schedule.intervalMs);
|
|
116
|
+
return {
|
|
117
|
+
kind: 'timer' as const,
|
|
118
|
+
[Symbol.dispose]() {
|
|
119
|
+
clearInterval(timer);
|
|
120
|
+
},
|
|
121
|
+
};
|
|
77
122
|
}
|
|
78
123
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
124
|
+
return scheduleCronJob(job, schedule.expression, runScheduledJob);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function createNonOverlappingRunner(job: Awaited<ReturnType<typeof loadJobs>>[number]) {
|
|
128
|
+
let running = false;
|
|
129
|
+
return async () => {
|
|
130
|
+
if (running) {
|
|
131
|
+
console.warn(
|
|
132
|
+
`[jobs] skipping ${job.name}; previous run is still active (overlap policy: skip)`,
|
|
133
|
+
);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
running = true;
|
|
138
|
+
try {
|
|
139
|
+
await runJob(job);
|
|
140
|
+
} finally {
|
|
141
|
+
running = false;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
83
144
|
}
|
|
84
145
|
|
|
85
146
|
async function runNamedJob(jobs: Awaited<ReturnType<typeof loadJobs>>, jobName: string) {
|
|
@@ -97,21 +158,36 @@ async function runJob(job: Awaited<ReturnType<typeof loadJobs>>[number]) {
|
|
|
97
158
|
console.info(`[jobs] running ${job.name} (schedule: ${job.schedule ?? 'manual'})`);
|
|
98
159
|
try {
|
|
99
160
|
await job.run();
|
|
100
|
-
console.info(
|
|
161
|
+
console.info(
|
|
162
|
+
`[jobs] ${job.name} completed in ${(Date.now() - startedAt.getTime()).toFixed(0)}ms`,
|
|
163
|
+
);
|
|
101
164
|
} catch (error) {
|
|
102
165
|
console.error(`[jobs] ${job.name} failed: ${(error as Error).message}`);
|
|
103
166
|
process.exitCode = 1;
|
|
104
167
|
}
|
|
105
168
|
}
|
|
106
169
|
|
|
107
|
-
function listJobs(jobs: Awaited<ReturnType<typeof loadJobs
|
|
170
|
+
function listJobs(jobs: Awaited<ReturnType<typeof loadJobs>>, options: { asJson?: boolean } = {}) {
|
|
171
|
+
if (options.asJson) {
|
|
172
|
+
console.info(
|
|
173
|
+
JSON.stringify(
|
|
174
|
+
jobs.map(({ run: _run, ...job }) => job),
|
|
175
|
+
null,
|
|
176
|
+
2,
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
108
182
|
for (const job of jobs) {
|
|
109
|
-
console.info(
|
|
183
|
+
console.info(
|
|
184
|
+
`- ${job.name}${job.schedule ? ` (${job.schedule})` : ''}${job.description ? ` — ${job.description}` : ''}`,
|
|
185
|
+
);
|
|
110
186
|
}
|
|
111
187
|
}
|
|
112
188
|
|
|
113
189
|
function parseOption(flag: string): string | undefined {
|
|
114
|
-
const index = args.
|
|
190
|
+
const index = args.indexOf(flag);
|
|
115
191
|
if (index !== -1 && args[index + 1] && !args[index + 1].startsWith('-')) {
|
|
116
192
|
return args[index + 1];
|
|
117
193
|
}
|
|
@@ -123,52 +199,138 @@ function parseOption(flag: string): string | undefined {
|
|
|
123
199
|
return undefined;
|
|
124
200
|
}
|
|
125
201
|
|
|
126
|
-
function
|
|
202
|
+
function normalizeSchedule(
|
|
203
|
+
schedule: string | undefined,
|
|
204
|
+
):
|
|
205
|
+
| { kind: 'cron'; expression: string }
|
|
206
|
+
| { kind: 'rate'; intervalMs: number }
|
|
207
|
+
| { kind: 'reboot' }
|
|
208
|
+
| undefined {
|
|
127
209
|
if (!schedule) {
|
|
128
|
-
return
|
|
210
|
+
return undefined;
|
|
129
211
|
}
|
|
130
212
|
const trimmed = schedule.trim().toLowerCase();
|
|
131
|
-
if (!trimmed) return
|
|
213
|
+
if (!trimmed) return undefined;
|
|
132
214
|
|
|
133
215
|
if (trimmed.startsWith('@')) {
|
|
134
|
-
|
|
135
|
-
|
|
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;
|
|
216
|
+
if (trimmed === '@reboot') {
|
|
217
|
+
return { kind: 'reboot' };
|
|
146
218
|
}
|
|
219
|
+
return isSupportedCronExpression(trimmed) ? { kind: 'cron', expression: trimmed } : undefined;
|
|
147
220
|
}
|
|
148
221
|
|
|
149
222
|
const rateMatch = /^rate\((\d+)\s+(second|seconds|minute|minutes|hour|hours)\)$/.exec(trimmed);
|
|
150
223
|
if (rateMatch) {
|
|
151
224
|
const value = Number(rateMatch[1]);
|
|
152
225
|
const unit = rateMatch[2];
|
|
153
|
-
const multiplier =
|
|
154
|
-
|
|
155
|
-
|
|
226
|
+
const multiplier = unit.startsWith('second')
|
|
227
|
+
? 1000
|
|
228
|
+
: unit.startsWith('minute')
|
|
229
|
+
? 60 * 1000
|
|
230
|
+
: unit.startsWith('hour')
|
|
231
|
+
? 60 * 60 * 1000
|
|
232
|
+
: 0;
|
|
233
|
+
if (value > 0 && multiplier > 0) {
|
|
234
|
+
return { kind: 'rate', intervalMs: value * multiplier };
|
|
235
|
+
}
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return isSupportedCronExpression(trimmed) ? { kind: 'cron', expression: trimmed } : undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function isSupportedCronExpression(expression: string): boolean {
|
|
243
|
+
try {
|
|
244
|
+
return Bun.cron.parse(expression) !== null;
|
|
245
|
+
} catch {
|
|
246
|
+
return false;
|
|
156
247
|
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function scheduleCronJob(
|
|
251
|
+
job: Awaited<ReturnType<typeof loadJobs>>[number],
|
|
252
|
+
expression: string,
|
|
253
|
+
runScheduledJob: () => Promise<void>,
|
|
254
|
+
) {
|
|
255
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
256
|
+
let stopped = false;
|
|
257
|
+
|
|
258
|
+
const queueNext = (relativeTo?: Date) => {
|
|
259
|
+
if (stopped) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const nextRun = Bun.cron.parse(expression, relativeTo);
|
|
264
|
+
if (!nextRun) {
|
|
265
|
+
console.warn(
|
|
266
|
+
`[jobs] schedule '${expression}' does not produce a future run time. Stopping local watch for ${job.name}.`,
|
|
267
|
+
);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
scheduleTimeout(nextRun, async () => {
|
|
271
|
+
await runScheduledJob();
|
|
272
|
+
queueNext(nextRun);
|
|
273
|
+
});
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const scheduleTimeout = (nextRun: Date, callback: () => Promise<void>) => {
|
|
277
|
+
const remainingMs = nextRun.getTime() - Date.now();
|
|
278
|
+
const delayMs = Math.max(0, Math.min(remainingMs, MAX_TIMEOUT_MS));
|
|
279
|
+
timer = setTimeout(() => {
|
|
280
|
+
if (stopped) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (remainingMs > MAX_TIMEOUT_MS && nextRun.getTime() > Date.now()) {
|
|
284
|
+
scheduleTimeout(nextRun, callback);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
void callback();
|
|
288
|
+
}, delayMs);
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
queueNext();
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
kind: 'timer' as const,
|
|
295
|
+
[Symbol.dispose]() {
|
|
296
|
+
stopped = true;
|
|
297
|
+
if (timer) {
|
|
298
|
+
clearTimeout(timer);
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function registerShutdown(timers: readonly Extract<ScheduledJobHandle, { kind: 'timer' }>[]) {
|
|
305
|
+
let stopped = false;
|
|
306
|
+
const stop = (signal: NodeJS.Signals) => {
|
|
307
|
+
if (stopped) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
stopped = true;
|
|
311
|
+
for (const timer of timers) {
|
|
312
|
+
timer[Symbol.dispose]();
|
|
313
|
+
}
|
|
314
|
+
process.stdin.pause();
|
|
315
|
+
console.info(`[jobs] received ${signal}; stopped ${timers.length} scheduled job timer(s).`);
|
|
316
|
+
};
|
|
157
317
|
|
|
158
|
-
|
|
318
|
+
process.once('SIGINT', () => stop('SIGINT'));
|
|
319
|
+
process.once('SIGTERM', () => stop('SIGTERM'));
|
|
159
320
|
}
|
|
160
321
|
|
|
161
322
|
function printHelp() {
|
|
162
323
|
console.info(`Usage:
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
324
|
+
bun src/backend/jobs/scheduler.ts [--list]
|
|
325
|
+
bun build/backend/jobs/scheduler.js --job <name>
|
|
326
|
+
bun build/backend/jobs/scheduler.js --watch [--job <name>]
|
|
166
327
|
|
|
167
328
|
Options:
|
|
168
329
|
--list Show registered jobs and exit
|
|
330
|
+
--json Print registered job metadata as JSON for external schedulers
|
|
169
331
|
--job <name> Run a specific job immediately (or watch a single job)
|
|
170
332
|
--all Run all jobs once (default when no options are provided)
|
|
171
|
-
--watch
|
|
333
|
+
--watch Watch jobs locally (supports cron expressions, cron nicknames, @reboot, and rate(...) syntax)
|
|
172
334
|
--help Display this message
|
|
173
335
|
`);
|
|
174
336
|
}
|
|
@@ -2,19 +2,35 @@
|
|
|
2
2
|
// When this file is present, the server scaffold loads build/backend/module.js,
|
|
3
3
|
// announces the manifest, and mounts every route definition automatically.
|
|
4
4
|
|
|
5
|
+
import {
|
|
6
|
+
groupFormIssuesByField,
|
|
7
|
+
prepareFormState,
|
|
8
|
+
processFormSubmission,
|
|
9
|
+
type FormIssue,
|
|
10
|
+
type FormValues,
|
|
11
|
+
} from '@webstir-io/webstir-backend/runtime/forms';
|
|
12
|
+
|
|
5
13
|
interface RouteHandlerResult {
|
|
6
14
|
status?: number;
|
|
7
15
|
headers?: Record<string, string>;
|
|
8
16
|
body?: unknown;
|
|
17
|
+
redirect?: {
|
|
18
|
+
location: string;
|
|
19
|
+
};
|
|
9
20
|
errors?: { code: string; message: string; details?: unknown }[];
|
|
10
21
|
}
|
|
11
22
|
|
|
12
|
-
type RouteHandler = (ctx: RouteContext) => Promise<RouteHandlerResult> | RouteHandlerResult;
|
|
13
|
-
|
|
14
23
|
interface RouteContext {
|
|
15
24
|
params: Record<string, string>;
|
|
16
25
|
query: Record<string, string>;
|
|
17
26
|
body: unknown;
|
|
27
|
+
session: Record<string, unknown> | null;
|
|
28
|
+
flash: readonly {
|
|
29
|
+
key: string;
|
|
30
|
+
level: 'info' | 'success' | 'warning' | 'error';
|
|
31
|
+
createdAt: string;
|
|
32
|
+
}[];
|
|
33
|
+
db: Record<string, unknown>;
|
|
18
34
|
auth?: {
|
|
19
35
|
userId?: string;
|
|
20
36
|
email?: string;
|
|
@@ -35,42 +51,168 @@ interface RouteContext {
|
|
|
35
51
|
now: () => Date;
|
|
36
52
|
}
|
|
37
53
|
|
|
54
|
+
type RequestHook = {
|
|
55
|
+
id: string;
|
|
56
|
+
handler:
|
|
57
|
+
| ((ctx: RouteContext) => Promise<RouteHandlerResult | undefined>)
|
|
58
|
+
| ((ctx: RouteContext) => RouteHandlerResult | undefined);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const accountSettingsPageDefinition = {
|
|
62
|
+
name: 'accountSettingsPage',
|
|
63
|
+
method: 'GET',
|
|
64
|
+
path: '/account/settings',
|
|
65
|
+
interaction: 'navigation',
|
|
66
|
+
session: { mode: 'optional' },
|
|
67
|
+
flash: { consume: ['settings-saved'] },
|
|
68
|
+
summary: 'Account settings form',
|
|
69
|
+
description:
|
|
70
|
+
'Demonstrates HTML-first form rendering with CSRF, redirect-after-post, and inline validation.',
|
|
71
|
+
} as const;
|
|
72
|
+
|
|
73
|
+
const updateAccountSettingsDefinition = {
|
|
74
|
+
name: 'accountSettingsUpdate',
|
|
75
|
+
method: 'POST',
|
|
76
|
+
path: '/account/settings',
|
|
77
|
+
interaction: 'mutation',
|
|
78
|
+
form: {
|
|
79
|
+
contentType: 'application/x-www-form-urlencoded',
|
|
80
|
+
csrf: true,
|
|
81
|
+
session: { write: true },
|
|
82
|
+
flash: {
|
|
83
|
+
publish: [{ key: 'settings-saved', level: 'success', when: 'success' }],
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
summary: 'Update account settings',
|
|
87
|
+
description: 'Demonstrates auth-aware mutations and redirect-after-post ergonomics.',
|
|
88
|
+
} as const;
|
|
89
|
+
|
|
38
90
|
const routes = [
|
|
39
91
|
{
|
|
40
92
|
definition: {
|
|
41
93
|
name: 'helloRoute',
|
|
42
94
|
method: 'GET',
|
|
43
95
|
path: '/hello/:name',
|
|
96
|
+
requestHooks: [{ id: 'audit-hello' }],
|
|
44
97
|
summary: 'Simple hello route',
|
|
45
|
-
description: 'Demonstrates manifest wiring + request context metadata.'
|
|
98
|
+
description: 'Demonstrates manifest wiring + request context metadata.',
|
|
46
99
|
},
|
|
47
100
|
handler: async (ctx: RouteContext) => {
|
|
48
101
|
if (!ctx.auth) {
|
|
49
102
|
return {
|
|
50
103
|
status: 401,
|
|
51
|
-
errors: [{ code: 'auth', message: 'Sign-in required to access /hello' }]
|
|
104
|
+
errors: [{ code: 'auth', message: 'Sign-in required to access /hello' }],
|
|
52
105
|
};
|
|
53
106
|
}
|
|
54
107
|
const name = ctx.params.name ?? 'world';
|
|
55
|
-
ctx.logger.info('hello route invoked', {
|
|
108
|
+
ctx.logger.info('hello route invoked', {
|
|
109
|
+
name,
|
|
110
|
+
requestId: ctx.requestId,
|
|
111
|
+
userId: ctx.auth.userId,
|
|
112
|
+
});
|
|
56
113
|
return {
|
|
57
114
|
status: 200,
|
|
58
115
|
body: {
|
|
59
116
|
message: `Hello ${name}`,
|
|
60
117
|
greetedAt: ctx.now().toISOString(),
|
|
61
|
-
user: ctx.auth.userId ?? 'anonymous'
|
|
62
|
-
}
|
|
118
|
+
user: ctx.auth.userId ?? 'anonymous',
|
|
119
|
+
},
|
|
63
120
|
};
|
|
64
|
-
}
|
|
65
|
-
}
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
definition: accountSettingsPageDefinition,
|
|
125
|
+
handler: async (ctx: RouteContext) => {
|
|
126
|
+
const form = prepareFormState({
|
|
127
|
+
session: ctx.session,
|
|
128
|
+
formId: 'account-settings',
|
|
129
|
+
route: updateAccountSettingsDefinition,
|
|
130
|
+
now: ctx.now,
|
|
131
|
+
});
|
|
132
|
+
ctx.session = form.session;
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
status: 200,
|
|
136
|
+
body: renderAccountSettingsPage({
|
|
137
|
+
csrfToken: form.csrfToken ?? '',
|
|
138
|
+
issues: form.issues,
|
|
139
|
+
values: form.values,
|
|
140
|
+
flash: ctx.flash,
|
|
141
|
+
currentEmail:
|
|
142
|
+
getFormValue(form.values, 'email') ??
|
|
143
|
+
getSessionProfileEmail(ctx.session) ??
|
|
144
|
+
ctx.auth?.email ??
|
|
145
|
+
'guest@example.com',
|
|
146
|
+
}),
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
definition: updateAccountSettingsDefinition,
|
|
152
|
+
handler: async (ctx: RouteContext) => {
|
|
153
|
+
const submission = processFormSubmission({
|
|
154
|
+
session: ctx.session,
|
|
155
|
+
body: ctx.body,
|
|
156
|
+
auth: ctx.auth,
|
|
157
|
+
formId: 'account-settings',
|
|
158
|
+
route: updateAccountSettingsDefinition,
|
|
159
|
+
redirectTo: accountSettingsPageDefinition.path,
|
|
160
|
+
requireAuth: {
|
|
161
|
+
redirectTo: accountSettingsPageDefinition.path,
|
|
162
|
+
message: 'Sign-in required to update account settings.',
|
|
163
|
+
},
|
|
164
|
+
validate(values) {
|
|
165
|
+
const email = getFormValue(values, 'email')?.trim() ?? '';
|
|
166
|
+
const issues: FormIssue[] = [];
|
|
167
|
+
if (email.length === 0) {
|
|
168
|
+
issues.push({ field: 'email', message: 'Email is required.' });
|
|
169
|
+
} else if (!email.includes('@')) {
|
|
170
|
+
issues.push({ field: 'email', message: 'Enter a valid email address.' });
|
|
171
|
+
}
|
|
172
|
+
return issues;
|
|
173
|
+
},
|
|
174
|
+
now: ctx.now,
|
|
175
|
+
});
|
|
176
|
+
const nextSession = submission.session ?? {};
|
|
177
|
+
ctx.session = nextSession;
|
|
178
|
+
if (!submission.ok) {
|
|
179
|
+
return submission.result;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
nextSession.profile = {
|
|
183
|
+
email: getFormValue(submission.values, 'email')?.trim() ?? 'guest@example.com',
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
status: 303,
|
|
188
|
+
redirect: {
|
|
189
|
+
location: accountSettingsPageDefinition.path,
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
const requestHooks: RequestHook[] = [
|
|
197
|
+
{
|
|
198
|
+
id: 'audit-hello',
|
|
199
|
+
handler: async (ctx) => {
|
|
200
|
+
ctx.db.lastHelloRequestId = ctx.requestId;
|
|
201
|
+
ctx.logger.info('hello route request received', {
|
|
202
|
+
requestId: ctx.requestId,
|
|
203
|
+
session: ctx.session,
|
|
204
|
+
});
|
|
205
|
+
return undefined;
|
|
206
|
+
},
|
|
207
|
+
},
|
|
66
208
|
];
|
|
67
209
|
|
|
68
210
|
const jobs = [
|
|
69
211
|
{
|
|
70
212
|
name: 'nightly',
|
|
71
213
|
schedule: '0 0 * * *',
|
|
72
|
-
description: 'Example nightly maintenance job metadata surfaced in the manifest.'
|
|
73
|
-
}
|
|
214
|
+
description: 'Example nightly maintenance job metadata surfaced in the manifest.',
|
|
215
|
+
},
|
|
74
216
|
];
|
|
75
217
|
|
|
76
218
|
export const module = {
|
|
@@ -80,8 +222,80 @@ export const module = {
|
|
|
80
222
|
version: '0.1.0',
|
|
81
223
|
kind: 'backend',
|
|
82
224
|
capabilities: ['http', 'auth', 'db'],
|
|
225
|
+
requestHooks: [
|
|
226
|
+
{
|
|
227
|
+
id: 'audit-hello',
|
|
228
|
+
phase: 'beforeHandler',
|
|
229
|
+
order: 10,
|
|
230
|
+
},
|
|
231
|
+
],
|
|
83
232
|
routes: routes.map((route) => route.definition),
|
|
84
|
-
jobs
|
|
233
|
+
jobs,
|
|
85
234
|
},
|
|
86
|
-
routes
|
|
235
|
+
routes,
|
|
236
|
+
requestHooks,
|
|
87
237
|
};
|
|
238
|
+
|
|
239
|
+
function renderAccountSettingsPage(options: {
|
|
240
|
+
csrfToken: string;
|
|
241
|
+
issues: readonly FormIssue[];
|
|
242
|
+
values: FormValues;
|
|
243
|
+
flash: readonly {
|
|
244
|
+
key: string;
|
|
245
|
+
level: 'info' | 'success' | 'warning' | 'error';
|
|
246
|
+
createdAt: string;
|
|
247
|
+
}[];
|
|
248
|
+
currentEmail: string;
|
|
249
|
+
}): string {
|
|
250
|
+
const grouped = groupFormIssuesByField(options.issues);
|
|
251
|
+
const email = escapeHtml(options.currentEmail);
|
|
252
|
+
const inlineErrors = grouped.fields.email?.join(' ') ?? '';
|
|
253
|
+
const flash = options.flash.map((message) => `${message.key}:${message.level}`).join(', ');
|
|
254
|
+
const formErrors = grouped.form.join(' ');
|
|
255
|
+
|
|
256
|
+
return [
|
|
257
|
+
'<main>',
|
|
258
|
+
' <h1>Account Settings</h1>',
|
|
259
|
+
` <p data-flash="${escapeHtml(flash)}" data-form-errors="${escapeHtml(formErrors)}">Signed in as ${email}</p>`,
|
|
260
|
+
' <form method="post" action="/account/settings">',
|
|
261
|
+
` <input type="hidden" name="_csrf" value="${escapeHtml(options.csrfToken)}" />`,
|
|
262
|
+
' <label>',
|
|
263
|
+
' Email',
|
|
264
|
+
` <input name="email" type="email" value="${email}" />`,
|
|
265
|
+
' </label>',
|
|
266
|
+
` <p data-field="email">${escapeHtml(inlineErrors)}</p>`,
|
|
267
|
+
' <button type="submit">Save Settings</button>',
|
|
268
|
+
' </form>',
|
|
269
|
+
'</main>',
|
|
270
|
+
].join('\n');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function getFormValue(values: FormValues, key: string): string | undefined {
|
|
274
|
+
const raw = values[key];
|
|
275
|
+
if (typeof raw === 'string') {
|
|
276
|
+
return raw;
|
|
277
|
+
}
|
|
278
|
+
if (Array.isArray(raw) && typeof raw[0] === 'string') {
|
|
279
|
+
return raw[0];
|
|
280
|
+
}
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function getSessionProfileEmail(session: Record<string, unknown> | null): string | undefined {
|
|
285
|
+
const profile = session?.profile;
|
|
286
|
+
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
return typeof (profile as { email?: unknown }).email === 'string'
|
|
290
|
+
? (profile as { email: string }).email
|
|
291
|
+
: undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function escapeHtml(value: string): string {
|
|
295
|
+
return value
|
|
296
|
+
.replaceAll('&', '&')
|
|
297
|
+
.replaceAll('<', '<')
|
|
298
|
+
.replaceAll('>', '>')
|
|
299
|
+
.replaceAll('"', '"')
|
|
300
|
+
.replaceAll("'", ''');
|
|
301
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type http from 'node:http';
|
|
2
1
|
import pino, { stdTimeFunctions, type Logger } from 'pino';
|
|
3
2
|
|
|
4
3
|
import type { AppEnv } from '../env.js';
|
|
@@ -8,17 +7,8 @@ export function createBaseLogger(env: AppEnv): Logger {
|
|
|
8
7
|
level: env.logging.level,
|
|
9
8
|
base: {
|
|
10
9
|
service: env.logging.serviceName,
|
|
11
|
-
environment: env.NODE_ENV
|
|
10
|
+
environment: env.NODE_ENV,
|
|
12
11
|
},
|
|
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
|
|
12
|
+
timestamp: stdTimeFunctions.isoTime,
|
|
23
13
|
});
|
|
24
14
|
}
|
|
@@ -30,7 +30,7 @@ export function createMetricsTracker(config: MetricsConfig): MetricsTracker {
|
|
|
30
30
|
},
|
|
31
31
|
snapshot() {
|
|
32
32
|
return undefined;
|
|
33
|
-
}
|
|
33
|
+
},
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -53,9 +53,12 @@ export function createMetricsTracker(config: MetricsConfig): MetricsTracker {
|
|
|
53
53
|
},
|
|
54
54
|
snapshot() {
|
|
55
55
|
const counts = Object.fromEntries(
|
|
56
|
-
[...byStatus.entries()].map(([status, count]) => [String(status), count])
|
|
56
|
+
[...byStatus.entries()].map(([status, count]) => [String(status), count]),
|
|
57
57
|
);
|
|
58
|
-
const averageDurationMs =
|
|
58
|
+
const averageDurationMs =
|
|
59
|
+
durations.length > 0
|
|
60
|
+
? durations.reduce((sum, value) => sum + value, 0) / durations.length
|
|
61
|
+
: 0;
|
|
59
62
|
const p95DurationMs = durations.length > 0 ? percentile(durations, 0.95) : 0;
|
|
60
63
|
return {
|
|
61
64
|
enabled: true,
|
|
@@ -64,9 +67,9 @@ export function createMetricsTracker(config: MetricsConfig): MetricsTracker {
|
|
|
64
67
|
averageDurationMs,
|
|
65
68
|
p95DurationMs,
|
|
66
69
|
byStatus: counts,
|
|
67
|
-
windowSize: config.windowSize
|
|
70
|
+
windowSize: config.windowSize,
|
|
68
71
|
};
|
|
69
|
-
}
|
|
72
|
+
},
|
|
70
73
|
};
|
|
71
74
|
}
|
|
72
75
|
|