@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
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
import { executeRequestHookPhase, type RequestHookReferenceLike } from './request-hooks.js';
|
|
6
|
+
import {
|
|
7
|
+
createProcessEnvAccessor,
|
|
8
|
+
createReadinessTracker,
|
|
9
|
+
loadModuleRuntime,
|
|
10
|
+
logManifestSummary,
|
|
11
|
+
matchRoute,
|
|
12
|
+
normalizePath,
|
|
13
|
+
normalizeRouteHandlerResult,
|
|
14
|
+
RequestBodyTooLargeError,
|
|
15
|
+
resolveResponseHeaders,
|
|
16
|
+
summarizeManifest,
|
|
17
|
+
type EnvAccessor,
|
|
18
|
+
type ManifestSummary,
|
|
19
|
+
type ModuleRuntime,
|
|
20
|
+
type BackendRouteDefinitionLike,
|
|
21
|
+
type ReadinessTracker,
|
|
22
|
+
type RouteHandlerResult,
|
|
23
|
+
} from './core.js';
|
|
24
|
+
import {
|
|
25
|
+
parseCookieHeader,
|
|
26
|
+
prepareSessionState,
|
|
27
|
+
type SessionCookieConfig,
|
|
28
|
+
type SessionFlashMessage,
|
|
29
|
+
createInMemorySessionStore,
|
|
30
|
+
type SessionStore,
|
|
31
|
+
} from './session.js';
|
|
32
|
+
import { matchView, renderRequestTimeView, type CompiledView, type LoggerLike } from './views.js';
|
|
33
|
+
|
|
34
|
+
export type { RouteHandlerResult } from './core.js';
|
|
35
|
+
|
|
36
|
+
export interface RuntimeLogger {
|
|
37
|
+
child(bindings: Record<string, unknown>): RuntimeLogger;
|
|
38
|
+
info(value: unknown, message?: string): void;
|
|
39
|
+
warn(value: unknown, message?: string): void;
|
|
40
|
+
error(value: unknown, message?: string): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MetricsTracker {
|
|
44
|
+
record(metric: { method: string; route: string; status: number; durationMs: number }): void;
|
|
45
|
+
snapshot(): unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface BunRuntimeEnvLike<TAuthConfig = unknown, TMetricsConfig = unknown> {
|
|
49
|
+
NODE_ENV: string;
|
|
50
|
+
PORT: number;
|
|
51
|
+
auth: TAuthConfig;
|
|
52
|
+
metrics: TMetricsConfig;
|
|
53
|
+
http: {
|
|
54
|
+
bodyLimitBytes: number;
|
|
55
|
+
};
|
|
56
|
+
sessions: SessionCookieConfig;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface BunRuntimeBootstrapOptions<
|
|
60
|
+
TEnv extends BunRuntimeEnvLike = BunRuntimeEnvLike,
|
|
61
|
+
TLogger extends RuntimeLogger = RuntimeLogger,
|
|
62
|
+
TSession extends Record<string, unknown> = Record<string, unknown>,
|
|
63
|
+
TAuth = unknown,
|
|
64
|
+
TMetricsTracker extends MetricsTracker = MetricsTracker,
|
|
65
|
+
> {
|
|
66
|
+
importMetaUrl: string;
|
|
67
|
+
moduleCandidates?: readonly string[];
|
|
68
|
+
loadEnv(): TEnv;
|
|
69
|
+
resolveWorkspaceRoot(): string;
|
|
70
|
+
resolveRequestAuth(
|
|
71
|
+
request: Request,
|
|
72
|
+
auth: TEnv['auth'],
|
|
73
|
+
logger?: {
|
|
74
|
+
warn?(message: string, metadata?: Record<string, unknown>): void;
|
|
75
|
+
},
|
|
76
|
+
): Promise<TAuth | undefined>;
|
|
77
|
+
createBaseLogger(env: TEnv): TLogger;
|
|
78
|
+
createMetricsTracker(config: TEnv['metrics']): TMetricsTracker;
|
|
79
|
+
sessionStore: SessionStore<TSession>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface DefaultBunBackendBootstrapOptions<
|
|
83
|
+
TEnv extends BunRuntimeEnvLike = BunRuntimeEnvLike,
|
|
84
|
+
TLogger extends RuntimeLogger = RuntimeLogger,
|
|
85
|
+
TSession extends Record<string, unknown> = Record<string, unknown>,
|
|
86
|
+
TAuth = unknown,
|
|
87
|
+
TMetricsTracker extends MetricsTracker = MetricsTracker,
|
|
88
|
+
> {
|
|
89
|
+
importMetaUrl: string;
|
|
90
|
+
loadEnv(): TEnv;
|
|
91
|
+
moduleCandidates?: readonly string[];
|
|
92
|
+
resolveWorkspaceRoot?: () => string;
|
|
93
|
+
resolveRequestAuth?: BunRuntimeBootstrapOptions<
|
|
94
|
+
TEnv,
|
|
95
|
+
TLogger,
|
|
96
|
+
TSession,
|
|
97
|
+
TAuth,
|
|
98
|
+
TMetricsTracker
|
|
99
|
+
>['resolveRequestAuth'];
|
|
100
|
+
createBaseLogger?: (env: TEnv) => TLogger;
|
|
101
|
+
createMetricsTracker?: (config: TEnv['metrics']) => TMetricsTracker;
|
|
102
|
+
sessionStore?: SessionStore<TSession>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface BunServerLike {
|
|
106
|
+
stop(closeActiveConnections?: boolean): void;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface BunLike {
|
|
110
|
+
serve(options: {
|
|
111
|
+
port: number;
|
|
112
|
+
hostname?: string;
|
|
113
|
+
fetch(request: Request): Response | Promise<Response>;
|
|
114
|
+
error?(error: Error): Response | Promise<Response>;
|
|
115
|
+
}): BunServerLike;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface RouteContext<
|
|
119
|
+
TLogger extends RuntimeLogger,
|
|
120
|
+
TSession extends Record<string, unknown>,
|
|
121
|
+
TAuth,
|
|
122
|
+
> {
|
|
123
|
+
request: Request;
|
|
124
|
+
reply: Response;
|
|
125
|
+
params: Record<string, string>;
|
|
126
|
+
query: Record<string, string>;
|
|
127
|
+
body: unknown;
|
|
128
|
+
auth: TAuth | undefined;
|
|
129
|
+
session: TSession | null;
|
|
130
|
+
flash: SessionFlashMessage[];
|
|
131
|
+
db: Record<string, unknown>;
|
|
132
|
+
env: EnvAccessor;
|
|
133
|
+
logger: TLogger;
|
|
134
|
+
requestId: string;
|
|
135
|
+
now: () => Date;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
type ModuleRouteDefinition = BackendRouteDefinitionLike & {
|
|
139
|
+
requestHooks?: RequestHookReferenceLike[];
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export async function startBunBackend<
|
|
143
|
+
TEnv extends BunRuntimeEnvLike,
|
|
144
|
+
TLogger extends RuntimeLogger,
|
|
145
|
+
TSession extends Record<string, unknown>,
|
|
146
|
+
TAuth,
|
|
147
|
+
TMetricsTracker extends MetricsTracker,
|
|
148
|
+
>(
|
|
149
|
+
options: BunRuntimeBootstrapOptions<TEnv, TLogger, TSession, TAuth, TMetricsTracker>,
|
|
150
|
+
): Promise<void> {
|
|
151
|
+
const bun = requireBunRuntime();
|
|
152
|
+
const env = options.loadEnv();
|
|
153
|
+
const logger = options.createBaseLogger(env);
|
|
154
|
+
const metrics = options.createMetricsTracker(env.metrics);
|
|
155
|
+
const readiness = createReadinessTracker();
|
|
156
|
+
readiness.booting();
|
|
157
|
+
|
|
158
|
+
type BunRouteContext = RouteContext<TLogger, TSession, TAuth>;
|
|
159
|
+
type BackendModuleRuntime = ModuleRuntime<
|
|
160
|
+
BunRouteContext,
|
|
161
|
+
RouteHandlerResult,
|
|
162
|
+
ModuleRouteDefinition
|
|
163
|
+
>;
|
|
164
|
+
|
|
165
|
+
let runtime: BackendModuleRuntime;
|
|
166
|
+
let loadError: string | undefined;
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
runtime = await loadModuleRuntime<BunRouteContext, RouteHandlerResult, ModuleRouteDefinition>({
|
|
170
|
+
importMetaUrl: options.importMetaUrl,
|
|
171
|
+
candidates: options.moduleCandidates,
|
|
172
|
+
});
|
|
173
|
+
} catch (error) {
|
|
174
|
+
loadError = (error as Error).message ?? 'Failed to load module definition';
|
|
175
|
+
logger.error({ err: error }, '[webstir-backend] module load failed');
|
|
176
|
+
readiness.error(loadError);
|
|
177
|
+
runtime = { routes: [], views: [] };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (runtime.source) {
|
|
181
|
+
logger.info(`[webstir-backend] loaded module definition from ${runtime.source}`);
|
|
182
|
+
} else {
|
|
183
|
+
logger.warn(
|
|
184
|
+
'[webstir-backend] no module definition found. Add src/backend/module.ts to describe routes.',
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
logManifestSummary(logger, runtime.manifest, runtime.routes.length, runtime.views.length);
|
|
189
|
+
for (const warning of runtime.warnings ?? []) {
|
|
190
|
+
logger.warn({ warning }, '[webstir-backend] request hook configuration warning');
|
|
191
|
+
}
|
|
192
|
+
const manifestSummary = summarizeManifest(runtime.manifest);
|
|
193
|
+
|
|
194
|
+
bun.serve({
|
|
195
|
+
port: env.PORT,
|
|
196
|
+
hostname: '0.0.0.0',
|
|
197
|
+
fetch: async (request) => {
|
|
198
|
+
return await handleRequest({
|
|
199
|
+
request,
|
|
200
|
+
runtime,
|
|
201
|
+
readiness,
|
|
202
|
+
manifestSummary,
|
|
203
|
+
env,
|
|
204
|
+
logger,
|
|
205
|
+
metrics,
|
|
206
|
+
options,
|
|
207
|
+
});
|
|
208
|
+
},
|
|
209
|
+
error: (error) => {
|
|
210
|
+
logger.error({ err: error }, '[webstir-backend] Bun server request failed');
|
|
211
|
+
return jsonResponse(500, { error: 'internal_error', message: error.message });
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
if (!loadError) {
|
|
216
|
+
readiness.ready();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
logger.info({ port: env.PORT, mode: env.NODE_ENV, runtime: 'bun' }, 'API server running');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function createDefaultBunBackendBootstrap<
|
|
223
|
+
TEnv extends BunRuntimeEnvLike,
|
|
224
|
+
TLogger extends RuntimeLogger = RuntimeLogger,
|
|
225
|
+
TSession extends Record<string, unknown> = Record<string, unknown>,
|
|
226
|
+
TAuth = unknown,
|
|
227
|
+
TMetricsTracker extends MetricsTracker = MetricsTracker,
|
|
228
|
+
>(
|
|
229
|
+
options: DefaultBunBackendBootstrapOptions<TEnv, TLogger, TSession, TAuth, TMetricsTracker>,
|
|
230
|
+
): BunRuntimeBootstrapOptions<TEnv, TLogger, TSession, TAuth, TMetricsTracker> {
|
|
231
|
+
return {
|
|
232
|
+
importMetaUrl: options.importMetaUrl,
|
|
233
|
+
moduleCandidates: options.moduleCandidates,
|
|
234
|
+
loadEnv: options.loadEnv,
|
|
235
|
+
resolveWorkspaceRoot:
|
|
236
|
+
options.resolveWorkspaceRoot ??
|
|
237
|
+
(() => resolveWorkspaceRootFromImportMetaUrl(options.importMetaUrl)),
|
|
238
|
+
resolveRequestAuth: options.resolveRequestAuth ?? (async () => undefined),
|
|
239
|
+
createBaseLogger: options.createBaseLogger ?? (() => createDefaultBaseLogger() as TLogger),
|
|
240
|
+
createMetricsTracker:
|
|
241
|
+
options.createMetricsTracker ?? (() => createDefaultMetricsTracker() as TMetricsTracker),
|
|
242
|
+
sessionStore: options.sessionStore ?? createInMemorySessionStore<TSession>(),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function createDefaultBaseLogger(): RuntimeLogger {
|
|
247
|
+
return {
|
|
248
|
+
child() {
|
|
249
|
+
return this;
|
|
250
|
+
},
|
|
251
|
+
info(value: unknown, message?: string) {
|
|
252
|
+
writeDefaultLog('info', value, message);
|
|
253
|
+
},
|
|
254
|
+
warn(value: unknown, message?: string) {
|
|
255
|
+
writeDefaultLog('warn', value, message);
|
|
256
|
+
},
|
|
257
|
+
error(value: unknown, message?: string) {
|
|
258
|
+
writeDefaultLog('error', value, message);
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function createDefaultMetricsTracker(): MetricsTracker {
|
|
264
|
+
return {
|
|
265
|
+
record() {},
|
|
266
|
+
snapshot() {
|
|
267
|
+
return { enabled: false };
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function writeDefaultLog(level: 'info' | 'warn' | 'error', value: unknown, message?: string): void {
|
|
273
|
+
const line = typeof value === 'string' && !message ? value : message;
|
|
274
|
+
const detail = typeof value === 'string' && !message ? undefined : value;
|
|
275
|
+
const output = [line, detail ? JSON.stringify(detail) : undefined].filter(Boolean).join(' ');
|
|
276
|
+
|
|
277
|
+
if (level === 'error') {
|
|
278
|
+
console.error(output);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (level === 'warn') {
|
|
283
|
+
console.warn(output);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
console.log(output);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function resolveWorkspaceRootFromImportMetaUrl(importMetaUrl: string): string {
|
|
291
|
+
return path.resolve(path.dirname(fileURLToPath(importMetaUrl)), '..', '..');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function handleRequest<
|
|
295
|
+
TEnv extends BunRuntimeEnvLike,
|
|
296
|
+
TLogger extends RuntimeLogger,
|
|
297
|
+
TSession extends Record<string, unknown>,
|
|
298
|
+
TAuth,
|
|
299
|
+
TMetricsTracker extends MetricsTracker,
|
|
300
|
+
>(args: {
|
|
301
|
+
request: Request;
|
|
302
|
+
runtime: ModuleRuntime<
|
|
303
|
+
RouteContext<TLogger, TSession, TAuth>,
|
|
304
|
+
RouteHandlerResult,
|
|
305
|
+
ModuleRouteDefinition
|
|
306
|
+
>;
|
|
307
|
+
readiness: ReadinessTracker;
|
|
308
|
+
env: TEnv;
|
|
309
|
+
logger: TLogger;
|
|
310
|
+
metrics: TMetricsTracker;
|
|
311
|
+
manifestSummary?: ManifestSummary;
|
|
312
|
+
options: BunRuntimeBootstrapOptions<TEnv, TLogger, TSession, TAuth, TMetricsTracker>;
|
|
313
|
+
}): Promise<Response> {
|
|
314
|
+
const { request, runtime, readiness, manifestSummary, env, logger, metrics, options } = args;
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
const url = new URL(request.url);
|
|
318
|
+
const pathname = normalizePath(url.pathname);
|
|
319
|
+
const method = (request.method ?? 'GET').toUpperCase();
|
|
320
|
+
|
|
321
|
+
if (isHealthPath(pathname)) {
|
|
322
|
+
return jsonResponse(200, { ok: true, uptime: process.uptime() });
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (isReadyPath(pathname)) {
|
|
326
|
+
const snapshot = readiness.snapshot();
|
|
327
|
+
const statusCode = snapshot.status === 'ready' ? 200 : 503;
|
|
328
|
+
return jsonResponse(statusCode, {
|
|
329
|
+
status: snapshot.status,
|
|
330
|
+
message: snapshot.message,
|
|
331
|
+
manifest: manifestSummary,
|
|
332
|
+
metrics: metrics.snapshot(),
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (isMetricsPath(pathname)) {
|
|
337
|
+
const snapshot = metrics.snapshot();
|
|
338
|
+
return jsonResponse(200, snapshot ?? { enabled: false });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (method === 'OPTIONS') {
|
|
342
|
+
const requestOrigin = request.headers.get('origin');
|
|
343
|
+
const allowOrigin = env.NODE_ENV === 'development' ? requestOrigin : undefined;
|
|
344
|
+
return new Response(null, {
|
|
345
|
+
status: 204,
|
|
346
|
+
headers: {
|
|
347
|
+
...(allowOrigin ? { 'Access-Control-Allow-Origin': allowOrigin } : {}),
|
|
348
|
+
'Access-Control-Allow-Methods': 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS',
|
|
349
|
+
'Access-Control-Allow-Headers':
|
|
350
|
+
request.headers.get('access-control-request-headers') ?? 'content-type',
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const matchedRoute = matchRoute(runtime.routes, method, pathname);
|
|
356
|
+
const matchedView =
|
|
357
|
+
!matchedRoute && (method === 'GET' || method === 'HEAD')
|
|
358
|
+
? matchView(runtime.views, pathname)
|
|
359
|
+
: undefined;
|
|
360
|
+
|
|
361
|
+
if (!matchedRoute && !matchedView) {
|
|
362
|
+
metrics.record({ method, route: pathname, status: 404, durationMs: 0 });
|
|
363
|
+
return jsonResponse(404, { error: 'not_found', path: pathname });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const routeName = matchedRoute
|
|
367
|
+
? (matchedRoute.route.name ?? matchedRoute.route.definition?.path ?? pathname)
|
|
368
|
+
: (matchedView?.view.name ?? pathname);
|
|
369
|
+
const startTime = performance.now();
|
|
370
|
+
const requestId = extractRequestId(request);
|
|
371
|
+
const requestLogger = createBunRequestLogger(logger, request, routeName, requestId);
|
|
372
|
+
const structuredLogger = createStructuredLogger(requestLogger);
|
|
373
|
+
const envAccessor = createProcessEnvAccessor();
|
|
374
|
+
const now = () => new Date();
|
|
375
|
+
|
|
376
|
+
let responseStatus = 200;
|
|
377
|
+
try {
|
|
378
|
+
if (matchedView) {
|
|
379
|
+
const response = await handleViewRequest({
|
|
380
|
+
request,
|
|
381
|
+
method,
|
|
382
|
+
url,
|
|
383
|
+
matchedView,
|
|
384
|
+
env,
|
|
385
|
+
envAccessor,
|
|
386
|
+
requestLogger,
|
|
387
|
+
structuredLogger,
|
|
388
|
+
requestId,
|
|
389
|
+
now,
|
|
390
|
+
options,
|
|
391
|
+
});
|
|
392
|
+
responseStatus = response.status;
|
|
393
|
+
return response;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const routeMatch = matchedRoute;
|
|
397
|
+
if (!routeMatch) {
|
|
398
|
+
throw new Error('Expected a matched backend route.');
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const body = await readRequestBody(request, env.http.bodyLimitBytes);
|
|
402
|
+
const sessionState = prepareSessionState<TSession, RouteHandlerResult>({
|
|
403
|
+
cookies: parseCookieHeader(request.headers.get('cookie') ?? undefined),
|
|
404
|
+
route: routeMatch.route.definition,
|
|
405
|
+
config: env.sessions,
|
|
406
|
+
store: options.sessionStore,
|
|
407
|
+
now,
|
|
408
|
+
});
|
|
409
|
+
const ctx: RouteContext<TLogger, TSession, TAuth> = {
|
|
410
|
+
request,
|
|
411
|
+
reply: new Response(null),
|
|
412
|
+
params: routeMatch.params,
|
|
413
|
+
query: Object.fromEntries(url.searchParams.entries()),
|
|
414
|
+
body,
|
|
415
|
+
auth: undefined,
|
|
416
|
+
session: sessionState.session,
|
|
417
|
+
flash: sessionState.flash,
|
|
418
|
+
db: Object.create(null),
|
|
419
|
+
env: envAccessor,
|
|
420
|
+
logger: requestLogger,
|
|
421
|
+
requestId,
|
|
422
|
+
now,
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const routeDefinition = routeMatch.route.definition ?? {
|
|
426
|
+
name: routeMatch.route.name,
|
|
427
|
+
path: pathname,
|
|
428
|
+
method,
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
const beforeAuth = await executeRequestHookPhase({
|
|
432
|
+
hooks: routeMatch.route.requestHooks,
|
|
433
|
+
phase: 'beforeAuth',
|
|
434
|
+
context: ctx,
|
|
435
|
+
route: routeDefinition,
|
|
436
|
+
logger: structuredLogger,
|
|
437
|
+
});
|
|
438
|
+
if (beforeAuth.shortCircuited && beforeAuth.result) {
|
|
439
|
+
const response = createCommittedResponse(beforeAuth.result, {
|
|
440
|
+
method,
|
|
441
|
+
sessionState,
|
|
442
|
+
session: ctx.session,
|
|
443
|
+
route: routeMatch.route.definition,
|
|
444
|
+
requestId,
|
|
445
|
+
});
|
|
446
|
+
responseStatus = response.status;
|
|
447
|
+
return response;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (ctx.auth === undefined) {
|
|
451
|
+
ctx.auth = await options.resolveRequestAuth(request, env.auth, structuredLogger);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const beforeHandler = await executeRequestHookPhase({
|
|
455
|
+
hooks: routeMatch.route.requestHooks,
|
|
456
|
+
phase: 'beforeHandler',
|
|
457
|
+
context: ctx,
|
|
458
|
+
route: routeDefinition,
|
|
459
|
+
logger: structuredLogger,
|
|
460
|
+
});
|
|
461
|
+
if (beforeHandler.shortCircuited && beforeHandler.result) {
|
|
462
|
+
const response = createCommittedResponse(beforeHandler.result, {
|
|
463
|
+
method,
|
|
464
|
+
sessionState,
|
|
465
|
+
session: ctx.session,
|
|
466
|
+
route: routeMatch.route.definition,
|
|
467
|
+
requestId,
|
|
468
|
+
});
|
|
469
|
+
responseStatus = response.status;
|
|
470
|
+
return response;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const handlerResult = await routeMatch.route.handler(ctx);
|
|
474
|
+
const afterHandler = await executeRequestHookPhase({
|
|
475
|
+
hooks: routeMatch.route.requestHooks,
|
|
476
|
+
phase: 'afterHandler',
|
|
477
|
+
context: ctx,
|
|
478
|
+
route: routeDefinition,
|
|
479
|
+
logger: structuredLogger,
|
|
480
|
+
result: handlerResult,
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
const response = createCommittedResponse(afterHandler.result ?? handlerResult, {
|
|
484
|
+
method,
|
|
485
|
+
sessionState,
|
|
486
|
+
session: ctx.session,
|
|
487
|
+
route: routeMatch.route.definition,
|
|
488
|
+
requestId,
|
|
489
|
+
});
|
|
490
|
+
responseStatus = response.status;
|
|
491
|
+
return response;
|
|
492
|
+
} catch (error) {
|
|
493
|
+
requestLogger.error({ err: error }, 'request handler failed');
|
|
494
|
+
if (error instanceof RequestBodyTooLargeError) {
|
|
495
|
+
responseStatus = error.statusCode;
|
|
496
|
+
return jsonResponse(
|
|
497
|
+
error.statusCode,
|
|
498
|
+
{ error: error.code, message: error.message },
|
|
499
|
+
requestId,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
responseStatus = 500;
|
|
503
|
+
return jsonResponse(
|
|
504
|
+
500,
|
|
505
|
+
{
|
|
506
|
+
error: 'internal_error',
|
|
507
|
+
message: (error as Error).message,
|
|
508
|
+
},
|
|
509
|
+
requestId,
|
|
510
|
+
);
|
|
511
|
+
} finally {
|
|
512
|
+
const durationMs = performance.now() - startTime;
|
|
513
|
+
metrics.record({
|
|
514
|
+
method,
|
|
515
|
+
route: routeName,
|
|
516
|
+
status: responseStatus,
|
|
517
|
+
durationMs,
|
|
518
|
+
});
|
|
519
|
+
requestLogger.info({ status: responseStatus, durationMs }, 'request.completed');
|
|
520
|
+
}
|
|
521
|
+
} catch (error) {
|
|
522
|
+
logger.error({ err: error }, '[webstir-backend] request failed');
|
|
523
|
+
if (error instanceof RequestBodyTooLargeError) {
|
|
524
|
+
return jsonResponse(error.statusCode, { error: error.code, message: error.message });
|
|
525
|
+
}
|
|
526
|
+
return jsonResponse(500, { error: 'internal_error', message: (error as Error).message });
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function handleViewRequest<
|
|
531
|
+
TEnv extends BunRuntimeEnvLike,
|
|
532
|
+
TLogger extends RuntimeLogger,
|
|
533
|
+
TSession extends Record<string, unknown>,
|
|
534
|
+
TAuth,
|
|
535
|
+
TMetricsTracker extends MetricsTracker,
|
|
536
|
+
>(args: {
|
|
537
|
+
request: Request;
|
|
538
|
+
method: string;
|
|
539
|
+
url: URL;
|
|
540
|
+
matchedView: { view: CompiledView; params: Record<string, string> };
|
|
541
|
+
env: TEnv;
|
|
542
|
+
envAccessor: EnvAccessor;
|
|
543
|
+
requestLogger: TLogger;
|
|
544
|
+
structuredLogger: LoggerLike;
|
|
545
|
+
requestId: string;
|
|
546
|
+
now: () => Date;
|
|
547
|
+
options: BunRuntimeBootstrapOptions<TEnv, TLogger, TSession, TAuth, TMetricsTracker>;
|
|
548
|
+
}): Promise<Response> {
|
|
549
|
+
const {
|
|
550
|
+
request,
|
|
551
|
+
method,
|
|
552
|
+
url,
|
|
553
|
+
matchedView,
|
|
554
|
+
env,
|
|
555
|
+
envAccessor,
|
|
556
|
+
structuredLogger,
|
|
557
|
+
requestId,
|
|
558
|
+
now,
|
|
559
|
+
options,
|
|
560
|
+
} = args;
|
|
561
|
+
const sessionState = prepareSessionState<TSession, RouteHandlerResult>({
|
|
562
|
+
cookies: parseCookieHeader(request.headers.get('cookie') ?? undefined),
|
|
563
|
+
config: env.sessions,
|
|
564
|
+
store: options.sessionStore,
|
|
565
|
+
now,
|
|
566
|
+
});
|
|
567
|
+
const rendered = await renderRequestTimeView({
|
|
568
|
+
workspaceRoot: options.resolveWorkspaceRoot(),
|
|
569
|
+
url,
|
|
570
|
+
view: matchedView.view,
|
|
571
|
+
params: matchedView.params,
|
|
572
|
+
cookies: parseCookieHeader(request.headers.get('cookie') ?? undefined),
|
|
573
|
+
headers: toRequestHeadersRecord(request.headers),
|
|
574
|
+
auth: await options.resolveRequestAuth(request, env.auth, structuredLogger),
|
|
575
|
+
session: sessionState.session,
|
|
576
|
+
env: envAccessor,
|
|
577
|
+
logger: structuredLogger,
|
|
578
|
+
requestId,
|
|
579
|
+
now,
|
|
580
|
+
});
|
|
581
|
+
const commit = sessionState.commit({
|
|
582
|
+
session: sessionState.session,
|
|
583
|
+
result: { status: 200 },
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
const headers = new Headers({
|
|
587
|
+
'cache-control': 'no-store',
|
|
588
|
+
'content-type': 'text/html; charset=utf-8',
|
|
589
|
+
'x-request-id': requestId,
|
|
590
|
+
'x-webstir-document-cache': rendered.documentCache.status,
|
|
591
|
+
});
|
|
592
|
+
if (commit.setCookie) {
|
|
593
|
+
headers.append('set-cookie', commit.setCookie);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return new Response(method === 'HEAD' ? null : rendered.html, {
|
|
597
|
+
status: 200,
|
|
598
|
+
headers,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function createCommittedResponse<
|
|
603
|
+
TSession extends Record<string, unknown>,
|
|
604
|
+
TResult extends RouteHandlerResult,
|
|
605
|
+
TRouteDefinition extends BackendRouteDefinitionLike,
|
|
606
|
+
>(
|
|
607
|
+
result: TResult,
|
|
608
|
+
options: {
|
|
609
|
+
method: string;
|
|
610
|
+
sessionState: ReturnType<typeof prepareSessionState<TSession, TResult>>;
|
|
611
|
+
session: TSession | null;
|
|
612
|
+
route?: TRouteDefinition;
|
|
613
|
+
requestId: string;
|
|
614
|
+
},
|
|
615
|
+
): Response {
|
|
616
|
+
const normalizedResult = normalizeRouteHandlerResult(result);
|
|
617
|
+
const commit = options.sessionState.commit({
|
|
618
|
+
session: options.session,
|
|
619
|
+
route: options.route,
|
|
620
|
+
result: normalizedResult as TResult,
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
const status = resolveResponseStatus(normalizedResult);
|
|
624
|
+
const headers = new Headers(resolveResponseHeaders(normalizedResult));
|
|
625
|
+
headers.set('x-request-id', options.requestId);
|
|
626
|
+
if (commit.setCookie) {
|
|
627
|
+
headers.append('set-cookie', commit.setCookie);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (normalizedResult.errors) {
|
|
631
|
+
return jsonResponse(status, { errors: normalizedResult.errors }, options.requestId, headers);
|
|
632
|
+
}
|
|
633
|
+
if (normalizedResult.redirect) {
|
|
634
|
+
return new Response(null, { status, headers });
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const payload = normalizedResult.fragment
|
|
638
|
+
? normalizedResult.fragment.body
|
|
639
|
+
: normalizedResult.body;
|
|
640
|
+
if (payload === undefined || payload === null || options.method === 'HEAD') {
|
|
641
|
+
return new Response(null, { status, headers });
|
|
642
|
+
}
|
|
643
|
+
if (typeof payload === 'string' || payload instanceof ArrayBuffer) {
|
|
644
|
+
return new Response(payload, { status, headers });
|
|
645
|
+
}
|
|
646
|
+
if (payload instanceof Uint8Array) {
|
|
647
|
+
return new Response(Buffer.from(payload), { status, headers });
|
|
648
|
+
}
|
|
649
|
+
return jsonResponse(status, payload, options.requestId, headers);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function readRequestBody(request: Request, maxBodyBytes: number): Promise<unknown> {
|
|
653
|
+
const method = (request.method ?? 'GET').toUpperCase();
|
|
654
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
655
|
+
return undefined;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const declaredContentLength = Number(request.headers.get('content-length') ?? '');
|
|
659
|
+
if (Number.isFinite(declaredContentLength) && declaredContentLength > maxBodyBytes) {
|
|
660
|
+
throw new RequestBodyTooLargeError(maxBodyBytes);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const bodyBuffer = await request.arrayBuffer();
|
|
664
|
+
if (bodyBuffer.byteLength === 0) {
|
|
665
|
+
return undefined;
|
|
666
|
+
}
|
|
667
|
+
if (bodyBuffer.byteLength > maxBodyBytes) {
|
|
668
|
+
throw new RequestBodyTooLargeError(maxBodyBytes);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const bodyText = Buffer.from(bodyBuffer).toString('utf8');
|
|
672
|
+
const contentType = request.headers.get('content-type') ?? '';
|
|
673
|
+
if (contentType.includes('application/json')) {
|
|
674
|
+
try {
|
|
675
|
+
return JSON.parse(bodyText);
|
|
676
|
+
} catch {
|
|
677
|
+
return undefined;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
681
|
+
return Object.fromEntries(new URLSearchParams(bodyText).entries());
|
|
682
|
+
}
|
|
683
|
+
if (contentType.includes('text/plain')) {
|
|
684
|
+
return bodyText;
|
|
685
|
+
}
|
|
686
|
+
return bodyText;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function createBunRequestLogger<TLogger extends RuntimeLogger>(
|
|
690
|
+
baseLogger: TLogger,
|
|
691
|
+
request: Request,
|
|
692
|
+
route: string,
|
|
693
|
+
requestId: string,
|
|
694
|
+
): TLogger {
|
|
695
|
+
const url = new URL(request.url);
|
|
696
|
+
return baseLogger.child({
|
|
697
|
+
requestId,
|
|
698
|
+
method: request.method ?? 'GET',
|
|
699
|
+
path: url.pathname,
|
|
700
|
+
route,
|
|
701
|
+
}) as TLogger;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function createStructuredLogger(logger: RuntimeLogger): LoggerLike {
|
|
705
|
+
return {
|
|
706
|
+
info(message, metadata) {
|
|
707
|
+
logStructured(logger.info.bind(logger), message, metadata);
|
|
708
|
+
},
|
|
709
|
+
warn(message, metadata) {
|
|
710
|
+
logStructured(logger.warn.bind(logger), message, metadata);
|
|
711
|
+
},
|
|
712
|
+
error(message, metadata) {
|
|
713
|
+
logStructured(logger.error.bind(logger), message, metadata);
|
|
714
|
+
},
|
|
715
|
+
with(bindings) {
|
|
716
|
+
return createStructuredLogger(logger.child(bindings));
|
|
717
|
+
},
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function logStructured(
|
|
722
|
+
log: (value: unknown, message?: string) => void,
|
|
723
|
+
message: string,
|
|
724
|
+
metadata?: Record<string, unknown>,
|
|
725
|
+
): void {
|
|
726
|
+
if (metadata && Object.keys(metadata).length > 0) {
|
|
727
|
+
log(metadata, message);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
log(message);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function extractRequestId(request: Request): string {
|
|
734
|
+
const header = request.headers.get('x-request-id');
|
|
735
|
+
if (header && header.length > 0) {
|
|
736
|
+
return header;
|
|
737
|
+
}
|
|
738
|
+
try {
|
|
739
|
+
return randomUUID();
|
|
740
|
+
} catch {
|
|
741
|
+
return `${Date.now()}`;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function toRequestHeadersRecord(headers: Headers): Record<string, string> {
|
|
746
|
+
return Object.fromEntries(headers.entries());
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function resolveResponseStatus(result: ReturnType<typeof normalizeRouteHandlerResult>): number {
|
|
750
|
+
if (result.redirect) {
|
|
751
|
+
return result.status ?? 303;
|
|
752
|
+
}
|
|
753
|
+
return result.status ?? (result.errors ? 400 : 200);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function jsonResponse(
|
|
757
|
+
status: number,
|
|
758
|
+
payload: unknown,
|
|
759
|
+
requestId?: string,
|
|
760
|
+
headers?: Headers,
|
|
761
|
+
): Response {
|
|
762
|
+
const responseHeaders = new Headers(headers);
|
|
763
|
+
if (!responseHeaders.has('content-type')) {
|
|
764
|
+
responseHeaders.set('content-type', 'application/json');
|
|
765
|
+
}
|
|
766
|
+
if (requestId && !responseHeaders.has('x-request-id')) {
|
|
767
|
+
responseHeaders.set('x-request-id', requestId);
|
|
768
|
+
}
|
|
769
|
+
return new Response(JSON.stringify(payload), {
|
|
770
|
+
status,
|
|
771
|
+
headers: responseHeaders,
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function requireBunRuntime(): BunLike {
|
|
776
|
+
const bun = (globalThis as typeof globalThis & { Bun?: BunLike }).Bun;
|
|
777
|
+
if (!bun?.serve) {
|
|
778
|
+
throw new Error('The default Webstir backend runtime requires Bun at runtime.');
|
|
779
|
+
}
|
|
780
|
+
return bun;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function isHealthPath(pathname: string): boolean {
|
|
784
|
+
return pathname === '/api/health' || pathname === '/healthz';
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function isReadyPath(pathname: string): boolean {
|
|
788
|
+
return pathname === '/readyz';
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function isMetricsPath(pathname: string): boolean {
|
|
792
|
+
return pathname === '/metrics';
|
|
793
|
+
}
|