gorsee 0.2.1 → 0.2.3

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 (51) hide show
  1. package/README.md +132 -4
  2. package/package.json +7 -4
  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 +11 -4
  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 +11 -3
  23. package/src/cli/cmd-typegen.ts +13 -3
  24. package/src/cli/cmd-upgrade.ts +10 -2
  25. package/src/cli/context.ts +12 -0
  26. package/src/cli/framework-md.ts +43 -16
  27. package/src/client.ts +26 -0
  28. package/src/dev/partial-handler.ts +17 -74
  29. package/src/dev/request-handler.ts +36 -67
  30. package/src/dev.ts +92 -157
  31. package/src/index-client.ts +4 -0
  32. package/src/index.ts +17 -2
  33. package/src/prod.ts +195 -253
  34. package/src/runtime/project.ts +73 -0
  35. package/src/server/cache-utils.ts +23 -0
  36. package/src/server/cache.ts +37 -14
  37. package/src/server/html-shell.ts +69 -0
  38. package/src/server/index.ts +40 -2
  39. package/src/server/manifest.ts +36 -0
  40. package/src/server/middleware.ts +18 -2
  41. package/src/server/not-found.ts +35 -0
  42. package/src/server/page-render.ts +123 -0
  43. package/src/server/redis-cache-store.ts +87 -0
  44. package/src/server/redis-client.ts +71 -0
  45. package/src/server/request-preflight.ts +45 -0
  46. package/src/server/route-request.ts +72 -0
  47. package/src/server/rpc-utils.ts +27 -0
  48. package/src/server/rpc.ts +70 -18
  49. package/src/server/sqlite-cache-store.ts +109 -0
  50. package/src/server/static-file.ts +63 -0
  51. package/src/server-entry.ts +36 -0
package/README.md CHANGED
@@ -36,7 +36,7 @@ Open [http://localhost:3000](http://localhost:3000).
36
36
  ### Islands — partial hydration
37
37
 
38
38
  ```tsx
39
- import { island, createSignal } from "gorsee"
39
+ import { island, createSignal } from "gorsee/client"
40
40
 
41
41
  // Only THIS component gets JavaScript. Rest of the page = zero JS.
42
42
  export default island(function LikeButton({ postId }) {
@@ -48,7 +48,7 @@ export default island(function LikeButton({ postId }) {
48
48
  ### Reactive WebSocket
49
49
 
50
50
  ```tsx
51
- import { createLive } from "gorsee"
51
+ import { createLive } from "gorsee/client"
52
52
 
53
53
  function StockPrice() {
54
54
  const { value: price, connected } = createLive({
@@ -63,7 +63,7 @@ function StockPrice() {
63
63
  ### Optimistic Mutations
64
64
 
65
65
  ```tsx
66
- import { createSignal, createMutation } from "gorsee"
66
+ import { createSignal, createMutation } from "gorsee/client"
67
67
 
68
68
  const [todos, setTodos] = createSignal(["Buy milk"])
69
69
  const addTodo = createMutation({
@@ -77,7 +77,7 @@ await addTodo.optimistic(todos, setTodos, (list, text) => [...list, text], "New
77
77
  ### Built-in Auth
78
78
 
79
79
  ```tsx
80
- import { createAuth } from "gorsee/auth"
80
+ import { createAuth } from "gorsee/server"
81
81
 
82
82
  const auth = createAuth({ secret: process.env.SESSION_SECRET })
83
83
 
@@ -130,6 +130,134 @@ export default pipe(
130
130
  | `gorsee typegen` | Generate typed routes |
131
131
  | `gorsee migrate` | Run DB migrations |
132
132
 
133
+ ## Recommended Imports
134
+
135
+ ```tsx
136
+ // Route components, islands, navigation, forms, browser-safe reactivity
137
+ import { createSignal, island, Link, Head } from "gorsee/client"
138
+
139
+ // Middleware, loaders, auth, db, security, logging
140
+ import { createAuth, createDB, cors, log } from "gorsee/server"
141
+ ```
142
+
143
+ - Use `gorsee/client` for route components and anything that must stay browser-safe.
144
+ - Use `gorsee/server` for loaders, middleware, API routes, auth, db, security, env, and logging.
145
+ - Keep root `gorsee` only for backward compatibility. New code should not depend on it.
146
+ - If you need explicit legacy semantics during migration, use `gorsee/compat`.
147
+
148
+ ## Adapter Utilities
149
+
150
+ ```tsx
151
+ import {
152
+ createAuth,
153
+ createMemorySessionStore,
154
+ createNamespacedSessionStore,
155
+ createRedisSessionStore,
156
+ createSQLiteSessionStore,
157
+ createMemoryCacheStore,
158
+ createNamespacedCacheStore,
159
+ createRedisCacheStore,
160
+ createSQLiteCacheStore,
161
+ createMemoryRPCRegistry,
162
+ createScopedRPCRegistry,
163
+ } from "gorsee/server"
164
+
165
+ const sharedSessions = createMemorySessionStore()
166
+ const tenantSessions = createNamespacedSessionStore(sharedSessions, "tenant-a")
167
+ const auth = createAuth({ secret: process.env.SESSION_SECRET!, store: tenantSessions })
168
+
169
+ const sharedCache = createMemoryCacheStore()
170
+ const tenantCache = createNamespacedCacheStore(sharedCache, "tenant-a")
171
+
172
+ const sessionStore = createSQLiteSessionStore("./data/auth.sqlite")
173
+ const cacheStore = createSQLiteCacheStore("./data/cache.sqlite")
174
+
175
+ const redisSessionStore = createRedisSessionStore(redisClient, { prefix: "gorsee:sessions" })
176
+ const redisCacheStore = createRedisCacheStore(redisClient, { prefix: "gorsee:cache" })
177
+
178
+ const sharedRPC = createMemoryRPCRegistry()
179
+ const tenantRPC = createScopedRPCRegistry(sharedRPC, "tenant-a")
180
+ ```
181
+
182
+ - `createNamespacedSessionStore` isolates auth sessions over one shared backing store.
183
+ - `createNamespacedCacheStore` isolates route cache keys per app, tenant, or test worker.
184
+ - `createSQLiteSessionStore` and `createSQLiteCacheStore` provide persistent local adapters without adding external infrastructure.
185
+ - `createSQLiteSessionStore` prunes expired rows automatically on hot paths and also exposes `deleteExpired()` for explicit maintenance.
186
+ - `createSQLiteCacheStore` supports retention policy via `maxEntryAgeMs` and also exposes `deleteExpired()` for explicit cache pruning.
187
+ - `createRedisSessionStore` and `createRedisCacheStore` work with any client that supports `get/set/del/keys`, so you can plug in `redis` or `ioredis` without framework lock-in.
188
+ - RPC handlers remain intentionally process-local. The stable extension point is injected request-time registry resolution, not pretending closures can live in Redis.
189
+ - `createScopedRPCRegistry` isolates RPC registrations without forking the entire server runtime.
190
+
191
+ ## Redis Recipes
192
+
193
+ ### node-redis
194
+
195
+ ```tsx
196
+ import { createClient } from "redis"
197
+ import {
198
+ createAuth,
199
+ createRedisSessionStore,
200
+ createRedisCacheStore,
201
+ createNodeRedisLikeClient,
202
+ routeCache,
203
+ } from "gorsee/server"
204
+
205
+ const redis = createClient({ url: process.env.REDIS_URL })
206
+ await redis.connect()
207
+ const redisClient = createNodeRedisLikeClient(redis)
208
+
209
+ const auth = createAuth({
210
+ secret: process.env.SESSION_SECRET!,
211
+ store: createRedisSessionStore(redisClient, { prefix: "app:sessions" }),
212
+ })
213
+
214
+ export const cache = routeCache({
215
+ maxAge: 60,
216
+ staleWhileRevalidate: 300,
217
+ store: createRedisCacheStore(redisClient, {
218
+ prefix: "app:cache",
219
+ maxEntryAgeMs: 360_000,
220
+ }),
221
+ })
222
+ ```
223
+
224
+ ### ioredis
225
+
226
+ ```tsx
227
+ import Redis from "ioredis"
228
+ import {
229
+ createAuth,
230
+ createRedisSessionStore,
231
+ createRedisCacheStore,
232
+ createIORedisLikeClient,
233
+ routeCache,
234
+ } from "gorsee/server"
235
+
236
+ const redis = new Redis(process.env.REDIS_URL!)
237
+ const redisClient = createIORedisLikeClient(redis)
238
+
239
+ const auth = createAuth({
240
+ secret: process.env.SESSION_SECRET!,
241
+ store: createRedisSessionStore(redisClient, { prefix: "app:sessions" }),
242
+ })
243
+
244
+ export const cache = routeCache({
245
+ maxAge: 60,
246
+ staleWhileRevalidate: 300,
247
+ store: createRedisCacheStore(redisClient, {
248
+ prefix: "app:cache",
249
+ maxEntryAgeMs: 360_000,
250
+ }),
251
+ })
252
+ ```
253
+
254
+ ### Notes
255
+
256
+ - For multi-instance deployments, keep the same Redis prefixes across all app replicas.
257
+ - Session expiry is enforced by store TTL and validated again by `createAuth()`.
258
+ - Cache retention is controlled by `maxEntryAgeMs`; `routeCache()` still decides HIT/STALE/MISS at request time.
259
+ - `createNodeRedisLikeClient()` and `createIORedisLikeClient()` let you pass real Redis SDK clients without writing framework-specific glue.
260
+
133
261
  ## Requirements
134
262
 
135
263
  - [Bun](https://bun.sh) >= 1.0
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "gorsee",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Full-stack TypeScript framework — islands, reactive WebSocket, optimistic mutations, built-in auth, type-safe routes",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Oleg Gorsky",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "https://github.com/AYKGorsee/gorsee-js"
10
+ "url": "git+https://github.com/AYKGorsee/gorsee-js.git"
11
11
  },
12
12
  "homepage": "https://github.com/AYKGorsee/gorsee-js#readme",
13
13
  "keywords": [
@@ -22,12 +22,14 @@
22
22
  "web-framework"
23
23
  ],
24
24
  "bin": {
25
- "gorsee": "./bin/gorsee.js"
25
+ "gorsee": "bin/gorsee.js"
26
26
  },
27
27
  "exports": {
28
28
  ".": "./src/index.ts",
29
+ "./compat": "./src/index.ts",
30
+ "./client": "./src/client.ts",
29
31
  "./reactive": "./src/reactive/index.ts",
30
- "./server": "./src/server/index.ts",
32
+ "./server": "./src/server-entry.ts",
31
33
  "./types": "./src/types/index.ts",
32
34
  "./db": "./src/db/index.ts",
33
35
  "./router": "./src/router/index.ts",
@@ -63,6 +65,7 @@
63
65
  "test": "bun test",
64
66
  "check": "tsc --noEmit",
65
67
  "dev": "bun run src/dev.ts",
68
+ "release:check": "node scripts/release-check.mjs",
66
69
  "prepublishOnly": "bun run check"
67
70
  },
68
71
  "engines": {
package/src/auth/index.ts CHANGED
@@ -1,12 +1,17 @@
1
1
  // Built-in session-based auth with HMAC-signed cookies
2
2
 
3
+ import { timingSafeEqual } from "node:crypto"
3
4
  import type { Context, MiddlewareFn } from "../server/middleware.ts"
5
+ export { createNamespacedSessionStore } from "./store-utils.ts"
6
+ export { createRedisSessionStore } from "./redis-session-store.ts"
7
+ export { createSQLiteSessionStore } from "./sqlite-session-store.ts"
4
8
 
5
9
  export interface AuthConfig {
6
10
  secret: string
7
11
  cookieName?: string
8
12
  maxAge?: number
9
13
  loginPath?: string
14
+ store?: SessionStore
10
15
  }
11
16
 
12
17
  export interface Session {
@@ -16,7 +21,26 @@ export interface Session {
16
21
  expiresAt: number
17
22
  }
18
23
 
19
- const sessions = new Map<string, Session>()
24
+ type Awaitable<T> = T | Promise<T>
25
+
26
+ export interface SessionStore {
27
+ get(id: string): Awaitable<Session | undefined>
28
+ set(id: string, session: Session): Awaitable<void>
29
+ delete(id: string): Awaitable<void>
30
+ entries(): Awaitable<Iterable<[string, Session]> | AsyncIterable<[string, Session]>>
31
+ }
32
+
33
+ export function createMemorySessionStore(): SessionStore {
34
+ const sessions = new Map<string, Session>()
35
+ return {
36
+ get: (id) => sessions.get(id),
37
+ set: (id, session) => { sessions.set(id, session) },
38
+ delete: (id) => { sessions.delete(id) },
39
+ entries: () => sessions.entries(),
40
+ }
41
+ }
42
+
43
+ const defaultSessionStore = createMemorySessionStore()
20
44
 
21
45
  let cachedKey: CryptoKey | null = null
22
46
  let cachedSecret = ""
@@ -53,15 +77,19 @@ async function verify(
53
77
  if (dotIndex === -1) return null
54
78
  const value = signed.slice(0, dotIndex)
55
79
  const expected = await sign(value, secret)
56
- // Constant-time-ish comparison via re-signing
57
- if (expected === signed) return value
58
- return null
80
+ // Constant-time comparison to prevent timing attacks
81
+ if (expected.length !== signed.length) return null
82
+ const a = Buffer.from(expected)
83
+ const b = Buffer.from(signed)
84
+ if (!timingSafeEqual(a, b)) return null
85
+ return value
59
86
  }
60
87
 
61
- function pruneExpired(): void {
88
+ async function pruneExpired(store: SessionStore): Promise<void> {
62
89
  const now = Date.now()
63
- for (const [id, session] of sessions) {
64
- if (session.expiresAt <= now) sessions.delete(id)
90
+ const entries = await store.entries()
91
+ for await (const [id, session] of entries) {
92
+ if (session.expiresAt <= now) await store.delete(id)
65
93
  }
66
94
  }
67
95
 
@@ -71,6 +99,7 @@ function resolveConfig(config: AuthConfig) {
71
99
  cookieName: config.cookieName ?? "gorsee_session",
72
100
  maxAge: config.maxAge ?? 86400,
73
101
  loginPath: config.loginPath ?? "/login",
102
+ store: config.store ?? defaultSessionStore,
74
103
  }
75
104
  }
76
105
 
@@ -78,21 +107,21 @@ export function createAuth(config: AuthConfig): {
78
107
  middleware: MiddlewareFn
79
108
  requireAuth: MiddlewareFn
80
109
  login: (ctx: Context, userId: string, data?: Record<string, unknown>) => Promise<void>
81
- logout: (ctx: Context) => void
110
+ logout: (ctx: Context) => Promise<void>
82
111
  getSession: (ctx: Context) => Session | null
83
112
  } {
84
113
  const cfg = resolveConfig(config)
85
114
 
86
115
  const middleware: MiddlewareFn = async (ctx, next) => {
87
- const cookie = ctx.cookies.get(cfg.cookieName)
116
+ const cookie = ctx.cookies.get(cfg.cookieName)
88
117
  if (cookie) {
89
- const sessionId = await verify(cookie, cfg.secret)
90
- if (sessionId) {
91
- const session = sessions.get(sessionId)
118
+ const sessionId = await verify(cookie, cfg.secret)
119
+ if (sessionId) {
120
+ const session = await cfg.store.get(sessionId)
92
121
  if (session && session.expiresAt > Date.now()) {
93
122
  ctx.locals.session = session
94
123
  } else if (session) {
95
- sessions.delete(sessionId)
124
+ await cfg.store.delete(sessionId)
96
125
  }
97
126
  }
98
127
  }
@@ -111,7 +140,7 @@ export function createAuth(config: AuthConfig): {
111
140
  userId: string,
112
141
  data: Record<string, unknown> = {},
113
142
  ): Promise<void> {
114
- pruneExpired()
143
+ await pruneExpired(cfg.store)
115
144
  const id = crypto.randomUUID()
116
145
  const session: Session = {
117
146
  id,
@@ -119,21 +148,23 @@ export function createAuth(config: AuthConfig): {
119
148
  data,
120
149
  expiresAt: Date.now() + cfg.maxAge * 1000,
121
150
  }
122
- sessions.set(id, session)
151
+ await cfg.store.set(id, session)
123
152
  ctx.locals.session = session
124
153
  const signed = await sign(id, cfg.secret)
154
+ const isProduction = process.env.NODE_ENV === "production"
125
155
  ctx.setCookie(cfg.cookieName, signed, {
126
156
  maxAge: cfg.maxAge,
127
157
  httpOnly: true,
158
+ secure: isProduction,
128
159
  sameSite: "Lax",
129
160
  path: "/",
130
161
  })
131
162
  }
132
163
 
133
- function logout(ctx: Context): void {
164
+ async function logout(ctx: Context): Promise<void> {
134
165
  const session = ctx.locals.session as Session | undefined
135
166
  if (session) {
136
- sessions.delete(session.id)
167
+ await cfg.store.delete(session.id)
137
168
  ctx.locals.session = undefined
138
169
  }
139
170
  ctx.deleteCookie(cfg.cookieName)
@@ -0,0 +1,46 @@
1
+ import type { Session, SessionStore } from "./index.ts"
2
+ import { buildRedisKey, stripRedisPrefix, type RedisLikeClient } from "../server/redis-client.ts"
3
+
4
+ interface RedisSessionStoreOptions {
5
+ prefix?: string
6
+ }
7
+
8
+ interface StoredSessionPayload {
9
+ session: Session
10
+ }
11
+
12
+ export function createRedisSessionStore(
13
+ client: RedisLikeClient,
14
+ options: RedisSessionStoreOptions = {},
15
+ ): SessionStore {
16
+ const prefix = options.prefix ?? "gorsee:session"
17
+
18
+ return {
19
+ async get(id) {
20
+ const raw = await client.get(buildRedisKey(prefix, id))
21
+ if (!raw) return undefined
22
+ const payload = JSON.parse(raw) as StoredSessionPayload
23
+ return payload.session
24
+ },
25
+ async set(id, session) {
26
+ const key = buildRedisKey(prefix, id)
27
+ await client.set(key, JSON.stringify({ session } satisfies StoredSessionPayload))
28
+ const ttlSeconds = Math.max(1, Math.ceil((session.expiresAt - Date.now()) / 1000))
29
+ if (client.expire) await client.expire(key, ttlSeconds)
30
+ },
31
+ async delete(id) {
32
+ await client.del(buildRedisKey(prefix, id))
33
+ },
34
+ async entries() {
35
+ const keys = await client.keys(`${prefix}:*`)
36
+ const sessions: Array<[string, Session]> = []
37
+ for (const key of keys) {
38
+ const raw = await client.get(key)
39
+ if (!raw) continue
40
+ const payload = JSON.parse(raw) as StoredSessionPayload
41
+ sessions.push([stripRedisPrefix(prefix, key), payload.session])
42
+ }
43
+ return sessions
44
+ },
45
+ }
46
+ }
@@ -0,0 +1,98 @@
1
+ import { Database } from "bun:sqlite"
2
+ import type { Session, SessionStore } from "./index.ts"
3
+
4
+ interface SQLiteSessionStoreOptions {
5
+ pruneExpiredOnInit?: boolean
6
+ pruneExpiredOnGet?: boolean
7
+ pruneExpiredOnSet?: boolean
8
+ pruneExpiredOnEntries?: boolean
9
+ }
10
+
11
+ function ensureSessionTable(sqlite: Database): void {
12
+ sqlite.run(`
13
+ CREATE TABLE IF NOT EXISTS gorsee_sessions (
14
+ id TEXT PRIMARY KEY,
15
+ payload TEXT NOT NULL,
16
+ expires_at INTEGER NOT NULL
17
+ )
18
+ `)
19
+ sqlite.run("CREATE INDEX IF NOT EXISTS idx_gorsee_sessions_expires_at ON gorsee_sessions (expires_at)")
20
+ }
21
+
22
+ function pruneExpiredRows(sqlite: Database, now: number = Date.now()): number {
23
+ const result = sqlite.prepare("DELETE FROM gorsee_sessions WHERE expires_at <= ?1").run(now)
24
+ return (result as { changes?: number }).changes ?? 0
25
+ }
26
+
27
+ export function createSQLiteSessionStore(
28
+ path = ":memory:",
29
+ options: SQLiteSessionStoreOptions = {},
30
+ ): SessionStore & { close(): void; deleteExpired(now?: number): number } {
31
+ const sqlite = new Database(path)
32
+ sqlite.run("PRAGMA journal_mode=WAL;")
33
+ ensureSessionTable(sqlite)
34
+ const {
35
+ pruneExpiredOnInit = true,
36
+ pruneExpiredOnGet = true,
37
+ pruneExpiredOnSet = true,
38
+ pruneExpiredOnEntries = true,
39
+ } = options
40
+
41
+ if (pruneExpiredOnInit) pruneExpiredRows(sqlite)
42
+
43
+ return {
44
+ async get(id) {
45
+ if (pruneExpiredOnGet) pruneExpiredRows(sqlite)
46
+ const row = sqlite
47
+ .prepare("SELECT payload, expires_at FROM gorsee_sessions WHERE id = ?1")
48
+ .get(id) as { payload: string; expires_at: number } | null
49
+ if (!row) return undefined
50
+ return {
51
+ ...(JSON.parse(row.payload) as Omit<Session, "expiresAt">),
52
+ expiresAt: row.expires_at,
53
+ }
54
+ },
55
+ async set(id, session) {
56
+ if (pruneExpiredOnSet) pruneExpiredRows(sqlite)
57
+ sqlite.prepare(`
58
+ INSERT INTO gorsee_sessions (id, payload, expires_at)
59
+ VALUES (?1, ?2, ?3)
60
+ ON CONFLICT(id) DO UPDATE SET
61
+ payload = excluded.payload,
62
+ expires_at = excluded.expires_at
63
+ `).run(
64
+ id,
65
+ JSON.stringify({
66
+ id: session.id,
67
+ userId: session.userId,
68
+ data: session.data,
69
+ }),
70
+ session.expiresAt,
71
+ )
72
+ },
73
+ async delete(id) {
74
+ sqlite.prepare("DELETE FROM gorsee_sessions WHERE id = ?1").run(id)
75
+ },
76
+ async entries() {
77
+ if (pruneExpiredOnEntries) pruneExpiredRows(sqlite)
78
+ const rows = sqlite.prepare("SELECT id, payload, expires_at FROM gorsee_sessions").all() as Array<{
79
+ id: string
80
+ payload: string
81
+ expires_at: number
82
+ }>
83
+ return rows.map((row) => [
84
+ row.id,
85
+ {
86
+ ...(JSON.parse(row.payload) as Omit<Session, "expiresAt">),
87
+ expiresAt: row.expires_at,
88
+ },
89
+ ] as [string, Session])
90
+ },
91
+ deleteExpired(now = Date.now()) {
92
+ return pruneExpiredRows(sqlite, now)
93
+ },
94
+ close() {
95
+ sqlite.close()
96
+ },
97
+ }
98
+ }
@@ -0,0 +1,21 @@
1
+ import type { Session, SessionStore } from "./index.ts"
2
+
3
+ export function createNamespacedSessionStore(store: SessionStore, namespace: string): SessionStore {
4
+ const prefix = `${namespace}:`
5
+ return {
6
+ get: (id) => store.get(prefix + id),
7
+ set: async (id, session) => {
8
+ await store.set(prefix + id, { ...session, id })
9
+ },
10
+ delete: async (id) => {
11
+ await store.delete(prefix + id)
12
+ },
13
+ entries: async function* () {
14
+ const entries = await store.entries()
15
+ for await (const [id, session] of entries) {
16
+ if (!id.startsWith(prefix)) continue
17
+ yield [id.slice(prefix.length), { ...(session as Session), id: id.slice(prefix.length) }] as [string, Session]
18
+ }
19
+ },
20
+ }
21
+ }
@@ -1,6 +1,6 @@
1
1
  // Client bundle builder -- uses Bun.build() to create browser-ready JS per route
2
2
 
3
- import { join, resolve, relative } from "node:path"
3
+ import { join, resolve, relative, dirname } from "node:path"
4
4
  import { mkdir, rm } from "node:fs/promises"
5
5
  import { serverStripPlugin } from "./server-strip.ts"
6
6
  import { cssModulesPlugin, getCollectedCSS, resetCollectedCSS } from "./css-modules.ts"
@@ -10,7 +10,8 @@ const FRAMEWORK_ROOT = resolve(import.meta.dir, "..")
10
10
  const CLIENT_JSX_RUNTIME = resolve(FRAMEWORK_ROOT, "jsx-runtime-client.ts")
11
11
 
12
12
  const GORSEE_CLIENT_RESOLVE: Record<string, string> = {
13
- "gorsee": resolve(FRAMEWORK_ROOT, "index.ts"),
13
+ "gorsee": resolve(FRAMEWORK_ROOT, "index-client.ts"),
14
+ "gorsee/client": resolve(FRAMEWORK_ROOT, "client.ts"),
14
15
  "gorsee/reactive": resolve(FRAMEWORK_ROOT, "reactive/index.ts"),
15
16
  "gorsee/types": resolve(FRAMEWORK_ROOT, "types/index.ts"),
16
17
  "gorsee/runtime": resolve(FRAMEWORK_ROOT, "runtime/index.ts"),
@@ -24,11 +25,19 @@ function routeToEntryName(route: Route, cwd: string): string {
24
25
  return rel.replace(/\.(tsx?|jsx?)$/, "").replace(/[\[\]]/g, "_")
25
26
  }
26
27
 
27
- function generateEntryCode(routeFile: string, hydrateImport: string, routerImport: string): string {
28
+ function toImportSpecifier(fromFile: string, toFile: string): string {
29
+ const rel = relative(dirname(fromFile), toFile).replace(/\\/g, "/")
30
+ return rel.startsWith(".") ? rel : `./${rel}`
31
+ }
32
+
33
+ function generateEntryCode(routeFile: string, entryPath: string, hydrateImport: string, routerImport: string): string {
34
+ const hydrateSpecifier = toImportSpecifier(entryPath, hydrateImport)
35
+ const routerSpecifier = toImportSpecifier(entryPath, routerImport)
36
+
28
37
  return `
29
- import Component from "${routeFile}";
30
- import { hydrate } from "${hydrateImport}";
31
- import { initRouter } from "${routerImport}";
38
+ import Component from "gorsee:route:${routeFile}";
39
+ import { hydrate } from "${hydrateSpecifier}";
40
+ import { initRouter } from "${routerSpecifier}";
32
41
  var container = document.getElementById("app");
33
42
  var dataEl = document.getElementById("__GORSEE_DATA__");
34
43
  var data = dataEl ? JSON.parse(dataEl.textContent) : {};
@@ -74,7 +83,7 @@ export async function buildClientBundles(
74
83
  const entryPath = join(entryDir, `${name}.ts`)
75
84
  const clientModule = resolve(FRAMEWORK_ROOT, "runtime/client.ts")
76
85
  const routerModule = resolve(FRAMEWORK_ROOT, "runtime/router.ts")
77
- const code = generateEntryCode(route.filePath, clientModule, routerModule)
86
+ const code = generateEntryCode(route.filePath, entryPath, clientModule, routerModule)
78
87
  await Bun.write(entryPath, code)
79
88
  entrypoints.push(entryPath)
80
89
  entryMap.set(route.path, `${name}.js`)
@@ -88,10 +97,19 @@ export async function buildClientBundles(
88
97
  minify: options?.minify ?? false,
89
98
  sourcemap: options?.sourcemap ? "external" : "none",
90
99
  splitting: true,
100
+ jsx: {
101
+ runtime: "automatic",
102
+ importSource: "gorsee",
103
+ development: true,
104
+ },
91
105
  plugins: [
92
106
  {
93
107
  name: "gorsee-client-resolve",
94
108
  setup(build) {
109
+ build.onResolve({ filter: /^gorsee:route:/ }, (args) => ({
110
+ path: args.path.slice("gorsee:route:".length),
111
+ }))
112
+
95
113
  build.onResolve({ filter: /^gorsee(\/.*)?$/ }, (args) => {
96
114
  const mapped = GORSEE_CLIENT_RESOLVE[args.path]
97
115
  if (mapped) return { path: mapped }
@@ -0,0 +1,34 @@
1
+ import type { Route } from "../router/scanner.ts"
2
+ import { inspectRouteBuildMetadata } from "./route-metadata.ts"
3
+ import type { BuildManifest } from "../server/manifest.ts"
4
+
5
+ export async function createBuildManifest(
6
+ routes: Route[],
7
+ entryMap: Map<string, string>,
8
+ hashMap: Map<string, string>,
9
+ prerenderedPaths: Iterable<string> = [],
10
+ ): Promise<BuildManifest> {
11
+ const prerendered = new Set(prerenderedPaths)
12
+ const manifest: BuildManifest = {
13
+ routes: {},
14
+ chunks: [],
15
+ prerendered: [...prerendered],
16
+ buildTime: new Date().toISOString(),
17
+ }
18
+
19
+ for (const route of routes) {
20
+ const metadata = await inspectRouteBuildMetadata(route)
21
+ const jsRel = entryMap.get(route.path)
22
+ manifest.routes[route.path] = {
23
+ js: jsRel ? hashMap.get(jsRel) : undefined,
24
+ hasLoader: metadata.hasLoader,
25
+ prerendered: prerendered.has(route.path) || undefined,
26
+ }
27
+ }
28
+
29
+ for (const hashed of hashMap.values()) {
30
+ if (hashed.includes("chunk-")) manifest.chunks.push(hashed)
31
+ }
32
+
33
+ return manifest
34
+ }
@@ -0,0 +1,12 @@
1
+ import type { Route } from "../router/scanner.ts"
2
+
3
+ export interface RouteBuildMetadata {
4
+ hasLoader: boolean
5
+ }
6
+
7
+ export async function inspectRouteBuildMetadata(route: Route): Promise<RouteBuildMetadata> {
8
+ const source = await Bun.file(route.filePath).text()
9
+ return {
10
+ hasLoader: /export\s+(async\s+)?function\s+loader\b/.test(source),
11
+ }
12
+ }