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,224 @@
|
|
|
1
|
+
// Client-side SPA router
|
|
2
|
+
// Intercepts link clicks, fetches partial pages from server, swaps content
|
|
3
|
+
// Uses History API for back/forward navigation
|
|
4
|
+
|
|
5
|
+
import { hydrate } from "./client.ts"
|
|
6
|
+
|
|
7
|
+
interface NavigationResult {
|
|
8
|
+
html: string
|
|
9
|
+
data?: unknown
|
|
10
|
+
params?: Record<string, string>
|
|
11
|
+
title?: string
|
|
12
|
+
css?: string[]
|
|
13
|
+
script?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type NavigateCallback = (url: string) => void
|
|
17
|
+
type BeforeNavigateCallback = (url: string) => boolean | void
|
|
18
|
+
|
|
19
|
+
let currentPath = ""
|
|
20
|
+
const subscribers: NavigateCallback[] = []
|
|
21
|
+
const beforeNavigateHooks: BeforeNavigateCallback[] = []
|
|
22
|
+
let navigating = false
|
|
23
|
+
let loadingElement: HTMLElement | null = null
|
|
24
|
+
|
|
25
|
+
/** Set a loading indicator element to show during navigation */
|
|
26
|
+
export function setLoadingElement(el: HTMLElement): void {
|
|
27
|
+
loadingElement = el
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function showLoading(): void {
|
|
31
|
+
if (loadingElement) {
|
|
32
|
+
loadingElement.style.display = ""
|
|
33
|
+
loadingElement.removeAttribute("hidden")
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hideLoading(): void {
|
|
38
|
+
if (loadingElement) {
|
|
39
|
+
loadingElement.style.display = "none"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function onNavigate(fn: NavigateCallback): () => void {
|
|
44
|
+
subscribers.push(fn)
|
|
45
|
+
return () => {
|
|
46
|
+
const i = subscribers.indexOf(fn)
|
|
47
|
+
if (i >= 0) subscribers.splice(i, 1)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function beforeNavigate(fn: BeforeNavigateCallback): () => void {
|
|
52
|
+
beforeNavigateHooks.push(fn)
|
|
53
|
+
return () => {
|
|
54
|
+
const i = beforeNavigateHooks.indexOf(fn)
|
|
55
|
+
if (i >= 0) beforeNavigateHooks.splice(i, 1)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getCurrentPath(): string {
|
|
60
|
+
return currentPath
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function fetchPage(url: string): Promise<NavigationResult> {
|
|
64
|
+
const res = await fetch(url, {
|
|
65
|
+
headers: { "X-Gorsee-Navigate": "partial" },
|
|
66
|
+
})
|
|
67
|
+
if (!res.ok) throw new Error(`Navigation failed: ${res.status}`)
|
|
68
|
+
return res.json()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function updateHead(result: NavigationResult): void {
|
|
72
|
+
if (result.title) document.title = result.title
|
|
73
|
+
|
|
74
|
+
// Remove old route CSS
|
|
75
|
+
document.querySelectorAll("link[data-g-route-css]").forEach((el) => el.remove())
|
|
76
|
+
|
|
77
|
+
// Add new route CSS
|
|
78
|
+
if (result.css) {
|
|
79
|
+
for (const href of result.css) {
|
|
80
|
+
const link = document.createElement("link")
|
|
81
|
+
link.rel = "stylesheet"
|
|
82
|
+
link.href = href
|
|
83
|
+
link.dataset.gRouteCss = ""
|
|
84
|
+
document.head.appendChild(link)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function updateDataScript(result: NavigationResult): void {
|
|
90
|
+
// Update __GORSEE_DATA__
|
|
91
|
+
let dataEl = document.getElementById("__GORSEE_DATA__")
|
|
92
|
+
if (result.data !== undefined) {
|
|
93
|
+
if (!dataEl) {
|
|
94
|
+
const script = document.createElement("script")
|
|
95
|
+
script.id = "__GORSEE_DATA__"
|
|
96
|
+
script.type = "application/json"
|
|
97
|
+
document.body.appendChild(script)
|
|
98
|
+
dataEl = script
|
|
99
|
+
}
|
|
100
|
+
dataEl.textContent = JSON.stringify(result.data)
|
|
101
|
+
} else if (dataEl) {
|
|
102
|
+
dataEl.remove()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Update __GORSEE_PARAMS__
|
|
106
|
+
if (result.params) {
|
|
107
|
+
(globalThis as unknown as Record<string, unknown>).__GORSEE_PARAMS__ = result.params
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function navigate(url: string, pushState = true): Promise<void> {
|
|
112
|
+
if (navigating) return
|
|
113
|
+
if (url === currentPath) return
|
|
114
|
+
|
|
115
|
+
// Before-navigate hooks (can cancel)
|
|
116
|
+
for (const hook of beforeNavigateHooks) {
|
|
117
|
+
if (hook(url) === false) return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
navigating = true
|
|
121
|
+
showLoading()
|
|
122
|
+
try {
|
|
123
|
+
const result = await fetchPage(url)
|
|
124
|
+
const container = document.getElementById("app")
|
|
125
|
+
if (!container) return
|
|
126
|
+
|
|
127
|
+
// Update DOM
|
|
128
|
+
container.innerHTML = result.html
|
|
129
|
+
updateHead(result)
|
|
130
|
+
updateDataScript(result)
|
|
131
|
+
|
|
132
|
+
// Update history
|
|
133
|
+
if (pushState) {
|
|
134
|
+
history.pushState({ gorsee: true }, "", url)
|
|
135
|
+
}
|
|
136
|
+
currentPath = url
|
|
137
|
+
|
|
138
|
+
// Hydrate new content
|
|
139
|
+
if (result.script) {
|
|
140
|
+
const mod = await import(/* @vite-ignore */ result.script)
|
|
141
|
+
if (mod.default && typeof mod.default === "function") {
|
|
142
|
+
const data = result.data ?? {}
|
|
143
|
+
const params = result.params ?? {}
|
|
144
|
+
hydrate(() => mod.default({ data, params }), container)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Scroll to top
|
|
149
|
+
window.scrollTo(0, 0)
|
|
150
|
+
|
|
151
|
+
// Notify subscribers
|
|
152
|
+
for (const fn of subscribers) fn(url)
|
|
153
|
+
} finally {
|
|
154
|
+
hideLoading()
|
|
155
|
+
navigating = false
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Prefetch cache
|
|
160
|
+
const prefetchCache = new Set<string>()
|
|
161
|
+
|
|
162
|
+
export function prefetch(url: string): void {
|
|
163
|
+
if (prefetchCache.has(url)) return
|
|
164
|
+
prefetchCache.add(url)
|
|
165
|
+
// Use low-priority fetch
|
|
166
|
+
const link = document.createElement("link")
|
|
167
|
+
link.rel = "prefetch"
|
|
168
|
+
link.href = url
|
|
169
|
+
link.setAttribute("as", "fetch")
|
|
170
|
+
link.setAttribute("crossorigin", "")
|
|
171
|
+
document.head.appendChild(link)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function shouldHandleClick(e: MouseEvent, anchor: HTMLAnchorElement): boolean {
|
|
175
|
+
// Don't handle modified clicks (new tab, etc.)
|
|
176
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return false
|
|
177
|
+
if (e.button !== 0) return false
|
|
178
|
+
|
|
179
|
+
// Don't handle links with target
|
|
180
|
+
if (anchor.target && anchor.target !== "_self") return false
|
|
181
|
+
|
|
182
|
+
// Only handle same-origin
|
|
183
|
+
if (anchor.origin !== location.origin) return false
|
|
184
|
+
|
|
185
|
+
// Don't handle downloads
|
|
186
|
+
if (anchor.hasAttribute("download")) return false
|
|
187
|
+
|
|
188
|
+
// Don't handle hash-only links
|
|
189
|
+
if (anchor.pathname === location.pathname && anchor.hash) return false
|
|
190
|
+
|
|
191
|
+
return true
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function initRouter(): void {
|
|
195
|
+
currentPath = location.pathname
|
|
196
|
+
|
|
197
|
+
// Intercept link clicks
|
|
198
|
+
document.addEventListener("click", (e) => {
|
|
199
|
+
const anchor = (e.target as Element).closest?.("a[href]") as HTMLAnchorElement | null
|
|
200
|
+
if (!anchor) return
|
|
201
|
+
if (anchor.dataset.gNoRouter !== undefined) return
|
|
202
|
+
|
|
203
|
+
if (shouldHandleClick(e, anchor)) {
|
|
204
|
+
e.preventDefault()
|
|
205
|
+
navigate(anchor.pathname + anchor.search)
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// Handle back/forward
|
|
210
|
+
window.addEventListener("popstate", (e) => {
|
|
211
|
+
if (e.state?.gorsee || currentPath !== location.pathname) {
|
|
212
|
+
navigate(location.pathname + location.search, false)
|
|
213
|
+
}
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
// Prefetch on hover
|
|
217
|
+
document.addEventListener("mouseover", (e) => {
|
|
218
|
+
const anchor = (e.target as Element).closest?.("a[href]") as HTMLAnchorElement | null
|
|
219
|
+
if (!anchor) return
|
|
220
|
+
if (anchor.origin !== location.origin) return
|
|
221
|
+
if (anchor.dataset.gNoRouter !== undefined) return
|
|
222
|
+
prefetch(anchor.pathname + anchor.search)
|
|
223
|
+
})
|
|
224
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Server-side rendering -- renders component tree to HTML string
|
|
2
|
+
// No DOM API used -- pure string concatenation
|
|
3
|
+
|
|
4
|
+
import type { Component } from "./jsx-runtime.ts"
|
|
5
|
+
import { escapeHTML, escapeAttr, VOID_ELEMENTS, resolveValue } from "./html-escape.ts"
|
|
6
|
+
|
|
7
|
+
export type VNode = {
|
|
8
|
+
type: string | Component | symbol
|
|
9
|
+
props: Record<string, unknown>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function renderChild(child: unknown): string {
|
|
13
|
+
if (child == null || typeof child === "boolean") return ""
|
|
14
|
+
|
|
15
|
+
if (Array.isArray(child)) {
|
|
16
|
+
let s = ""
|
|
17
|
+
for (let i = 0; i < child.length; i++) s += renderChild(child[i])
|
|
18
|
+
return s
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (typeof child === "object" && child !== null && "type" in child) {
|
|
22
|
+
return renderVNode(child as VNode)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const resolved = resolveValue(child)
|
|
26
|
+
if (resolved == null || typeof resolved === "boolean") return ""
|
|
27
|
+
return escapeHTML(String(resolved))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function renderAttrs(props: Record<string, unknown>): string {
|
|
31
|
+
let result = ""
|
|
32
|
+
|
|
33
|
+
for (const key in props) {
|
|
34
|
+
if (key === "children" || key === "ref" || key.startsWith("on:")) continue
|
|
35
|
+
|
|
36
|
+
const value = resolveValue(props[key])
|
|
37
|
+
|
|
38
|
+
if (key === "className" || key === "class") {
|
|
39
|
+
if (value != null) result += ` class="${escapeAttr(String(value))}"`
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (key === "style" && typeof value === "object" && value !== null) {
|
|
44
|
+
let styles = ""
|
|
45
|
+
for (const p in value as Record<string, unknown>) {
|
|
46
|
+
if (styles) styles += "; "
|
|
47
|
+
const sv = resolveValue((value as Record<string, unknown>)[p])
|
|
48
|
+
if (sv != null) styles += `${p}: ${sv}`
|
|
49
|
+
}
|
|
50
|
+
result += ` style="${escapeAttr(styles)}"`
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (typeof value === "boolean") {
|
|
55
|
+
if (value) result += ` ${key}`
|
|
56
|
+
} else if (value != null) {
|
|
57
|
+
result += ` ${key}="${escapeAttr(String(value))}"`
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return result
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function renderVNode(vnode: VNode): string {
|
|
65
|
+
const { type, props } = vnode
|
|
66
|
+
|
|
67
|
+
// Fragment
|
|
68
|
+
if (typeof type === "symbol") {
|
|
69
|
+
return renderChild(props.children)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Component
|
|
73
|
+
if (typeof type === "function") {
|
|
74
|
+
const result = type(props)
|
|
75
|
+
return renderChild(result)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// HTML element
|
|
79
|
+
const tag = type as string
|
|
80
|
+
const attrs = renderAttrs(props)
|
|
81
|
+
|
|
82
|
+
if (VOID_ELEMENTS.has(tag)) {
|
|
83
|
+
return `<${tag}${attrs} />`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const children = renderChild(props.children)
|
|
87
|
+
return `<${tag}${attrs}>${children}</${tag}>`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Server-side jsx function -- creates VNodes instead of DOM nodes
|
|
91
|
+
export function ssrJsx(
|
|
92
|
+
type: string | Component | symbol,
|
|
93
|
+
props: Record<string, unknown> | null
|
|
94
|
+
): VNode {
|
|
95
|
+
return { type, props: props ?? {} }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const ssrJsxs = ssrJsx
|
|
99
|
+
|
|
100
|
+
export function renderToString(root: unknown): string {
|
|
101
|
+
return renderChild(root)
|
|
102
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Out-of-order streaming SSR
|
|
2
|
+
// Sends HTML shell with Suspense fallbacks immediately,
|
|
3
|
+
// then streams resolved chunks as data becomes available
|
|
4
|
+
|
|
5
|
+
import type { Component } from "./jsx-runtime.ts"
|
|
6
|
+
import { escapeHTML, escapeAttr, VOID_ELEMENTS, resolveValue } from "./html-escape.ts"
|
|
7
|
+
|
|
8
|
+
interface SuspenseSlot {
|
|
9
|
+
id: string
|
|
10
|
+
fallback: unknown
|
|
11
|
+
children: unknown
|
|
12
|
+
resolve: () => Promise<unknown>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface StreamContext {
|
|
16
|
+
suspenseSlots: SuspenseSlot[]
|
|
17
|
+
nextId: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface VNode {
|
|
21
|
+
type: string | Component | symbol
|
|
22
|
+
props: Record<string, unknown>
|
|
23
|
+
__gorsee_suspense?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function streamJsx(
|
|
27
|
+
type: string | Component | symbol,
|
|
28
|
+
props: Record<string, unknown> | null
|
|
29
|
+
): VNode {
|
|
30
|
+
return { type, props: props ?? {} }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const streamJsxs = streamJsx
|
|
34
|
+
|
|
35
|
+
// Marker for Suspense components in streaming mode
|
|
36
|
+
export function StreamSuspense(props: {
|
|
37
|
+
fallback: unknown
|
|
38
|
+
children: unknown
|
|
39
|
+
__streamCtx?: StreamContext
|
|
40
|
+
}): VNode {
|
|
41
|
+
const node = streamJsx("gorsee-suspense", {
|
|
42
|
+
fallback: props.fallback,
|
|
43
|
+
children: props.children,
|
|
44
|
+
})
|
|
45
|
+
node.__gorsee_suspense = true
|
|
46
|
+
return node
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function renderAttrs(props: Record<string, unknown>): string {
|
|
50
|
+
const parts: string[] = []
|
|
51
|
+
for (const [key, rawValue] of Object.entries(props)) {
|
|
52
|
+
if (key === "children" || key === "ref" || key === "fallback" || key.startsWith("on:")) continue
|
|
53
|
+
const value = resolveValue(rawValue)
|
|
54
|
+
if (key === "className" || key === "class") {
|
|
55
|
+
if (value != null) parts.push(` class="${escapeAttr(String(value))}"`)
|
|
56
|
+
} else if (typeof value === "boolean") {
|
|
57
|
+
if (value) parts.push(` ${key}`)
|
|
58
|
+
} else if (value != null) {
|
|
59
|
+
parts.push(` ${key}="${escapeAttr(String(value))}"`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return parts.join("")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function renderShellNode(node: unknown, ctx: StreamContext): string {
|
|
66
|
+
if (node == null || typeof node === "boolean") return ""
|
|
67
|
+
|
|
68
|
+
if (Array.isArray(node)) {
|
|
69
|
+
return node.map((n) => renderShellNode(n, ctx)).join("")
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (typeof node === "object" && node !== null && "type" in node) {
|
|
73
|
+
const vnode = node as VNode
|
|
74
|
+
|
|
75
|
+
// Suspense boundary -- render fallback, register slot for later resolution
|
|
76
|
+
if (vnode.__gorsee_suspense) {
|
|
77
|
+
const id = `s${ctx.nextId++}`
|
|
78
|
+
const fallbackHtml = renderShellNode(vnode.props.fallback, ctx)
|
|
79
|
+
|
|
80
|
+
ctx.suspenseSlots.push({
|
|
81
|
+
id,
|
|
82
|
+
fallback: vnode.props.fallback,
|
|
83
|
+
children: vnode.props.children,
|
|
84
|
+
resolve: async () => {
|
|
85
|
+
// Resolve the async children
|
|
86
|
+
const children = vnode.props.children
|
|
87
|
+
if (typeof children === "function") {
|
|
88
|
+
return await (children as () => Promise<unknown>)()
|
|
89
|
+
}
|
|
90
|
+
return children
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
return `<div data-g-suspense="${id}">${fallbackHtml}</div>`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Fragment
|
|
98
|
+
if (typeof vnode.type === "symbol") {
|
|
99
|
+
return renderShellNode(vnode.props.children, ctx)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Component
|
|
103
|
+
if (typeof vnode.type === "function") {
|
|
104
|
+
const result = vnode.type(vnode.props)
|
|
105
|
+
return renderShellNode(result, ctx)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// HTML element
|
|
109
|
+
const tag = vnode.type as string
|
|
110
|
+
const attrs = renderAttrs(vnode.props)
|
|
111
|
+
if (VOID_ELEMENTS.has(tag)) return `<${tag}${attrs} />`
|
|
112
|
+
const children = renderShellNode(vnode.props.children, ctx)
|
|
113
|
+
return `<${tag}${attrs}>${children}</${tag}>`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const resolved = resolveValue(node)
|
|
117
|
+
if (resolved == null || typeof resolved === "boolean") return ""
|
|
118
|
+
return escapeHTML(String(resolved))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Generate the inline script that swaps a Suspense fallback with resolved content
|
|
122
|
+
function chunkScript(slotId: string, html: string): string {
|
|
123
|
+
return [
|
|
124
|
+
`<template data-g-chunk="${slotId}">${html}</template>`,
|
|
125
|
+
`<script>`,
|
|
126
|
+
`(function(){`,
|
|
127
|
+
` var t=document.querySelector('[data-g-chunk="${slotId}"]');`,
|
|
128
|
+
` var s=document.querySelector('[data-g-suspense="${slotId}"]');`,
|
|
129
|
+
` if(t&&s){s.innerHTML=t.content.firstChild?'':'';s.replaceChildren(...t.content.childNodes);t.remove();s.removeAttribute('data-g-suspense')}`,
|
|
130
|
+
`})();`,
|
|
131
|
+
`</script>`,
|
|
132
|
+
].join("")
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface StreamOptions {
|
|
136
|
+
shell?: (body: string) => string
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const defaultShell = (body: string) => `<!DOCTYPE html>
|
|
140
|
+
<html lang="en">
|
|
141
|
+
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Gorsee App</title></head>
|
|
142
|
+
<body><div id="app">${body}</div>`
|
|
143
|
+
|
|
144
|
+
const STREAM_TAIL = `</body></html>`
|
|
145
|
+
|
|
146
|
+
export function renderToStream(
|
|
147
|
+
root: unknown,
|
|
148
|
+
options?: StreamOptions
|
|
149
|
+
): ReadableStream<Uint8Array> {
|
|
150
|
+
const shell = options?.shell ?? defaultShell
|
|
151
|
+
const encoder = new TextEncoder()
|
|
152
|
+
const ctx: StreamContext = { suspenseSlots: [], nextId: 0 }
|
|
153
|
+
|
|
154
|
+
return new ReadableStream({
|
|
155
|
+
async start(controller) {
|
|
156
|
+
// 1. Render shell synchronously (with fallbacks for Suspense boundaries)
|
|
157
|
+
const shellHtml = renderShellNode(root, ctx)
|
|
158
|
+
controller.enqueue(encoder.encode(shell(shellHtml)))
|
|
159
|
+
|
|
160
|
+
// 2. Resolve each Suspense slot and stream chunks
|
|
161
|
+
const promises = ctx.suspenseSlots.map(async (slot) => {
|
|
162
|
+
try {
|
|
163
|
+
const resolved = await slot.resolve()
|
|
164
|
+
const html = renderShellNode(resolved, ctx)
|
|
165
|
+
const script = chunkScript(slot.id, html)
|
|
166
|
+
controller.enqueue(encoder.encode(script))
|
|
167
|
+
} catch (err) {
|
|
168
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
169
|
+
const errorHtml = `<div class="error">Error: ${escapeHTML(message)}</div>`
|
|
170
|
+
const script = chunkScript(slot.id, errorHtml)
|
|
171
|
+
controller.enqueue(encoder.encode(script))
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
await Promise.all(promises)
|
|
176
|
+
|
|
177
|
+
// 3. Close the stream
|
|
178
|
+
controller.enqueue(encoder.encode(STREAM_TAIL))
|
|
179
|
+
controller.close()
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Suspense component -- shows fallback while async resources load
|
|
2
|
+
// Works with createResource() from reactive module
|
|
3
|
+
|
|
4
|
+
import { createSignal } from "../reactive/signal.ts"
|
|
5
|
+
|
|
6
|
+
export interface SuspenseProps {
|
|
7
|
+
fallback: unknown
|
|
8
|
+
children: unknown
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type AsyncChild = () => Promise<unknown>
|
|
12
|
+
|
|
13
|
+
// Client-side Suspense: renders fallback, then swaps to content when ready
|
|
14
|
+
export function Suspense(props: SuspenseProps): unknown {
|
|
15
|
+
const children = props.children
|
|
16
|
+
|
|
17
|
+
// If children is a function that returns a promise, handle async
|
|
18
|
+
if (typeof children === "function") {
|
|
19
|
+
const asyncFn = children as AsyncChild
|
|
20
|
+
const [resolved, setResolved] = createSignal<unknown>(null)
|
|
21
|
+
const [pending, setPending] = createSignal(true)
|
|
22
|
+
|
|
23
|
+
asyncFn().then((result) => {
|
|
24
|
+
setResolved(result)
|
|
25
|
+
setPending(false)
|
|
26
|
+
}).catch((err) => {
|
|
27
|
+
setResolved(err instanceof Error ? err.message : String(err))
|
|
28
|
+
setPending(false)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// Return a reactive choice between fallback and resolved
|
|
32
|
+
return () => pending() ? props.fallback : resolved()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Synchronous children -- just return them
|
|
36
|
+
return children
|
|
37
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Runtime helpers for type-safe route links and navigation
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build a URL from a route pattern and params.
|
|
5
|
+
* Replaces [param] and [...param] placeholders with actual values.
|
|
6
|
+
*/
|
|
7
|
+
export function typedLink(
|
|
8
|
+
path: string,
|
|
9
|
+
params: Record<string, string> = {},
|
|
10
|
+
): string {
|
|
11
|
+
return path
|
|
12
|
+
.replace(/\[\.\.\.(\w+)\]/g, (_, key) => params[key] ?? "")
|
|
13
|
+
.replace(/\[(\w+)\]/g, (_, key) => encodeURIComponent(params[key] ?? ""))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Navigate to a typed route. Client-side only.
|
|
18
|
+
* Dynamically imports the router to avoid circular dependencies.
|
|
19
|
+
*/
|
|
20
|
+
export function typedNavigate(
|
|
21
|
+
path: string,
|
|
22
|
+
params: Record<string, string> = {},
|
|
23
|
+
): Promise<void> {
|
|
24
|
+
const url = typedLink(path, params)
|
|
25
|
+
return import("./router.ts").then((m) => m.navigate(url))
|
|
26
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Type-safe validated forms with branded types
|
|
2
|
+
// Combines client-side validation with server-side action safety
|
|
3
|
+
|
|
4
|
+
export interface FieldRule {
|
|
5
|
+
required?: boolean
|
|
6
|
+
minLength?: number
|
|
7
|
+
maxLength?: number
|
|
8
|
+
pattern?: RegExp
|
|
9
|
+
min?: number
|
|
10
|
+
max?: number
|
|
11
|
+
custom?: (value: string) => string | null // return error message or null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface FormField {
|
|
15
|
+
name: string
|
|
16
|
+
rules: FieldRule
|
|
17
|
+
label?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface FormSchema {
|
|
21
|
+
fields: FormField[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ValidationError {
|
|
25
|
+
field: string
|
|
26
|
+
message: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ValidationResult<T> {
|
|
30
|
+
valid: boolean
|
|
31
|
+
data: T | null
|
|
32
|
+
errors: ValidationError[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Define a form schema for validation */
|
|
36
|
+
export function defineForm(fields: FormField[]): FormSchema {
|
|
37
|
+
return { fields }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function validateField(value: string | undefined, field: FormField): string | null {
|
|
41
|
+
const { rules, label } = field
|
|
42
|
+
const name = label ?? field.name
|
|
43
|
+
const v = value ?? ""
|
|
44
|
+
|
|
45
|
+
if (rules.required && !v.trim()) return `${name} is required`
|
|
46
|
+
if (!v && !rules.required) return null
|
|
47
|
+
if (rules.minLength !== undefined && v.length < rules.minLength)
|
|
48
|
+
return `${name} must be at least ${rules.minLength} characters`
|
|
49
|
+
if (rules.maxLength !== undefined && v.length > rules.maxLength)
|
|
50
|
+
return `${name} must be at most ${rules.maxLength} characters`
|
|
51
|
+
if (rules.pattern && !rules.pattern.test(v)) return `${name} format is invalid`
|
|
52
|
+
if (rules.min !== undefined && Number(v) < rules.min) return `${name} must be at least ${rules.min}`
|
|
53
|
+
if (rules.max !== undefined && Number(v) > rules.max) return `${name} must be at most ${rules.max}`
|
|
54
|
+
if (rules.custom) return rules.custom(v)
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Validate form data against schema */
|
|
59
|
+
export function validateForm<T extends Record<string, string>>(
|
|
60
|
+
data: Record<string, string>,
|
|
61
|
+
schema: FormSchema,
|
|
62
|
+
): ValidationResult<T> {
|
|
63
|
+
const errors: ValidationError[] = []
|
|
64
|
+
|
|
65
|
+
for (const field of schema.fields) {
|
|
66
|
+
const error = validateField(data[field.name], field)
|
|
67
|
+
if (error) errors.push({ field: field.name, message: error })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
valid: errors.length === 0,
|
|
72
|
+
data: errors.length === 0 ? (data as T) : null,
|
|
73
|
+
errors,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Server action helper: parse request and validate */
|
|
78
|
+
export async function validateAction<T extends Record<string, string>>(
|
|
79
|
+
request: Request,
|
|
80
|
+
schema: FormSchema,
|
|
81
|
+
): Promise<ValidationResult<T>> {
|
|
82
|
+
const contentType = request.headers.get("content-type") ?? ""
|
|
83
|
+
let data: Record<string, string>
|
|
84
|
+
|
|
85
|
+
if (contentType.includes("application/json")) {
|
|
86
|
+
data = await request.json()
|
|
87
|
+
} else {
|
|
88
|
+
const formData = await request.formData()
|
|
89
|
+
data = {}
|
|
90
|
+
formData.forEach((v, k) => { data[k] = String(v) })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return validateForm<T>(data, schema)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Generate client-side validation attributes for HTML inputs */
|
|
97
|
+
export function fieldAttrs(field: FormField): Record<string, unknown> {
|
|
98
|
+
const attrs: Record<string, unknown> = { name: field.name }
|
|
99
|
+
if (field.rules.required) attrs.required = true
|
|
100
|
+
if (field.rules.minLength !== undefined) attrs.minlength = field.rules.minLength
|
|
101
|
+
if (field.rules.maxLength !== undefined) attrs.maxlength = field.rules.maxLength
|
|
102
|
+
if (field.rules.pattern) attrs.pattern = field.rules.pattern.source
|
|
103
|
+
if (field.rules.min !== undefined) attrs.min = field.rules.min
|
|
104
|
+
if (field.rules.max !== undefined) attrs.max = field.rules.max
|
|
105
|
+
return attrs
|
|
106
|
+
}
|