@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.
- package/README.md +1 -0
- package/docs/cli.md +45 -0
- package/docs/deployment.md +3 -0
- package/docs/getting-started.md +14 -0
- package/docs/server.md +50 -0
- package/docs/settings.md +4 -0
- package/docs/sidebar.md +1 -1
- package/docs/ui-guidelines.md +3 -1
- package/package.json +8 -3
- package/src/cli/api-command.ts +119 -0
- package/src/cli/credentials.ts +67 -0
- package/src/cli/device-auth.ts +179 -0
- package/src/cli/index.ts +4 -0
- package/src/cli/runtime.ts +51 -0
- package/src/server/auth/passkeys.ts +3 -3
- package/src/server/create-web-app-server.ts +200 -63
- package/src/server/index.ts +1 -0
- package/src/server/route-catalog.ts +147 -0
- package/src/server/routes.ts +12 -2
- package/src/web/WebAppRoot.tsx +112 -10
- package/src/web/api-client.ts +64 -0
- package/src/web/index.ts +1 -0
- package/src/web/realtime/useRealtime.ts +2 -2
- package/src/web/styles.css +66 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
|
697
|
-
const
|
|
698
|
-
|
|
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
|
});
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/server/routes.ts
CHANGED
|
@@ -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
|
|