gorsee 0.1.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.
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/package.json +69 -0
- package/src/auth/index.ts +147 -0
- package/src/build/client.ts +121 -0
- package/src/build/css-modules.ts +69 -0
- package/src/build/devalue-parse.ts +2 -0
- package/src/build/rpc-transform.ts +62 -0
- package/src/build/server-strip.ts +87 -0
- package/src/build/ssg.ts +100 -0
- package/src/cli/bun-plugin.ts +37 -0
- package/src/cli/cmd-build.ts +182 -0
- package/src/cli/cmd-check.ts +225 -0
- package/src/cli/cmd-create.ts +313 -0
- package/src/cli/cmd-dev.ts +13 -0
- package/src/cli/cmd-generate.ts +147 -0
- package/src/cli/cmd-migrate.ts +45 -0
- package/src/cli/cmd-routes.ts +29 -0
- package/src/cli/cmd-start.ts +21 -0
- package/src/cli/cmd-typegen.ts +83 -0
- package/src/cli/framework-md.ts +196 -0
- package/src/cli/index.ts +84 -0
- package/src/db/index.ts +2 -0
- package/src/db/migrate.ts +89 -0
- package/src/db/sqlite.ts +40 -0
- package/src/deploy/dockerfile.ts +38 -0
- package/src/dev/error-overlay.ts +54 -0
- package/src/dev/hmr.ts +31 -0
- package/src/dev/partial-handler.ts +109 -0
- package/src/dev/request-handler.ts +158 -0
- package/src/dev/watcher.ts +48 -0
- package/src/dev.ts +273 -0
- package/src/env/index.ts +74 -0
- package/src/errors/catalog.ts +48 -0
- package/src/errors/formatter.ts +63 -0
- package/src/errors/index.ts +2 -0
- package/src/i18n/index.ts +72 -0
- package/src/index.ts +27 -0
- package/src/jsx-runtime-client.ts +13 -0
- package/src/jsx-runtime.ts +20 -0
- package/src/jsx-types-html.ts +242 -0
- package/src/log/index.ts +44 -0
- package/src/prod.ts +310 -0
- package/src/reactive/computed.ts +7 -0
- package/src/reactive/effect.ts +7 -0
- package/src/reactive/index.ts +7 -0
- package/src/reactive/live.ts +97 -0
- package/src/reactive/optimistic.ts +83 -0
- package/src/reactive/resource.ts +138 -0
- package/src/reactive/signal.ts +20 -0
- package/src/reactive/store.ts +36 -0
- package/src/router/index.ts +2 -0
- package/src/router/matcher.ts +53 -0
- package/src/router/scanner.ts +206 -0
- package/src/runtime/client.ts +28 -0
- package/src/runtime/error-boundary.ts +35 -0
- package/src/runtime/event-replay.ts +50 -0
- package/src/runtime/form.ts +49 -0
- package/src/runtime/head.ts +113 -0
- package/src/runtime/html-escape.ts +30 -0
- package/src/runtime/hydration.ts +95 -0
- package/src/runtime/image.ts +48 -0
- package/src/runtime/index.ts +12 -0
- package/src/runtime/island-hydrator.ts +84 -0
- package/src/runtime/island.ts +88 -0
- package/src/runtime/jsx-runtime.ts +167 -0
- package/src/runtime/link.ts +45 -0
- package/src/runtime/router.ts +224 -0
- package/src/runtime/server.ts +102 -0
- package/src/runtime/stream.ts +182 -0
- package/src/runtime/suspense.ts +37 -0
- package/src/runtime/typed-routes.ts +26 -0
- package/src/runtime/validated-form.ts +106 -0
- package/src/security/cors.ts +80 -0
- package/src/security/csrf.ts +85 -0
- package/src/security/headers.ts +50 -0
- package/src/security/index.ts +4 -0
- package/src/security/rate-limit.ts +80 -0
- package/src/server/action.ts +48 -0
- package/src/server/cache.ts +102 -0
- package/src/server/compress.ts +60 -0
- package/src/server/etag.ts +23 -0
- package/src/server/guard.ts +69 -0
- package/src/server/index.ts +19 -0
- package/src/server/middleware.ts +143 -0
- package/src/server/mime.ts +48 -0
- package/src/server/pipe.ts +46 -0
- package/src/server/rpc-hash.ts +17 -0
- package/src/server/rpc.ts +125 -0
- package/src/server/sse.ts +96 -0
- package/src/server/ws.ts +56 -0
- package/src/testing/index.ts +74 -0
- package/src/types/index.ts +4 -0
- package/src/types/safe-html.ts +32 -0
- package/src/types/safe-sql.ts +28 -0
- package/src/types/safe-url.ts +40 -0
- package/src/types/user-input.ts +12 -0
- package/src/unsafe/index.ts +18 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// CORS middleware — configurable Cross-Origin Resource Sharing
|
|
2
|
+
|
|
3
|
+
import type { MiddlewareFn } from "../server/middleware.ts"
|
|
4
|
+
|
|
5
|
+
export interface CORSOptions {
|
|
6
|
+
origin?: string | string[] | ((origin: string) => boolean)
|
|
7
|
+
methods?: string[]
|
|
8
|
+
allowHeaders?: string[]
|
|
9
|
+
exposeHeaders?: string[]
|
|
10
|
+
credentials?: boolean
|
|
11
|
+
maxAge?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_METHODS = ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"]
|
|
15
|
+
const DEFAULT_HEADERS = ["Content-Type", "Authorization", "X-Requested-With"]
|
|
16
|
+
|
|
17
|
+
function isOriginAllowed(origin: string, allowed: CORSOptions["origin"]): boolean {
|
|
18
|
+
if (!allowed) return false
|
|
19
|
+
if (allowed === "*") return true
|
|
20
|
+
if (typeof allowed === "string") return origin === allowed
|
|
21
|
+
if (Array.isArray(allowed)) return allowed.includes(origin)
|
|
22
|
+
if (typeof allowed === "function") return allowed(origin)
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function cors(options: CORSOptions = {}): MiddlewareFn {
|
|
27
|
+
const {
|
|
28
|
+
origin = "*",
|
|
29
|
+
methods = DEFAULT_METHODS,
|
|
30
|
+
allowHeaders = DEFAULT_HEADERS,
|
|
31
|
+
exposeHeaders = [],
|
|
32
|
+
credentials = false,
|
|
33
|
+
maxAge = 86400,
|
|
34
|
+
} = options
|
|
35
|
+
|
|
36
|
+
return async (ctx, next) => {
|
|
37
|
+
const requestOrigin = ctx.request.headers.get("origin") ?? ""
|
|
38
|
+
|
|
39
|
+
// Preflight
|
|
40
|
+
if (ctx.request.method === "OPTIONS") {
|
|
41
|
+
const headers = new Headers()
|
|
42
|
+
|
|
43
|
+
if (origin === "*" && !credentials) {
|
|
44
|
+
headers.set("Access-Control-Allow-Origin", "*")
|
|
45
|
+
} else if (isOriginAllowed(requestOrigin, origin)) {
|
|
46
|
+
headers.set("Access-Control-Allow-Origin", requestOrigin)
|
|
47
|
+
headers.set("Vary", "Origin")
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
headers.set("Access-Control-Allow-Methods", methods.join(", "))
|
|
51
|
+
headers.set("Access-Control-Allow-Headers", allowHeaders.join(", "))
|
|
52
|
+
if (exposeHeaders.length > 0) {
|
|
53
|
+
headers.set("Access-Control-Expose-Headers", exposeHeaders.join(", "))
|
|
54
|
+
}
|
|
55
|
+
if (credentials) headers.set("Access-Control-Allow-Credentials", "true")
|
|
56
|
+
headers.set("Access-Control-Max-Age", String(maxAge))
|
|
57
|
+
|
|
58
|
+
return new Response(null, { status: 204, headers })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Actual request
|
|
62
|
+
const response = await next()
|
|
63
|
+
|
|
64
|
+
if (origin === "*" && !credentials) {
|
|
65
|
+
response.headers.set("Access-Control-Allow-Origin", "*")
|
|
66
|
+
} else if (isOriginAllowed(requestOrigin, origin)) {
|
|
67
|
+
response.headers.set("Access-Control-Allow-Origin", requestOrigin)
|
|
68
|
+
response.headers.append("Vary", "Origin")
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (credentials) {
|
|
72
|
+
response.headers.set("Access-Control-Allow-Credentials", "true")
|
|
73
|
+
}
|
|
74
|
+
if (exposeHeaders.length > 0) {
|
|
75
|
+
response.headers.set("Access-Control-Expose-Headers", exposeHeaders.join(", "))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return response
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// CSRF protection using Signed Double-Submit Cookie pattern
|
|
2
|
+
// Works for both SSR (token in HTML) and SPA (cookie-based)
|
|
3
|
+
|
|
4
|
+
import { timingSafeEqual } from "node:crypto"
|
|
5
|
+
|
|
6
|
+
const CSRF_COOKIE = "__gorsee_csrf"
|
|
7
|
+
const CSRF_HEADER = "x-gorsee-csrf"
|
|
8
|
+
const TOKEN_LENGTH = 32
|
|
9
|
+
|
|
10
|
+
function randomBytes(length: number): string {
|
|
11
|
+
const bytes = new Uint8Array(length)
|
|
12
|
+
crypto.getRandomValues(bytes)
|
|
13
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function hmacSign(token: string, secret: string): Promise<string> {
|
|
17
|
+
const key = await crypto.subtle.importKey(
|
|
18
|
+
"raw",
|
|
19
|
+
new TextEncoder().encode(secret),
|
|
20
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
21
|
+
false,
|
|
22
|
+
["sign"]
|
|
23
|
+
)
|
|
24
|
+
const sig = await crypto.subtle.sign(
|
|
25
|
+
"HMAC",
|
|
26
|
+
key,
|
|
27
|
+
new TextEncoder().encode(token)
|
|
28
|
+
)
|
|
29
|
+
return Array.from(new Uint8Array(sig), (b) => b.toString(16).padStart(2, "0")).join("")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function generateCSRFToken(): string {
|
|
33
|
+
return randomBytes(TOKEN_LENGTH)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function validateCSRFToken(
|
|
37
|
+
request: Request,
|
|
38
|
+
secret: string
|
|
39
|
+
): Promise<boolean> {
|
|
40
|
+
// Skip safe methods
|
|
41
|
+
if (["GET", "HEAD", "OPTIONS"].includes(request.method)) return true
|
|
42
|
+
|
|
43
|
+
const cookieHeader = request.headers.get("cookie") ?? ""
|
|
44
|
+
const headerToken = request.headers.get(CSRF_HEADER) ?? ""
|
|
45
|
+
|
|
46
|
+
// Parse cookie
|
|
47
|
+
let cookieToken = ""
|
|
48
|
+
for (const pair of cookieHeader.split(";")) {
|
|
49
|
+
const [key, ...rest] = pair.trim().split("=")
|
|
50
|
+
if (key === CSRF_COOKIE) {
|
|
51
|
+
cookieToken = rest.join("=")
|
|
52
|
+
break
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!cookieToken || !headerToken) return false
|
|
57
|
+
|
|
58
|
+
// Verify: cookie contains "token.signature", header contains "token"
|
|
59
|
+
const [token, signature] = cookieToken.split(".")
|
|
60
|
+
if (!token || !signature) return false
|
|
61
|
+
|
|
62
|
+
// Header must match token part of cookie
|
|
63
|
+
if (headerToken !== token) return false
|
|
64
|
+
|
|
65
|
+
// Verify HMAC signature (timing-safe comparison)
|
|
66
|
+
const expectedSig = await hmacSign(token, secret)
|
|
67
|
+
if (signature.length !== expectedSig.length) return false
|
|
68
|
+
return timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSig))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function csrfProtection(secret: string): Promise<{
|
|
72
|
+
token: string
|
|
73
|
+
cookie: string
|
|
74
|
+
headerName: string
|
|
75
|
+
}> {
|
|
76
|
+
const token = generateCSRFToken()
|
|
77
|
+
const signature = await hmacSign(token, secret)
|
|
78
|
+
const cookieValue = `${token}.${signature}`
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
token,
|
|
82
|
+
cookie: `${CSRF_COOKIE}=${cookieValue}; Path=/; SameSite=Lax; Secure`,
|
|
83
|
+
headerName: CSRF_HEADER,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export interface SecurityConfig {
|
|
2
|
+
csp: boolean
|
|
3
|
+
hsts: boolean
|
|
4
|
+
csrf: boolean
|
|
5
|
+
rateLimit: { requests: number; window: string } | false
|
|
6
|
+
nonce?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const DEFAULT_CONFIG: SecurityConfig = {
|
|
10
|
+
csp: true,
|
|
11
|
+
hsts: true,
|
|
12
|
+
csrf: true,
|
|
13
|
+
rateLimit: { requests: 100, window: "1m" },
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function securityHeaders(
|
|
17
|
+
config: Partial<SecurityConfig> = {},
|
|
18
|
+
nonce?: string
|
|
19
|
+
): Record<string, string> {
|
|
20
|
+
const cfg = { ...DEFAULT_CONFIG, ...config }
|
|
21
|
+
const headers: Record<string, string> = {}
|
|
22
|
+
|
|
23
|
+
if (cfg.csp) {
|
|
24
|
+
const scriptSrc = nonce ? `'nonce-${nonce}'` : "'self'"
|
|
25
|
+
headers["Content-Security-Policy"] = [
|
|
26
|
+
"default-src 'self'",
|
|
27
|
+
`script-src ${scriptSrc}`,
|
|
28
|
+
"style-src 'self' 'unsafe-inline'",
|
|
29
|
+
"img-src 'self' data: https:",
|
|
30
|
+
"font-src 'self'",
|
|
31
|
+
"connect-src 'self' ws: wss:",
|
|
32
|
+
"frame-ancestors 'none'",
|
|
33
|
+
"base-uri 'self'",
|
|
34
|
+
"form-action 'self'",
|
|
35
|
+
].join("; ")
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (cfg.hsts) {
|
|
39
|
+
headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Always set these
|
|
43
|
+
headers["X-Content-Type-Options"] = "nosniff"
|
|
44
|
+
headers["X-Frame-Options"] = "DENY"
|
|
45
|
+
headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
|
46
|
+
headers["X-XSS-Protection"] = "0" // Modern browsers: CSP is better
|
|
47
|
+
headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
|
48
|
+
|
|
49
|
+
return headers
|
|
50
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { securityHeaders, type SecurityConfig } from "./headers.ts"
|
|
2
|
+
export { csrfProtection, generateCSRFToken, validateCSRFToken } from "./csrf.ts"
|
|
3
|
+
export { createRateLimiter, type RateLimiter } from "./rate-limit.ts"
|
|
4
|
+
export { cors, type CORSOptions } from "./cors.ts"
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// In-memory token bucket rate limiter (for Bun runtime)
|
|
2
|
+
// For Cloudflare Workers: use Durable Objects adapter (future)
|
|
3
|
+
|
|
4
|
+
export interface RateLimiter {
|
|
5
|
+
check(key: string): { allowed: boolean; remaining: number; resetAt: number }
|
|
6
|
+
reset(key: string): void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface Bucket {
|
|
10
|
+
tokens: number
|
|
11
|
+
lastRefill: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseWindow(window: string): number {
|
|
15
|
+
const match = window.match(/^(\d+)(s|m|h)$/)
|
|
16
|
+
if (!match) throw new Error(`Invalid rate limit window: "${window}"`)
|
|
17
|
+
const value = Number(match[1])
|
|
18
|
+
switch (match[2]) {
|
|
19
|
+
case "s": return value * 1000
|
|
20
|
+
case "m": return value * 60_000
|
|
21
|
+
case "h": return value * 3_600_000
|
|
22
|
+
default: throw new Error(`Invalid unit: ${match[2]}`)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createRateLimiter(
|
|
27
|
+
maxRequests: number,
|
|
28
|
+
window: string
|
|
29
|
+
): RateLimiter {
|
|
30
|
+
const windowMs = parseWindow(window)
|
|
31
|
+
const buckets = new Map<string, Bucket>()
|
|
32
|
+
|
|
33
|
+
// Cleanup old entries periodically
|
|
34
|
+
const CLEANUP_INTERVAL = Math.max(windowMs * 2, 60_000)
|
|
35
|
+
const cleanup = setInterval(() => {
|
|
36
|
+
const now = Date.now()
|
|
37
|
+
for (const [key, bucket] of buckets) {
|
|
38
|
+
if (now - bucket.lastRefill > windowMs * 2) {
|
|
39
|
+
buckets.delete(key)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}, CLEANUP_INTERVAL)
|
|
43
|
+
|
|
44
|
+
// Don't prevent process from exiting
|
|
45
|
+
if (typeof cleanup === "object" && "unref" in cleanup) {
|
|
46
|
+
(cleanup as NodeJS.Timeout).unref()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
check(key: string): { allowed: boolean; remaining: number; resetAt: number } {
|
|
51
|
+
const now = Date.now()
|
|
52
|
+
let bucket = buckets.get(key)
|
|
53
|
+
|
|
54
|
+
if (!bucket) {
|
|
55
|
+
bucket = { tokens: maxRequests, lastRefill: now }
|
|
56
|
+
buckets.set(key, bucket)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Refill tokens based on elapsed time
|
|
60
|
+
const elapsed = now - bucket.lastRefill
|
|
61
|
+
if (elapsed >= windowMs) {
|
|
62
|
+
bucket.tokens = maxRequests
|
|
63
|
+
bucket.lastRefill = now
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const resetAt = bucket.lastRefill + windowMs
|
|
67
|
+
|
|
68
|
+
if (bucket.tokens > 0) {
|
|
69
|
+
bucket.tokens--
|
|
70
|
+
return { allowed: true, remaining: bucket.tokens, resetAt }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { allowed: false, remaining: 0, resetAt }
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
reset(key: string): void {
|
|
77
|
+
buckets.delete(key)
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Server Actions -- form mutations with progressive enhancement
|
|
2
|
+
// Usage in route:
|
|
3
|
+
// export const action = defineAction(async (ctx) => { ... })
|
|
4
|
+
//
|
|
5
|
+
// Client-side: submits via fetch, returns result
|
|
6
|
+
// SSR: handles POST form submissions with redirect
|
|
7
|
+
|
|
8
|
+
import type { Context } from "./middleware.ts"
|
|
9
|
+
|
|
10
|
+
export type ActionFn<T = unknown> = (ctx: Context) => Promise<T | Response>
|
|
11
|
+
|
|
12
|
+
export interface ActionResult<T = unknown> {
|
|
13
|
+
data?: T
|
|
14
|
+
error?: string
|
|
15
|
+
status: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function defineAction<T = unknown>(fn: ActionFn<T>): ActionFn<T> {
|
|
19
|
+
return fn
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function handleAction<T>(
|
|
23
|
+
actionFn: ActionFn<T>,
|
|
24
|
+
ctx: Context,
|
|
25
|
+
): Promise<ActionResult<T>> {
|
|
26
|
+
try {
|
|
27
|
+
const result = await actionFn(ctx)
|
|
28
|
+
if (result instanceof Response) {
|
|
29
|
+
return { status: result.status }
|
|
30
|
+
}
|
|
31
|
+
return { data: result, status: 200 }
|
|
32
|
+
} catch (err) {
|
|
33
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
34
|
+
return { error: message, status: 500 }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Parse form data from request into a typed object
|
|
39
|
+
export async function parseFormData(request: Request): Promise<Record<string, string>> {
|
|
40
|
+
const formData = await request.formData()
|
|
41
|
+
const result: Record<string, string> = {}
|
|
42
|
+
for (const [key, value] of formData.entries()) {
|
|
43
|
+
if (typeof value === "string") {
|
|
44
|
+
result[key] = value
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return result
|
|
48
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Route-level response cache with stale-while-revalidate
|
|
2
|
+
// Usage: export const cache = { maxAge: 60, staleWhileRevalidate: 300 }
|
|
3
|
+
|
|
4
|
+
import type { MiddlewareFn } from "./middleware.ts"
|
|
5
|
+
|
|
6
|
+
export interface CacheOptions {
|
|
7
|
+
maxAge: number // fresh cache TTL in seconds
|
|
8
|
+
staleWhileRevalidate?: number // serve stale while revalidating (seconds)
|
|
9
|
+
vary?: string[] // cache key varies by these headers
|
|
10
|
+
key?: (url: URL) => string // custom cache key generator
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface CacheEntry {
|
|
14
|
+
body: string
|
|
15
|
+
headers: Record<string, string>
|
|
16
|
+
status: number
|
|
17
|
+
createdAt: number
|
|
18
|
+
revalidating?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const store = new Map<string, CacheEntry>()
|
|
22
|
+
|
|
23
|
+
function buildKey(url: URL, vary: string[], request: Request, customKey?: (url: URL) => string): string {
|
|
24
|
+
const base = customKey ? customKey(url) : url.pathname + url.search
|
|
25
|
+
if (vary.length === 0) return base
|
|
26
|
+
const varyParts = vary.map((h) => `${h}=${request.headers.get(h) ?? ""}`).join("&")
|
|
27
|
+
return `${base}?__vary=${varyParts}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function routeCache(options: CacheOptions): MiddlewareFn {
|
|
31
|
+
const { maxAge, staleWhileRevalidate = 0, vary = [], key: customKey } = options
|
|
32
|
+
|
|
33
|
+
return async (ctx, next) => {
|
|
34
|
+
if (ctx.request.method !== "GET") return next()
|
|
35
|
+
|
|
36
|
+
const cacheKey = buildKey(ctx.url, vary, ctx.request, customKey)
|
|
37
|
+
const entry = store.get(cacheKey)
|
|
38
|
+
const now = Date.now()
|
|
39
|
+
|
|
40
|
+
if (entry) {
|
|
41
|
+
const age = (now - entry.createdAt) / 1000
|
|
42
|
+
// Fresh — serve from cache
|
|
43
|
+
if (age < maxAge) {
|
|
44
|
+
return new Response(entry.body, {
|
|
45
|
+
status: entry.status,
|
|
46
|
+
headers: { ...entry.headers, "X-Cache": "HIT", "Age": String(Math.floor(age)) },
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
// Stale but within revalidation window — serve stale, revalidate in background
|
|
50
|
+
if (age < maxAge + staleWhileRevalidate && !entry.revalidating) {
|
|
51
|
+
entry.revalidating = true
|
|
52
|
+
revalidate(cacheKey, ctx, next)
|
|
53
|
+
return new Response(entry.body, {
|
|
54
|
+
status: entry.status,
|
|
55
|
+
headers: { ...entry.headers, "X-Cache": "STALE", "Age": String(Math.floor(age)) },
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Miss — fetch and cache
|
|
61
|
+
const response = await next()
|
|
62
|
+
if (response.status === 200) {
|
|
63
|
+
const body = await response.text()
|
|
64
|
+
const headers: Record<string, string> = {}
|
|
65
|
+
response.headers.forEach((v, k) => { headers[k] = v })
|
|
66
|
+
store.set(cacheKey, { body, headers, status: response.status, createdAt: now })
|
|
67
|
+
return new Response(body, {
|
|
68
|
+
status: response.status,
|
|
69
|
+
headers: { ...headers, "X-Cache": "MISS" },
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
return response
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function revalidate(key: string, ctx: import("./middleware.ts").Context, next: () => Promise<Response>): Promise<void> {
|
|
77
|
+
try {
|
|
78
|
+
const response = await next()
|
|
79
|
+
if (response.status === 200) {
|
|
80
|
+
const body = await response.text()
|
|
81
|
+
const headers: Record<string, string> = {}
|
|
82
|
+
response.headers.forEach((v, k) => { headers[k] = v })
|
|
83
|
+
store.set(key, { body, headers, status: response.status, createdAt: Date.now() })
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// revalidation failed, stale entry stays
|
|
87
|
+
const entry = store.get(key)
|
|
88
|
+
if (entry) entry.revalidating = false
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 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)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Clear all cached entries */
|
|
100
|
+
export function clearCache(): void {
|
|
101
|
+
store.clear()
|
|
102
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Response compression middleware — gzip/deflate
|
|
2
|
+
// Uses Web Streams API (native in Bun)
|
|
3
|
+
|
|
4
|
+
import type { MiddlewareFn } from "./middleware.ts"
|
|
5
|
+
|
|
6
|
+
const COMPRESSIBLE_TYPES = new Set([
|
|
7
|
+
"text/html",
|
|
8
|
+
"text/css",
|
|
9
|
+
"text/javascript",
|
|
10
|
+
"application/javascript",
|
|
11
|
+
"application/json",
|
|
12
|
+
"text/xml",
|
|
13
|
+
"application/xml",
|
|
14
|
+
"image/svg+xml",
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
function isCompressible(contentType: string | null): boolean {
|
|
18
|
+
if (!contentType) return false
|
|
19
|
+
const type = contentType.split(";")[0]!.trim()
|
|
20
|
+
return COMPRESSIBLE_TYPES.has(type)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function compress(): MiddlewareFn {
|
|
24
|
+
return async (_ctx, next) => {
|
|
25
|
+
const response = await next()
|
|
26
|
+
const contentType = response.headers.get("content-type")
|
|
27
|
+
|
|
28
|
+
if (!isCompressible(contentType)) return response
|
|
29
|
+
if (response.headers.has("content-encoding")) return response
|
|
30
|
+
if (!response.body) return response
|
|
31
|
+
|
|
32
|
+
const acceptEncoding = _ctx.request.headers.get("accept-encoding") ?? ""
|
|
33
|
+
|
|
34
|
+
if (acceptEncoding.includes("gzip")) {
|
|
35
|
+
const compressed = response.body.pipeThrough(new CompressionStream("gzip"))
|
|
36
|
+
const headers = new Headers(response.headers)
|
|
37
|
+
headers.set("Content-Encoding", "gzip")
|
|
38
|
+
headers.delete("Content-Length")
|
|
39
|
+
return new Response(compressed, {
|
|
40
|
+
status: response.status,
|
|
41
|
+
statusText: response.statusText,
|
|
42
|
+
headers,
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (acceptEncoding.includes("deflate")) {
|
|
47
|
+
const compressed = response.body.pipeThrough(new CompressionStream("deflate"))
|
|
48
|
+
const headers = new Headers(response.headers)
|
|
49
|
+
headers.set("Content-Encoding", "deflate")
|
|
50
|
+
headers.delete("Content-Length")
|
|
51
|
+
return new Response(compressed, {
|
|
52
|
+
status: response.status,
|
|
53
|
+
statusText: response.statusText,
|
|
54
|
+
headers,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return response
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// ETag support for static file serving
|
|
2
|
+
// Generates weak ETags based on file size + modification time
|
|
3
|
+
|
|
4
|
+
import { stat } from "node:fs/promises"
|
|
5
|
+
|
|
6
|
+
export function generateETag(size: number, mtimeMs: number): string {
|
|
7
|
+
return `W/"${size.toString(16)}-${Math.floor(mtimeMs).toString(16)}"`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function fileETag(filePath: string): Promise<string | null> {
|
|
11
|
+
try {
|
|
12
|
+
const s = await stat(filePath)
|
|
13
|
+
return generateETag(s.size, s.mtimeMs)
|
|
14
|
+
} catch {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isNotModified(request: Request, etag: string): boolean {
|
|
20
|
+
const ifNoneMatch = request.headers.get("if-none-match")
|
|
21
|
+
if (!ifNoneMatch) return false
|
|
22
|
+
return ifNoneMatch.split(",").some((t) => t.trim() === etag)
|
|
23
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Route guards — declarative access control for routes
|
|
2
|
+
// Usage: export const guard = requireRole("admin")
|
|
3
|
+
|
|
4
|
+
import type { Context, MiddlewareFn } from "./middleware.ts"
|
|
5
|
+
|
|
6
|
+
type GuardFn = (ctx: Context) => boolean | Promise<boolean>
|
|
7
|
+
|
|
8
|
+
interface GuardOptions {
|
|
9
|
+
onFail?: (ctx: Context) => Response | Promise<Response>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const DEFAULT_DENY = (_ctx: Context) => new Response("Forbidden", { status: 403 })
|
|
13
|
+
|
|
14
|
+
/** Create a guard middleware from a predicate */
|
|
15
|
+
export function createGuard(check: GuardFn, options?: GuardOptions): MiddlewareFn {
|
|
16
|
+
const onFail = options?.onFail ?? DEFAULT_DENY
|
|
17
|
+
return async (ctx, next) => {
|
|
18
|
+
const allowed = await check(ctx)
|
|
19
|
+
if (!allowed) return onFail(ctx)
|
|
20
|
+
return next()
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Guard: require authenticated session */
|
|
25
|
+
export function requireAuth(loginPath = "/login"): MiddlewareFn {
|
|
26
|
+
return createGuard(
|
|
27
|
+
(ctx) => !!ctx.locals.session,
|
|
28
|
+
{ onFail: (ctx) => ctx.redirect(loginPath) },
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Guard: require specific role (reads from session.data.role) */
|
|
33
|
+
export function requireRole(role: string, options?: GuardOptions): MiddlewareFn {
|
|
34
|
+
return createGuard((ctx) => {
|
|
35
|
+
const session = ctx.locals.session as { data?: { role?: string } } | undefined
|
|
36
|
+
return session?.data?.role === role
|
|
37
|
+
}, options)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Guard: combine multiple guards (all must pass) */
|
|
41
|
+
export function allGuards(...guards: MiddlewareFn[]): MiddlewareFn {
|
|
42
|
+
return async (ctx, next) => {
|
|
43
|
+
for (const guard of guards) {
|
|
44
|
+
let passed = false
|
|
45
|
+
const guardNext = async () => {
|
|
46
|
+
passed = true
|
|
47
|
+
return new Response(null)
|
|
48
|
+
}
|
|
49
|
+
const result = await guard(ctx, guardNext)
|
|
50
|
+
if (!passed) return result
|
|
51
|
+
}
|
|
52
|
+
return next()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Guard: any one guard passing is sufficient */
|
|
57
|
+
export function anyGuard(...guards: MiddlewareFn[]): MiddlewareFn {
|
|
58
|
+
return async (ctx, next) => {
|
|
59
|
+
for (const guard of guards) {
|
|
60
|
+
let passed = false
|
|
61
|
+
await guard(ctx, async () => {
|
|
62
|
+
passed = true
|
|
63
|
+
return new Response(null)
|
|
64
|
+
})
|
|
65
|
+
if (passed) return next()
|
|
66
|
+
}
|
|
67
|
+
return new Response("Forbidden", { status: 403 })
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { server, handleRPCRequest, __registerRPC, getRPCHandler } from "./rpc.ts"
|
|
2
|
+
export {
|
|
3
|
+
middleware,
|
|
4
|
+
createContext,
|
|
5
|
+
runMiddlewareChain,
|
|
6
|
+
type Context,
|
|
7
|
+
type MiddlewareFn,
|
|
8
|
+
type NextFn,
|
|
9
|
+
} from "./middleware.ts"
|
|
10
|
+
export { defineAction, handleAction, parseFormData, type ActionFn, type ActionResult } from "./action.ts"
|
|
11
|
+
export { joinRoom, leaveRoom, broadcastToRoom, getRoomSize, createWSContext, type WSContext, type WSHandler } from "./ws.ts"
|
|
12
|
+
export { compress } from "./compress.ts"
|
|
13
|
+
export { getMimeType } from "./mime.ts"
|
|
14
|
+
export { fileETag, generateETag, isNotModified } from "./etag.ts"
|
|
15
|
+
export { redirect, RedirectError, type CookieOptions } from "./middleware.ts"
|
|
16
|
+
export { createSSEStream, createEventSource, type SSEOptions, type SSEStream, type EventSourceSignal } from "./sse.ts"
|
|
17
|
+
export { routeCache, invalidateCache, clearCache, type CacheOptions } from "./cache.ts"
|
|
18
|
+
export { createGuard, requireAuth, requireRole, allGuards, anyGuard } from "./guard.ts"
|
|
19
|
+
export { pipe, when, forMethods, forPaths } from "./pipe.ts"
|