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.
Files changed (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/package.json +69 -0
  4. package/src/auth/index.ts +147 -0
  5. package/src/build/client.ts +121 -0
  6. package/src/build/css-modules.ts +69 -0
  7. package/src/build/devalue-parse.ts +2 -0
  8. package/src/build/rpc-transform.ts +62 -0
  9. package/src/build/server-strip.ts +87 -0
  10. package/src/build/ssg.ts +100 -0
  11. package/src/cli/bun-plugin.ts +37 -0
  12. package/src/cli/cmd-build.ts +182 -0
  13. package/src/cli/cmd-check.ts +225 -0
  14. package/src/cli/cmd-create.ts +313 -0
  15. package/src/cli/cmd-dev.ts +13 -0
  16. package/src/cli/cmd-generate.ts +147 -0
  17. package/src/cli/cmd-migrate.ts +45 -0
  18. package/src/cli/cmd-routes.ts +29 -0
  19. package/src/cli/cmd-start.ts +21 -0
  20. package/src/cli/cmd-typegen.ts +83 -0
  21. package/src/cli/framework-md.ts +196 -0
  22. package/src/cli/index.ts +84 -0
  23. package/src/db/index.ts +2 -0
  24. package/src/db/migrate.ts +89 -0
  25. package/src/db/sqlite.ts +40 -0
  26. package/src/deploy/dockerfile.ts +38 -0
  27. package/src/dev/error-overlay.ts +54 -0
  28. package/src/dev/hmr.ts +31 -0
  29. package/src/dev/partial-handler.ts +109 -0
  30. package/src/dev/request-handler.ts +158 -0
  31. package/src/dev/watcher.ts +48 -0
  32. package/src/dev.ts +273 -0
  33. package/src/env/index.ts +74 -0
  34. package/src/errors/catalog.ts +48 -0
  35. package/src/errors/formatter.ts +63 -0
  36. package/src/errors/index.ts +2 -0
  37. package/src/i18n/index.ts +72 -0
  38. package/src/index.ts +27 -0
  39. package/src/jsx-runtime-client.ts +13 -0
  40. package/src/jsx-runtime.ts +20 -0
  41. package/src/jsx-types-html.ts +242 -0
  42. package/src/log/index.ts +44 -0
  43. package/src/prod.ts +310 -0
  44. package/src/reactive/computed.ts +7 -0
  45. package/src/reactive/effect.ts +7 -0
  46. package/src/reactive/index.ts +7 -0
  47. package/src/reactive/live.ts +97 -0
  48. package/src/reactive/optimistic.ts +83 -0
  49. package/src/reactive/resource.ts +138 -0
  50. package/src/reactive/signal.ts +20 -0
  51. package/src/reactive/store.ts +36 -0
  52. package/src/router/index.ts +2 -0
  53. package/src/router/matcher.ts +53 -0
  54. package/src/router/scanner.ts +206 -0
  55. package/src/runtime/client.ts +28 -0
  56. package/src/runtime/error-boundary.ts +35 -0
  57. package/src/runtime/event-replay.ts +50 -0
  58. package/src/runtime/form.ts +49 -0
  59. package/src/runtime/head.ts +113 -0
  60. package/src/runtime/html-escape.ts +30 -0
  61. package/src/runtime/hydration.ts +95 -0
  62. package/src/runtime/image.ts +48 -0
  63. package/src/runtime/index.ts +12 -0
  64. package/src/runtime/island-hydrator.ts +84 -0
  65. package/src/runtime/island.ts +88 -0
  66. package/src/runtime/jsx-runtime.ts +167 -0
  67. package/src/runtime/link.ts +45 -0
  68. package/src/runtime/router.ts +224 -0
  69. package/src/runtime/server.ts +102 -0
  70. package/src/runtime/stream.ts +182 -0
  71. package/src/runtime/suspense.ts +37 -0
  72. package/src/runtime/typed-routes.ts +26 -0
  73. package/src/runtime/validated-form.ts +106 -0
  74. package/src/security/cors.ts +80 -0
  75. package/src/security/csrf.ts +85 -0
  76. package/src/security/headers.ts +50 -0
  77. package/src/security/index.ts +4 -0
  78. package/src/security/rate-limit.ts +80 -0
  79. package/src/server/action.ts +48 -0
  80. package/src/server/cache.ts +102 -0
  81. package/src/server/compress.ts +60 -0
  82. package/src/server/etag.ts +23 -0
  83. package/src/server/guard.ts +69 -0
  84. package/src/server/index.ts +19 -0
  85. package/src/server/middleware.ts +143 -0
  86. package/src/server/mime.ts +48 -0
  87. package/src/server/pipe.ts +46 -0
  88. package/src/server/rpc-hash.ts +17 -0
  89. package/src/server/rpc.ts +125 -0
  90. package/src/server/sse.ts +96 -0
  91. package/src/server/ws.ts +56 -0
  92. package/src/testing/index.ts +74 -0
  93. package/src/types/index.ts +4 -0
  94. package/src/types/safe-html.ts +32 -0
  95. package/src/types/safe-sql.ts +28 -0
  96. package/src/types/safe-url.ts +40 -0
  97. package/src/types/user-input.ts +12 -0
  98. package/src/unsafe/index.ts +18 -0
@@ -0,0 +1,95 @@
1
+ // Hydration context -- cursor-based DOM reuse
2
+ // During hydration, jsx() reuses server-rendered DOM nodes
3
+ // instead of creating new ones, then attaches reactive bindings
4
+
5
+ interface HydrationCursor {
6
+ parent: Node
7
+ index: number
8
+ }
9
+
10
+ const stack: HydrationCursor[] = []
11
+ let active = false
12
+ let mismatches = 0
13
+
14
+ export function isHydrating(): boolean {
15
+ return active
16
+ }
17
+
18
+ export function getHydrationMismatches(): number {
19
+ return mismatches
20
+ }
21
+
22
+ export function enterHydration(root: Element): void {
23
+ active = true
24
+ mismatches = 0
25
+ stack.length = 0
26
+ stack.push({ parent: root, index: 0 })
27
+ }
28
+
29
+ export function exitHydration(): void {
30
+ active = false
31
+ stack.length = 0
32
+ if (mismatches > 0 && typeof console !== "undefined") {
33
+ console.warn(`[gorsee] Hydration completed with ${mismatches} mismatch(es). Server and client HTML may differ.`)
34
+ }
35
+ }
36
+
37
+ export function claimElement(expectedTag?: string): Element | null {
38
+ const cursor = stack[stack.length - 1]
39
+ if (!cursor) return null
40
+
41
+ // Skip whitespace-only text nodes and comments (server may differ from client)
42
+ while (cursor.index < cursor.parent.childNodes.length) {
43
+ const node = cursor.parent.childNodes[cursor.index]!
44
+ if (node.nodeType === 1) {
45
+ // Element node — check tag match if provided
46
+ if (expectedTag && (node as Element).tagName.toLowerCase() !== expectedTag.toLowerCase()) {
47
+ mismatches++
48
+ // Skip mismatched element and continue looking
49
+ cursor.index++
50
+ continue
51
+ }
52
+ cursor.index++
53
+ return node as Element
54
+ }
55
+ if (node.nodeType === 3 && node.textContent?.trim() === "") {
56
+ cursor.index++
57
+ continue
58
+ }
59
+ if (node.nodeType === 8) {
60
+ // Comment node -- skip
61
+ cursor.index++
62
+ continue
63
+ }
64
+ break
65
+ }
66
+ return null
67
+ }
68
+
69
+ export function claimText(): Text | null {
70
+ const cursor = stack[stack.length - 1]
71
+ if (!cursor) return null
72
+
73
+ while (cursor.index < cursor.parent.childNodes.length) {
74
+ const node = cursor.parent.childNodes[cursor.index]!
75
+ if (node.nodeType === 3) {
76
+ cursor.index++
77
+ return node as Text
78
+ }
79
+ // Skip comments
80
+ if (node.nodeType === 8) {
81
+ cursor.index++
82
+ continue
83
+ }
84
+ break
85
+ }
86
+ return null
87
+ }
88
+
89
+ export function pushCursor(parent: Node): void {
90
+ stack.push({ parent, index: 0 })
91
+ }
92
+
93
+ export function popCursor(): void {
94
+ stack.pop()
95
+ }
@@ -0,0 +1,48 @@
1
+ // <Image> component -- optimized image rendering
2
+ // Server: renders <img> with lazy loading, width/height, srcset
3
+ // Prevents layout shift with explicit dimensions
4
+
5
+ export interface ImageProps {
6
+ src: string
7
+ alt: string
8
+ width?: number
9
+ height?: number
10
+ loading?: "lazy" | "eager"
11
+ priority?: boolean
12
+ sizes?: string
13
+ class?: string
14
+ className?: string
15
+ [key: string]: unknown
16
+ }
17
+
18
+ export function Image(props: ImageProps): unknown {
19
+ const {
20
+ src,
21
+ alt,
22
+ width,
23
+ height,
24
+ loading: loadingProp,
25
+ priority,
26
+ sizes,
27
+ ...rest
28
+ } = props
29
+
30
+ const loading = priority ? "eager" : (loadingProp ?? "lazy")
31
+ const fetchpriority = priority ? "high" : undefined
32
+ const decoding = priority ? "sync" : "async"
33
+
34
+ const imgProps: Record<string, unknown> = {
35
+ src,
36
+ alt,
37
+ loading,
38
+ decoding,
39
+ ...rest,
40
+ }
41
+
42
+ if (width !== undefined) imgProps.width = String(width)
43
+ if (height !== undefined) imgProps.height = String(height)
44
+ if (sizes) imgProps.sizes = sizes
45
+ if (fetchpriority) imgProps.fetchpriority = fetchpriority
46
+
47
+ return { type: "img", props: imgProps }
48
+ }
@@ -0,0 +1,12 @@
1
+ export { Suspense } from "./suspense.ts"
2
+ export { render, hydrate } from "./client.ts"
3
+ export { enterHydration, exitHydration, isHydrating } from "./hydration.ts"
4
+ export { renderToString, ssrJsx, ssrJsxs } from "./server.ts"
5
+ export { renderToStream, StreamSuspense, streamJsx, streamJsxs } from "./stream.ts"
6
+ export { EVENT_REPLAY_SCRIPT, replayEvents, stopEventCapture } from "./event-replay.ts"
7
+ export { Link } from "./link.ts"
8
+ export { Head } from "./head.ts"
9
+ export { navigate, onNavigate, beforeNavigate, getCurrentPath, prefetch, initRouter, setLoadingElement } from "./router.ts"
10
+ export { useFormAction } from "./form.ts"
11
+ export { island, isIsland } from "./island.ts"
12
+ export { hydrateIslands, registerIsland } from "./island-hydrator.ts"
@@ -0,0 +1,84 @@
1
+ // Client-side island hydration
2
+ // Scans DOM for data-island elements, hydrates each independently
3
+ // Static content around islands stays untouched (zero JS)
4
+
5
+ import { enterHydration, exitHydration } from "./hydration.ts"
6
+ import { replayEvents } from "./event-replay.ts"
7
+
8
+ type IslandLoader = () => Promise<{ default: (props: Record<string, unknown>) => unknown }>
9
+
10
+ const islandRegistry = new Map<string, IslandLoader>()
11
+
12
+ /** Register an island component loader by name. */
13
+ export function registerIsland(name: string, loader: IslandLoader): void {
14
+ islandRegistry.set(name, loader)
15
+ }
16
+
17
+ /** Parse the escaped JSON props from a data-props attribute. */
18
+ function parseIslandProps(raw: string): Record<string, unknown> {
19
+ try {
20
+ return JSON.parse(raw) as Record<string, unknown>
21
+ } catch {
22
+ console.warn(`[gorsee] Failed to parse island props: ${raw}`)
23
+ return {}
24
+ }
25
+ }
26
+
27
+ /** Hydrate a single island element. */
28
+ async function hydrateOne(el: Element): Promise<void> {
29
+ const name = el.getAttribute("data-island")
30
+ if (!name) return
31
+
32
+ const loader = islandRegistry.get(name)
33
+ if (!loader) {
34
+ console.warn(`[gorsee] Island "${name}" not found in registry`)
35
+ return
36
+ }
37
+
38
+ const mod = await loader()
39
+ const component = mod.default
40
+ const rawProps = el.getAttribute("data-props") ?? "{}"
41
+ const props = parseIslandProps(rawProps)
42
+
43
+ enterHydration(el as HTMLElement)
44
+ component(props)
45
+ exitHydration()
46
+
47
+ replayEvents(el as HTMLElement)
48
+ }
49
+
50
+ /**
51
+ * Hydrate all island components on the page.
52
+ * Finds all elements with data-island attribute and hydrates each independently.
53
+ * Lazy islands use IntersectionObserver to defer until visible.
54
+ */
55
+ export function hydrateIslands(): void {
56
+ const islands = document.querySelectorAll("[data-island]")
57
+
58
+ for (let i = 0; i < islands.length; i++) {
59
+ const el = islands[i]!
60
+ const isLazy = el.hasAttribute("data-island-lazy")
61
+
62
+ if (isLazy && typeof IntersectionObserver !== "undefined") {
63
+ observeLazy(el)
64
+ } else {
65
+ void hydrateOne(el)
66
+ }
67
+ }
68
+ }
69
+
70
+ /** Observe a lazy island and hydrate when it enters the viewport. */
71
+ function observeLazy(el: Element): void {
72
+ const observer = new IntersectionObserver(
73
+ (entries) => {
74
+ for (const entry of entries) {
75
+ if (entry.isIntersecting) {
76
+ observer.unobserve(el)
77
+ void hydrateOne(el)
78
+ }
79
+ }
80
+ },
81
+ { rootMargin: "200px" },
82
+ )
83
+ observer.observe(el)
84
+ }
@@ -0,0 +1,88 @@
1
+ // Island component wrapper -- marks components for client-side hydration
2
+ // Usage: export default island(MyComponent) in route files
3
+ // Server: renders component with data-island attribute + serialized props
4
+ // Client: returns wrapped element for hydration
5
+
6
+ import type { VNode } from "./server.ts"
7
+
8
+ export interface IslandOptions {
9
+ lazy?: boolean // load island JS lazily (IntersectionObserver)
10
+ }
11
+
12
+ // Symbol to mark island wrappers for identification
13
+ export const ISLAND_MARKER = Symbol("gorsee-island")
14
+
15
+ interface IslandWrapper<P extends Record<string, unknown>> {
16
+ (props: P): unknown
17
+ [ISLAND_MARKER]: true
18
+ componentName: string
19
+ originalComponent: (props: P) => unknown
20
+ options: IslandOptions
21
+ }
22
+
23
+ /**
24
+ * Escape a JSON string for safe embedding in HTML data attributes.
25
+ * Prevents breaking out of attribute values or injecting HTML.
26
+ */
27
+ function escapePropsForAttr(json: string): string {
28
+ return json
29
+ .replace(/&/g, "&amp;")
30
+ .replace(/"/g, "&quot;")
31
+ .replace(/</g, "&lt;")
32
+ .replace(/>/g, "&gt;")
33
+ }
34
+
35
+ /**
36
+ * Wrap a component as an island -- only this component gets hydrated on the client.
37
+ * Static surrounding content stays as server-rendered HTML with zero JS.
38
+ */
39
+ export function island<P extends Record<string, unknown>>(
40
+ component: (props: P) => unknown,
41
+ options: IslandOptions = {},
42
+ ): IslandWrapper<P> {
43
+ const name = component.name || "Anonymous"
44
+
45
+ const wrapper = function islandWrapper(props: P): unknown {
46
+ const propsWithoutChildren = extractSerializableProps(props)
47
+ const serialized = escapePropsForAttr(JSON.stringify(propsWithoutChildren))
48
+
49
+ const attrs: Record<string, unknown> = {
50
+ "data-island": name,
51
+ "data-props": serialized,
52
+ children: component(props),
53
+ }
54
+
55
+ if (options.lazy) {
56
+ attrs["data-island-lazy"] = "true"
57
+ }
58
+
59
+ // On server: ssrJsx produces VNode; on client: jsx produces DOM node
60
+ // We return a plain object that both renderers understand
61
+ return { type: "div", props: attrs } as VNode
62
+ }
63
+
64
+ wrapper[ISLAND_MARKER] = true as const
65
+ wrapper.componentName = name
66
+ wrapper.originalComponent = component
67
+ wrapper.options = options
68
+
69
+ return wrapper
70
+ }
71
+
72
+ /** Extract only serializable props (skip children, functions, symbols). */
73
+ function extractSerializableProps(
74
+ props: Record<string, unknown>,
75
+ ): Record<string, unknown> {
76
+ const result: Record<string, unknown> = {}
77
+ for (const key in props) {
78
+ if (key === "children") continue
79
+ const val = props[key]
80
+ if (typeof val === "function" || typeof val === "symbol") continue
81
+ result[key] = val
82
+ }
83
+ return result
84
+ }
85
+
86
+ export function isIsland(fn: unknown): fn is IslandWrapper<Record<string, unknown>> {
87
+ return typeof fn === "function" && ISLAND_MARKER in fn
88
+ }
@@ -0,0 +1,167 @@
1
+ // Gorsee.js JSX Runtime
2
+ // Compiles JSX to direct DOM operations with reactive bindings
3
+ // No Virtual DOM -- signals update DOM nodes directly
4
+ // Supports hydration mode: reuses server-rendered DOM nodes
5
+
6
+ import { createEffect } from "../reactive/effect.ts"
7
+ import { isHydrating, claimElement, claimText, pushCursor, popCursor } from "./hydration.ts"
8
+ import { isSignal } from "./html-escape.ts"
9
+
10
+ export type GorseeNode = Node | string | number | boolean | null | undefined | GorseeNode[]
11
+ export type Component = (props: Record<string, unknown>) => GorseeNode
12
+
13
+ export const Fragment = Symbol("Fragment")
14
+
15
+ export interface JSXElement {
16
+ type: string | Component | typeof Fragment
17
+ props: Record<string, unknown>
18
+ children: unknown[]
19
+ }
20
+
21
+ function createTextNode(value: unknown): Text {
22
+ return document.createTextNode(String(value ?? ""))
23
+ }
24
+
25
+ function bindProperty(el: HTMLElement, key: string, value: unknown): void {
26
+ if (key === "children" || key === "ref") return
27
+
28
+ if (key.startsWith("on:")) {
29
+ el.addEventListener(key.slice(3), value as EventListener)
30
+ return
31
+ }
32
+
33
+ if (key === "className" || key === "class") {
34
+ if (isSignal(value)) {
35
+ createEffect(() => { el.className = String(value()) })
36
+ } else if (!isHydrating()) {
37
+ el.className = String(value ?? "")
38
+ }
39
+ return
40
+ }
41
+
42
+ if (key === "style" && typeof value === "object" && value !== null) {
43
+ const styles = value as Record<string, unknown>
44
+ for (const [prop, val] of Object.entries(styles)) {
45
+ if (isSignal(val)) {
46
+ const p = prop
47
+ createEffect(() => { el.style.setProperty(p, String(val())) })
48
+ } else if (!isHydrating()) {
49
+ el.style.setProperty(prop, String(val))
50
+ }
51
+ }
52
+ return
53
+ }
54
+
55
+ if (isSignal(value)) {
56
+ createEffect(() => { el.setAttribute(key, String(value())) })
57
+ } else if (!isHydrating()) {
58
+ if (typeof value === "boolean") {
59
+ if (value) el.setAttribute(key, "")
60
+ else el.removeAttribute(key)
61
+ } else if (value != null) {
62
+ el.setAttribute(key, String(value))
63
+ }
64
+ }
65
+ }
66
+
67
+ function hydrateChild(parent: Node, child: unknown): void {
68
+ if (child == null || typeof child === "boolean") return
69
+
70
+ if (Array.isArray(child)) {
71
+ for (const c of child) hydrateChild(parent, c)
72
+ return
73
+ }
74
+
75
+ if (typeof child === "function" && isSignal(child)) {
76
+ const textNode = claimText() ?? createTextNode(child())
77
+ createEffect(() => { textNode.textContent = String(child() ?? "") })
78
+ return
79
+ }
80
+
81
+ if (typeof child === "object" && child instanceof Node) return
82
+
83
+ // Static text -- just advance cursor
84
+ claimText()
85
+ }
86
+
87
+ function insertChild(parent: Node, child: unknown): void {
88
+ if (child == null || typeof child === "boolean") return
89
+
90
+ if (Array.isArray(child)) {
91
+ for (const c of child) insertChild(parent, c)
92
+ return
93
+ }
94
+
95
+ if (child instanceof Node) {
96
+ parent.appendChild(child)
97
+ return
98
+ }
99
+
100
+ if (isSignal(child)) {
101
+ const textNode = createTextNode(child())
102
+ parent.appendChild(textNode)
103
+ createEffect(() => { textNode.textContent = String(child() ?? "") })
104
+ return
105
+ }
106
+
107
+ parent.appendChild(createTextNode(child))
108
+ }
109
+
110
+ export function jsx(
111
+ type: string | Component | typeof Fragment,
112
+ props: Record<string, unknown> | null
113
+ ): Node | DocumentFragment {
114
+ const allProps = props ?? {}
115
+ const children = allProps.children
116
+ const hydrating = isHydrating()
117
+
118
+ // Fragment
119
+ if (type === Fragment) {
120
+ if (hydrating) {
121
+ if (children != null) hydrateChild(document.createDocumentFragment(), children)
122
+ return document.createDocumentFragment()
123
+ }
124
+ const frag = document.createDocumentFragment()
125
+ if (children != null) insertChild(frag, children)
126
+ return frag
127
+ }
128
+
129
+ // Component
130
+ if (typeof type === "function") {
131
+ const result = type(allProps)
132
+ if (result instanceof Node) return result
133
+ if (hydrating) return document.createDocumentFragment()
134
+ const wrapper = document.createDocumentFragment()
135
+ insertChild(wrapper, result)
136
+ return wrapper
137
+ }
138
+
139
+ // HTML element
140
+ if (hydrating) {
141
+ const el = claimElement() as HTMLElement
142
+ if (el) {
143
+ // Attach event listeners and reactive bindings to existing element
144
+ for (const [key, value] of Object.entries(allProps)) {
145
+ if (key !== "children") bindProperty(el, key, value)
146
+ }
147
+ // Hydrate children
148
+ if (children != null) {
149
+ pushCursor(el)
150
+ hydrateChild(el, children)
151
+ popCursor()
152
+ }
153
+ return el
154
+ }
155
+ // Fallback: element missing from server HTML, create it
156
+ }
157
+
158
+ const el = document.createElement(type)
159
+ for (const [key, value] of Object.entries(allProps)) {
160
+ if (key !== "children") bindProperty(el, key, value)
161
+ }
162
+ if (children != null) insertChild(el, children)
163
+ return el
164
+ }
165
+
166
+ export const jsxs = jsx
167
+ export const jsxDEV = jsx
@@ -0,0 +1,45 @@
1
+ // <Link> component -- SPA navigation link with prefetch
2
+ // Server: renders <a> tag
3
+ // Client: renders <a> tag with router integration
4
+
5
+ import { navigate, prefetch } from "./router.ts"
6
+
7
+ export interface LinkProps {
8
+ href: string
9
+ children?: unknown
10
+ class?: string
11
+ className?: string
12
+ prefetch?: boolean | "hover" | "viewport"
13
+ replace?: boolean
14
+ target?: string
15
+ [key: string]: unknown
16
+ }
17
+
18
+ // Client-side Link component
19
+ export function Link(props: LinkProps): unknown {
20
+ const { href, prefetch: prefetchMode, replace, children, ...rest } = props
21
+
22
+ // Eagerly prefetch if requested
23
+ if (prefetchMode === true && typeof window !== "undefined") {
24
+ prefetch(href)
25
+ }
26
+
27
+ return {
28
+ type: "a",
29
+ props: {
30
+ href,
31
+ "on:click": (e: MouseEvent) => {
32
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return
33
+ const target = (e.currentTarget as HTMLAnchorElement)?.target
34
+ if (target && target !== "_self") return
35
+ e.preventDefault()
36
+ if (replace) {
37
+ history.replaceState({ gorsee: true }, "", href)
38
+ }
39
+ navigate(href, !replace)
40
+ },
41
+ ...rest,
42
+ children,
43
+ },
44
+ }
45
+ }