@barefootjs/hono 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/dist/adapter/hono-adapter.d.ts +141 -0
- package/dist/adapter/hono-adapter.d.ts.map +1 -0
- package/dist/adapter/index.d.ts +6 -0
- package/dist/adapter/index.d.ts.map +1 -0
- package/dist/adapter/index.js +632 -0
- package/dist/app.d.ts +131 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +139 -0
- package/dist/async.d.ts +15 -0
- package/dist/async.d.ts.map +1 -0
- package/dist/async.js +12 -0
- package/dist/build.d.ts +65 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +785 -0
- package/dist/client-shim.d.ts +59 -0
- package/dist/client-shim.d.ts.map +1 -0
- package/dist/client-shim.js +90 -0
- package/dist/dev-worker.d.ts +25 -0
- package/dist/dev-worker.d.ts.map +1 -0
- package/dist/dev-worker.js +65 -0
- package/dist/dev.d.ts +36 -0
- package/dist/dev.d.ts.map +1 -0
- package/dist/dev.js +418 -0
- package/dist/dialog-context.d.ts +13 -0
- package/dist/dialog-context.d.ts.map +1 -0
- package/dist/dialog-context.js +10 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +632 -0
- package/dist/jsx/jsx-dev-runtime/index.d.ts +9 -0
- package/dist/jsx/jsx-dev-runtime/index.d.ts.map +1 -0
- package/dist/jsx/jsx-dev-runtime/index.js +6 -0
- package/dist/jsx/jsx-runtime/index.d.ts +32 -0
- package/dist/jsx/jsx-runtime/index.d.ts.map +1 -0
- package/dist/jsx/jsx-runtime/index.js +10 -0
- package/dist/portal-ssr.d.ts +22 -0
- package/dist/portal-ssr.d.ts.map +1 -0
- package/dist/portal-ssr.js +73 -0
- package/dist/portals.d.ts +26 -0
- package/dist/portals.d.ts.map +1 -0
- package/dist/portals.js +41 -0
- package/dist/preload.d.ts +56 -0
- package/dist/preload.d.ts.map +1 -0
- package/dist/preload.js +51 -0
- package/dist/scripts.d.ts +80 -0
- package/dist/scripts.d.ts.map +1 -0
- package/dist/scripts.js +198 -0
- package/dist/test-render.d.ts +28 -0
- package/dist/test-render.d.ts.map +1 -0
- package/dist/utils.d.ts +16 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +16 -0
- package/package.json +116 -0
- package/src/__tests__/async.test.tsx +106 -0
- package/src/__tests__/bfscripts-entry-roots.test.tsx +135 -0
- package/src/__tests__/build.test.ts +299 -0
- package/src/__tests__/dev.test.tsx +123 -0
- package/src/__tests__/hydration-props-type.test.ts +141 -0
- package/src/__tests__/manifest-scripts.test.ts +87 -0
- package/src/__tests__/scaffold.test.ts +209 -0
- package/src/__tests__/ssr-context-bridge.test.ts +110 -0
- package/src/__tests__/string-literal-css-var-prop.test.ts +84 -0
- package/src/__tests__/stub-deps-scripts.test.ts +183 -0
- package/src/adapter/hono-adapter.ts +1114 -0
- package/src/adapter/index.ts +6 -0
- package/src/app.ts +220 -0
- package/src/async.tsx +55 -0
- package/src/build.ts +230 -0
- package/src/client-shim.ts +164 -0
- package/src/dev-worker.ts +93 -0
- package/src/dev.tsx +146 -0
- package/src/dialog-context.tsx +44 -0
- package/src/index.ts +26 -0
- package/src/jsx/jsx-dev-runtime/index.ts +9 -0
- package/src/jsx/jsx-runtime/index.ts +40 -0
- package/src/portal-ssr.tsx +92 -0
- package/src/portals.tsx +98 -0
- package/src/preload.tsx +166 -0
- package/src/scripts.tsx +220 -0
- package/src/test-render.ts +143 -0
- package/src/utils.ts +26 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BarefootJS Dev Reloader for Cloudflare Workers / other edge runtimes.
|
|
3
|
+
*
|
|
4
|
+
* Unlike `./dev`'s `createDevReloader`, this variant does not watch the local
|
|
5
|
+
* filesystem — it generates a fresh boot ID on every cold start and relies on
|
|
6
|
+
* the standard SSE `Last-Event-ID` reconnection protocol to detect restarts:
|
|
7
|
+
*
|
|
8
|
+
* 1. First connect → send `event: hello`, `id: <BOOT_ID>`.
|
|
9
|
+
* 2. Worker restart → SSE stream drops, client reconnects with
|
|
10
|
+
* `Last-Event-ID: <old BOOT_ID>`.
|
|
11
|
+
* 3. Server sees mismatch → send `event: reload`, client refreshes.
|
|
12
|
+
*
|
|
13
|
+
* Pair with `BfDevReload` from `@barefootjs/hono/dev-reload`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Context } from 'hono'
|
|
17
|
+
|
|
18
|
+
export interface CreateDevReloaderOptions {
|
|
19
|
+
/** Override the dev gate. Defaults to `process.env.NODE_ENV !== 'production'`. */
|
|
20
|
+
enabled?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const HEARTBEAT_MS = 5000
|
|
24
|
+
|
|
25
|
+
// Generated once per Worker isolate. A cold start or code change rotates this,
|
|
26
|
+
// which is exactly the signal we want to surface to the browser.
|
|
27
|
+
const BOOT_ID = generateBootId()
|
|
28
|
+
|
|
29
|
+
function generateBootId(): string {
|
|
30
|
+
try {
|
|
31
|
+
return crypto.randomUUID()
|
|
32
|
+
} catch {
|
|
33
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 10)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isDevDefault(): boolean {
|
|
38
|
+
return process.env.NODE_ENV !== 'production'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Hono route handler that streams Server-Sent Events. Returns 404 in
|
|
43
|
+
* production (`NODE_ENV=production`) unless `enabled` is set explicitly.
|
|
44
|
+
*/
|
|
45
|
+
export function createDevReloader(
|
|
46
|
+
options: CreateDevReloaderOptions = {},
|
|
47
|
+
): (c: Context) => Response | Promise<Response> {
|
|
48
|
+
const { enabled = isDevDefault() } = options
|
|
49
|
+
|
|
50
|
+
return (c: Context) => {
|
|
51
|
+
if (!enabled) return c.notFound()
|
|
52
|
+
|
|
53
|
+
const lastEventId = (c.req.header('Last-Event-ID') ?? '').trim()
|
|
54
|
+
const signal = c.req.raw.signal
|
|
55
|
+
|
|
56
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
57
|
+
start(controller) {
|
|
58
|
+
const encoder = new TextEncoder()
|
|
59
|
+
const send = (chunk: string) => {
|
|
60
|
+
try {
|
|
61
|
+
controller.enqueue(encoder.encode(chunk))
|
|
62
|
+
} catch {
|
|
63
|
+
// Stream already closed (client disconnected).
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
send(`retry: 1000\n\n`)
|
|
68
|
+
// On reconnect with a different boot id, the Worker restarted (or was
|
|
69
|
+
// evicted from the isolate cache) while the client was disconnected —
|
|
70
|
+
// signal a reload so the browser picks up any new code + assets.
|
|
71
|
+
const event = lastEventId && lastEventId !== BOOT_ID ? 'reload' : 'hello'
|
|
72
|
+
send(`event: ${event}\nid: ${BOOT_ID}\ndata: ${BOOT_ID}\n\n`)
|
|
73
|
+
|
|
74
|
+
const heartbeat = setInterval(() => send(`: hb\n\n`), HEARTBEAT_MS)
|
|
75
|
+
const onAbort = () => {
|
|
76
|
+
clearInterval(heartbeat)
|
|
77
|
+
try { controller.close() } catch { /* already closed */ }
|
|
78
|
+
}
|
|
79
|
+
if (signal.aborted) onAbort()
|
|
80
|
+
else signal.addEventListener('abort', onAbort, { once: true })
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
return new Response(stream, {
|
|
85
|
+
headers: {
|
|
86
|
+
'Content-Type': 'text/event-stream',
|
|
87
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
88
|
+
'Connection': 'keep-alive',
|
|
89
|
+
'X-Accel-Buffering': 'no',
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
}
|
package/src/dev.tsx
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BarefootJS Dev Reloader (Hono, Node-side)
|
|
3
|
+
*
|
|
4
|
+
* `createDevReloader` turns `bf build --watch`'s sentinel file
|
|
5
|
+
* (`<distDir>/.dev/build-id`) into an SSE stream:
|
|
6
|
+
*
|
|
7
|
+
* [bf build --watch] → writes `<distDir>/.dev/build-id` after each successful build
|
|
8
|
+
* [createDevReloader] → watches that file, streams SSE `event: reload`
|
|
9
|
+
*
|
|
10
|
+
* Mount it on a Hono route in the generated app; the matching browser-
|
|
11
|
+
* side subscriber (`<DevReload />`) lives in the project itself (see
|
|
12
|
+
* the hono-node scaffold's `dev-reload.tsx`) so its endpoint URL and
|
|
13
|
+
* reconnect behavior are an in-tree edit.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* // factory.ts
|
|
17
|
+
* import { createDevReloader } from '@barefootjs/hono/dev'
|
|
18
|
+
* app.get('/_bf/reload', createDevReloader({ distDir: './dist' }))
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* Disabled (404) when `NODE_ENV === 'production'` unless `enabled: true`
|
|
22
|
+
* is passed explicitly.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { Context } from 'hono'
|
|
26
|
+
import { mkdir, readFile, watch } from 'node:fs/promises'
|
|
27
|
+
import { resolve } from 'node:path'
|
|
28
|
+
|
|
29
|
+
export interface CreateDevReloaderOptions {
|
|
30
|
+
/** Directory that `bf build` writes output into (contains `.dev/build-id`). */
|
|
31
|
+
distDir: string
|
|
32
|
+
/** Override the dev gate. Defaults to `process.env.NODE_ENV !== 'production'`. */
|
|
33
|
+
enabled?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Sentinel path contract with `@barefootjs/cli`. These values must match
|
|
37
|
+
// `DEV_SENTINEL_SUBDIR` / `DEV_SENTINEL_FILENAME` in `packages/cli/src/lib/build.ts`
|
|
38
|
+
// — duplicated intentionally to avoid a runtime dep on the CLI.
|
|
39
|
+
const DEV_SUBDIR = '.dev'
|
|
40
|
+
const BUILD_ID_FILE = 'build-id'
|
|
41
|
+
/**
|
|
42
|
+
* Heartbeat interval for idle keepalive. Must stay comfortably under Bun's
|
|
43
|
+
* default 10s idleTimeout — otherwise the server would close a quiet SSE
|
|
44
|
+
* stream and the browser would EventSource-reconnect every cycle, which can
|
|
45
|
+
* lose a rebuild event emitted in the gap between close and reconnect.
|
|
46
|
+
*/
|
|
47
|
+
const HEARTBEAT_MS = 5000
|
|
48
|
+
|
|
49
|
+
function isDevDefault(): boolean {
|
|
50
|
+
return process.env.NODE_ENV !== 'production'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Hono route handler that streams Server-Sent Events and emits `reload` every
|
|
55
|
+
* time `<distDir>/.dev/build-id` is written. Disabled (404) in production.
|
|
56
|
+
*/
|
|
57
|
+
export function createDevReloader(
|
|
58
|
+
options: CreateDevReloaderOptions,
|
|
59
|
+
): (c: Context) => Response | Promise<Response> {
|
|
60
|
+
const { distDir, enabled = isDevDefault() } = options
|
|
61
|
+
|
|
62
|
+
return async (c: Context) => {
|
|
63
|
+
if (!enabled) return c.notFound()
|
|
64
|
+
|
|
65
|
+
const devDir = resolve(distDir, DEV_SUBDIR)
|
|
66
|
+
// Ensure the directory exists so fs.watch doesn't ENOENT before the first build.
|
|
67
|
+
await mkdir(devDir, { recursive: true })
|
|
68
|
+
|
|
69
|
+
const buildIdPath = resolve(devDir, BUILD_ID_FILE)
|
|
70
|
+
const signal = c.req.raw.signal
|
|
71
|
+
// If the client reconnects with Last-Event-ID (the build-id it last saw)
|
|
72
|
+
// and the current build-id is newer, a rebuild happened while it was
|
|
73
|
+
// disconnected — recover by firing `reload` immediately instead of `hello`.
|
|
74
|
+
const lastEventId = (c.req.header('Last-Event-ID') ?? '').trim()
|
|
75
|
+
|
|
76
|
+
const readBuildId = async (): Promise<string> => {
|
|
77
|
+
try {
|
|
78
|
+
return (await readFile(buildIdPath, 'utf8')).trim()
|
|
79
|
+
} catch {
|
|
80
|
+
return ''
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
85
|
+
async start(controller) {
|
|
86
|
+
const encoder = new TextEncoder()
|
|
87
|
+
const send = (chunk: string) => {
|
|
88
|
+
try {
|
|
89
|
+
controller.enqueue(encoder.encode(chunk))
|
|
90
|
+
} catch {
|
|
91
|
+
// Stream already closed (client disconnected).
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
send(`retry: 1000\n\n`)
|
|
96
|
+
let lastSentId = ''
|
|
97
|
+
const initialId = await readBuildId()
|
|
98
|
+
if (initialId) {
|
|
99
|
+
lastSentId = initialId
|
|
100
|
+
const event = lastEventId && lastEventId !== initialId ? 'reload' : 'hello'
|
|
101
|
+
send(`event: ${event}\nid: ${initialId}\ndata: ${initialId}\n\n`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Heartbeat keeps the connection under Bun's idleTimeout so that a
|
|
105
|
+
// silent period between builds doesn't close the socket (which would
|
|
106
|
+
// otherwise race with in-flight rebuilds and drop `reload` events).
|
|
107
|
+
const heartbeat = setInterval(() => send(`: hb\n\n`), HEARTBEAT_MS)
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
// Watch the parent directory: the build-id file may not exist yet,
|
|
111
|
+
// and `fs.watch` on a missing path throws.
|
|
112
|
+
const iter = watch(devDir, { signal })
|
|
113
|
+
for await (const event of iter) {
|
|
114
|
+
if (event.filename !== BUILD_ID_FILE) continue
|
|
115
|
+
const id = await readBuildId()
|
|
116
|
+
if (!id || id === lastSentId) continue
|
|
117
|
+
lastSentId = id
|
|
118
|
+
send(`event: reload\nid: ${id}\ndata: ${id}\n\n`)
|
|
119
|
+
}
|
|
120
|
+
} catch (err) {
|
|
121
|
+
const name = (err as { name?: string } | undefined)?.name
|
|
122
|
+
if (name !== 'AbortError') {
|
|
123
|
+
const message = (err as Error).message ?? 'watch error'
|
|
124
|
+
send(`event: error\ndata: ${JSON.stringify(message)}\n\n`)
|
|
125
|
+
}
|
|
126
|
+
} finally {
|
|
127
|
+
clearInterval(heartbeat)
|
|
128
|
+
try { controller.close() } catch { /* already closed */ }
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
cancel() {
|
|
132
|
+
// Client disconnected; fs.watch will unwind via `signal`.
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
return new Response(stream, {
|
|
137
|
+
headers: {
|
|
138
|
+
'Content-Type': 'text/event-stream',
|
|
139
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
140
|
+
'Connection': 'keep-alive',
|
|
141
|
+
'X-Accel-Buffering': 'no',
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// @jsxRuntime automatic
|
|
2
|
+
// @jsxImportSource hono/jsx
|
|
3
|
+
//
|
|
4
|
+
// Pragmas as line-comments above the JSDoc; see scripts.tsx for the rationale.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Dialog Context
|
|
8
|
+
*
|
|
9
|
+
* Provides scopeId sharing between Dialog components.
|
|
10
|
+
* DialogRoot sets the context, child components (DialogOverlay, DialogContent) read it.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* ```tsx
|
|
14
|
+
* import { DialogContext, useDialogContext } from '@barefootjs/hono'
|
|
15
|
+
*
|
|
16
|
+
* // In DialogRoot
|
|
17
|
+
* <DialogContext.Provider value={{ scopeId }}>
|
|
18
|
+
* {children}
|
|
19
|
+
* </DialogContext.Provider>
|
|
20
|
+
*
|
|
21
|
+
* // In DialogOverlay/DialogContent
|
|
22
|
+
* const ctx = useDialogContext()
|
|
23
|
+
* <Portal scopeId={ctx?.scopeId}>...</Portal>
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { createContext, useContext } from 'hono/jsx'
|
|
28
|
+
|
|
29
|
+
export type DialogContextValue = {
|
|
30
|
+
scopeId: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Context for sharing scopeId between Dialog components.
|
|
35
|
+
*/
|
|
36
|
+
export const DialogContext = createContext<DialogContextValue | null>(null)
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Hook to access Dialog context.
|
|
40
|
+
* Returns null if not inside a DialogRoot.
|
|
41
|
+
*/
|
|
42
|
+
export function useDialogContext(): DialogContextValue | null {
|
|
43
|
+
return useContext(DialogContext)
|
|
44
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BarefootJS Hono Integration
|
|
3
|
+
*
|
|
4
|
+
* Provides Hono-specific adapters and utilities for BarefootJS.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Hono Adapter for JSX compilation
|
|
8
|
+
export { HonoAdapter, honoAdapter } from './adapter'
|
|
9
|
+
export type { HonoAdapterOptions } from './adapter'
|
|
10
|
+
|
|
11
|
+
// BfScripts is exported from a separate entry point to avoid JSX runtime issues in tests
|
|
12
|
+
// Usage: import { BfScripts } from '@barefootjs/hono/scripts'
|
|
13
|
+
export type { CollectedScript } from './scripts'
|
|
14
|
+
|
|
15
|
+
// Portal components for SSR
|
|
16
|
+
// Usage: import { BfPortals, Portal } from '@barefootjs/hono/portals'
|
|
17
|
+
export type { CollectedPortal } from './portals'
|
|
18
|
+
export type { PortalProps } from './portal-ssr'
|
|
19
|
+
|
|
20
|
+
// Async streaming boundary
|
|
21
|
+
// Usage: import { BfAsync } from '@barefootjs/hono/async'
|
|
22
|
+
export type { BfAsyncProps } from './async'
|
|
23
|
+
|
|
24
|
+
// Dialog context for scopeId sharing
|
|
25
|
+
// Usage: import { DialogContext, useDialogContext } from '@barefootjs/hono/dialog-context'
|
|
26
|
+
export type { DialogContextValue } from './dialog-context'
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BarefootJS Hono JSX Extension - Development Runtime
|
|
3
|
+
*
|
|
4
|
+
* Re-exports `jsxDEV` / `Fragment` from hono/jsx and surfaces the same
|
|
5
|
+
* JSX namespace as the production runtime so dev builds see identical types.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { jsxDEV, Fragment } from 'hono/jsx/jsx-dev-runtime'
|
|
9
|
+
export type { JSX } from '../jsx-runtime'
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BarefootJS Hono JSX Extension
|
|
3
|
+
*
|
|
4
|
+
* Combines hono/jsx runtime with @barefootjs/jsx type definitions.
|
|
5
|
+
* - Runtime functions from hono/jsx
|
|
6
|
+
* - Typed event handlers and IntrinsicElements from @barefootjs/jsx
|
|
7
|
+
* - JSX.Element from hono/jsx (for Suspense/streaming support)
|
|
8
|
+
*
|
|
9
|
+
* Usage in tsconfig.json:
|
|
10
|
+
* "jsxImportSource": "@barefootjs/hono/jsx"
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// Runtime functions from hono/jsx.
|
|
14
|
+
export { jsx, jsxs, Fragment, jsxAttr, jsxEscape, jsxTemplate } from 'hono/jsx/jsx-runtime'
|
|
15
|
+
|
|
16
|
+
// Re-export JSX namespace from @barefootjs/jsx, but override Element type for Hono.
|
|
17
|
+
import type { JSX as BaseJSX } from '@barefootjs/jsx/jsx-runtime'
|
|
18
|
+
|
|
19
|
+
export declare namespace JSX {
|
|
20
|
+
// Use Hono's Element type for Suspense/streaming compatibility.
|
|
21
|
+
type Element = import('hono/jsx/jsx-runtime').JSX.Element
|
|
22
|
+
|
|
23
|
+
// Re-use types from @barefootjs/jsx.
|
|
24
|
+
type IntrinsicElements = BaseJSX.IntrinsicElements
|
|
25
|
+
type IntrinsicAttributes = BaseJSX.IntrinsicAttributes
|
|
26
|
+
type ElementChildrenAttribute = BaseJSX.ElementChildrenAttribute
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* BarefootJS compiler built-in: streaming async boundary.
|
|
31
|
+
*
|
|
32
|
+
* The compiler intercepts `<Async fallback={...}>` in JSX source and emits it
|
|
33
|
+
* as a `<Suspense>` node in the Hono adapter output (IRAsync → renderAsync).
|
|
34
|
+
* This declaration provides TypeScript types for source files; no runtime
|
|
35
|
+
* implementation is needed because the compiler replaces it before execution.
|
|
36
|
+
*/
|
|
37
|
+
export declare function Async(props: {
|
|
38
|
+
fallback: JSX.Element
|
|
39
|
+
children: JSX.Element | JSX.Element[] | null | undefined
|
|
40
|
+
}): JSX.Element
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// @jsxRuntime automatic
|
|
2
|
+
// @jsxImportSource hono/jsx
|
|
3
|
+
//
|
|
4
|
+
// Pragmas as line-comments above the JSDoc; see scripts.tsx for the rationale.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Portal Component for SSR
|
|
8
|
+
*
|
|
9
|
+
* Renders children to document.body during SSR via BfPortals collection.
|
|
10
|
+
* On client-side, renders children normally (to be moved by createPortal).
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* ```tsx
|
|
14
|
+
* import { Portal } from '@barefootjs/hono'
|
|
15
|
+
*
|
|
16
|
+
* function MyDialog({ scopeId }: { scopeId: string }) {
|
|
17
|
+
* return (
|
|
18
|
+
* <Portal scopeId={scopeId}>
|
|
19
|
+
* <div class="dialog">Dialog content</div>
|
|
20
|
+
* </Portal>
|
|
21
|
+
* )
|
|
22
|
+
* }
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { useRequestContext } from 'hono/jsx-renderer'
|
|
27
|
+
import { Fragment } from 'hono/jsx'
|
|
28
|
+
import type { Child } from 'hono/jsx'
|
|
29
|
+
import { collectPortal, isPortalsRendered } from './portals'
|
|
30
|
+
|
|
31
|
+
let portalCounter = 0
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Generate unique portal ID for SSR/hydration matching.
|
|
35
|
+
* Counter resets per request in production (new context per request).
|
|
36
|
+
*/
|
|
37
|
+
function generatePortalId(): string {
|
|
38
|
+
return `bf-portal-${++portalCounter}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Reset portal counter (for testing or explicit reset).
|
|
43
|
+
*/
|
|
44
|
+
export function resetPortalCounter(): void {
|
|
45
|
+
portalCounter = 0
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PortalProps {
|
|
49
|
+
children: Child
|
|
50
|
+
scopeId?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Portal component that moves children to document.body during SSR.
|
|
55
|
+
*
|
|
56
|
+
* During SSR:
|
|
57
|
+
* - Collects content for BfPortals output (before BfPortals renders)
|
|
58
|
+
* - Returns placeholder <template> for hydration matching
|
|
59
|
+
* - If BfPortals already rendered (Suspense), outputs inline
|
|
60
|
+
*
|
|
61
|
+
* On client:
|
|
62
|
+
* - Renders children normally (createPortal moves them later)
|
|
63
|
+
*/
|
|
64
|
+
export function Portal(props: PortalProps) {
|
|
65
|
+
const portalId = generatePortalId()
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
// Check if we're in SSR context (will throw if not)
|
|
69
|
+
useRequestContext()
|
|
70
|
+
|
|
71
|
+
if (isPortalsRendered()) {
|
|
72
|
+
// BfPortals already rendered (e.g., inside Suspense boundary)
|
|
73
|
+
// Output portal content inline
|
|
74
|
+
return (
|
|
75
|
+
<div bf-pi={portalId} bf-po={props.scopeId || ''}>
|
|
76
|
+
{props.children}
|
|
77
|
+
</div>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Collect portal content for BfPortals output
|
|
82
|
+
collectPortal(portalId, props.scopeId || '', props.children)
|
|
83
|
+
|
|
84
|
+
// Return placeholder for hydration matching
|
|
85
|
+
// Client will find this and know the portal content is at body end
|
|
86
|
+
return <template bf-pp={portalId} />
|
|
87
|
+
} catch {
|
|
88
|
+
// Outside request context (client-side rendering)
|
|
89
|
+
// Render children normally - they will be moved by createPortal on mount
|
|
90
|
+
return <Fragment>{props.children}</Fragment>
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/portals.tsx
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// @jsxRuntime automatic
|
|
2
|
+
// @jsxImportSource hono/jsx
|
|
3
|
+
//
|
|
4
|
+
// Pragmas as line-comments above the JSDoc; see scripts.tsx for the rationale.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* BfPortals Component
|
|
8
|
+
*
|
|
9
|
+
* Renders collected portal content at the end of the document body.
|
|
10
|
+
* BarefootJS Portal components collect their content during SSR render,
|
|
11
|
+
* and this component outputs them all at once to ensure correct positioning.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* ```tsx
|
|
15
|
+
* import { BfPortals } from '@barefootjs/hono'
|
|
16
|
+
*
|
|
17
|
+
* <html>
|
|
18
|
+
* <body>
|
|
19
|
+
* {children}
|
|
20
|
+
* <BfPortals />
|
|
21
|
+
* <BfScripts />
|
|
22
|
+
* </body>
|
|
23
|
+
* </html>
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { useRequestContext } from 'hono/jsx-renderer'
|
|
28
|
+
import { Fragment } from 'hono/jsx'
|
|
29
|
+
import type { Child } from 'hono/jsx'
|
|
30
|
+
|
|
31
|
+
export type CollectedPortal = {
|
|
32
|
+
id: string
|
|
33
|
+
scopeId: string
|
|
34
|
+
content: Child
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Collect portal content for SSR output.
|
|
39
|
+
* Called by Portal component during SSR rendering.
|
|
40
|
+
*/
|
|
41
|
+
export function collectPortal(id: string, scopeId: string, content: Child): void {
|
|
42
|
+
try {
|
|
43
|
+
const c = useRequestContext()
|
|
44
|
+
const portals: CollectedPortal[] = c.get('bfCollectedPortals') || []
|
|
45
|
+
portals.push({ id, scopeId, content })
|
|
46
|
+
c.set('bfCollectedPortals', portals)
|
|
47
|
+
} catch {
|
|
48
|
+
// Outside request context (client-side) - no-op
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Check if BfPortals has already been rendered.
|
|
54
|
+
* Used by Portal component to determine if it should output inline.
|
|
55
|
+
*/
|
|
56
|
+
export function isPortalsRendered(): boolean {
|
|
57
|
+
try {
|
|
58
|
+
const c = useRequestContext()
|
|
59
|
+
return c.get('bfPortalsRendered') ?? false
|
|
60
|
+
} catch {
|
|
61
|
+
// Outside request context (client-side)
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Renders all collected portal content.
|
|
68
|
+
* Place this component at the end of your <body> element, before BfScripts.
|
|
69
|
+
*
|
|
70
|
+
* After rendering, sets 'bfPortalsRendered' flag to true.
|
|
71
|
+
* Portal components rendered after BfPortals (e.g., inside Suspense boundaries)
|
|
72
|
+
* will check this flag and output their content inline instead.
|
|
73
|
+
*/
|
|
74
|
+
export function BfPortals() {
|
|
75
|
+
try {
|
|
76
|
+
const c = useRequestContext()
|
|
77
|
+
|
|
78
|
+
// Mark that BfPortals has been rendered.
|
|
79
|
+
// Portal components rendered after this point (e.g., inside Suspense)
|
|
80
|
+
// should output their content inline.
|
|
81
|
+
c.set('bfPortalsRendered', true)
|
|
82
|
+
|
|
83
|
+
const portals: CollectedPortal[] = c.get('bfCollectedPortals') || []
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Fragment>
|
|
87
|
+
{portals.map(({ id, scopeId, content }) => (
|
|
88
|
+
<div key={id} bf-pi={id} bf-po={scopeId}>
|
|
89
|
+
{content}
|
|
90
|
+
</div>
|
|
91
|
+
))}
|
|
92
|
+
</Fragment>
|
|
93
|
+
)
|
|
94
|
+
} catch {
|
|
95
|
+
// Context unavailable (e.g., not using jsxRenderer)
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
}
|