gorsee 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.
Files changed (52) hide show
  1. package/README.md +132 -4
  2. package/package.json +4 -2
  3. package/src/auth/index.ts +48 -17
  4. package/src/auth/redis-session-store.ts +46 -0
  5. package/src/auth/sqlite-session-store.ts +98 -0
  6. package/src/auth/store-utils.ts +21 -0
  7. package/src/build/client.ts +25 -7
  8. package/src/build/manifest.ts +34 -0
  9. package/src/build/route-metadata.ts +12 -0
  10. package/src/build/ssg.ts +19 -49
  11. package/src/cli/bun-plugin.ts +23 -2
  12. package/src/cli/cmd-build.ts +42 -71
  13. package/src/cli/cmd-check.ts +40 -26
  14. package/src/cli/cmd-create.ts +20 -5
  15. package/src/cli/cmd-deploy.ts +10 -2
  16. package/src/cli/cmd-dev.ts +9 -9
  17. package/src/cli/cmd-docs.ts +152 -0
  18. package/src/cli/cmd-generate.ts +15 -7
  19. package/src/cli/cmd-migrate.ts +15 -7
  20. package/src/cli/cmd-routes.ts +12 -5
  21. package/src/cli/cmd-start.ts +14 -5
  22. package/src/cli/cmd-test.ts +129 -0
  23. package/src/cli/cmd-typegen.ts +13 -3
  24. package/src/cli/cmd-upgrade.ts +143 -0
  25. package/src/cli/context.ts +12 -0
  26. package/src/cli/framework-md.ts +43 -16
  27. package/src/cli/index.ts +18 -0
  28. package/src/client.ts +26 -0
  29. package/src/dev/partial-handler.ts +17 -74
  30. package/src/dev/request-handler.ts +36 -67
  31. package/src/dev.ts +92 -157
  32. package/src/index-client.ts +4 -0
  33. package/src/index.ts +17 -2
  34. package/src/prod.ts +195 -253
  35. package/src/runtime/project.ts +73 -0
  36. package/src/server/cache-utils.ts +23 -0
  37. package/src/server/cache.ts +37 -14
  38. package/src/server/html-shell.ts +69 -0
  39. package/src/server/index.ts +40 -2
  40. package/src/server/manifest.ts +36 -0
  41. package/src/server/middleware.ts +18 -2
  42. package/src/server/not-found.ts +35 -0
  43. package/src/server/page-render.ts +123 -0
  44. package/src/server/redis-cache-store.ts +87 -0
  45. package/src/server/redis-client.ts +71 -0
  46. package/src/server/request-preflight.ts +45 -0
  47. package/src/server/route-request.ts +72 -0
  48. package/src/server/rpc-utils.ts +27 -0
  49. package/src/server/rpc.ts +70 -18
  50. package/src/server/sqlite-cache-store.ts +109 -0
  51. package/src/server/static-file.ts +63 -0
  52. package/src/server-entry.ts +36 -0
@@ -8,9 +8,12 @@ export interface CacheOptions {
8
8
  staleWhileRevalidate?: number // serve stale while revalidating (seconds)
9
9
  vary?: string[] // cache key varies by these headers
10
10
  key?: (url: URL) => string // custom cache key generator
11
+ store?: CacheStore
11
12
  }
12
13
 
13
- interface CacheEntry {
14
+ type Awaitable<T> = T | Promise<T>
15
+
16
+ export interface CacheEntry {
14
17
  body: string
15
18
  headers: Record<string, string>
16
19
  status: number
@@ -18,7 +21,26 @@ interface CacheEntry {
18
21
  revalidating?: boolean
19
22
  }
20
23
 
21
- const store = new Map<string, CacheEntry>()
24
+ export interface CacheStore {
25
+ get(key: string): Awaitable<CacheEntry | undefined>
26
+ set(key: string, entry: CacheEntry): Awaitable<void>
27
+ delete(key: string): Awaitable<void>
28
+ clear(): Awaitable<void>
29
+ keys(): Awaitable<Iterable<string> | AsyncIterable<string>>
30
+ }
31
+
32
+ export function createMemoryCacheStore(): CacheStore {
33
+ const store = new Map<string, CacheEntry>()
34
+ return {
35
+ get: (key) => store.get(key),
36
+ set: (key, entry) => { store.set(key, entry) },
37
+ delete: (key) => { store.delete(key) },
38
+ clear: () => { store.clear() },
39
+ keys: () => store.keys(),
40
+ }
41
+ }
42
+
43
+ const defaultCacheStore = createMemoryCacheStore()
22
44
 
23
45
  function buildKey(url: URL, vary: string[], request: Request, customKey?: (url: URL) => string): string {
24
46
  const base = customKey ? customKey(url) : url.pathname + url.search
@@ -28,13 +50,13 @@ function buildKey(url: URL, vary: string[], request: Request, customKey?: (url:
28
50
  }
29
51
 
30
52
  export function routeCache(options: CacheOptions): MiddlewareFn {
31
- const { maxAge, staleWhileRevalidate = 0, vary = [], key: customKey } = options
53
+ const { maxAge, staleWhileRevalidate = 0, vary = [], key: customKey, store = defaultCacheStore } = options
32
54
 
33
55
  return async (ctx, next) => {
34
56
  if (ctx.request.method !== "GET") return next()
35
57
 
36
58
  const cacheKey = buildKey(ctx.url, vary, ctx.request, customKey)
37
- const entry = store.get(cacheKey)
59
+ const entry = await store.get(cacheKey)
38
60
  const now = Date.now()
39
61
 
40
62
  if (entry) {
@@ -49,7 +71,7 @@ export function routeCache(options: CacheOptions): MiddlewareFn {
49
71
  // Stale but within revalidation window — serve stale, revalidate in background
50
72
  if (age < maxAge + staleWhileRevalidate && !entry.revalidating) {
51
73
  entry.revalidating = true
52
- revalidate(cacheKey, ctx, next)
74
+ revalidate(cacheKey, store, next)
53
75
  return new Response(entry.body, {
54
76
  status: entry.status,
55
77
  headers: { ...entry.headers, "X-Cache": "STALE", "Age": String(Math.floor(age)) },
@@ -63,7 +85,7 @@ export function routeCache(options: CacheOptions): MiddlewareFn {
63
85
  const body = await response.text()
64
86
  const headers: Record<string, string> = {}
65
87
  response.headers.forEach((v, k) => { headers[k] = v })
66
- store.set(cacheKey, { body, headers, status: response.status, createdAt: now })
88
+ await store.set(cacheKey, { body, headers, status: response.status, createdAt: now })
67
89
  return new Response(body, {
68
90
  status: response.status,
69
91
  headers: { ...headers, "X-Cache": "MISS" },
@@ -73,30 +95,31 @@ export function routeCache(options: CacheOptions): MiddlewareFn {
73
95
  }
74
96
  }
75
97
 
76
- async function revalidate(key: string, ctx: import("./middleware.ts").Context, next: () => Promise<Response>): Promise<void> {
98
+ async function revalidate(key: string, store: CacheStore, next: () => Promise<Response>): Promise<void> {
77
99
  try {
78
100
  const response = await next()
79
101
  if (response.status === 200) {
80
102
  const body = await response.text()
81
103
  const headers: Record<string, string> = {}
82
104
  response.headers.forEach((v, k) => { headers[k] = v })
83
- store.set(key, { body, headers, status: response.status, createdAt: Date.now() })
105
+ await store.set(key, { body, headers, status: response.status, createdAt: Date.now() })
84
106
  }
85
107
  } catch {
86
108
  // revalidation failed, stale entry stays
87
- const entry = store.get(key)
109
+ const entry = await store.get(key)
88
110
  if (entry) entry.revalidating = false
89
111
  }
90
112
  }
91
113
 
92
114
  /** Invalidate cached entry by path */
93
- export function invalidateCache(path: string): void {
94
- for (const key of store.keys()) {
95
- if (key.startsWith(path)) store.delete(key)
115
+ export async function invalidateCache(path: string): Promise<void> {
116
+ const keys = await defaultCacheStore.keys()
117
+ for await (const key of keys) {
118
+ if (key.startsWith(path)) await defaultCacheStore.delete(key)
96
119
  }
97
120
  }
98
121
 
99
122
  /** Clear all cached entries */
100
- export function clearCache(): void {
101
- store.clear()
123
+ export async function clearCache(): Promise<void> {
124
+ await defaultCacheStore.clear()
102
125
  }
@@ -0,0 +1,69 @@
1
+ export interface HTMLWrapOptions {
2
+ title?: string
3
+ clientScript?: string
4
+ loaderData?: unknown
5
+ params?: Record<string, string>
6
+ cssFiles?: string[]
7
+ headElements?: string[]
8
+ bodyPrefix?: string[]
9
+ bodySuffix?: string[]
10
+ }
11
+
12
+ export function generateNonce(): string {
13
+ const bytes = new Uint8Array(16)
14
+ crypto.getRandomValues(bytes)
15
+ return btoa(String.fromCharCode(...bytes))
16
+ }
17
+
18
+ export function wrapHTML(
19
+ body: string,
20
+ nonce: string | undefined,
21
+ options: HTMLWrapOptions = {},
22
+ ): string {
23
+ const {
24
+ title = "Gorsee App",
25
+ clientScript,
26
+ loaderData,
27
+ params,
28
+ cssFiles = [],
29
+ headElements = [],
30
+ bodyPrefix = [],
31
+ bodySuffix = [],
32
+ } = options
33
+
34
+ let dataScript = ""
35
+ if (loaderData !== undefined) {
36
+ const json = JSON.stringify(loaderData).replace(/</g, "\\u003c")
37
+ dataScript = `\n <script id="__GORSEE_DATA__" type="application/json"${renderNonceAttr(nonce)}>${json}</script>`
38
+ }
39
+
40
+ let paramsScript = ""
41
+ if (params && Object.keys(params).length > 0) {
42
+ paramsScript = `\n <script${renderNonceAttr(nonce)}>window.__GORSEE_PARAMS__=${JSON.stringify(params)}</script>`
43
+ }
44
+
45
+ const clientTag = clientScript
46
+ ? `\n <script type="module" src="${clientScript}"${renderNonceAttr(nonce)}></script>`
47
+ : ""
48
+
49
+ return `<!DOCTYPE html>
50
+ <html lang="en">
51
+ <head>
52
+ <meta charset="UTF-8" />
53
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
54
+ <title>${title}</title>
55
+ <link rel="stylesheet" href="/styles.css" />
56
+ ${cssFiles.map((file) => ` <link rel="stylesheet" href="${file}" />`).join("\n")}
57
+ ${headElements.join("\n")}
58
+ </head>
59
+ <body>
60
+ ${bodyPrefix.join("\n")}
61
+ <div id="app">${body}</div>${dataScript}${paramsScript}${clientTag}
62
+ ${bodySuffix.join("\n")}
63
+ </body>
64
+ </html>`
65
+ }
66
+
67
+ function renderNonceAttr(nonce?: string): string {
68
+ return nonce ? ` nonce="${nonce}"` : ""
69
+ }
@@ -1,4 +1,13 @@
1
- export { server, handleRPCRequest, __registerRPC, getRPCHandler } from "./rpc.ts"
1
+ export {
2
+ server,
3
+ handleRPCRequest,
4
+ __registerRPC,
5
+ getRPCHandler,
6
+ createMemoryRPCRegistry,
7
+ handleRPCRequestWithRegistry,
8
+ type RPCHandler,
9
+ type RPCRegistry,
10
+ } from "./rpc.ts"
2
11
  export {
3
12
  middleware,
4
13
  createContext,
@@ -14,6 +23,35 @@ export { getMimeType } from "./mime.ts"
14
23
  export { fileETag, generateETag, isNotModified } from "./etag.ts"
15
24
  export { redirect, RedirectError, type CookieOptions } from "./middleware.ts"
16
25
  export { createSSEStream, createEventSource, type SSEOptions, type SSEStream, type EventSourceSignal } from "./sse.ts"
17
- export { routeCache, invalidateCache, clearCache, type CacheOptions } from "./cache.ts"
26
+ export {
27
+ routeCache,
28
+ invalidateCache,
29
+ clearCache,
30
+ createMemoryCacheStore,
31
+ type CacheOptions,
32
+ type CacheEntry,
33
+ type CacheStore,
34
+ } from "./cache.ts"
18
35
  export { createGuard, requireAuth, requireRole, allGuards, anyGuard } from "./guard.ts"
19
36
  export { pipe, when, forMethods, forPaths } from "./pipe.ts"
37
+ export { createNamespacedCacheStore } from "./cache-utils.ts"
38
+ export { createRedisCacheStore } from "./redis-cache-store.ts"
39
+ export { createScopedRPCRegistry } from "./rpc-utils.ts"
40
+ export { createSQLiteCacheStore } from "./sqlite-cache-store.ts"
41
+ export {
42
+ type RedisLikeClient,
43
+ type NodeRedisClientLike,
44
+ type IORedisClientLike,
45
+ createNodeRedisLikeClient,
46
+ createIORedisLikeClient,
47
+ deleteExpiredRedisKeys,
48
+ } from "./redis-client.ts"
49
+ export {
50
+ loadBuildManifest,
51
+ getRouteBuildEntry,
52
+ getClientBundleForRoute,
53
+ isPrerenderedRoute,
54
+ getPrerenderedHtmlPath,
55
+ type BuildManifest,
56
+ type BuildManifestRoute,
57
+ } from "./manifest.ts"
@@ -0,0 +1,36 @@
1
+ import { join } from "node:path"
2
+ import { readFile } from "node:fs/promises"
3
+
4
+ export interface BuildManifestRoute {
5
+ js?: string
6
+ hasLoader: boolean
7
+ prerendered?: boolean
8
+ }
9
+
10
+ export interface BuildManifest {
11
+ routes: Record<string, BuildManifestRoute>
12
+ chunks: string[]
13
+ prerendered: string[]
14
+ buildTime: string
15
+ }
16
+
17
+ export async function loadBuildManifest(distDir: string): Promise<BuildManifest> {
18
+ const raw = await readFile(join(distDir, "manifest.json"), "utf-8")
19
+ return JSON.parse(raw) as BuildManifest
20
+ }
21
+
22
+ export function getRouteBuildEntry(manifest: BuildManifest, pathname: string): BuildManifestRoute | undefined {
23
+ return manifest.routes[pathname]
24
+ }
25
+
26
+ export function getClientBundleForRoute(manifest: BuildManifest, pathname: string): string | undefined {
27
+ return getRouteBuildEntry(manifest, pathname)?.js
28
+ }
29
+
30
+ export function isPrerenderedRoute(manifest: BuildManifest, pathname: string): boolean {
31
+ return getRouteBuildEntry(manifest, pathname)?.prerendered === true
32
+ }
33
+
34
+ export function getPrerenderedHtmlPath(pathname: string): string {
35
+ return pathname === "/" ? "index.html" : join(pathname.slice(1), "index.html")
36
+ }
@@ -40,8 +40,12 @@ export function middleware(fn: MiddlewareFn): MiddlewareFn {
40
40
  return fn
41
41
  }
42
42
 
43
+ function sanitizeCookiePart(str: string): string {
44
+ return str.replace(/[\r\n;,]/g, "")
45
+ }
46
+
43
47
  function serializeCookie(name: string, value: string, options: CookieOptions = {}): string {
44
- let cookie = `${name}=${value}`
48
+ let cookie = `${sanitizeCookiePart(name)}=${sanitizeCookiePart(value)}`
45
49
  if (options.maxAge !== undefined) cookie += `; Max-Age=${options.maxAge}`
46
50
  if (options.expires) cookie += `; Expires=${options.expires.toUTCString()}`
47
51
  cookie += `; Path=${options.path ?? "/"}`
@@ -77,9 +81,21 @@ export function createContext(request: Request, params: Record<string, string> =
77
81
  responseHeaders,
78
82
 
79
83
  redirect(target: string, status = 302) {
84
+ // Prevent open redirect: only allow relative paths or same-origin URLs
85
+ let safeTarget = target
86
+ if (target.startsWith("//") || /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(target)) {
87
+ try {
88
+ const targetUrl = new URL(target)
89
+ if (targetUrl.origin !== url.origin) {
90
+ safeTarget = "/"
91
+ }
92
+ } catch {
93
+ safeTarget = "/"
94
+ }
95
+ }
80
96
  const res = new Response(null, {
81
97
  status,
82
- headers: { Location: target },
98
+ headers: { Location: safeTarget },
83
99
  })
84
100
  // Apply pending cookies to redirect response
85
101
  for (const cookie of pendingCookies) {
@@ -0,0 +1,35 @@
1
+ import { join } from "node:path"
2
+ import { wrapHTML, type HTMLWrapOptions } from "./html-shell.ts"
3
+
4
+ interface NotFoundOptions extends Pick<HTMLWrapOptions, "bodyPrefix" | "bodySuffix" | "headElements"> {
5
+ title?: string
6
+ }
7
+
8
+ export async function renderNotFoundPage(
9
+ routesDir: string,
10
+ nonce: string,
11
+ options: NotFoundOptions = {},
12
+ ): Promise<string> {
13
+ const { title = "404 - Not Found", bodyPrefix, bodySuffix, headElements } = options
14
+
15
+ try {
16
+ const notFoundPath = join(routesDir, "404.tsx")
17
+ const file = Bun.file(notFoundPath)
18
+ if (await file.exists()) {
19
+ const mod = await import(notFoundPath)
20
+ if (typeof mod.default === "function") {
21
+ const { ssrJsx, renderToString } = await import("../runtime/server.ts")
22
+ const vnode = ssrJsx(mod.default as any, {})
23
+ const body = renderToString(vnode)
24
+ return wrapHTML(body, nonce, { title, bodyPrefix, bodySuffix, headElements })
25
+ }
26
+ }
27
+ } catch {}
28
+
29
+ return wrapHTML("<h1>404</h1><p>Page not found</p>", nonce, {
30
+ title,
31
+ bodyPrefix,
32
+ bodySuffix,
33
+ headElements,
34
+ })
35
+ }
@@ -0,0 +1,123 @@
1
+ import { resetServerHead, getServerHead } from "../runtime/head.ts"
2
+ import { renderToString, ssrJsx } from "../runtime/server.ts"
3
+ import type { Context } from "./middleware.ts"
4
+ import type { MatchResult } from "../router/matcher.ts"
5
+
6
+ type PageModule = Record<string, unknown>
7
+
8
+ export interface ResolvedPageRoute {
9
+ component: Function
10
+ pageComponent: Function
11
+ loaderData: unknown
12
+ cssFiles: string[]
13
+ renderMode: string
14
+ }
15
+
16
+ export interface RenderedPage {
17
+ html: string
18
+ headElements: string[]
19
+ title?: string
20
+ }
21
+
22
+ export interface PartialResponsePayload {
23
+ html: string
24
+ data?: unknown
25
+ params?: Record<string, string>
26
+ title?: string
27
+ css?: string[]
28
+ script?: string
29
+ }
30
+
31
+ export async function resolvePageRoute(
32
+ mod: PageModule,
33
+ match: MatchResult,
34
+ ctx: Context,
35
+ ): Promise<ResolvedPageRoute | null> {
36
+ const component = mod.default as Function | undefined
37
+ if (typeof component !== "function") return null
38
+
39
+ const layoutPaths = match.route.layoutPaths ?? []
40
+ const layoutImportPromises = layoutPaths.map((layoutPath) => import(layoutPath))
41
+ const pageLoaderPromise = typeof mod.loader === "function" ? mod.loader(ctx) : undefined
42
+
43
+ const [layoutMods, loaderData] = await Promise.all([
44
+ Promise.all(layoutImportPromises),
45
+ pageLoaderPromise,
46
+ ])
47
+
48
+ const layoutLoaderPromises = layoutMods.map((layoutMod) =>
49
+ typeof layoutMod.loader === "function" ? layoutMod.loader(ctx) : undefined,
50
+ )
51
+ const layoutLoaderResults = await Promise.all(layoutLoaderPromises)
52
+
53
+ const cssFiles: string[] = []
54
+ if (typeof mod.css === "string") cssFiles.push(mod.css)
55
+ if (Array.isArray(mod.css)) cssFiles.push(...(mod.css as string[]))
56
+
57
+ let pageComponent: Function = component
58
+ for (let i = layoutMods.length - 1; i >= 0; i--) {
59
+ const Layout = layoutMods[i]!.default
60
+ if (typeof Layout === "function") {
61
+ const inner = pageComponent
62
+ const layoutData = layoutLoaderResults[i]
63
+ pageComponent = (props: Record<string, unknown>) =>
64
+ Layout({ ...props, data: layoutData, children: inner(props) })
65
+ }
66
+ }
67
+
68
+ return {
69
+ component,
70
+ pageComponent,
71
+ loaderData,
72
+ cssFiles,
73
+ renderMode: (mod.render as string) ?? "async",
74
+ }
75
+ }
76
+
77
+ export function createClientScriptPath(entryFile?: string): string | undefined {
78
+ return entryFile ? `/_gorsee/${entryFile}` : undefined
79
+ }
80
+
81
+ export function renderPageDocument(
82
+ pageComponent: Function,
83
+ ctx: Context,
84
+ params: Record<string, string>,
85
+ loaderData: unknown,
86
+ ): RenderedPage {
87
+ resetServerHead()
88
+ const pageProps = { params, ctx, data: loaderData }
89
+ const vnode = ssrJsx(pageComponent as any, pageProps)
90
+ const html = renderToString(vnode)
91
+ const headElements = getServerHead()
92
+
93
+ return {
94
+ html,
95
+ headElements,
96
+ title: extractTitle(headElements),
97
+ }
98
+ }
99
+
100
+ export function buildPartialResponsePayload(
101
+ rendered: RenderedPage,
102
+ loaderData: unknown,
103
+ params: Record<string, string>,
104
+ cssFiles: string[],
105
+ clientScript?: string,
106
+ ): PartialResponsePayload {
107
+ return {
108
+ html: rendered.html,
109
+ data: loaderData,
110
+ params: Object.keys(params).length > 0 ? params : undefined,
111
+ title: rendered.title,
112
+ css: cssFiles.length > 0 ? cssFiles : undefined,
113
+ script: clientScript,
114
+ }
115
+ }
116
+
117
+ export function extractTitle(headElements: string[]): string | undefined {
118
+ for (const element of headElements) {
119
+ const titleMatch = element.match(/<title>(.+?)<\/title>/)
120
+ if (titleMatch) return titleMatch[1]
121
+ }
122
+ return undefined
123
+ }
@@ -0,0 +1,87 @@
1
+ import type { CacheEntry, CacheStore } from "./cache.ts"
2
+ import {
3
+ buildRedisKey,
4
+ deleteExpiredRedisKeys,
5
+ stripRedisPrefix,
6
+ type RedisLikeClient,
7
+ } from "./redis-client.ts"
8
+
9
+ interface RedisCacheStoreOptions {
10
+ prefix?: string
11
+ maxEntryAgeMs?: number
12
+ }
13
+
14
+ interface StoredCacheEntry {
15
+ entry: CacheEntry
16
+ }
17
+
18
+ function isExpired(entry: CacheEntry, maxEntryAgeMs: number): boolean {
19
+ return Date.now() - entry.createdAt > maxEntryAgeMs
20
+ }
21
+
22
+ export function createRedisCacheStore(
23
+ client: RedisLikeClient,
24
+ options: RedisCacheStoreOptions = {},
25
+ ): CacheStore & { deleteExpired(): Promise<number> } {
26
+ const prefix = options.prefix ?? "gorsee:cache"
27
+ const maxEntryAgeMs = options.maxEntryAgeMs ?? 24 * 60 * 60 * 1000
28
+
29
+ return {
30
+ async get(key) {
31
+ const raw = await client.get(buildRedisKey(prefix, key))
32
+ if (!raw) return undefined
33
+ const payload = JSON.parse(raw) as StoredCacheEntry
34
+ if (isExpired(payload.entry, maxEntryAgeMs)) {
35
+ await client.del(buildRedisKey(prefix, key))
36
+ return undefined
37
+ }
38
+ return payload.entry
39
+ },
40
+ async set(key, entry) {
41
+ const redisKey = buildRedisKey(prefix, key)
42
+ await client.set(redisKey, JSON.stringify({ entry } satisfies StoredCacheEntry))
43
+ if (client.expire) {
44
+ await client.expire(redisKey, Math.max(1, Math.ceil(maxEntryAgeMs / 1000)))
45
+ }
46
+ },
47
+ async delete(key) {
48
+ await client.del(buildRedisKey(prefix, key))
49
+ },
50
+ async clear() {
51
+ const keys = await client.keys(`${prefix}:*`)
52
+ if (keys.length === 0) return
53
+ for (const key of keys) await client.del(key)
54
+ },
55
+ async keys() {
56
+ const keys = await client.keys(`${prefix}:*`)
57
+ const visibleKeys: string[] = []
58
+ for (const key of keys) {
59
+ const raw = await client.get(key)
60
+ if (!raw) continue
61
+ const payload = JSON.parse(raw) as StoredCacheEntry
62
+ if (isExpired(payload.entry, maxEntryAgeMs)) {
63
+ await client.del(key)
64
+ continue
65
+ }
66
+ visibleKeys.push(stripRedisPrefix(prefix, key))
67
+ }
68
+ return visibleKeys
69
+ },
70
+ async deleteExpired() {
71
+ return deleteExpiredRedisKeys(
72
+ client,
73
+ `${prefix}:*`,
74
+ Date.now(),
75
+ (raw) => {
76
+ try {
77
+ const payload = JSON.parse(raw) as StoredCacheEntry
78
+ return payload.entry.createdAt
79
+ } catch {
80
+ return undefined
81
+ }
82
+ },
83
+ maxEntryAgeMs,
84
+ )
85
+ },
86
+ }
87
+ }
@@ -0,0 +1,71 @@
1
+ type Awaitable<T> = T | Promise<T>
2
+
3
+ export interface RedisLikeClient {
4
+ get(key: string): Awaitable<string | null>
5
+ set(key: string, value: string): Awaitable<unknown>
6
+ del(key: string): Awaitable<number>
7
+ keys(pattern: string): Awaitable<string[]>
8
+ expire?(key: string, seconds: number): Awaitable<number>
9
+ ttl?(key: string): Awaitable<number>
10
+ }
11
+
12
+ export function buildRedisKey(prefix: string, key: string): string {
13
+ return `${prefix}:${key}`
14
+ }
15
+
16
+ export function stripRedisPrefix(prefix: string, key: string): string {
17
+ const expected = `${prefix}:`
18
+ return key.startsWith(expected) ? key.slice(expected.length) : key
19
+ }
20
+
21
+ export interface NodeRedisClientLike {
22
+ get(key: string): Awaitable<string | null>
23
+ set(key: string, value: string): Awaitable<unknown>
24
+ del(key: string): Awaitable<number>
25
+ keys(pattern: string): Awaitable<string[]>
26
+ expire?(key: string, seconds: number): Awaitable<number>
27
+ ttl?(key: string): Awaitable<number>
28
+ }
29
+
30
+ export interface IORedisClientLike {
31
+ get(key: string): Awaitable<string | null>
32
+ set(key: string, value: string): Awaitable<unknown>
33
+ del(key: string): Awaitable<number>
34
+ keys(pattern: string): Awaitable<string[]>
35
+ expire?(key: string, seconds: number): Awaitable<number>
36
+ ttl?(key: string): Awaitable<number>
37
+ }
38
+
39
+ export function createNodeRedisLikeClient(client: NodeRedisClientLike): RedisLikeClient {
40
+ return {
41
+ get: (key) => client.get(key),
42
+ set: (key, value) => client.set(key, value),
43
+ del: (key) => client.del(key),
44
+ keys: (pattern) => client.keys(pattern),
45
+ expire: client.expire ? (key, seconds) => client.expire!(key, seconds) : undefined,
46
+ ttl: client.ttl ? (key) => client.ttl!(key) : undefined,
47
+ }
48
+ }
49
+
50
+ export function createIORedisLikeClient(client: IORedisClientLike): RedisLikeClient {
51
+ return createNodeRedisLikeClient(client)
52
+ }
53
+
54
+ export async function deleteExpiredRedisKeys(
55
+ client: RedisLikeClient,
56
+ pattern: string,
57
+ now: number,
58
+ parseCreatedAt: (value: string) => number | undefined,
59
+ maxEntryAgeMs: number,
60
+ ): Promise<number> {
61
+ let deleted = 0
62
+ for (const key of await client.keys(pattern)) {
63
+ const raw = await client.get(key)
64
+ if (raw === null) continue
65
+ const createdAt = parseCreatedAt(raw)
66
+ if (createdAt === undefined) continue
67
+ if (createdAt > now - maxEntryAgeMs) continue
68
+ deleted += await client.del(key)
69
+ }
70
+ return deleted
71
+ }
@@ -0,0 +1,45 @@
1
+ import { handleRPCRequest, handleRPCRequestWithRegistry, type RPCRegistry } from "./rpc.ts"
2
+
3
+ interface RateLimitResult {
4
+ allowed: boolean
5
+ resetAt: number
6
+ }
7
+
8
+ interface RateLimiterLike {
9
+ check(key: string): RateLimitResult
10
+ }
11
+
12
+ export function createRateLimitResponse(
13
+ rateLimiter: RateLimiterLike,
14
+ key: string,
15
+ ): Response | null {
16
+ const result = rateLimiter.check(key)
17
+ if (result.allowed) return null
18
+
19
+ return new Response("Too Many Requests", {
20
+ status: 429,
21
+ headers: {
22
+ "Retry-After": String(Math.ceil((result.resetAt - Date.now()) / 1000)),
23
+ },
24
+ })
25
+ }
26
+
27
+ export function applyResponseHeaders(
28
+ response: Response,
29
+ headers: Record<string, string>,
30
+ ): Response {
31
+ for (const key in headers) response.headers.set(key, headers[key]!)
32
+ return response
33
+ }
34
+
35
+ export async function handleRPCWithHeaders(
36
+ request: Request,
37
+ headers: Record<string, string>,
38
+ registry?: Pick<RPCRegistry, "getHandler">,
39
+ ): Promise<Response | null> {
40
+ const response = registry
41
+ ? await handleRPCRequestWithRegistry(request, registry)
42
+ : await handleRPCRequest(request)
43
+ if (!response) return null
44
+ return applyResponseHeaders(response, headers)
45
+ }