@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,598 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
resolveRequestHooks,
|
|
6
|
+
type CompiledRequestHook,
|
|
7
|
+
type RequestHookDefinitionLike,
|
|
8
|
+
type RequestHookHandler,
|
|
9
|
+
type RequestHookReferenceLike,
|
|
10
|
+
} from './request-hooks.js';
|
|
11
|
+
import type { SessionAwareRouteDefinitionLike } from './session.js';
|
|
12
|
+
import {
|
|
13
|
+
compileViews,
|
|
14
|
+
type CompiledView,
|
|
15
|
+
type ModuleViewLike,
|
|
16
|
+
type ViewDefinitionLike,
|
|
17
|
+
} from './views.js';
|
|
18
|
+
|
|
19
|
+
export interface EnvAccessor {
|
|
20
|
+
get(name: string): string | undefined;
|
|
21
|
+
require(name: string): string;
|
|
22
|
+
entries(): Record<string, string | undefined>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RouteHandlerResult {
|
|
26
|
+
status?: number;
|
|
27
|
+
headers?: Record<string, string>;
|
|
28
|
+
body?: unknown;
|
|
29
|
+
redirect?: {
|
|
30
|
+
location: string;
|
|
31
|
+
};
|
|
32
|
+
fragment?: {
|
|
33
|
+
target: string;
|
|
34
|
+
selector?: string;
|
|
35
|
+
mode?: 'replace' | 'append' | 'prepend';
|
|
36
|
+
body: unknown;
|
|
37
|
+
};
|
|
38
|
+
errors?: { code: string; message: string; details?: unknown }[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type NormalizedRouteHandlerResult = RouteHandlerResult & {
|
|
42
|
+
fragment?: {
|
|
43
|
+
target: string;
|
|
44
|
+
selector?: string;
|
|
45
|
+
mode?: 'replace' | 'append' | 'prepend';
|
|
46
|
+
body: unknown;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export interface BackendRouteDefinitionLike extends SessionAwareRouteDefinitionLike {
|
|
51
|
+
name?: string;
|
|
52
|
+
method?: string;
|
|
53
|
+
path?: string;
|
|
54
|
+
requestHooks?: RequestHookReferenceLike[];
|
|
55
|
+
interaction?: 'navigation' | 'mutation';
|
|
56
|
+
form?: SessionAwareRouteDefinitionLike['form'] & {
|
|
57
|
+
contentType?: 'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain';
|
|
58
|
+
csrf?: boolean;
|
|
59
|
+
};
|
|
60
|
+
fragment?: {
|
|
61
|
+
target: string;
|
|
62
|
+
selector?: string;
|
|
63
|
+
mode?: 'replace' | 'append' | 'prepend';
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type RouteHandler<TContext, TResult extends RouteHandlerResult> = (
|
|
68
|
+
ctx: TContext,
|
|
69
|
+
) => Promise<TResult> | TResult;
|
|
70
|
+
|
|
71
|
+
export interface ModuleRouteLike<
|
|
72
|
+
TContext,
|
|
73
|
+
TResult extends RouteHandlerResult,
|
|
74
|
+
TRouteDefinition extends BackendRouteDefinitionLike,
|
|
75
|
+
> {
|
|
76
|
+
definition?: TRouteDefinition;
|
|
77
|
+
handler?: RouteHandler<TContext, TResult>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ModuleManifestLike<
|
|
81
|
+
TRouteDefinition extends BackendRouteDefinitionLike = BackendRouteDefinitionLike,
|
|
82
|
+
TViewDefinition extends ViewDefinitionLike = ViewDefinitionLike,
|
|
83
|
+
> {
|
|
84
|
+
name?: string;
|
|
85
|
+
version?: string;
|
|
86
|
+
capabilities?: string[];
|
|
87
|
+
requestHooks?: RequestHookDefinitionLike[];
|
|
88
|
+
routes?: TRouteDefinition[];
|
|
89
|
+
views?: TViewDefinition[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type LifecycleHook = (context: {
|
|
93
|
+
env: EnvAccessor;
|
|
94
|
+
logger: { info(message: string): void };
|
|
95
|
+
}) => Promise<void> | void;
|
|
96
|
+
|
|
97
|
+
export interface ModuleRequestHook<
|
|
98
|
+
TContext,
|
|
99
|
+
TResult extends RouteHandlerResult,
|
|
100
|
+
TRouteDefinition extends BackendRouteDefinitionLike,
|
|
101
|
+
> {
|
|
102
|
+
id?: string;
|
|
103
|
+
handler?: RequestHookHandler<TContext, TResult, TRouteDefinition>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface ModuleDefinitionLike<
|
|
107
|
+
TContext,
|
|
108
|
+
TResult extends RouteHandlerResult,
|
|
109
|
+
TRouteDefinition extends BackendRouteDefinitionLike = BackendRouteDefinitionLike,
|
|
110
|
+
> {
|
|
111
|
+
manifest?: ModuleManifestLike<TRouteDefinition>;
|
|
112
|
+
routes?: ModuleRouteLike<TContext, TResult, TRouteDefinition>[];
|
|
113
|
+
views?: ModuleViewLike[];
|
|
114
|
+
requestHooks?: ModuleRequestHook<TContext, TResult, TRouteDefinition>[];
|
|
115
|
+
init?: LifecycleHook;
|
|
116
|
+
dispose?: LifecycleHook;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface CompiledRoute<
|
|
120
|
+
TContext,
|
|
121
|
+
TResult extends RouteHandlerResult,
|
|
122
|
+
TRouteDefinition extends BackendRouteDefinitionLike,
|
|
123
|
+
> {
|
|
124
|
+
method: string;
|
|
125
|
+
name: string;
|
|
126
|
+
match: (pathname: string) => { matched: boolean; params: Record<string, string> };
|
|
127
|
+
handler: RouteHandler<TContext, TResult>;
|
|
128
|
+
requestHooks: CompiledRequestHook<TContext, TResult, TRouteDefinition>[];
|
|
129
|
+
definition?: TRouteDefinition;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface ModuleRuntime<
|
|
133
|
+
TContext,
|
|
134
|
+
TResult extends RouteHandlerResult,
|
|
135
|
+
TRouteDefinition extends BackendRouteDefinitionLike = BackendRouteDefinitionLike,
|
|
136
|
+
> {
|
|
137
|
+
definition?: ModuleDefinitionLike<TContext, TResult, TRouteDefinition>;
|
|
138
|
+
manifest?: ModuleManifestLike<TRouteDefinition>;
|
|
139
|
+
routes: CompiledRoute<TContext, TResult, TRouteDefinition>[];
|
|
140
|
+
views: CompiledView[];
|
|
141
|
+
source?: string;
|
|
142
|
+
warnings?: string[];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type ReadinessStatus = 'booting' | 'ready' | 'error';
|
|
146
|
+
|
|
147
|
+
export interface ReadinessState {
|
|
148
|
+
status: ReadinessStatus;
|
|
149
|
+
message?: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface ReadinessTracker {
|
|
153
|
+
booting(): void;
|
|
154
|
+
ready(): void;
|
|
155
|
+
error(reason: string): void;
|
|
156
|
+
snapshot(): ReadinessState;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface ManifestSummary {
|
|
160
|
+
name?: string;
|
|
161
|
+
version?: string;
|
|
162
|
+
routes: number;
|
|
163
|
+
views: number;
|
|
164
|
+
capabilities?: string[];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export class RequestBodyTooLargeError extends Error {
|
|
168
|
+
readonly statusCode = 413;
|
|
169
|
+
readonly code = 'payload_too_large';
|
|
170
|
+
|
|
171
|
+
constructor(maxBytes: number) {
|
|
172
|
+
super(`Request body exceeded ${maxBytes} bytes.`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function createProcessEnvAccessor(): EnvAccessor {
|
|
177
|
+
return {
|
|
178
|
+
get: (name) => process.env[name],
|
|
179
|
+
require: (name) => {
|
|
180
|
+
const value = process.env[name];
|
|
181
|
+
if (value === undefined) {
|
|
182
|
+
throw new Error(`Missing required env var ${name}`);
|
|
183
|
+
}
|
|
184
|
+
return value;
|
|
185
|
+
},
|
|
186
|
+
entries: () => ({ ...process.env }),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function createReadinessTracker(): ReadinessTracker {
|
|
191
|
+
let status: ReadinessStatus = 'booting';
|
|
192
|
+
let message: string | undefined;
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
booting() {
|
|
196
|
+
status = 'booting';
|
|
197
|
+
message = undefined;
|
|
198
|
+
},
|
|
199
|
+
ready() {
|
|
200
|
+
status = 'ready';
|
|
201
|
+
message = undefined;
|
|
202
|
+
},
|
|
203
|
+
error(reason: string) {
|
|
204
|
+
status = 'error';
|
|
205
|
+
message = reason;
|
|
206
|
+
},
|
|
207
|
+
snapshot(): ReadinessState {
|
|
208
|
+
return { status, message };
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function normalizePath(value: string | undefined): string {
|
|
214
|
+
if (!value || value === '/') {
|
|
215
|
+
return '/';
|
|
216
|
+
}
|
|
217
|
+
const trimmed = value.endsWith('/') ? value.slice(0, -1) : value;
|
|
218
|
+
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function matchRoute<
|
|
222
|
+
TContext,
|
|
223
|
+
TResult extends RouteHandlerResult,
|
|
224
|
+
TRouteDefinition extends BackendRouteDefinitionLike,
|
|
225
|
+
>(
|
|
226
|
+
routes: readonly CompiledRoute<TContext, TResult, TRouteDefinition>[],
|
|
227
|
+
method: string,
|
|
228
|
+
pathname: string,
|
|
229
|
+
):
|
|
230
|
+
| { route: CompiledRoute<TContext, TResult, TRouteDefinition>; params: Record<string, string> }
|
|
231
|
+
| undefined {
|
|
232
|
+
const normalizedMethod = (method ?? 'GET').toUpperCase();
|
|
233
|
+
for (const route of routes) {
|
|
234
|
+
if (route.method !== normalizedMethod) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const { matched, params } = route.match(pathname);
|
|
238
|
+
if (matched) {
|
|
239
|
+
return { route, params };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export async function loadModuleRuntime<
|
|
246
|
+
TContext,
|
|
247
|
+
TResult extends RouteHandlerResult,
|
|
248
|
+
TRouteDefinition extends BackendRouteDefinitionLike,
|
|
249
|
+
>(options: {
|
|
250
|
+
importMetaUrl: string;
|
|
251
|
+
candidates?: readonly string[];
|
|
252
|
+
}): Promise<ModuleRuntime<TContext, TResult, TRouteDefinition>> {
|
|
253
|
+
const loaded = await tryLoadModuleDefinition<TContext, TResult, TRouteDefinition>(options);
|
|
254
|
+
if (!loaded) {
|
|
255
|
+
return { routes: [], views: [] };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const manifest = sanitizeManifest<TRouteDefinition>(loaded.definition.manifest);
|
|
259
|
+
const compiled = compileRoutes<TContext, TResult, TRouteDefinition>(
|
|
260
|
+
loaded.definition.routes ?? [],
|
|
261
|
+
{
|
|
262
|
+
manifestRequestHooks: manifest?.requestHooks,
|
|
263
|
+
requestHookImplementations: loaded.definition.requestHooks,
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
const views = compileViews(resolveModuleViews(loaded.definition, manifest));
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
definition: loaded.definition,
|
|
270
|
+
manifest,
|
|
271
|
+
routes: compiled.routes,
|
|
272
|
+
views,
|
|
273
|
+
source: loaded.source,
|
|
274
|
+
warnings: compiled.warnings,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function summarizeManifest<TRouteDefinition extends BackendRouteDefinitionLike>(
|
|
279
|
+
manifest?: ModuleManifestLike<TRouteDefinition>,
|
|
280
|
+
): ManifestSummary | undefined {
|
|
281
|
+
if (!manifest) {
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
name: manifest.name,
|
|
287
|
+
version: manifest.version,
|
|
288
|
+
routes: Array.isArray(manifest.routes) ? manifest.routes.length : 0,
|
|
289
|
+
views: Array.isArray(manifest.views) ? manifest.views.length : 0,
|
|
290
|
+
capabilities:
|
|
291
|
+
manifest.capabilities && manifest.capabilities.length > 0 ? manifest.capabilities : undefined,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function logManifestSummary(
|
|
296
|
+
logger: { info(message: string): void },
|
|
297
|
+
manifest: ModuleManifestLike | undefined,
|
|
298
|
+
routeCount: number,
|
|
299
|
+
viewCount: number,
|
|
300
|
+
): void {
|
|
301
|
+
if (!manifest) {
|
|
302
|
+
logger.info(
|
|
303
|
+
`[webstir-backend] manifest routes=${routeCount} views=${viewCount} (no manifest metadata found)`,
|
|
304
|
+
);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const caps = manifest.capabilities?.length ? ` [${manifest.capabilities.join(', ')}]` : '';
|
|
309
|
+
const routes = Array.isArray(manifest.routes) ? manifest.routes.length : routeCount;
|
|
310
|
+
const views = Array.isArray(manifest.views) ? manifest.views.length : viewCount;
|
|
311
|
+
logger.info(
|
|
312
|
+
`[webstir-backend] manifest name=${manifest.name ?? 'unknown'} routes=${routes} views=${views}${caps}`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function normalizeRouteHandlerResult(
|
|
317
|
+
result: RouteHandlerResult,
|
|
318
|
+
): NormalizedRouteHandlerResult {
|
|
319
|
+
const validatedFragment = validateFragmentResult(result.fragment);
|
|
320
|
+
if (!validatedFragment.valid) {
|
|
321
|
+
return {
|
|
322
|
+
status: result.status && result.status >= 400 ? result.status : 500,
|
|
323
|
+
headers: result.headers,
|
|
324
|
+
errors: [
|
|
325
|
+
{
|
|
326
|
+
code: 'invalid_fragment_response',
|
|
327
|
+
message: 'Fragment responses require a non-empty target, supported mode, and body.',
|
|
328
|
+
details: validatedFragment.issues,
|
|
329
|
+
},
|
|
330
|
+
],
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (!validatedFragment.fragment) {
|
|
335
|
+
return result;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
...result,
|
|
340
|
+
fragment: validatedFragment.fragment,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function resolveResponseHeaders(
|
|
345
|
+
result: NormalizedRouteHandlerResult,
|
|
346
|
+
): Record<string, string> {
|
|
347
|
+
const headers: Record<string, string> = { ...(result.headers ?? {}) };
|
|
348
|
+
const lowerCaseHeaders = lowerCaseHeaderMap(headers);
|
|
349
|
+
|
|
350
|
+
if (result.redirect) {
|
|
351
|
+
headers.location = result.redirect.location;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (result.fragment) {
|
|
355
|
+
headers['x-webstir-fragment-cache'] = 'bypass';
|
|
356
|
+
headers['x-webstir-fragment-target'] = result.fragment.target;
|
|
357
|
+
if (result.fragment.selector) {
|
|
358
|
+
headers['x-webstir-fragment-selector'] = result.fragment.selector;
|
|
359
|
+
}
|
|
360
|
+
if (result.fragment.mode) {
|
|
361
|
+
headers['x-webstir-fragment-mode'] = result.fragment.mode;
|
|
362
|
+
}
|
|
363
|
+
if (!('cache-control' in lowerCaseHeaders)) {
|
|
364
|
+
headers['cache-control'] = 'no-store';
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (!('content-type' in lowerCaseHeaders)) {
|
|
369
|
+
const payload = result.fragment ? result.fragment.body : result.body;
|
|
370
|
+
if (payload !== undefined && payload !== null) {
|
|
371
|
+
headers['content-type'] = resolveContentType(payload);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return headers;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function lowerCaseHeaderMap(headers: Record<string, string>): Record<string, string> {
|
|
379
|
+
return Object.fromEntries(
|
|
380
|
+
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function resolveContentType(payload: unknown): string {
|
|
385
|
+
if (typeof payload === 'string') {
|
|
386
|
+
return 'text/html; charset=utf-8';
|
|
387
|
+
}
|
|
388
|
+
if (Buffer.isBuffer(payload)) {
|
|
389
|
+
return 'application/octet-stream';
|
|
390
|
+
}
|
|
391
|
+
return 'application/json';
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function validateFragmentResult(
|
|
395
|
+
fragment: RouteHandlerResult['fragment'],
|
|
396
|
+
):
|
|
397
|
+
| { valid: true; fragment?: NormalizedRouteHandlerResult['fragment'] }
|
|
398
|
+
| { valid: false; issues: string[] } {
|
|
399
|
+
if (!fragment) {
|
|
400
|
+
return { valid: true };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const issues: string[] = [];
|
|
404
|
+
const target = typeof fragment.target === 'string' ? fragment.target.trim() : '';
|
|
405
|
+
if (!target) {
|
|
406
|
+
issues.push('target');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
let selector: string | undefined;
|
|
410
|
+
if (fragment.selector !== undefined) {
|
|
411
|
+
if (typeof fragment.selector !== 'string' || !fragment.selector.trim()) {
|
|
412
|
+
issues.push('selector');
|
|
413
|
+
} else {
|
|
414
|
+
selector = fragment.selector.trim();
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
let mode: 'replace' | 'append' | 'prepend' | undefined;
|
|
419
|
+
if (fragment.mode !== undefined) {
|
|
420
|
+
if (fragment.mode === 'replace' || fragment.mode === 'append' || fragment.mode === 'prepend') {
|
|
421
|
+
mode = fragment.mode;
|
|
422
|
+
} else {
|
|
423
|
+
issues.push('mode');
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (fragment.body === undefined || fragment.body === null) {
|
|
428
|
+
issues.push('body');
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (issues.length > 0) {
|
|
432
|
+
return { valid: false, issues };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
valid: true,
|
|
437
|
+
fragment: {
|
|
438
|
+
target,
|
|
439
|
+
selector,
|
|
440
|
+
mode,
|
|
441
|
+
body: fragment.body,
|
|
442
|
+
},
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function sanitizeManifest<TRouteDefinition extends BackendRouteDefinitionLike>(
|
|
447
|
+
manifest?: ModuleManifestLike<TRouteDefinition>,
|
|
448
|
+
): ModuleManifestLike<TRouteDefinition> | undefined {
|
|
449
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
450
|
+
return undefined;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
...manifest,
|
|
455
|
+
routes: Array.isArray(manifest.routes) ? manifest.routes : [],
|
|
456
|
+
views: Array.isArray(manifest.views) ? manifest.views : [],
|
|
457
|
+
requestHooks: Array.isArray(manifest.requestHooks) ? manifest.requestHooks : [],
|
|
458
|
+
capabilities: Array.isArray(manifest.capabilities) ? manifest.capabilities : undefined,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function compileRoutes<
|
|
463
|
+
TContext,
|
|
464
|
+
TResult extends RouteHandlerResult,
|
|
465
|
+
TRouteDefinition extends BackendRouteDefinitionLike,
|
|
466
|
+
>(
|
|
467
|
+
routes: ModuleRouteLike<TContext, TResult, TRouteDefinition>[],
|
|
468
|
+
options: {
|
|
469
|
+
manifestRequestHooks?: RequestHookDefinitionLike[];
|
|
470
|
+
requestHookImplementations?: ModuleRequestHook<TContext, TResult, TRouteDefinition>[];
|
|
471
|
+
},
|
|
472
|
+
): { routes: CompiledRoute<TContext, TResult, TRouteDefinition>[]; warnings: string[] } {
|
|
473
|
+
const compiled: CompiledRoute<TContext, TResult, TRouteDefinition>[] = [];
|
|
474
|
+
const warnings: string[] = [];
|
|
475
|
+
|
|
476
|
+
for (const route of routes) {
|
|
477
|
+
if (typeof route.handler !== 'function') {
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const method = (route.definition?.method ?? 'GET').toUpperCase();
|
|
482
|
+
const pathPattern = normalizePath(route.definition?.path ?? '/');
|
|
483
|
+
const routeName = route.definition?.name ?? pathPattern;
|
|
484
|
+
const resolvedHooks = resolveRequestHooks<TContext, TResult, TRouteDefinition>({
|
|
485
|
+
routeName,
|
|
486
|
+
routeReferences: route.definition?.requestHooks,
|
|
487
|
+
manifestDefinitions: options.manifestRequestHooks,
|
|
488
|
+
registrations: options.requestHookImplementations,
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
compiled.push({
|
|
492
|
+
method,
|
|
493
|
+
name: routeName,
|
|
494
|
+
match: createPathMatcher(pathPattern),
|
|
495
|
+
handler: route.handler,
|
|
496
|
+
requestHooks: resolvedHooks.hooks,
|
|
497
|
+
definition: route.definition,
|
|
498
|
+
});
|
|
499
|
+
warnings.push(...resolvedHooks.warnings);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return { routes: compiled, warnings };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function resolveModuleViews<
|
|
506
|
+
TContext,
|
|
507
|
+
TResult extends RouteHandlerResult,
|
|
508
|
+
TRouteDefinition extends BackendRouteDefinitionLike,
|
|
509
|
+
>(
|
|
510
|
+
definition: ModuleDefinitionLike<TContext, TResult, TRouteDefinition>,
|
|
511
|
+
manifest?: ModuleManifestLike<TRouteDefinition>,
|
|
512
|
+
): ModuleViewLike[] {
|
|
513
|
+
if (Array.isArray(definition.views) && definition.views.length > 0) {
|
|
514
|
+
return definition.views;
|
|
515
|
+
}
|
|
516
|
+
if (Array.isArray(manifest?.views) && manifest.views.length > 0) {
|
|
517
|
+
return manifest.views.map((view) => ({ definition: view }));
|
|
518
|
+
}
|
|
519
|
+
return [];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function createPathMatcher(pattern: string) {
|
|
523
|
+
const normalized = normalizePath(pattern);
|
|
524
|
+
const paramRegex = /:([A-Za-z0-9_]+)/g;
|
|
525
|
+
const regex = new RegExp(
|
|
526
|
+
'^' +
|
|
527
|
+
normalized
|
|
528
|
+
.replace(/\//g, '\\/')
|
|
529
|
+
.replace(paramRegex, (_segment, name) => `(?<${name}>[^/]+)`) +
|
|
530
|
+
'$',
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
return (pathname: string) => {
|
|
534
|
+
const pathToTest = normalizePath(pathname);
|
|
535
|
+
const match = regex.exec(pathToTest);
|
|
536
|
+
if (!match) {
|
|
537
|
+
return { matched: false, params: {} };
|
|
538
|
+
}
|
|
539
|
+
return {
|
|
540
|
+
matched: true,
|
|
541
|
+
params: (match.groups ?? {}) as Record<string, string>,
|
|
542
|
+
};
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function tryLoadModuleDefinition<
|
|
547
|
+
TContext,
|
|
548
|
+
TResult extends RouteHandlerResult,
|
|
549
|
+
TRouteDefinition extends BackendRouteDefinitionLike,
|
|
550
|
+
>(options: {
|
|
551
|
+
importMetaUrl: string;
|
|
552
|
+
candidates?: readonly string[];
|
|
553
|
+
}): Promise<
|
|
554
|
+
| { definition: ModuleDefinitionLike<TContext, TResult, TRouteDefinition>; source: string }
|
|
555
|
+
| undefined
|
|
556
|
+
> {
|
|
557
|
+
const here = path.dirname(fileURLToPath(options.importMetaUrl));
|
|
558
|
+
const candidates = options.candidates ?? [
|
|
559
|
+
'module.js',
|
|
560
|
+
'module.mjs',
|
|
561
|
+
'module/index.js',
|
|
562
|
+
'module/index.mjs',
|
|
563
|
+
];
|
|
564
|
+
|
|
565
|
+
for (const rel of candidates) {
|
|
566
|
+
const full = path.join(here, rel);
|
|
567
|
+
try {
|
|
568
|
+
const imported = await import(`${pathToFileURL(full).href}?t=${Date.now()}`);
|
|
569
|
+
const definition = extractModuleDefinition<TContext, TResult, TRouteDefinition>(imported);
|
|
570
|
+
if (definition) {
|
|
571
|
+
return { definition, source: rel };
|
|
572
|
+
}
|
|
573
|
+
} catch {
|
|
574
|
+
// ignore and continue
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return undefined;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function extractModuleDefinition<
|
|
582
|
+
TContext,
|
|
583
|
+
TResult extends RouteHandlerResult,
|
|
584
|
+
TRouteDefinition extends BackendRouteDefinitionLike,
|
|
585
|
+
>(
|
|
586
|
+
exports: Record<string, unknown>,
|
|
587
|
+
): ModuleDefinitionLike<TContext, TResult, TRouteDefinition> | undefined {
|
|
588
|
+
const keys = ['module', 'moduleDefinition', 'default', 'backendModule'];
|
|
589
|
+
for (const key of keys) {
|
|
590
|
+
if (key in exports) {
|
|
591
|
+
const value = exports[key as keyof typeof exports];
|
|
592
|
+
if (value && typeof value === 'object') {
|
|
593
|
+
return value as ModuleDefinitionLike<TContext, TResult, TRouteDefinition>;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return undefined;
|
|
598
|
+
}
|