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
@@ -0,0 +1,87 @@
1
+ // Bun.build plugin: strips server-only code from client bundles
2
+ // - Removes export function loader() and its body
3
+ // - Replaces server-only modules (gorsee/db) with empty stubs
4
+
5
+ import type { BunPlugin } from "bun"
6
+ import { transformServerCalls } from "./rpc-transform.ts"
7
+
8
+ const SERVER_ONLY_MODULES = ["gorsee/db", "gorsee/log"]
9
+
10
+ // Modules that need a lightweight client stub instead of full server code
11
+ const SERVER_STUB_MODULES: Record<string, string> = {
12
+ "gorsee/server": `export function server(fn) { return fn; }
13
+ export function middleware(fn) { return fn; }`,
14
+ }
15
+
16
+ // Regex to strip `export async function loader` or `export function loader`
17
+ // Handles multi-line function bodies by counting braces
18
+ function stripLoader(source: string): string {
19
+ const loaderRe = /export\s+(async\s+)?function\s+loader\s*\([^)]*\)\s*(\{)/g
20
+ const match = loaderRe.exec(source)
21
+ if (!match) return source
22
+
23
+ const start = match.index
24
+ let braces = 1
25
+ let i = loaderRe.lastIndex
26
+
27
+ while (i < source.length && braces > 0) {
28
+ if (source[i] === "{") braces++
29
+ else if (source[i] === "}") braces--
30
+ i++
31
+ }
32
+
33
+ return source.slice(0, start) + source.slice(i)
34
+ }
35
+
36
+ // Pre-compiled regexes for import stripping
37
+ const SERVER_IMPORT_REGEXES = SERVER_ONLY_MODULES.map(
38
+ (mod) => new RegExp(`import\\s+.*?from\\s+["']${mod.replace("/", "\\/")}["'];?\\n?`, "g")
39
+ )
40
+
41
+ function stripUnusedServerImports(source: string): string {
42
+ for (const re of SERVER_IMPORT_REGEXES) {
43
+ re.lastIndex = 0
44
+ source = source.replace(re, "")
45
+ }
46
+ return source
47
+ }
48
+
49
+ export const serverStripPlugin: BunPlugin = {
50
+ name: "gorsee-server-strip",
51
+ setup(build) {
52
+ // Replace server-only modules with empty exports
53
+ for (const mod of SERVER_ONLY_MODULES) {
54
+ build.onResolve({ filter: new RegExp(`^${mod.replace("/", "\\/")}$`) }, () => ({
55
+ path: mod,
56
+ namespace: "gorsee-empty",
57
+ }))
58
+ }
59
+
60
+ build.onLoad({ filter: /.*/, namespace: "gorsee-empty" }, () => ({
61
+ contents: "export default {}",
62
+ loader: "js",
63
+ }))
64
+
65
+ // Replace server modules with lightweight client stubs
66
+ for (const mod of Object.keys(SERVER_STUB_MODULES)) {
67
+ build.onResolve({ filter: new RegExp(`^${mod.replace("/", "\\/")}$`) }, () => ({
68
+ path: mod,
69
+ namespace: "gorsee-stub",
70
+ }))
71
+ }
72
+
73
+ build.onLoad({ filter: /.*/, namespace: "gorsee-stub" }, (args) => ({
74
+ contents: SERVER_STUB_MODULES[args.path] ?? "export default {}",
75
+ loader: "js",
76
+ }))
77
+
78
+ // Strip loader + transform RPC calls in route files
79
+ build.onLoad({ filter: /routes\/.*\.tsx?$/ }, async (args) => {
80
+ let source = await Bun.file(args.path).text()
81
+ source = stripLoader(source)
82
+ source = stripUnusedServerImports(source)
83
+ source = transformServerCalls(source, args.path)
84
+ return { contents: source, loader: args.path.endsWith(".tsx") ? "tsx" : "ts" }
85
+ })
86
+ },
87
+ }
@@ -0,0 +1,100 @@
1
+ // Static Site Generation -- pre-renders pages at build time
2
+ // Routes with `export const prerender = true` are rendered to static HTML
3
+
4
+ import { createRouter } from "../router/scanner.ts"
5
+ import { renderToString, ssrJsx } from "../runtime/server.ts"
6
+ import { createContext } from "../server/middleware.ts"
7
+ import { resetServerHead, getServerHead } from "../runtime/head.ts"
8
+ import { join } from "node:path"
9
+ import { mkdir, writeFile } from "node:fs/promises"
10
+
11
+ export interface SSGResult {
12
+ pages: Map<string, string> // path → HTML content
13
+ errors: string[]
14
+ }
15
+
16
+ interface SSGOptions {
17
+ routesDir: string
18
+ outDir: string
19
+ wrapHTML: (body: string, opts?: Record<string, unknown>) => string
20
+ }
21
+
22
+ export async function generateStaticPages(options: SSGOptions): Promise<SSGResult> {
23
+ const { routesDir, outDir, wrapHTML } = options
24
+ const routes = await createRouter(routesDir)
25
+ const pages = new Map<string, string>()
26
+ const errors: string[] = []
27
+
28
+ for (const route of routes) {
29
+ if (route.isDynamic) continue
30
+
31
+ try {
32
+ const mod = await import(route.filePath)
33
+
34
+ // Skip routes that don't opt-in to prerendering
35
+ if (!mod.prerender) continue
36
+
37
+ const component = mod.default as Function | undefined
38
+ if (typeof component !== "function") continue
39
+
40
+ // Parallel loading: import layout modules + run page loader
41
+ const fakeRequest = new Request(`http://localhost${route.path}`)
42
+ const ctx = createContext(fakeRequest, {})
43
+ const layoutPaths = route.layoutPaths ?? []
44
+ const layoutImportPromises = layoutPaths.map((lp) => import(lp))
45
+ const pageLoaderPromise = typeof mod.loader === "function" ? mod.loader(ctx) : undefined
46
+
47
+ const [layoutMods, loaderData] = await Promise.all([
48
+ Promise.all(layoutImportPromises),
49
+ pageLoaderPromise,
50
+ ])
51
+
52
+ // Run layout loaders in parallel
53
+ const layoutLoaderPromises = layoutMods.map((lm) =>
54
+ typeof lm.loader === "function" ? lm.loader(ctx) : undefined,
55
+ )
56
+ const layoutLoaderResults = await Promise.all(layoutLoaderPromises)
57
+
58
+ // Nested layout wrapping: outermost first, innermost wraps page
59
+ let pageComponent: Function = component
60
+ for (let i = layoutMods.length - 1; i >= 0; i--) {
61
+ const Layout = layoutMods[i]!.default
62
+ if (typeof Layout === "function") {
63
+ const inner = pageComponent
64
+ const layoutData = layoutLoaderResults[i]
65
+ pageComponent = (props: Record<string, unknown>) =>
66
+ Layout({ ...props, data: layoutData, children: inner(props) })
67
+ }
68
+ }
69
+
70
+ // Render
71
+ resetServerHead()
72
+ const pageProps = { params: {}, ctx, data: loaderData }
73
+ const vnode = ssrJsx(pageComponent as any, pageProps)
74
+ const body = renderToString(vnode)
75
+ const headElements = getServerHead()
76
+
77
+ // Extract title
78
+ let title: string | undefined
79
+ for (const el of headElements) {
80
+ const m = el.match(/<title>(.+?)<\/title>/)
81
+ if (m) { title = m[1]; break }
82
+ }
83
+
84
+ const html = wrapHTML(body, { title, loaderData, headElements })
85
+
86
+ // Write file
87
+ const outPath = route.path === "/"
88
+ ? join(outDir, "index.html")
89
+ : join(outDir, route.path, "index.html")
90
+ await mkdir(join(outPath, ".."), { recursive: true })
91
+ await writeFile(outPath, html)
92
+
93
+ pages.set(route.path, outPath)
94
+ } catch (err) {
95
+ errors.push(`${route.path}: ${err instanceof Error ? err.message : String(err)}`)
96
+ }
97
+ }
98
+
99
+ return { pages, errors }
100
+ }
@@ -0,0 +1,37 @@
1
+ // Bun plugin that resolves "gorsee/*" imports to framework source
2
+ // and transforms JSX in route files to use our runtime
3
+
4
+ import { plugin } from "bun"
5
+ import { resolve } from "node:path"
6
+
7
+ // Framework root — where gorsee source lives
8
+ const FRAMEWORK_ROOT = resolve(import.meta.dir, "..")
9
+
10
+ const EXPORT_MAP: Record<string, string> = {
11
+ "gorsee": resolve(FRAMEWORK_ROOT, "index.ts"),
12
+ "gorsee/reactive": resolve(FRAMEWORK_ROOT, "reactive/index.ts"),
13
+ "gorsee/server": resolve(FRAMEWORK_ROOT, "server/index.ts"),
14
+ "gorsee/types": resolve(FRAMEWORK_ROOT, "types/index.ts"),
15
+ "gorsee/db": resolve(FRAMEWORK_ROOT, "db/index.ts"),
16
+ "gorsee/router": resolve(FRAMEWORK_ROOT, "router/index.ts"),
17
+ "gorsee/log": resolve(FRAMEWORK_ROOT, "log/index.ts"),
18
+ "gorsee/unsafe": resolve(FRAMEWORK_ROOT, "unsafe/index.ts"),
19
+ "gorsee/runtime": resolve(FRAMEWORK_ROOT, "runtime/index.ts"),
20
+ "gorsee/security": resolve(FRAMEWORK_ROOT, "security/index.ts"),
21
+ "gorsee/jsx-runtime": resolve(FRAMEWORK_ROOT, "jsx-runtime.ts"),
22
+ "gorsee/jsx-dev-runtime": resolve(FRAMEWORK_ROOT, "jsx-runtime.ts"),
23
+ }
24
+
25
+ plugin({
26
+ name: "gorsee-resolve",
27
+ setup(build) {
28
+ // Resolve gorsee/* imports
29
+ build.onResolve({ filter: /^gorsee(\/.*)?$/ }, (args) => {
30
+ const mapped = EXPORT_MAP[args.path]
31
+ if (mapped) {
32
+ return { path: mapped }
33
+ }
34
+ return undefined
35
+ })
36
+ },
37
+ })
@@ -0,0 +1,182 @@
1
+ // gorsee build -- production build
2
+ // Bundles client JS, minifies, generates asset hashes
3
+
4
+ import { join } from "node:path"
5
+ import { mkdir, rm, writeFile, readdir, stat, watch } from "node:fs/promises"
6
+ import { createRouter } from "../router/scanner.ts"
7
+ import { buildClientBundles } from "../build/client.ts"
8
+ import { generateStaticPages } from "../build/ssg.ts"
9
+ import { createHash } from "node:crypto"
10
+
11
+ interface BuildManifest {
12
+ routes: Record<string, { js?: string; hasLoader: boolean; prerendered?: boolean }>
13
+ chunks: string[]
14
+ prerendered: string[]
15
+ buildTime: string
16
+ }
17
+
18
+ async function hashFile(path: string): Promise<string> {
19
+ const content = await Bun.file(path).arrayBuffer()
20
+ return createHash("sha256").update(new Uint8Array(content)).digest("hex").slice(0, 8)
21
+ }
22
+
23
+ async function getAllFiles(dir: string): Promise<string[]> {
24
+ const files: string[] = []
25
+ try {
26
+ const entries = await readdir(dir, { withFileTypes: true })
27
+ for (const entry of entries) {
28
+ const full = join(dir, entry.name)
29
+ if (entry.isDirectory()) {
30
+ files.push(...await getAllFiles(full))
31
+ } else {
32
+ files.push(full)
33
+ }
34
+ }
35
+ } catch {}
36
+ return files
37
+ }
38
+
39
+ export async function runBuild(_args: string[]) {
40
+ if (_args.includes("--watch")) {
41
+ return runBuildWatch()
42
+ }
43
+ const cwd = process.cwd()
44
+ const startTime = performance.now()
45
+ console.log("\n Gorsee Build\n")
46
+
47
+ const distDir = join(cwd, "dist")
48
+ await rm(distDir, { recursive: true, force: true })
49
+ await mkdir(join(distDir, "client"), { recursive: true })
50
+ await mkdir(join(distDir, "server"), { recursive: true })
51
+
52
+ // 1. Scan routes
53
+ const routesDir = join(cwd, "routes")
54
+ const routes = await createRouter(routesDir)
55
+ console.log(` [1/5] Found ${routes.length} route(s)`)
56
+
57
+ // 2. Build client bundles (minified)
58
+ const build = await buildClientBundles(routes, cwd, { minify: true, sourcemap: true })
59
+ console.log(` [2/5] Client bundles built (${build.entryMap.size} entries)`)
60
+
61
+ // 3. Hash and copy client files to dist
62
+ const clientSrc = join(cwd, ".gorsee", "client")
63
+ const clientFiles = await getAllFiles(clientSrc)
64
+ const hashMap = new Map<string, string>()
65
+
66
+ for (const file of clientFiles) {
67
+ const rel = file.slice(clientSrc.length + 1)
68
+ const hash = await hashFile(file)
69
+ const ext = rel.lastIndexOf(".")
70
+ const hashed = ext > 0 ? `${rel.slice(0, ext)}.${hash}${rel.slice(ext)}` : `${rel}.${hash}`
71
+ const dest = join(distDir, "client", hashed)
72
+ await mkdir(join(dest, ".."), { recursive: true })
73
+ await Bun.write(dest, Bun.file(file))
74
+ hashMap.set(rel, hashed)
75
+ }
76
+ console.log(` [3/5] Assets hashed (${hashMap.size} files)`)
77
+
78
+ // 4. Generate manifest
79
+ const manifest: BuildManifest = {
80
+ routes: {},
81
+ chunks: [],
82
+ prerendered: [],
83
+ buildTime: new Date().toISOString(),
84
+ }
85
+
86
+ for (const route of routes) {
87
+ const jsRel = build.entryMap.get(route.path)
88
+ manifest.routes[route.path] = {
89
+ js: jsRel ? hashMap.get(jsRel) : undefined,
90
+ hasLoader: false, // will be detected at runtime
91
+ }
92
+ }
93
+
94
+ for (const [, hashed] of hashMap) {
95
+ if (hashed.includes("chunk-")) manifest.chunks.push(hashed)
96
+ }
97
+
98
+ await writeFile(join(distDir, "manifest.json"), JSON.stringify(manifest, null, 2))
99
+ console.log(` [4/5] Manifest generated`)
100
+
101
+ // 5. Static Site Generation (prerender pages with `export const prerender = true`)
102
+ const ssgResult = await generateStaticPages({
103
+ routesDir,
104
+ outDir: join(distDir, "static"),
105
+ wrapHTML: (body, opts: Record<string, unknown> = {}) => {
106
+ const title = (opts.title as string) ?? "Gorsee App"
107
+ const headElements = (opts.headElements as string[]) ?? []
108
+ return `<!DOCTYPE html>
109
+ <html lang="en">
110
+ <head>
111
+ <meta charset="UTF-8" />
112
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
113
+ <title>${title}</title>
114
+ <link rel="stylesheet" href="/styles.css" />
115
+ ${headElements.join("\n")}
116
+ </head>
117
+ <body>
118
+ <div id="app">${body}</div>
119
+ </body>
120
+ </html>`
121
+ },
122
+ })
123
+ for (const path of ssgResult.pages.keys()) {
124
+ manifest.prerendered.push(path)
125
+ if (manifest.routes[path]) manifest.routes[path]!.prerendered = true
126
+ }
127
+ if (ssgResult.pages.size > 0) {
128
+ await writeFile(join(distDir, "manifest.json"), JSON.stringify(manifest, null, 2))
129
+ }
130
+ if (ssgResult.errors.length > 0) {
131
+ for (const err of ssgResult.errors) console.error(` SSG error: ${err}`)
132
+ }
133
+ console.log(` [5/5] SSG: ${ssgResult.pages.size} page(s) pre-rendered`)
134
+
135
+ // Stats
136
+ let totalSize = 0
137
+ for (const file of await getAllFiles(join(distDir, "client"))) {
138
+ const s = await stat(file)
139
+ totalSize += s.size
140
+ }
141
+
142
+ const elapsed = ((performance.now() - startTime) / 1000).toFixed(2)
143
+ console.log()
144
+ console.log(` Output: dist/`)
145
+ console.log(` Client size: ${(totalSize / 1024).toFixed(1)} KB`)
146
+ console.log(` Built in ${elapsed}s`)
147
+ console.log()
148
+ }
149
+
150
+ async function runBuildWatch() {
151
+ const cwd = process.cwd()
152
+ const routesDir = join(cwd, "routes")
153
+
154
+ console.log("\n Gorsee Build --watch\n")
155
+ console.log(" Performing initial build...")
156
+ await runBuild([])
157
+
158
+ let building = false
159
+ let queued = false
160
+
161
+ async function rebuild() {
162
+ if (building) { queued = true; return }
163
+ building = true
164
+ try {
165
+ const start = performance.now()
166
+ await runBuild([])
167
+ const ms = (performance.now() - start).toFixed(0)
168
+ console.log(` Rebuilt in ${ms}ms`)
169
+ } catch (err) {
170
+ console.error(" Build error:", err)
171
+ } finally {
172
+ building = false
173
+ if (queued) { queued = false; await rebuild() }
174
+ }
175
+ }
176
+
177
+ console.log(" Watching for changes...")
178
+ const watcher = watch(routesDir, { recursive: true })
179
+ for await (const _event of watcher) {
180
+ await rebuild()
181
+ }
182
+ }
@@ -0,0 +1,225 @@
1
+ // gorsee check -- validate project structure, types, and safety
2
+
3
+ import { join, relative } from "node:path"
4
+ import { readdir, stat, readFile } from "node:fs/promises"
5
+ import { createRouter } from "../router/scanner.ts"
6
+
7
+ interface CheckResult {
8
+ errors: CheckIssue[]
9
+ warnings: CheckIssue[]
10
+ info: string[]
11
+ }
12
+
13
+ interface CheckIssue {
14
+ code: string
15
+ file: string
16
+ line?: number
17
+ message: string
18
+ fix?: string
19
+ }
20
+
21
+ const MAX_FILE_LINES = 500
22
+
23
+ async function getAllTsFiles(dir: string): Promise<string[]> {
24
+ const files: string[] = []
25
+ let entries: string[]
26
+ try {
27
+ entries = await readdir(dir)
28
+ } catch {
29
+ return files
30
+ }
31
+
32
+ for (const entry of entries) {
33
+ if (entry === "node_modules" || entry === "dist" || entry === ".git") continue
34
+ const fullPath = join(dir, entry)
35
+ const s = await stat(fullPath)
36
+ if (s.isDirectory()) {
37
+ files.push(...(await getAllTsFiles(fullPath)))
38
+ } else if (entry.endsWith(".ts") || entry.endsWith(".tsx")) {
39
+ files.push(fullPath)
40
+ }
41
+ }
42
+ return files
43
+ }
44
+
45
+ async function checkFileSize(file: string, cwd: string): Promise<CheckIssue[]> {
46
+ const issues: CheckIssue[] = []
47
+ const content = await readFile(file, "utf-8")
48
+ const lines = content.split("\n").length
49
+ const rel = relative(cwd, file)
50
+
51
+ if (lines > MAX_FILE_LINES) {
52
+ issues.push({
53
+ code: "E901",
54
+ file: rel,
55
+ message: `File has ${lines} lines (limit: ${MAX_FILE_LINES}). Must split before merge.`,
56
+ fix: "Extract logic into focused modules by responsibility",
57
+ })
58
+ } else if (lines > 300) {
59
+ issues.push({
60
+ code: "W901",
61
+ file: rel,
62
+ message: `File has ${lines} lines (warning threshold: 300). Plan to split.`,
63
+ fix: "Consider extracting into separate modules",
64
+ })
65
+ }
66
+
67
+ return issues
68
+ }
69
+
70
+ async function checkUnsafePatterns(file: string, cwd: string): Promise<CheckIssue[]> {
71
+ const issues: CheckIssue[] = []
72
+ const content = await readFile(file, "utf-8")
73
+ const lines = content.split("\n")
74
+ const rel = relative(cwd, file)
75
+
76
+ for (let i = 0; i < lines.length; i++) {
77
+ const line = lines[i]!
78
+ const lineNum = i + 1
79
+
80
+ // Check for string concatenation in SQL-like contexts
81
+ if (line.match(/db\.(get|all|run)\s*\(\s*`/) && !line.includes("SafeSQL")) {
82
+ issues.push({
83
+ code: "E001",
84
+ file: rel,
85
+ line: lineNum,
86
+ message: "Raw template literal in database query (SQL injection risk)",
87
+ fix: "Use SafeSQL`...` tagged template: db.get(SafeSQL`SELECT ...`)",
88
+ })
89
+ }
90
+
91
+ // Check for string concat in db calls
92
+ if (line.match(/db\.(get|all|run)\s*\(\s*["']/) || line.match(/db\.(get|all|run)\s*\([^S]*\+/)) {
93
+ issues.push({
94
+ code: "E001",
95
+ file: rel,
96
+ line: lineNum,
97
+ message: "String concatenation in database query (SQL injection risk)",
98
+ fix: "Use SafeSQL`...` tagged template with parameterized values",
99
+ })
100
+ }
101
+
102
+ // Check for innerHTML usage
103
+ if (line.includes(".innerHTML") && !line.includes("unsafeHTML")) {
104
+ issues.push({
105
+ code: "E002",
106
+ file: rel,
107
+ line: lineNum,
108
+ message: "Direct innerHTML assignment (XSS risk)",
109
+ fix: "Use SafeHTML`...` or sanitize() from gorsee/types",
110
+ })
111
+ }
112
+ }
113
+
114
+ return issues
115
+ }
116
+
117
+ async function checkProjectStructure(cwd: string): Promise<CheckIssue[]> {
118
+ const issues: CheckIssue[] = []
119
+
120
+ // Check routes/ exists
121
+ try {
122
+ await stat(join(cwd, "routes"))
123
+ } catch {
124
+ issues.push({
125
+ code: "E902",
126
+ file: ".",
127
+ message: "Missing routes/ directory",
128
+ fix: "Create routes/ directory with at least routes/index.ts",
129
+ })
130
+ }
131
+
132
+ // Check app.config.ts exists
133
+ try {
134
+ await stat(join(cwd, "app.config.ts"))
135
+ } catch {
136
+ issues.push({
137
+ code: "W902",
138
+ file: ".",
139
+ message: "Missing app.config.ts",
140
+ fix: "Create app.config.ts with project configuration",
141
+ })
142
+ }
143
+
144
+ return issues
145
+ }
146
+
147
+ export async function runCheck(_args: string[]) {
148
+ const cwd = process.cwd()
149
+ console.log("\n Gorsee Check\n")
150
+
151
+ const result: CheckResult = { errors: [], warnings: [], info: [] }
152
+
153
+ // 1. Project structure
154
+ const structIssues = await checkProjectStructure(cwd)
155
+ for (const issue of structIssues) {
156
+ if (issue.code.startsWith("E")) result.errors.push(issue)
157
+ else result.warnings.push(issue)
158
+ }
159
+
160
+ // 2. Routes
161
+ try {
162
+ const routes = await createRouter(join(cwd, "routes"))
163
+ result.info.push(`Found ${routes.length} route(s)`)
164
+ } catch {
165
+ result.info.push("Could not scan routes")
166
+ }
167
+
168
+ // 3. File checks
169
+ const files = await getAllTsFiles(join(cwd, "routes"))
170
+ files.push(...(await getAllTsFiles(join(cwd, "shared"))))
171
+ files.push(...(await getAllTsFiles(join(cwd, "middleware"))))
172
+
173
+ for (const file of files) {
174
+ const sizeIssues = await checkFileSize(file, cwd)
175
+ const safetyIssues = await checkUnsafePatterns(file, cwd)
176
+ for (const issue of [...sizeIssues, ...safetyIssues]) {
177
+ if (issue.code.startsWith("E")) result.errors.push(issue)
178
+ else result.warnings.push(issue)
179
+ }
180
+ }
181
+
182
+ // 4. TypeScript check
183
+ console.log(" Running TypeScript check...")
184
+ const tsc = Bun.spawn(["bun", "x", "tsc", "--noEmit"], {
185
+ cwd,
186
+ stdout: "pipe",
187
+ stderr: "pipe",
188
+ })
189
+ const tscExit = await tsc.exited
190
+ if (tscExit !== 0) {
191
+ const stderr = await new Response(tsc.stderr).text()
192
+ result.errors.push({
193
+ code: "TSC",
194
+ file: ".",
195
+ message: `TypeScript errors:\n${stderr.trim()}`,
196
+ })
197
+ } else {
198
+ result.info.push("TypeScript: no errors")
199
+ }
200
+
201
+ // Report
202
+ for (const info of result.info) {
203
+ console.log(` [info] ${info}`)
204
+ }
205
+ for (const warn of result.warnings) {
206
+ const loc = warn.line ? `${warn.file}:${warn.line}` : warn.file
207
+ console.log(` [warn] ${warn.code} ${loc}: ${warn.message}`)
208
+ if (warn.fix) console.log(` Fix: ${warn.fix}`)
209
+ }
210
+ for (const err of result.errors) {
211
+ const loc = err.line ? `${err.file}:${err.line}` : err.file
212
+ console.log(` [ERROR] ${err.code} ${loc}: ${err.message}`)
213
+ if (err.fix) console.log(` Fix: ${err.fix}`)
214
+ }
215
+
216
+ console.log()
217
+ if (result.errors.length === 0) {
218
+ console.log(` Result: PASS (${result.warnings.length} warning(s))`)
219
+ } else {
220
+ console.log(` Result: FAIL (${result.errors.length} error(s), ${result.warnings.length} warning(s))`)
221
+ }
222
+ console.log()
223
+
224
+ process.exit(result.errors.length > 0 ? 1 : 0)
225
+ }