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.
Files changed (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/package.json +69 -0
  4. package/src/auth/index.ts +147 -0
  5. package/src/build/client.ts +121 -0
  6. package/src/build/css-modules.ts +69 -0
  7. package/src/build/devalue-parse.ts +2 -0
  8. package/src/build/rpc-transform.ts +62 -0
  9. package/src/build/server-strip.ts +87 -0
  10. package/src/build/ssg.ts +100 -0
  11. package/src/cli/bun-plugin.ts +37 -0
  12. package/src/cli/cmd-build.ts +182 -0
  13. package/src/cli/cmd-check.ts +225 -0
  14. package/src/cli/cmd-create.ts +313 -0
  15. package/src/cli/cmd-dev.ts +13 -0
  16. package/src/cli/cmd-generate.ts +147 -0
  17. package/src/cli/cmd-migrate.ts +45 -0
  18. package/src/cli/cmd-routes.ts +29 -0
  19. package/src/cli/cmd-start.ts +21 -0
  20. package/src/cli/cmd-typegen.ts +83 -0
  21. package/src/cli/framework-md.ts +196 -0
  22. package/src/cli/index.ts +84 -0
  23. package/src/db/index.ts +2 -0
  24. package/src/db/migrate.ts +89 -0
  25. package/src/db/sqlite.ts +40 -0
  26. package/src/deploy/dockerfile.ts +38 -0
  27. package/src/dev/error-overlay.ts +54 -0
  28. package/src/dev/hmr.ts +31 -0
  29. package/src/dev/partial-handler.ts +109 -0
  30. package/src/dev/request-handler.ts +158 -0
  31. package/src/dev/watcher.ts +48 -0
  32. package/src/dev.ts +273 -0
  33. package/src/env/index.ts +74 -0
  34. package/src/errors/catalog.ts +48 -0
  35. package/src/errors/formatter.ts +63 -0
  36. package/src/errors/index.ts +2 -0
  37. package/src/i18n/index.ts +72 -0
  38. package/src/index.ts +27 -0
  39. package/src/jsx-runtime-client.ts +13 -0
  40. package/src/jsx-runtime.ts +20 -0
  41. package/src/jsx-types-html.ts +242 -0
  42. package/src/log/index.ts +44 -0
  43. package/src/prod.ts +310 -0
  44. package/src/reactive/computed.ts +7 -0
  45. package/src/reactive/effect.ts +7 -0
  46. package/src/reactive/index.ts +7 -0
  47. package/src/reactive/live.ts +97 -0
  48. package/src/reactive/optimistic.ts +83 -0
  49. package/src/reactive/resource.ts +138 -0
  50. package/src/reactive/signal.ts +20 -0
  51. package/src/reactive/store.ts +36 -0
  52. package/src/router/index.ts +2 -0
  53. package/src/router/matcher.ts +53 -0
  54. package/src/router/scanner.ts +206 -0
  55. package/src/runtime/client.ts +28 -0
  56. package/src/runtime/error-boundary.ts +35 -0
  57. package/src/runtime/event-replay.ts +50 -0
  58. package/src/runtime/form.ts +49 -0
  59. package/src/runtime/head.ts +113 -0
  60. package/src/runtime/html-escape.ts +30 -0
  61. package/src/runtime/hydration.ts +95 -0
  62. package/src/runtime/image.ts +48 -0
  63. package/src/runtime/index.ts +12 -0
  64. package/src/runtime/island-hydrator.ts +84 -0
  65. package/src/runtime/island.ts +88 -0
  66. package/src/runtime/jsx-runtime.ts +167 -0
  67. package/src/runtime/link.ts +45 -0
  68. package/src/runtime/router.ts +224 -0
  69. package/src/runtime/server.ts +102 -0
  70. package/src/runtime/stream.ts +182 -0
  71. package/src/runtime/suspense.ts +37 -0
  72. package/src/runtime/typed-routes.ts +26 -0
  73. package/src/runtime/validated-form.ts +106 -0
  74. package/src/security/cors.ts +80 -0
  75. package/src/security/csrf.ts +85 -0
  76. package/src/security/headers.ts +50 -0
  77. package/src/security/index.ts +4 -0
  78. package/src/security/rate-limit.ts +80 -0
  79. package/src/server/action.ts +48 -0
  80. package/src/server/cache.ts +102 -0
  81. package/src/server/compress.ts +60 -0
  82. package/src/server/etag.ts +23 -0
  83. package/src/server/guard.ts +69 -0
  84. package/src/server/index.ts +19 -0
  85. package/src/server/middleware.ts +143 -0
  86. package/src/server/mime.ts +48 -0
  87. package/src/server/pipe.ts +46 -0
  88. package/src/server/rpc-hash.ts +17 -0
  89. package/src/server/rpc.ts +125 -0
  90. package/src/server/sse.ts +96 -0
  91. package/src/server/ws.ts +56 -0
  92. package/src/testing/index.ts +74 -0
  93. package/src/types/index.ts +4 -0
  94. package/src/types/safe-html.ts +32 -0
  95. package/src/types/safe-sql.ts +28 -0
  96. package/src/types/safe-url.ts +40 -0
  97. package/src/types/user-input.ts +12 -0
  98. 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"