gorsee 0.2.0 → 0.2.2

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 (52) hide show
  1. package/README.md +132 -4
  2. package/package.json +4 -2
  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 +152 -0
  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 +129 -0
  23. package/src/cli/cmd-typegen.ts +13 -3
  24. package/src/cli/cmd-upgrade.ts +143 -0
  25. package/src/cli/context.ts +12 -0
  26. package/src/cli/framework-md.ts +43 -16
  27. package/src/cli/index.ts +18 -0
  28. package/src/client.ts +26 -0
  29. package/src/dev/partial-handler.ts +17 -74
  30. package/src/dev/request-handler.ts +36 -67
  31. package/src/dev.ts +92 -157
  32. package/src/index-client.ts +4 -0
  33. package/src/index.ts +17 -2
  34. package/src/prod.ts +195 -253
  35. package/src/runtime/project.ts +73 -0
  36. package/src/server/cache-utils.ts +23 -0
  37. package/src/server/cache.ts +37 -14
  38. package/src/server/html-shell.ts +69 -0
  39. package/src/server/index.ts +40 -2
  40. package/src/server/manifest.ts +36 -0
  41. package/src/server/middleware.ts +18 -2
  42. package/src/server/not-found.ts +35 -0
  43. package/src/server/page-render.ts +123 -0
  44. package/src/server/redis-cache-store.ts +87 -0
  45. package/src/server/redis-client.ts +71 -0
  46. package/src/server/request-preflight.ts +45 -0
  47. package/src/server/route-request.ts +72 -0
  48. package/src/server/rpc-utils.ts +27 -0
  49. package/src/server/rpc.ts +70 -18
  50. package/src/server/sqlite-cache-store.ts +109 -0
  51. package/src/server/static-file.ts +63 -0
  52. package/src/server-entry.ts +36 -0
@@ -35,29 +35,56 @@ app.config.ts Configuration
35
35
  ## Imports
36
36
 
37
37
  \`\`\`typescript
38
- // Reactivity
39
- import { createSignal, createComputed, createEffect, createResource, createStore } from "gorsee/reactive"
38
+ // Browser-safe route code
39
+ import { createSignal, createComputed, createEffect, createResource, createStore } from "gorsee/client"
40
40
 
41
- // Types (compile-time safety)
42
- import { SafeSQL } from "gorsee/types" // SQL injection = compile error
43
- import { SafeHTML, sanitize } from "gorsee/types" // XSS = compile error
44
- import { SafeURL, validateURL } from "gorsee/types"
45
- import { validate, type UserInput } from "gorsee/types"
41
+ // Server-only code
42
+ import { server, middleware, type Context, createDB, createAuth, cors, log } from "gorsee/server"
43
+ \`\`\`
46
44
 
47
- // Database
48
- import { createDB } from "gorsee/db"
45
+ ## Import Boundaries
49
46
 
50
- // Server functions
51
- import { server } from "gorsee/server"
52
- import { middleware, type Context } from "gorsee/server"
47
+ - \`gorsee/client\` for route components, islands, navigation, forms, and reactive primitives
48
+ - \`gorsee/server\` for middleware, loaders, RPC, auth, db, security, env, and logging
49
+ - Root \`gorsee\` is compatibility-only and should not be used in new code
50
+ - \`gorsee/compat\` is available as an explicit legacy migration entrypoint
51
+
52
+ ## Adapter Recipes
53
53
 
54
- // Logging
55
- import { log } from "gorsee/log"
54
+ \`\`\`typescript
55
+ import { createClient } from "redis"
56
+ import {
57
+ createAuth,
58
+ createRedisSessionStore,
59
+ createRedisCacheStore,
60
+ createNodeRedisLikeClient,
61
+ routeCache,
62
+ } from "gorsee/server"
63
+
64
+ const redis = createClient({ url: process.env.REDIS_URL })
65
+ await redis.connect()
66
+ const redisClient = createNodeRedisLikeClient(redis)
67
+
68
+ const auth = createAuth({
69
+ secret: process.env.SESSION_SECRET!,
70
+ store: createRedisSessionStore(redisClient, { prefix: "app:sessions" }),
71
+ })
56
72
 
57
- // Escape hatches (DANGER)
58
- import { unsafeSQL, unsafeHTML } from "gorsee/unsafe"
73
+ export const cache = routeCache({
74
+ maxAge: 60,
75
+ staleWhileRevalidate: 300,
76
+ store: createRedisCacheStore(redisClient, {
77
+ prefix: "app:cache",
78
+ maxEntryAgeMs: 360_000,
79
+ }),
80
+ })
59
81
  \`\`\`
60
82
 
83
+ - SQLite adapters are the default persistent single-node path
84
+ - Redis adapters are the default multi-instance path
85
+ - \`createNodeRedisLikeClient()\` and \`createIORedisLikeClient()\` normalize real Redis SDK clients to the framework adapter contract
86
+ - RPC handlers remain process-local by design; do not try to distribute closures through Redis
87
+
61
88
  ## Patterns
62
89
 
63
90
  ### Page Route
package/src/cli/index.ts CHANGED
@@ -15,6 +15,9 @@ const COMMANDS: Record<string, string> = {
15
15
  generate: "Generate CRUD scaffold for entity",
16
16
  typegen: "Generate typed route definitions",
17
17
  deploy: "Generate deploy config (vercel/fly/cloudflare/netlify/docker)",
18
+ test: "Run tests (unit/integration/e2e)",
19
+ docs: "Generate API documentation from routes",
20
+ upgrade: "Upgrade Gorsee.js to latest version",
18
21
  help: "Show this help message",
19
22
  }
20
23
 
@@ -60,6 +63,21 @@ async function main() {
60
63
  const { runDeploy } = await import("./cmd-deploy.ts")
61
64
  await runDeploy(args.slice(1))
62
65
  break
66
+ case "test": {
67
+ const { runTest } = await import("./cmd-test.ts")
68
+ await runTest(args.slice(1))
69
+ break
70
+ }
71
+ case "docs": {
72
+ const { runDocs } = await import("./cmd-docs.ts")
73
+ await runDocs(args.slice(1))
74
+ break
75
+ }
76
+ case "upgrade": {
77
+ const { runUpgrade } = await import("./cmd-upgrade.ts")
78
+ await runUpgrade(args.slice(1))
79
+ break
80
+ }
63
81
  case "help":
64
82
  case undefined:
65
83
  case "--help":
package/src/client.ts ADDED
@@ -0,0 +1,26 @@
1
+ // Explicit browser-safe public entrypoint.
2
+ // Use this in route components and client-facing modules.
3
+
4
+ export {
5
+ createSignal,
6
+ createComputed,
7
+ createEffect,
8
+ createResource,
9
+ createStore,
10
+ createLive,
11
+ invalidateResource,
12
+ invalidateAll,
13
+ createMutation,
14
+ } from "./reactive/index.ts"
15
+
16
+ export { Suspense } from "./runtime/suspense.ts"
17
+ export { Link } from "./runtime/link.ts"
18
+ export { Head } from "./runtime/head.ts"
19
+ export { navigate, onNavigate, beforeNavigate, getCurrentPath } from "./runtime/router.ts"
20
+ export { useFormAction } from "./runtime/form.ts"
21
+ export { Image } from "./runtime/image.ts"
22
+ export { ErrorBoundary } from "./runtime/error-boundary.ts"
23
+ export { island } from "./runtime/island.ts"
24
+ export { createEventSource } from "./server/sse.ts"
25
+ export { typedLink, typedNavigate } from "./runtime/typed-routes.ts"
26
+ export { defineForm, validateForm, fieldAttrs } from "./runtime/validated-form.ts"
@@ -2,8 +2,12 @@
2
2
  // Returns JSON with rendered HTML + metadata instead of full page
3
3
 
4
4
  import { createContext } from "../server/middleware.ts"
5
- import { renderToString, ssrJsx } from "../runtime/server.ts"
6
- import { resetServerHead, getServerHead } from "../runtime/head.ts"
5
+ import {
6
+ buildPartialResponsePayload,
7
+ createClientScriptPath,
8
+ renderPageDocument,
9
+ resolvePageRoute,
10
+ } from "../server/page-render.ts"
7
11
  import type { MatchResult } from "../router/matcher.ts"
8
12
  import type { BuildResult } from "../build/client.ts"
9
13
 
@@ -13,15 +17,6 @@ interface PartialRenderOptions {
13
17
  clientBuild: BuildResult
14
18
  }
15
19
 
16
- interface PartialResponse {
17
- html: string
18
- data?: unknown
19
- params?: Record<string, string>
20
- title?: string
21
- css?: string[]
22
- script?: string
23
- }
24
-
25
20
  export async function handlePartialNavigation(opts: PartialRenderOptions): Promise<Response> {
26
21
  const { match, request, clientBuild } = opts
27
22
  const mod = await import(match.route.filePath)
@@ -32,76 +27,24 @@ export async function handlePartialNavigation(opts: PartialRenderOptions): Promi
32
27
  return new Response("Not a page route", { status: 400 })
33
28
  }
34
29
 
35
- const component = mod.default as Function | undefined
36
- if (typeof component !== "function") {
30
+ const resolved = await resolvePageRoute(mod, match, ctx)
31
+ if (!resolved) {
37
32
  return new Response(JSON.stringify({ error: "Route has no default export" }), {
38
33
  status: 500,
39
34
  headers: { "Content-Type": "application/json" },
40
35
  })
41
36
  }
42
37
 
43
- // Parallel loading: import layout modules + run page loader simultaneously
44
- const layoutPaths = match.route.layoutPaths ?? []
45
- const layoutImportPromises = layoutPaths.map((lp) => import(lp))
46
- const pageLoaderPromise = typeof mod.loader === "function" ? mod.loader(ctx) : undefined
47
-
48
- const [layoutMods, loaderData] = await Promise.all([
49
- Promise.all(layoutImportPromises),
50
- pageLoaderPromise,
51
- ])
52
-
53
- // Run layout loaders in parallel
54
- const layoutLoaderPromises = layoutMods.map((lm) =>
55
- typeof lm.loader === "function" ? lm.loader(ctx) : undefined,
38
+ const { pageComponent, loaderData, cssFiles } = resolved
39
+ const rendered = renderPageDocument(pageComponent, ctx, match.params, loaderData)
40
+ const clientScript = createClientScriptPath(clientBuild.entryMap.get(match.route.path))
41
+ const result = buildPartialResponsePayload(
42
+ rendered,
43
+ loaderData,
44
+ match.params,
45
+ cssFiles,
46
+ clientScript,
56
47
  )
57
- const layoutLoaderResults = await Promise.all(layoutLoaderPromises)
58
-
59
- // CSS
60
- const cssFiles: string[] = []
61
- if (typeof mod.css === "string") cssFiles.push(mod.css)
62
- if (Array.isArray(mod.css)) cssFiles.push(...(mod.css as string[]))
63
-
64
- // Nested layout wrapping: outermost first, innermost wraps page
65
- let pageComponent: Function = component
66
- for (let i = layoutMods.length - 1; i >= 0; i--) {
67
- const Layout = layoutMods[i]!.default
68
- if (typeof Layout === "function") {
69
- const inner = pageComponent
70
- const layoutData = layoutLoaderResults[i]
71
- pageComponent = (props: Record<string, unknown>) =>
72
- Layout({ ...props, data: layoutData, children: inner(props) })
73
- }
74
- }
75
-
76
- // Render
77
- resetServerHead()
78
- const pageProps = { params: match.params, ctx, data: loaderData }
79
- const vnode = ssrJsx(pageComponent as any, pageProps)
80
- const html = renderToString(vnode)
81
-
82
- // Extract title from Head component
83
- const headElements = getServerHead()
84
- let title: string | undefined
85
- for (const el of headElements) {
86
- const titleMatch = el.match(/<title>(.+?)<\/title>/)
87
- if (titleMatch) {
88
- title = titleMatch[1]
89
- break
90
- }
91
- }
92
-
93
- // Client JS path
94
- const clientJsFile = clientBuild.entryMap.get(match.route.path)
95
- const clientScript = clientJsFile ? `/_gorsee/${clientJsFile}` : undefined
96
-
97
- const result: PartialResponse = {
98
- html,
99
- data: loaderData,
100
- params: Object.keys(match.params).length > 0 ? match.params : undefined,
101
- title,
102
- css: cssFiles.length > 0 ? cssFiles : undefined,
103
- script: clientScript,
104
- }
105
48
 
106
49
  return new Response(JSON.stringify(result), {
107
50
  headers: { "Content-Type": "application/json" },
@@ -1,10 +1,13 @@
1
1
  // Page route request handler — SSR + streaming + layouts + loaders
2
2
 
3
- import { createContext, runMiddlewareChain, RedirectError, type MiddlewareFn } from "../server/middleware.ts"
3
+ import { createContext } from "../server/middleware.ts"
4
4
  import { renderToString, ssrJsx } from "../runtime/server.ts"
5
5
  import { renderToStream, streamJsx } from "../runtime/stream.ts"
6
+ import { resetServerHead, getServerHead } from "../runtime/head.ts"
6
7
  import { handleAction } from "../server/action.ts"
7
8
  import { log } from "../log/index.ts"
9
+ import { createClientScriptPath, renderPageDocument, resolvePageRoute } from "../server/page-render.ts"
10
+ import { handleRouteRequest } from "../server/route-request.ts"
8
11
  import type { MatchResult } from "../router/matcher.ts"
9
12
  import type { BuildResult } from "../build/client.ts"
10
13
 
@@ -15,7 +18,7 @@ interface RenderOptions {
15
18
  start: number
16
19
  clientBuild: BuildResult
17
20
  secHeaders: Record<string, string>
18
- wrapHTML: (body: string, nonce: string, opts?: { title?: string; clientScript?: string; loaderData?: unknown; params?: Record<string, string>; cssFiles?: string[] }) => string
21
+ wrapHTML: (body: string, nonce: string, opts?: { title?: string; clientScript?: string; loaderData?: unknown; params?: Record<string, string>; cssFiles?: string[]; headElements?: string[] }) => string
19
22
  }
20
23
 
21
24
  async function renderPageRoute(mod: Record<string, unknown>, opts: RenderOptions): Promise<Response> {
@@ -47,49 +50,24 @@ async function renderPageRoute(mod: Record<string, unknown>, opts: RenderOptions
47
50
  return new Response("Route has no default export", { status: 500 })
48
51
  }
49
52
 
50
- // Parallel loading: import layout modules + run page loader simultaneously
51
- const layoutPaths = match.route.layoutPaths ?? []
52
- const layoutImportPromises = layoutPaths.map((lp) => import(lp))
53
- const pageLoaderPromise = typeof mod.loader === "function" ? mod.loader(ctx) : undefined
54
-
55
- const [layoutMods, loaderData] = await Promise.all([
56
- Promise.all(layoutImportPromises),
57
- pageLoaderPromise,
58
- ])
59
-
60
- // Run layout loaders in parallel
61
- const layoutLoaderPromises = layoutMods.map((lm) =>
62
- typeof lm.loader === "function" ? lm.loader(ctx) : undefined,
63
- )
64
- const layoutLoaderResults = await Promise.all(layoutLoaderPromises)
65
-
66
- // CSS
67
- const cssFiles: string[] = []
68
- if (typeof mod.css === "string") cssFiles.push(mod.css)
69
- if (Array.isArray(mod.css)) cssFiles.push(...(mod.css as string[]))
70
-
71
- // Nested layout wrapping: outermost first, innermost wraps page
72
- let pageComponent: Function = component
73
- for (let i = layoutMods.length - 1; i >= 0; i--) {
74
- const Layout = layoutMods[i]!.default
75
- if (typeof Layout === "function") {
76
- const inner = pageComponent
77
- const layoutData = layoutLoaderResults[i]
78
- pageComponent = (props: Record<string, unknown>) =>
79
- Layout({ ...props, data: layoutData, children: inner(props) })
80
- }
53
+ const resolved = await resolvePageRoute(mod, match, ctx)
54
+ if (!resolved) {
55
+ return new Response("Route has no default export", { status: 500 })
81
56
  }
82
57
 
58
+ const { pageComponent, loaderData, cssFiles, renderMode } = resolved
83
59
  const pageProps = { params: match.params, ctx, data: loaderData }
84
- const renderMode = (mod.render as string) ?? "async"
85
- const clientJsFile = clientBuild.entryMap.get(match.route.path)
86
- const clientScript = clientJsFile ? `/_gorsee/${clientJsFile}` : undefined
60
+ const clientScript = createClientScriptPath(clientBuild.entryMap.get(match.route.path))
87
61
  const htmlOpts = { clientScript, loaderData, params: match.params, cssFiles }
88
62
 
89
63
  if (renderMode === "stream") {
64
+ resetServerHead()
90
65
  const vnode = streamJsx(pageComponent as any, pageProps)
91
66
  const stream = renderToStream(vnode, {
92
- shell: (body: string) => wrapHTML(body, nonce, htmlOpts),
67
+ shell: (body: string) => wrapHTML(body, nonce, {
68
+ ...htmlOpts,
69
+ headElements: getServerHead(),
70
+ }),
93
71
  })
94
72
  const elapsed = (performance.now() - start).toFixed(1)
95
73
  log.info("request", { method: request.method, path: match.route.path, status: 200, ms: elapsed, mode: "stream" })
@@ -99,9 +77,11 @@ async function renderPageRoute(mod: Record<string, unknown>, opts: RenderOptions
99
77
  }
100
78
 
101
79
  // Default: async SSR
102
- const vnode = ssrJsx(pageComponent as any, pageProps)
103
- const body = renderToString(vnode)
104
- const html = wrapHTML(body, nonce, htmlOpts)
80
+ const rendered = renderPageDocument(pageComponent, ctx, match.params, loaderData)
81
+ const html = wrapHTML(rendered.html, nonce, {
82
+ ...htmlOpts,
83
+ headElements: rendered.headElements,
84
+ })
105
85
  const elapsed = (performance.now() - start).toFixed(1)
106
86
  log.info("request", { method: request.method, path: match.route.path, status: 200, ms: elapsed })
107
87
 
@@ -112,33 +92,22 @@ async function renderPageRoute(mod: Record<string, unknown>, opts: RenderOptions
112
92
 
113
93
  export async function handlePageRequest(opts: RenderOptions): Promise<Response> {
114
94
  const { match, request } = opts
115
- const mod = await import(match.route.filePath)
116
-
117
- // Middleware chain (inherited from parent directories)
118
- const middlewares: MiddlewareFn[] = []
119
- for (const mwPath of match.route.middlewarePaths) {
120
- const mwMod = await import(mwPath)
121
- if (typeof mwMod.default === "function") middlewares.push(mwMod.default)
122
- }
123
-
124
- const ctx = createContext(request, match.params)
125
- try {
126
- return await runMiddlewareChain(middlewares, ctx, () => renderPageRoute(mod, opts))
127
- } catch (err) {
128
- // Redirect from loader/action (throw redirect("/path"))
129
- if (err instanceof RedirectError) {
130
- return new Response(null, {
131
- status: err.status,
132
- headers: { Location: err.url },
133
- })
134
- }
135
- // Error boundary: render _error.tsx if available
136
- if (match.route.errorPath) {
137
- const errObj = err instanceof Error ? err : new Error(String(err))
138
- return renderErrorBoundary(match.route.errorPath, errObj, opts)
139
- }
140
- throw err
141
- }
95
+ return handleRouteRequest({
96
+ match,
97
+ request,
98
+ onPartialRequest: async () => {
99
+ const { handlePartialNavigation } = await import("./partial-handler.ts")
100
+ return handlePartialNavigation({ match, request, clientBuild: opts.clientBuild })
101
+ },
102
+ onPageRequest: async ({ mod }) => renderPageRoute(mod, opts),
103
+ onRouteError: async (err) => {
104
+ if (match.route.errorPath) {
105
+ const errObj = err instanceof Error ? err : new Error(String(err))
106
+ return renderErrorBoundary(match.route.errorPath, errObj, opts)
107
+ }
108
+ throw err
109
+ },
110
+ })
142
111
  }
143
112
 
144
113
  async function renderErrorBoundary(errorPath: string, error: Error, opts: RenderOptions): Promise<Response> {