@barefootjs/hono 0.30.6 → 0.31.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/src/build.ts DELETED
@@ -1,230 +0,0 @@
1
- // Hono build config factory for barefoot.config.ts
2
-
3
- import type { BuildOptions } from '@barefootjs/jsx'
4
- import { HonoAdapter } from './adapter/index.ts'
5
- import type { HonoAdapterOptions } from './adapter/index.ts'
6
-
7
- export interface HonoBuildOptions extends BuildOptions {
8
- /** Inject Hono script collection wrapper (default: true) */
9
- scriptCollection?: boolean
10
- /** Base path for client JS script URLs (default: '/static/components/') */
11
- scriptBasePath?: string
12
- /** Adapter-specific options passed to HonoAdapter */
13
- adapterOptions?: HonoAdapterOptions
14
- }
15
-
16
- /**
17
- * Create a BarefootBuildConfig for Hono projects.
18
- *
19
- * Uses structural typing — does not import BarefootBuildConfig to avoid
20
- * circular dependency between @barefootjs/hono and @barefootjs/cli.
21
- */
22
- export function createConfig(options: HonoBuildOptions = {}) {
23
- const useScriptCollection = options.scriptCollection ?? true
24
-
25
- return {
26
- adapter: new HonoAdapter(options.adapterOptions),
27
- paths: options.paths,
28
- components: options.components,
29
- outDir: options.outDir,
30
- minify: options.minify,
31
- contentHash: options.contentHash,
32
- externals: options.externals,
33
- externalsBasePath: options.externalsBasePath,
34
- bundleEntries: options.bundleEntries,
35
- localImportPrefixes: options.localImportPrefixes,
36
- transformMarkedTemplate: useScriptCollection
37
- ? (content: string, componentId: string, clientJsPath: string) =>
38
- addScriptCollection(content, componentId, clientJsPath, options.scriptBasePath)
39
- : undefined,
40
- }
41
- }
42
-
43
- /**
44
- * Add Hono script collection wrapper to an SSR marked template.
45
- * Injects imports, a helper function, and script collector into each
46
- * exported component function.
47
- */
48
- export function addScriptCollection(content: string, componentId: string, clientJsPath: string, scriptBasePath: string = '/static/components/'): string {
49
- const basePath = scriptBasePath.endsWith('/') ? scriptBasePath : scriptBasePath + '/'
50
- const importStatement = "import { useRequestContext } from 'hono/jsx-renderer'\nimport { Fragment } from 'hono/jsx'\n"
51
-
52
- // Find the last import statement and add our import after it
53
- const importMatch = content.match(/^([\s\S]*?)((?:import[^\n]+\n)*)/m)
54
- if (!importMatch) {
55
- return content
56
- }
57
-
58
- const beforeImports = importMatch[1]
59
- const existingImports = importMatch[2]
60
- const restOfFile = content.slice(importMatch[0].length)
61
-
62
- // Helper function to wrap JSX with inline script tags (for Suspense streaming)
63
- const helperFn = `
64
- function __bfWrap(jsx: any, scripts: string[]) {
65
- if (scripts.length === 0) return jsx
66
- return <Fragment>{jsx}{scripts.map(s => <script type="module" src={s} />)}</Fragment>
67
- }
68
- `
69
-
70
- // Script collection code to insert at the start of each component function.
71
- // When BfScripts has already rendered (e.g., inside Suspense boundaries),
72
- // scripts are output inline instead of being collected.
73
- const scriptCollector = `
74
- let __bfInlineScripts: string[] = []
75
- // Script collection for client JS hydration
76
- try {
77
- const __c = useRequestContext()
78
- const __scripts: { src: string }[] = __c.get('bfCollectedScripts') || []
79
- const __outputScripts: Set<string> = __c.get('bfOutputScripts') || new Set()
80
- const __bfRendered = __c.get('bfScriptsRendered')
81
- if (!__outputScripts.has('__barefoot__')) {
82
- __outputScripts.add('__barefoot__')
83
- if (__bfRendered) __bfInlineScripts.push('${basePath}barefoot.js')
84
- else __scripts.push({ src: '${basePath}barefoot.js' })
85
- }
86
- if (!__outputScripts.has('${componentId}')) {
87
- __outputScripts.add('${componentId}')
88
- if (__bfRendered) __bfInlineScripts.push('${basePath}${clientJsPath}')
89
- else __scripts.push({ src: '${basePath}${clientJsPath}' })
90
- }
91
- __c.set('bfCollectedScripts', __scripts)
92
- __c.set('bfOutputScripts', __outputScripts)
93
- } catch {}
94
- `
95
-
96
- // Insert script collector at the start of each component function body.
97
- // Matches both exported and non-exported PascalCase components (#786).
98
- // Uses paren counting instead of regex to correctly handle nested
99
- // delimiters in destructured params (e.g. `onInput = () => {}`).
100
- //
101
- // The regex matches against a comment-masked copy so a docstring
102
- // example like `function MyNode(this: HTMLElement, props)` is NOT
103
- // misread as a real function declaration (#1236). The paren counter
104
- // still walks the ORIGINAL `restOfFile` and keeps the quote-skip
105
- // logic — TS parameter type annotations contain balanced strings
106
- // (e.g. `"data-key"?: string`) that the skip handles correctly.
107
- let modifiedRest = restOfFile
108
- const maskedRest = maskComments(restOfFile)
109
- const exportFuncPattern = /(?:export )?function ([A-Z]\w*)\s*\(/g
110
- const insertions: Array<{ index: number; text: string }> = []
111
- let efMatch: RegExpExecArray | null
112
- while ((efMatch = exportFuncPattern.exec(maskedRest)) !== null) {
113
- const openParenPos = efMatch.index + efMatch[0].length - 1
114
- // Count parens to find matching ')'
115
- let depth = 1
116
- let i = openParenPos + 1
117
- while (i < restOfFile.length && depth > 0) {
118
- const ch = restOfFile[i]
119
- if (ch === "'" || ch === '"' || ch === '`') {
120
- i++
121
- while (i < restOfFile.length) {
122
- if (restOfFile[i] === '\\') { i += 2; continue }
123
- if (restOfFile[i] === ch) { i++; break }
124
- i++
125
- }
126
- continue
127
- }
128
- if (ch === '(') depth++
129
- else if (ch === ')') depth--
130
- i++
131
- }
132
- // i is now right after matching ')'; find the next '{' for function body
133
- while (i < restOfFile.length && restOfFile[i] !== '{') i++
134
- if (i < restOfFile.length) {
135
- insertions.push({ index: i + 1, text: scriptCollector })
136
- }
137
- }
138
- // Apply insertions from back to front to preserve indices
139
- for (let ii = insertions.length - 1; ii >= 0; ii--) {
140
- const ins = insertions[ii]
141
- modifiedRest = modifiedRest.slice(0, ins.index) + ins.text + modifiedRest.slice(ins.index)
142
- }
143
-
144
- // Wrap each return (...) with __bfWrap((...), __bfInlineScripts).
145
- //
146
- // Intentionally NO comment/string masking here. JSX bodies routinely
147
- // contain unbalanced apostrophes in text content (`Hey! How's it
148
- // going`) which a string-aware scanner misreads as an open quote and
149
- // ends up blanking everything until the next stray `'`, breaking
150
- // paren counting. Plain `(` / `)` counting works for JSX returns
151
- // because JSX text cannot contain literal parens — those only appear
152
- // inside `{expr}` slots, which are balanced JS.
153
- const returnPattern = /return\s*\(/g
154
- const returnMatches: Array<{ index: number; length: number }> = []
155
- let m: RegExpExecArray | null
156
- while ((m = returnPattern.exec(modifiedRest)) !== null) {
157
- returnMatches.push({ index: m.index, length: m[0].length })
158
- }
159
- // Process from last to first to keep earlier offsets valid
160
- for (let ri = returnMatches.length - 1; ri >= 0; ri--) {
161
- const rm = returnMatches[ri]
162
- const afterOpen = rm.index + rm.length // position after 'return ('
163
- let depth = 1
164
- let ci = afterOpen
165
- while (ci < modifiedRest.length && depth > 0) {
166
- if (modifiedRest[ci] === '(') depth++
167
- else if (modifiedRest[ci] === ')') depth--
168
- ci++
169
- }
170
- // ci is right after the matching ')'; insert wrap closing there
171
- modifiedRest = modifiedRest.slice(0, ci) + ', __bfInlineScripts)' + modifiedRest.slice(ci)
172
- // Replace 'return (' with 'return __bfWrap(('
173
- modifiedRest = modifiedRest.slice(0, rm.index) + 'return __bfWrap((' + modifiedRest.slice(rm.index + rm.length)
174
- }
175
-
176
- return beforeImports + existingImports + importStatement + helperFn + modifiedRest
177
- }
178
-
179
- /**
180
- * Replace comment contents with spaces (preserving length and newlines
181
- * so indices computed against the masked text are valid in the
182
- * original). Used by `addScriptCollection` so its `function Foo(`
183
- * regex ignores JSDoc / inline comments — a docstring example like
184
- * `function MyNode(this: HTMLElement, props)` previously masqueraded
185
- * as a real function declaration (#1236).
186
- *
187
- * Handles `//` line comments and `/* ... *\/` block comments (incl.
188
- * JSDoc). String literals are intentionally NOT masked: JSX text
189
- * content routinely contains unbalanced apostrophes (`How's`) that a
190
- * string-aware masker would misread as an open quote, blanking the
191
- * rest of the file and hiding later function declarations.
192
- *
193
- * Strings inside comments are handled implicitly: the whole comment
194
- * (including any quotes it contains) is blanked.
195
- *
196
- * **Known limitation**: this function does NOT track string
197
- * boundaries, so a `//` or `/*` appearing INSIDE a string literal is
198
- * still treated as a comment delimiter. Example: in
199
- * `const u = "https://x.y" ; export function Foo() {}` the `//` in
200
- * `https://` is misread as a line comment and the rest of the line is
201
- * blanked — a `function Foo()` on that same line would be hidden from
202
- * the regex. SSR template output (the only caller) does not embed
203
- * such cases in practice. If a future caller can produce them, swap
204
- * in a real lexer rather than extending this helper.
205
- */
206
- export function maskComments(s: string): string {
207
- let out = ''
208
- let i = 0
209
- while (i < s.length) {
210
- const ch = s[i]
211
- const next = s[i + 1]
212
- if (ch === '/' && next === '*') {
213
- const end = s.indexOf('*/', i + 2)
214
- const stop = end === -1 ? s.length : end + 2
215
- for (let j = i; j < stop; j++) out += s[j] === '\n' ? '\n' : ' '
216
- i = stop
217
- continue
218
- }
219
- if (ch === '/' && next === '/') {
220
- const end = s.indexOf('\n', i + 2)
221
- const stop = end === -1 ? s.length : end
222
- for (let j = i; j < stop; j++) out += ' '
223
- i = stop
224
- continue
225
- }
226
- out += ch
227
- i++
228
- }
229
- return out
230
- }
package/src/dev.tsx DELETED
@@ -1,154 +0,0 @@
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
- // `readFile(path, 'utf8')` is the obvious call, but Deno's
79
- // `node:fs/promises` types resolve that positional-encoding
80
- // overload to `NonSharedBuffer` rather than `string`, so `.trim()`
81
- // is missing and `deno check` fails (TS2769 + TS2339). Read raw
82
- // bytes and decode with `TextDecoder` instead: it defaults to
83
- // UTF-8 (same result as before), sidesteps the broken overload
84
- // entirely, and is portable across Node/Deno/Workers — matching
85
- // the `TextEncoder` used just below.
86
- return new TextDecoder().decode(await readFile(buildIdPath)).trim()
87
- } catch {
88
- return ''
89
- }
90
- }
91
-
92
- const stream = new ReadableStream<Uint8Array>({
93
- async start(controller) {
94
- const encoder = new TextEncoder()
95
- const send = (chunk: string) => {
96
- try {
97
- controller.enqueue(encoder.encode(chunk))
98
- } catch {
99
- // Stream already closed (client disconnected).
100
- }
101
- }
102
-
103
- send(`retry: 1000\n\n`)
104
- let lastSentId = ''
105
- const initialId = await readBuildId()
106
- if (initialId) {
107
- lastSentId = initialId
108
- const event = lastEventId && lastEventId !== initialId ? 'reload' : 'hello'
109
- send(`event: ${event}\nid: ${initialId}\ndata: ${initialId}\n\n`)
110
- }
111
-
112
- // Heartbeat keeps the connection under Bun's idleTimeout so that a
113
- // silent period between builds doesn't close the socket (which would
114
- // otherwise race with in-flight rebuilds and drop `reload` events).
115
- const heartbeat = setInterval(() => send(`: hb\n\n`), HEARTBEAT_MS)
116
-
117
- try {
118
- // Watch the parent directory: the build-id file may not exist yet,
119
- // and `fs.watch` on a missing path throws.
120
- const iter = watch(devDir, { signal })
121
- for await (const event of iter) {
122
- if (event.filename !== BUILD_ID_FILE) continue
123
- const id = await readBuildId()
124
- if (!id || id === lastSentId) continue
125
- lastSentId = id
126
- send(`event: reload\nid: ${id}\ndata: ${id}\n\n`)
127
- }
128
- } catch (err) {
129
- const name = (err as { name?: string } | undefined)?.name
130
- if (name !== 'AbortError') {
131
- const message = (err as Error).message ?? 'watch error'
132
- send(`event: error\ndata: ${JSON.stringify(message)}\n\n`)
133
- }
134
- } finally {
135
- clearInterval(heartbeat)
136
- try { controller.close() } catch { /* already closed */ }
137
- }
138
- },
139
- cancel() {
140
- // Client disconnected; fs.watch will unwind via `signal`.
141
- },
142
- })
143
-
144
- return new Response(stream, {
145
- headers: {
146
- 'Content-Type': 'text/event-stream',
147
- 'Cache-Control': 'no-cache, no-transform',
148
- 'Connection': 'keep-alive',
149
- 'X-Accel-Buffering': 'no',
150
- },
151
- })
152
- }
153
- }
154
-