gorsee 0.1.3 → 0.2.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/package.json +11 -2
- package/src/cli/cmd-deploy.ts +141 -0
- package/src/cli/index.ts +5 -0
- package/src/deploy/cloudflare.ts +109 -0
- package/src/deploy/fly.ts +79 -0
- package/src/deploy/index.ts +31 -0
- package/src/deploy/netlify.ts +77 -0
- package/src/deploy/vercel.ts +94 -0
- package/src/index.ts +1 -0
- package/src/plugins/drizzle.ts +84 -0
- package/src/plugins/index.ts +86 -0
- package/src/plugins/lucia.ts +111 -0
- package/src/plugins/prisma.ts +85 -0
- package/src/plugins/resend.ts +78 -0
- package/src/plugins/s3.ts +102 -0
- package/src/plugins/stripe.ts +133 -0
- package/src/plugins/tailwind.ts +92 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gorsee",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
@@ -42,7 +42,16 @@
|
|
|
42
42
|
"./env": "./src/env/index.ts",
|
|
43
43
|
"./auth": "./src/auth/index.ts",
|
|
44
44
|
"./routes": "./src/runtime/typed-routes.ts",
|
|
45
|
-
"./cli/cmd-create": "./src/cli/cmd-create.ts"
|
|
45
|
+
"./cli/cmd-create": "./src/cli/cmd-create.ts",
|
|
46
|
+
"./plugins": "./src/plugins/index.ts",
|
|
47
|
+
"./plugins/drizzle": "./src/plugins/drizzle.ts",
|
|
48
|
+
"./plugins/prisma": "./src/plugins/prisma.ts",
|
|
49
|
+
"./plugins/tailwind": "./src/plugins/tailwind.ts",
|
|
50
|
+
"./plugins/lucia": "./src/plugins/lucia.ts",
|
|
51
|
+
"./plugins/s3": "./src/plugins/s3.ts",
|
|
52
|
+
"./plugins/resend": "./src/plugins/resend.ts",
|
|
53
|
+
"./plugins/stripe": "./src/plugins/stripe.ts",
|
|
54
|
+
"./deploy": "./src/deploy/index.ts"
|
|
46
55
|
},
|
|
47
56
|
"files": [
|
|
48
57
|
"src/",
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Gorsee.js — CLI deploy command
|
|
2
|
+
|
|
3
|
+
import { writeFile, access, mkdir } from "node:fs/promises"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
|
|
6
|
+
type Target = "vercel" | "fly" | "cloudflare" | "netlify" | "docker"
|
|
7
|
+
|
|
8
|
+
const TARGETS: Target[] = ["vercel", "fly", "cloudflare", "netlify", "docker"]
|
|
9
|
+
|
|
10
|
+
const DETECT_FILES: Record<string, Target> = {
|
|
11
|
+
"vercel.json": "vercel",
|
|
12
|
+
"fly.toml": "fly",
|
|
13
|
+
"wrangler.toml": "cloudflare",
|
|
14
|
+
"netlify.toml": "netlify",
|
|
15
|
+
"Dockerfile": "docker",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
19
|
+
try {
|
|
20
|
+
await access(path)
|
|
21
|
+
return true
|
|
22
|
+
} catch {
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function detectTarget(cwd: string): Promise<Target | null> {
|
|
28
|
+
for (const [file, target] of Object.entries(DETECT_FILES)) {
|
|
29
|
+
if (await fileExists(join(cwd, file))) return target
|
|
30
|
+
}
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function writeAndLog(filePath: string, content: string): Promise<void> {
|
|
35
|
+
await writeFile(filePath, content, "utf-8")
|
|
36
|
+
console.log(` created ${filePath}`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function deployVercel(cwd: string): Promise<void> {
|
|
40
|
+
const { generateVercelConfig, generateVercelServerlessEntry } = await import("../deploy/vercel.ts")
|
|
41
|
+
await writeAndLog(join(cwd, "vercel.json"), JSON.stringify(generateVercelConfig(), null, 2))
|
|
42
|
+
await mkdir(join(cwd, "api"), { recursive: true })
|
|
43
|
+
await writeAndLog(join(cwd, "api/index.ts"), generateVercelServerlessEntry())
|
|
44
|
+
console.log("\n Next steps:")
|
|
45
|
+
console.log(" 1. Install Vercel CLI: npm i -g vercel")
|
|
46
|
+
console.log(" 2. Run: vercel")
|
|
47
|
+
console.log(" 3. Follow prompts to link your project")
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function deployFly(cwd: string, appName: string): Promise<void> {
|
|
51
|
+
const { generateFlyConfig, generateFlyDockerfile } = await import("../deploy/fly.ts")
|
|
52
|
+
await writeAndLog(join(cwd, "fly.toml"), generateFlyConfig(appName))
|
|
53
|
+
await writeAndLog(join(cwd, "Dockerfile"), generateFlyDockerfile())
|
|
54
|
+
console.log("\n Next steps:")
|
|
55
|
+
console.log(" 1. Install Fly CLI: curl -L https://fly.io/install.sh | sh")
|
|
56
|
+
console.log(` 2. Run: fly launch --name ${appName}`)
|
|
57
|
+
console.log(" 3. Deploy: fly deploy")
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function deployCloudflare(cwd: string, name: string): Promise<void> {
|
|
61
|
+
const { generateWranglerConfig, generateCloudflareEntry, generateCloudflareStaticAssets } =
|
|
62
|
+
await import("../deploy/cloudflare.ts")
|
|
63
|
+
await writeAndLog(join(cwd, "wrangler.toml"), generateWranglerConfig(name))
|
|
64
|
+
await writeAndLog(join(cwd, "worker.ts"), generateCloudflareEntry())
|
|
65
|
+
await writeAndLog(join(cwd, "_routes.json"), JSON.stringify(generateCloudflareStaticAssets(), null, 2))
|
|
66
|
+
console.log("\n Next steps:")
|
|
67
|
+
console.log(" 1. Install Wrangler: npm i -g wrangler")
|
|
68
|
+
console.log(" 2. Authenticate: wrangler login")
|
|
69
|
+
console.log(" 3. Deploy: wrangler deploy")
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function deployNetlify(cwd: string): Promise<void> {
|
|
73
|
+
const { generateNetlifyConfig, generateNetlifyFunction } = await import("../deploy/netlify.ts")
|
|
74
|
+
await writeAndLog(join(cwd, "netlify.toml"), generateNetlifyConfig())
|
|
75
|
+
const edgeFnDir = join(cwd, "netlify/edge-functions")
|
|
76
|
+
await mkdir(edgeFnDir, { recursive: true })
|
|
77
|
+
await writeAndLog(join(edgeFnDir, "gorsee-handler.ts"), generateNetlifyFunction())
|
|
78
|
+
console.log("\n Next steps:")
|
|
79
|
+
console.log(" 1. Install Netlify CLI: npm i -g netlify-cli")
|
|
80
|
+
console.log(" 2. Run: netlify init")
|
|
81
|
+
console.log(" 3. Deploy: netlify deploy --prod")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function deployDocker(cwd: string): Promise<void> {
|
|
85
|
+
const { generateDockerfile, generateDockerignore } = await import("../deploy/dockerfile.ts")
|
|
86
|
+
await writeAndLog(join(cwd, "Dockerfile"), generateDockerfile())
|
|
87
|
+
await writeAndLog(join(cwd, ".dockerignore"), generateDockerignore())
|
|
88
|
+
console.log("\n Next steps:")
|
|
89
|
+
console.log(" 1. Build image: docker build -t gorsee-app .")
|
|
90
|
+
console.log(" 2. Run: docker run -p 3000:3000 gorsee-app")
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function runDeploy(args: string[]): Promise<void> {
|
|
94
|
+
const cwd = process.cwd()
|
|
95
|
+
const initOnly = args.includes("--init")
|
|
96
|
+
const targetArg = args.find((a) => !a.startsWith("-")) as Target | undefined
|
|
97
|
+
|
|
98
|
+
let target = targetArg ?? null
|
|
99
|
+
if (!target) {
|
|
100
|
+
target = await detectTarget(cwd)
|
|
101
|
+
if (!target) {
|
|
102
|
+
console.error(" No deploy target specified and none detected.")
|
|
103
|
+
console.error(` Usage: gorsee deploy <${TARGETS.join("|")}> [--init]`)
|
|
104
|
+
process.exit(1)
|
|
105
|
+
}
|
|
106
|
+
console.log(` Auto-detected target: ${target}`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!TARGETS.includes(target)) {
|
|
110
|
+
console.error(` Unknown target: ${target}`)
|
|
111
|
+
console.error(` Available: ${TARGETS.join(", ")}`)
|
|
112
|
+
process.exit(1)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const projectName = cwd.split("/").pop() ?? "gorsee-app"
|
|
116
|
+
console.log(`\n Generating ${target} deploy config...\n`)
|
|
117
|
+
|
|
118
|
+
switch (target) {
|
|
119
|
+
case "vercel":
|
|
120
|
+
await deployVercel(cwd)
|
|
121
|
+
break
|
|
122
|
+
case "fly":
|
|
123
|
+
await deployFly(cwd, projectName)
|
|
124
|
+
break
|
|
125
|
+
case "cloudflare":
|
|
126
|
+
await deployCloudflare(cwd, projectName)
|
|
127
|
+
break
|
|
128
|
+
case "netlify":
|
|
129
|
+
await deployNetlify(cwd)
|
|
130
|
+
break
|
|
131
|
+
case "docker":
|
|
132
|
+
await deployDocker(cwd)
|
|
133
|
+
break
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (initOnly) {
|
|
137
|
+
console.log("\n Config generated (--init mode). Deploy manually when ready.")
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
console.log()
|
|
141
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ const COMMANDS: Record<string, string> = {
|
|
|
14
14
|
migrate: "Run database migrations",
|
|
15
15
|
generate: "Generate CRUD scaffold for entity",
|
|
16
16
|
typegen: "Generate typed route definitions",
|
|
17
|
+
deploy: "Generate deploy config (vercel/fly/cloudflare/netlify/docker)",
|
|
17
18
|
help: "Show this help message",
|
|
18
19
|
}
|
|
19
20
|
|
|
@@ -55,6 +56,10 @@ async function main() {
|
|
|
55
56
|
const { runTypegen } = await import("./cmd-typegen.ts")
|
|
56
57
|
await runTypegen(args.slice(1))
|
|
57
58
|
break
|
|
59
|
+
case "deploy":
|
|
60
|
+
const { runDeploy } = await import("./cmd-deploy.ts")
|
|
61
|
+
await runDeploy(args.slice(1))
|
|
62
|
+
break
|
|
58
63
|
case "help":
|
|
59
64
|
case undefined:
|
|
60
65
|
case "--help":
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Gorsee.js — Cloudflare Workers/Pages deploy adapter
|
|
2
|
+
|
|
3
|
+
export function generateWranglerConfig(name: string): string {
|
|
4
|
+
const today = new Date().toISOString().split("T")[0]
|
|
5
|
+
|
|
6
|
+
return `# Gorsee.js — Cloudflare Workers configuration
|
|
7
|
+
# Auto-generated by \`gorsee deploy cloudflare\`
|
|
8
|
+
|
|
9
|
+
name = "${name}"
|
|
10
|
+
main = "dist/worker.js"
|
|
11
|
+
compatibility_date = "${today}"
|
|
12
|
+
compatibility_flags = ["nodejs_compat"]
|
|
13
|
+
|
|
14
|
+
[site]
|
|
15
|
+
bucket = "./dist/client"
|
|
16
|
+
|
|
17
|
+
[build]
|
|
18
|
+
command = "bun run build"
|
|
19
|
+
|
|
20
|
+
# KV namespace for caching (optional)
|
|
21
|
+
# [[kv_namespaces]]
|
|
22
|
+
# binding = "CACHE"
|
|
23
|
+
# id = ""
|
|
24
|
+
|
|
25
|
+
# Environment variables
|
|
26
|
+
[vars]
|
|
27
|
+
NODE_ENV = "production"
|
|
28
|
+
|
|
29
|
+
# Production overrides
|
|
30
|
+
[env.production]
|
|
31
|
+
name = "${name}"
|
|
32
|
+
route = { pattern = "*", zone_name = "" }
|
|
33
|
+
`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function generateCloudflareEntry(): string {
|
|
37
|
+
return `// Gorsee.js — Cloudflare Worker entry
|
|
38
|
+
// Auto-generated by \`gorsee deploy cloudflare\`
|
|
39
|
+
|
|
40
|
+
export default {
|
|
41
|
+
async fetch(
|
|
42
|
+
request: Request,
|
|
43
|
+
env: Record<string, unknown>,
|
|
44
|
+
ctx: ExecutionContext,
|
|
45
|
+
): Promise<Response> {
|
|
46
|
+
const url = new URL(request.url)
|
|
47
|
+
|
|
48
|
+
// Serve static client assets from KV/site
|
|
49
|
+
if (url.pathname.startsWith("/_gorsee/")) {
|
|
50
|
+
const assetPath = url.pathname.slice("/_gorsee/".length)
|
|
51
|
+
// @ts-expect-error — __STATIC_CONTENT is injected by Cloudflare
|
|
52
|
+
const asset = await (env.__STATIC_CONTENT as any)?.get(assetPath)
|
|
53
|
+
if (asset) {
|
|
54
|
+
return new Response(asset, {
|
|
55
|
+
headers: {
|
|
56
|
+
"Content-Type": "application/javascript",
|
|
57
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Serve public static files
|
|
64
|
+
const staticExts = [".css", ".ico", ".svg", ".png", ".jpg", ".woff2", ".txt"]
|
|
65
|
+
if (staticExts.some((ext) => url.pathname.endsWith(ext))) {
|
|
66
|
+
// @ts-expect-error — __STATIC_CONTENT is injected by Cloudflare
|
|
67
|
+
const asset = await (env.__STATIC_CONTENT as any)?.get(url.pathname.slice(1))
|
|
68
|
+
if (asset) {
|
|
69
|
+
return new Response(asset, {
|
|
70
|
+
headers: { "Cache-Control": "public, max-age=3600" },
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Forward all other requests to Gorsee server handler
|
|
76
|
+
// In production, this imports the built server bundle
|
|
77
|
+
try {
|
|
78
|
+
const { handleRequest } = await import("./server-handler.js")
|
|
79
|
+
return await handleRequest(request, env)
|
|
80
|
+
} catch {
|
|
81
|
+
return new Response("Internal Server Error", { status: 500 })
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface CloudflareRoutesConfig {
|
|
89
|
+
version: number
|
|
90
|
+
include: string[]
|
|
91
|
+
exclude: string[]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function generateCloudflareStaticAssets(): CloudflareRoutesConfig {
|
|
95
|
+
return {
|
|
96
|
+
version: 1,
|
|
97
|
+
include: ["/*"],
|
|
98
|
+
exclude: [
|
|
99
|
+
"/_gorsee/*",
|
|
100
|
+
"/favicon.ico",
|
|
101
|
+
"/robots.txt",
|
|
102
|
+
"/styles.css",
|
|
103
|
+
"/*.png",
|
|
104
|
+
"/*.jpg",
|
|
105
|
+
"/*.svg",
|
|
106
|
+
"/*.woff2",
|
|
107
|
+
],
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Gorsee.js — Fly.io deploy adapter
|
|
2
|
+
|
|
3
|
+
export function generateFlyConfig(appName: string): string {
|
|
4
|
+
return `# Gorsee.js — Fly.io configuration
|
|
5
|
+
# Auto-generated by \`gorsee deploy fly\`
|
|
6
|
+
|
|
7
|
+
app = "${appName}"
|
|
8
|
+
primary_region = "iad"
|
|
9
|
+
|
|
10
|
+
[build]
|
|
11
|
+
dockerfile = "Dockerfile"
|
|
12
|
+
|
|
13
|
+
[env]
|
|
14
|
+
NODE_ENV = "production"
|
|
15
|
+
PORT = "3000"
|
|
16
|
+
|
|
17
|
+
[http_service]
|
|
18
|
+
internal_port = 3000
|
|
19
|
+
force_https = true
|
|
20
|
+
auto_stop_machines = "stop"
|
|
21
|
+
auto_start_machines = true
|
|
22
|
+
min_machines_running = 1
|
|
23
|
+
|
|
24
|
+
[http_service.concurrency]
|
|
25
|
+
type = "requests"
|
|
26
|
+
hard_limit = 250
|
|
27
|
+
soft_limit = 200
|
|
28
|
+
|
|
29
|
+
[[http_service.checks]]
|
|
30
|
+
grace_period = "10s"
|
|
31
|
+
interval = "30s"
|
|
32
|
+
method = "GET"
|
|
33
|
+
path = "/api/health"
|
|
34
|
+
timeout = "5s"
|
|
35
|
+
|
|
36
|
+
[[vm]]
|
|
37
|
+
size = "shared-cpu-1x"
|
|
38
|
+
memory = "512mb"
|
|
39
|
+
processes = ["app"]
|
|
40
|
+
count_min = 1
|
|
41
|
+
count_max = 3
|
|
42
|
+
`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function generateFlyDockerfile(): string {
|
|
46
|
+
return `# Gorsee.js — Fly.io optimized Dockerfile
|
|
47
|
+
# Auto-generated by \`gorsee deploy fly\`
|
|
48
|
+
|
|
49
|
+
FROM oven/bun:1 AS builder
|
|
50
|
+
WORKDIR /app
|
|
51
|
+
COPY package.json bun.lock* ./
|
|
52
|
+
RUN bun install --frozen-lockfile
|
|
53
|
+
COPY . .
|
|
54
|
+
RUN bun run build
|
|
55
|
+
|
|
56
|
+
FROM oven/bun:1-slim
|
|
57
|
+
WORKDIR /app
|
|
58
|
+
|
|
59
|
+
COPY --from=builder /app/package.json ./
|
|
60
|
+
COPY --from=builder /app/node_modules ./node_modules
|
|
61
|
+
COPY --from=builder /app/dist ./dist
|
|
62
|
+
COPY --from=builder /app/routes ./routes
|
|
63
|
+
COPY --from=builder /app/public ./public
|
|
64
|
+
COPY --from=builder /app/.env* ./
|
|
65
|
+
|
|
66
|
+
ENV NODE_ENV=production
|
|
67
|
+
ENV PORT=3000
|
|
68
|
+
|
|
69
|
+
# Fly.io runtime env vars are injected automatically:
|
|
70
|
+
# FLY_ALLOC_ID, FLY_APP_NAME, FLY_REGION, FLY_MACHINE_ID
|
|
71
|
+
|
|
72
|
+
EXPOSE 3000
|
|
73
|
+
|
|
74
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \\
|
|
75
|
+
CMD curl -f http://localhost:3000/api/health || exit 1
|
|
76
|
+
|
|
77
|
+
CMD ["bun", "run", "start"]
|
|
78
|
+
`
|
|
79
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Gorsee.js — Deploy adapters barrel export
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
generateDockerfile,
|
|
5
|
+
generateDockerignore,
|
|
6
|
+
} from "./dockerfile.ts"
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
generateVercelConfig,
|
|
10
|
+
generateVercelServerlessEntry,
|
|
11
|
+
generateVercelBuildOutput,
|
|
12
|
+
type VercelConfig,
|
|
13
|
+
type VercelOutputConfig,
|
|
14
|
+
} from "./vercel.ts"
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
generateFlyConfig,
|
|
18
|
+
generateFlyDockerfile,
|
|
19
|
+
} from "./fly.ts"
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
generateWranglerConfig,
|
|
23
|
+
generateCloudflareEntry,
|
|
24
|
+
generateCloudflareStaticAssets,
|
|
25
|
+
type CloudflareRoutesConfig,
|
|
26
|
+
} from "./cloudflare.ts"
|
|
27
|
+
|
|
28
|
+
export {
|
|
29
|
+
generateNetlifyConfig,
|
|
30
|
+
generateNetlifyFunction,
|
|
31
|
+
} from "./netlify.ts"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Gorsee.js — Netlify deploy adapter
|
|
2
|
+
|
|
3
|
+
export function generateNetlifyConfig(): string {
|
|
4
|
+
return `# Gorsee.js — Netlify configuration
|
|
5
|
+
# Auto-generated by \`gorsee deploy netlify\`
|
|
6
|
+
|
|
7
|
+
[build]
|
|
8
|
+
command = "bun run build"
|
|
9
|
+
publish = "dist/client"
|
|
10
|
+
|
|
11
|
+
[build.environment]
|
|
12
|
+
NODE_VERSION = "20"
|
|
13
|
+
|
|
14
|
+
[[edge_functions]]
|
|
15
|
+
function = "gorsee-handler"
|
|
16
|
+
path = "/*"
|
|
17
|
+
|
|
18
|
+
# Static assets bypass edge functions
|
|
19
|
+
[[edge_functions]]
|
|
20
|
+
function = "gorsee-handler"
|
|
21
|
+
path = "/_gorsee/*"
|
|
22
|
+
excludedPath = true
|
|
23
|
+
|
|
24
|
+
[[redirects]]
|
|
25
|
+
from = "/_gorsee/*"
|
|
26
|
+
to = "/client/:splat"
|
|
27
|
+
status = 200
|
|
28
|
+
|
|
29
|
+
[[redirects]]
|
|
30
|
+
from = "/*"
|
|
31
|
+
to = "/.netlify/edge-functions/gorsee-handler"
|
|
32
|
+
status = 200
|
|
33
|
+
force = false
|
|
34
|
+
|
|
35
|
+
[[headers]]
|
|
36
|
+
for = "/_gorsee/*"
|
|
37
|
+
[headers.values]
|
|
38
|
+
Cache-Control = "public, max-age=31536000, immutable"
|
|
39
|
+
`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function generateNetlifyFunction(): string {
|
|
43
|
+
return `// Gorsee.js — Netlify Edge Function
|
|
44
|
+
// Auto-generated by \`gorsee deploy netlify\`
|
|
45
|
+
// Place in: netlify/edge-functions/gorsee-handler.ts
|
|
46
|
+
|
|
47
|
+
import type { Context } from "https://edge.netlify.com"
|
|
48
|
+
|
|
49
|
+
export default async function handler(
|
|
50
|
+
request: Request,
|
|
51
|
+
context: Context,
|
|
52
|
+
): Promise<Response> {
|
|
53
|
+
const url = new URL(request.url)
|
|
54
|
+
|
|
55
|
+
// Skip static assets — handled by Netlify CDN
|
|
56
|
+
if (url.pathname.startsWith("/_gorsee/")) {
|
|
57
|
+
return context.next()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const staticExts = [".css", ".ico", ".svg", ".png", ".jpg", ".woff2", ".txt"]
|
|
61
|
+
if (staticExts.some((ext) => url.pathname.endsWith(ext))) {
|
|
62
|
+
return context.next()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Forward to Gorsee server handler
|
|
66
|
+
try {
|
|
67
|
+
const { handleRequest } = await import("../../dist/server-handler.js")
|
|
68
|
+
return await handleRequest(request, { netlifyContext: context })
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.error("Gorsee handler error:", err)
|
|
71
|
+
return new Response("Internal Server Error", { status: 500 })
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const config = { path: "/*" }
|
|
76
|
+
`
|
|
77
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Gorsee.js — Vercel deploy adapter
|
|
2
|
+
|
|
3
|
+
export interface VercelConfig {
|
|
4
|
+
version: number
|
|
5
|
+
framework: null
|
|
6
|
+
buildCommand: string
|
|
7
|
+
outputDirectory: string
|
|
8
|
+
routes: Array<{ src: string; dest?: string; headers?: Record<string, string> }>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function generateVercelConfig(): VercelConfig {
|
|
12
|
+
return {
|
|
13
|
+
version: 2,
|
|
14
|
+
framework: null,
|
|
15
|
+
buildCommand: "bun run build",
|
|
16
|
+
outputDirectory: ".vercel/output",
|
|
17
|
+
routes: [
|
|
18
|
+
{
|
|
19
|
+
src: "/_gorsee/(.*)",
|
|
20
|
+
headers: { "Cache-Control": "public, max-age=31536000, immutable" },
|
|
21
|
+
},
|
|
22
|
+
{ src: "/(.*)", dest: "/api/index" },
|
|
23
|
+
],
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function generateVercelServerlessEntry(): string {
|
|
28
|
+
return `// Gorsee.js — Vercel serverless entry
|
|
29
|
+
// Auto-generated by \`gorsee deploy vercel\`
|
|
30
|
+
|
|
31
|
+
import { startProductionServer } from "./dist/prod.js"
|
|
32
|
+
|
|
33
|
+
let initialized = false
|
|
34
|
+
|
|
35
|
+
async function ensureInit() {
|
|
36
|
+
if (!initialized) {
|
|
37
|
+
initialized = true
|
|
38
|
+
// Production server setup without Bun.serve()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default async function handler(request: Request): Promise<Response> {
|
|
43
|
+
await ensureInit()
|
|
44
|
+
|
|
45
|
+
const url = new URL(request.url)
|
|
46
|
+
|
|
47
|
+
// Serve static client assets
|
|
48
|
+
if (url.pathname.startsWith("/_gorsee/")) {
|
|
49
|
+
return new Response(null, { status: 404 })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Forward to Gorsee production handler
|
|
53
|
+
const { createRouter, matchRoute, buildStaticMap } = await import("./dist/router/index.js")
|
|
54
|
+
const { renderToString, ssrJsx } = await import("./dist/runtime/server.js")
|
|
55
|
+
const { handleRPCRequest } = await import("./dist/server/rpc.js")
|
|
56
|
+
|
|
57
|
+
// RPC handling
|
|
58
|
+
const rpcResponse = await handleRPCRequest(request)
|
|
59
|
+
if (rpcResponse) return rpcResponse
|
|
60
|
+
|
|
61
|
+
// Return placeholder — full implementation wires into prod.ts handler
|
|
62
|
+
return new Response("Gorsee on Vercel", {
|
|
63
|
+
headers: { "Content-Type": "text/html" },
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface VercelOutputConfig {
|
|
70
|
+
version: number
|
|
71
|
+
routes: Array<{ src: string; dest?: string; headers?: Record<string, string> }>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function generateVercelBuildOutput(routes: string[]): VercelOutputConfig {
|
|
75
|
+
const outputRoutes: VercelOutputConfig["routes"] = [
|
|
76
|
+
{
|
|
77
|
+
src: "/_gorsee/(.*)",
|
|
78
|
+
dest: "/static/_gorsee/$1",
|
|
79
|
+
headers: { "Cache-Control": "public, max-age=31536000, immutable" },
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
src: "/favicon\\.ico|/robots\\.txt|/styles\\.css",
|
|
83
|
+
dest: "/static/$0",
|
|
84
|
+
},
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
for (const route of routes) {
|
|
88
|
+
outputRoutes.push({ src: route, dest: "/functions/index" })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
outputRoutes.push({ src: "/(.*)", dest: "/functions/index" })
|
|
92
|
+
|
|
93
|
+
return { version: 3, routes: outputRoutes }
|
|
94
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -25,3 +25,4 @@ export { createEventSource } from "./server/sse.ts"
|
|
|
25
25
|
export { createAuth } from "./auth/index.ts"
|
|
26
26
|
export { typedLink, typedNavigate } from "./runtime/typed-routes.ts"
|
|
27
27
|
export { defineForm, validateForm, fieldAttrs } from "./runtime/validated-form.ts"
|
|
28
|
+
export { definePlugin, createPluginRunner, type GorseePlugin, type PluginContext } from "./plugins/index.ts"
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Drizzle ORM integration plugin -- zero external dependencies
|
|
2
|
+
|
|
3
|
+
import type { MiddlewareFn, Context } from "../server/middleware.ts"
|
|
4
|
+
import type { GorseePlugin } from "./index.ts"
|
|
5
|
+
import { definePlugin } from "./index.ts"
|
|
6
|
+
|
|
7
|
+
export interface DrizzlePluginConfig {
|
|
8
|
+
schema: string
|
|
9
|
+
out: string
|
|
10
|
+
dialect: "sqlite" | "postgres" | "mysql"
|
|
11
|
+
connectionUrl?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let drizzleInstance: unknown = null
|
|
15
|
+
|
|
16
|
+
/** Returns the current drizzle instance (available after setup) */
|
|
17
|
+
export function getDrizzle<T = unknown>(): T {
|
|
18
|
+
if (!drizzleInstance) {
|
|
19
|
+
throw new Error("Drizzle not initialized. Did you register drizzlePlugin?")
|
|
20
|
+
}
|
|
21
|
+
return drizzleInstance as T
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Middleware that attaches drizzle instance to ctx.locals.db */
|
|
25
|
+
export function drizzleMiddleware(instance: unknown): MiddlewareFn {
|
|
26
|
+
return async (ctx: Context, next) => {
|
|
27
|
+
ctx.locals.db = instance
|
|
28
|
+
return next()
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Generates drizzle.config.ts content string */
|
|
33
|
+
export function generateDrizzleConfig(config: DrizzlePluginConfig): string {
|
|
34
|
+
const dbCredentials =
|
|
35
|
+
config.dialect === "sqlite"
|
|
36
|
+
? `{ url: "${config.connectionUrl ?? "./data.db"}" }`
|
|
37
|
+
: `{ connectionString: "${config.connectionUrl ?? ""}" }`
|
|
38
|
+
|
|
39
|
+
return `import { defineConfig } from "drizzle-kit"
|
|
40
|
+
|
|
41
|
+
export default defineConfig({
|
|
42
|
+
schema: "${config.schema}",
|
|
43
|
+
out: "${config.out}",
|
|
44
|
+
dialect: "${config.dialect}",
|
|
45
|
+
dbCredentials: ${dbCredentials},
|
|
46
|
+
})
|
|
47
|
+
`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Creates a Drizzle ORM integration plugin */
|
|
51
|
+
export function drizzlePlugin(config: DrizzlePluginConfig): GorseePlugin {
|
|
52
|
+
return definePlugin({
|
|
53
|
+
name: "gorsee-drizzle",
|
|
54
|
+
|
|
55
|
+
async setup(app) {
|
|
56
|
+
if (config.dialect === "sqlite") {
|
|
57
|
+
const { Database } = await import("bun:sqlite" as string)
|
|
58
|
+
const db = new Database(config.connectionUrl ?? "./data.db")
|
|
59
|
+
db.exec("PRAGMA journal_mode=WAL")
|
|
60
|
+
|
|
61
|
+
// Dynamic import for drizzle-orm/bun-sqlite
|
|
62
|
+
try {
|
|
63
|
+
const { drizzle } = await import("drizzle-orm/bun-sqlite" as string)
|
|
64
|
+
drizzleInstance = drizzle(db)
|
|
65
|
+
} catch {
|
|
66
|
+
// If drizzle-orm not installed, store raw db
|
|
67
|
+
drizzleInstance = db
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
// For postgres/mysql, store connection URL for user to configure
|
|
71
|
+
drizzleInstance = { dialect: config.dialect, url: config.connectionUrl }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
app.addMiddleware(drizzleMiddleware(drizzleInstance))
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
async teardown() {
|
|
78
|
+
if (drizzleInstance && typeof (drizzleInstance as any).close === "function") {
|
|
79
|
+
;(drizzleInstance as any).close()
|
|
80
|
+
}
|
|
81
|
+
drizzleInstance = null
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Gorsee plugin system -- lifecycle management for framework extensions
|
|
2
|
+
|
|
3
|
+
import type { MiddlewareFn, Context } from "../server/middleware.ts"
|
|
4
|
+
|
|
5
|
+
export interface GorseePlugin {
|
|
6
|
+
name: string
|
|
7
|
+
/** Hook into server startup */
|
|
8
|
+
setup?: (app: PluginContext) => void | Promise<void>
|
|
9
|
+
/** Add middleware to every request */
|
|
10
|
+
middleware?: MiddlewareFn
|
|
11
|
+
/** Hook into build pipeline */
|
|
12
|
+
buildPlugins?: () => import("bun").BunPlugin[]
|
|
13
|
+
/** Cleanup on shutdown */
|
|
14
|
+
teardown?: () => void | Promise<void>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PluginContext {
|
|
18
|
+
/** Register a middleware */
|
|
19
|
+
addMiddleware(mw: MiddlewareFn): void
|
|
20
|
+
/** Register a route programmatically */
|
|
21
|
+
addRoute(path: string, handler: (ctx: Context) => Promise<Response>): void
|
|
22
|
+
/** Access app config */
|
|
23
|
+
config: Record<string, unknown>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Identity helper for type inference */
|
|
27
|
+
export function definePlugin(plugin: GorseePlugin): GorseePlugin {
|
|
28
|
+
return plugin
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PluginRunner {
|
|
32
|
+
register(plugin: GorseePlugin): void
|
|
33
|
+
setupAll(): Promise<void>
|
|
34
|
+
teardownAll(): Promise<void>
|
|
35
|
+
getMiddlewares(): MiddlewareFn[]
|
|
36
|
+
getBuildPlugins(): import("bun").BunPlugin[]
|
|
37
|
+
getRoutes(): Map<string, (ctx: Context) => Promise<Response>>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Creates a plugin runner that manages the full plugin lifecycle */
|
|
41
|
+
export function createPluginRunner(
|
|
42
|
+
config: Record<string, unknown> = {},
|
|
43
|
+
): PluginRunner {
|
|
44
|
+
const plugins: GorseePlugin[] = []
|
|
45
|
+
const middlewares: MiddlewareFn[] = []
|
|
46
|
+
const routes = new Map<string, (ctx: Context) => Promise<Response>>()
|
|
47
|
+
|
|
48
|
+
const pluginCtx: PluginContext = {
|
|
49
|
+
addMiddleware: (mw) => middlewares.push(mw),
|
|
50
|
+
addRoute: (path, handler) => routes.set(path, handler),
|
|
51
|
+
config,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
register(plugin: GorseePlugin) {
|
|
56
|
+
plugins.push(plugin)
|
|
57
|
+
if (plugin.middleware) {
|
|
58
|
+
middlewares.push(plugin.middleware)
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
async setupAll() {
|
|
63
|
+
for (const plugin of plugins) {
|
|
64
|
+
if (plugin.setup) await plugin.setup(pluginCtx)
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
async teardownAll() {
|
|
69
|
+
for (const plugin of [...plugins].reverse()) {
|
|
70
|
+
if (plugin.teardown) await plugin.teardown()
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
getMiddlewares: () => [...middlewares],
|
|
75
|
+
|
|
76
|
+
getBuildPlugins() {
|
|
77
|
+
const result: import("bun").BunPlugin[] = []
|
|
78
|
+
for (const plugin of plugins) {
|
|
79
|
+
if (plugin.buildPlugins) result.push(...plugin.buildPlugins())
|
|
80
|
+
}
|
|
81
|
+
return result
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
getRoutes: () => new Map(routes),
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Lucia auth adapter plugin -- session management integration
|
|
2
|
+
|
|
3
|
+
import type { MiddlewareFn, Context } from "../server/middleware.ts"
|
|
4
|
+
import type { GorseePlugin } from "./index.ts"
|
|
5
|
+
import { definePlugin } from "./index.ts"
|
|
6
|
+
|
|
7
|
+
export interface LuciaPluginConfig {
|
|
8
|
+
adapter: "sqlite" | "prisma" | "drizzle"
|
|
9
|
+
sessionTable?: string
|
|
10
|
+
userTable?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface LuciaInstance {
|
|
14
|
+
validateSession(sessionId: string): Promise<{ session: unknown; user: unknown } | null>
|
|
15
|
+
createSession(userId: string, attributes?: Record<string, unknown>): Promise<{ id: string }>
|
|
16
|
+
invalidateSession(sessionId: string): Promise<void>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let luciaInstance: LuciaInstance | null = null
|
|
20
|
+
|
|
21
|
+
/** Returns the Lucia instance (available after setup) */
|
|
22
|
+
export function getLucia(): LuciaInstance {
|
|
23
|
+
if (!luciaInstance) {
|
|
24
|
+
throw new Error("Lucia not initialized. Did you register luciaPlugin?")
|
|
25
|
+
}
|
|
26
|
+
return luciaInstance
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Extracts current session from context (set by middleware) */
|
|
30
|
+
export function getSession(ctx: Context): unknown | null {
|
|
31
|
+
return ctx.locals.session ?? null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Extracts current user from context (set by middleware) */
|
|
35
|
+
export function getUser(ctx: Context): unknown | null {
|
|
36
|
+
return ctx.locals.user ?? null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Creates the session validation middleware */
|
|
40
|
+
function createSessionMiddleware(): MiddlewareFn {
|
|
41
|
+
return async (ctx: Context, next) => {
|
|
42
|
+
ctx.locals.session = null
|
|
43
|
+
ctx.locals.user = null
|
|
44
|
+
|
|
45
|
+
const sessionId =
|
|
46
|
+
ctx.cookies.get("auth_session") ??
|
|
47
|
+
ctx.request.headers.get("Authorization")?.replace("Bearer ", "") ??
|
|
48
|
+
null
|
|
49
|
+
|
|
50
|
+
if (sessionId && luciaInstance) {
|
|
51
|
+
try {
|
|
52
|
+
const result = await luciaInstance.validateSession(sessionId)
|
|
53
|
+
if (result) {
|
|
54
|
+
ctx.locals.session = result.session
|
|
55
|
+
ctx.locals.user = result.user
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// Invalid session -- continue without auth
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return next()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Creates a Lucia auth integration plugin */
|
|
67
|
+
export function luciaPlugin(config: LuciaPluginConfig): GorseePlugin {
|
|
68
|
+
return definePlugin({
|
|
69
|
+
name: "gorsee-lucia",
|
|
70
|
+
|
|
71
|
+
async setup() {
|
|
72
|
+
try {
|
|
73
|
+
const { Lucia } = await import("lucia" as string)
|
|
74
|
+
let adapter: unknown
|
|
75
|
+
|
|
76
|
+
if (config.adapter === "sqlite") {
|
|
77
|
+
const { BunSQLiteAdapter } = await import("@lucia-auth/adapter-sqlite" as string)
|
|
78
|
+
const { Database } = await import("bun:sqlite" as string)
|
|
79
|
+
const db = new Database("./data.db")
|
|
80
|
+
adapter = new BunSQLiteAdapter(db, {
|
|
81
|
+
session: config.sessionTable ?? "session",
|
|
82
|
+
user: config.userTable ?? "user",
|
|
83
|
+
})
|
|
84
|
+
} else if (config.adapter === "prisma") {
|
|
85
|
+
const { PrismaAdapter } = await import("@lucia-auth/adapter-prisma" as string)
|
|
86
|
+
const { PrismaClient } = await import("@prisma/client" as string)
|
|
87
|
+
adapter = new PrismaAdapter(new PrismaClient(), {
|
|
88
|
+
session: config.sessionTable ?? "session",
|
|
89
|
+
user: config.userTable ?? "user",
|
|
90
|
+
})
|
|
91
|
+
} else {
|
|
92
|
+
// Drizzle adapter -- user must configure separately
|
|
93
|
+
adapter = null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (adapter) {
|
|
97
|
+
luciaInstance = new Lucia(adapter) as unknown as LuciaInstance
|
|
98
|
+
}
|
|
99
|
+
} catch {
|
|
100
|
+
// Lucia not installed -- instance stays null
|
|
101
|
+
luciaInstance = null
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
middleware: createSessionMiddleware(),
|
|
106
|
+
|
|
107
|
+
async teardown() {
|
|
108
|
+
luciaInstance = null
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Prisma integration plugin -- zero external dependencies
|
|
2
|
+
|
|
3
|
+
import type { MiddlewareFn, Context } from "../server/middleware.ts"
|
|
4
|
+
import type { GorseePlugin } from "./index.ts"
|
|
5
|
+
import { definePlugin } from "./index.ts"
|
|
6
|
+
|
|
7
|
+
export interface PrismaPluginConfig {
|
|
8
|
+
schemaPath?: string
|
|
9
|
+
datasourceUrl?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let prismaClient: unknown = null
|
|
13
|
+
|
|
14
|
+
/** Returns the current Prisma client (available after setup) */
|
|
15
|
+
export function getPrisma<T = unknown>(): T {
|
|
16
|
+
if (!prismaClient) {
|
|
17
|
+
throw new Error("Prisma not initialized. Did you register prismaPlugin?")
|
|
18
|
+
}
|
|
19
|
+
return prismaClient as T
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Middleware that attaches prisma to ctx.locals.prisma */
|
|
23
|
+
export function prismaMiddleware(client: unknown): MiddlewareFn {
|
|
24
|
+
return async (ctx: Context, next) => {
|
|
25
|
+
ctx.locals.prisma = client
|
|
26
|
+
return next()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Generates a basic schema.prisma content string */
|
|
31
|
+
export function generatePrismaSchema(config: PrismaPluginConfig): string {
|
|
32
|
+
const url = config.datasourceUrl ?? "file:./dev.db"
|
|
33
|
+
const provider = url.startsWith("file:") ? "sqlite"
|
|
34
|
+
: url.startsWith("postgres") ? "postgresql"
|
|
35
|
+
: "mysql"
|
|
36
|
+
|
|
37
|
+
return `generator client {
|
|
38
|
+
provider = "prisma-client-js"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
datasource db {
|
|
42
|
+
provider = "${provider}"
|
|
43
|
+
url = "${url}"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Add your models below
|
|
47
|
+
// model User {
|
|
48
|
+
// id Int @id @default(autoincrement())
|
|
49
|
+
// email String @unique
|
|
50
|
+
// name String?
|
|
51
|
+
// }
|
|
52
|
+
`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Creates a Prisma integration plugin */
|
|
56
|
+
export function prismaPlugin(config: PrismaPluginConfig = {}): GorseePlugin {
|
|
57
|
+
return definePlugin({
|
|
58
|
+
name: "gorsee-prisma",
|
|
59
|
+
|
|
60
|
+
async setup(app) {
|
|
61
|
+
try {
|
|
62
|
+
// Dynamic import -- PrismaClient must be generated by user
|
|
63
|
+
const { PrismaClient } = await import("@prisma/client" as string)
|
|
64
|
+
prismaClient = new PrismaClient({
|
|
65
|
+
datasourceUrl: config.datasourceUrl,
|
|
66
|
+
})
|
|
67
|
+
} catch {
|
|
68
|
+
// If @prisma/client not available, create a placeholder
|
|
69
|
+
prismaClient = {
|
|
70
|
+
_placeholder: true,
|
|
71
|
+
_message: "Run `bunx prisma generate` to create the client",
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
app.addMiddleware(prismaMiddleware(prismaClient))
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
async teardown() {
|
|
79
|
+
if (prismaClient && typeof (prismaClient as any).$disconnect === "function") {
|
|
80
|
+
await (prismaClient as any).$disconnect()
|
|
81
|
+
}
|
|
82
|
+
prismaClient = null
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Resend email plugin -- uses native fetch, no SDK dependency
|
|
2
|
+
|
|
3
|
+
import type { GorseePlugin } from "./index.ts"
|
|
4
|
+
import { definePlugin } from "./index.ts"
|
|
5
|
+
|
|
6
|
+
export interface ResendPluginConfig {
|
|
7
|
+
apiKey: string
|
|
8
|
+
from?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SendEmailOptions {
|
|
12
|
+
to: string | string[]
|
|
13
|
+
subject: string
|
|
14
|
+
html?: string
|
|
15
|
+
text?: string
|
|
16
|
+
from?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Mailer {
|
|
20
|
+
send(options: SendEmailOptions): Promise<{ id: string }>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const RESEND_API = "https://api.resend.com"
|
|
24
|
+
|
|
25
|
+
let mailerInstance: Mailer | null = null
|
|
26
|
+
|
|
27
|
+
/** Returns the mailer instance (available after setup) */
|
|
28
|
+
export function getMailer(): Mailer {
|
|
29
|
+
if (!mailerInstance) {
|
|
30
|
+
throw new Error("Resend not initialized. Did you register resendPlugin?")
|
|
31
|
+
}
|
|
32
|
+
return mailerInstance
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createMailer(config: ResendPluginConfig): Mailer {
|
|
36
|
+
return {
|
|
37
|
+
async send(options: SendEmailOptions): Promise<{ id: string }> {
|
|
38
|
+
const body = {
|
|
39
|
+
from: options.from ?? config.from ?? "onboarding@resend.dev",
|
|
40
|
+
to: Array.isArray(options.to) ? options.to : [options.to],
|
|
41
|
+
subject: options.subject,
|
|
42
|
+
...(options.html ? { html: options.html } : {}),
|
|
43
|
+
...(options.text ? { text: options.text } : {}),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const res = await fetch(`${RESEND_API}/emails`, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: {
|
|
49
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
50
|
+
"Content-Type": "application/json",
|
|
51
|
+
},
|
|
52
|
+
body: JSON.stringify(body),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
const err = await res.text()
|
|
57
|
+
throw new Error(`Resend API error (${res.status}): ${err}`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (await res.json()) as { id: string }
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Creates a Resend email plugin */
|
|
66
|
+
export function resendPlugin(config: ResendPluginConfig): GorseePlugin {
|
|
67
|
+
return definePlugin({
|
|
68
|
+
name: "gorsee-resend",
|
|
69
|
+
|
|
70
|
+
async setup() {
|
|
71
|
+
mailerInstance = createMailer(config)
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async teardown() {
|
|
75
|
+
mailerInstance = null
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// S3-compatible object storage plugin -- uses native fetch, no AWS SDK
|
|
2
|
+
|
|
3
|
+
import type { GorseePlugin } from "./index.ts"
|
|
4
|
+
import { definePlugin } from "./index.ts"
|
|
5
|
+
|
|
6
|
+
export interface S3PluginConfig {
|
|
7
|
+
bucket: string
|
|
8
|
+
region?: string
|
|
9
|
+
endpoint?: string
|
|
10
|
+
accessKeyId?: string
|
|
11
|
+
secretAccessKey?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface StorageClient {
|
|
15
|
+
upload(key: string, body: ArrayBuffer | ReadableStream | string, contentType?: string): Promise<string>
|
|
16
|
+
download(key: string): Promise<Response>
|
|
17
|
+
delete(key: string): Promise<void>
|
|
18
|
+
list(prefix?: string): Promise<string[]>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let storageClient: StorageClient | null = null
|
|
22
|
+
|
|
23
|
+
/** Returns the storage client (available after setup) */
|
|
24
|
+
export function getStorage(): StorageClient {
|
|
25
|
+
if (!storageClient) {
|
|
26
|
+
throw new Error("S3 not initialized. Did you register s3Plugin?")
|
|
27
|
+
}
|
|
28
|
+
return storageClient
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildEndpoint(config: S3PluginConfig): string {
|
|
32
|
+
if (config.endpoint) return config.endpoint.replace(/\/$/, "")
|
|
33
|
+
const region = config.region ?? "us-east-1"
|
|
34
|
+
return `https://${config.bucket}.s3.${region}.amazonaws.com`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createStorageClient(config: S3PluginConfig): StorageClient {
|
|
38
|
+
const baseUrl = buildEndpoint(config)
|
|
39
|
+
const headers: Record<string, string> = {}
|
|
40
|
+
|
|
41
|
+
// Basic auth headers (simplified -- production should use AWS Signature V4)
|
|
42
|
+
if (config.accessKeyId) {
|
|
43
|
+
headers["x-amz-access-key"] = config.accessKeyId
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
async upload(key: string, body: ArrayBuffer | ReadableStream | string, contentType?: string) {
|
|
48
|
+
const url = `${baseUrl}/${encodeURIComponent(key)}`
|
|
49
|
+
const ct = contentType ?? "application/octet-stream"
|
|
50
|
+
const fetchBody: BodyInit = body instanceof ArrayBuffer
|
|
51
|
+
? new Blob([body], { type: ct })
|
|
52
|
+
: body
|
|
53
|
+
const res = await fetch(url, {
|
|
54
|
+
method: "PUT",
|
|
55
|
+
headers: { ...headers, "Content-Type": ct },
|
|
56
|
+
body: fetchBody,
|
|
57
|
+
})
|
|
58
|
+
if (!res.ok) throw new Error(`S3 upload failed: ${res.status} ${res.statusText}`)
|
|
59
|
+
return url
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
async download(key) {
|
|
63
|
+
const url = `${baseUrl}/${encodeURIComponent(key)}`
|
|
64
|
+
const res = await fetch(url, { headers })
|
|
65
|
+
if (!res.ok) throw new Error(`S3 download failed: ${res.status} ${res.statusText}`)
|
|
66
|
+
return res
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
async delete(key) {
|
|
70
|
+
const url = `${baseUrl}/${encodeURIComponent(key)}`
|
|
71
|
+
const res = await fetch(url, { method: "DELETE", headers })
|
|
72
|
+
if (!res.ok) throw new Error(`S3 delete failed: ${res.status} ${res.statusText}`)
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
async list(prefix) {
|
|
76
|
+
const params = prefix ? `?list-type=2&prefix=${encodeURIComponent(prefix)}` : "?list-type=2"
|
|
77
|
+
const res = await fetch(`${baseUrl}${params}`, { headers })
|
|
78
|
+
if (!res.ok) throw new Error(`S3 list failed: ${res.status} ${res.statusText}`)
|
|
79
|
+
const xml = await res.text()
|
|
80
|
+
const keys: string[] = []
|
|
81
|
+
const regex = /<Key>([^<]+)<\/Key>/g
|
|
82
|
+
let match: RegExpExecArray | null
|
|
83
|
+
while ((match = regex.exec(xml)) !== null) keys.push(match[1]!)
|
|
84
|
+
return keys
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Creates an S3-compatible storage plugin */
|
|
90
|
+
export function s3Plugin(config: S3PluginConfig): GorseePlugin {
|
|
91
|
+
return definePlugin({
|
|
92
|
+
name: "gorsee-s3",
|
|
93
|
+
|
|
94
|
+
async setup() {
|
|
95
|
+
storageClient = createStorageClient(config)
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
async teardown() {
|
|
99
|
+
storageClient = null
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Stripe payments plugin -- uses native fetch, no stripe SDK dependency
|
|
2
|
+
|
|
3
|
+
import type { MiddlewareFn, Context } from "../server/middleware.ts"
|
|
4
|
+
import type { GorseePlugin } from "./index.ts"
|
|
5
|
+
import { definePlugin } from "./index.ts"
|
|
6
|
+
|
|
7
|
+
export interface StripePluginConfig {
|
|
8
|
+
secretKey: string
|
|
9
|
+
webhookSecret?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface CheckoutSessionOptions {
|
|
13
|
+
lineItems: Array<{ price: string; quantity: number }>
|
|
14
|
+
mode?: "payment" | "subscription"
|
|
15
|
+
successUrl: string
|
|
16
|
+
cancelUrl: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface StripeEvent {
|
|
20
|
+
id: string
|
|
21
|
+
type: string
|
|
22
|
+
data: { object: Record<string, unknown> }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface StripeClient {
|
|
26
|
+
createCheckoutSession(options: CheckoutSessionOptions): Promise<{ url: string; id: string }>
|
|
27
|
+
verifyWebhook(request: Request): Promise<StripeEvent>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const STRIPE_API = "https://api.stripe.com/v1"
|
|
31
|
+
|
|
32
|
+
let stripeClient: StripeClient | null = null
|
|
33
|
+
|
|
34
|
+
/** Returns the Stripe client (available after setup) */
|
|
35
|
+
export function getStripe(): StripeClient {
|
|
36
|
+
if (!stripeClient) {
|
|
37
|
+
throw new Error("Stripe not initialized. Did you register stripePlugin?")
|
|
38
|
+
}
|
|
39
|
+
return stripeClient
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function createStripeClient(config: StripePluginConfig): StripeClient {
|
|
43
|
+
const authHeader = `Basic ${btoa(config.secretKey + ":")}`
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
async createCheckoutSession(options) {
|
|
47
|
+
const params = new URLSearchParams()
|
|
48
|
+
params.set("mode", options.mode ?? "payment")
|
|
49
|
+
params.set("success_url", options.successUrl)
|
|
50
|
+
params.set("cancel_url", options.cancelUrl)
|
|
51
|
+
options.lineItems.forEach((item, i) => {
|
|
52
|
+
params.set(`line_items[${i}][price]`, item.price)
|
|
53
|
+
params.set(`line_items[${i}][quantity]`, String(item.quantity))
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const res = await fetch(`${STRIPE_API}/checkout/sessions`, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: { Authorization: authHeader, "Content-Type": "application/x-www-form-urlencoded" },
|
|
59
|
+
body: params.toString(),
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
const err = await res.text()
|
|
64
|
+
throw new Error(`Stripe API error (${res.status}): ${err}`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const data = (await res.json()) as { url: string; id: string }
|
|
68
|
+
return { url: data.url, id: data.id }
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
async verifyWebhook(request: Request) {
|
|
72
|
+
const body = await request.text()
|
|
73
|
+
const sig = request.headers.get("stripe-signature") ?? ""
|
|
74
|
+
|
|
75
|
+
if (!config.webhookSecret) {
|
|
76
|
+
throw new Error("Webhook secret not configured")
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Verify signature using HMAC-SHA256
|
|
80
|
+
const encoder = new TextEncoder()
|
|
81
|
+
const timestamp = sig.split(",").find((s) => s.startsWith("t="))?.slice(2) ?? ""
|
|
82
|
+
const v1Sig = sig.split(",").find((s) => s.startsWith("v1="))?.slice(3) ?? ""
|
|
83
|
+
const payload = `${timestamp}.${body}`
|
|
84
|
+
|
|
85
|
+
const key = await crypto.subtle.importKey(
|
|
86
|
+
"raw",
|
|
87
|
+
encoder.encode(config.webhookSecret),
|
|
88
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
89
|
+
false,
|
|
90
|
+
["sign"],
|
|
91
|
+
)
|
|
92
|
+
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload))
|
|
93
|
+
const expected = Array.from(new Uint8Array(signature))
|
|
94
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
95
|
+
.join("")
|
|
96
|
+
|
|
97
|
+
if (expected !== v1Sig) {
|
|
98
|
+
throw new Error("Invalid webhook signature")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return JSON.parse(body) as StripeEvent
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Creates a Stripe payments plugin */
|
|
107
|
+
export function stripePlugin(config: StripePluginConfig): GorseePlugin {
|
|
108
|
+
return definePlugin({
|
|
109
|
+
name: "gorsee-stripe",
|
|
110
|
+
|
|
111
|
+
async setup(app) {
|
|
112
|
+
stripeClient = createStripeClient(config)
|
|
113
|
+
|
|
114
|
+
// Register webhook route if webhook secret is configured
|
|
115
|
+
if (config.webhookSecret) {
|
|
116
|
+
app.addRoute("/api/stripe/webhook", async (ctx) => {
|
|
117
|
+
try {
|
|
118
|
+
const event = await stripeClient!.verifyWebhook(ctx.request)
|
|
119
|
+
return new Response(JSON.stringify({ received: true, type: event.type }), {
|
|
120
|
+
headers: { "Content-Type": "application/json" },
|
|
121
|
+
})
|
|
122
|
+
} catch (err) {
|
|
123
|
+
return new Response(JSON.stringify({ error: String(err) }), { status: 400 })
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
async teardown() {
|
|
130
|
+
stripeClient = null
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Tailwind CSS integration plugin -- build pipeline hook
|
|
2
|
+
|
|
3
|
+
import type { GorseePlugin } from "./index.ts"
|
|
4
|
+
import { definePlugin } from "./index.ts"
|
|
5
|
+
|
|
6
|
+
export interface TailwindPluginConfig {
|
|
7
|
+
configPath?: string
|
|
8
|
+
inputCSS?: string
|
|
9
|
+
outputCSS?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Generates tailwind.config.ts content */
|
|
13
|
+
export function generateTailwindConfig(options?: {
|
|
14
|
+
content?: string[]
|
|
15
|
+
theme?: Record<string, unknown>
|
|
16
|
+
}): string {
|
|
17
|
+
const content = options?.content ?? [
|
|
18
|
+
"./routes/**/*.{tsx,ts}",
|
|
19
|
+
"./components/**/*.{tsx,ts}",
|
|
20
|
+
]
|
|
21
|
+
const themeStr = options?.theme
|
|
22
|
+
? JSON.stringify(options.theme, null, 4)
|
|
23
|
+
: "{}"
|
|
24
|
+
|
|
25
|
+
return `/** @type {import('tailwindcss').Config} */
|
|
26
|
+
export default {
|
|
27
|
+
content: ${JSON.stringify(content, null, 4)},
|
|
28
|
+
theme: {
|
|
29
|
+
extend: ${themeStr},
|
|
30
|
+
},
|
|
31
|
+
plugins: [],
|
|
32
|
+
}
|
|
33
|
+
`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Generates base CSS with @tailwind directives */
|
|
37
|
+
export function generateTailwindCSS(): string {
|
|
38
|
+
return `@tailwind base;
|
|
39
|
+
@tailwind components;
|
|
40
|
+
@tailwind utilities;
|
|
41
|
+
`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Creates a Tailwind CSS integration plugin */
|
|
45
|
+
export function tailwindPlugin(config: TailwindPluginConfig = {}): GorseePlugin {
|
|
46
|
+
const inputCSS = config.inputCSS ?? "./styles/globals.css"
|
|
47
|
+
const outputCSS = config.outputCSS ?? "./.gorsee/client/tailwind.css"
|
|
48
|
+
|
|
49
|
+
return definePlugin({
|
|
50
|
+
name: "gorsee-tailwind",
|
|
51
|
+
|
|
52
|
+
async setup() {
|
|
53
|
+
// Generate tailwind.config.ts if it doesn't exist
|
|
54
|
+
const configPath = config.configPath ?? "./tailwind.config.ts"
|
|
55
|
+
const file = Bun.file(configPath)
|
|
56
|
+
if (!(await file.exists())) {
|
|
57
|
+
await Bun.write(configPath, generateTailwindConfig())
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
buildPlugins() {
|
|
62
|
+
return [
|
|
63
|
+
{
|
|
64
|
+
name: "gorsee-tailwind-transform",
|
|
65
|
+
setup(build) {
|
|
66
|
+
build.onLoad({ filter: /\.css$/ }, async (args) => {
|
|
67
|
+
const source = await Bun.file(args.path).text()
|
|
68
|
+
|
|
69
|
+
// If file contains @tailwind directives, process it
|
|
70
|
+
if (source.includes("@tailwind")) {
|
|
71
|
+
try {
|
|
72
|
+
const proc = Bun.spawn(
|
|
73
|
+
["bunx", "tailwindcss", "-i", args.path, "-o", outputCSS, "--minify"],
|
|
74
|
+
{ stdin: "inherit", stdout: "pipe", stderr: "pipe" },
|
|
75
|
+
)
|
|
76
|
+
await proc.exited
|
|
77
|
+
const processed = await Bun.file(outputCSS).text()
|
|
78
|
+
return { contents: processed, loader: "css" }
|
|
79
|
+
} catch {
|
|
80
|
+
// Fallback: return raw CSS if tailwindcss CLI not available
|
|
81
|
+
return { contents: source, loader: "css" }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return undefined
|
|
86
|
+
})
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
]
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
}
|