gorsee 0.2.1 → 0.2.3

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 (51) hide show
  1. package/README.md +132 -4
  2. package/package.json +7 -4
  3. package/src/auth/index.ts +48 -17
  4. package/src/auth/redis-session-store.ts +46 -0
  5. package/src/auth/sqlite-session-store.ts +98 -0
  6. package/src/auth/store-utils.ts +21 -0
  7. package/src/build/client.ts +25 -7
  8. package/src/build/manifest.ts +34 -0
  9. package/src/build/route-metadata.ts +12 -0
  10. package/src/build/ssg.ts +19 -49
  11. package/src/cli/bun-plugin.ts +23 -2
  12. package/src/cli/cmd-build.ts +42 -71
  13. package/src/cli/cmd-check.ts +40 -26
  14. package/src/cli/cmd-create.ts +20 -5
  15. package/src/cli/cmd-deploy.ts +10 -2
  16. package/src/cli/cmd-dev.ts +9 -9
  17. package/src/cli/cmd-docs.ts +11 -4
  18. package/src/cli/cmd-generate.ts +15 -7
  19. package/src/cli/cmd-migrate.ts +15 -7
  20. package/src/cli/cmd-routes.ts +12 -5
  21. package/src/cli/cmd-start.ts +14 -5
  22. package/src/cli/cmd-test.ts +11 -3
  23. package/src/cli/cmd-typegen.ts +13 -3
  24. package/src/cli/cmd-upgrade.ts +10 -2
  25. package/src/cli/context.ts +12 -0
  26. package/src/cli/framework-md.ts +43 -16
  27. package/src/client.ts +26 -0
  28. package/src/dev/partial-handler.ts +17 -74
  29. package/src/dev/request-handler.ts +36 -67
  30. package/src/dev.ts +92 -157
  31. package/src/index-client.ts +4 -0
  32. package/src/index.ts +17 -2
  33. package/src/prod.ts +195 -253
  34. package/src/runtime/project.ts +73 -0
  35. package/src/server/cache-utils.ts +23 -0
  36. package/src/server/cache.ts +37 -14
  37. package/src/server/html-shell.ts +69 -0
  38. package/src/server/index.ts +40 -2
  39. package/src/server/manifest.ts +36 -0
  40. package/src/server/middleware.ts +18 -2
  41. package/src/server/not-found.ts +35 -0
  42. package/src/server/page-render.ts +123 -0
  43. package/src/server/redis-cache-store.ts +87 -0
  44. package/src/server/redis-client.ts +71 -0
  45. package/src/server/request-preflight.ts +45 -0
  46. package/src/server/route-request.ts +72 -0
  47. package/src/server/rpc-utils.ts +27 -0
  48. package/src/server/rpc.ts +70 -18
  49. package/src/server/sqlite-cache-store.ts +109 -0
  50. package/src/server/static-file.ts +63 -0
  51. package/src/server-entry.ts +36 -0
package/src/prod.ts CHANGED
@@ -2,309 +2,251 @@
2
2
  // Serves pre-built client bundles + SSR pages from dist/
3
3
 
4
4
  import { createRouter, matchRoute, buildStaticMap } from "./router/index.ts"
5
- import { handleRPCRequest } from "./server/rpc.ts"
6
5
  import { securityHeaders } from "./security/headers.ts"
7
6
  import { createRateLimiter } from "./security/rate-limit.ts"
8
- import { renderToString, ssrJsx } from "./runtime/server.ts"
9
7
  import { renderToStream, streamJsx } from "./runtime/stream.ts"
10
- import { createContext, runMiddlewareChain, RedirectError, type MiddlewareFn } from "./server/middleware.ts"
11
- import { compress } from "./server/compress.ts"
12
8
  import { resetServerHead, getServerHead } from "./runtime/head.ts"
9
+ import type { MiddlewareFn } from "./server/middleware.ts"
10
+ import { compress } from "./server/compress.ts"
13
11
  import { log, setLogLevel } from "./log/index.ts"
14
12
  import { loadEnv } from "./env/index.ts"
15
- import { getMimeType } from "./server/mime.ts"
16
- import { fileETag, isNotModified } from "./server/etag.ts"
17
- import { join, resolve } from "node:path"
18
- import { readFile } from "node:fs/promises"
13
+ import { generateNonce, wrapHTML } from "./server/html-shell.ts"
14
+ import { renderNotFoundPage } from "./server/not-found.ts"
15
+ import { createRateLimitResponse, handleRPCWithHeaders } from "./server/request-preflight.ts"
16
+ import {
17
+ buildPartialResponsePayload,
18
+ createClientScriptPath,
19
+ renderPageDocument,
20
+ } from "./server/page-render.ts"
21
+ import { handleRouteRequest } from "./server/route-request.ts"
22
+ import { servePrefixedStaticFile, serveStaticFile } from "./server/static-file.ts"
23
+ import {
24
+ getClientBundleForRoute,
25
+ getPrerenderedHtmlPath,
26
+ isPrerenderedRoute,
27
+ loadBuildManifest,
28
+ } from "./server/manifest.ts"
29
+ import { join } from "node:path"
30
+ import { createProjectContext, resolveRuntimeEnv, type RuntimeOptions } from "./runtime/project.ts"
19
31
  // Route type used implicitly via createRouter return
20
32
 
21
- const CWD = process.cwd()
22
- const ROUTES_DIR = join(CWD, "routes")
23
- const PUBLIC_DIR = join(CWD, "public")
24
- const DIST_DIR = join(CWD, "dist")
25
- const CLIENT_DIR = join(DIST_DIR, "client")
26
- const PORT = Number(process.env.PORT) || 3000
27
-
28
- interface BuildManifest {
29
- routes: Record<string, { js?: string; hasLoader: boolean }>
30
- chunks: string[]
31
- buildTime: string
32
- }
33
-
34
- async function loadManifest(): Promise<BuildManifest> {
35
- const raw = await readFile(join(DIST_DIR, "manifest.json"), "utf-8")
36
- return JSON.parse(raw)
37
- }
38
-
39
- function generateNonce(): string {
40
- const bytes = new Uint8Array(16)
41
- crypto.getRandomValues(bytes)
42
- return btoa(String.fromCharCode(...bytes))
33
+ interface StartProductionServerOptions extends RuntimeOptions {
34
+ port?: number
35
+ registerSignalHandlers?: boolean
43
36
  }
44
37
 
45
- interface HTMLWrapOptions {
46
- title?: string
47
- clientScript?: string
48
- loaderData?: unknown
49
- params?: Record<string, string>
50
- cssFiles?: string[]
51
- headElements?: string[]
52
- }
53
-
54
- function wrapHTML(body: string, nonce: string, options: HTMLWrapOptions = {}): string {
55
- const { title = "Gorsee App", clientScript, loaderData, params, cssFiles = [], headElements = [] } = options
56
-
57
- let dataScript = ""
58
- if (loaderData !== undefined) {
59
- const json = JSON.stringify(loaderData).replace(/</g, "\\u003c")
60
- dataScript = `\n <script id="__GORSEE_DATA__" type="application/json" nonce="${nonce}">${json}</script>`
61
- }
62
-
63
- let paramsScript = ""
64
- if (params && Object.keys(params).length > 0) {
65
- paramsScript = `\n <script nonce="${nonce}">window.__GORSEE_PARAMS__=${JSON.stringify(params)}</script>`
66
- }
67
-
68
- const clientTag = clientScript
69
- ? `\n <script type="module" src="${clientScript}" nonce="${nonce}"></script>`
70
- : ""
71
-
72
- return `<!DOCTYPE html>
73
- <html lang="en">
74
- <head>
75
- <meta charset="UTF-8" />
76
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
77
- <title>${title}</title>
78
- <link rel="stylesheet" href="/styles.css" />
79
- ${cssFiles.map((f: string) => ` <link rel="stylesheet" href="${f}" />`).join("\n")}
80
- ${headElements.join("\n")}
81
- </head>
82
- <body>
83
- <div id="app">${body}</div>${dataScript}${paramsScript}${clientTag}
84
- </body>
85
- </html>`
38
+ interface ProductionRuntimeState {
39
+ cwd: string
40
+ routesDir: string
41
+ publicDir: string
42
+ distDir: string
43
+ clientDir: string
44
+ manifest: Awaited<ReturnType<typeof loadBuildManifest>>
45
+ routes: Awaited<ReturnType<typeof createRouter>>
46
+ staticMap: ReturnType<typeof buildStaticMap>
47
+ rateLimiter: ReturnType<typeof createRateLimiter>
48
+ compressMiddleware: ReturnType<typeof compress>
86
49
  }
87
50
 
88
- async function tryServeStatic(pathname: string, request: Request): Promise<Response | null> {
51
+ async function tryServeStatic(
52
+ pathname: string,
53
+ request: Request,
54
+ publicDir: string,
55
+ clientDir: string,
56
+ secHeaders: Record<string, string>,
57
+ ): Promise<Response | null> {
89
58
  if (pathname === "/") return null
90
59
  // Client assets from dist/
91
- if (pathname.startsWith("/_gorsee/")) {
92
- const relPath = pathname.slice("/_gorsee/".length)
93
- const filePath = resolve(CLIENT_DIR, relPath)
94
- if (!filePath.startsWith(CLIENT_DIR)) return null
95
- try {
96
- const file = Bun.file(filePath)
97
- if (await file.exists()) {
98
- return new Response(file, {
99
- headers: {
100
- "Content-Type": "application/javascript",
101
- "Cache-Control": "public, max-age=31536000, immutable",
102
- },
103
- })
104
- }
105
- } catch {}
106
- return null
107
- }
60
+ const bundleResponse = await servePrefixedStaticFile(pathname, "/_gorsee/", clientDir, {
61
+ contentType: "application/javascript",
62
+ cacheControl: "public, max-age=31536000, immutable",
63
+ extraHeaders: secHeaders,
64
+ })
65
+ if (bundleResponse) return bundleResponse
66
+
108
67
  // Public files
109
- try {
110
- const filePath = resolve(PUBLIC_DIR, pathname.slice(1))
111
- if (!filePath.startsWith(PUBLIC_DIR)) return null
112
- const file = Bun.file(filePath)
113
- if (await file.exists()) {
114
- const etag = await fileETag(filePath)
115
- if (etag && isNotModified(request, etag)) {
116
- return new Response(null, { status: 304 })
117
- }
118
- const headers: Record<string, string> = {
119
- "Content-Type": getMimeType(filePath),
120
- "Cache-Control": "public, max-age=3600",
121
- }
122
- if (etag) headers["ETag"] = etag
123
- return new Response(file, { headers })
124
- }
125
- } catch {}
126
- return null
68
+ return serveStaticFile(publicDir, pathname.slice(1), {
69
+ request,
70
+ etag: true,
71
+ cacheControl: "public, max-age=3600",
72
+ extraHeaders: secHeaders,
73
+ })
127
74
  }
128
75
 
129
- export async function startProductionServer() {
130
- await loadEnv(CWD)
131
- setLogLevel(process.env.LOG_LEVEL === "debug" ? "debug" : "info")
76
+ async function tryServePrerenderedPage(
77
+ pathname: string,
78
+ request: Request,
79
+ manifest: Awaited<ReturnType<typeof loadBuildManifest>>,
80
+ distDir: string,
81
+ secHeaders: Record<string, string>,
82
+ ): Promise<Response | null> {
83
+ if (request.method !== "GET") return null
84
+ if (!isPrerenderedRoute(manifest, pathname)) return null
85
+
86
+ return serveStaticFile(join(distDir, "static"), getPrerenderedHtmlPath(pathname), {
87
+ request,
88
+ etag: true,
89
+ cacheControl: "public, max-age=3600",
90
+ contentType: "text/html; charset=utf-8",
91
+ extraHeaders: secHeaders,
92
+ })
93
+ }
132
94
 
133
- const manifest = await loadManifest()
134
- log.info("loaded manifest", { routes: Object.keys(manifest.routes).length, built: manifest.buildTime })
95
+ export async function startProductionServer(options: StartProductionServerOptions = {}) {
96
+ const runtime = createProjectContext(options)
97
+ const registerSignalHandlers = options.registerSignalHandlers ?? true
135
98
 
136
- const routes = await createRouter(ROUTES_DIR)
137
- const staticMap = buildStaticMap(routes)
138
- const rateLimiter = createRateLimiter(
139
- Number(process.env.RATE_LIMIT) || 1000,
140
- process.env.RATE_WINDOW || "1m",
141
- )
142
-
143
- const compressMiddleware = compress()
144
- log.info("production server starting", { routes: routes.length })
99
+ await loadEnv(runtime.cwd)
100
+ const envConfig = resolveRuntimeEnv(process.env)
101
+ const port = options.port ?? envConfig.port
102
+ const fetchHandler = await createProductionFetchHandler({ cwd: runtime.cwd, env: process.env })
103
+ log.info("production server starting", { cwd: runtime.cwd })
145
104
 
146
105
  const server = Bun.serve({
147
- port: PORT,
148
- async fetch(request, server) {
149
- const url = new URL(request.url)
150
- const pathname = url.pathname
106
+ port,
107
+ fetch: fetchHandler,
108
+ })
151
109
 
152
- // Rate limiting
153
- const ip = server.requestIP(request)?.address ?? "unknown"
154
- const rl = rateLimiter.check(ip)
155
- if (!rl.allowed) {
156
- return new Response("Too Many Requests", {
157
- status: 429,
158
- headers: { "Retry-After": String(Math.ceil((rl.resetAt - Date.now()) / 1000)) },
159
- })
160
- }
110
+ log.info("production server started", { url: `http://localhost:${server.port}` })
161
111
 
162
- const nonce = generateNonce()
163
- const secHeaders = securityHeaders({}, nonce)
112
+ // Graceful shutdown
113
+ if (registerSignalHandlers) {
114
+ const shutdown = () => {
115
+ log.info("shutting down...")
116
+ server.stop(true) // close existing connections gracefully
117
+ process.exit(0)
118
+ }
119
+ process.on("SIGTERM", shutdown)
120
+ process.on("SIGINT", shutdown)
121
+ }
164
122
 
165
- // RPC
166
- const rpcResponse = await handleRPCRequest(request)
167
- if (rpcResponse) {
168
- for (const k in secHeaders) rpcResponse.headers.set(k, secHeaders[k]!)
169
- return rpcResponse
170
- }
123
+ return server
124
+ }
171
125
 
172
- // Static files
173
- const staticResponse = await tryServeStatic(pathname, request)
174
- if (staticResponse) return staticResponse
126
+ export async function createProductionFetchHandler(
127
+ options: Pick<StartProductionServerOptions, "cwd" | "env"> = {},
128
+ ): Promise<(request: Request, server?: { requestIP(request: Request): { address: string } | null }) => Promise<Response>> {
129
+ const state = await loadProductionRuntimeState(options)
175
130
 
176
- // Route matching
177
- const match = matchRoute(routes, pathname, staticMap)
178
- if (!match) {
179
- return new Response(wrapHTML("<h1>404</h1><p>Page not found</p>", nonce), {
180
- status: 404,
181
- headers: { "Content-Type": "text/html", ...secHeaders },
182
- })
183
- }
131
+ return async (request, server) => {
132
+ const url = new URL(request.url)
133
+ const pathname = url.pathname
184
134
 
185
- try {
186
- const mod = await import(match.route.filePath)
135
+ const ip = server?.requestIP(request)?.address ?? "unknown"
136
+ const rateLimitResponse = createRateLimitResponse(state.rateLimiter, ip)
137
+ if (rateLimitResponse) return rateLimitResponse
187
138
 
188
- // API route
189
- const ctx = createContext(request, match.params)
190
- if (typeof mod.GET === "function" && request.method === "GET") return mod.GET(ctx)
191
- if (typeof mod.POST === "function" && request.method === "POST") return mod.POST(ctx)
139
+ const nonce = generateNonce()
140
+ const secHeaders = securityHeaders({}, nonce)
192
141
 
193
- const component = mod.default as Function | undefined
194
- if (typeof component !== "function") {
195
- return new Response("Route has no default export", { status: 500 })
196
- }
142
+ const rpcResponse = await handleRPCWithHeaders(request, secHeaders)
143
+ if (rpcResponse) return rpcResponse
197
144
 
198
- // Middleware chain: compression + inherited route middleware
199
- const middlewares: MiddlewareFn[] = [compressMiddleware]
200
- for (const mwPath of match.route.middlewarePaths) {
201
- const mwMod = await import(mwPath)
202
- if (typeof mwMod.default === "function") middlewares.push(mwMod.default)
203
- }
145
+ const staticResponse = await tryServeStatic(pathname, request, state.publicDir, state.clientDir, secHeaders)
146
+ if (staticResponse) return staticResponse
204
147
 
205
- return await runMiddlewareChain(middlewares, ctx, async () => {
206
- // Parallel loading: import layout modules + run page loader simultaneously
207
- const layoutPaths = match.route.layoutPaths ?? []
208
- const layoutImportPromises = layoutPaths.map((lp) => import(lp))
209
- const pageLoaderPromise = typeof mod.loader === "function" ? mod.loader(ctx) : undefined
148
+ const prerenderedResponse = await tryServePrerenderedPage(pathname, request, state.manifest, state.distDir, secHeaders)
149
+ if (prerenderedResponse) return prerenderedResponse
210
150
 
211
- const [layoutMods, loaderData] = await Promise.all([
212
- Promise.all(layoutImportPromises),
213
- pageLoaderPromise,
214
- ])
151
+ const match = matchRoute(state.routes, pathname, state.staticMap)
152
+ if (!match) {
153
+ return new Response(await renderNotFoundPage(state.routesDir, nonce), {
154
+ status: 404,
155
+ headers: { "Content-Type": "text/html", ...secHeaders },
156
+ })
157
+ }
215
158
 
216
- // Run layout loaders in parallel
217
- const layoutLoaderPromises = layoutMods.map((lm) =>
218
- typeof lm.loader === "function" ? lm.loader(ctx) : undefined,
159
+ try {
160
+ return await handleRouteRequest({
161
+ match,
162
+ request,
163
+ extraMiddlewares: [state.compressMiddleware satisfies MiddlewareFn],
164
+ onPartialRequest: async ({ ctx, resolved }) => {
165
+ const { pageComponent, loaderData, cssFiles } = resolved
166
+ const rendered = renderPageDocument(pageComponent, ctx, match.params, loaderData)
167
+ const clientScript = createClientScriptPath(getClientBundleForRoute(state.manifest, match.route.path))
168
+ const partialPayload = buildPartialResponsePayload(
169
+ rendered,
170
+ loaderData,
171
+ match.params,
172
+ cssFiles,
173
+ clientScript,
219
174
  )
220
- const layoutLoaderResults = await Promise.all(layoutLoaderPromises)
221
-
222
- // CSS
223
- const cssFiles: string[] = []
224
- if (typeof mod.css === "string") cssFiles.push(mod.css)
225
- if (Array.isArray(mod.css)) cssFiles.push(...(mod.css as string[]))
226
-
227
- // Nested layout wrapping: outermost first, innermost wraps page
228
- let pageComponent: Function = component
229
- for (let i = layoutMods.length - 1; i >= 0; i--) {
230
- const Layout = layoutMods[i]!.default
231
- if (typeof Layout === "function") {
232
- const inner = pageComponent
233
- const layoutData = layoutLoaderResults[i]
234
- pageComponent = (props: Record<string, unknown>) =>
235
- Layout({ ...props, data: layoutData, children: inner(props) })
236
- }
237
- }
238
-
239
- // Partial navigation
240
- if (request.headers.get("X-Gorsee-Navigate") === "partial") {
241
- resetServerHead()
242
- const pageProps = { params: match.params, ctx, data: loaderData }
243
- const vnode = ssrJsx(pageComponent as any, pageProps)
244
- const html = renderToString(vnode)
245
- const headElements = getServerHead()
246
- let title: string | undefined
247
- for (const el of headElements) {
248
- const titleMatch = el.match(/<title>(.+?)<\/title>/)
249
- if (titleMatch) { title = titleMatch[1]; break }
250
- }
251
- const manifestRoute = manifest.routes[match.route.path]
252
- const clientScript = manifestRoute?.js ? `/_gorsee/${manifestRoute.js}` : undefined
253
- return new Response(JSON.stringify({ html, data: loaderData, params: match.params, title, css: cssFiles, script: clientScript }), {
254
- headers: { "Content-Type": "application/json", ...secHeaders },
255
- })
256
- }
175
+ return new Response(JSON.stringify(partialPayload), {
176
+ headers: { "Content-Type": "application/json", ...secHeaders },
177
+ })
178
+ },
179
+ onPageRequest: async ({ ctx, resolved }) => {
180
+ const { pageComponent, loaderData, cssFiles, renderMode } = resolved
257
181
 
258
- // Full SSR
259
182
  const pageProps = { params: match.params, ctx, data: loaderData }
260
- const manifestRoute = manifest.routes[match.route.path]
261
- const clientScript = manifestRoute?.js ? `/_gorsee/${manifestRoute.js}` : undefined
262
-
263
- resetServerHead()
264
- const renderMode = (mod.render as string) ?? "async"
183
+ const clientScript = createClientScriptPath(getClientBundleForRoute(state.manifest, match.route.path))
265
184
 
266
185
  if (renderMode === "stream") {
186
+ resetServerHead()
267
187
  const vnode = streamJsx(pageComponent as any, pageProps)
268
188
  const stream = renderToStream(vnode, {
269
- shell: (body: string) => wrapHTML(body, nonce, { clientScript, loaderData, params: match.params, cssFiles, headElements: getServerHead() }),
189
+ shell: (body: string) => wrapHTML(body, nonce, {
190
+ clientScript,
191
+ loaderData,
192
+ params: match.params,
193
+ cssFiles,
194
+ headElements: getServerHead(),
195
+ }),
270
196
  })
271
197
  return new Response(stream, {
272
198
  headers: { "Content-Type": "text/html", ...secHeaders },
273
199
  })
274
200
  }
275
201
 
276
- const vnode = ssrJsx(pageComponent as any, pageProps)
277
- const body = renderToString(vnode)
278
- const html = wrapHTML(body, nonce, { clientScript, loaderData, params: match.params, cssFiles, headElements: getServerHead() })
202
+ const rendered = renderPageDocument(pageComponent, ctx, match.params, loaderData)
203
+ const html = wrapHTML(rendered.html, nonce, {
204
+ clientScript,
205
+ loaderData,
206
+ params: match.params,
207
+ cssFiles,
208
+ headElements: rendered.headElements,
209
+ })
279
210
  return new Response(html, {
280
211
  headers: { "Content-Type": "text/html", ...secHeaders },
281
212
  })
282
- })
283
- } catch (err) {
284
- if (err instanceof RedirectError) {
285
- return new Response(null, {
286
- status: err.status,
287
- headers: { Location: err.url },
288
- })
289
- }
290
- const message = err instanceof Error ? err.message : String(err)
291
- log.error("request error", { path: pathname, error: message })
292
- return new Response(wrapHTML("<h1>500</h1><p>Internal Server Error</p>", nonce), {
293
- status: 500,
294
- headers: { "Content-Type": "text/html", ...secHeaders },
295
- })
296
- }
297
- },
298
- })
213
+ },
214
+ })
215
+ } catch (err) {
216
+ const message = err instanceof Error ? err.message : String(err)
217
+ log.error("request error", { path: pathname, error: message })
218
+ return new Response(wrapHTML("<h1>500</h1><p>Internal Server Error</p>", nonce), {
219
+ status: 500,
220
+ headers: { "Content-Type": "text/html", ...secHeaders },
221
+ })
222
+ }
223
+ }
224
+ }
299
225
 
300
- log.info("production server started", { url: `http://localhost:${server.port}` })
226
+ async function loadProductionRuntimeState(options: RuntimeOptions = {}): Promise<ProductionRuntimeState> {
227
+ const runtime = createProjectContext(options)
301
228
 
302
- // Graceful shutdown
303
- const shutdown = () => {
304
- log.info("shutting down...")
305
- server.stop(true) // close existing connections gracefully
306
- process.exit(0)
229
+ await loadEnv(runtime.cwd)
230
+ const envConfig = resolveRuntimeEnv(process.env)
231
+ setLogLevel(envConfig.logLevel)
232
+
233
+ const manifest = await loadBuildManifest(runtime.paths.distDir)
234
+ log.info("loaded manifest", { routes: Object.keys(manifest.routes).length, built: manifest.buildTime })
235
+
236
+ const routes = await createRouter(runtime.paths.routesDir)
237
+ const staticMap = buildStaticMap(routes)
238
+ const rateLimiter = createRateLimiter(envConfig.rateLimit, envConfig.rateWindow)
239
+
240
+ return {
241
+ cwd: runtime.cwd,
242
+ routesDir: runtime.paths.routesDir,
243
+ publicDir: runtime.paths.publicDir,
244
+ distDir: runtime.paths.distDir,
245
+ clientDir: runtime.paths.clientDir,
246
+ manifest,
247
+ routes,
248
+ staticMap,
249
+ rateLimiter,
250
+ compressMiddleware: compress(),
307
251
  }
308
- process.on("SIGTERM", shutdown)
309
- process.on("SIGINT", shutdown)
310
252
  }
@@ -0,0 +1,73 @@
1
+ import { join } from "node:path"
2
+
3
+ export interface RuntimeOptions {
4
+ cwd?: string
5
+ env?: NodeJS.ProcessEnv
6
+ }
7
+
8
+ export interface ProjectPaths {
9
+ cwd: string
10
+ routesDir: string
11
+ publicDir: string
12
+ distDir: string
13
+ clientDir: string
14
+ serverDir: string
15
+ gorseeDir: string
16
+ sharedDir: string
17
+ middlewareDir: string
18
+ migrationsDir: string
19
+ docsDir: string
20
+ dataFile: string
21
+ }
22
+
23
+ export interface ProjectContext {
24
+ cwd: string
25
+ env: NodeJS.ProcessEnv
26
+ paths: ProjectPaths
27
+ }
28
+
29
+ export interface RuntimeEnvConfig {
30
+ port: number
31
+ logLevel: "info" | "debug"
32
+ rateLimit: number
33
+ rateWindow: string
34
+ isProduction: boolean
35
+ }
36
+
37
+ export function resolveProjectPaths(cwd: string): ProjectPaths {
38
+ const gorseeDir = join(cwd, ".gorsee")
39
+ const distDir = join(cwd, "dist")
40
+ return {
41
+ cwd,
42
+ routesDir: join(cwd, "routes"),
43
+ publicDir: join(cwd, "public"),
44
+ distDir,
45
+ clientDir: join(distDir, "client"),
46
+ serverDir: join(distDir, "server"),
47
+ gorseeDir,
48
+ sharedDir: join(cwd, "shared"),
49
+ middlewareDir: join(cwd, "middleware"),
50
+ migrationsDir: join(cwd, "migrations"),
51
+ docsDir: join(cwd, "docs"),
52
+ dataFile: join(cwd, "data.sqlite"),
53
+ }
54
+ }
55
+
56
+ export function createProjectContext(options: RuntimeOptions = {}): ProjectContext {
57
+ const cwd = options.cwd ?? process.cwd()
58
+ return {
59
+ cwd,
60
+ env: options.env ?? process.env,
61
+ paths: resolveProjectPaths(cwd),
62
+ }
63
+ }
64
+
65
+ export function resolveRuntimeEnv(env: NodeJS.ProcessEnv): RuntimeEnvConfig {
66
+ return {
67
+ port: Number(env.PORT || "3000"),
68
+ logLevel: env.LOG_LEVEL === "debug" ? "debug" : "info",
69
+ rateLimit: Number(env.RATE_LIMIT) || 1000,
70
+ rateWindow: env.RATE_WINDOW || "1m",
71
+ isProduction: env.NODE_ENV === "production",
72
+ }
73
+ }
@@ -0,0 +1,23 @@
1
+ import type { CacheEntry, CacheStore } from "./cache.ts"
2
+
3
+ export function createNamespacedCacheStore(store: CacheStore, namespace: string): CacheStore {
4
+ const prefix = `${namespace}:`
5
+ return {
6
+ get: (key) => store.get(prefix + key),
7
+ set: async (key, entry) => { await store.set(prefix + key, entry) },
8
+ delete: async (key) => { await store.delete(prefix + key) },
9
+ clear: async () => {
10
+ const keys = await store.keys()
11
+ for await (const key of keys) {
12
+ if (key.startsWith(prefix)) await store.delete(key)
13
+ }
14
+ },
15
+ keys: async function* () {
16
+ const keys = await store.keys()
17
+ for await (const key of keys) {
18
+ if (!key.startsWith(prefix)) continue
19
+ yield key.slice(prefix.length)
20
+ }
21
+ },
22
+ }
23
+ }