gorsee 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/package.json +69 -0
- package/src/auth/index.ts +147 -0
- package/src/build/client.ts +121 -0
- package/src/build/css-modules.ts +69 -0
- package/src/build/devalue-parse.ts +2 -0
- package/src/build/rpc-transform.ts +62 -0
- package/src/build/server-strip.ts +87 -0
- package/src/build/ssg.ts +100 -0
- package/src/cli/bun-plugin.ts +37 -0
- package/src/cli/cmd-build.ts +182 -0
- package/src/cli/cmd-check.ts +225 -0
- package/src/cli/cmd-create.ts +313 -0
- package/src/cli/cmd-dev.ts +13 -0
- package/src/cli/cmd-generate.ts +147 -0
- package/src/cli/cmd-migrate.ts +45 -0
- package/src/cli/cmd-routes.ts +29 -0
- package/src/cli/cmd-start.ts +21 -0
- package/src/cli/cmd-typegen.ts +83 -0
- package/src/cli/framework-md.ts +196 -0
- package/src/cli/index.ts +84 -0
- package/src/db/index.ts +2 -0
- package/src/db/migrate.ts +89 -0
- package/src/db/sqlite.ts +40 -0
- package/src/deploy/dockerfile.ts +38 -0
- package/src/dev/error-overlay.ts +54 -0
- package/src/dev/hmr.ts +31 -0
- package/src/dev/partial-handler.ts +109 -0
- package/src/dev/request-handler.ts +158 -0
- package/src/dev/watcher.ts +48 -0
- package/src/dev.ts +273 -0
- package/src/env/index.ts +74 -0
- package/src/errors/catalog.ts +48 -0
- package/src/errors/formatter.ts +63 -0
- package/src/errors/index.ts +2 -0
- package/src/i18n/index.ts +72 -0
- package/src/index.ts +27 -0
- package/src/jsx-runtime-client.ts +13 -0
- package/src/jsx-runtime.ts +20 -0
- package/src/jsx-types-html.ts +242 -0
- package/src/log/index.ts +44 -0
- package/src/prod.ts +310 -0
- package/src/reactive/computed.ts +7 -0
- package/src/reactive/effect.ts +7 -0
- package/src/reactive/index.ts +7 -0
- package/src/reactive/live.ts +97 -0
- package/src/reactive/optimistic.ts +83 -0
- package/src/reactive/resource.ts +138 -0
- package/src/reactive/signal.ts +20 -0
- package/src/reactive/store.ts +36 -0
- package/src/router/index.ts +2 -0
- package/src/router/matcher.ts +53 -0
- package/src/router/scanner.ts +206 -0
- package/src/runtime/client.ts +28 -0
- package/src/runtime/error-boundary.ts +35 -0
- package/src/runtime/event-replay.ts +50 -0
- package/src/runtime/form.ts +49 -0
- package/src/runtime/head.ts +113 -0
- package/src/runtime/html-escape.ts +30 -0
- package/src/runtime/hydration.ts +95 -0
- package/src/runtime/image.ts +48 -0
- package/src/runtime/index.ts +12 -0
- package/src/runtime/island-hydrator.ts +84 -0
- package/src/runtime/island.ts +88 -0
- package/src/runtime/jsx-runtime.ts +167 -0
- package/src/runtime/link.ts +45 -0
- package/src/runtime/router.ts +224 -0
- package/src/runtime/server.ts +102 -0
- package/src/runtime/stream.ts +182 -0
- package/src/runtime/suspense.ts +37 -0
- package/src/runtime/typed-routes.ts +26 -0
- package/src/runtime/validated-form.ts +106 -0
- package/src/security/cors.ts +80 -0
- package/src/security/csrf.ts +85 -0
- package/src/security/headers.ts +50 -0
- package/src/security/index.ts +4 -0
- package/src/security/rate-limit.ts +80 -0
- package/src/server/action.ts +48 -0
- package/src/server/cache.ts +102 -0
- package/src/server/compress.ts +60 -0
- package/src/server/etag.ts +23 -0
- package/src/server/guard.ts +69 -0
- package/src/server/index.ts +19 -0
- package/src/server/middleware.ts +143 -0
- package/src/server/mime.ts +48 -0
- package/src/server/pipe.ts +46 -0
- package/src/server/rpc-hash.ts +17 -0
- package/src/server/rpc.ts +125 -0
- package/src/server/sse.ts +96 -0
- package/src/server/ws.ts +56 -0
- package/src/testing/index.ts +74 -0
- package/src/types/index.ts +4 -0
- package/src/types/safe-html.ts +32 -0
- package/src/types/safe-sql.ts +28 -0
- package/src/types/safe-url.ts +40 -0
- package/src/types/user-input.ts +12 -0
- package/src/unsafe/index.ts +18 -0
|
@@ -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
|
+
}
|
package/src/build/ssg.ts
ADDED
|
@@ -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
|
+
}
|