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,143 @@
1
+ // Server context, middleware chain, and cookie management
2
+
3
+ export interface CookieOptions {
4
+ maxAge?: number
5
+ expires?: Date
6
+ path?: string
7
+ domain?: string
8
+ secure?: boolean
9
+ httpOnly?: boolean
10
+ sameSite?: "Strict" | "Lax" | "None"
11
+ }
12
+
13
+ export interface Context {
14
+ request: Request
15
+ url: URL
16
+ params: Record<string, string>
17
+ cookies: Map<string, string>
18
+ locals: Record<string, unknown>
19
+ /** Response headers to be merged into the final response */
20
+ responseHeaders: Headers
21
+ redirect(url: string, status?: number): Response
22
+ setCookie(name: string, value: string, options?: CookieOptions): void
23
+ deleteCookie(name: string, options?: Pick<CookieOptions, "path" | "domain">): void
24
+ setHeader(name: string, value: string): void
25
+ }
26
+
27
+ export type NextFn = () => Promise<Response>
28
+ export type MiddlewareFn = (ctx: Context, next: NextFn) => Promise<Response>
29
+
30
+ /** Throwable redirect — use in loaders to interrupt and redirect */
31
+ export class RedirectError {
32
+ constructor(public url: string, public status: number = 302) {}
33
+ }
34
+
35
+ export function redirect(url: string, status = 302): never {
36
+ throw new RedirectError(url, status)
37
+ }
38
+
39
+ export function middleware(fn: MiddlewareFn): MiddlewareFn {
40
+ return fn
41
+ }
42
+
43
+ function serializeCookie(name: string, value: string, options: CookieOptions = {}): string {
44
+ let cookie = `${name}=${value}`
45
+ if (options.maxAge !== undefined) cookie += `; Max-Age=${options.maxAge}`
46
+ if (options.expires) cookie += `; Expires=${options.expires.toUTCString()}`
47
+ cookie += `; Path=${options.path ?? "/"}`
48
+ if (options.domain) cookie += `; Domain=${options.domain}`
49
+ if (options.secure) cookie += "; Secure"
50
+ if (options.httpOnly) cookie += "; HttpOnly"
51
+ if (options.sameSite) cookie += `; SameSite=${options.sameSite}`
52
+ return cookie
53
+ }
54
+
55
+ function parseCookies(request: Request): Map<string, string> {
56
+ const cookieHeader = request.headers.get("cookie") ?? ""
57
+ const cookies = new Map<string, string>()
58
+ for (const pair of cookieHeader.split(";")) {
59
+ const [key, ...rest] = pair.trim().split("=")
60
+ if (key) cookies.set(key, rest.join("="))
61
+ }
62
+ return cookies
63
+ }
64
+
65
+ export function createContext(request: Request, params: Record<string, string> = {}): Context {
66
+ const url = new URL(request.url)
67
+ const cookies = parseCookies(request)
68
+ const responseHeaders = new Headers()
69
+ const pendingCookies: string[] = []
70
+
71
+ return {
72
+ request,
73
+ url,
74
+ params,
75
+ cookies,
76
+ locals: {},
77
+ responseHeaders,
78
+
79
+ redirect(target: string, status = 302) {
80
+ const res = new Response(null, {
81
+ status,
82
+ headers: { Location: target },
83
+ })
84
+ // Apply pending cookies to redirect response
85
+ for (const cookie of pendingCookies) {
86
+ res.headers.append("Set-Cookie", cookie)
87
+ }
88
+ return res
89
+ },
90
+
91
+ setCookie(name: string, value: string, options?: CookieOptions) {
92
+ const cookie = serializeCookie(name, value, options)
93
+ pendingCookies.push(cookie)
94
+ responseHeaders.append("Set-Cookie", cookie)
95
+ cookies.set(name, value)
96
+ },
97
+
98
+ deleteCookie(name: string, options?: Pick<CookieOptions, "path" | "domain">) {
99
+ const cookie = serializeCookie(name, "", {
100
+ ...options,
101
+ maxAge: 0,
102
+ expires: new Date(0),
103
+ })
104
+ pendingCookies.push(cookie)
105
+ responseHeaders.append("Set-Cookie", cookie)
106
+ cookies.delete(name)
107
+ },
108
+
109
+ setHeader(name: string, value: string) {
110
+ responseHeaders.set(name, value)
111
+ },
112
+ }
113
+ }
114
+
115
+ export async function runMiddlewareChain(
116
+ middlewares: MiddlewareFn[],
117
+ ctx: Context,
118
+ handler: () => Promise<Response>
119
+ ): Promise<Response> {
120
+ let index = 0
121
+
122
+ const next = async (): Promise<Response> => {
123
+ if (index < middlewares.length) {
124
+ const mw = middlewares[index]!
125
+ index++
126
+ return mw(ctx, next)
127
+ }
128
+ return handler()
129
+ }
130
+
131
+ const response = await next()
132
+
133
+ // Merge ctx.responseHeaders into response
134
+ for (const [key, value] of ctx.responseHeaders.entries()) {
135
+ if (key.toLowerCase() === "set-cookie") {
136
+ response.headers.append(key, value)
137
+ } else {
138
+ response.headers.set(key, value)
139
+ }
140
+ }
141
+
142
+ return response
143
+ }
@@ -0,0 +1,48 @@
1
+ // MIME type detection for static file serving
2
+
3
+ const MIME_TYPES: Record<string, string> = {
4
+ ".html": "text/html; charset=utf-8",
5
+ ".css": "text/css; charset=utf-8",
6
+ ".js": "application/javascript; charset=utf-8",
7
+ ".mjs": "application/javascript; charset=utf-8",
8
+ ".json": "application/json; charset=utf-8",
9
+ ".xml": "application/xml; charset=utf-8",
10
+ ".txt": "text/plain; charset=utf-8",
11
+ ".md": "text/markdown; charset=utf-8",
12
+ ".csv": "text/csv; charset=utf-8",
13
+
14
+ // Images
15
+ ".png": "image/png",
16
+ ".jpg": "image/jpeg",
17
+ ".jpeg": "image/jpeg",
18
+ ".gif": "image/gif",
19
+ ".webp": "image/webp",
20
+ ".avif": "image/avif",
21
+ ".svg": "image/svg+xml",
22
+ ".ico": "image/x-icon",
23
+
24
+ // Fonts
25
+ ".woff": "font/woff",
26
+ ".woff2": "font/woff2",
27
+ ".ttf": "font/ttf",
28
+ ".otf": "font/otf",
29
+ ".eot": "application/vnd.ms-fontobject",
30
+
31
+ // Media
32
+ ".mp4": "video/mp4",
33
+ ".webm": "video/webm",
34
+ ".mp3": "audio/mpeg",
35
+ ".ogg": "audio/ogg",
36
+ ".wav": "audio/wav",
37
+
38
+ // Other
39
+ ".pdf": "application/pdf",
40
+ ".zip": "application/zip",
41
+ ".wasm": "application/wasm",
42
+ ".map": "application/json",
43
+ }
44
+
45
+ export function getMimeType(path: string): string {
46
+ const ext = path.slice(path.lastIndexOf(".")).toLowerCase()
47
+ return MIME_TYPES[ext] ?? "application/octet-stream"
48
+ }
@@ -0,0 +1,46 @@
1
+ // Middleware pipe — compose middleware functions into a pipeline
2
+ // Usage: pipe(cors(), compress(), rateLimit(), auth())
3
+ // Killer DX: declarative route-level middleware composition
4
+
5
+ import type { MiddlewareFn } from "./middleware.ts"
6
+
7
+ /** Compose multiple middleware into a single middleware function */
8
+ export function pipe(...middlewares: MiddlewareFn[]): MiddlewareFn {
9
+ if (middlewares.length === 0) return async (_ctx, next) => next()
10
+ if (middlewares.length === 1) return middlewares[0]!
11
+
12
+ return async (ctx, next) => {
13
+ let index = 0
14
+ const run = async (): Promise<Response> => {
15
+ if (index < middlewares.length) {
16
+ const mw = middlewares[index]!
17
+ index++
18
+ return mw(ctx, run)
19
+ }
20
+ return next()
21
+ }
22
+ return run()
23
+ }
24
+ }
25
+
26
+ /** Create a conditional middleware — runs inner only if predicate passes */
27
+ export function when(
28
+ predicate: (ctx: import("./middleware.ts").Context) => boolean,
29
+ middleware: MiddlewareFn,
30
+ ): MiddlewareFn {
31
+ return async (ctx, next) => {
32
+ if (predicate(ctx)) return middleware(ctx, next)
33
+ return next()
34
+ }
35
+ }
36
+
37
+ /** Create a middleware that runs only for specific HTTP methods */
38
+ export function forMethods(methods: string[], middleware: MiddlewareFn): MiddlewareFn {
39
+ const methodSet = new Set(methods.map((m) => m.toUpperCase()))
40
+ return when((ctx) => methodSet.has(ctx.request.method), middleware)
41
+ }
42
+
43
+ /** Create a middleware that runs only for specific path prefixes */
44
+ export function forPaths(prefixes: string[], middleware: MiddlewareFn): MiddlewareFn {
45
+ return when((ctx) => prefixes.some((p) => ctx.url.pathname.startsWith(p)), middleware)
46
+ }
@@ -0,0 +1,17 @@
1
+ // Shared RPC hash function -- must produce identical IDs on server and client
2
+ // ID = sha256(filePath + ":" + callIndex).slice(0, 12)
3
+
4
+ import { createHash } from "node:crypto"
5
+
6
+ export function hashRPC(filePath: string, index: number): string {
7
+ return createHash("sha256")
8
+ .update(`${filePath}:${index}`)
9
+ .digest("hex")
10
+ .slice(0, 12)
11
+ }
12
+
13
+ // Scan source for server() call positions and return their hashes
14
+ export function scanServerCalls(source: string, filePath: string): string[] {
15
+ const matches = [...source.matchAll(/\bserver\s*\(\s*(?=async\s|function\s)/g)]
16
+ return matches.map((_, i) => hashRPC(filePath, i))
17
+ }
@@ -0,0 +1,125 @@
1
+ // server() function + RPC registry
2
+ // On server: registers function for RPC with hash(filePath:index)
3
+ // On client: replaced by build plugin with fetch() stub
4
+
5
+ import { stringify } from "devalue"
6
+ import { hashRPC } from "./rpc-hash.ts"
7
+
8
+ export interface ServerOptions {
9
+ middleware?: unknown[]
10
+ }
11
+
12
+ // RPC registry
13
+ const rpcHandlers = new Map<string, (...args: unknown[]) => Promise<unknown>>()
14
+
15
+ // Track per-file call counters for hash synchronization
16
+ const fileCallCounters = new Map<string, number>()
17
+
18
+ export function __registerRPC(id: string, fn: (...args: unknown[]) => Promise<unknown>): void {
19
+ rpcHandlers.set(id, fn)
20
+ }
21
+
22
+ export function __resetRPCState(): void {
23
+ fileCallCounters.clear()
24
+ rpcHandlers.clear()
25
+ }
26
+
27
+ export function getRPCHandler(id: string): ((...args: unknown[]) => Promise<unknown>) | undefined {
28
+ return rpcHandlers.get(id)
29
+ }
30
+
31
+ function getCallerFile(): string | null {
32
+ const orig = Error.prepareStackTrace
33
+ Error.prepareStackTrace = (_err, stack) => stack
34
+ const err = new Error()
35
+ const stack = err.stack as unknown as NodeJS.CallSite[]
36
+ Error.prepareStackTrace = orig
37
+
38
+ // Walk up stack: 0=getCallerFile, 1=server(), 2=caller (route file)
39
+ for (let i = 2; i < stack.length; i++) {
40
+ const file = stack[i]?.getFileName()
41
+ if (file && !file.includes("/server/rpc.ts") && !file.includes("node_modules")) {
42
+ return file
43
+ }
44
+ }
45
+ return null
46
+ }
47
+
48
+ // server() wrapper -- registers with hash-based ID matching client transform
49
+ export function server<TArgs extends unknown[], TReturn>(
50
+ fn: (...args: TArgs) => Promise<TReturn>,
51
+ _options?: ServerOptions
52
+ ): (...args: TArgs) => Promise<TReturn> {
53
+ const callerFile = getCallerFile()
54
+ if (callerFile) {
55
+ const counter = fileCallCounters.get(callerFile) ?? 0
56
+ fileCallCounters.set(callerFile, counter + 1)
57
+ const id = hashRPC(callerFile, counter)
58
+ rpcHandlers.set(id, fn as (...args: unknown[]) => Promise<unknown>)
59
+ }
60
+ return fn
61
+ }
62
+
63
+ // RPC HTTP handler
64
+ export async function handleRPCRequest(request: Request): Promise<Response | null> {
65
+ const url = new URL(request.url)
66
+ const match = url.pathname.match(/^\/api\/_rpc\/([a-zA-Z0-9]+)$/)
67
+ if (!match) return null
68
+
69
+ const id = match[1]!
70
+ const handler = rpcHandlers.get(id)
71
+
72
+ if (!handler) {
73
+ return new Response(JSON.stringify({ error: `RPC handler not found: ${id}` }), {
74
+ status: 404,
75
+ headers: { "Content-Type": "application/json" },
76
+ })
77
+ }
78
+
79
+ try {
80
+ const MAX_RPC_BODY = 1024 * 1024 // 1MB
81
+ let args: unknown[] = []
82
+ if (request.method === "POST") {
83
+ const contentLength = Number(request.headers.get("content-length") ?? "0")
84
+ if (contentLength > MAX_RPC_BODY) {
85
+ return new Response(JSON.stringify({ error: "Request body too large" }), {
86
+ status: 413,
87
+ headers: { "Content-Type": "application/json" },
88
+ })
89
+ }
90
+ const body = await request.text()
91
+ if (body) {
92
+ let parsed: unknown
93
+ try {
94
+ parsed = JSON.parse(body)
95
+ } catch {
96
+ return new Response(JSON.stringify({ error: "Invalid JSON in request body" }), {
97
+ status: 400,
98
+ headers: { "Content-Type": "application/json" },
99
+ })
100
+ }
101
+ if (!Array.isArray(parsed)) {
102
+ return new Response(JSON.stringify({ error: "RPC args must be an array" }), {
103
+ status: 400,
104
+ headers: { "Content-Type": "application/json" },
105
+ })
106
+ }
107
+ args = parsed
108
+ }
109
+ }
110
+
111
+ const result = await handler(...args)
112
+ const serialized = stringify(result)
113
+
114
+ return new Response(serialized, {
115
+ status: 200,
116
+ headers: { "Content-Type": "application/json" },
117
+ })
118
+ } catch (err) {
119
+ const message = err instanceof Error ? err.message : String(err)
120
+ return new Response(JSON.stringify({ error: message }), {
121
+ status: 500,
122
+ headers: { "Content-Type": "application/json" },
123
+ })
124
+ }
125
+ }
@@ -0,0 +1,96 @@
1
+ import { createSignal, type SignalGetter } from "../reactive/signal.ts"
2
+
3
+ export interface SSEOptions {
4
+ headers?: Record<string, string>
5
+ }
6
+
7
+ export interface SSEStream {
8
+ response: Response
9
+ send: (event: string, data: unknown) => void
10
+ close: () => void
11
+ }
12
+
13
+ export function createSSEStream(options?: SSEOptions): SSEStream {
14
+ let controller: ReadableStreamDefaultController<Uint8Array> | null = null
15
+ const encoder = new TextEncoder()
16
+
17
+ const stream = new ReadableStream<Uint8Array>({
18
+ start(ctrl) {
19
+ controller = ctrl
20
+ },
21
+ cancel() {
22
+ controller = null
23
+ },
24
+ })
25
+
26
+ const response = new Response(stream, {
27
+ headers: {
28
+ "Content-Type": "text/event-stream",
29
+ "Cache-Control": "no-cache",
30
+ Connection: "keep-alive",
31
+ ...options?.headers,
32
+ },
33
+ })
34
+
35
+ function send(event: string, data: unknown): void {
36
+ if (!controller) return
37
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
38
+ controller.enqueue(encoder.encode(payload))
39
+ }
40
+
41
+ function close(): void {
42
+ if (controller) {
43
+ controller.close()
44
+ controller = null
45
+ }
46
+ }
47
+
48
+ return { response, send, close }
49
+ }
50
+
51
+ // --- Client-side: reactive EventSource signal ---
52
+
53
+ export interface EventSourceSignal<T> {
54
+ readonly value: SignalGetter<T>
55
+ readonly connected: SignalGetter<boolean>
56
+ close: () => void
57
+ }
58
+
59
+ export function createEventSource<T>(
60
+ url: string,
61
+ event: string,
62
+ initialValue: T,
63
+ ): EventSourceSignal<T> {
64
+ const [value, setValue] = createSignal<T>(initialValue)
65
+ const [connected, setConnected] = createSignal(false)
66
+
67
+ if (typeof globalThis.EventSource === "undefined") {
68
+ return { value, connected, close: () => {} }
69
+ }
70
+
71
+ const source = new EventSource(url)
72
+
73
+ source.addEventListener("open", () => {
74
+ setConnected(true)
75
+ })
76
+
77
+ source.addEventListener(event, (e: MessageEvent) => {
78
+ try {
79
+ const parsed = JSON.parse(String(e.data)) as T
80
+ setValue(parsed)
81
+ } catch {
82
+ // ignore malformed messages
83
+ }
84
+ })
85
+
86
+ source.addEventListener("error", () => {
87
+ setConnected(false)
88
+ })
89
+
90
+ function close(): void {
91
+ source.close()
92
+ setConnected(false)
93
+ }
94
+
95
+ return { value, connected, close }
96
+ }
@@ -0,0 +1,56 @@
1
+ // WebSocket support for real-time features
2
+ // Routes can export a `ws` handler for WebSocket connections
3
+
4
+ export interface WSContext {
5
+ send(data: string | ArrayBuffer): void
6
+ close(code?: number, reason?: string): void
7
+ id: string
8
+ }
9
+
10
+ export type WSHandler = {
11
+ open?: (ws: WSContext) => void
12
+ message?: (ws: WSContext, data: string | ArrayBuffer) => void
13
+ close?: (ws: WSContext) => void
14
+ }
15
+
16
+ // Room-based pub/sub for common real-time patterns
17
+ const rooms = new Map<string, Set<WSContext>>()
18
+
19
+ export function joinRoom(room: string, ws: WSContext): void {
20
+ let members = rooms.get(room)
21
+ if (!members) {
22
+ members = new Set()
23
+ rooms.set(room, members)
24
+ }
25
+ members.add(ws)
26
+ }
27
+
28
+ export function leaveRoom(room: string, ws: WSContext): void {
29
+ const members = rooms.get(room)
30
+ if (!members) return
31
+ members.delete(ws)
32
+ if (members.size === 0) rooms.delete(room)
33
+ }
34
+
35
+ export function broadcastToRoom(room: string, data: string, exclude?: WSContext): void {
36
+ const members = rooms.get(room)
37
+ if (!members) return
38
+ for (const ws of members) {
39
+ if (ws === exclude) continue
40
+ try { ws.send(data) } catch {}
41
+ }
42
+ }
43
+
44
+ export function getRoomSize(room: string): number {
45
+ return rooms.get(room)?.size ?? 0
46
+ }
47
+
48
+ let wsIdCounter = 0
49
+
50
+ export function createWSContext(ws: { send(data: string | ArrayBuffer): void; close(code?: number, reason?: string): void }): WSContext {
51
+ return {
52
+ send: (data) => ws.send(data),
53
+ close: (code, reason) => ws.close(code, reason),
54
+ id: `ws_${++wsIdCounter}`,
55
+ }
56
+ }
@@ -0,0 +1,74 @@
1
+ // Testing utilities for Gorsee.js applications
2
+
3
+ import { createContext, type Context, type MiddlewareFn, runMiddlewareChain } from "../server/middleware.ts"
4
+ import { renderToString, ssrJsx } from "../runtime/server.ts"
5
+
6
+ /** Create a mock request for testing */
7
+ export function createTestRequest(
8
+ path: string,
9
+ options: {
10
+ method?: string
11
+ headers?: Record<string, string>
12
+ body?: string | Record<string, unknown>
13
+ } = {},
14
+ ): Request {
15
+ const { method = "GET", headers = {}, body } = options
16
+ const url = `http://localhost${path}`
17
+ const init: RequestInit = { method, headers }
18
+ if (body) {
19
+ if (typeof body === "object") {
20
+ init.body = JSON.stringify(body)
21
+ ;(init.headers as Record<string, string>)["Content-Type"] = "application/json"
22
+ } else {
23
+ init.body = body
24
+ }
25
+ }
26
+ return new Request(url, init)
27
+ }
28
+
29
+ /** Create a test context for middleware/loader testing */
30
+ export function createTestContext(
31
+ path: string,
32
+ options: {
33
+ method?: string
34
+ headers?: Record<string, string>
35
+ params?: Record<string, string>
36
+ body?: string | Record<string, unknown>
37
+ } = {},
38
+ ): Context {
39
+ const { params = {}, ...reqOpts } = options
40
+ const request = createTestRequest(path, reqOpts)
41
+ return createContext(request, params)
42
+ }
43
+
44
+ /** Run a middleware function with a test context and handler */
45
+ export async function runTestMiddleware(
46
+ middleware: MiddlewareFn,
47
+ ctx: Context,
48
+ handler?: () => Promise<Response>,
49
+ ): Promise<Response> {
50
+ const defaultHandler = async () => new Response("OK")
51
+ return runMiddlewareChain([middleware], ctx, handler ?? defaultHandler)
52
+ }
53
+
54
+ /** Render a component to HTML string for snapshot/assertion testing */
55
+ export function renderComponent(
56
+ component: Function,
57
+ props: Record<string, unknown> = {},
58
+ ): string {
59
+ const vnode = ssrJsx(component as any, props)
60
+ return renderToString(vnode)
61
+ }
62
+
63
+ /** Test a loader function directly */
64
+ export async function testLoader<T>(
65
+ loader: (ctx: Context) => Promise<T>,
66
+ path: string,
67
+ options: {
68
+ params?: Record<string, string>
69
+ headers?: Record<string, string>
70
+ } = {},
71
+ ): Promise<T> {
72
+ const ctx = createTestContext(path, options)
73
+ return loader(ctx)
74
+ }
@@ -0,0 +1,4 @@
1
+ export { SafeSQL, type SafeSQLValue } from "./safe-sql.ts"
2
+ export { SafeHTML, sanitize } from "./safe-html.ts"
3
+ export { SafeURL, validateURL } from "./safe-url.ts"
4
+ export { type UserInput, validate } from "./user-input.ts"
@@ -0,0 +1,32 @@
1
+ declare const __safeHTMLBrand: unique symbol
2
+
3
+ export type SafeHTMLValue = string & { readonly [__safeHTMLBrand]: true }
4
+
5
+ const ESCAPE_MAP: Record<string, string> = {
6
+ "&": "&amp;",
7
+ "<": "&lt;",
8
+ ">": "&gt;",
9
+ '"': "&quot;",
10
+ "'": "&#x27;",
11
+ }
12
+
13
+ const ESCAPE_RE = /[&<>"']/g
14
+
15
+ export function sanitize(raw: string): SafeHTMLValue {
16
+ const escaped = raw.replace(ESCAPE_RE, (ch) => ESCAPE_MAP[ch] ?? ch)
17
+ return escaped as SafeHTMLValue
18
+ }
19
+
20
+ export function SafeHTML(
21
+ strings: TemplateStringsArray,
22
+ ...values: unknown[]
23
+ ): SafeHTMLValue {
24
+ const parts: string[] = []
25
+ for (let i = 0; i < strings.length; i++) {
26
+ parts.push(strings[i]!)
27
+ if (i < values.length) {
28
+ parts.push(sanitize(String(values[i])) as string)
29
+ }
30
+ }
31
+ return parts.join("") as SafeHTMLValue
32
+ }
@@ -0,0 +1,28 @@
1
+ declare const __safeSQLBrand: unique symbol
2
+
3
+ export interface SafeSQLValue {
4
+ readonly text: string
5
+ readonly params: readonly unknown[]
6
+ readonly [__safeSQLBrand]: true
7
+ }
8
+
9
+ export function SafeSQL(
10
+ strings: TemplateStringsArray,
11
+ ...values: unknown[]
12
+ ): SafeSQLValue {
13
+ const parts: string[] = []
14
+ const params: unknown[] = []
15
+
16
+ for (let i = 0; i < strings.length; i++) {
17
+ parts.push(strings[i]!)
18
+ if (i < values.length) {
19
+ params.push(values[i])
20
+ parts.push("?")
21
+ }
22
+ }
23
+
24
+ return {
25
+ text: parts.join(""),
26
+ params,
27
+ } as unknown as SafeSQLValue
28
+ }