bosia 0.4.4 → 0.5.0

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.
@@ -0,0 +1,127 @@
1
+ // ─── Client-Side Loader Cache ─────────────────────────────
2
+ // Stores the result of each server loader run by stable id (the
3
+ // +page.server.ts / +layout.server.ts path emitted by codegen) along
4
+ // with the LoaderDeps record captured server-side. On each client
5
+ // navigation we use the cache + a "dirty" set populated by the
6
+ // `invalidate()` API to decide which loaders need to re-run, sending
7
+ // the decision as an `_invalidated=<bits>` query param so the server
8
+ // can skip the loaders that haven't conceptually changed.
9
+ //
10
+ // Cache lives in browser memory only — wiped on hard refresh.
11
+
12
+ import type { LoaderDeps } from "../hooks.ts";
13
+
14
+ export type CacheSnapshot = {
15
+ pathname: string;
16
+ params: Record<string, string>;
17
+ searchParams: Record<string, string | null>;
18
+ cookies: Record<string, string | undefined>;
19
+ };
20
+
21
+ export type CacheEntry = {
22
+ nodeId: string;
23
+ data: Record<string, any>;
24
+ deps: LoaderDeps;
25
+ snapshot: CacheSnapshot;
26
+ };
27
+
28
+ export type EvalContext = {
29
+ pathname: string;
30
+ params: Record<string, string>;
31
+ url: URL;
32
+ cookies: Record<string, string | undefined>;
33
+ };
34
+
35
+ function readDocumentCookies(): Record<string, string> {
36
+ const out: Record<string, string> = {};
37
+ if (typeof document === "undefined") return out;
38
+ for (const pair of document.cookie.split(";")) {
39
+ const idx = pair.indexOf("=");
40
+ if (idx === -1) continue;
41
+ const name = pair.slice(0, idx).trim();
42
+ const value = pair.slice(idx + 1).trim();
43
+ if (name) {
44
+ try {
45
+ out[name] = decodeURIComponent(value);
46
+ } catch {
47
+ out[name] = value;
48
+ }
49
+ }
50
+ }
51
+ return out;
52
+ }
53
+
54
+ export function liveContext(
55
+ pathname: string,
56
+ params: Record<string, string>,
57
+ url: URL,
58
+ ): EvalContext {
59
+ return {
60
+ pathname,
61
+ params,
62
+ url,
63
+ cookies: readDocumentCookies(),
64
+ };
65
+ }
66
+
67
+ /** Capture only the values relevant to a loader's tracked deps. */
68
+ export function captureSnapshot(deps: LoaderDeps, ctx: EvalContext): CacheSnapshot {
69
+ const params: Record<string, string> = {};
70
+ for (const k of deps.params) params[k] = ctx.params[k] ?? "";
71
+ const searchParams: Record<string, string | null> = {};
72
+ for (const k of deps.searchParams) searchParams[k] = ctx.url.searchParams.get(k);
73
+ const cookies: Record<string, string | undefined> = {};
74
+ for (const k of deps.cookies) cookies[k] = ctx.cookies[k];
75
+ return { pathname: ctx.pathname, params, searchParams, cookies };
76
+ }
77
+
78
+ export type DirtyState = {
79
+ all: boolean;
80
+ keys: Set<string>;
81
+ urls: Set<string>;
82
+ urlMatchers: Array<(u: URL) => boolean>;
83
+ };
84
+
85
+ export function shouldRerun(entry: CacheEntry, dirty: DirtyState, next: EvalContext): boolean {
86
+ if (dirty.all) return true;
87
+ const { deps, snapshot } = entry;
88
+ // Dirty keys
89
+ for (const k of deps.keys) {
90
+ if (dirty.keys.has(k)) return true;
91
+ }
92
+ // Dirty URLs (string match or predicate match)
93
+ if (deps.urls.length > 0) {
94
+ for (const u of deps.urls) {
95
+ if (dirty.urls.has(u)) return true;
96
+ for (const fn of dirty.urlMatchers) {
97
+ try {
98
+ if (fn(new URL(u))) return true;
99
+ } catch {
100
+ // ignore malformed URL deps
101
+ }
102
+ }
103
+ }
104
+ }
105
+ // Param value changes
106
+ for (const k of deps.params) {
107
+ if (snapshot.params[k] !== (next.params[k] ?? "")) return true;
108
+ }
109
+ // Search param value changes
110
+ for (const k of deps.searchParams) {
111
+ if (snapshot.searchParams[k] !== next.url.searchParams.get(k)) return true;
112
+ }
113
+ // Cookie value changes
114
+ for (const k of deps.cookies) {
115
+ if (k === "*") {
116
+ // Broad cookie read — re-run on any cookie change. Detect by
117
+ // comparing the full incoming jar against the captured slice.
118
+ // In practice an empty captured slice will never equal the live
119
+ // jar once any cookie exists, so we re-run whenever cookies do.
120
+ return true;
121
+ }
122
+ if (snapshot.cookies[k] !== next.cookies[k]) return true;
123
+ }
124
+ // Pathname change for loaders that read the URL
125
+ if (deps.uses_url && snapshot.pathname !== next.pathname) return true;
126
+ return false;
127
+ }
@@ -0,0 +1,59 @@
1
+ // ─── Public Invalidation API ──────────────────────────────
2
+ // Counterpart to SvelteKit's `invalidate()` / `invalidateAll()`. Marks
3
+ // loader cache entries dirty and triggers the App.svelte nav effect to
4
+ // re-run only the loaders whose dependencies were invalidated.
5
+ //
6
+ // Usage:
7
+ // import { invalidate, invalidateAll } from "bosia/client";
8
+ // await invalidate("app:user");
9
+ // await invalidate("/api/posts");
10
+ // await invalidate((url) => url.pathname.startsWith("/api/"));
11
+ // await invalidateAll();
12
+
13
+ import { appState } from "./appState.svelte.ts";
14
+
15
+ type InvalidateTarget = string | URL | ((url: URL) => boolean);
16
+
17
+ function bumpTick() {
18
+ appState.invalidationTick = appState.invalidationTick + 1;
19
+ }
20
+
21
+ /**
22
+ * Mark a dependency as invalid; the next navigation (and any in-progress
23
+ * navigation that hasn't started its fetch yet) will re-run loaders that
24
+ * declared a matching `depends()` key, fetched a matching URL, or — when
25
+ * given a predicate — fetched any URL the predicate returns true for.
26
+ *
27
+ * Returns a promise that resolves after the nav effect has flushed.
28
+ */
29
+ export function invalidate(target: InvalidateTarget): Promise<void> {
30
+ if (typeof target === "function") {
31
+ appState.dirty.urlMatchers.push(target);
32
+ } else {
33
+ const str = typeof target === "string" ? target : target.href;
34
+ const isUrl = str.startsWith("/") || str.includes("://");
35
+ if (isUrl) {
36
+ try {
37
+ // Normalize to an absolute URL — fetches the loader recorded are
38
+ // always absolute, so a relative `/api/foo` must be promoted here.
39
+ const abs = new URL(str, window.location.origin).href;
40
+ appState.dirty.urls.add(abs);
41
+ } catch {
42
+ appState.dirty.urls.add(str);
43
+ }
44
+ } else {
45
+ appState.dirty.keys.add(str);
46
+ }
47
+ }
48
+ bumpTick();
49
+ return Promise.resolve();
50
+ }
51
+
52
+ /**
53
+ * Mark every loader as invalid. Next navigation re-runs all of them.
54
+ */
55
+ export function invalidateAll(): Promise<void> {
56
+ appState.dirty.all = true;
57
+ bumpTick();
58
+ return Promise.resolve();
59
+ }
@@ -2,11 +2,52 @@
2
2
  // Supports `data-bosia-preload="hover"` and `data-bosia-preload="viewport"`
3
3
  // on <a> elements or their ancestors.
4
4
 
5
+ import { findMatch } from "../matcher.ts";
6
+ import { clientRoutes } from "bosia:routes";
7
+ import { appState } from "./appState.svelte.ts";
8
+ import { liveContext, shouldRerun } from "./loaderCache.ts";
9
+
10
+ /**
11
+ * Build the `_invalidated` mask bits for a target path using the current
12
+ * client loader cache. Char 0 = page, char i+1 = layout depth i; '1' = run,
13
+ * '0' = skip. Returns `null` when the route cannot be matched.
14
+ */
15
+ export function buildMaskBits(path: string): string | null {
16
+ const url = new URL(path, window.location.origin);
17
+ const pathname = url.pathname;
18
+ const match = findMatch(clientRoutes, pathname);
19
+ if (!match) return null;
20
+ const ctx = liveContext(pathname, match.params, url);
21
+ const layoutIds = (match.route as any).layoutIds as (string | null)[];
22
+ const pageId = (match.route as any).pageId as string | null;
23
+
24
+ const layoutRunFlags = layoutIds.map((id) => {
25
+ if (id === null) return false;
26
+ const entry = appState.loaderCache.layouts[id];
27
+ if (!entry) return true;
28
+ return shouldRerun(entry, appState.dirty, ctx);
29
+ });
30
+
31
+ let pageRun = false;
32
+ if (pageId !== null) {
33
+ const entry = appState.loaderCache.page;
34
+ if (!entry || entry.nodeId !== pageId) pageRun = true;
35
+ else pageRun = shouldRerun(entry, appState.dirty, ctx);
36
+ }
37
+
38
+ return (pageRun ? "1" : "0") + layoutRunFlags.map((b) => (b ? "1" : "0")).join("");
39
+ }
40
+
5
41
  /** Builds the `/__bosia/data/…` URL for a given client path. */
6
- export function dataUrl(path: string): string {
42
+ export function dataUrl(path: string, invalidatedBits?: string): string {
7
43
  const url = new URL(path, window.location.origin);
8
44
  let p = url.pathname.replace(/\/$/, "");
9
- return `/__bosia/data${p || "/index"}.json${url.search}`;
45
+ let qs = url.search;
46
+ if (invalidatedBits) {
47
+ const sep = qs ? "&" : "?";
48
+ qs = `${qs}${sep}_invalidated=${invalidatedBits}`;
49
+ }
50
+ return `/__bosia/data${p || "/index"}.json${qs}`;
10
51
  }
11
52
 
12
53
  export const prefetchCache = new Map<string, { data: any; ts: number }>();
@@ -33,7 +74,11 @@ export async function prefetchPath(path: string): Promise<void> {
33
74
 
34
75
  pending.add(path);
35
76
  try {
36
- const res = await fetch(dataUrl(path));
77
+ // Send the same mask as a real client nav would so the server can skip
78
+ // loaders whose tracked inputs haven't changed. Falls back to running
79
+ // everything when the route can't be matched (e.g. external/unknown URL).
80
+ const maskBits = buildMaskBits(path) ?? undefined;
81
+ const res = await fetch(dataUrl(path, maskBits));
37
82
  if (res.ok) {
38
83
  if (prefetchCache.size >= MAX_PREFETCH_ENTRIES) {
39
84
  const oldest = prefetchCache.keys().next().value;
package/src/core/cors.ts CHANGED
@@ -13,8 +13,24 @@ export interface CorsConfig {
13
13
  maxAge?: number;
14
14
  }
15
15
 
16
- const DEFAULT_METHODS = "GET, HEAD, PUT, PATCH, POST, DELETE";
17
- const DEFAULT_HEADERS = "Content-Type, Authorization";
16
+ const DEFAULT_METHODS = ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"];
17
+ const DEFAULT_HEADERS = ["Content-Type", "Authorization"];
18
+
19
+ function parseHeaderList(value: string): string[] {
20
+ return value
21
+ .split(",")
22
+ .map((s) => s.trim())
23
+ .filter(Boolean);
24
+ }
25
+
26
+ /**
27
+ * Headers applied to *every* response when CORS is configured, regardless of
28
+ * whether the request Origin is allowed. Keeps caches/CDNs from serving a
29
+ * response with `Access-Control-Allow-Origin: X` to a different origin Y.
30
+ */
31
+ export function applyCorsVary(headers: Headers): void {
32
+ headers.set("Vary", "Origin");
33
+ }
18
34
 
19
35
  /**
20
36
  * Returns CORS response headers if the request Origin is in the allowed list.
@@ -48,22 +64,52 @@ export function getCorsHeaders(
48
64
 
49
65
  /**
50
66
  * Handles OPTIONS preflight requests.
51
- * Returns a 204 response with CORS headers, or null if the origin is not allowed.
67
+ *
68
+ * - Returns `null` if the request's Origin is missing or not allowed — the
69
+ * caller treats this as "not a CORS preflight we serve", avoiding leaking
70
+ * policy details to unknown origins.
71
+ * - Returns a 403 (carrying `Access-Control-Allow-Origin` + `Vary: Origin`)
72
+ * when the requested method or any requested header falls outside the
73
+ * configured allow-list. A 403 surfaces a clearer "not allowed by CORS
74
+ * policy" message in the browser than letting the OPTIONS request fall
75
+ * through to the default handler.
76
+ * - Otherwise returns a 204 with the standard preflight headers.
52
77
  */
53
78
  export function handlePreflight(request: Request, config: CorsConfig): Response | null {
54
79
  const base = getCorsHeaders(request, config);
55
80
  if (!base) return null;
56
81
 
82
+ const allowedMethods = config.allowedMethods ?? DEFAULT_METHODS;
83
+ const allowedHeaders = config.allowedHeaders ?? DEFAULT_HEADERS;
84
+
85
+ const requestedMethod = request.headers.get("access-control-request-method");
86
+ if (requestedMethod) {
87
+ const upper = requestedMethod.toUpperCase();
88
+ const allowedUpper = allowedMethods.map((m) => m.toUpperCase());
89
+ if (!allowedUpper.includes(upper)) {
90
+ return rejectPreflight(base, `Method ${requestedMethod} not allowed by CORS policy`);
91
+ }
92
+ }
93
+
94
+ const requestedHeadersRaw = request.headers.get("access-control-request-headers");
95
+ if (requestedHeadersRaw) {
96
+ const requested = parseHeaderList(requestedHeadersRaw).map((h) => h.toLowerCase());
97
+ const allowedLower = allowedHeaders.map((h) => h.toLowerCase());
98
+ const disallowed = requested.find((h) => !allowedLower.includes(h));
99
+ if (disallowed) {
100
+ return rejectPreflight(base, `Header ${disallowed} not allowed by CORS policy`);
101
+ }
102
+ }
103
+
57
104
  const headers = new Headers(base as HeadersInit);
58
- headers.set(
59
- "Access-Control-Allow-Methods",
60
- config.allowedMethods?.join(", ") ?? DEFAULT_METHODS,
61
- );
62
- headers.set(
63
- "Access-Control-Allow-Headers",
64
- config.allowedHeaders?.join(", ") ?? DEFAULT_HEADERS,
65
- );
105
+ headers.set("Access-Control-Allow-Methods", allowedMethods.join(", "));
106
+ headers.set("Access-Control-Allow-Headers", allowedHeaders.join(", "));
66
107
  headers.set("Access-Control-Max-Age", String(config.maxAge ?? 86400));
67
108
 
68
109
  return new Response(null, { status: 204, headers });
69
110
  }
111
+
112
+ function rejectPreflight(base: Record<string, string>, reason: string): Response {
113
+ const headers = new Headers(base as HeadersInit);
114
+ return new Response(reason, { status: 403, headers });
115
+ }
@@ -0,0 +1,47 @@
1
+ // ─── CSP Nonce ───────────────────────────────────────────
2
+ // Per-request cryptographic nonce. Embedded as `nonce="..."` on every
3
+ // framework-emitted <script> tag so that user code (or operators) can
4
+ // configure a `Content-Security-Policy` header with `script-src 'nonce-…'`
5
+ // and lock down inline-script execution without breaking framework
6
+ // hydration.
7
+ //
8
+ // 16 random bytes → base64 (22 chars after stripping `=` padding) gives
9
+ // the 128 bits of entropy recommended by the CSP spec.
10
+
11
+ const NONCE_BYTES = 16;
12
+
13
+ export function generateNonce(): string {
14
+ const bytes = new Uint8Array(NONCE_BYTES);
15
+ crypto.getRandomValues(bytes);
16
+ let binary = "";
17
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
18
+ return btoa(binary).replace(/=+$/, "");
19
+ }
20
+
21
+ /** Returns ` nonce="…"` (with leading space) when `nonce` is non-empty, otherwise `""`. */
22
+ export function nonceAttr(nonce: string | undefined): string {
23
+ return nonce ? ` nonce="${nonce}"` : "";
24
+ }
25
+
26
+ // ─── Optional CSP Header ─────────────────────────────────
27
+ // Opt-in via `CSP_DIRECTIVES` env. The literal `{nonce}` placeholder in
28
+ // the configured value is replaced with the per-request nonce on each
29
+ // response. Empty/unset env → no `Content-Security-Policy` header.
30
+ //
31
+ // Example:
32
+ // CSP_DIRECTIVES="default-src 'self'; script-src 'self' 'nonce-{nonce}'"
33
+
34
+ export const CSP_DIRECTIVES_TEMPLATE: string | null = process.env.CSP_DIRECTIVES?.trim() || null;
35
+
36
+ /**
37
+ * `true` when an operator has opted into CSP via `CSP_DIRECTIVES`. The
38
+ * framework gates *all* nonce-related wire output on this flag — without a
39
+ * matching `Content-Security-Policy` header the nonce attribute is just dead
40
+ * bytes, so we omit it.
41
+ */
42
+ export const CSP_ENABLED: boolean = CSP_DIRECTIVES_TEMPLATE !== null;
43
+
44
+ export function buildCspHeader(nonce: string): string | null {
45
+ if (!CSP_DIRECTIVES_TEMPLATE) return null;
46
+ return CSP_DIRECTIVES_TEMPLATE.replace(/\{nonce\}/g, nonce);
47
+ }
package/src/core/csrf.ts CHANGED
@@ -31,12 +31,15 @@ export function checkCsrf(
31
31
  if (SAFE_METHODS.has(request.method.toUpperCase())) return null;
32
32
 
33
33
  // Derive the expected origin.
34
- // In dev, the browser hits the proxy on DEV_PORT (e.g. localhost:9000)
35
- // while the Elysia server sees url.origin as localhost:9001.
36
- // X-Forwarded-Host / Host headers reflect the actual host the client used.
37
- const forwardedHost = request.headers.get("x-forwarded-host");
34
+ // `X-Forwarded-*` headers are only trusted when `TRUST_PROXY=true`, since a
35
+ // directly-exposed server would otherwise let a client spoof its own origin
36
+ // via attacker-controlled forwarded headers. Behind a real reverse proxy
37
+ // (nginx, Caddy, Cloudflare) the operator opts in by setting the env.
38
+ const trustProxy = process.env.TRUST_PROXY === "true";
39
+ const forwardedHost = trustProxy ? request.headers.get("x-forwarded-host") : null;
38
40
  const host = forwardedHost ?? request.headers.get("host");
39
- const protocol = request.headers.get("x-forwarded-proto") ?? url.protocol.replace(":", "");
41
+ const forwardedProto = trustProxy ? request.headers.get("x-forwarded-proto") : null;
42
+ const protocol = forwardedProto ?? url.protocol.replace(":", "");
40
43
  const expectedOrigin = host ? `${protocol}://${host}` : url.origin;
41
44
 
42
45
  const allowedOrigins = new Set([expectedOrigin, ...(config.allowedOrigins ?? [])]);
package/src/core/dev.ts CHANGED
@@ -94,6 +94,10 @@ async function startAppServer() {
94
94
  PORT: String(APP_PORT),
95
95
  // Allow externalized deps (elysia, etc.) to resolve from bosia's node_modules
96
96
  NODE_PATH: BOSIA_NODE_PATH,
97
+ // Dev proxy injects X-Forwarded-Host/Proto reflecting the public DEV_PORT, so CSRF
98
+ // origin checks must honour them. Safe in dev because the proxy controls these
99
+ // headers — no untrusted client can spoof them.
100
+ TRUST_PROXY: "true",
97
101
  },
98
102
  });
99
103
 
@@ -204,16 +208,24 @@ const devServer = Bun.serve({
204
208
  );
205
209
  }
206
210
 
207
- // Proxy everything else to the app server
211
+ // Proxy everything else to the app server. Inject X-Forwarded-Host/Proto so
212
+ // the app's CSRF origin check (gated behind TRUST_PROXY=true, also set in the
213
+ // app env above) reconstructs the public-facing origin from the dev proxy
214
+ // rather than the inner-app's host (localhost:APP_PORT).
208
215
  try {
216
+ const reqUrl = new URL(req.url);
209
217
  const target = new URL(req.url);
210
218
  target.hostname = "localhost";
211
219
  target.port = String(APP_PORT);
212
220
 
221
+ const forwardedHeaders = new Headers(req.headers);
222
+ forwardedHeaders.set("x-forwarded-host", reqUrl.host);
223
+ forwardedHeaders.set("x-forwarded-proto", reqUrl.protocol.replace(":", ""));
224
+
213
225
  return await fetch(
214
226
  new Request(target.toString(), {
215
227
  method: req.method,
216
- headers: req.headers,
228
+ headers: forwardedHeaders,
217
229
  body: req.body,
218
230
  redirect: "manual",
219
231
  }),
@@ -29,11 +29,10 @@ export class Redirect {
29
29
  const DANGEROUS_SCHEMES = /^(javascript|data|vbscript):/i;
30
30
 
31
31
  function validateRedirectLocation(location: string, options?: RedirectOptions): void {
32
- if (options?.allowExternal) return;
33
-
34
32
  const trimmed = location.trim();
35
33
 
36
- // Reject dangerous schemes
34
+ // Dangerous schemes are rejected even when `allowExternal: true` —
35
+ // `javascript:` / `data:` / `vbscript:` are never legitimate redirect targets.
37
36
  if (DANGEROUS_SCHEMES.test(trimmed)) {
38
37
  throw new Error(
39
38
  `redirect(): dangerous scheme in URL "${location}". ` +
@@ -41,6 +40,8 @@ function validateRedirectLocation(location: string, options?: RedirectOptions):
41
40
  );
42
41
  }
43
42
 
43
+ if (options?.allowExternal) return;
44
+
44
45
  // Reject protocol-relative URLs (//evil.com)
45
46
  if (trimmed.startsWith("//")) {
46
47
  throw new Error(
package/src/core/hooks.ts CHANGED
@@ -34,7 +34,16 @@ export interface Cookies {
34
34
  export type RequestEvent = {
35
35
  request: Request;
36
36
  url: URL;
37
- locals: Record<string, any>;
37
+ /**
38
+ * Per-request scratch object for user hooks/load functions.
39
+ *
40
+ * `locals.nonce` is populated by the framework with a fresh per-request
41
+ * cryptographic nonce (base64, 128 bits of entropy) and is safe to embed
42
+ * as `nonce="${event.locals.nonce}"` on user-authored inline scripts when
43
+ * the operator enables a `Content-Security-Policy` via the
44
+ * `CSP_DIRECTIVES` env var.
45
+ */
46
+ locals: Record<string, any> & { nonce?: string };
38
47
  params: Record<string, string>;
39
48
  cookies: Cookies;
40
49
  };
@@ -47,6 +56,33 @@ export type LoadEvent = {
47
56
  fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
48
57
  parent: () => Promise<Record<string, any>>;
49
58
  metadata: Record<string, any> | null;
59
+ /**
60
+ * Declare custom dependency keys for this loader. The client cache
61
+ * will re-run the loader when `invalidate(key)` is called with any
62
+ * of these keys. Keys are arbitrary strings, but conventionally
63
+ * namespaced (e.g. `"app:user"`).
64
+ */
65
+ depends: (...keys: string[]) => void;
66
+ };
67
+
68
+ /**
69
+ * Tracked dependencies captured for a single loader during one run.
70
+ * Shipped to the client so subsequent client-side navigations can
71
+ * decide whether to re-run the loader.
72
+ */
73
+ export type LoaderDeps = {
74
+ /** `depends(...keys)` declarations. */
75
+ keys: string[];
76
+ /** Absolute URLs passed to the loader's `fetch()`. */
77
+ urls: string[];
78
+ /** Route params the loader read (`params.X`). */
79
+ params: string[];
80
+ /** Search params the loader read (`url.searchParams.get(X)` / `.has(X)`). */
81
+ searchParams: string[];
82
+ /** Cookies the loader read (`cookies.get(X)`). */
83
+ cookies: string[];
84
+ /** True if the loader read `url.pathname`/`url.origin`/`url.hash`/`url.href`. */
85
+ uses_url: boolean;
50
86
  };
51
87
 
52
88
  export type ResolveFunction = (event: RequestEvent) => MaybePromise<Response>;