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.
- package/README.md +132 -4
- package/package.json +7 -4
- package/src/auth/index.ts +48 -17
- package/src/auth/redis-session-store.ts +46 -0
- package/src/auth/sqlite-session-store.ts +98 -0
- package/src/auth/store-utils.ts +21 -0
- package/src/build/client.ts +25 -7
- package/src/build/manifest.ts +34 -0
- package/src/build/route-metadata.ts +12 -0
- package/src/build/ssg.ts +19 -49
- package/src/cli/bun-plugin.ts +23 -2
- package/src/cli/cmd-build.ts +42 -71
- package/src/cli/cmd-check.ts +40 -26
- package/src/cli/cmd-create.ts +20 -5
- package/src/cli/cmd-deploy.ts +10 -2
- package/src/cli/cmd-dev.ts +9 -9
- package/src/cli/cmd-docs.ts +11 -4
- package/src/cli/cmd-generate.ts +15 -7
- package/src/cli/cmd-migrate.ts +15 -7
- package/src/cli/cmd-routes.ts +12 -5
- package/src/cli/cmd-start.ts +14 -5
- package/src/cli/cmd-test.ts +11 -3
- package/src/cli/cmd-typegen.ts +13 -3
- package/src/cli/cmd-upgrade.ts +10 -2
- package/src/cli/context.ts +12 -0
- package/src/cli/framework-md.ts +43 -16
- package/src/client.ts +26 -0
- package/src/dev/partial-handler.ts +17 -74
- package/src/dev/request-handler.ts +36 -67
- package/src/dev.ts +92 -157
- package/src/index-client.ts +4 -0
- package/src/index.ts +17 -2
- package/src/prod.ts +195 -253
- package/src/runtime/project.ts +73 -0
- package/src/server/cache-utils.ts +23 -0
- package/src/server/cache.ts +37 -14
- package/src/server/html-shell.ts +69 -0
- package/src/server/index.ts +40 -2
- package/src/server/manifest.ts +36 -0
- package/src/server/middleware.ts +18 -2
- package/src/server/not-found.ts +35 -0
- package/src/server/page-render.ts +123 -0
- package/src/server/redis-cache-store.ts +87 -0
- package/src/server/redis-client.ts +71 -0
- package/src/server/request-preflight.ts +45 -0
- package/src/server/route-request.ts +72 -0
- package/src/server/rpc-utils.ts +27 -0
- package/src/server/rpc.ts +70 -18
- package/src/server/sqlite-cache-store.ts +109 -0
- package/src/server/static-file.ts +63 -0
- 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 {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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(
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
134
|
-
|
|
95
|
+
export async function startProductionServer(options: StartProductionServerOptions = {}) {
|
|
96
|
+
const runtime = createProjectContext(options)
|
|
97
|
+
const registerSignalHandlers = options.registerSignalHandlers ?? true
|
|
135
98
|
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const pathname = url.pathname
|
|
106
|
+
port,
|
|
107
|
+
fetch: fetchHandler,
|
|
108
|
+
})
|
|
151
109
|
|
|
152
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
186
|
-
|
|
135
|
+
const ip = server?.requestIP(request)?.address ?? "unknown"
|
|
136
|
+
const rateLimitResponse = createRateLimitResponse(state.rateLimiter, ip)
|
|
137
|
+
if (rateLimitResponse) return rateLimitResponse
|
|
187
138
|
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
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, {
|
|
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
|
|
277
|
-
const
|
|
278
|
-
|
|
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
|
-
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
226
|
+
async function loadProductionRuntimeState(options: RuntimeOptions = {}): Promise<ProductionRuntimeState> {
|
|
227
|
+
const runtime = createProjectContext(options)
|
|
301
228
|
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
+
}
|