gorsee 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +132 -4
  2. package/package.json +4 -2
  3. package/src/auth/index.ts +48 -17
  4. package/src/auth/redis-session-store.ts +46 -0
  5. package/src/auth/sqlite-session-store.ts +98 -0
  6. package/src/auth/store-utils.ts +21 -0
  7. package/src/build/client.ts +25 -7
  8. package/src/build/manifest.ts +34 -0
  9. package/src/build/route-metadata.ts +12 -0
  10. package/src/build/ssg.ts +19 -49
  11. package/src/cli/bun-plugin.ts +23 -2
  12. package/src/cli/cmd-build.ts +42 -71
  13. package/src/cli/cmd-check.ts +40 -26
  14. package/src/cli/cmd-create.ts +20 -5
  15. package/src/cli/cmd-deploy.ts +10 -2
  16. package/src/cli/cmd-dev.ts +9 -9
  17. package/src/cli/cmd-docs.ts +152 -0
  18. package/src/cli/cmd-generate.ts +15 -7
  19. package/src/cli/cmd-migrate.ts +15 -7
  20. package/src/cli/cmd-routes.ts +12 -5
  21. package/src/cli/cmd-start.ts +14 -5
  22. package/src/cli/cmd-test.ts +129 -0
  23. package/src/cli/cmd-typegen.ts +13 -3
  24. package/src/cli/cmd-upgrade.ts +143 -0
  25. package/src/cli/context.ts +12 -0
  26. package/src/cli/framework-md.ts +43 -16
  27. package/src/cli/index.ts +18 -0
  28. package/src/client.ts +26 -0
  29. package/src/dev/partial-handler.ts +17 -74
  30. package/src/dev/request-handler.ts +36 -67
  31. package/src/dev.ts +92 -157
  32. package/src/index-client.ts +4 -0
  33. package/src/index.ts +17 -2
  34. package/src/prod.ts +195 -253
  35. package/src/runtime/project.ts +73 -0
  36. package/src/server/cache-utils.ts +23 -0
  37. package/src/server/cache.ts +37 -14
  38. package/src/server/html-shell.ts +69 -0
  39. package/src/server/index.ts +40 -2
  40. package/src/server/manifest.ts +36 -0
  41. package/src/server/middleware.ts +18 -2
  42. package/src/server/not-found.ts +35 -0
  43. package/src/server/page-render.ts +123 -0
  44. package/src/server/redis-cache-store.ts +87 -0
  45. package/src/server/redis-client.ts +71 -0
  46. package/src/server/request-preflight.ts +45 -0
  47. package/src/server/route-request.ts +72 -0
  48. package/src/server/rpc-utils.ts +27 -0
  49. package/src/server/rpc.ts +70 -18
  50. package/src/server/sqlite-cache-store.ts +109 -0
  51. package/src/server/static-file.ts +63 -0
  52. package/src/server-entry.ts +36 -0
@@ -0,0 +1,72 @@
1
+ import { createContext, runMiddlewareChain, RedirectError, type Context, type MiddlewareFn } from "./middleware.ts"
2
+ import { resolvePageRoute, type ResolvedPageRoute } from "./page-render.ts"
3
+ import type { MatchResult } from "../router/matcher.ts"
4
+
5
+ type RouteModule = Record<string, unknown>
6
+
7
+ interface RouteRequestContext {
8
+ match: MatchResult
9
+ request: Request
10
+ mod: RouteModule
11
+ ctx: Context
12
+ }
13
+
14
+ interface ResolvedRouteRequestContext extends RouteRequestContext {
15
+ resolved: ResolvedPageRoute
16
+ }
17
+
18
+ interface RouteRequestOptions {
19
+ match: MatchResult
20
+ request: Request
21
+ extraMiddlewares?: MiddlewareFn[]
22
+ onPartialRequest: (ctx: ResolvedRouteRequestContext) => Promise<Response>
23
+ onPageRequest: (ctx: ResolvedRouteRequestContext) => Promise<Response>
24
+ onRouteError?: (error: unknown, ctx: ResolvedRouteRequestContext) => Promise<Response>
25
+ }
26
+
27
+ export async function handleRouteRequest(options: RouteRequestOptions): Promise<Response> {
28
+ const { match, request, extraMiddlewares = [], onPartialRequest, onPageRequest, onRouteError } = options
29
+ const mod = await import(match.route.filePath)
30
+ const ctx = createContext(request, match.params)
31
+ let routeCtx: ResolvedRouteRequestContext | undefined
32
+
33
+ if (typeof mod.GET === "function" && request.method === "GET") return (mod.GET as Function)(ctx)
34
+ if (typeof mod.POST === "function" && request.method === "POST") return (mod.POST as Function)(ctx)
35
+
36
+ try {
37
+ const resolved = await resolvePageRoute(mod, match, ctx)
38
+ if (!resolved) return new Response("Route has no default export", { status: 500 })
39
+
40
+ routeCtx = { match, request, mod, ctx, resolved }
41
+ const currentRouteCtx = routeCtx
42
+ const middlewares = await loadRouteMiddlewares(match, extraMiddlewares)
43
+
44
+ return await runMiddlewareChain(middlewares, ctx, async () => {
45
+ if (request.headers.get("X-Gorsee-Navigate") === "partial") {
46
+ return onPartialRequest(currentRouteCtx)
47
+ }
48
+ return onPageRequest(currentRouteCtx)
49
+ })
50
+ } catch (error) {
51
+ if (error instanceof RedirectError) {
52
+ return new Response(null, {
53
+ status: error.status,
54
+ headers: { Location: error.url },
55
+ })
56
+ }
57
+ if (onRouteError && routeCtx) return onRouteError(error, routeCtx)
58
+ throw error
59
+ }
60
+ }
61
+
62
+ async function loadRouteMiddlewares(
63
+ match: MatchResult,
64
+ extraMiddlewares: MiddlewareFn[],
65
+ ): Promise<MiddlewareFn[]> {
66
+ const middlewares = [...extraMiddlewares]
67
+ for (const mwPath of match.route.middlewarePaths) {
68
+ const mwMod = await import(mwPath)
69
+ if (typeof mwMod.default === "function") middlewares.push(mwMod.default)
70
+ }
71
+ return middlewares
72
+ }
@@ -0,0 +1,27 @@
1
+ import type { RPCRegistry } from "./rpc.ts"
2
+
3
+ export function createScopedRPCRegistry(registry: RPCRegistry, scope: string): RPCRegistry {
4
+ const prefix = `${scope}:`
5
+ const fileCounters = new Map<string, number>()
6
+ const scopedIds = new Set<string>()
7
+ return {
8
+ getHandler: (id) => registry.getHandler(prefix + id),
9
+ setHandler: (id, fn) => {
10
+ const scopedId = prefix + id
11
+ scopedIds.add(scopedId)
12
+ registry.setHandler(scopedId, fn)
13
+ },
14
+ deleteHandler: (id) => {
15
+ const scopedId = prefix + id
16
+ scopedIds.delete(scopedId)
17
+ registry.deleteHandler(scopedId)
18
+ },
19
+ clear: () => {
20
+ for (const scopedId of scopedIds) registry.deleteHandler(scopedId)
21
+ scopedIds.clear()
22
+ fileCounters.clear()
23
+ },
24
+ getFileCallCount: (file) => fileCounters.get(file) ?? 0,
25
+ setFileCallCount: (file, count) => { fileCounters.set(file, count) },
26
+ }
27
+ }
package/src/server/rpc.ts CHANGED
@@ -9,23 +9,45 @@ export interface ServerOptions {
9
9
  middleware?: unknown[]
10
10
  }
11
11
 
12
- // RPC registry
13
- const rpcHandlers = new Map<string, (...args: unknown[]) => Promise<unknown>>()
12
+ export type RPCHandler = (...args: unknown[]) => Promise<unknown>
13
+
14
+ export interface RPCRegistry {
15
+ getHandler(id: string): RPCHandler | undefined
16
+ setHandler(id: string, fn: RPCHandler): void
17
+ deleteHandler(id: string): void
18
+ clear(): void
19
+ getFileCallCount(file: string): number
20
+ setFileCallCount(file: string, count: number): void
21
+ }
22
+
23
+ export function createMemoryRPCRegistry(): RPCRegistry {
24
+ const rpcHandlers = new Map<string, RPCHandler>()
25
+ const fileCallCounters = new Map<string, number>()
26
+ return {
27
+ getHandler: (id) => rpcHandlers.get(id),
28
+ setHandler: (id, fn) => { rpcHandlers.set(id, fn) },
29
+ deleteHandler: (id) => { rpcHandlers.delete(id) },
30
+ clear: () => {
31
+ rpcHandlers.clear()
32
+ fileCallCounters.clear()
33
+ },
34
+ getFileCallCount: (file) => fileCallCounters.get(file) ?? 0,
35
+ setFileCallCount: (file, count) => { fileCallCounters.set(file, count) },
36
+ }
37
+ }
14
38
 
15
- // Track per-file call counters for hash synchronization
16
- const fileCallCounters = new Map<string, number>()
39
+ const defaultRPCRegistry = createMemoryRPCRegistry()
17
40
 
18
- export function __registerRPC(id: string, fn: (...args: unknown[]) => Promise<unknown>): void {
19
- rpcHandlers.set(id, fn)
41
+ export function __registerRPC(id: string, fn: RPCHandler): void {
42
+ defaultRPCRegistry.setHandler(id, fn)
20
43
  }
21
44
 
22
45
  export function __resetRPCState(): void {
23
- fileCallCounters.clear()
24
- rpcHandlers.clear()
46
+ defaultRPCRegistry.clear()
25
47
  }
26
48
 
27
- export function getRPCHandler(id: string): ((...args: unknown[]) => Promise<unknown>) | undefined {
28
- return rpcHandlers.get(id)
49
+ export function getRPCHandler(id: string): RPCHandler | undefined {
50
+ return defaultRPCRegistry.getHandler(id)
29
51
  }
30
52
 
31
53
  function getCallerFile(): string | null {
@@ -52,22 +74,24 @@ export function server<TArgs extends unknown[], TReturn>(
52
74
  ): (...args: TArgs) => Promise<TReturn> {
53
75
  const callerFile = getCallerFile()
54
76
  if (callerFile) {
55
- const counter = fileCallCounters.get(callerFile) ?? 0
56
- fileCallCounters.set(callerFile, counter + 1)
77
+ const counter = defaultRPCRegistry.getFileCallCount(callerFile)
78
+ defaultRPCRegistry.setFileCallCount(callerFile, counter + 1)
57
79
  const id = hashRPC(callerFile, counter)
58
- rpcHandlers.set(id, fn as (...args: unknown[]) => Promise<unknown>)
80
+ defaultRPCRegistry.setHandler(id, fn as (...args: unknown[]) => Promise<unknown>)
59
81
  }
60
82
  return fn
61
83
  }
62
84
 
63
- // RPC HTTP handler
64
- export async function handleRPCRequest(request: Request): Promise<Response | null> {
85
+ export async function handleRPCRequestWithRegistry(
86
+ request: Request,
87
+ registry: Pick<RPCRegistry, "getHandler">,
88
+ ): Promise<Response | null> {
65
89
  const url = new URL(request.url)
66
90
  const match = url.pathname.match(/^\/api\/_rpc\/([a-zA-Z0-9]+)$/)
67
91
  if (!match) return null
68
92
 
69
93
  const id = match[1]!
70
- const handler = rpcHandlers.get(id)
94
+ const handler = registry.getHandler(id)
71
95
 
72
96
  if (!handler) {
73
97
  return new Response(JSON.stringify({ error: `RPC handler not found: ${id}` }), {
@@ -87,7 +111,28 @@ export async function handleRPCRequest(request: Request): Promise<Response | nul
87
111
  headers: { "Content-Type": "application/json" },
88
112
  })
89
113
  }
90
- const body = await request.text()
114
+ // Read body with actual size limit (Content-Length can be spoofed)
115
+ const reader = request.body?.getReader()
116
+ let bodyBytes = 0
117
+ const chunks: Uint8Array[] = []
118
+ if (reader) {
119
+ while (true) {
120
+ const { done, value } = await reader.read()
121
+ if (done) break
122
+ bodyBytes += value.byteLength
123
+ if (bodyBytes > MAX_RPC_BODY) {
124
+ reader.cancel()
125
+ return new Response(JSON.stringify({ error: "Request body too large" }), {
126
+ status: 413,
127
+ headers: { "Content-Type": "application/json" },
128
+ })
129
+ }
130
+ chunks.push(value)
131
+ }
132
+ }
133
+ const body = new TextDecoder().decode(
134
+ chunks.length === 1 ? chunks[0] : Buffer.concat(chunks),
135
+ )
91
136
  if (body) {
92
137
  let parsed: unknown
93
138
  try {
@@ -116,10 +161,17 @@ export async function handleRPCRequest(request: Request): Promise<Response | nul
116
161
  headers: { "Content-Type": "application/json" },
117
162
  })
118
163
  } catch (err) {
119
- const message = err instanceof Error ? err.message : String(err)
164
+ // Don't leak internal error details to the client
165
+ const isDev = process.env.NODE_ENV !== "production"
166
+ const message = isDev && err instanceof Error ? err.message : "Internal server error"
120
167
  return new Response(JSON.stringify({ error: message }), {
121
168
  status: 500,
122
169
  headers: { "Content-Type": "application/json" },
123
170
  })
124
171
  }
125
172
  }
173
+
174
+ // RPC HTTP handler
175
+ export async function handleRPCRequest(request: Request): Promise<Response | null> {
176
+ return handleRPCRequestWithRegistry(request, defaultRPCRegistry)
177
+ }
@@ -0,0 +1,109 @@
1
+ import { Database } from "bun:sqlite"
2
+ import type { CacheEntry, CacheStore } from "./cache.ts"
3
+
4
+ interface SQLiteCacheStoreOptions {
5
+ maxEntryAgeMs?: number
6
+ pruneExpiredOnInit?: boolean
7
+ pruneExpiredOnGet?: boolean
8
+ pruneExpiredOnSet?: boolean
9
+ pruneExpiredOnKeys?: boolean
10
+ }
11
+
12
+ function ensureCacheTable(sqlite: Database): void {
13
+ sqlite.run(`
14
+ CREATE TABLE IF NOT EXISTS gorsee_route_cache (
15
+ cache_key TEXT PRIMARY KEY,
16
+ body TEXT NOT NULL,
17
+ headers TEXT NOT NULL,
18
+ status INTEGER NOT NULL,
19
+ created_at INTEGER NOT NULL,
20
+ revalidating INTEGER NOT NULL DEFAULT 0
21
+ )
22
+ `)
23
+ }
24
+
25
+ function pruneExpiredRows(sqlite: Database, maxEntryAgeMs: number, now: number = Date.now()): number {
26
+ const cutoff = now - maxEntryAgeMs
27
+ const result = sqlite.prepare("DELETE FROM gorsee_route_cache WHERE created_at <= ?1").run(cutoff)
28
+ return (result as { changes?: number }).changes ?? 0
29
+ }
30
+
31
+ export function createSQLiteCacheStore(
32
+ path = ":memory:",
33
+ options: SQLiteCacheStoreOptions = {},
34
+ ): CacheStore & { close(): void; deleteExpired(now?: number): number } {
35
+ const sqlite = new Database(path)
36
+ sqlite.run("PRAGMA journal_mode=WAL;")
37
+ ensureCacheTable(sqlite)
38
+ const {
39
+ maxEntryAgeMs = 24 * 60 * 60 * 1000,
40
+ pruneExpiredOnInit = true,
41
+ pruneExpiredOnGet = true,
42
+ pruneExpiredOnSet = true,
43
+ pruneExpiredOnKeys = true,
44
+ } = options
45
+
46
+ if (pruneExpiredOnInit) pruneExpiredRows(sqlite, maxEntryAgeMs)
47
+
48
+ return {
49
+ async get(key) {
50
+ if (pruneExpiredOnGet) pruneExpiredRows(sqlite, maxEntryAgeMs)
51
+ const row = sqlite.prepare(`
52
+ SELECT body, headers, status, created_at, revalidating
53
+ FROM gorsee_route_cache
54
+ WHERE cache_key = ?1
55
+ `).get(key) as {
56
+ body: string
57
+ headers: string
58
+ status: number
59
+ created_at: number
60
+ revalidating: number
61
+ } | null
62
+ if (!row) return undefined
63
+ return {
64
+ body: row.body,
65
+ headers: JSON.parse(row.headers) as Record<string, string>,
66
+ status: row.status,
67
+ createdAt: row.created_at,
68
+ revalidating: row.revalidating === 1,
69
+ }
70
+ },
71
+ async set(key, entry) {
72
+ if (pruneExpiredOnSet) pruneExpiredRows(sqlite, maxEntryAgeMs)
73
+ sqlite.prepare(`
74
+ INSERT INTO gorsee_route_cache (cache_key, body, headers, status, created_at, revalidating)
75
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)
76
+ ON CONFLICT(cache_key) DO UPDATE SET
77
+ body = excluded.body,
78
+ headers = excluded.headers,
79
+ status = excluded.status,
80
+ created_at = excluded.created_at,
81
+ revalidating = excluded.revalidating
82
+ `).run(
83
+ key,
84
+ entry.body,
85
+ JSON.stringify(entry.headers),
86
+ entry.status,
87
+ entry.createdAt,
88
+ entry.revalidating ? 1 : 0,
89
+ )
90
+ },
91
+ async delete(key) {
92
+ sqlite.prepare("DELETE FROM gorsee_route_cache WHERE cache_key = ?1").run(key)
93
+ },
94
+ async clear() {
95
+ sqlite.prepare("DELETE FROM gorsee_route_cache").run()
96
+ },
97
+ async keys() {
98
+ if (pruneExpiredOnKeys) pruneExpiredRows(sqlite, maxEntryAgeMs)
99
+ const rows = sqlite.prepare("SELECT cache_key FROM gorsee_route_cache").all() as Array<{ cache_key: string }>
100
+ return rows.map((row) => row.cache_key)
101
+ },
102
+ deleteExpired(now = Date.now()) {
103
+ return pruneExpiredRows(sqlite, maxEntryAgeMs, now)
104
+ },
105
+ close() {
106
+ sqlite.close()
107
+ },
108
+ }
109
+ }
@@ -0,0 +1,63 @@
1
+ import { getMimeType } from "./mime.ts"
2
+ import { fileETag, isNotModified } from "./etag.ts"
3
+ import { resolve } from "node:path"
4
+
5
+ export interface StaticFileOptions {
6
+ contentType?: string
7
+ cacheControl?: string
8
+ request?: Request
9
+ etag?: boolean
10
+ extraHeaders?: Record<string, string>
11
+ }
12
+
13
+ export async function serveStaticFile(
14
+ rootDir: string,
15
+ relativePath: string,
16
+ options: StaticFileOptions = {},
17
+ ): Promise<Response | null> {
18
+ const {
19
+ contentType,
20
+ cacheControl,
21
+ request,
22
+ etag = false,
23
+ extraHeaders = {},
24
+ } = options
25
+
26
+ try {
27
+ const filePath = resolve(rootDir, relativePath)
28
+ if (!filePath.startsWith(rootDir)) return null
29
+
30
+ const file = Bun.file(filePath)
31
+ if (!await file.exists()) return null
32
+
33
+ const headers: Record<string, string> = {
34
+ "Content-Type": contentType ?? getMimeType(filePath),
35
+ ...extraHeaders,
36
+ }
37
+
38
+ if (cacheControl) headers["Cache-Control"] = cacheControl
39
+
40
+ if (etag) {
41
+ const tag = await fileETag(filePath)
42
+ if (tag && request && isNotModified(request, tag)) {
43
+ return new Response(null, { status: 304, headers: extraHeaders })
44
+ }
45
+ if (tag) headers["ETag"] = tag
46
+ }
47
+
48
+ return new Response(file, { headers })
49
+ } catch {
50
+ return null
51
+ }
52
+ }
53
+
54
+ export async function servePrefixedStaticFile(
55
+ pathname: string,
56
+ prefix: string,
57
+ rootDir: string,
58
+ options: StaticFileOptions = {},
59
+ ): Promise<Response | null> {
60
+ if (!pathname.startsWith(prefix)) return null
61
+ const relativePath = pathname.slice(prefix.length)
62
+ return serveStaticFile(rootDir, relativePath, options)
63
+ }
@@ -0,0 +1,36 @@
1
+ // Explicit server-facing public entrypoint.
2
+ // Use this in loaders, middleware, API routes, and server services.
3
+
4
+ export * from "./server/index.ts"
5
+ export {
6
+ createAuth,
7
+ createMemorySessionStore,
8
+ createNamespacedSessionStore,
9
+ createRedisSessionStore,
10
+ createSQLiteSessionStore,
11
+ type AuthConfig,
12
+ type Session,
13
+ type SessionStore,
14
+ } from "./auth/index.ts"
15
+ export { createDB, type DB, runMigrations, createMigration, type MigrationResult } from "./db/index.ts"
16
+ export {
17
+ securityHeaders,
18
+ type SecurityConfig,
19
+ csrfProtection,
20
+ generateCSRFToken,
21
+ validateCSRFToken,
22
+ createRateLimiter,
23
+ type RateLimiter,
24
+ cors,
25
+ type CORSOptions,
26
+ } from "./security/index.ts"
27
+ export { env, getPublicEnv, loadEnv } from "./env/index.ts"
28
+ export { log, setLogLevel } from "./log/index.ts"
29
+ export {
30
+ createNodeRedisLikeClient,
31
+ createIORedisLikeClient,
32
+ deleteExpiredRedisKeys,
33
+ type RedisLikeClient,
34
+ type NodeRedisClientLike,
35
+ type IORedisClientLike,
36
+ } from "./server/redis-client.ts"