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.
Files changed (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/package.json +69 -0
  4. package/src/auth/index.ts +147 -0
  5. package/src/build/client.ts +121 -0
  6. package/src/build/css-modules.ts +69 -0
  7. package/src/build/devalue-parse.ts +2 -0
  8. package/src/build/rpc-transform.ts +62 -0
  9. package/src/build/server-strip.ts +87 -0
  10. package/src/build/ssg.ts +100 -0
  11. package/src/cli/bun-plugin.ts +37 -0
  12. package/src/cli/cmd-build.ts +182 -0
  13. package/src/cli/cmd-check.ts +225 -0
  14. package/src/cli/cmd-create.ts +313 -0
  15. package/src/cli/cmd-dev.ts +13 -0
  16. package/src/cli/cmd-generate.ts +147 -0
  17. package/src/cli/cmd-migrate.ts +45 -0
  18. package/src/cli/cmd-routes.ts +29 -0
  19. package/src/cli/cmd-start.ts +21 -0
  20. package/src/cli/cmd-typegen.ts +83 -0
  21. package/src/cli/framework-md.ts +196 -0
  22. package/src/cli/index.ts +84 -0
  23. package/src/db/index.ts +2 -0
  24. package/src/db/migrate.ts +89 -0
  25. package/src/db/sqlite.ts +40 -0
  26. package/src/deploy/dockerfile.ts +38 -0
  27. package/src/dev/error-overlay.ts +54 -0
  28. package/src/dev/hmr.ts +31 -0
  29. package/src/dev/partial-handler.ts +109 -0
  30. package/src/dev/request-handler.ts +158 -0
  31. package/src/dev/watcher.ts +48 -0
  32. package/src/dev.ts +273 -0
  33. package/src/env/index.ts +74 -0
  34. package/src/errors/catalog.ts +48 -0
  35. package/src/errors/formatter.ts +63 -0
  36. package/src/errors/index.ts +2 -0
  37. package/src/i18n/index.ts +72 -0
  38. package/src/index.ts +27 -0
  39. package/src/jsx-runtime-client.ts +13 -0
  40. package/src/jsx-runtime.ts +20 -0
  41. package/src/jsx-types-html.ts +242 -0
  42. package/src/log/index.ts +44 -0
  43. package/src/prod.ts +310 -0
  44. package/src/reactive/computed.ts +7 -0
  45. package/src/reactive/effect.ts +7 -0
  46. package/src/reactive/index.ts +7 -0
  47. package/src/reactive/live.ts +97 -0
  48. package/src/reactive/optimistic.ts +83 -0
  49. package/src/reactive/resource.ts +138 -0
  50. package/src/reactive/signal.ts +20 -0
  51. package/src/reactive/store.ts +36 -0
  52. package/src/router/index.ts +2 -0
  53. package/src/router/matcher.ts +53 -0
  54. package/src/router/scanner.ts +206 -0
  55. package/src/runtime/client.ts +28 -0
  56. package/src/runtime/error-boundary.ts +35 -0
  57. package/src/runtime/event-replay.ts +50 -0
  58. package/src/runtime/form.ts +49 -0
  59. package/src/runtime/head.ts +113 -0
  60. package/src/runtime/html-escape.ts +30 -0
  61. package/src/runtime/hydration.ts +95 -0
  62. package/src/runtime/image.ts +48 -0
  63. package/src/runtime/index.ts +12 -0
  64. package/src/runtime/island-hydrator.ts +84 -0
  65. package/src/runtime/island.ts +88 -0
  66. package/src/runtime/jsx-runtime.ts +167 -0
  67. package/src/runtime/link.ts +45 -0
  68. package/src/runtime/router.ts +224 -0
  69. package/src/runtime/server.ts +102 -0
  70. package/src/runtime/stream.ts +182 -0
  71. package/src/runtime/suspense.ts +37 -0
  72. package/src/runtime/typed-routes.ts +26 -0
  73. package/src/runtime/validated-form.ts +106 -0
  74. package/src/security/cors.ts +80 -0
  75. package/src/security/csrf.ts +85 -0
  76. package/src/security/headers.ts +50 -0
  77. package/src/security/index.ts +4 -0
  78. package/src/security/rate-limit.ts +80 -0
  79. package/src/server/action.ts +48 -0
  80. package/src/server/cache.ts +102 -0
  81. package/src/server/compress.ts +60 -0
  82. package/src/server/etag.ts +23 -0
  83. package/src/server/guard.ts +69 -0
  84. package/src/server/index.ts +19 -0
  85. package/src/server/middleware.ts +143 -0
  86. package/src/server/mime.ts +48 -0
  87. package/src/server/pipe.ts +46 -0
  88. package/src/server/rpc-hash.ts +17 -0
  89. package/src/server/rpc.ts +125 -0
  90. package/src/server/sse.ts +96 -0
  91. package/src/server/ws.ts +56 -0
  92. package/src/testing/index.ts +74 -0
  93. package/src/types/index.ts +4 -0
  94. package/src/types/safe-html.ts +32 -0
  95. package/src/types/safe-sql.ts +28 -0
  96. package/src/types/safe-url.ts +40 -0
  97. package/src/types/user-input.ts +12 -0
  98. 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,2 @@
1
+ // Re-export devalue.parse for client bundles (tree-shakeable)
2
+ export { parse } from "devalue"
@@ -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
+