diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34e2ae4..1ab6199 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: version: stable - name: Install glaze - run: raco pkg install --auto --no-docs --skip-installed git+https://github.com/turinglambdaai/glaze.git#v0.14.0 + run: raco pkg install --auto --no-docs --skip-installed git+https://github.com/turinglambdaai/glaze.git#main # the updater verifies manifests through the crypto package (libcrypto # FFI bundled with the Racket distribution) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd9182a..a92efb3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,7 +82,7 @@ jobs: distribution: full - name: Install glaze - run: raco pkg install --auto --no-docs --skip-installed git+https://github.com/turinglambdaai/glaze.git#v0.14.0 + run: raco pkg install --auto --no-docs --skip-installed git+https://github.com/turinglambdaai/glaze.git#main # the updater verifies manifests through the crypto package (libcrypto # FFI bundled with the Racket distribution) @@ -240,7 +240,7 @@ jobs: distribution: full - name: Install glaze - run: raco pkg install --auto --no-docs --skip-installed git+https://github.com/turinglambdaai/glaze.git#v0.14.0 + run: raco pkg install --auto --no-docs --skip-installed git+https://github.com/turinglambdaai/glaze.git#main # the updater verifies manifests through the crypto package (libcrypto # FFI bundled with the Racket distribution) @@ -661,7 +661,7 @@ jobs: distribution: full - name: Install glaze - run: raco pkg install --auto --no-docs --skip-installed git+https://github.com/turinglambdaai/glaze.git#v0.14.0 + run: raco pkg install --auto --no-docs --skip-installed git+https://github.com/turinglambdaai/glaze.git#main # the updater verifies manifests through the crypto package (libcrypto # FFI bundled with the Racket distribution) @@ -831,7 +831,7 @@ jobs: - name: Install glaze and crypto run: | - raco pkg install --auto --no-docs --skip-installed git+https://github.com/turinglambdaai/glaze.git#v0.14.0 crypto + raco pkg install --auto --no-docs --skip-installed git+https://github.com/turinglambdaai/glaze.git#main crypto - uses: actions/download-artifact@v4 with: diff --git a/backend/main.rkt b/backend/main.rkt index 18f8166..3c9e48d 100644 --- a/backend/main.rkt +++ b/backend/main.rkt @@ -126,44 +126,44 @@ (hasheq 'ok #t 'version app-version)] [(POST "api/kv/get") - (kv-get-r [key string?]) + (kv-get-r [key GString]) (hasheq 'value (nullable (kv-get (current-db) key)))] [(POST "api/kv/set") - (kv-set-r [key string?] [value string?]) + (kv-set-r [key GString] [value GString]) (kv-set! (current-db) key value) (hasheq 'ok #t)] [(POST "api/translations/get") - (translations-get [itemId exact-integer?] [lang string?]) + (translations-get [itemId GInt64] [lang GString]) (define-values (title text) (translation-get (current-db) itemId lang)) (hasheq 'found (if (or title text) #t 'null) 'title (nullable title) 'text (nullable text))] [(POST "api/translations/get-batch") - (translations-get-batch-r [itemIds (listof exact-integer?)] [lang string?]) + (translations-get-batch-r [itemIds (glist GInt64)] [lang GString]) (hasheq 'rows (for/list ([row (in-list (translations-get-batch (current-db) itemIds lang))]) (match-define (list id title text) row) (list id (nullable title) (nullable text))))] [(POST "api/translations/put") - (translations-put-r [rows list?]) + (translations-put-r [rows (glist (glist GValue))]) (translations-put! (current-db) (jsexpr->trans-rows rows)) (hasheq 'ok #t)] [(POST "api/items/get") - (items-get [id exact-integer?]) + (items-get [id GInt64]) (hasheq 'json (nullable (item-get (current-db) id)))] [(POST "api/items/put") - (items-put-r [items list?]) + (items-put-r [items (glist (glist GValue))]) (items-put! (current-db) (jsexpr->items items)) (hasheq 'ok #t)] [(POST "api/data/read") - (data-read [name string?]) + (data-read [name GString]) (define raw (read-data-file name)) (define masked-json (if (and (string=? name "settings") raw) @@ -175,7 +175,7 @@ (hasheq 'json (nullable masked-json))] [(POST "api/data/write") - (data-write [name string?] [json string?]) + (data-write [name GString] [json GString]) ;; A masked key on the write path echoes the masked read — swap the ;; stored key back so the page can never clear it by saving untouched. (write-data-file @@ -381,12 +381,21 @@ (seed-rollout-bucket!) (surface-install-failure!) + ;; Transport: native (the page loads from glaze:// and every call rides + ;; the GLZ1 message handler — no TCP port) is the production shape on + ;; macOS; other platforms and explicit dev runs opt into HTTP with + ;; HACKDIGEST_TRANSPORT=http. GLAZE_API_TOKEN pins a fixed token for + ;; dev/test automation on the HTTP transport; the native transport has + ;; no token because the message handler is the trust boundary. + (define transport + (or (let ([v (getenv "HACKDIGEST_TRANSPORT")]) + (and (member v '("native" "http")) (string->symbol v))) + (if (eq? (system-type 'os) 'macosx) 'native 'http))) (run-app #:public-dir frontend-dist #:api api #:events bus - ;; GLAZE_API_TOKEN pins a fixed token for dev/test automation; default - ;; generates a random one exchanged for an HttpOnly cookie at boot. + #:transport transport #:api-token (or (getenv "GLAZE_API_TOKEN") #t) #:title "HackDigest" #:width 1160 diff --git a/packages/core/src/bridge.ts b/packages/core/src/bridge.ts index c9973ae..c1f37b1 100644 --- a/packages/core/src/bridge.ts +++ b/packages/core/src/bridge.ts @@ -1,24 +1,65 @@ -// Bridge to the Glaze Racket backend (replaces the Tauri IPC layer). -// The page is served by the same local server, so plain relative fetch -// reaches /api/*; the api-token rides an HttpOnly cookie set by glaze's -// one-time bootstrap. With no backend (plain-browser dev via vite), the -// probe fails and callers fall back to IndexedDB / window.open, matching -// the old isTauri() branches. +// Bridge to the Glaze Racket backend. +// +// Two transports behind one interface: +// - native: the page runs on the controlled glaze:// origin; calls ride +// the GLZ1 message handler (window.__glz1, injected at document start +// by the native bootstrap). No TCP port exists in production. +// - http: the page is served by the loopback server (vite dev, the HTTP +// adapter) and talks plain relative fetch; /api/health probes for the +// backend, and without one callers fall back to IndexedDB / +// window.open, matching the old isTauri() branches. +// +// Streaming routes keep their line protocol on both transports (first +// line `OK` or `ERR `, then raw text or ndjson lines): +// the native channel delivers the handler's bytes verbatim as chunk +// frames, so one parser serves both. + +type OnDelta = (text: string) => void; + +interface Glz1 { + invoke( + path: string, + args?: unknown, + opts?: { method?: string; timeoutMs?: number } + ): Promise; + invokeStream( + path: string, + args?: unknown, + opts?: { method?: string; onDelta?: OnDelta; signal?: AbortSignal } + ): Promise; +} + +declare global { + interface Window { + __glz1?: Glz1; + } +} + +function nativeBridge(): Glz1 | null { + return (typeof window !== 'undefined' && window.__glz1) || null; +} let probe: Promise | null = null; -/** True once the Glaze backend answers /api/health. Memoized per session. */ +/** True once a backend answers — native bridge present, or /api/health ok. Memoized per session. */ export function hasBackend(): Promise { if (!probe) { - probe = fetch('/api/health', { method: 'GET' }) - .then((r) => r.ok) - .catch(() => false); + const g = nativeBridge(); + probe = g + ? Promise.resolve(true) + : fetch('/api/health', { method: 'GET' }) + .then((r) => r.ok) + .catch(() => false); } return probe; } /** POST JSON to the backend; returns the parsed response or null (JSON null). */ export async function apiPost(path: string, body?: unknown): Promise { + const g = nativeBridge(); + if (g) { + return (await g.invoke(path, body ?? {}, { method: 'POST' })) as T; + } const res = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -31,16 +72,106 @@ export async function apiPost(path: string, body?: unknown): Promise { return json as T; } -/** - * Streamed chat: first line is `OK` or `ERR `, then raw - * text deltas. Passes each delta to onDelta as it arrives and returns the - * full text. The AbortSignal cancels the fetch (the backend sees the socket - * drop and stops writing). - */ -export async function streamChat( +// The streaming line protocol shared by both transports. Errors cannot +// throw out of the onDelta callback (the native bootstrap swallows +// callback exceptions by design), so the parser records the error and the +// caller raises it after the stream settles. +class LineStreamParser { + private buf = ''; + private statusDone = false; + error: Error | null = null; + + constructor( + private readonly mode: 'text' | 'lines', + private readonly onItem: OnDelta + ) {} + + push(chunk: string): void { + if (this.error) return; + this.buf += chunk; + if (!this.statusDone) { + const nl = this.buf.indexOf('\n'); + if (nl === -1) return; + const status = this.buf.slice(0, nl); + this.buf = this.buf.slice(nl + 1); + this.statusDone = true; + if (status.startsWith('ERR')) { + const msg = status.slice(3).trim(); + const m = msg.match(/^(\d+)\s?(.*)$/); + this.error = new Error(m && m[2] ? m[2] : msg || 'request failed'); + return; + } + } + if (this.mode === 'text') { + if (this.buf) { + this.onItem(this.buf); + this.buf = ''; + } + return; + } + for (;;) { + const nl = this.buf.indexOf('\n'); + if (nl === -1) break; + const line = this.buf.slice(0, nl).trim(); + this.buf = this.buf.slice(nl + 1); + if (line) this.onItem(line); + } + } + + /** Data held back past the last chunk; throws if no status line ever arrived. */ + finish(): string { + if (!this.statusDone) throw new Error('stream ended before status line'); + const tail = this.buf; + this.buf = ''; + return tail; + } +} + +function deliverTail(p: LineStreamParser, mode: 'text' | 'lines', onItem: OnDelta): void { + const tail = p.finish(); + const last = mode === 'text' ? tail : tail.trim(); + if (last) onItem(last); +} + +// Native transport: chunk frames carry the handler's bytes verbatim; the +// parser reconstructs the same line protocol the HTTP body has. +async function invokeNativeStream( + path: string, + body: unknown, + mode: 'text' | 'lines', + onItem: OnDelta, + signal?: AbortSignal +): Promise { + const g = nativeBridge(); + if (!g) throw new Error('native bridge unavailable'); + const items: string[] = []; + const p = new LineStreamParser(mode, (t) => { + items.push(t); + onItem(t); + }); + try { + await g.invokeStream(path, body, { + method: 'POST', + onDelta: (d) => p.push(d), + signal, + }); + deliverTail(p, mode, (t) => { + items.push(t); + onItem(t); + }); + if (p.error) throw p.error; + return items.join(''); + } catch (e) { + throw p.error ?? e; + } +} + +// HTTP transport: a fetch reader feeds the same parser with body chunks. +async function fetchStream( path: string, body: unknown, - onDelta: (text: string) => void, + mode: 'text' | 'lines', + onItem: OnDelta, signal?: AbortSignal ): Promise { const res = await fetch(path, { @@ -53,39 +184,43 @@ export async function streamChat( const detail = await res.text().catch(() => ''); throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ''}`); } + const items: string[] = []; + const p = new LineStreamParser(mode, (t) => { + items.push(t); + onItem(t); + }); const reader = res.body.getReader(); const decoder = new TextDecoder(); - let buf = ''; - let full = ''; - let statusDone = false; for (;;) { const { done, value } = await reader.read(); if (done) break; - buf += decoder.decode(value, { stream: true }); - if (!statusDone) { - const nl = buf.indexOf('\n'); - if (nl === -1) continue; - const status = buf.slice(0, nl); - buf = buf.slice(nl + 1); - statusDone = true; - if (status.startsWith('ERR')) { - const msg = status.slice(3).trim(); - const m = msg.match(/^(\d+)\s?(.*)$/); - throw new Error(m && m[2] ? m[2] : msg || 'LLM request failed'); - } - } - if (buf) { - full += buf; - onDelta(buf); - buf = ''; - } + p.push(decoder.decode(value, { stream: true })); + if (p.error) throw p.error; } - if (buf) { - full += buf; - onDelta(buf); - } - if (!statusDone) throw new Error('stream ended before status line'); - return full; + deliverTail(p, mode, (t) => { + items.push(t); + onItem(t); + }); + if (p.error) throw p.error; + return items.join(''); +} + +/** + * Streamed chat: first line is `OK` or `ERR `, then raw + * text deltas. Passes each delta to onDelta as it arrives and returns the + * full text. The AbortSignal cancels the stream (the backend sees the + * stream cancelled and stops writing). + */ +export function streamChat( + path: string, + body: unknown, + onDelta: OnDelta, + signal?: AbortSignal +): Promise { + const g = nativeBridge(); + return g + ? invokeNativeStream(path, body, 'text', onDelta, signal) + : fetchStream(path, body, 'text', onDelta, signal); } /** @@ -99,45 +234,10 @@ export async function streamLines( onLine: (line: string) => void, signal?: AbortSignal ): Promise { - const res = await fetch(path, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - signal, - }); - if (!res.ok || !res.body) { - const detail = await res.text().catch(() => ''); - throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ''}`); - } - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buf = ''; - let statusDone = false; - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - buf += decoder.decode(value, { stream: true }); - for (;;) { - if (!statusDone) { - const nl = buf.indexOf('\n'); - if (nl === -1) break; - const status = buf.slice(0, nl); - buf = buf.slice(nl + 1); - statusDone = true; - if (status.startsWith('ERR')) { - const msg = status.slice(3).trim(); - const m = msg.match(/^(\d+)\s?(.*)$/); - throw new Error(m && m[2] ? m[2] : msg || 'request failed'); - } - continue; - } - const nl = buf.indexOf('\n'); - if (nl === -1) break; - const line = buf.slice(0, nl).trim(); - buf = buf.slice(nl + 1); - if (line) onLine(line); - } + const g = nativeBridge(); + if (g) { + await invokeNativeStream(path, body, 'lines', onLine, signal); + return; } - if (buf.trim() && statusDone) onLine(buf.trim()); - if (!statusDone) throw new Error('stream ended before status line'); + await fetchStream(path, body, 'lines', onLine, signal); } diff --git a/packages/core/test/bridge.test.ts b/packages/core/test/bridge.test.ts new file mode 100644 index 0000000..66ee281 --- /dev/null +++ b/packages/core/test/bridge.test.ts @@ -0,0 +1,184 @@ +// Bridge dual-transport tests: the native (__glz1) path and the plain +// fetch path must expose the same semantics — apiPost returns the value, +// streamChat/streamLines parse the OK/ERR line protocol, and an ERR line +// throws the backend's message on both transports. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { apiPost, hasBackend, streamChat, streamLines } from '../src/bridge'; + +// A __glz1 double: invokes resolve with the handler's value; streams run +// the handler (which fires onDelta per chunk) and resolve with its return +// value — the same shape the real channel gives the bridge. +function nativeBridge(handlers: { + invoke?: (path: string, args: unknown) => unknown; + stream?: (path: string, args: unknown, onDelta: (t: string) => void) => string; +}) { + (window as unknown as { __glz1?: unknown }).__glz1 = { + invoke: (path: string, args: unknown) => Promise.resolve(handlers.invoke?.(path, args)), + invokeStream: (path: string, args: unknown, opts?: { onDelta?: (t: string) => void }) => { + const full = handlers.stream?.(path, args, (t) => opts?.onDelta?.(t)) ?? ''; + return Promise.resolve(full); + }, + }; +} + +describe('bridge: http transport', () => { + beforeEach(() => { + delete (window as unknown as { __glz1?: unknown }).__glz1; + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('hasBackend probes /api/health', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true }))); + expect(await hasBackend()).toBe(true); + expect(vi.mocked(fetch).mock.calls[0][0]).toBe('/api/health'); + }); + + it('apiPost throws the backend error message', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: async () => ({ error: 'boom' }), + })) + ); + await expect(apiPost('/api/x', {})).rejects.toThrow('boom'); + }); + + it('streamChat fires onDelta across fetch chunks and returns the full text', async () => { + const chunks = [new TextEncoder().encode('OK\nHel'), new TextEncoder().encode('lo')]; + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + body: { + getReader: () => ({ + read: async () => + chunks.length ? { done: false, value: chunks.shift()! } : { done: true, value: undefined }, + }), + }, + })) + ); + const deltas: string[] = []; + const full = await streamChat('/api/llm/chat/stream', {}, (t) => deltas.push(t)); + expect(full).toBe('Hello'); + expect(deltas.join('')).toBe('Hello'); + }); + + it('streamChat throws the ERR-line message on the http path', async () => { + const chunks = [new TextEncoder().encode('ERR 500 key bad\n')]; + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + body: { + getReader: () => ({ + read: async () => + chunks.length ? { done: false, value: chunks.shift()! } : { done: true, value: undefined }, + }), + }, + })) + ); + await expect(streamChat('/api/llm/chat/stream', {}, () => {})).rejects.toThrow('key bad'); + }); + + it('streamLines fires per line even when lines split across chunks', async () => { + const chunks = [ + new TextEncoder().encode('OK\n{"a"'), + new TextEncoder().encode(':1}\n{"a":2}'), + new TextEncoder().encode('\n'), + ]; + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + body: { + getReader: () => ({ + read: async () => + chunks.length ? { done: false, value: chunks.shift()! } : { done: true, value: undefined }, + }), + }, + })) + ); + const lines: string[] = []; + await streamLines('/api/translate/story', {}, (l) => lines.push(l)); + expect(lines).toEqual(['{"a":1}', '{"a":2}']); + }); +}); + +describe('bridge: native transport', () => { + beforeEach(() => { + vi.unstubAllGlobals(); + }); + afterEach(() => { + delete (window as unknown as { __glz1?: unknown }).__glz1; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('hasBackend is true when __glz1 is present', async () => { + nativeBridge({}); + expect(await hasBackend()).toBe(true); + }); + + it('apiPost resolves the invoke value', async () => { + nativeBridge({ invoke: () => ({ pong: true }) }); + expect(await apiPost<{ pong: boolean }>('/api/kv/get', { key: 'x' })).toEqual({ pong: true }); + }); + + it('apiPost propagates backend errors as Error', async () => { + nativeBridge({ + invoke: () => { + throw new Error('not found'); + }, + }); + await expect(apiPost('/api/nope', {})).rejects.toThrow('not found'); + }); + + it('streamChat fires onDelta per chunk and returns the full text', async () => { + const deltas: string[] = []; + nativeBridge({ + stream: (_p, _a, onDelta) => { + onDelta('OK\n'); + onDelta('Hel'); + onDelta('lo'); + return 'OK\nHello'; + }, + }); + const full = await streamChat('/api/llm/chat/stream', {}, (t) => deltas.push(t)); + expect(full).toBe('Hello'); + expect(deltas.join('')).toBe('Hello'); + }); + + it('streamChat throws the ERR-line message on the native path', async () => { + nativeBridge({ + stream: (_p, _a, onDelta) => { + onDelta('ERR 429 rate limited\n'); + return 'ERR 429 rate limited\n'; + }, + }); + await expect(streamChat('/api/llm/chat/stream', {}, () => {})).rejects.toThrow('rate limited'); + }); + + it('streamLines fires per complete line on the native path', async () => { + const lines: string[] = []; + nativeBridge({ + stream: (_p, _a, onDelta) => { + onDelta('OK\n{"a":1}\n{"a":2}\n'); + return 'OK\n{"a":1}\n{"a":2}\n'; + }, + }); + await streamLines('/api/translate/story', {}, (l) => lines.push(l)); + expect(lines).toEqual(['{"a":1}', '{"a":2}']); + }); + + it('streamLines throws when the stream ends before a status line', async () => { + nativeBridge({ stream: () => 'no status here' }); + await expect(streamLines('/api/x', {}, () => {})).rejects.toThrow('status line'); + }); +}); +