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.
- package/README.md +132 -4
- package/package.json +4 -2
- package/src/auth/index.ts +48 -17
- package/src/auth/redis-session-store.ts +46 -0
- package/src/auth/sqlite-session-store.ts +98 -0
- package/src/auth/store-utils.ts +21 -0
- package/src/build/client.ts +25 -7
- package/src/build/manifest.ts +34 -0
- package/src/build/route-metadata.ts +12 -0
- package/src/build/ssg.ts +19 -49
- package/src/cli/bun-plugin.ts +23 -2
- package/src/cli/cmd-build.ts +42 -71
- package/src/cli/cmd-check.ts +40 -26
- package/src/cli/cmd-create.ts +20 -5
- package/src/cli/cmd-deploy.ts +10 -2
- package/src/cli/cmd-dev.ts +9 -9
- package/src/cli/cmd-docs.ts +152 -0
- package/src/cli/cmd-generate.ts +15 -7
- package/src/cli/cmd-migrate.ts +15 -7
- package/src/cli/cmd-routes.ts +12 -5
- package/src/cli/cmd-start.ts +14 -5
- package/src/cli/cmd-test.ts +129 -0
- package/src/cli/cmd-typegen.ts +13 -3
- package/src/cli/cmd-upgrade.ts +143 -0
- package/src/cli/context.ts +12 -0
- package/src/cli/framework-md.ts +43 -16
- package/src/cli/index.ts +18 -0
- package/src/client.ts +26 -0
- package/src/dev/partial-handler.ts +17 -74
- package/src/dev/request-handler.ts +36 -67
- package/src/dev.ts +92 -157
- package/src/index-client.ts +4 -0
- package/src/index.ts +17 -2
- package/src/prod.ts +195 -253
- package/src/runtime/project.ts +73 -0
- package/src/server/cache-utils.ts +23 -0
- package/src/server/cache.ts +37 -14
- package/src/server/html-shell.ts +69 -0
- package/src/server/index.ts +40 -2
- package/src/server/manifest.ts +36 -0
- package/src/server/middleware.ts +18 -2
- package/src/server/not-found.ts +35 -0
- package/src/server/page-render.ts +123 -0
- package/src/server/redis-cache-store.ts +87 -0
- package/src/server/redis-client.ts +71 -0
- package/src/server/request-preflight.ts +45 -0
- package/src/server/route-request.ts +72 -0
- package/src/server/rpc-utils.ts +27 -0
- package/src/server/rpc.ts +70 -18
- package/src/server/sqlite-cache-store.ts +109 -0
- package/src/server/static-file.ts +63 -0
- package/src/server-entry.ts +36 -0
package/src/server/cache.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
export {
|
|
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 {
|
|
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
|
+
}
|
package/src/server/middleware.ts
CHANGED
|
@@ -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:
|
|
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
|
+
}
|