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,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
|
+
}
|
package/src/server/ws.ts
ADDED
|
@@ -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,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
|
+
"&": "&",
|
|
7
|
+
"<": "<",
|
|
8
|
+
">": ">",
|
|
9
|
+
'"': """,
|
|
10
|
+
"'": "'",
|
|
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
|
+
}
|