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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Oleg Gorsky
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Gorsee.js
|
|
2
|
+
|
|
3
|
+
Full-stack TypeScript framework with features no one else has.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bunx gorsee create my-app
|
|
9
|
+
cd my-app
|
|
10
|
+
bun install
|
|
11
|
+
bun run dev
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Open [http://localhost:3000](http://localhost:3000).
|
|
15
|
+
|
|
16
|
+
## Why Gorsee.js?
|
|
17
|
+
|
|
18
|
+
| Feature | Gorsee.js | Next.js | Nuxt | SvelteKit |
|
|
19
|
+
|---------|:---------:|:-------:|:----:|:---------:|
|
|
20
|
+
| Islands (partial hydration) | ✅ | ❌ | ❌ | ❌ |
|
|
21
|
+
| Reactive WebSocket signals | ✅ | ❌ | ❌ | ❌ |
|
|
22
|
+
| Optimistic mutations + rollback | ✅ | ❌ | ❌ | ❌ |
|
|
23
|
+
| Server-Sent Events (reactive) | ✅ | manual | manual | manual |
|
|
24
|
+
| Route-level cache (SWR) | ✅ | ISR only | partial | ❌ |
|
|
25
|
+
| Built-in auth (HMAC sessions) | ✅ | manual | manual | manual |
|
|
26
|
+
| Guards (declarative ACL) | ✅ | manual | partial | ❌ |
|
|
27
|
+
| Middleware pipe/compose | ✅ | ❌ | ❌ | ❌ |
|
|
28
|
+
| Validated forms (client+server) | ✅ | ❌ | ❌ | ❌ |
|
|
29
|
+
| Type-safe routes (generated) | ✅ | experimental | partial | ❌ |
|
|
30
|
+
| Branded types (SafeSQL/HTML/URL) | ✅ | ❌ | ❌ | ❌ |
|
|
31
|
+
|
|
32
|
+
**Zero-cost architecture** — every feature is a separate module. Don't use it? It's not in your bundle. Base client bundle: ~2-3 KB per route.
|
|
33
|
+
|
|
34
|
+
## Features
|
|
35
|
+
|
|
36
|
+
### Islands — partial hydration
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
import { island, createSignal } from "gorsee"
|
|
40
|
+
|
|
41
|
+
// Only THIS component gets JavaScript. Rest of the page = zero JS.
|
|
42
|
+
export default island(function LikeButton({ postId }) {
|
|
43
|
+
const [count, setCount] = createSignal(0)
|
|
44
|
+
return <button on:click={() => setCount(c => c + 1)}>Like {count()}</button>
|
|
45
|
+
})
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Reactive WebSocket
|
|
49
|
+
|
|
50
|
+
```tsx
|
|
51
|
+
import { createLive } from "gorsee"
|
|
52
|
+
|
|
53
|
+
function StockPrice() {
|
|
54
|
+
const { value: price, connected } = createLive({
|
|
55
|
+
url: "wss://api.example.com/btc",
|
|
56
|
+
initialValue: 0,
|
|
57
|
+
reconnect: true, // auto-reconnect with exponential backoff
|
|
58
|
+
})
|
|
59
|
+
return <span>{connected() ? price() : "..."}</span>
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Optimistic Mutations
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
import { createSignal, createMutation } from "gorsee"
|
|
67
|
+
|
|
68
|
+
const [todos, setTodos] = createSignal(["Buy milk"])
|
|
69
|
+
const addTodo = createMutation({
|
|
70
|
+
mutationFn: async (text) => fetch("/api/todos", { method: "POST", body: text }),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// UI updates instantly. Rolls back automatically on server error.
|
|
74
|
+
await addTodo.optimistic(todos, setTodos, (list, text) => [...list, text], "New todo")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Built-in Auth
|
|
78
|
+
|
|
79
|
+
```tsx
|
|
80
|
+
import { createAuth } from "gorsee/auth"
|
|
81
|
+
|
|
82
|
+
const auth = createAuth({ secret: process.env.SESSION_SECRET })
|
|
83
|
+
|
|
84
|
+
// In middleware:
|
|
85
|
+
export default auth.middleware // parses session
|
|
86
|
+
export default auth.requireAuth // redirects to /login if not authenticated
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### File-Based Routing
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
routes/
|
|
93
|
+
index.tsx → /
|
|
94
|
+
about.tsx → /about
|
|
95
|
+
users/[id].tsx → /users/:id
|
|
96
|
+
blog/[...slug].tsx → /blog/*
|
|
97
|
+
(admin)/
|
|
98
|
+
dashboard.tsx → /dashboard (group — no /admin prefix)
|
|
99
|
+
_layout.tsx → wraps all pages
|
|
100
|
+
_middleware.ts → runs before all routes
|
|
101
|
+
_error.tsx → error boundary
|
|
102
|
+
_loading.tsx → loading state
|
|
103
|
+
404.tsx → custom 404
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Middleware Pipe
|
|
107
|
+
|
|
108
|
+
```tsx
|
|
109
|
+
import { pipe, forPaths, forMethods } from "gorsee/server"
|
|
110
|
+
import { cors } from "gorsee/security"
|
|
111
|
+
|
|
112
|
+
export default pipe(
|
|
113
|
+
forPaths(["/api"], cors({ origin: "https://app.com" })),
|
|
114
|
+
forMethods(["POST"], rateLimit(100, "1m")),
|
|
115
|
+
auth.middleware,
|
|
116
|
+
)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## CLI Commands
|
|
120
|
+
|
|
121
|
+
| Command | Description |
|
|
122
|
+
|---------|-------------|
|
|
123
|
+
| `gorsee create <name>` | Scaffold new project |
|
|
124
|
+
| `gorsee dev` | Dev server with HMR |
|
|
125
|
+
| `gorsee build` | Production build |
|
|
126
|
+
| `gorsee start` | Production server |
|
|
127
|
+
| `gorsee check` | Type check + safety audit |
|
|
128
|
+
| `gorsee routes` | Show route table |
|
|
129
|
+
| `gorsee generate <entity>` | CRUD scaffold |
|
|
130
|
+
| `gorsee typegen` | Generate typed routes |
|
|
131
|
+
| `gorsee migrate` | Run DB migrations |
|
|
132
|
+
|
|
133
|
+
## Requirements
|
|
134
|
+
|
|
135
|
+
- [Bun](https://bun.sh) >= 1.0
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gorsee",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Full-stack TypeScript framework — islands, reactive WebSocket, optimistic mutations, built-in auth, type-safe routes",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Oleg Gorsky",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/AYKGorsee/gorsee-js"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/AYKGorsee/gorsee-js#readme",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"framework",
|
|
15
|
+
"typescript",
|
|
16
|
+
"fullstack",
|
|
17
|
+
"ssr",
|
|
18
|
+
"islands",
|
|
19
|
+
"reactive",
|
|
20
|
+
"bun",
|
|
21
|
+
"jsx",
|
|
22
|
+
"web-framework"
|
|
23
|
+
],
|
|
24
|
+
"bin": {
|
|
25
|
+
"gorsee": "./src/cli/index.ts"
|
|
26
|
+
},
|
|
27
|
+
"exports": {
|
|
28
|
+
".": "./src/index.ts",
|
|
29
|
+
"./reactive": "./src/reactive/index.ts",
|
|
30
|
+
"./server": "./src/server/index.ts",
|
|
31
|
+
"./types": "./src/types/index.ts",
|
|
32
|
+
"./db": "./src/db/index.ts",
|
|
33
|
+
"./router": "./src/router/index.ts",
|
|
34
|
+
"./log": "./src/log/index.ts",
|
|
35
|
+
"./unsafe": "./src/unsafe/index.ts",
|
|
36
|
+
"./runtime": "./src/runtime/index.ts",
|
|
37
|
+
"./security": "./src/security/index.ts",
|
|
38
|
+
"./jsx-runtime": "./src/jsx-runtime.ts",
|
|
39
|
+
"./jsx-dev-runtime": "./src/jsx-runtime.ts",
|
|
40
|
+
"./testing": "./src/testing/index.ts",
|
|
41
|
+
"./i18n": "./src/i18n/index.ts",
|
|
42
|
+
"./env": "./src/env/index.ts",
|
|
43
|
+
"./auth": "./src/auth/index.ts",
|
|
44
|
+
"./routes": "./src/runtime/typed-routes.ts",
|
|
45
|
+
"./cli/cmd-create": "./src/cli/cmd-create.ts"
|
|
46
|
+
},
|
|
47
|
+
"files": [
|
|
48
|
+
"src/",
|
|
49
|
+
"README.md",
|
|
50
|
+
"LICENSE"
|
|
51
|
+
],
|
|
52
|
+
"scripts": {
|
|
53
|
+
"test": "bun test",
|
|
54
|
+
"check": "tsc --noEmit",
|
|
55
|
+
"dev": "bun run src/dev.ts",
|
|
56
|
+
"prepublishOnly": "bun run check"
|
|
57
|
+
},
|
|
58
|
+
"engines": {
|
|
59
|
+
"bun": ">=1.0.0"
|
|
60
|
+
},
|
|
61
|
+
"dependencies": {
|
|
62
|
+
"alien-signals": "^3.1.0",
|
|
63
|
+
"devalue": "^5.1.1"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@types/bun": "latest",
|
|
67
|
+
"typescript": "^5.7.0"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Built-in session-based auth with HMAC-signed cookies
|
|
2
|
+
|
|
3
|
+
import type { Context, MiddlewareFn } from "../server/middleware.ts"
|
|
4
|
+
|
|
5
|
+
export interface AuthConfig {
|
|
6
|
+
secret: string
|
|
7
|
+
cookieName?: string
|
|
8
|
+
maxAge?: number
|
|
9
|
+
loginPath?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Session {
|
|
13
|
+
id: string
|
|
14
|
+
userId: string
|
|
15
|
+
data: Record<string, unknown>
|
|
16
|
+
expiresAt: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const sessions = new Map<string, Session>()
|
|
20
|
+
|
|
21
|
+
let cachedKey: CryptoKey | null = null
|
|
22
|
+
let cachedSecret = ""
|
|
23
|
+
|
|
24
|
+
async function getSigningKey(secret: string): Promise<CryptoKey> {
|
|
25
|
+
if (cachedKey && cachedSecret === secret) return cachedKey
|
|
26
|
+
const enc = new TextEncoder()
|
|
27
|
+
cachedKey = await crypto.subtle.importKey(
|
|
28
|
+
"raw",
|
|
29
|
+
enc.encode(secret),
|
|
30
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
31
|
+
false,
|
|
32
|
+
["sign", "verify"],
|
|
33
|
+
)
|
|
34
|
+
cachedSecret = secret
|
|
35
|
+
return cachedKey
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function sign(value: string, secret: string): Promise<string> {
|
|
39
|
+
const key = await getSigningKey(secret)
|
|
40
|
+
const enc = new TextEncoder()
|
|
41
|
+
const signature = await crypto.subtle.sign("HMAC", key, enc.encode(value))
|
|
42
|
+
const sigHex = Array.from(new Uint8Array(signature))
|
|
43
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
44
|
+
.join("")
|
|
45
|
+
return `${value}.${sigHex}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function verify(
|
|
49
|
+
signed: string,
|
|
50
|
+
secret: string,
|
|
51
|
+
): Promise<string | null> {
|
|
52
|
+
const dotIndex = signed.lastIndexOf(".")
|
|
53
|
+
if (dotIndex === -1) return null
|
|
54
|
+
const value = signed.slice(0, dotIndex)
|
|
55
|
+
const expected = await sign(value, secret)
|
|
56
|
+
// Constant-time-ish comparison via re-signing
|
|
57
|
+
if (expected === signed) return value
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pruneExpired(): void {
|
|
62
|
+
const now = Date.now()
|
|
63
|
+
for (const [id, session] of sessions) {
|
|
64
|
+
if (session.expiresAt <= now) sessions.delete(id)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resolveConfig(config: AuthConfig) {
|
|
69
|
+
return {
|
|
70
|
+
secret: config.secret,
|
|
71
|
+
cookieName: config.cookieName ?? "gorsee_session",
|
|
72
|
+
maxAge: config.maxAge ?? 86400,
|
|
73
|
+
loginPath: config.loginPath ?? "/login",
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function createAuth(config: AuthConfig): {
|
|
78
|
+
middleware: MiddlewareFn
|
|
79
|
+
requireAuth: MiddlewareFn
|
|
80
|
+
login: (ctx: Context, userId: string, data?: Record<string, unknown>) => Promise<void>
|
|
81
|
+
logout: (ctx: Context) => void
|
|
82
|
+
getSession: (ctx: Context) => Session | null
|
|
83
|
+
} {
|
|
84
|
+
const cfg = resolveConfig(config)
|
|
85
|
+
|
|
86
|
+
const middleware: MiddlewareFn = async (ctx, next) => {
|
|
87
|
+
const cookie = ctx.cookies.get(cfg.cookieName)
|
|
88
|
+
if (cookie) {
|
|
89
|
+
const sessionId = await verify(cookie, cfg.secret)
|
|
90
|
+
if (sessionId) {
|
|
91
|
+
const session = sessions.get(sessionId)
|
|
92
|
+
if (session && session.expiresAt > Date.now()) {
|
|
93
|
+
ctx.locals.session = session
|
|
94
|
+
} else if (session) {
|
|
95
|
+
sessions.delete(sessionId)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return next()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const requireAuth: MiddlewareFn = async (ctx, next) => {
|
|
103
|
+
if (!ctx.locals.session) {
|
|
104
|
+
return ctx.redirect(cfg.loginPath)
|
|
105
|
+
}
|
|
106
|
+
return next()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function login(
|
|
110
|
+
ctx: Context,
|
|
111
|
+
userId: string,
|
|
112
|
+
data: Record<string, unknown> = {},
|
|
113
|
+
): Promise<void> {
|
|
114
|
+
pruneExpired()
|
|
115
|
+
const id = crypto.randomUUID()
|
|
116
|
+
const session: Session = {
|
|
117
|
+
id,
|
|
118
|
+
userId,
|
|
119
|
+
data,
|
|
120
|
+
expiresAt: Date.now() + cfg.maxAge * 1000,
|
|
121
|
+
}
|
|
122
|
+
sessions.set(id, session)
|
|
123
|
+
ctx.locals.session = session
|
|
124
|
+
const signed = await sign(id, cfg.secret)
|
|
125
|
+
ctx.setCookie(cfg.cookieName, signed, {
|
|
126
|
+
maxAge: cfg.maxAge,
|
|
127
|
+
httpOnly: true,
|
|
128
|
+
sameSite: "Lax",
|
|
129
|
+
path: "/",
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function logout(ctx: Context): void {
|
|
134
|
+
const session = ctx.locals.session as Session | undefined
|
|
135
|
+
if (session) {
|
|
136
|
+
sessions.delete(session.id)
|
|
137
|
+
ctx.locals.session = undefined
|
|
138
|
+
}
|
|
139
|
+
ctx.deleteCookie(cfg.cookieName)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function getSession(ctx: Context): Session | null {
|
|
143
|
+
return (ctx.locals.session as Session) ?? null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { middleware, requireAuth, login, logout, getSession }
|
|
147
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Client bundle builder -- uses Bun.build() to create browser-ready JS per route
|
|
2
|
+
|
|
3
|
+
import { join, resolve, relative } from "node:path"
|
|
4
|
+
import { mkdir, rm } from "node:fs/promises"
|
|
5
|
+
import { serverStripPlugin } from "./server-strip.ts"
|
|
6
|
+
import { cssModulesPlugin, getCollectedCSS, resetCollectedCSS } from "./css-modules.ts"
|
|
7
|
+
import type { Route } from "../router/scanner.ts"
|
|
8
|
+
|
|
9
|
+
const FRAMEWORK_ROOT = resolve(import.meta.dir, "..")
|
|
10
|
+
const CLIENT_JSX_RUNTIME = resolve(FRAMEWORK_ROOT, "jsx-runtime-client.ts")
|
|
11
|
+
|
|
12
|
+
const GORSEE_CLIENT_RESOLVE: Record<string, string> = {
|
|
13
|
+
"gorsee": resolve(FRAMEWORK_ROOT, "index.ts"),
|
|
14
|
+
"gorsee/reactive": resolve(FRAMEWORK_ROOT, "reactive/index.ts"),
|
|
15
|
+
"gorsee/types": resolve(FRAMEWORK_ROOT, "types/index.ts"),
|
|
16
|
+
"gorsee/runtime": resolve(FRAMEWORK_ROOT, "runtime/index.ts"),
|
|
17
|
+
"gorsee/unsafe": resolve(FRAMEWORK_ROOT, "unsafe/index.ts"),
|
|
18
|
+
"gorsee/jsx-runtime": CLIENT_JSX_RUNTIME,
|
|
19
|
+
"gorsee/jsx-dev-runtime": CLIENT_JSX_RUNTIME,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function routeToEntryName(route: Route, cwd: string): string {
|
|
23
|
+
const rel = relative(join(cwd, "routes"), route.filePath)
|
|
24
|
+
return rel.replace(/\.(tsx?|jsx?)$/, "").replace(/[\[\]]/g, "_")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function generateEntryCode(routeFile: string, hydrateImport: string, routerImport: string): string {
|
|
28
|
+
return `
|
|
29
|
+
import Component from "${routeFile}";
|
|
30
|
+
import { hydrate } from "${hydrateImport}";
|
|
31
|
+
import { initRouter } from "${routerImport}";
|
|
32
|
+
var container = document.getElementById("app");
|
|
33
|
+
var dataEl = document.getElementById("__GORSEE_DATA__");
|
|
34
|
+
var data = dataEl ? JSON.parse(dataEl.textContent) : {};
|
|
35
|
+
var params = window.__GORSEE_PARAMS__ || {};
|
|
36
|
+
hydrate(function() { return Component({ data: data, params: params }); }, container);
|
|
37
|
+
initRouter();
|
|
38
|
+
`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface BuildResult {
|
|
42
|
+
entryMap: Map<string, string> // routePath → client JS path (relative to outdir)
|
|
43
|
+
cssModules?: string // collected CSS from .module.css files
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface BuildOptions {
|
|
47
|
+
minify?: boolean
|
|
48
|
+
sourcemap?: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function buildClientBundles(
|
|
52
|
+
routes: Route[],
|
|
53
|
+
cwd: string,
|
|
54
|
+
options?: BuildOptions,
|
|
55
|
+
): Promise<BuildResult> {
|
|
56
|
+
const outDir = join(cwd, ".gorsee", "client")
|
|
57
|
+
await rm(outDir, { recursive: true, force: true })
|
|
58
|
+
await mkdir(outDir, { recursive: true })
|
|
59
|
+
|
|
60
|
+
const entryDir = join(cwd, ".gorsee", "entries")
|
|
61
|
+
await rm(entryDir, { recursive: true, force: true })
|
|
62
|
+
await mkdir(entryDir, { recursive: true })
|
|
63
|
+
|
|
64
|
+
const entryMap = new Map<string, string>()
|
|
65
|
+
const pageRoutes = routes.filter((r) => !r.filePath.includes("/api/"))
|
|
66
|
+
if (pageRoutes.length === 0) return { entryMap }
|
|
67
|
+
|
|
68
|
+
resetCollectedCSS()
|
|
69
|
+
|
|
70
|
+
const entrypoints: string[] = []
|
|
71
|
+
|
|
72
|
+
for (const route of pageRoutes) {
|
|
73
|
+
const name = routeToEntryName(route, cwd)
|
|
74
|
+
const entryPath = join(entryDir, `${name}.ts`)
|
|
75
|
+
const clientModule = resolve(FRAMEWORK_ROOT, "runtime/client.ts")
|
|
76
|
+
const routerModule = resolve(FRAMEWORK_ROOT, "runtime/router.ts")
|
|
77
|
+
const code = generateEntryCode(route.filePath, clientModule, routerModule)
|
|
78
|
+
await Bun.write(entryPath, code)
|
|
79
|
+
entrypoints.push(entryPath)
|
|
80
|
+
entryMap.set(route.path, `${name}.js`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const result = await Bun.build({
|
|
84
|
+
entrypoints,
|
|
85
|
+
outdir: outDir,
|
|
86
|
+
target: "browser",
|
|
87
|
+
format: "esm",
|
|
88
|
+
minify: options?.minify ?? false,
|
|
89
|
+
sourcemap: options?.sourcemap ? "external" : "none",
|
|
90
|
+
splitting: true,
|
|
91
|
+
plugins: [
|
|
92
|
+
{
|
|
93
|
+
name: "gorsee-client-resolve",
|
|
94
|
+
setup(build) {
|
|
95
|
+
build.onResolve({ filter: /^gorsee(\/.*)?$/ }, (args) => {
|
|
96
|
+
const mapped = GORSEE_CLIENT_RESOLVE[args.path]
|
|
97
|
+
if (mapped) return { path: mapped }
|
|
98
|
+
return undefined
|
|
99
|
+
})
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
serverStripPlugin,
|
|
103
|
+
cssModulesPlugin,
|
|
104
|
+
],
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
if (!result.success) {
|
|
108
|
+
for (const log of result.logs) {
|
|
109
|
+
console.error("[build]", log.message)
|
|
110
|
+
}
|
|
111
|
+
throw new Error("Client build failed")
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Write collected CSS modules output
|
|
115
|
+
const cssModules = getCollectedCSS()
|
|
116
|
+
if (cssModules) {
|
|
117
|
+
await Bun.write(join(outDir, "modules.css"), cssModules)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { entryMap, cssModules }
|
|
121
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// CSS Modules plugin for Bun.build
|
|
2
|
+
// Transforms .module.css imports to scoped class names
|
|
3
|
+
// Input: import styles from "./button.module.css"
|
|
4
|
+
// Output: styles = { container: "button_container_a1b2c" }
|
|
5
|
+
|
|
6
|
+
import type { BunPlugin } from "bun"
|
|
7
|
+
import { createHash } from "node:crypto"
|
|
8
|
+
import { readFile } from "node:fs/promises"
|
|
9
|
+
import { basename, join, dirname } from "node:path"
|
|
10
|
+
|
|
11
|
+
function hashClassName(filePath: string, className: string): string {
|
|
12
|
+
const hash = createHash("md5")
|
|
13
|
+
.update(filePath + className)
|
|
14
|
+
.digest("hex")
|
|
15
|
+
.slice(0, 5)
|
|
16
|
+
const base = basename(filePath, ".module.css")
|
|
17
|
+
return `${base}_${className}_${hash}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function transformCSS(filePath: string, source: string): { css: string; classMap: Record<string, string> } {
|
|
21
|
+
const classMap: Record<string, string> = {}
|
|
22
|
+
|
|
23
|
+
const css = source.replace(/\.([a-zA-Z_][\w-]*)/g, (match, className) => {
|
|
24
|
+
// Don't transform pseudo-classes and pseudo-elements
|
|
25
|
+
if (match.startsWith("::") || match.startsWith(":.")) return match
|
|
26
|
+
const scoped = hashClassName(filePath, className)
|
|
27
|
+
classMap[className] = scoped
|
|
28
|
+
return `.${scoped}`
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
return { css, classMap }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Collected CSS from all modules (used during build)
|
|
35
|
+
const collectedCSS: string[] = []
|
|
36
|
+
|
|
37
|
+
export function getCollectedCSS(): string {
|
|
38
|
+
return collectedCSS.join("\n")
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function resetCollectedCSS(): void {
|
|
42
|
+
collectedCSS.length = 0
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const cssModulesPlugin: BunPlugin = {
|
|
46
|
+
name: "gorsee-css-modules",
|
|
47
|
+
setup(build) {
|
|
48
|
+
build.onResolve({ filter: /\.module\.css$/ }, (args) => ({
|
|
49
|
+
path: join(dirname(args.importer), args.path),
|
|
50
|
+
namespace: "css-module",
|
|
51
|
+
}))
|
|
52
|
+
|
|
53
|
+
build.onLoad({ filter: /.*/, namespace: "css-module" }, async (args) => {
|
|
54
|
+
const source = await readFile(args.path, "utf-8")
|
|
55
|
+
const { css, classMap } = transformCSS(args.path, source)
|
|
56
|
+
|
|
57
|
+
collectedCSS.push(css)
|
|
58
|
+
|
|
59
|
+
const exports = Object.entries(classMap)
|
|
60
|
+
.map(([k, v]) => ` "${k}": "${v}"`)
|
|
61
|
+
.join(",\n")
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
contents: `export default {\n${exports}\n};`,
|
|
65
|
+
loader: "js",
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
},
|
|
69
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// RPC transform plugin for client bundles
|
|
2
|
+
// Replaces server() calls with fetch-based RPC stubs
|
|
3
|
+
// server(async (args) => { ... }) → async (args) => fetch("/_rpc/hash", ...)
|
|
4
|
+
|
|
5
|
+
import { hashRPC } from "../server/rpc-hash.ts"
|
|
6
|
+
|
|
7
|
+
const DEVALUE_PARSE_MODULE = new URL("./devalue-parse.ts", import.meta.url).pathname
|
|
8
|
+
|
|
9
|
+
// Strip TypeScript type annotations from function args: "count: number, name: string" → "count, name"
|
|
10
|
+
function stripTypeAnnotations(args: string): string {
|
|
11
|
+
return args.split(",").map((arg) => {
|
|
12
|
+
const trimmed = arg.trim()
|
|
13
|
+
// Handle destructuring, rest params, defaults — just strip ": Type" suffix
|
|
14
|
+
return trimmed.replace(/\s*:\s*[^,=]+$/, "").replace(/\s*:\s*[^,=]+(?=\s*=)/, "")
|
|
15
|
+
}).join(", ")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function transformServerCalls(source: string, filePath: string): string {
|
|
19
|
+
// Match server( followed by async/function — not inside strings
|
|
20
|
+
const matches = [...source.matchAll(/\bserver\s*\(\s*(?=async\s|function\s)/g)]
|
|
21
|
+
if (matches.length === 0) return source
|
|
22
|
+
|
|
23
|
+
// Process in reverse order to preserve indices
|
|
24
|
+
let result = source
|
|
25
|
+
for (let i = matches.length - 1; i >= 0; i--) {
|
|
26
|
+
const match = matches[i]!
|
|
27
|
+
const start = match.index!
|
|
28
|
+
const id = hashRPC(filePath, i)
|
|
29
|
+
|
|
30
|
+
// Find the matching closing paren for server(...)
|
|
31
|
+
let depth = 0
|
|
32
|
+
let end = start
|
|
33
|
+
for (let j = start; j < source.length; j++) {
|
|
34
|
+
if (source[j] === "(") depth++
|
|
35
|
+
else if (source[j] === ")") {
|
|
36
|
+
depth--
|
|
37
|
+
if (depth === 0) { end = j + 1; break }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const inner = source.slice(start + match[0].length, end - 1)
|
|
42
|
+
const argsMatch = inner.match(/^(async\s+)?(?:\(([^)]*)\)|(\w+))\s*=>/)
|
|
43
|
+
const rawArgs = argsMatch ? (argsMatch[2] ?? argsMatch[3] ?? "") : ""
|
|
44
|
+
const args = rawArgs ? stripTypeAnnotations(rawArgs) : "...args"
|
|
45
|
+
|
|
46
|
+
const stub = `(async (${args}) => {
|
|
47
|
+
const res = await fetch("/api/_rpc/${id}", {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: { "Content-Type": "application/json" },
|
|
50
|
+
body: JSON.stringify([${args}])
|
|
51
|
+
});
|
|
52
|
+
if (!res.ok) throw new Error("RPC failed: " + res.status);
|
|
53
|
+
return __gorseeDevalParse(await res.text());
|
|
54
|
+
})`
|
|
55
|
+
|
|
56
|
+
result = result.slice(0, start) + stub + result.slice(end)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Add devalue parse import at top
|
|
60
|
+
return `import { parse as __gorseeDevalParse } from "${DEVALUE_PARSE_MODULE}";\n` + result
|
|
61
|
+
}
|
|
62
|
+
|