gorsee 0.2.1 → 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.
- package/README.md +132 -4
- package/package.json +4 -2
- package/src/auth/index.ts +48 -17
- package/src/auth/redis-session-store.ts +46 -0
- package/src/auth/sqlite-session-store.ts +98 -0
- package/src/auth/store-utils.ts +21 -0
- package/src/build/client.ts +25 -7
- package/src/build/manifest.ts +34 -0
- package/src/build/route-metadata.ts +12 -0
- package/src/build/ssg.ts +19 -49
- package/src/cli/bun-plugin.ts +23 -2
- package/src/cli/cmd-build.ts +42 -71
- package/src/cli/cmd-check.ts +40 -26
- package/src/cli/cmd-create.ts +20 -5
- package/src/cli/cmd-deploy.ts +10 -2
- package/src/cli/cmd-dev.ts +9 -9
- package/src/cli/cmd-docs.ts +11 -4
- package/src/cli/cmd-generate.ts +15 -7
- package/src/cli/cmd-migrate.ts +15 -7
- package/src/cli/cmd-routes.ts +12 -5
- package/src/cli/cmd-start.ts +14 -5
- package/src/cli/cmd-test.ts +11 -3
- package/src/cli/cmd-typegen.ts +13 -3
- package/src/cli/cmd-upgrade.ts +10 -2
- package/src/cli/context.ts +12 -0
- package/src/cli/framework-md.ts +43 -16
- package/src/client.ts +26 -0
- package/src/dev/partial-handler.ts +17 -74
- package/src/dev/request-handler.ts +36 -67
- package/src/dev.ts +92 -157
- package/src/index-client.ts +4 -0
- package/src/index.ts +17 -2
- package/src/prod.ts +195 -253
- package/src/runtime/project.ts +73 -0
- package/src/server/cache-utils.ts +23 -0
- package/src/server/cache.ts +37 -14
- package/src/server/html-shell.ts +69 -0
- package/src/server/index.ts +40 -2
- package/src/server/manifest.ts +36 -0
- package/src/server/middleware.ts +18 -2
- package/src/server/not-found.ts +35 -0
- package/src/server/page-render.ts +123 -0
- package/src/server/redis-cache-store.ts +87 -0
- package/src/server/redis-client.ts +71 -0
- package/src/server/request-preflight.ts +45 -0
- package/src/server/route-request.ts +72 -0
- package/src/server/rpc-utils.ts +27 -0
- package/src/server/rpc.ts +70 -18
- package/src/server/sqlite-cache-store.ts +109 -0
- package/src/server/static-file.ts +63 -0
- 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/
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gorsee",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
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",
|
|
@@ -26,8 +26,10 @@
|
|
|
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
|
|
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",
|
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
|
-
|
|
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
|
|
57
|
-
if (expected
|
|
58
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
116
|
+
const cookie = ctx.cookies.get(cfg.cookieName)
|
|
88
117
|
if (cookie) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const session =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/build/client.ts
CHANGED
|
@@ -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
|
|
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 "
|
|
30
|
-
import { hydrate } from "${
|
|
31
|
-
import { initRouter } from "${
|
|
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
|
+
}
|