@pablozaiden/webapp 0.2.0 → 0.2.2

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.
@@ -227,14 +227,14 @@ export function passkeyStatus(req: Request, store: WebAppStore, config: RuntimeC
227
227
  const users = store.countUsers();
228
228
  const owner = store.getOwnerUser();
229
229
  const passkeyConfigured = store.listPasskeys().length > 0;
230
- const ownerPasskeySetupRequired = users > 0 && Boolean(owner && !owner.passkeyConfigured);
230
+ const ownerPasskeySetupRequired = enabled && !config.passkeyDisabled && users > 0 && Boolean(owner && !owner.passkeyConfigured);
231
231
  return {
232
232
  enabled,
233
233
  passkeyConfigured,
234
234
  passkeyDisabled: config.passkeyDisabled,
235
235
  passkeyRequired: enabled && users > 0 && !config.passkeyDisabled,
236
- authenticated: !enabled || Boolean(getPasskeySessionUser(req, store, config)),
237
- bootstrapRequired: enabled && users === 0,
236
+ authenticated: !enabled || config.passkeyDisabled || Boolean(getPasskeySessionUser(req, store, config)),
237
+ bootstrapRequired: enabled && !config.passkeyDisabled && users === 0,
238
238
  ownerPasskeySetupRequired,
239
239
  };
240
240
  }
@@ -1,4 +1,4 @@
1
- import type { Server } from "bun";
1
+ import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
2
2
  import type { CurrentUser, LogLevelName, ThemePreference, WebAppConfigResponse, WebAppUserRole } from "../contracts";
3
3
  import { authenticateApiKey, assertScopes, createApiKey, deleteApiKey, listApiKeys } from "./auth/api-keys";
4
4
  import {
@@ -50,6 +50,8 @@ export interface WebAppServerConfig<TEvent = unknown> {
50
50
  version?: string;
51
51
  store?: WebAppStore;
52
52
  routes?: RouteTable<TEvent>;
53
+ publicRoutes?: Record<string, PublicRouteDefinition>;
54
+ websockets?: Record<string, Partial<WebSocketHandler<WebAppWebSocketData>>>;
53
55
  auth?: {
54
56
  passkeys?: boolean | { rpName?: string; userName?: string; userDisplayName?: string };
55
57
  apiKeys?: boolean;
@@ -58,8 +60,29 @@ export interface WebAppServerConfig<TEvent = unknown> {
58
60
  realtime?: {
59
61
  path?: string;
60
62
  };
63
+ logLevel?: {
64
+ onChange?: (level: LogLevelName) => void;
65
+ };
61
66
  }
62
67
 
68
+ export const WEBAPP_SOCKET_HANDLER = "webappSocketHandler";
69
+
70
+ export type WebAppWebSocketData = WebSocketData & {
71
+ webappSocketHandler?: string;
72
+ [key: string]: unknown;
73
+ };
74
+
75
+ export type PublicRouteAsset = Response | Blob | ArrayBuffer | Uint8Array | string;
76
+ export type PublicRouteHandler = (req: Request) => PublicRouteAsset | undefined | Promise<PublicRouteAsset | undefined>;
77
+ export type PublicRouteValue = PublicRouteAsset | PublicRouteHandler;
78
+ export type PublicRouteDefinition =
79
+ | PublicRouteValue
80
+ | {
81
+ GET?: PublicRouteValue;
82
+ HEAD?: PublicRouteValue;
83
+ headers?: HeadersInit;
84
+ };
85
+
63
86
  export interface WebAppServer<TEvent = unknown> {
64
87
  config: RuntimeConfig;
65
88
  store: WebAppStore;
@@ -70,6 +93,7 @@ export interface WebAppServer<TEvent = unknown> {
70
93
  }
71
94
 
72
95
  const log = createLogger("webapp:server");
96
+ const LOG_LEVELS = new Set<LogLevelName>(["trace", "debug", "info", "warn", "error"]);
73
97
 
74
98
  function bearerToken(req: Request): string | undefined {
75
99
  const header = req.headers.get("authorization")?.trim();
@@ -127,6 +151,20 @@ function htmlResponse(index: unknown): Response {
127
151
  return index as Response;
128
152
  }
129
153
 
154
+ function publicAssetResponse(asset: PublicRouteAsset, extraHeaders?: HeadersInit): Response {
155
+ const response = asset instanceof Response
156
+ ? asset
157
+ : typeof asset === "string"
158
+ ? new Response(asset, { headers: { "content-type": "text/plain; charset=utf-8" } })
159
+ : new Response(asset as BodyInit);
160
+ if (extraHeaders) {
161
+ for (const [name, value] of new Headers(extraHeaders)) {
162
+ response.headers.set(name, value);
163
+ }
164
+ }
165
+ return withSecurityHeaders(response);
166
+ }
167
+
130
168
  function secureDynamicResponse(response: Response): Response {
131
169
  return response instanceof Response ? withSecurityHeaders(response) : response;
132
170
  }
@@ -143,6 +181,16 @@ function currentUser(auth: AuthenticatedRequestState): CurrentUser | undefined {
143
181
  return auth.kind === "anonymous" ? undefined : auth.user;
144
182
  }
145
183
 
184
+ function toCurrentUserRecord(user: { id: string; username: string; role: "owner" | "admin" | "user" }): CurrentUser {
185
+ return {
186
+ id: user.id,
187
+ username: user.username,
188
+ role: user.role,
189
+ isOwner: user.role === "owner",
190
+ isAdmin: user.role === "owner" || user.role === "admin",
191
+ };
192
+ }
193
+
146
194
  function requireUser(auth: AuthenticatedRequestState): CurrentUser {
147
195
  const user = currentUser(auth);
148
196
  if (!user) {
@@ -240,15 +288,29 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
240
288
  const store = input.store ?? sqliteWebAppStore({ dataDir: config.dataDir });
241
289
  store.initialize();
242
290
  const savedLogLevel = store.getLogLevelPreference();
243
- setLogLevel(config.logLevelFromEnv ? config.logLevel : savedLogLevel ?? config.logLevel);
291
+ const activeLogLevel = config.logLevelFromEnv ? config.logLevel : savedLogLevel ?? config.logLevel;
292
+ setLogLevel(activeLogLevel);
293
+ input.logLevel?.onChange?.(activeLogLevel);
244
294
  const realtime = createRealtimeBus<TEvent>();
245
295
  const version = input.version ?? "0.0.0-development";
246
296
  const wsPath = input.realtime?.path ?? "/api/ws";
247
297
  const routes = input.routes ?? {};
298
+ const publicRoutes = input.publicRoutes ?? {};
299
+ const appWebsockets = input.websockets ?? {};
248
300
  const passkeysEnabled = input.auth?.passkeys !== false;
249
301
  const apiKeysEnabled = input.auth?.apiKeys ?? false;
250
302
  const deviceAuthEnabled = input.auth?.deviceAuth ?? false;
251
303
 
304
+ function disabledAuthOwner(): CurrentUser {
305
+ const existing = store.getOwnerUser();
306
+ if (existing) {
307
+ return toCurrentUserRecord(existing);
308
+ }
309
+ const owner = createUserRecord({ username: "admin", role: "owner" });
310
+ store.createUser(owner);
311
+ return toCurrentUserRecord(owner);
312
+ }
313
+
252
314
  async function authorize(req: Request, required: boolean): Promise<AuthenticatedRequestState | Response> {
253
315
  const token = bearerToken(req);
254
316
  if (token) {
@@ -278,6 +340,9 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
278
340
  return errorResponse(401, "invalid_token", "Bearer token is invalid");
279
341
  }
280
342
  if (passkeysEnabled) {
343
+ if (config.passkeyDisabled) {
344
+ return { kind: "passkey", user: disabledAuthOwner() };
345
+ }
281
346
  const user = getPasskeySessionUser(req, store, config);
282
347
  if (user) {
283
348
  return { kind: "passkey", user };
@@ -291,11 +356,16 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
291
356
  }) : { kind: "anonymous" };
292
357
  }
293
358
  }
359
+ if (required && passkeysEnabled && !config.passkeyDisabled && store.countUsers() === 0) {
360
+ return errorResponse(401, "authentication_required", "Passkey authentication is required", undefined, {
361
+ headers: { "x-webapp-passkey-required": "true" },
362
+ });
363
+ }
294
364
  return { kind: "anonymous" };
295
365
  }
296
366
 
297
367
  function configResponse(req: Request): WebAppConfigResponse {
298
- const user = passkeysEnabled ? getPasskeySessionUser(req, store, config) : undefined;
368
+ const user = passkeysEnabled && config.passkeyDisabled ? disabledAuthOwner() : passkeysEnabled ? getPasskeySessionUser(req, store, config) : undefined;
299
369
  return {
300
370
  appName: config.appName,
301
371
  version,
@@ -601,8 +671,12 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
601
671
  if (originFailure) return originFailure;
602
672
  if (config.logLevelFromEnv) return errorResponse(409, "log_level_from_env", "Log level is controlled by environment");
603
673
  const body = await parseJson<{ level: LogLevelName }>(req);
674
+ if (!LOG_LEVELS.has(body.level)) {
675
+ return errorResponse(400, "invalid_log_level", "Log level must be one of trace, debug, info, warn, error");
676
+ }
604
677
  store.setLogLevelPreference(body.level);
605
678
  setLogLevel(body.level);
679
+ input.logLevel?.onChange?.(body.level);
606
680
  return successResponse({ level: body.level });
607
681
  }
608
682
  }
@@ -624,78 +698,123 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
624
698
  return undefined;
625
699
  }
626
700
 
627
- async function handleRequest(req: Request, server?: Server<WebSocketData>): Promise<Response | undefined> {
701
+ async function handlePublicRoute(req: Request): Promise<Response | undefined> {
702
+ const url = new URL(req.url);
703
+ const route = publicRoutes[url.pathname];
704
+ if (!route) {
705
+ return undefined;
706
+ }
707
+ const methodName = req.method === "HEAD" ? "HEAD" : req.method === "GET" ? "GET" : undefined;
708
+ if (!methodName) {
709
+ return withSecurityHeaders(methodNotAllowed());
710
+ }
711
+ const definition = typeof route === "object" && route !== null && !(route instanceof Response) && !(route instanceof Blob) && !(route instanceof ArrayBuffer) && !(route instanceof Uint8Array) && ("GET" in route || "HEAD" in route || "headers" in route)
712
+ ? route
713
+ : undefined;
714
+ const value = definition ? definition[methodName] ?? (methodName === "HEAD" ? definition.GET : undefined) : route as PublicRouteValue;
715
+ if (!value) {
716
+ return withSecurityHeaders(methodNotAllowed());
717
+ }
718
+ const asset = typeof value === "function" ? await value(req) : value;
719
+ if (!asset) {
720
+ return withSecurityHeaders(notFound());
721
+ }
722
+ const response = publicAssetResponse(asset, definition?.headers);
723
+ if (req.method === "HEAD") {
724
+ return new Response(null, { status: response.status, statusText: response.statusText, headers: response.headers });
725
+ }
726
+ return response;
727
+ }
728
+
729
+ async function handleMatchedRoute(req: Request, matched: NonNullable<ReturnType<typeof matchRoute<TEvent>>>, server?: Server<WebAppWebSocketData>): Promise<Response | undefined> {
730
+ const handler = matched.route[method(req) ?? "GET"];
731
+ if (!handler) {
732
+ return withSecurityHeaders(methodNotAllowed());
733
+ }
734
+ const routeAuth = matched.route.auth ?? "required";
735
+ const required = requiresAuth(routeAuth);
736
+ const auth = await authorize(req, required);
737
+ if (auth instanceof Response) {
738
+ return withSecurityHeaders(auth);
739
+ }
740
+ try {
741
+ enforceRouteAuth(routeAuth, auth);
742
+ if (matched.route.userParam) {
743
+ const paramValue = matched.params[matched.route.userParam];
744
+ if (!paramValue) {
745
+ throw new AuthError("route_misconfigured", `Route userParam "${matched.route.userParam}" is missing from matched params`, 500);
746
+ }
747
+ assertUser(auth, paramValue);
748
+ }
749
+ if (routeAuth !== "public" && (auth.kind === "api-key" || auth.kind === "bearer")) {
750
+ assertScopes(auth.kind === "api-key" ? auth.scopes : scopesFromBearer(auth.claims), matched.route.scopes ?? []);
751
+ }
752
+ } catch (error) {
753
+ return withSecurityHeaders(authErrorResponse(error));
754
+ }
755
+ const current = () => requireUser(auth);
756
+ const userRealtime = {
757
+ publishChanged: (resource, options = {}) => realtime.publishChanged(resource, { ...options, target: { ...options.target, userId: current().id } }),
758
+ publishEntityChanged: (resource, id, options = {}) => realtime.publishEntityChanged(resource, id, { ...options, target: { ...options.target, userId: current().id } }),
759
+ publishDeleted: (resource, id, options = {}) => realtime.publishDeleted(resource, id, { ...options, target: { ...options.target, userId: current().id } }),
760
+ publishSettingsChanged: (options = {}) => realtime.publishSettingsChanged({ ...options, target: { ...options.target, userId: current().id } }),
761
+ } satisfies UserScopedRealtimePublisher<TEvent>;
762
+ const originFailure = checkSameOrigin(req, config, auth, matched.route.sameOrigin ?? "mutations");
763
+ if (originFailure) {
764
+ return withSecurityHeaders(originFailure);
765
+ }
766
+ try {
767
+ const response = await handler(req, {
768
+ params: matched.params,
769
+ auth,
770
+ user: currentUser(auth),
771
+ requireUser: () => requireUser(auth),
772
+ requireAdmin: () => requireAdmin(auth),
773
+ requireOwner: () => requireOwner(auth),
774
+ assertUser: (userId) => assertUser(auth, userId),
775
+ filterOwned: createFilterOwned(auth),
776
+ requireOwned: createRequireOwned(auth),
777
+ realtime,
778
+ userRealtime,
779
+ server,
780
+ });
781
+ return response ? withSecurityHeaders(response) : undefined;
782
+ } catch (error) {
783
+ return withSecurityHeaders(routeHandlerErrorResponse(error));
784
+ }
785
+ }
786
+
787
+ async function handleRequest(req: Request, server?: Server<WebAppWebSocketData>): Promise<Response | undefined> {
628
788
  const url = new URL(req.url);
789
+ const publicRoute = await handlePublicRoute(req);
790
+ if (publicRoute) {
791
+ return publicRoute;
792
+ }
793
+ const matched = matchRoute(routes, url.pathname);
629
794
  if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/.well-known/") || url.pathname === "/device") {
630
795
  const builtIn = await handleBuiltIn(req, server);
631
796
  if (builtIn) {
632
797
  return secureDynamicResponse(builtIn);
633
798
  }
634
- const matched = matchRoute(routes, url.pathname);
635
799
  if (!matched) {
636
800
  return withSecurityHeaders(notFound());
637
801
  }
638
- const handler = matched.route[method(req) ?? "GET"];
639
- if (!handler) {
640
- return withSecurityHeaders(methodNotAllowed());
641
- }
642
- const routeAuth = matched.route.auth ?? "required";
643
- const required = requiresAuth(routeAuth);
644
- const auth = await authorize(req, required);
645
- if (auth instanceof Response) {
646
- return withSecurityHeaders(auth);
647
- }
648
- try {
649
- enforceRouteAuth(routeAuth, auth);
650
- if (matched.route.userParam) {
651
- const paramValue = matched.params[matched.route.userParam];
652
- if (!paramValue) {
653
- throw new AuthError("route_misconfigured", `Route userParam "${matched.route.userParam}" is missing from matched params`, 500);
654
- }
655
- assertUser(auth, paramValue);
656
- }
657
- if (routeAuth !== "public" && (auth.kind === "api-key" || auth.kind === "bearer")) {
658
- assertScopes(auth.kind === "api-key" ? auth.scopes : scopesFromBearer(auth.claims), matched.route.scopes ?? []);
659
- }
660
- } catch (error) {
661
- return withSecurityHeaders(authErrorResponse(error));
662
- }
663
- const current = () => requireUser(auth);
664
- const userRealtime = {
665
- publishChanged: (resource, options = {}) => realtime.publishChanged(resource, { ...options, target: { ...options.target, userId: current().id } }),
666
- publishEntityChanged: (resource, id, options = {}) => realtime.publishEntityChanged(resource, id, { ...options, target: { ...options.target, userId: current().id } }),
667
- publishDeleted: (resource, id, options = {}) => realtime.publishDeleted(resource, id, { ...options, target: { ...options.target, userId: current().id } }),
668
- publishSettingsChanged: (options = {}) => realtime.publishSettingsChanged({ ...options, target: { ...options.target, userId: current().id } }),
669
- } satisfies UserScopedRealtimePublisher<TEvent>;
670
- const originFailure = checkSameOrigin(req, config, auth, matched.route.sameOrigin ?? "mutations");
671
- if (originFailure) {
672
- return withSecurityHeaders(originFailure);
673
- }
674
- try {
675
- return withSecurityHeaders(await handler(req, {
676
- params: matched.params,
677
- auth,
678
- user: currentUser(auth),
679
- requireUser: () => requireUser(auth),
680
- requireAdmin: () => requireAdmin(auth),
681
- requireOwner: () => requireOwner(auth),
682
- assertUser: (userId) => assertUser(auth, userId),
683
- filterOwned: createFilterOwned(auth),
684
- requireOwned: createRequireOwned(auth),
685
- realtime,
686
- userRealtime,
687
- server,
688
- }));
689
- } catch (error) {
690
- return withSecurityHeaders(routeHandlerErrorResponse(error));
691
- }
802
+ return handleMatchedRoute(req, matched, server);
803
+ }
804
+ if (matched) {
805
+ return handleMatchedRoute(req, matched, server);
692
806
  }
693
807
  return htmlResponse(input.index);
694
808
  }
695
809
 
696
- function start(): Server<WebSocketData> {
697
- const dynamicHandler = (req: Request, server: Server<WebSocketData>) => handleRequest(req, server);
698
- const server = Bun.serve<WebSocketData>({
810
+ function customHandler(socket: ServerWebSocket<WebAppWebSocketData>): Partial<WebSocketHandler<WebAppWebSocketData>> | undefined {
811
+ const handlerName = socket.data.webappSocketHandler;
812
+ return handlerName ? appWebsockets[handlerName] : undefined;
813
+ }
814
+
815
+ function start(): Server<WebAppWebSocketData> {
816
+ const dynamicHandler = (req: Request, server: Server<WebAppWebSocketData>) => handleRequest(req, server);
817
+ const server = Bun.serve<WebAppWebSocketData>({
699
818
  hostname: config.host,
700
819
  port: config.port,
701
820
  routes: {
@@ -707,16 +826,34 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
707
826
  },
708
827
  websocket: {
709
828
  open(socket) {
829
+ const handler = customHandler(socket);
830
+ if (handler?.open) {
831
+ handler.open(socket);
832
+ return;
833
+ }
710
834
  realtime.add(socket);
711
835
  },
712
836
  message(socket, message) {
837
+ const handler = customHandler(socket);
838
+ if (handler?.message) {
839
+ handler.message(socket, message);
840
+ return;
841
+ }
713
842
  if (message === "ping") {
714
843
  socket.send(JSON.stringify({ type: "pong" }));
715
844
  }
716
845
  },
717
- close(socket) {
846
+ close(socket, code, reason) {
847
+ const handler = customHandler(socket);
848
+ if (handler?.close) {
849
+ handler.close(socket, code, reason);
850
+ return;
851
+ }
718
852
  realtime.remove(socket);
719
853
  },
854
+ drain(socket) {
855
+ customHandler(socket)?.drain?.(socket);
856
+ },
720
857
  },
721
858
  development: config.development,
722
859
  });
@@ -1,5 +1,6 @@
1
1
  export * from "./runtime-config";
2
2
  export * from "./routes";
3
+ export * from "./route-catalog";
3
4
  export * from "./responses";
4
5
  export * from "./create-web-app-server";
5
6
  export * from "./auth/store";
@@ -0,0 +1,147 @@
1
+ import type { HttpMethod, RouteAuth, RouteDefinition, RouteTable, SameOriginMode } from "./routes";
2
+
3
+ const HTTP_METHODS: HttpMethod[] = ["GET", "POST", "PUT", "PATCH", "DELETE"];
4
+
5
+ export interface RouteCatalogEntry {
6
+ path: string;
7
+ cliPath: string;
8
+ methods: HttpMethod[];
9
+ auth: RouteAuth;
10
+ sameOrigin: SameOriginMode;
11
+ scopes: string[];
12
+ description?: string;
13
+ tags: string[];
14
+ requestSchema?: unknown;
15
+ querySchema?: unknown;
16
+ responseSchema?: unknown;
17
+ }
18
+
19
+ export interface RouteCatalogMatch {
20
+ entry: RouteCatalogEntry;
21
+ path: string;
22
+ params: Record<string, string>;
23
+ }
24
+
25
+ function trimSlashes(value: string): string {
26
+ return value.replace(/^\/+|\/+$/g, "");
27
+ }
28
+
29
+ function defaultCliPath(path: string): string {
30
+ return trimSlashes(path.startsWith("/api/") ? path.slice("/api/".length) : path);
31
+ }
32
+
33
+ function methodsFor(route: RouteDefinition): HttpMethod[] {
34
+ return HTTP_METHODS.filter((method) => typeof route[method] === "function");
35
+ }
36
+
37
+ export function createRouteCatalog<TEvent = unknown>(routes: RouteTable<TEvent>): RouteCatalogEntry[] {
38
+ return Object.entries(routes)
39
+ .filter(([, route]) => route.catalog !== false)
40
+ .map(([path, route]) => ({
41
+ path,
42
+ cliPath: route.cliPath ?? defaultCliPath(path),
43
+ methods: methodsFor(route),
44
+ auth: route.auth ?? "required",
45
+ sameOrigin: route.sameOrigin ?? "mutations",
46
+ scopes: route.scopes ?? [],
47
+ description: route.description,
48
+ tags: route.tags ?? [],
49
+ requestSchema: route.requestSchema,
50
+ querySchema: route.querySchema,
51
+ responseSchema: route.responseSchema,
52
+ }))
53
+ .filter((entry) => entry.methods.length > 0)
54
+ .sort((left, right) => left.path.localeCompare(right.path));
55
+ }
56
+
57
+ function normalizeApiPath(input: string): string {
58
+ const [path = ""] = input.trim().split(/[?#]/);
59
+ if (!path) {
60
+ throw new Error("API endpoint is required");
61
+ }
62
+ if (path.startsWith("/api/") || path === "/api") {
63
+ return path;
64
+ }
65
+ if (path.startsWith("api/")) {
66
+ return `/${path}`;
67
+ }
68
+ if (path.startsWith("/")) {
69
+ return path;
70
+ }
71
+ return `/api/${path}`;
72
+ }
73
+
74
+ function normalizeCliPath(input: string): string {
75
+ const [path = ""] = input.trim().split(/[?#]/);
76
+ return trimSlashes(path.startsWith("/api/") ? path.slice("/api/".length) : path);
77
+ }
78
+
79
+ function decodePathPart(value: string): string | undefined {
80
+ try {
81
+ return decodeURIComponent(value);
82
+ } catch {
83
+ return undefined;
84
+ }
85
+ }
86
+
87
+ function matchPattern(pattern: string, value: string): Record<string, string> | undefined {
88
+ const patternParts = trimSlashes(pattern).split("/").filter(Boolean);
89
+ const valueParts = trimSlashes(value).split("/").filter(Boolean);
90
+ if (patternParts.length !== valueParts.length) {
91
+ return undefined;
92
+ }
93
+ const params: Record<string, string> = {};
94
+ for (let index = 0; index < patternParts.length; index += 1) {
95
+ const patternPart = patternParts[index]!;
96
+ const valuePart = valueParts[index]!;
97
+ if (patternPart.startsWith(":")) {
98
+ const decoded = decodePathPart(valuePart);
99
+ if (decoded === undefined) {
100
+ return undefined;
101
+ }
102
+ params[patternPart.slice(1)] = decoded;
103
+ continue;
104
+ }
105
+ if (patternPart !== valuePart) {
106
+ return undefined;
107
+ }
108
+ }
109
+ return params;
110
+ }
111
+
112
+ function concretePath(pattern: string, params: Record<string, string>): string {
113
+ return trimSlashes(pattern)
114
+ .split("/")
115
+ .map((part) => part.startsWith(":") ? encodeURIComponent(params[part.slice(1)] ?? "") : part)
116
+ .join("/")
117
+ .replace(/^/, "/");
118
+ }
119
+
120
+ function specificity(entry: RouteCatalogEntry): number {
121
+ return entry.path.split("/").reduce((score, part) => score + (part && !part.startsWith(":") ? 2 : 1), 0);
122
+ }
123
+
124
+ export function findRouteCatalogEntry(catalog: readonly RouteCatalogEntry[], input: string): RouteCatalogMatch | undefined {
125
+ const apiPath = normalizeApiPath(input);
126
+ const cliPath = normalizeCliPath(input);
127
+ const sorted = [...catalog].sort((left, right) => specificity(right) - specificity(left));
128
+ for (const entry of sorted) {
129
+ if (entry.path === apiPath) {
130
+ return { entry, path: apiPath, params: {} };
131
+ }
132
+ if (entry.cliPath === cliPath) {
133
+ return { entry, path: entry.path, params: {} };
134
+ }
135
+ }
136
+ for (const entry of sorted) {
137
+ const apiParams = matchPattern(entry.path, apiPath);
138
+ if (apiParams) {
139
+ return { entry, path: apiPath, params: apiParams };
140
+ }
141
+ const cliParams = matchPattern(entry.cliPath, cliPath);
142
+ if (cliParams) {
143
+ return { entry, path: concretePath(entry.path, cliParams), params: cliParams };
144
+ }
145
+ }
146
+ return undefined;
147
+ }
@@ -36,14 +36,24 @@ export interface RouteContext<TParams extends Record<string, string> = Record<st
36
36
  export type WebAppRouteHandler<TParams extends Record<string, string> = Record<string, string>, TEvent = unknown> = (
37
37
  req: Request,
38
38
  ctx: RouteContext<TParams, TEvent>,
39
- ) => Response | Promise<Response>;
39
+ ) => Response | undefined | Promise<Response | undefined>;
40
+
41
+ export interface RouteMetadata {
42
+ description?: string;
43
+ cliPath?: string;
44
+ tags?: string[];
45
+ requestSchema?: unknown;
46
+ querySchema?: unknown;
47
+ responseSchema?: unknown;
48
+ catalog?: boolean;
49
+ }
40
50
 
41
51
  export type RouteDefinition<TEvent = unknown> = {
42
52
  auth?: RouteAuth;
43
53
  sameOrigin?: SameOriginMode;
44
54
  scopes?: string[];
45
55
  userParam?: string;
46
- } & Partial<Record<HttpMethod, WebAppRouteHandler<Record<string, string>, TEvent>>>;
56
+ } & RouteMetadata & Partial<Record<HttpMethod, WebAppRouteHandler<Record<string, string>, TEvent>>>;
47
57
 
48
58
  export type RouteTable<TEvent = unknown> = Record<string, RouteDefinition<TEvent>>;
49
59