@webstir-io/webstir-backend 0.1.15 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/README.md +106 -79
  2. package/dist/add.d.ts +59 -0
  3. package/dist/add.js +626 -0
  4. package/dist/build/artifacts.d.ts +115 -1
  5. package/dist/build/artifacts.js +4 -4
  6. package/dist/build/entries.js +1 -1
  7. package/dist/build/pipeline.d.ts +33 -1
  8. package/dist/build/pipeline.js +307 -65
  9. package/dist/cache/diff.js +9 -8
  10. package/dist/cache/reporters.js +1 -1
  11. package/dist/deploy-cli.d.ts +2 -0
  12. package/dist/deploy-cli.js +86 -0
  13. package/dist/diagnostics/summary.js +2 -2
  14. package/dist/index.d.ts +6 -0
  15. package/dist/index.js +4 -0
  16. package/dist/manifest/pipeline.js +103 -32
  17. package/dist/provider.js +35 -17
  18. package/dist/runtime/bun.d.ts +51 -0
  19. package/dist/runtime/bun.js +499 -0
  20. package/dist/runtime/core.d.ts +141 -0
  21. package/dist/runtime/core.js +316 -0
  22. package/dist/runtime/deploy-backend.d.ts +20 -0
  23. package/dist/runtime/deploy-backend.js +175 -0
  24. package/dist/runtime/deploy-shared.d.ts +43 -0
  25. package/dist/runtime/deploy-shared.js +75 -0
  26. package/dist/runtime/deploy-static.d.ts +2 -0
  27. package/dist/runtime/deploy-static.js +161 -0
  28. package/dist/runtime/deploy.d.ts +3 -0
  29. package/dist/runtime/deploy.js +91 -0
  30. package/dist/runtime/forms.d.ts +73 -0
  31. package/dist/runtime/forms.js +236 -0
  32. package/dist/runtime/request-hooks.d.ts +47 -0
  33. package/dist/runtime/request-hooks.js +102 -0
  34. package/dist/runtime/session-metadata.d.ts +13 -0
  35. package/dist/runtime/session-metadata.js +98 -0
  36. package/dist/runtime/session-runtime.d.ts +28 -0
  37. package/dist/runtime/session-runtime.js +180 -0
  38. package/dist/runtime/session.d.ts +83 -0
  39. package/dist/runtime/session.js +396 -0
  40. package/dist/runtime/views.d.ts +74 -0
  41. package/dist/runtime/views.js +221 -0
  42. package/dist/scaffold/assets.js +25 -21
  43. package/dist/testing/context.js +1 -1
  44. package/dist/testing/index.d.ts +1 -1
  45. package/dist/testing/index.js +100 -56
  46. package/dist/utils/bun.d.ts +2 -0
  47. package/dist/utils/bun.js +13 -0
  48. package/dist/watch.d.ts +13 -1
  49. package/dist/watch.js +345 -97
  50. package/dist/workspace.d.ts +8 -0
  51. package/dist/workspace.js +44 -3
  52. package/package.json +49 -14
  53. package/scripts/publish.sh +2 -92
  54. package/scripts/smoke.mjs +282 -107
  55. package/scripts/update-contract.sh +12 -10
  56. package/src/add.ts +964 -0
  57. package/src/build/artifacts.ts +49 -46
  58. package/src/build/entries.ts +12 -12
  59. package/src/build/pipeline.ts +779 -403
  60. package/src/cache/diff.ts +111 -105
  61. package/src/cache/reporters.ts +26 -26
  62. package/src/deploy-cli.ts +111 -0
  63. package/src/diagnostics/summary.ts +28 -22
  64. package/src/index.ts +11 -0
  65. package/src/manifest/pipeline.ts +328 -215
  66. package/src/provider.ts +115 -98
  67. package/src/runtime/bun.ts +793 -0
  68. package/src/runtime/core.ts +598 -0
  69. package/src/runtime/deploy-backend.ts +239 -0
  70. package/src/runtime/deploy-shared.ts +136 -0
  71. package/src/runtime/deploy-static.ts +191 -0
  72. package/src/runtime/deploy.ts +143 -0
  73. package/src/runtime/forms.ts +364 -0
  74. package/src/runtime/request-hooks.ts +165 -0
  75. package/src/runtime/session-metadata.ts +135 -0
  76. package/src/runtime/session-runtime.ts +267 -0
  77. package/src/runtime/session.ts +642 -0
  78. package/src/runtime/views.ts +385 -0
  79. package/src/scaffold/assets.ts +77 -73
  80. package/src/testing/context.js +8 -9
  81. package/src/testing/context.ts +9 -9
  82. package/src/testing/index.d.ts +14 -3
  83. package/src/testing/index.js +254 -175
  84. package/src/testing/index.ts +298 -195
  85. package/src/testing/types.d.ts +18 -19
  86. package/src/testing/types.ts +18 -18
  87. package/src/utils/bun.ts +26 -0
  88. package/src/watch.ts +503 -99
  89. package/src/workspace.ts +59 -3
  90. package/templates/backend/.env.example +15 -0
  91. package/templates/backend/auth/adapter.ts +335 -36
  92. package/templates/backend/db/connection.ts +190 -65
  93. package/templates/backend/db/migrate.ts +149 -43
  94. package/templates/backend/db/types.d.ts +1 -1
  95. package/templates/backend/env.ts +132 -20
  96. package/templates/backend/functions/hello/index.ts +1 -2
  97. package/templates/backend/index.ts +15 -508
  98. package/templates/backend/jobs/nightly/index.ts +1 -1
  99. package/templates/backend/jobs/runtime.ts +24 -11
  100. package/templates/backend/jobs/scheduler.ts +208 -46
  101. package/templates/backend/module.ts +227 -13
  102. package/templates/backend/observability/logger.ts +2 -12
  103. package/templates/backend/observability/metrics.ts +8 -5
  104. package/templates/backend/session/sqlite.ts +152 -0
  105. package/templates/backend/session/store.ts +45 -0
  106. package/templates/backend/tsconfig.json +1 -1
  107. package/tests/add.test.js +327 -0
  108. package/tests/authAdapter.test.js +315 -0
  109. package/tests/bundlerParity.test.js +217 -0
  110. package/tests/cacheReporter.test.js +10 -10
  111. package/tests/dbConnection.test.js +209 -0
  112. package/tests/deploy.test.js +357 -0
  113. package/tests/envLoader.test.js +271 -17
  114. package/tests/integration.test.js +2432 -3
  115. package/tests/jobsScheduler.test.js +253 -0
  116. package/tests/manifest.test.js +287 -12
  117. package/tests/migrationRunner.test.js +249 -0
  118. package/tests/sessionScaffoldStore.test.js +752 -0
  119. package/tests/sessionStore.test.js +490 -0
  120. package/tests/testing.test.js +252 -0
  121. package/tests/watch.test.js +192 -32
  122. package/tsconfig.json +3 -10
  123. package/templates/backend/server/fastify.ts +0 -288
@@ -1,9 +1,20 @@
1
- #!/usr/bin/env node
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
- if (args.includes('--list')) {
21
- listJobs(jobs);
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(jobName ? `[jobs] job '${jobName}' not found` : '[jobs] no jobs available to watch');
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 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.');
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(job: Awaited<ReturnType<typeof loadJobs>>[number]) {
66
- const intervalMs = toInterval(job.schedule);
67
- if (intervalMs === null) {
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'}' is not supported by the built-in watcher. Run manually or use an external scheduler.`
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
- if (intervalMs === 0) {
75
- void runJob(job);
76
- return undefined;
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
- void runJob(job);
80
- return setInterval(() => {
81
- void runJob(job);
82
- }, intervalMs);
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(`[jobs] ${job.name} completed in ${(Date.now() - startedAt.getTime()).toFixed(0)}ms`);
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(`- ${job.name}${job.schedule ? ` (${job.schedule})` : ''}${job.description ? ` — ${job.description}` : ''}`);
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.findIndex((arg) => arg === flag);
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 toInterval(schedule: string | undefined): number | null {
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 null;
210
+ return undefined;
129
211
  }
130
212
  const trimmed = schedule.trim().toLowerCase();
131
- if (!trimmed) return null;
213
+ if (!trimmed) return undefined;
132
214
 
133
215
  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;
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
- 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;
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
- return null;
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
- 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>]
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 Run supported jobs on an interval (supports @hourly/@daily/@weekly/@reboot and rate(...) syntax)
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', { name, requestId: ctx.requestId, userId: ctx.auth.userId });
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('&', '&amp;')
297
+ .replaceAll('<', '&lt;')
298
+ .replaceAll('>', '&gt;')
299
+ .replaceAll('"', '&quot;')
300
+ .replaceAll("'", '&#39;');
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 = durations.length > 0 ? durations.reduce((sum, value) => sum + value, 0) / durations.length : 0;
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