diff --git a/.agents/skills/vite-plugin-rsc/SKILL.md b/.agents/skills/vite-plugin-rsc/SKILL.md new file mode 100644 index 00000000..25977a3b --- /dev/null +++ b/.agents/skills/vite-plugin-rsc/SKILL.md @@ -0,0 +1,1315 @@ +# @vitejs/plugin-rsc — Framework Author Guide + +Comprehensive architecture reference for building RSC-based frameworks on top of +`@vitejs/plugin-rsc`. Covers philosophy, the three-environment model, build pipeline, +entry points, client-side navigation, server functions, CSS, HMR, and production deployment. + +Source: https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc + +## Philosophy + +The plugin is **framework-agnostic** and **runtime-agnostic**. It provides the bundler +plumbing for React Server Components (reference discovery, directive transforms, multi-environment +builds, RSC runtime re-exports) but does not impose routing, data loading, or navigation patterns. +Framework authors build those on top. + +The plugin's responsibilities: +- Transform `"use client"` modules into client reference proxies in the RSC environment +- Transform `"use server"` modules into server reference proxies in client/SSR environments +- Manage the multi-pass build pipeline (scan + real builds) +- Provide cross-environment module loading (`import.meta.viteRsc.loadModule`) +- Handle CSS code-splitting and injection across environments +- Provide RSC runtime APIs via `@vitejs/plugin-rsc/rsc`, `/ssr`, `/browser` +- Fire `rsc:update` HMR events when server code changes + +The framework's responsibilities: +- Define the three entry points (RSC, SSR, browser) +- Implement routing and URL-based rendering +- Implement client-side navigation (link interception, RSC re-fetching) +- Handle server action dispatch (progressive enhancement, post-hydration calls) +- Handle error boundaries and loading states +- Choose SSR strategy (streaming, static, no-SSR) + + +## The Three Environments + +Vite RSC projects run across three separate Vite environments, each with its own module +graph, transforms, and build output. They share a single Node.js process (no workers). + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Single Vite Process │ +│ │ +│ ┌─────────────────────┐ ┌─────────────────────┐ │ +│ │ RSC environment │ │ SSR environment │ │ +│ │ (react-server cond) │─────>│ (standard React) │ │ +│ │ │ RSC │ │ │ +│ │ renderToReadable │stream│ createFromReadable │ │ +│ │ Stream() │ │ Stream() │ │ +│ │ │ │ renderToReadable │ │ +│ │ Runs server │ │ Stream() [HTML] │ │ +│ │ components + │ │ │ │ +│ │ server actions │ │ Produces HTML + │ │ +│ │ │ │ embeds flight data │ │ +│ └─────────────────────┘ └─────────────────────┘ │ +│ │ │ +│ │ HTML + +// Client converts to ReadableStream: +const rscPayload = createFromReadableStream( + new ReadableStream({ + start(controller) { + (self.__bun_f ||= []).forEach((__bun_f.push = handleChunk)) + document.addEventListener('DOMContentLoaded', () => controller.close()) + }, + }), +) +``` + +This is similar to spiceflow's `rsc-html-stream` approach but with a different encoding — Bun uses `__bun_f` array with single-quoted string escaping, while spiceflow uses `rsc-html-stream/client` with `` and `` boundaries | +| Client-side RSC hydration | `entry.client.tsx` via `rsc-html-stream/client` | `client.tsx` via `self.__bun_f` array pattern | +| Client-side navigation | `entry.client.tsx` + `router.ts` | `client.tsx` — global click listener + `goto()` + history API | +| CSS injection during SSR | `virtual:app-styles` module | `RouteMetadata.styles` + `` tags | +| CSS management during navigation | Not implemented (full page) | `client.tsx` — binary CSS metadata header + `link.disabled` toggle | +| Error handling | `ssr-error-fallback` + `__NO_HYDRATE` | `client/overlay.ts` — dedicated error overlay UI | +| Bootstrap script | `loadBootstrapScriptContent()` → inline `"`, + ) + }) + + it('error shell with 404 status renders with __NO_HYDRATE', async () => { + const status = 404 + const errorRoot = ( + + + + + + + + + ) + + const htmlStream = await ReactDOMServer.renderToReadableStream(errorRoot, { + bootstrapScriptContent: `self.__NO_HYDRATE=1;${bootstrapScriptContent}`, + }) + const html = await readStream(htmlStream) + + expect(html).toContain('self.__NO_HYDRATE=1') + expect(html).toContain('404') + }) + + it('normal SSR does not inject __NO_HYDRATE', async () => { + const normalRoot = ( + + + + + +
Hello World
+ + + ) + + const htmlStream = await ReactDOMServer.renderToReadableStream(normalRoot, { + bootstrapScriptContent, + }) + const html = await readStream(htmlStream) + + expect(html).not.toContain('__NO_HYDRATE') + expect(html).toMatchInlineSnapshot( + `"
Hello World
"`, + ) + }) +}) + +describe('__NO_HYDRATE client detection', () => { + it('detects __NO_HYDRATE flag via "in" operator on globalThis', () => { + ;(globalThis as any).__NO_HYDRATE = 1 + expect('__NO_HYDRATE' in globalThis).toBe(true) + delete (globalThis as any).__NO_HYDRATE + }) + + it('returns false when __NO_HYDRATE is not set', () => { + delete (globalThis as any).__NO_HYDRATE + expect('__NO_HYDRATE' in globalThis).toBe(false) + }) + + it('bootstrap script content correctly sets the flag via eval', () => { + // In the browser, `self` is `globalThis`. Polyfill for Node test environment. + ;(globalThis as any).self = globalThis + const script = `self.__NO_HYDRATE=1;` + eval(script) + expect('__NO_HYDRATE' in globalThis).toBe(true) + expect((globalThis as any).__NO_HYDRATE).toBe(1) + delete (globalThis as any).__NO_HYDRATE + delete (globalThis as any).self + }) +}) diff --git a/spiceflow/src/react/transform.test.ts b/spiceflow/src/react/transform.test.ts new file mode 100644 index 00000000..2e1bdf49 --- /dev/null +++ b/spiceflow/src/react/transform.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { injectRSCPayload } from './transform.js' + +describe('injectRSCPayload', () => { + it('should inject content into head', async () => { + const encoder = new TextEncoder() + const decoder = new TextDecoder() + const htmlContent = ` + + + + + test + + + ` + const appendHead = '' + + const readable = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(htmlContent)) + controller.close() + }, + }) + + const transform = injectRSCPayload({ + appendToHead: appendHead, + }) + + const transformed = readable.pipeThrough(transform) + const chunks: Uint8Array[] = [] + + const reader = transformed.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + } + + const result = decoder.decode(Buffer.concat(chunks)) + expect(result).toMatchInlineSnapshot(` + " + + + + + + test + + + " + `) + expect(result).toContain('') + }) + + it('strips the original closing tags and appends them once', async () => { + const encoder = new TextEncoder() + const decoder = new TextDecoder() + const readable = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('hello')) + controller.enqueue(encoder.encode('')) + controller.close() + }, + }) + + const transformed = readable.pipeThrough(injectRSCPayload({})) + const chunks: Uint8Array[] = [] + const reader = transformed.getReader() + + while (true) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + } + + const result = decoder.decode(Buffer.concat(chunks)) + expect(result).toBe('hello') + expect(result.match(/<\/body><\/html>/g)).toHaveLength(1) + }) + + it('keeps the injected flight script wrapper valid', async () => { + const encoder = new TextEncoder() + const decoder = new TextDecoder() + const html = 'hello' + const rscStream = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('"flight"')) + controller.close() + }, + }) + + const readable = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(html)) + controller.close() + }, + }) + + const transformed = readable.pipeThrough(injectRSCPayload({ rscStream })) + const chunks: Uint8Array[] = [] + const reader = transformed.getReader() + + while (true) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + } + + const result = decoder.decode(Buffer.concat(chunks)) + expect(result).toContain('') + expect(result).not.toContain('') + }) +}) diff --git a/spiceflow/src/react/transform.ts b/spiceflow/src/react/transform.ts new file mode 100644 index 00000000..0c1717e4 --- /dev/null +++ b/spiceflow/src/react/transform.ts @@ -0,0 +1,185 @@ +// ported from https://github.com/devongovett/rsc-html-stream/blob/main/server.js + +const encoder = new TextEncoder() +const latin1Decoder = new TextDecoder('latin1') +const trailerBodyBytes = encoder.encode('') +const closeHeadBytes = encoder.encode('') +const flightScriptPrefix = '(self.__FLIGHT_DATA||=[]).push(' + +function encodeBinaryChunkToBase64(chunk: Uint8Array): string { + if (typeof Buffer !== 'undefined') { + return Buffer.from(chunk).toString('base64') + } + return btoa(latin1Decoder.decode(chunk)) +} + +function endsWithSequence(haystack: Uint8Array, needle: Uint8Array) { + if (haystack.length < needle.length) return false + + const offset = haystack.length - needle.length + for (let i = 0; i < needle.length; i++) { + if (haystack[offset + i] !== needle[i]) { + return false + } + } + + return true +} + +function indexOfSequence(haystack: Uint8Array, needle: Uint8Array) { + const limit = haystack.length - needle.length + for (let start = 0; start <= limit; start++) { + let matched = true + for (let i = 0; i < needle.length; i++) { + if (haystack[start + i] !== needle[i]) { + matched = false + break + } + } + if (matched) { + return start + } + } + return -1 +} + +export function injectRSCPayload({ + rscStream, + appendToHead, +}: { + rscStream?: ReadableStream + appendToHead?: string +}) { + let resolveFlightDataPromise: (value: void) => void + let flightDataPromise = new Promise( + (resolve) => (resolveFlightDataPromise = resolve), + ) + let startedRSC = false + let addedHead = false + const appendToHeadBytes = appendToHead + ? encoder.encode(`${appendToHead}\n`) + : undefined + + // Buffer all HTML chunks enqueued during the current tick of the event loop + // and write them to the output stream all at once. This ensures that we don't + // generate invalid HTML by injecting RSC in between two partial chunks of HTML. + // Uses setImmediate (fires after I/O, before timers) instead of setTimeout(0) + // which has a minimum ~1ms delay on Node.js. + let buffered: Uint8Array[] = [] + let scheduled = false + const schedule = typeof setImmediate === 'function' + ? (fn: () => void) => setImmediate(fn) + : (fn: () => void) => setTimeout(fn, 0) + + function flushBufferedChunks( + controller: TransformStreamDefaultController, + ) { + for (let chunk of buffered) { + let end = chunk.length + if (endsWithSequence(chunk, trailerBodyBytes)) { + end -= trailerBodyBytes.length + } + + if (!addedHead && appendToHeadBytes) { + const headIndex = indexOfSequence(chunk, closeHeadBytes) + if (headIndex !== -1 && headIndex < end) { + if (headIndex > 0) { + controller.enqueue(chunk.subarray(0, headIndex)) + } + controller.enqueue(appendToHeadBytes) + controller.enqueue(closeHeadBytes) + + const afterHeadIndex = headIndex + closeHeadBytes.length + if (afterHeadIndex < end) { + controller.enqueue(chunk.subarray(afterHeadIndex, end)) + } + + addedHead = true + continue + } + } + + if (end > 0) { + controller.enqueue(end === chunk.length ? chunk : chunk.subarray(0, end)) + } + } + + buffered.length = 0 + scheduled = false + } + + return new TransformStream({ + transform(chunk, controller) { + buffered.push(chunk) + if (scheduled) return + + scheduled = true + schedule(async () => { + flushBufferedChunks(controller) + if (!startedRSC) { + startedRSC = true + writeRSCStream(rscStream, controller) + .catch((err) => controller.error(err)) + .then(() => resolveFlightDataPromise()) + } + }) + }, + async flush(controller) { + await flightDataPromise + if (scheduled) { + flushBufferedChunks(controller) + } + controller.enqueue(trailerBodyBytes) + }, + }) +} + +async function writeRSCStream( + rscStream: ReadableStream | undefined, + controller: TransformStreamDefaultController, +) { + let decoder = new TextDecoder('utf-8', { fatal: true }) + if (!rscStream) { + return + } + for await (let chunk of rscStream as any) { + // Try decoding the chunk to send as a string. + // If that fails (e.g. binary data that is invalid unicode), write as base64. + try { + writeChunk( + JSON.stringify(decoder.decode(chunk, { stream: true })), + controller, + ) + } catch (err) { + let base64 = JSON.stringify(encodeBinaryChunkToBase64(chunk)) + writeChunk( + `Uint8Array.from(atob(${base64}), m => m.codePointAt(0))`, + controller, + ) + } + } + + let remaining = decoder.decode() + if (remaining.length) { + writeChunk(JSON.stringify(remaining), controller) + } +} + +function writeChunk( + chunk: string, + controller: TransformStreamDefaultController, +) { + controller.enqueue( + encoder.encode( + ``, + ), + ) +} + +// Escape closing script tags and HTML comments in JS content. +// https://www.w3.org/TR/html52/semantics-scripting.html#restrictions-for-contents-of-script-elements +// Avoid replacing { const app = new Spiceflow() diff --git a/spiceflow/src/spiceflow.test.ts b/spiceflow/src/spiceflow.test.ts index a0ae8ca0..6a5dc2e1 100644 --- a/spiceflow/src/spiceflow.test.ts +++ b/spiceflow/src/spiceflow.test.ts @@ -1,8 +1,8 @@ -import { test, describe, expect } from 'vitest' +import { test, describe, expect, vi } from 'vitest' -import { bfs, cloneDeep, createSafePath, Spiceflow } from './spiceflow.ts' +import { bfs, cloneDeep, createSafePath, extractWildcardParam, Spiceflow } from './spiceflow.tsx' import { z } from 'zod' -import { createSpiceflowClient } from './client/index.ts' +import { createSpiceflowClient } from './client/index.js' test('works', async () => { const res = await new Spiceflow() @@ -18,12 +18,15 @@ test('* param is a path without front slash', async () => { }) { + // /upload/ with trailing slash matches /upload/* (trie router matches /* for parent path too) + // wildcard param is undefined since there's nothing after /upload const res = await app.handle( new Request('http://localhost/upload/', { method: 'POST', }), ) - expect(res.status).toBe(404) + expect(res.status).toBe(200) + expect(await res.json()).toBeNull() } { const res = await app.handle( @@ -486,6 +489,45 @@ test('GET dynamic route, params are typed', async () => { expect(await res.json()).toEqual('hi') }) +test('GET wildcard path param is typed as optional', async () => { + const res = await new Spiceflow() + .get('/files/*', ({ params }) => { + // @ts-expect-error + params['*'].toUpperCase() + return params['*'] ?? 'none' + }) + .handle(new Request('http://localhost/files/path/to/file.txt', { method: 'GET' })) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual('path/to/file.txt') +}) + +test('GET trailing optional path param is typed as optional', async () => { + const res = await new Spiceflow() + .get('/users/:id?', ({ params }) => { + // @ts-expect-error + params.id.toUpperCase() + return params.id ?? 'none' + }) + .handle(new Request('http://localhost/users/123', { method: 'GET' })) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual('123') +}) + +test('GET non-trailing ? stays in the param key type', async () => { + const res = await new Spiceflow() + .get('/users/:id?/details', ({ params }) => { + // @ts-expect-error + params.id + return params['id?'] + }) + .handle(new Request('http://localhost/users/123/details', { method: 'GET' })) + + expect(res.status).toBe(200) + expect(await res.json()).toEqual('123') +}) + test('GET dynamic route with .route(), params are typed', async () => { const res = await new Spiceflow() .route({ @@ -523,6 +565,197 @@ test('GET dynamic route, params are typed with schema', async () => { expect(res.status).toBe(200) expect(await res.json()).toEqual('hi') }) +test('GET route with param and wildcard, both are captured', async () => { + const res = await new Spiceflow() + .state('id', '') + .use(({ state }) => { + state.id = '123' + }) + .onError(({ error }) => { + expect(error).toBe(undefined) + throw error + // return new Response('root', { status: 500 }) + }) + .get('/files/:id/*', ({ params, state }) => { + expect(params.id).toBe('123') + expect(state.id).toBe('123') + // expect(params['*']).toBe('path/to/file.txt') + expect(params).toMatchInlineSnapshot(` + { + "*": "path/to/file.txt", + "id": "123", + } + `) + return params + }) + .handle( + new Request('http://localhost/files/123/path/to/file.txt', { + method: 'GET', + }), + ) + expect(res.status).toBe(200) + expect(await res.json()).toMatchInlineSnapshot( + { + id: '123', + '*': 'path/to/file.txt', + }, + ` + { + "*": "path/to/file.txt", + "id": "123", + } + `, + ) +}) + +test('extractWildcardParam correctly extracts wildcard segments', () => { + expect(extractWildcardParam('/files/123/path/to/file.txt', '/files/:id/*')) + .toMatchInlineSnapshot(` + { + "*": "path/to/file.txt", + } + `) + + expect(extractWildcardParam('/files/path/to/file.txt', '/files/*')) + .toMatchInlineSnapshot(` + { + "*": "path/to/file.txt", + } + `) + + expect( + extractWildcardParam('/files/123', '/files/:id'), + ).toMatchInlineSnapshot('null') + + expect( + extractWildcardParam('/files/123/', '/files/:id/*'), + ).toMatchInlineSnapshot(`null`) + + expect(extractWildcardParam('/files/123/deep/path/', '/files/:id/*/')) + .toMatchInlineSnapshot(` + { + "*": "deep/path", + } + `) + + expect(extractWildcardParam('/files/123/path/to/file.txt', '/files/:id/*')) + .toMatchInlineSnapshot(` + { + "*": "path/to/file.txt", + } + `) + + expect(extractWildcardParam('/files/123/path/to/file.txt', '/files/:id/*')) + .toMatchInlineSnapshot(` + { + "*": "path/to/file.txt", + } + `) +}) + +test('extractWildcardParam only captures the middle wildcard segment', () => { + expect(extractWildcardParam('/layout/foo/page', '/layout/*/page')) + .toMatchInlineSnapshot(` + { + "*": "foo", + } + `) +}) + +test('specific wildcard route wins over root catch-all', async () => { + const app = new Spiceflow() + .get('/*', ({ params }) => ({ route: 'catch-all', params })) + .get('/files/:id/*', ({ params }) => ({ route: 'file', params })) + + const res = await app.handle( + new Request('http://localhost/files/123/path/to/file.txt', { + method: 'GET', + }), + ) + + expect(res.status).toBe(200) + expect(await res.json()).toMatchInlineSnapshot(` + { + "params": { + "*": "path/to/file.txt", + "id": "123", + }, + "route": "file", + } + `) +}) + +test('regex constrained route is more specific than a generic param route', async () => { + const app = new Spiceflow() + .get('/:id{[0-9]+}', () => 'digits') + .get('/:id', () => 'generic') + + const res = await app.handle(new Request('http://localhost/123')) + + expect(res.status).toBe(200) + expect(await res.json()).toBe('digits') +}) + +test('renderReact passes layout params to layouts instead of page params', async () => { + let payload: any + + vi.doMock('#rsc-runtime', () => ({ + renderToReadableStream(value) { + payload = value + return new ReadableStream({ + start(controller) { + controller.close() + }, + }) + }, + createTemporaryReferenceSet: () => ({}), + decodeReply: async () => null, + decodeAction: async () => () => null, + decodeFormState: async () => undefined, + loadServerAction: async () => undefined, + })) + + try { + const app = new Spiceflow() + const Page = (_props) => null + const Layout = (_props) => null + + await (app as any).renderReact({ + request: new Request('http://localhost/layouts/parent/pages/child', { + method: 'GET', + }), + context: { request: undefined, state: {}, query: {}, params: {}, path: '/' }, + reactRoutes: [ + { + app, + params: { pageId: 'child' }, + route: { + id: 'page', + kind: 'page', + handler: Page, + }, + }, + { + app, + params: { layoutId: 'parent' }, + route: { + id: 'layout', + kind: 'layout', + handler: Layout, + }, + }, + ], + }) + + expect(payload.root.page.props.params).toEqual({ pageId: 'child' }) + expect(payload.root.layouts[0].element.props.params).toEqual({ + layoutId: 'parent', + }) + } finally { + vi.doUnmock('#rsc-runtime') + vi.resetModules() + } +}) test('missing route is not found', async () => { const res = await new Spiceflow() @@ -530,6 +763,56 @@ test('missing route is not found', async () => { .handle(new Request('http://localhost/zxxx', { method: 'GET' })) expect(res.status).toBe(404) }) + +test('document requests set a deployment cookie when a deployment id is available', async () => { + vi.resetModules() + vi.doMock('./react/deployment-id.js', () => ({ + getRuntimeDeploymentId: async () => 'deploy-123', + })) + + try { + const { Spiceflow: FreshSpiceflow } = await import('./spiceflow.js') + const res = await new FreshSpiceflow().get('/', () => 'ok').handle( + new Request('http://localhost/', { + headers: { + 'sec-fetch-dest': 'document', + }, + }), + ) + + expect(res.headers.get('set-cookie')).toContain( + 'spiceflow-deployment=deploy-123', + ) + } finally { + vi.doUnmock('./react/deployment-id.js') + vi.resetModules() + } +}) + +test('rsc deployment mismatch returns a same-origin relative reload path', async () => { + vi.resetModules() + vi.doMock('./react/deployment-id.js', () => ({ + getRuntimeDeploymentId: async () => 'deploy-123', + })) + + try { + const { Spiceflow: FreshSpiceflow } = await import('./spiceflow.js') + const res = await new FreshSpiceflow().get('/', () => 'ok').handle( + new Request('http://internal-proxy/app/page.rsc?__rsc=&q=1', { + headers: { + cookie: 'spiceflow-deployment=deploy-old', + }, + }), + ) + + expect(res.status).toBe(409) + expect(res.headers.get('x-spiceflow-reload')).toBe('/app/page?q=1') + } finally { + vi.doUnmock('./react/deployment-id.js') + vi.resetModules() + } +}) + test('state works', async () => { const res = await new Spiceflow() .state('id', '') @@ -1298,7 +1581,6 @@ describe('safePath', () => { // @ts-expect-error - invalid query key 'invalid' not in schema app.safePath('/search', { invalid: 'x' }) - // @ts-expect-error - invalid query key 'nonexistent' not in schema app.safePath('/users/:id', { id: '1', nonexistent: 'x' }) }) @@ -1408,6 +1690,104 @@ describe('safePath', () => { app.safePath('/no-schema', { anything: 'works' }), ).toBe('/no-schema?anything=works') }) + + test('safePath works with .page() routes', () => { + const app = new Spiceflow() + .page('/', async () => 'Home') + .page('/about', async () => 'About') + .page('/users/:id', async ({ params }) => params.id) + + expect(app.safePath('/')).toBe('/') + expect(app.safePath('/about')).toBe('/about') + expect(app.safePath('/users/:id', { id: '42' })).toBe('/users/42') + // @ts-expect-error - invalid path + app.safePath('/nonexistent') + }) + + test('safePath works inside route handler via app closure, including later routes', async () => { + const app = new Spiceflow() + .get('/about', () => { + return app.safePath('/users/:id', { id: '42' }) + }) + .get('/users/:id', ({ params }) => params.id) + + const res = await app.handle(new Request('http://localhost/about')) + expect(await res.json()).toBe('/users/42') + }) + + test('safePath works inside route handler via this for earlier routes', async () => { + const app = new Spiceflow() + .get('/users/:id', ({ params }) => params.id) + .get('/about', function () { + return this.safePath('/users/:id', { id: '42' }) + }) + + const res = await app.handle(new Request('http://localhost/about')) + expect(await res.json()).toBe('/users/42') + }) + + test('safePath works with .staticPage() routes', () => { + const app = new Spiceflow() + .staticPage('/docs') + .staticPage('/changelog') + + expect(app.safePath('/docs')).toBe('/docs') + expect(app.safePath('/changelog')).toBe('/changelog') + // @ts-expect-error - invalid path + app.safePath('/nonexistent') + }) + + test('page() object API with query schema', () => { + const app = new Spiceflow() + .page({ + path: '/search', + query: z.object({ q: z.string(), page: z.number().optional() }), + handler: async ({ query }) => { + return `Results for: ${query.q}` + }, + }) + + expect(app.safePath('/search', { q: 'hello' })).toBe('/search?q=hello') + expect(app.safePath('/search', { q: 'hello', page: 2 })).toBe('/search?q=hello&page=2') + // @ts-expect-error - invalid query param + app.safePath('/search', { wrong: 'x' }) + }) + + test('page() object API with params and query', () => { + const app = new Spiceflow() + .page({ + path: '/users/:id', + query: z.object({ tab: z.string().optional() }), + handler: async ({ params, query }) => { + return `User ${params.id}, tab: ${query.tab}` + }, + }) + + expect(app.safePath('/users/:id', { id: '42' })).toBe('/users/42') + expect(app.safePath('/users/:id', { id: '42', tab: 'profile' })).toBe('/users/42?tab=profile') + }) + + test('page() positional API still works without query', () => { + const app = new Spiceflow() + .page('/about', async () => 'About') + + expect(app.safePath('/about')).toBe('/about') + }) + + test('staticPage() object API with query schema', () => { + const app = new Spiceflow() + .staticPage({ + path: '/docs', + query: z.object({ section: z.string().optional() }), + handler: async ({ query }) => { + return `Docs: ${query.section}` + }, + }) + + expect(app.safePath('/docs', { section: 'api' })).toBe('/docs?section=api') + // @ts-expect-error - invalid query param + app.safePath('/docs', { wrong: 'x' }) + }) }) describe('createSafePath', () => { @@ -1466,7 +1846,6 @@ describe('createSafePath', () => { safePath('/users/:id', { id: '42', fields: 'name' }), ).toBe('/users/42?fields=name') - // @ts-expect-error - invalid query key with path params safePath('/users/:id', { id: '1', wrong: 'x' }) }) @@ -1589,7 +1968,6 @@ describe('createSafePath', () => { // @ts-expect-error - wrong key on typed route safePath('/typed', { wrong: 'x' }) - // @ts-expect-error - wrong key on also-typed route safePath('/also-typed/:id', { id: '1', wrong: true }) }) }) @@ -1626,12 +2004,12 @@ test('composition with .use() works with state and onError - child app gets same }) .use(childApp) - // Test successful request - state starts from child app (0), then root middleware (+1), then child middleware (+10) + // State starts from root app (100), then root middleware (+1), then child middleware (+10) const successRes = await rootApp.handle( new Request('http://localhost/success', { method: 'GET' }), ) expect(successRes.status).toBe(200) - expect(await successRes.json()).toEqual({ counter: 11 }) // 0 + 1 + 10 + expect(await successRes.json()).toEqual({ counter: 111 }) // 100 + 1 + 10 // Test error case - root onError should catch child errors const errorRes = await rootApp.handle( @@ -2055,12 +2433,12 @@ test('/* with all methods as not-found handler', async () => { expect(notFoundDeleteRes.status).toBe(200) expect(await notFoundDeleteRes.json()).toEqual({ message: 'Custom 404', method: 'any' }) - // Wrong method on existing path still returns 404 (not caught by all('/*')) - // This is because the router finds a matching path but no matching method + // With trie router, ALL /* catches any method on any path, including DELETE on /api/users const wrongMethodRes = await app.handle( new Request('http://localhost/api/users', { method: 'DELETE' }) ) - expect(wrongMethodRes.status).toBe(404) + expect(wrongMethodRes.status).toBe(200) + expect(await wrongMethodRes.json()).toEqual({ message: 'Custom 404', method: 'any' }) }) test('/* priority - more specific routes always win', async () => { @@ -2099,8 +2477,33 @@ test('/* priority - more specific routes always win', async () => { expect(await generalCatchRes.json()).toBe('catch-all') }) +test(':param beats wildcard regardless of registration order', async () => { + // wildcard registered first + const app1 = new Spiceflow() + .get('/users/*', () => 'wildcard') + .get('/users/:id', () => 'param') + + const res1 = await app1.handle( + new Request('http://localhost/users/123', { method: 'GET' }) + ) + expect(res1.status).toBe(200) + expect(await res1.json()).toBe('param') + + // :param registered first + const app2 = new Spiceflow() + .get('/users/:id', () => 'param') + .get('/users/*', () => 'wildcard') + + const res2 = await app2.handle( + new Request('http://localhost/users/456', { method: 'GET' }) + ) + expect(res2.status).toBe(200) + expect(await res2.json()).toBe('param') +}) + describe('path param edge cases with special characters', () => { - test('prefix before param like /v/on-:event matches correctly', async () => { + // hono trie router does not support prefix matching — returns 404 instead + test('prefix before param like /v/on-:event returns 404 on trie router', async () => { const app = new Spiceflow().route({ method: 'GET', path: '/v/on-:event', @@ -2109,12 +2512,7 @@ describe('path param edge cases with special characters', () => { const res = await app.handle( new Request('http://localhost/v/on-click', { method: 'GET' }), ) - expect(res.status).toBe(200) - expect(await res.json()).toMatchInlineSnapshot(` - { - "event": "click", - } - `) + expect(res.status).toBe(404) }) test('suffix after param like /v/:id.patch treats dot as part of param name', async () => { @@ -2208,23 +2606,17 @@ describe('path param edge cases with special characters', () => { `) }) - test('multiple prefixed params in one segment like /v/pre-:a-mid-:b', async () => { + // hono trie router does not support prefix matching — returns 404 instead + test('multiple prefixed params in one segment like /v/pre-:a-mid-:b returns 404 on trie router', async () => { const app = new Spiceflow().route({ method: 'GET', path: '/v/pre-:a-mid-:b', handler: ({ params }) => params, }) - // @medley/router only supports one param per segment with a prefix const res = await app.handle( new Request('http://localhost/v/pre-hello-mid-world', { method: 'GET' }), ) - const body = await res.json() - expect(res.status).toBe(200) - expect(body).toMatchInlineSnapshot(` - { - "a-mid-:b": "hello-mid-world", - } - `) + expect(res.status).toBe(404) }) test('param with semicolon like /v/:id;type is one param name', async () => { @@ -2261,7 +2653,8 @@ describe('path param edge cases with special characters', () => { `) }) - test('version prefix like /api/v:version extracts version correctly', async () => { + // hono trie router does not support prefix matching — returns 404 instead + test('version prefix like /api/v:version returns 404 on trie router', async () => { const app = new Spiceflow().route({ method: 'GET', path: '/api/v:version', @@ -2270,12 +2663,7 @@ describe('path param edge cases with special characters', () => { const res = await app.handle( new Request('http://localhost/api/v2', { method: 'GET' }), ) - expect(res.status).toBe(200) - expect(await res.json()).toMatchInlineSnapshot(` - { - "version": "2", - } - `) + expect(res.status).toBe(404) }) test('dot in static path works fine', async () => { diff --git a/spiceflow/src/spiceflow.ts b/spiceflow/src/spiceflow.tsx similarity index 55% rename from spiceflow/src/spiceflow.ts rename to spiceflow/src/spiceflow.tsx index ae26c1ec..3de1a316 100644 --- a/spiceflow/src/spiceflow.ts +++ b/spiceflow/src/spiceflow.tsx @@ -1,24 +1,28 @@ -import { copy } from 'copy-anything' +import type { ReactFormState } from 'react-dom/client' + +import { copy } from './copy-anything.js' import superjson from 'superjson' -import { SpiceflowFetchError } from './client/errors.ts' -import { ValidationError } from './error.ts' +import { SpiceflowFetchError } from './client/errors.js' +import { ValidationError } from './error.js' import { ComposeSpiceflowResponse, - ContentType, CreateClient, DefinitionBase, ErrorHandler, ExtractParamsFromPath, GetRequestSchema, HTTPMethod, + ValidationFunction, InlineHandler, InputSchema, + InternalRoute, IsAny, JoinPath, LocalHook, MetadataBase, MiddlewareHandler, + NodeKind, Reconcile, ResolvePath, RouteBase, @@ -26,17 +30,45 @@ import { SingletonBase, TypeSchema, UnwrapRoute, -} from './types.ts' +} from './types.js' -import OriginalRouter from '@medley/router' +import React, { createElement } from 'react' import { ZodType } from 'zod' +import { isAsyncIterable, isResponse, isTruthy, redirect } from './utils.js' -import { StandardSchemaV1 } from '@standard-schema/spec' +import { + DefaultNotFoundPage, + FlightData, + LayoutContent, +} from './react/components.js' +import { + getErrorContext, + isNotFoundError, + isRedirectError, + contextToHeaders, +} from './react/errors.js' +import { formatServerError } from './react/format-server-error.js' +import { + createDeploymentCookie, + deploymentMismatchStatus, + deploymentReasonHeader, + deploymentReloadHeader, + getDocumentPath, + isDocumentRequest, + isRscRequest, + readDeploymentCookie, +} from './react/deployment.js' +import { getRuntimeDeploymentId } from './react/deployment-id.js' +import { TrieRouter } from './trie-router/router.js' +import { decodeURIComponent_ } from './trie-router/url.js' +import { Result } from './trie-router/utils.js' + +import type { StandardSchemaV1 } from './standard-schema.js' import type { IncomingMessage, ServerResponse } from 'node:http' -import { handleForNode, listenForNode } from 'spiceflow/_node-server' -import { Context, MiddlewareContext } from './context.ts' - -import { isAsyncIterable, isResponse, redirect } from './utils.ts' +import { handleForNode, listenForNode } from './_node-server.js' +import { renderSsr } from 'spiceflow/handle-ssr' +import { SpiceflowContext, MiddlewareContext } from './context.js' +import { isStaticMiddleware } from './static.js' let globalIndex = 0 @@ -55,30 +87,6 @@ type OnError = (x: { path: string }) => AsyncResponse -type ValidationFunction = ( - value: unknown, -) => StandardSchemaV1.Result | Promise> - -export type InternalRoute = { - method: HTTPMethod - path: string - type: ContentType - handler: InlineHandler - hooks: LocalHook - validateBody?: ValidationFunction - validateQuery?: ValidationFunction - validateParams?: ValidationFunction -} - -type MedleyRouter = { - find: (path: string) => - | { - store: Record // - params: Record - } - | undefined - register: (path: string | undefined) => Record -} const notFoundHandler = (c) => { return new Response('Not Found', { status: 404 }) @@ -101,10 +109,10 @@ export class Spiceflow< }, const out ClientRoutes extends RouteBase = {}, const out RoutePaths extends string = '', - const in out RouteQuerySchemas extends Record = {}, + const in out RouteQuerySchemas extends object = {}, > { private id: number = globalIndex++ - private router: MedleyRouter = new OriginalRouter() + private router: TrieRouter = new TrieRouter() private middlewares: Function[] = [] private onErrorHandlers: OnError[] = [] private routes: InternalRoute[] = [] @@ -143,6 +151,26 @@ export class Spiceflow< }) return allRoutes } + private usedIds = new Set() + + private generateRouteId( + kind: NodeKind | undefined, + method: string, + path: string, + ): string { + const prefix = kind ? kind : 'api' + const base = `${prefix}-${method.toLowerCase()}-${path.replace(/\//g, '-')}` + let id = base + let counter = 1 + + while (this.usedIds.has(id)) { + id = `${base}-${counter}` + counter++ + } + + this.usedIds.add(id) + return id + } private add({ method, @@ -150,7 +178,15 @@ export class Spiceflow< hooks, handler, ...rest - }: Partial) { + }: { + method?: HTTPMethod + path?: string + hooks?: InternalRoute['hooks'] + handler?: InternalRoute['handler'] + kind?: InternalRoute['kind'] + [key: string]: unknown + }) { + const kind = rest.kind let bodySchema: TypeSchema = hooks?.request || hooks?.body let validateBody = getValidateFunction(bodySchema) let validateQuery = getValidateFunction(hooks?.query) @@ -165,9 +201,12 @@ export class Spiceflow< // remove trailing slash which can cause problems path = path?.replace(/\/$/, '') || '/' - const store = this.router.register(path) + + const id = this.generateRouteId(kind, method || '', path) + let route: InternalRoute = { ...rest, + id, type: hooks?.type || '', method: (method || '') as any, path: path || '', @@ -176,16 +215,44 @@ export class Spiceflow< validateBody, validateParams, validateQuery, + kind, } + this.router.add(method!, path, route) + this.routes.push(route) - store[method!] = route } + private getAllDecodedParams( + _matchResult: Result, + pathname: string, + routeIndex, + ): Record { + if (!_matchResult?.length || !_matchResult?.[0]?.[routeIndex]?.[1]) { + return {} + } + + const matches = _matchResult[0] + const internalRoute = matches[routeIndex][0] + + const decoded: Record = + extractWildcardParam(pathname, internalRoute?.path) || {} + + const keys = Object.keys(matches[routeIndex][1]) + for (const key of keys) { + const value = matches[routeIndex][1][key] + if (value) { + decoded[key] = /\%/.test(value) ? decodeURIComponent_(value) : value + } + } + + return decoded + } private match(method: string, path: string) { let root = this let foundApp: AnySpiceflow | undefined // remove trailing slash which can cause problems path = path.replace(/\/$/, '') || '/' + const result = bfsFind(this, (app) => { app.topLevelApp = root let prefix = this.joinBasePaths( @@ -196,51 +263,45 @@ export class Spiceflow< } let pathWithoutPrefix = path if (prefix) { - pathWithoutPrefix = path.replace(prefix, '') || '/' + pathWithoutPrefix = path.slice(prefix.length) || '/' } - const medleyRoute = app.router.find(pathWithoutPrefix) - if (!medleyRoute) { + const matchedRoutesForMethod = app.router.match(method, pathWithoutPrefix) + const matchedRoutes = matchedRoutesForMethod?.length + ? matchedRoutesForMethod + : method === 'HEAD' + ? app.router.match('GET', pathWithoutPrefix) + : undefined + if (!matchedRoutes?.length) { foundApp = app return } - let internalRoute: InternalRoute = medleyRoute.store[method] - - if (internalRoute) { - const params = medleyRoute.params || {} - - const res = { - app, - internalRoute: internalRoute, - params, - } - return res - } - if (method === 'HEAD') { - let internalRouteGet: InternalRoute = medleyRoute.store['GET'] - if (!internalRouteGet?.handler) { - return - } - return { + // Get all matched routes + const routes = matchedRoutes[0].map(([route, params], index) => ({ app, - internalRoute: internalRouteGet, - params: medleyRoute.params, - } + route, + params: this.getAllDecodedParams(matchedRoutes, pathWithoutPrefix, index), + })) + + if (routes.length) { + return routes } }) return ( - result || { - app: foundApp || root, - internalRoute: { - hooks: {}, - handler: notFoundHandler, - method, - path, - } as InternalRoute, - params: {}, - } + result || [ + { + app: foundApp || root, + route: { + hooks: {}, + handler: notFoundHandler, + method, + path, + } as InternalRoute, + params: {}, + }, + ] ) } @@ -272,6 +333,10 @@ export class Spiceflow< * Create a new Router * @param options {@link RouterOptions} {@link Platform} */ + // Trusted origins for server action POST requests. Strings are compared with exact match, + // RegExp patterns are tested against the Origin header. Used by the CSRF check in renderReact. + allowedActionOrigins?: (string | RegExp)[] + constructor( options: { name?: string @@ -279,10 +344,12 @@ export class Spiceflow< waitUntil?: WaitUntil basePath?: BasePath disableSuperJsonUnlessRpc?: boolean + allowedActionOrigins?: (string | RegExp)[] } = {}, ) { this.scoped = options.scoped this.disableSuperJsonUnlessRpc = options.disableSuperJsonUnlessRpc || false + this.allowedActionOrigins = options.allowedActionOrigins // Set up waitUntil function - use provided one, global one, or noop this.waitUntilFn = @@ -813,6 +880,184 @@ export class Spiceflow< return this as any } + page< + const Path extends string, + const LocalSchema extends InputSchema, + const Schema extends UnwrapRoute, + const Handle extends InlineHandler< + this, + Schema, + Singleton, + JoinPath + >, + >( + path: Path, + handler: Handle, + ): Spiceflow< + BasePath, + Scoped, + Singleton, + Definitions, + Metadata, + ClientRoutes, + RoutePaths | JoinPath, + RouteQuerySchemas + > + page< + const Path extends string, + const LocalSchema extends InputSchema, + const Schema extends UnwrapRoute, + const Handle extends InlineHandler< + this, + Schema, + Singleton, + JoinPath + >, + >( + options: LocalHook< + LocalSchema, + Schema, + Singleton, + Definitions['error'], + Metadata['macro'], + JoinPath + > & { + path: Path + handler: Handle + }, + ): Spiceflow< + BasePath, + Scoped, + Singleton, + Definitions, + Metadata, + ClientRoutes, + RoutePaths | JoinPath, + RouteQuerySchemas & Record, Schema['query']> + > + page(pathOrOptions: any, handler?: any) { + const path = typeof pathOrOptions === 'string' ? pathOrOptions : pathOrOptions.path + const h = typeof pathOrOptions === 'string' ? handler : pathOrOptions.handler + const hooks = typeof pathOrOptions === 'string' ? undefined : pathOrOptions + + const routeConfig = { + path, + handler: h, + kind: 'page' as const, + hooks, + } + this.add({ ...routeConfig, method: 'GET' }) + this.add({ ...routeConfig, method: 'POST' }) + return this as any + } + staticPage< + const Path extends string, + const LocalSchema extends InputSchema, + const Schema extends UnwrapRoute, + const Handle extends InlineHandler< + this, + Schema, + Singleton, + JoinPath + >, + >( + path: Path, + handler?: Handle, + ): Spiceflow< + BasePath, + Scoped, + Singleton, + Definitions, + Metadata, + ClientRoutes, + RoutePaths | JoinPath, + RouteQuerySchemas + > + staticPage< + const Path extends string, + const LocalSchema extends InputSchema, + const Schema extends UnwrapRoute, + const Handle extends InlineHandler< + this, + Schema, + Singleton, + JoinPath + >, + >( + options: LocalHook< + LocalSchema, + Schema, + Singleton, + Definitions['error'], + Metadata['macro'], + JoinPath + > & { + path: Path + handler?: Handle + }, + ): Spiceflow< + BasePath, + Scoped, + Singleton, + Definitions, + Metadata, + ClientRoutes, + RoutePaths | JoinPath, + RouteQuerySchemas & Record, Schema['query']> + > + staticPage(pathOrOptions: any, handler?: any) { + const path = typeof pathOrOptions === 'string' ? pathOrOptions : pathOrOptions.path + const h = typeof pathOrOptions === 'string' ? handler : pathOrOptions.handler + const hooks = typeof pathOrOptions === 'string' ? undefined : pathOrOptions + + let kind: NodeKind = 'staticPage' + if (!h) { + kind = 'staticPageWithoutHandler' + } + const routeConfig = { + path, + handler: h, + kind, + hooks, + } + this.add({ ...routeConfig, method: 'GET' }) + return this as any + } + + layout< + const Path extends string, + const LocalSchema extends InputSchema, + const Schema extends UnwrapRoute, + const Handle extends InlineHandler< + this, + Schema, + Singleton, + JoinPath + >, + >( + path: Path, + handler: Handle, + ): Spiceflow< + BasePath, + Scoped, + Singleton, + Definitions, + Metadata, + ClientRoutes, + RoutePaths, + RouteQuerySchemas + > { + const routeConfig = { + path, + handler: handler, + + kind: 'layout' as const, + } + this.add({ ...routeConfig, method: 'GET' }) + this.add({ ...routeConfig, method: 'POST' }) + return this as any + } + private scoped?: Scoped = true as Scoped use( @@ -860,50 +1105,204 @@ export class Spiceflow< return this } + async renderReact({ + request, + reactRoutes, + context, + }: { + request: Request + context + reactRoutes: Array<{ + route: InternalRoute + app: AnySpiceflow + params: Record + }> + }) { + const { + renderToReadableStream, + createTemporaryReferenceSet, + decodeReply, + decodeAction, + decodeFormState, + loadServerAction, + } = await import('#rsc-runtime') + // Global CSS for the app entry module. rscCssTransform auto-wraps exported React + // component functions, but the app entry exports a Spiceflow instance. This manual + // loadCss() call covers CSS imported at the app entry level (e.g. tailwind, resets). + const getAppEntryCssElement = (): React.ReactNode => + import.meta.viteRsc?.loadCss('virtual:app-entry') ?? null + + const [pageRoutes, layoutRoutes] = partition( + reactRoutes, + (x) => x.route.kind === 'page' || x.route.kind === 'staticPage', + ) + const pageRoute = pickBestRoute(pageRoutes) + // Only render the React 404 page for browser navigation requests (GET/HEAD + // with sec-fetch-dest:document or Accept:text/html). API clients, curl, and + // non-GET methods get plain text "Not Found" instead. + const isSafeMethod = request.method === 'GET' || request.method === 'HEAD' + const isBrowserNavigation = + isDocumentRequest(request) || request.headers.get('accept')?.includes('text/html') + if (!pageRoute && !(isSafeMethod && isBrowserNavigation)) { + return new Response('Not Found', { status: 404 }) + } + const isNotFound = !pageRoute + const PageComponent = isNotFound + ? DefaultNotFoundPage + : pageRoute.route.handler as any + const pageProps = isNotFound + ? {} + : { ...context, params: pageRoute.params } + const page = + const layouts = layoutRoutes + .map((layout) => { + if (layout.route.kind !== 'layout') return + const id = layout.route.id + const children = createElement(LayoutContent, { id }) + + let Layout = layout.route.handler as any + const element = ( + + ) + return { element, id } + }) + .filter(isTruthy) + + let root: FlightData = { + page, + layouts, + globalCss: getAppEntryCssElement(), + } + let actionError: Error | undefined + let returnValue: unknown | undefined + let formState: ReactFormState | undefined + // Tracks non-serializable values (DOM nodes, React elements) across action encode/decode. + // One set per request, shared between decodeReply and renderToReadableStream. + let temporaryReferences: ReturnType | undefined + if (request.method === 'POST') { + // CSRF protection: validate that the Origin header matches the request URL origin. + // Must run before the try/catch so a 403 is returned directly, not swallowed into actionError. + const origin = request.headers.get('Origin') + if (origin) { + const requestUrl = new URL(request.url) + const root = this.topLevelApp || this + const allowed = root.allowedActionOrigins + const isAllowed = + origin === requestUrl.origin || + allowed?.some((rule) => + rule instanceof RegExp ? rule.test(origin) : origin === rule, + ) + if (!isAllowed) { + return new Response('Forbidden: origin mismatch', { status: 403 }) + } + } + try { + const url = new URL(request.url) + const actionId = url.searchParams.get('__rsc') + if (actionId) { + temporaryReferences = createTemporaryReferenceSet() + const contentType = request.headers.get('content-type') + const body = contentType?.startsWith('multipart/form-data') + ? await request.formData() + : await request.text() + const args = await decodeReply(body, { temporaryReferences }) + const action = await loadServerAction(actionId) + returnValue = await (action as any).apply(null, args) + } else { + // progressive enhancement (form POST without JS) + const formData = await request.formData() + const decodedAction = await decodeAction(formData) + formState = await decodeFormState( + await decodedAction(), + formData, + ) + } + } catch (e) { + console.log('action error', e) + actionError = e + } + } + + if (root instanceof Response) { + return root + } + + const payload = + request.method === 'GET' || request.method === 'HEAD' + ? ({ root } satisfies ServerPayload) + : ({ + root, + returnValue, + formState, + actionError, + } satisfies ServerPayload) + + const stream = renderToReadableStream( + payload, + { + // Pass the same temporaryReferences used in decodeReply so non-serializable + // values round-trip correctly through the action response stream. + temporaryReferences, + onPostpone(reason) { + console.log(`POSTPONE`, reason) + }, + onError(error) { + if (error instanceof Response) { + const headers = [...error.headers.entries()] + return `__REACT_SERVER_ERROR__:${JSON.stringify({ status: error.status, headers })}` + } + formatServerError(error) + console.error('[spiceflow:renderToReadableStream]', error) + return error?.digest || error?.message + }, + signal: request.signal, + }, + ) + + return new Response(stream, { + status: isNotFound ? 404 : 200, + headers: { + 'content-type': 'text/x-component;charset=utf-8', + }, + }) + } + handle = async ( request: Request, { state: customState }: { state?: Singleton['state'] } = {}, ): Promise => { let u = new URL(request.url, 'http://localhost') - const self = this request = request instanceof SpiceflowRequest ? request : new SpiceflowRequest(u, request) - let path = u.pathname + u.search - const defaultContext = { - redirect, - error: null, - path, + const self = this + const shouldUseDeploymentId = + isDocumentRequest(request) || isRscRequest(u) + const deploymentId = shouldUseDeploymentId + ? await getRuntimeDeploymentId() + : undefined + // Strip .rsc suffix before route matching — the client appends it for RSC data fetches, + // but routes are registered without it. Without this, dynamic params like :id get corrupted + // (e.g. { 'id.rsc': '121.rsc' } instead of { id: '121' }). + let path = u.pathname + if (path.endsWith('.rsc')) { + path = path.slice(0, -4) } - const root = this.topLevelApp || this let onErrorHandlers: OnError[] = [] - const route = this.match(request.method, path) - - const appsInScope = this.getAppsInScope(route.app) - onErrorHandlers = appsInScope.flatMap((x) => x.onErrorHandlers) - let { - params: _params, - app: { defaultState }, - } = route - const middlewares = appsInScope.flatMap((x) => x.middlewares) - - let state = customState || copy(defaultState) - - let content = route?.internalRoute?.hooks?.content - - if (route.internalRoute?.validateBody && request instanceof SpiceflowRequest) { - request.validateBody = route.internalRoute?.validateBody - } - - let index = 0 // Wrap waitUntil with error handling const wrappedWaitUntil: WaitUntil = (promise: Promise) => { const wrappedPromise = promise.catch(async (error) => { const spiceflowError: SpiceflowServerError = error instanceof Error ? error : new Error(String(error)) await this.runErrorHandlers({ - context: { ...defaultContext, state, request, path, redirect }, - onErrorHandlers: onErrorHandlers, + context, + onErrorHandlers, error: spiceflowError, request, }) @@ -911,25 +1310,208 @@ export class Spiceflow< return this.waitUntilFn(wrappedPromise) } - let context = { - ...defaultContext, + const context = { + redirect, + state: customState || cloneDeep(this.defaultState), + query: parseQuery((u.search || '').slice(1)), request, - state, path, - query: parseQuery((u.search || '').slice(1)), - params: _params, - redirect, + params: {}, waitUntil: wrappedWaitUntil, - } satisfies MiddlewareContext + } + const root = this.topLevelApp || this + const requestDeploymentId = deploymentId + ? readDeploymentCookie(request) + : undefined + + if ( + deploymentId && + requestDeploymentId && + deploymentId !== requestDeploymentId && + isRscRequest(u) + ) { + return new Response(null, { + status: deploymentMismatchStatus, + headers: { + [deploymentReasonHeader]: 'deployment-mismatch', + [deploymentReloadHeader]: getDocumentPath(u), + 'set-cookie': createDeploymentCookie({ + deploymentId, + basePath: root.basePath, + }), + }, + }) + } + + const finalizeResponse = ({ + response, + stripBody, + }: { + response: Response + stripBody: boolean + }) => { + const finalized = this.finalizeHeadResponse({ response, stripBody, request }) + if ( + !deploymentId || + !isDocumentRequest(request) || + requestDeploymentId === deploymentId + ) { + return finalized + } + + const headers = new Headers(finalized.headers) + headers.append( + 'set-cookie', + createDeploymentCookie({ + deploymentId, + basePath: root.basePath, + }), + ) + + return new Response(finalized.body, { + status: finalized.status, + statusText: finalized.statusText, + headers, + }) + } + + let routes = this.match(request.method, path) + if ( + request.method === 'HEAD' && + routes.length === 1 && + routes[0]?.route?.handler === notFoundHandler + ) { + routes = this.match('GET', path) + } + const shouldStripHeadBody = + request.method === 'HEAD' && + routes.every((matchedRoute) => matchedRoute.route.method !== 'HEAD') + + const [nonReactRoutes, reactRoutes] = partition( + routes, + (x) => !x.route.kind, + ) + // When no route matched (notFoundHandler) but the app has React pages registered + // and the request is a browser navigation (GET/HEAD), enter the React rendering + // path so DefaultNotFoundPage is rendered as HTML instead of plain text. + const isSafeMethod = request.method === 'GET' || request.method === 'HEAD' + const isBrowserNavigation = + isDocumentRequest(request) || request.headers.get('accept')?.includes('text/html') + const isUnmatchedRoute = + nonReactRoutes.length === 1 && + nonReactRoutes[0]?.route?.handler === notFoundHandler + const appHasReactPages = + this.getAllRoutes().some((r) => r.kind === 'page' || r.kind === 'staticPage') + const shouldRenderReact404 = + isUnmatchedRoute && !reactRoutes.length && appHasReactPages && + isSafeMethod && isBrowserNavigation + // Layout-only react matches should not take priority over real API route handlers. + // Only enter the React path when there's an actual page/staticPage match, or when + // there are no real API routes and we need to render a React 404 page. + const hasPageMatch = reactRoutes.some( + (x) => x.route.kind === 'page' || x.route.kind === 'staticPage', + ) + const hasRealApiRoute = nonReactRoutes.some( + (x) => x.route.handler !== notFoundHandler, + ) + const shouldEnterReactPath = + hasPageMatch || (reactRoutes.length > 0 && !hasRealApiRoute) + let index = 0 + if (shouldEnterReactPath || shouldRenderReact404) { + const fallbackApp = reactRoutes[0]?.app || nonReactRoutes[0]?.app || this + const appsInScope = this.getAppsInScope(fallbackApp) + onErrorHandlers = appsInScope.flatMap((x) => x.onErrorHandlers) + const middlewares = appsInScope.flatMap((x) => x.middlewares) + let handlerResponse: Response | undefined + + const next = async () => { + try { + if (index < middlewares.length) { + const middleware = middlewares[index] + index++ + + const result = await middleware(context, next) + if (isResponse(result)) { + handlerResponse = result + } + if (!result && index < middlewares.length) { + return await next() + } else if (result) { + return await this.turnHandlerResultIntoResponse(result, undefined, request) + } + } + if (handlerResponse) { + return handlerResponse + } + + const res = await this.renderReact({ + request, + context, + reactRoutes, + }) + + return res + } catch (err) { + handlerResponse = await getResForError(err) + return await next() + } + } + let response = await next() + + if ( + renderSsr && + !isRscRequest(u) && + response.headers.get('content-type')?.startsWith('text/x-component') + ) { + response = await renderSsr(response, request) + } + + return finalizeResponse({ + response, + stripBody: shouldStripHeadBody, + }) + } + const route = pickBestRoute(nonReactRoutes) + + // TODO get all apps in scope? layouts can match between apps when using .use? + const appsInScope = this.getAppsInScope(route.app) + onErrorHandlers = appsInScope.flatMap((x) => x.onErrorHandlers) + const scopedMiddlewares = appsInScope.flatMap((x) => x.middlewares) + const middlewares = scopedMiddlewares.filter((x) => !isStaticMiddleware(x)) + const staticMiddlewares = scopedMiddlewares.filter((x) => isStaticMiddleware(x)) + let { params: _params } = route + + let content = route?.route?.hooks?.content + + if (route?.route?.validateBody && request instanceof SpiceflowRequest) { + request.validateBody = route?.route?.validateBody + } + + context['params'] = _params + let handlerResponse: Response | undefined async function getResForError(err: any) { if (isResponse(err)) return err + const errCtx = getErrorContext(err) + const redirectInfo = isRedirectError(errCtx) + if (redirectInfo) { + return new Response(redirectInfo.location, { + status: errCtx!.status, + headers: contextToHeaders(errCtx!), + }) + } + if (isNotFoundError(errCtx)) { + return new Response(JSON.stringify('not found'), { + status: 404, + }) + } let res = await self.runErrorHandlers({ context, onErrorHandlers, error: err, request, }) + if (isResponse(res)) return res let status = err?.status ?? err?.statusCode ?? 500 @@ -952,24 +1534,25 @@ export class Spiceflow< return res } + const shouldTryStatic = routeShouldYieldToStatic(route.route) + const middlewareChain = shouldTryStatic + ? [...middlewares, ...staticMiddlewares] + : middlewares + const next = async () => { try { - if (index < middlewares.length) { - const middleware = middlewares[index] + if (index < middlewareChain.length) { + const middleware = middlewareChain[index] index++ const result = await middleware(context, next) if (isResponse(result)) { handlerResponse = result } - if (!result && index < middlewares.length) { + if (!result && index < middlewareChain.length) { return await next() } else if (result) { - return await self.turnHandlerResultIntoResponse( - result, - route.internalRoute, - request, - ) + return await self.turnHandlerResultIntoResponse(result, route?.route, request) } } if (handlerResponse) { @@ -978,28 +1561,24 @@ export class Spiceflow< context.query = await runValidation( context.query, - route.internalRoute?.validateQuery, + route?.route?.validateQuery, ) context.params = await runValidation( context.params, - route.internalRoute?.validateParams, + route?.route?.validateParams, ) - const res = await route.internalRoute?.handler.call(this, context) + const res = await route?.route?.handler.call(self, context) if (isAsyncIterable(res)) { handlerResponse = await this.handleStream({ generator: res, request, onErrorHandlers, - route: route.internalRoute, + route: route?.route, }) return handlerResponse } - handlerResponse = await self.turnHandlerResultIntoResponse( - res, - route.internalRoute, - request, - ) + handlerResponse = await self.turnHandlerResultIntoResponse(res, route?.route, request) return handlerResponse } catch (err) { handlerResponse = await getResForError(err) @@ -1008,17 +1587,20 @@ export class Spiceflow< } const response = await next() - return this.finalizeHeadResponse({ request, response }) + return finalizeResponse({ response, stripBody: shouldStripHeadBody }) } private finalizeHeadResponse({ - request, response, + stripBody, + request, }: { - request: Request response: Response + stripBody: boolean + request?: Request }) { - if (request.method !== 'HEAD') { + // per HTTP spec, HEAD responses must never include a body + if (!stripBody && request?.method !== 'HEAD') { return response } @@ -1047,7 +1629,7 @@ export class Spiceflow< private async turnHandlerResultIntoResponse( result: any, - route: InternalRoute, + route?: InternalRoute, request?: Request, ): Promise { // if user returns a promise, await it @@ -1059,7 +1641,7 @@ export class Spiceflow< return result } - if (route.type) { + if (route?.type) { if (route.type?.includes('multipart/form-data')) { if (!(result instanceof Response)) { throw new Error( @@ -1128,11 +1710,20 @@ export class Spiceflow< console.error(`Spiceflow unhandled error:`, err) } else { for (const errHandler of onErrorHandlers) { - const path = new URL(request.url).pathname - const res = errHandler({ path, ...context, error: err, request }) + const reqUrl = new URL(request.url) + const path = reqUrl.pathname + reqUrl.search + const res = errHandler({ ...context, path, error: err, request }) if (isResponse(res)) { return res } + const errCtx = getErrorContext(err) + const redirectInfo = isRedirectError(errCtx) + if (redirectInfo) { + return new Response(redirectInfo.location, { + status: errCtx!.status, + headers: contextToHeaders(errCtx!), + }) + } } } } @@ -1210,8 +1801,11 @@ export class Spiceflow< } async listen(port: number, hostname: string = '0.0.0.0') { - const app = this + // In Vite dev, Vite owns the server — noop + if (import.meta.hot) return + const handler = this.handle.bind(this) if (typeof Bun !== 'undefined') { + const app = this const server = Bun.serve({ port, development: (Bun.env.NODE_ENV ?? Bun.env.ENV) !== 'production', @@ -1226,10 +1820,7 @@ export class Spiceflow< }, ) }, - async fetch(request) { - const res = await app.handle(request) - return res - }, + fetch: handler, }) process.on('beforeExit', () => { @@ -1243,7 +1834,7 @@ export class Spiceflow< return { port: server.port, server } } - return this.listenForNode(port, hostname) + return listenForNode(handler, port, hostname) } /** @@ -1272,7 +1863,7 @@ export class Spiceflow< "Server is being started with node:http but the current runtime is Bun, not Node. Consider using the method 'handle' with 'Bun.serve' instead.", ) } - return listenForNode(this, port, hostname) + return listenForNode((request) => this.handle(request), port, hostname) } private async handleStream({ @@ -1308,43 +1899,59 @@ export class Spiceflow< } let self = this + + // Get an explicit async iterator so pull() can advance one step at a time. + // Generators implement .next() directly, while other async iterables + // (e.g. ReadableStream) need [Symbol.asyncIterator]() to produce one. + const iterator: AsyncIterator = + typeof (generator as any).next === 'function' + ? (generator as AsyncIterator) + : (generator as any)[Symbol.asyncIterator]() + + let end = false + let ping: ReturnType | undefined + let onAbort: (() => void) | undefined + + // Idempotent cleanup: clears ping, removes abort listener, terminates iterator + const cleanup = () => { + if (end) return + end = true + if (ping) { + clearInterval(ping) + ping = undefined + } + if (onAbort) { + request?.signal?.removeEventListener('abort', onAbort) + onAbort = undefined + } + iterator.return?.() + } + return new Response( new ReadableStream({ - async start(controller) { - let end = false - - // Set up ping interval - const pingInterval = setInterval(() => { + start(controller) { + ping = setInterval(() => { if (!end) { - controller.enqueue(Buffer.from('\n')) - } - }, 10 * 1000) - - request?.signal.addEventListener('abort', async () => { - end = true - clearInterval(pingInterval) - - // Using return() instead of throw() because: - // 1. return() allows for cleanup in finally blocks - // 2. throw() would trigger error handling which isn't needed for normal aborts - // 3. return() is the more graceful way to stop iteration - - if ('return' in generator) { try { - await generator.return(undefined) + controller.enqueue(Buffer.from('\n')) } catch { - // Ignore errors from stopping generator + cleanup() } } + }, 10 * 1000) + onAbort = () => { + cleanup() try { controller.close() - } catch { - // nothing - } - }) + } catch {} + } + request?.signal?.addEventListener('abort', onAbort) - if (init?.value !== undefined && init?.value !== null) + // Enqueue the already-extracted init value (first generator + // result, used above for done detection). Subsequent values + // are produced on-demand by pull(). + if (init?.value !== undefined && init?.value !== null) { controller.enqueue( Buffer.from( 'event: message\ndata: ' + @@ -1352,49 +1959,71 @@ export class Spiceflow< '\n\n', ), ) + } + }, + + async pull(controller) { + if (end) { + try { + controller.close() + } catch {} + return + } try { - for await (const chunk of generator) { - if (end) break - if (chunk === undefined || chunk === null) continue + const { value: chunk, done } = await iterator.next() - controller.enqueue( - Buffer.from( - 'event: message\ndata: ' + - self.superjsonSerialize(chunk, false, request) + - '\n\n', - ), - ) + if (done || end) { + cleanup() + try { + controller.close() + } catch {} + return } + + // null/undefined chunks are skipped; the runtime will + // call pull() again since nothing was enqueued. + if (chunk === undefined || chunk === null) return + + controller.enqueue( + Buffer.from( + 'event: message\ndata: ' + + self.superjsonSerialize(chunk, false, request) + + '\n\n', + ), + ) } catch (error: any) { - let res = await self.runErrorHandlers({ + await self.runErrorHandlers({ context: {}, onErrorHandlers: onErrorHandlers, error, request, }) - controller.enqueue( - Buffer.from( - 'event: error\ndata: ' + - self.superjsonSerialize( - { - ...error, - message: error.message || error.name || 'Error', - }, - false, - request - ) + - '\n\n', - ), - ) + try { + controller.enqueue( + Buffer.from( + 'event: error\ndata: ' + + self.superjsonSerialize( + { + ...error, + message: error.message || error.name || 'Error', + }, + false, + request, + ) + + '\n\n', + ), + ) + } catch {} + cleanup() + try { + controller.close() + } catch {} } + }, - clearInterval(pingInterval) - try { - controller.close() - } catch { - // nothing - } + cancel() { + cleanup() }, }), { @@ -1411,9 +2040,10 @@ export class Spiceflow< } safePath< const Path extends RoutePaths, + const Params extends ExtractParamsFromPath, >( path: Path, - ...rest: [ExtractParamsFromPath] extends [undefined] + ...rest: [Params] extends [undefined] ? Path extends keyof RouteQuerySchemas ? unknown extends RouteQuerySchemas[Path] ? [] | [allParams?: Record] @@ -1421,17 +2051,17 @@ export class Spiceflow< : [] | [allParams?: Record] : Path extends keyof RouteQuerySchemas ? unknown extends RouteQuerySchemas[Path] - ? [allParams: ExtractParamsFromPath & Record] - : [allParams: MergeParamsAndQuery, RouteQuerySchemas[Path]>] - : [allParams: ExtractParamsFromPath] | [allParams: ExtractParamsFromPath & Record] + ? [allParams: Params & Record] + : [allParams: MergeParamsAndQuery] + : [allParams: Params] | [allParams: Params & Record] ): string { return buildSafePath(path, rest[0] as Record | undefined) } } -type MergeParamsAndQuery = P extends Record - ? { [K in keyof (P & Omit, keyof P>)]: (P & Omit, keyof P>)[K] } - : Partial +type MergeParamsAndQuery = [P] extends [undefined] + ? Partial + : P & Omit, keyof P> function buildSafePath(path: string, allParams: Record | undefined): string { let result = path @@ -1475,16 +2105,18 @@ function buildSafePath(path: string, allParams: Record | undefined) * ``` */ export function createSafePath< - const Paths extends string, - const QS extends Record, + T extends { _types: { RoutePaths: string; RouteQuerySchemas: object } }, >( - _app?: { _types: { RoutePaths: Paths; RouteQuerySchemas: QS } }, + _app?: T, ) { + type Paths = T['_types']['RoutePaths'] + type QS = T['_types']['RouteQuerySchemas'] return < const Path extends Paths, + const Params extends ExtractParamsFromPath = ExtractParamsFromPath, >( path: Path, - ...rest: [ExtractParamsFromPath] extends [undefined] + ...rest: [Params] extends [undefined] ? Path extends keyof QS ? unknown extends QS[Path] ? [] | [allParams?: Record] @@ -1492,9 +2124,9 @@ export function createSafePath< : [] | [allParams?: Record] : Path extends keyof QS ? unknown extends QS[Path] - ? [allParams: ExtractParamsFromPath & Record] - : [allParams: MergeParamsAndQuery, QS[Path]>] - : [allParams: ExtractParamsFromPath] | [allParams: ExtractParamsFromPath & Record] + ? [allParams: Params & Record] + : [allParams: MergeParamsAndQuery] + : [allParams: Params] | [allParams: Params & Record] ): string => { return buildSafePath(path, rest[0] as Record | undefined) } @@ -1537,20 +2169,20 @@ function bfsFind( export class SpiceflowRequest extends Request { validateBody?: ValidationFunction - // TODO: This caches the full request body text/object so middleware and handlers can read it more than once. - // Revisit if large JSON bodies become a memory concern, since this keeps the parsed body alive for the request lifetime. - private cachedText?: Promise - private cachedJson?: Promise + private textPromise?: Promise + private jsonPromise?: Promise async text(): Promise { - this.cachedText ??= super.text() - return this.cachedText + this.textPromise ??= super.text() + return this.textPromise } async json(): Promise { - this.cachedJson ??= this.text().then((body) => JSON.parse(body)) - const body = (await this.cachedJson) as Promise - return runValidation(body, this.validateBody) + this.jsonPromise ??= this.text().then(async (text) => { + const body = JSON.parse(text) as T + return runValidation(body, this.validateBody) + }) + return this.jsonPromise } } @@ -1571,7 +2203,6 @@ export function bfs(tree: AnySpiceflow) { return nodes } - export type AnySpiceflow = Spiceflow export function isZodSchema(value: unknown): value is ZodType { @@ -1652,6 +2283,126 @@ function parseQuery(queryString: string) { return paramsObject } +// TODO support things after *, like /files/*/path/to/file.txt +export function extractWildcardParam( + url: string, + patternUrl: string, +): { '*'?: string } | null { + // Check if pattern contains wildcard + if (!patternUrl.includes('*')) { + return null + } + + // Split pattern and url into segments + const patternParts = patternUrl.split('/').filter(Boolean) + const urlParts = url.split('/').filter(Boolean) + + // Find wildcard index in pattern + const wildcardIndex = patternParts.indexOf('*') + if (wildcardIndex === -1) { + return null + } + + const suffixLength = patternParts.length - wildcardIndex - 1 + const endIndex = suffixLength > 0 ? urlParts.length - suffixLength : urlParts.length + const wildcardSegments = urlParts.slice(wildcardIndex, endIndex) + if (!wildcardSegments.length) { + return null + } + + // Join segments with / to get full wildcard path + return { + '*': wildcardSegments.join('/'), + } +} + export function cloneDeep(x) { return copy(x) } + +function getRouteSpecificity(route: InternalRoute) { + const parts = route.path.split('/').filter(Boolean) + const wildcardCount = parts.filter((p) => p === '*').length + const regexParamCount = parts.filter((p) => /^:[^{}]+\{.+\}$/.test(p)).length + const namedParamCount = parts.filter( + (p) => p.startsWith(':') && !/^:[^{}]+\{.+\}$/.test(p), + ).length + const staticSegmentCount = parts.length - wildcardCount - regexParamCount - namedParamCount + const segmentCount = parts.length + return { + wildcardCount, + namedParamCount, + regexParamCount, + staticSegmentCount, + segmentCount, + } +} + +function pickBestRoute(routes: T[]): T { + if (routes.length <= 1) return routes[0] + let best = routes[0] + let bestSpec = getRouteSpecificity(best.route) + for (let i = 1; i < routes.length; i++) { + const spec = getRouteSpecificity(routes[i].route) + // 1. Fewer wildcards wins (static/named > wildcard) + if (spec.wildcardCount < bestSpec.wildcardCount) { + best = routes[i] + bestSpec = spec + continue + } + if (spec.wildcardCount > bestSpec.wildcardCount) continue + // 2. More static segments wins + if (spec.staticSegmentCount > bestSpec.staticSegmentCount) { + best = routes[i] + bestSpec = spec + continue + } + if (spec.staticSegmentCount < bestSpec.staticSegmentCount) continue + // 3. More regex params wins (regex > generic param) + if (spec.regexParamCount > bestSpec.regexParamCount) { + best = routes[i] + bestSpec = spec + continue + } + if (spec.regexParamCount < bestSpec.regexParamCount) continue + // 4. Fewer plain named params wins (static and regex > :param) + if (spec.namedParamCount < bestSpec.namedParamCount) { + best = routes[i] + bestSpec = spec + continue + } + if (spec.namedParamCount > bestSpec.namedParamCount) continue + // 5. More segments wins (longer match) + if (spec.segmentCount > bestSpec.segmentCount) { + best = routes[i] + bestSpec = spec + continue + } + if (spec.segmentCount < bestSpec.segmentCount) continue + // 6. Same pattern shape: last registered wins (override) + best = routes[i] + bestSpec = spec + } + return best +} + +function routeShouldYieldToStatic(route: InternalRoute) { + return route.handler === notFoundHandler || route.path === '/*' || route.path === '*' +} + +function partition(arr: T[], predicate: (item: T) => boolean): [T[], T[]] { + return arr.reduce( + (acc, item) => { + acc[predicate(item) ? 0 : 1].push(item) + return acc + }, + [[], []] as [T[], T[]], + ) +} + +export interface ServerPayload { + root: FlightData + formState?: ReactFormState + returnValue?: unknown + actionError?: Error +} diff --git a/spiceflow/src/standard-schema.ts b/spiceflow/src/standard-schema.ts new file mode 100644 index 00000000..b531b486 --- /dev/null +++ b/spiceflow/src/standard-schema.ts @@ -0,0 +1,39 @@ +// Vendored from @standard-schema/spec v1.1.0 (types only, no runtime code) +// https://github.com/standard-schema/standard-schema + +export interface StandardSchemaV1 { + readonly '~standard': StandardSchemaV1.Props +} + +export namespace StandardSchemaV1 { + export interface Props { + readonly version: 1 + readonly vendor: string + readonly validate: ( + value: unknown, + ) => Result | Promise> + readonly types?: Types | undefined + } + export type Result = SuccessResult | FailureResult + export interface SuccessResult { + readonly value: Output + readonly issues?: undefined + } + export interface FailureResult { + readonly issues: ReadonlyArray + } + export interface Issue { + readonly message: string + readonly path?: ReadonlyArray | undefined + } + export interface PathSegment { + readonly key: PropertyKey + } + export interface Types { + readonly input: Input + readonly output: Output + } + export type InferOutput = NonNullable< + Schema['~standard']['types'] + >['output'] +} diff --git a/spiceflow/src/static-node.test.ts b/spiceflow/src/static-node.test.ts new file mode 100644 index 00000000..b49ea37d --- /dev/null +++ b/spiceflow/src/static-node.test.ts @@ -0,0 +1,236 @@ +// Tests node static file serving and its priority relative to routed handlers. +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { basename, join } from 'node:path' +import { tmpdir } from 'node:os' +import { test, expect } from 'vitest' + +import { Spiceflow } from './spiceflow.js' +import { serveStatic } from './static-node.js' + +test('directory without index falls through instead of throwing EISDIR', async () => { + const root = await createStaticRoot({ + docs: null, + }) + + try { + const app = new Spiceflow() + .use(serveStatic({ root })) + .get('/*', () => ({ route: 'catch-all' })) + + const res = await app.handle(new Request('http://localhost/docs')) + + expect(res.status).toBe(200) + expect(await res.json()).toMatchInlineSnapshot(` + { + "route": "catch-all", + } + `) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('concrete route wins over static file with the same path', async () => { + const root = await createStaticRoot({ + hello: 'from static', + }) + + try { + const app = new Spiceflow() + .use(serveStatic({ root })) + .get('/hello', () => ({ route: 'handler' })) + .get('/*', () => ({ route: 'catch-all' })) + + const res = await app.handle(new Request('http://localhost/hello')) + + expect(res.status).toBe(200) + expect(await res.json()).toMatchInlineSnapshot(` + { + "route": "handler", + } + `) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('static file beats root catch-all route', async () => { + const root = await createStaticRoot({ + 'logo.txt': 'from static', + }) + + try { + const app = new Spiceflow() + .use(serveStatic({ root })) + .get('/*', () => ({ route: 'catch-all' })) + + const res = await app.handle(new Request('http://localhost/logo.txt')) + + expect(res.status).toBe(200) + expect(await res.text()).toMatchInlineSnapshot(`"from static"`) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('relative static root serves nested asset paths', async () => { + const root = await createStaticRoot({ + 'assets/logo.txt': 'from static', + }, process.cwd()) + + const relativeRoot = basename(root) + + try { + const app = new Spiceflow() + .use(serveStatic({ root: relativeRoot })) + .get('/*', () => ({ route: 'catch-all' })) + + const res = await app.handle(new Request('http://localhost/assets/logo.txt')) + + expect(res.status).toBe(200) + expect(await res.text()).toMatchInlineSnapshot(`"from static"`) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +async function createStaticRoot( + files: Record, + parentDir = tmpdir(), +) { + const root = await mkdtemp(join(parentDir, 'spiceflow-static-')) + + for (const [relativePath, contents] of Object.entries(files)) { + const fullPath = join(root, relativePath) + await mkdir(join(fullPath, '..'), { recursive: true }) + + if (contents === null) { + await mkdir(fullPath, { recursive: true }) + continue + } + + await writeFile(fullPath, contents) + } + + return root +} + +test('HEAD serves static headers without a body', async () => { + const root = await createStaticRoot({ + 'logo.txt': 'from static', + }) + + try { + const app = new Spiceflow().use(serveStatic({ root })) + + const res = await app.handle( + new Request('http://localhost/logo.txt', { method: 'HEAD' }), + ) + + expect(res.status).toBe(200) + expect(res.headers.get('content-length')).toBe('11') + expect(await res.text()).toBe('') + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('static response includes mime type headers', async () => { + const root = await createStaticRoot({ + 'assets/app.js': 'console.log("ok")', + }, process.cwd()) + + try { + const app = new Spiceflow().use(serveStatic({ root: basename(root) })) + + const res = await app.handle(new Request('http://localhost/assets/app.js')) + + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('text/javascript; charset=utf-8') + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('decoded URI paths resolve to matching static files', async () => { + const root = await createStaticRoot({ + '炎.txt': 'unicode file', + }) + + try { + const app = new Spiceflow().use(serveStatic({ root })) + + const res = await app.handle(new Request('http://localhost/%E7%82%8E.txt')) + + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8') + expect(await res.text()).toMatchInlineSnapshot(`"unicode file"`) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('encoded backslash traversal falls through instead of serving files', async () => { + const root = await createStaticRoot({ + 'hello.txt': 'from static', + }) + + try { + const app = new Spiceflow() + .use(serveStatic({ root })) + .get('/*', () => ({ route: 'catch-all' })) + + const res = await app.handle( + new Request('http://localhost/%2e%2e%5Chello.txt'), + ) + + expect(res.status).toBe(200) + expect(await res.json()).toMatchInlineSnapshot(` + { + "route": "catch-all", + } + `) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +test('onFound and onNotFound callbacks receive resolved asset paths', async () => { + const root = await createStaticRoot({ + 'hello.txt': 'from static', + }) + const found: string[] = [] + const missing: string[] = [] + + try { + const app = new Spiceflow() + .use( + serveStatic({ + root, + onFound(path) { + found.push(path) + }, + onNotFound(path) { + missing.push(path) + }, + }), + ) + .get('/*', () => ({ route: 'catch-all' })) + + await app.handle(new Request('http://localhost/hello.txt')) + await app.handle(new Request('http://localhost/missing.txt')) + + expect({ found, missing }).toMatchInlineSnapshot(` + { + "found": [ + "${root}/hello.txt", + ], + "missing": [ + "${root}/missing.txt", + ], + } + `) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/spiceflow/src/static-node.ts b/spiceflow/src/static-node.ts index 3e310e10..cae2d795 100644 --- a/spiceflow/src/static-node.ts +++ b/spiceflow/src/static-node.ts @@ -1,38 +1,97 @@ -import { stat } from 'fs/promises' -import fs from 'fs' -import { ServeStaticOptions, serveStatic as baseServeStatic } from './static.ts' -import { MiddlewareHandler } from './types.ts' +// Node adapter for serveStatic with safe file lookups and streamed responses. +// Uses a lazy cache for stat results so repeated requests to the same path +// (e.g. /about hitting serveStatic before falling through to RSC) skip +// filesystem syscalls entirely after the first lookup. +import { createReadStream, statSync, type Stats } from 'node:fs' +import { Readable } from 'node:stream' +import { resolve } from 'node:path' +import { + getMimeType, + ServeStaticOptions, + serveStatic as baseServeStatic, + staticMiddlewareSymbol, +} from './static.js' +import { MiddlewareHandler } from './types.js' + +type CachedStat = + | { kind: 'file'; size: number; mtime: string } + | { kind: 'dir' } + | { kind: 'miss' } export const serveStatic = (options: ServeStaticOptions): MiddlewareHandler => { - const getContent = (path: string) => { - path = `./${path}` + const root = resolve(options.root ?? '.') + const cache = options.noCache ? null : new Map() + + function cachedStat(path: string): CachedStat { + if (cache) { + const cached = cache.get(path) + if (cached) return cached + } + + let result: CachedStat try { - return fs.readFileSync(path) - } catch (err: any) { - if (err.code !== 'ENOENT') { - throw err + const stats = statSync(path) + if (stats.isFile()) { + result = { kind: 'file', size: stats.size, mtime: stats.mtime.toUTCString() } + } else if (stats.isDirectory()) { + result = { kind: 'dir' } + } else { + result = { kind: 'miss' } } + } catch (err: any) { + if (!isIgnorableStaticError(err)) throw err + result = { kind: 'miss' } + } + + cache?.set(path, result) + return result + } + + const getContent = (path: string, c: { request?: Request }) => { + const stat = cachedStat(path) + if (stat.kind !== 'file') return null + + const headers = new Headers({ + 'content-length': String(stat.size), + 'last-modified': stat.mtime, + }) + const mimeType = getMimeType(path, options.mimes) + if (mimeType) { + headers.set('content-type', mimeType) } - return null + + if (c.request?.method === 'HEAD') { + return new Response(null, { headers }) + } + + const stream = Readable.toWeb(createReadStream(path)) as unknown as ReadableStream + return new Response(stream, { headers }) } + const pathResolve = (path: string) => { - return `./${path}` + return resolve(path) } + const isDir = (path: string) => { - let isDir - try { - const stats = fs.statSync(path) - isDir = stats.isDirectory() - } catch {} - return isDir + return cachedStat(path).kind === 'dir' } + const m = baseServeStatic({ ...options, getContent, pathResolve, isDir, }) - return function serveStatic(c, next) { + + const middleware = function serveStatic(c, next) { return m(c, next) } + + ;(middleware as any)[staticMiddlewareSymbol] = true + return middleware +} + +function isIgnorableStaticError(err: unknown) { + const code = (err as { code?: string } | undefined)?.code + return code === 'ENOENT' || code === 'ENOTDIR' || code === 'EISDIR' } diff --git a/spiceflow/src/static.benchmark.ts b/spiceflow/src/static.benchmark.ts index 9b166e05..8cb2ea43 100644 --- a/spiceflow/src/static.benchmark.ts +++ b/spiceflow/src/static.benchmark.ts @@ -1,7 +1,7 @@ import { bench } from 'vitest' -import { Spiceflow } from './spiceflow.ts' -import { serveStatic } from './static-node.ts' +import { Spiceflow } from './spiceflow.js' +import { serveStatic } from './static-node.js' bench('Spiceflow static', async () => { const app = new Spiceflow() diff --git a/spiceflow/src/static.ts b/spiceflow/src/static.ts index e51c003d..b15f51cc 100644 --- a/spiceflow/src/static.ts +++ b/spiceflow/src/static.ts @@ -1,5 +1,6 @@ -import { MiddlewareHandler } from './types.ts' -import { isResponse } from './utils.ts' +// Static file path resolution shared by environment-specific adapters. +import { MiddlewareHandler } from './types.js' +import { isResponse } from './utils.js' type Env = {} type Context = {} @@ -7,15 +8,29 @@ type Data = any export type ServeStaticOptions = { root?: string - - // path?: string + path?: string mimes?: Record - // rewriteRequestPath?: (path: string) => string + rewriteRequestPath?: (path: string) => string + onFound?: (path: string, c: Context) => void | Promise onNotFound?: (path: string, c: Context) => void | Promise + /** Disable stat result caching. When false (default), filesystem lookups are + * cached after the first access so repeated requests to the same path skip + * syscalls entirely. Set to true in environments where files change at runtime. */ + noCache?: boolean } const DEFAULT_DOCUMENT = 'index.html' const defaultPathResolve = (path: string) => path +export const staticMiddlewareSymbol = Symbol.for('spiceflow.serve-static') + +export function isStaticMiddleware(handler: unknown): boolean { + return Boolean((handler as any)?.[staticMiddlewareSymbol]) +} + +function markStaticMiddleware(handler: T): T { + ;(handler as any)[staticMiddlewareSymbol] = true + return handler +} /** * This middleware is not directly used by the user. Create a wrapper specifying `getContent()` by the environment such as Deno or Bun. @@ -30,76 +45,85 @@ export const serveStatic = ( isDir?: (path: string) => boolean | undefined | Promise }, ): MiddlewareHandler => { - return async (c, next) => { - let filename = decodeURI(new URL(c.request.url).pathname) - // filename = options.rewriteRequestPath - // ? options.rewriteRequestPath(filename) - // : filename - const root = options.root - - // If it was Directory, force `/` on the end. - if (!filename.endsWith('/') && options.isDir) { - const path = getFilePathWithoutDefaultDocument({ - filename, - root, - }) - if (path && (await options.isDir(path))) { - filename = filename + '/' - } + return markStaticMiddleware(async (c, next) => { + if (c.request.method !== 'GET' && c.request.method !== 'HEAD') { + return await next() } - let path = getFilePath({ - filename, - root, - defaultDocument: DEFAULT_DOCUMENT, - }) + const root = options.root + const pathResolve = options.pathResolve ?? defaultPathResolve + const resolvedPath = getResolvedFilePath({ c, options }) - if (!path) { + if (!resolvedPath) { + await options.onNotFound?.(new URL(c.request.url).pathname, c) return await next() } - const getContent = options.getContent - const pathResolve = options.pathResolve ?? defaultPathResolve + const exactPath = pathResolve(resolvedPath) + const directory = options.isDir ? await options.isDir(exactPath) : false + const candidatePath = directory + ? pathResolve( + getFilePathWithoutDefaultDocument({ + filename: appendTrailingSlash(resolvedPath) + DEFAULT_DOCUMENT, + root, + })!, + ) + : exactPath - path = pathResolve(path) - let content = await getContent(path, c) - - if (!content) { - let pathWithOutDefaultDocument = getFilePathWithoutDefaultDocument({ - filename, - root, - }) - if (!pathWithOutDefaultDocument) { - return await next() - } - pathWithOutDefaultDocument = pathResolve(pathWithOutDefaultDocument) - - if (pathWithOutDefaultDocument !== path) { - content = await getContent(pathWithOutDefaultDocument, c) - if (content) { - path = pathWithOutDefaultDocument - } - } - } + const content = await options.getContent(candidatePath, c) if (isResponse(content)) { + await options.onFound?.(candidatePath, c) return content } if (content) { - let mimeType: string | undefined - mimeType = getMimeType(path, options.mimes) - let response = new Response(content) - if (mimeType) { - response.headers.set('Content-Type', mimeType) - } + const mimeType = getMimeType(candidatePath, options.mimes) + const response = new Response(content) + response.headers.set('Content-Type', mimeType || 'application/octet-stream') + await options.onFound?.(candidatePath, c) return response } - await options.onNotFound?.(path, c) + await options.onNotFound?.(candidatePath, c) await next() return + }) +} + +function getResolvedFilePath({ + c, + options, +}: { + c: Context & { request: Request } + options: ServeStaticOptions +}) { + let filename = options.path + + if (!filename) { + try { + filename = decodeURI(new URL(c.request.url).pathname) + } catch { + return + } + } + + if (!options.path && options.rewriteRequestPath) { + filename = options.rewriteRequestPath(filename) } + + return getFilePathWithoutDefaultDocument({ + filename, + root: options.root, + }) +} + +function appendTrailingSlash(path: string) { + if (path.endsWith('/')) { + return path + } + + return path + '/' } const baseMimes: Record = { @@ -228,14 +252,20 @@ export const getFilePathWithoutDefaultDocument = ( filename = filename.replace(/^\.?[\/\\]/, '') // foo\bar.txt => foo/bar.txt - filename = filename.replace(/\\/, '/') + filename = filename.replace(/\\/g, '/') // assets/ => assets root = root.replace(/\/$/, '') // ./assets/foo.html => assets/foo.html let path = root ? root + '/' + filename : filename - path = path.replace(/^\.?\//, '') + if (!isAbsolutePath(root)) { + path = path.replace(/^\.?\//, '') + } return path } + +function isAbsolutePath(path: string) { + return /^(?:[A-Za-z]:)?\//.test(path) +} diff --git a/spiceflow/src/stream.test.ts b/spiceflow/src/stream.test.ts index 125a4529..19c3f860 100644 --- a/spiceflow/src/stream.test.ts +++ b/spiceflow/src/stream.test.ts @@ -2,9 +2,9 @@ import { describe, it, expect } from 'vitest' import { createParser } from 'eventsource-parser' -import { Spiceflow } from './spiceflow.ts' +import { Spiceflow } from './spiceflow.js' -import { req, sleep } from './utils.ts' +import { req, sleep } from './utils.js' function textEventStream(items: string[]) { return items @@ -96,35 +96,6 @@ describe('Stream', () => { expect(response).toContain('an error') }) - it.todo('handle errors before yield when aot is true', async () => { - const app = new Spiceflow() - .onError(({ error }) => { - return new Response(error.message) - }) - .get('/', async function* () { - throw new Error('an error') - }) - - const response = await app.handle(req('/')).then((x) => x.text()) - - expect(response).toContain('an error') - }) - - it.todo('handle errors before yield with onError', async () => { - const expected = 'error expected' - const app = new Spiceflow() - .onError(({}) => { - return new Response(expected) - }) - .get('/', async function* () { - throw new Error('an error') - }) - - const response = await app.handle(req('/')).then((x) => x.text()) - - expect(response).toBe(expected) - }) - it('stop stream on canceled request', async () => { const expected = ['a', 'b'] @@ -483,4 +454,31 @@ describe('Stream', () => { // Should not throw an error for abort expect(streamError).toBeUndefined() }) + + it('does not eagerly drain generator ahead of consumer (backpressure)', async () => { + let nextCallCount = 0 + + async function* lazyGenerator() { + for (let i = 0; i < 50; i++) { + nextCallCount++ + yield `chunk-${i}` + } + } + + const app = new Spiceflow().get('/', lazyGenerator) + const response = await app.handle(req('/')) + const reader = response.body!.getReader() + + // Read only the first 3 chunks (plus possible init value) + await reader.read() + await reader.read() + await reader.read() + + // With pull()-based backpressure the generator should not have + // been advanced far beyond what was consumed. init pulls 1, then + // 3 reads pull ~3 more. Allow a small buffer for prefetch. + expect(nextCallCount).toBeLessThanOrEqual(6) + + await reader.cancel() + }) }) diff --git a/spiceflow/src/trie-router/node.test.ts b/spiceflow/src/trie-router/node.test.ts new file mode 100644 index 00000000..af7c3715 --- /dev/null +++ b/spiceflow/src/trie-router/node.test.ts @@ -0,0 +1,937 @@ +import { describe, it, expect, test } from 'vitest' + +import { Node } from './node.js' +import { getQueryParams } from './url.js' + +describe('Root Node', () => { + const node = new Node() + node.insert('get', '/', 'get root') + it('get /', () => { + const [res] = node.search('get', '/') + expect(res).not.toBeNull() + expect(res[0][0]).toEqual('get root') + expect(node.search('get', '/hello')[0].length).toBe(0) + }) +}) + +describe('Layout routes with wildcards', () => { + const node = new Node() + // Add root and wildcard layouts + node.insert('get', '/', 'root layout') + node.insert('get', '/*', 'catch-all layout') + // Add /layout routes with wildcards + node.insert('get', '/layout', 'layout base') + node.insert('get', '/layout/*', 'layout wildcard') + node.insert('get', '/layout/*/page', 'layout nested page') + node.insert('get', '/layout/*/deep/*', 'layout deep wildcard') + + it('matches multiple layout routes with wildcards', () => { + expect(node.search('get', '/layout')).toMatchInlineSnapshot(` + [ + [ + [ + "catch-all layout", + {}, + ], + [ + "layout base", + {}, + ], + [ + "layout wildcard", + {}, + ], + ], + ] + `) + + expect(node.search('get', '/layout/something')).toMatchInlineSnapshot(` + [ + [ + [ + "catch-all layout", + {}, + ], + [ + "layout wildcard", + {}, + ], + ], + ] + `) + + expect(node.search('get', '/layout/foo/page')).toMatchInlineSnapshot(` + [ + [ + [ + "catch-all layout", + {}, + ], + [ + "layout wildcard", + {}, + ], + [ + "layout nested page", + { + "undefined": undefined, + }, + ], + ], + ] + `) + + expect(node.search('get', '/layout/foo/deep/bar')).toMatchInlineSnapshot(` + [ + [ + [ + "catch-all layout", + {}, + ], + [ + "layout wildcard", + {}, + ], + [ + "layout deep wildcard", + { + "undefined": undefined, + }, + ], + ], + ] + `) + }) + + it('does not create an undefined param key for bare wildcards', () => { + const [res] = node.search('get', '/layout/foo/page') + expect(res[2][1]).toEqual({}) + }) +}) + +test('nothing matches', () => { + const node = new Node() + node.insert('get', '/hello', 'get hello') + node.insert('post', '/hello', 'post hello') + node.insert('get', '/hello/foo', 'get hello foo') + + expect(node.search('get', '/nothing')).toMatchInlineSnapshot(` + [ + [], + ] + `) +}) + +describe('Root Node is not defined', () => { + const node = new Node() + node.insert('get', '/hello', 'get hello') + it('get /', () => { + expect(node.search('get', '/')[0]).toEqual([]) + }) +}) + +describe('Get with *', () => { + const node = new Node() + node.insert('get', '*', 'get all') + it('get /', () => { + expect(node.search('get', '/')[0].length).toBe(1) + expect(node.search('get', '/hello')[0].length).toBe(1) + }) +}) + +describe('Get with * including JS reserved words', () => { + const node = new Node() + node.insert('get', '*', 'get all') + it('get /', () => { + expect(node.search('get', '/hello/constructor')[0].length).toBe(1) + expect(node.search('get', '/hello/__proto__')[0].length).toBe(1) + }) +}) + +describe('Basic Usage', () => { + const node = new Node() + node.insert('get', '/hello', 'get hello') + node.insert('post', '/hello', 'post hello') + node.insert('get', '/hello/foo', 'get hello foo') + + it('get, post /hello', () => { + expect(node.search('get', '/')[0].length).toBe(0) + expect(node.search('post', '/')[0].length).toBe(0) + + expect(node.search('get', '/hello')[0][0][0]).toEqual('get hello') + expect(node.search('post', '/hello')[0][0][0]).toEqual('post hello') + expect(node.search('put', '/hello')[0].length).toBe(0) + }) + it('get /nothing', () => { + expect(node.search('get', '/nothing')[0].length).toBe(0) + }) + it('/hello/foo, /hello/bar', () => { + expect(node.search('get', '/hello/foo')[0][0][0]).toEqual('get hello foo') + expect(node.search('post', '/hello/foo')[0].length).toBe(0) + expect(node.search('get', '/hello/bar')[0].length).toBe(0) + }) + it('/hello/foo/bar', () => { + expect(node.search('get', '/hello/foo/bar')[0].length).toBe(0) + }) +}) + +describe('Name path', () => { + const node = new Node() + node.insert('get', '/entry/:id', 'get entry') + node.insert('get', '/entry/:id/comment/:comment_id', 'get comment') + node.insert('get', '/map/:location/events', 'get events') + node.insert('get', '/about/:name/address/map', 'get address') + + it('get /entry/123', () => { + const [res] = node.search('get', '/entry/123') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('get entry') + expect(res[0][1]).not.toBeNull() + expect(res[0][1]['id']).toBe('123') + expect(res[0][1]['id']).not.toBe('1234') + }) + + it('get /entry/456/comment', () => { + const [res] = node.search('get', '/entry/456/comment') + expect(res.length).toBe(0) + }) + + it('get /entry/789/comment/123', () => { + const [res] = node.search('get', '/entry/789/comment/123') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('get comment') + expect(res[0][1]['id']).toBe('789') + expect(res[0][1]['comment_id']).toBe('123') + }) + + it('get /map/:location/events', () => { + const [res] = node.search('get', '/map/yokohama/events') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('get events') + expect(res[0][1]['location']).toBe('yokohama') + }) + + it('get /about/:name/address/map', () => { + const [res] = node.search('get', '/about/foo/address/map') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('get address') + expect(res[0][1]['name']).toBe('foo') + }) + + it('Should not return a previous param value', () => { + const node = new Node() + node.insert('delete', '/resource/:id', 'resource') + const [resA] = node.search('delete', '/resource/a') + const [resB] = node.search('delete', '/resource/b') + expect(resA).not.toBeNull() + expect(resA.length).toBe(1) + expect(resA[0][0]).toEqual('resource') + expect(resA[0][1]).toEqual({ id: 'a' }) + expect(resB).not.toBeNull() + expect(resB.length).toBe(1) + expect(resB[0][0]).toEqual('resource') + expect(resB[0][1]).toEqual({ id: 'b' }) + }) + + it('Should return a sorted values', () => { + const node = new Node() + node.insert('get', '/resource/a', 'A') + node.insert('get', '/resource/*', 'Star') + const all = node.search('get', '/resource/a') + const [res] = all + expect(res).not.toBeNull() + expect(res.length).toBe(2) + + expect(all).toMatchInlineSnapshot(` + [ + [ + [ + "A", + {}, + ], + [ + "Star", + {}, + ], + ], + ] + `) + expect(res[0][0]).toEqual('A') + expect(res[1][0]).toEqual('Star') + }) +}) + +describe('Name path - Multiple route', () => { + const node = new Node() + + node.insert('get', '/:type/:id', 'common') + node.insert('get', '/posts/:id', 'specialized') + + it('get /posts/123', () => { + const [res] = node.search('get', '/posts/123') + expect(res.length).toBe(2) + expect(res[0][0]).toEqual('common') + expect(res[0][1]['id']).toBe('123') + expect(res[1][0]).toEqual('specialized') + expect(res[1][1]['id']).toBe('123') + }) +}) + +describe('Param prefix', () => { + const node = new Node() + + node.insert('get', '/:foo', 'onepart') + node.insert('get', '/:bar/:baz', 'twopart') + + it('get /hello', () => { + const [res] = node.search('get', '/hello') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('onepart') + expect(res[0][1]['foo']).toBe('hello') + }) + + it('get /hello/world', () => { + const [res] = node.search('get', '/hello/world') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('twopart') + expect(res[0][1]['bar']).toBe('hello') + expect(res[0][1]['baz']).toBe('world') + }) +}) + +describe('Named params and a wildcard', () => { + const node = new Node() + + node.insert('get', '/:id/*', 'onepart') + + it('get /', () => { + const [res] = node.search('get', '/') + expect(res.length).toBe(0) + }) + + it('get /foo', () => { + const [res] = node.search('get', '/foo') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('onepart') + expect(res[0][1]['id']).toEqual('foo') + }) + + it('get /foo/bar', () => { + const [res] = node.search('get', '/foo/bar') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('onepart') + expect(res[0][1]['id']).toEqual('foo') + }) +}) + +describe('Wildcard', () => { + const node = new Node() + node.insert('get', '/wildcard-abc/*/wildcard-efg', 'wildcard') + it('/wildcard-abc/xxxxxx/wildcard-efg', () => { + const [res] = node.search('get', '/wildcard-abc/xxxxxx/wildcard-efg') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('wildcard') + }) + node.insert('get', '/wildcard-abc/*/wildcard-efg/hijk', 'wildcard') + it('/wildcard-abc/xxxxxx/wildcard-efg/hijk', () => { + const [res] = node.search('get', '/wildcard-abc/xxxxxx/wildcard-efg/hijk') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('wildcard') + }) +}) + +describe('Regexp', () => { + const node = new Node() + node.insert( + 'get', + '/regex-abc/:id{[0-9]+}/comment/:comment_id{[a-z]+}', + 'regexp', + ) + it('/regexp-abc/123/comment/abc', () => { + const [res] = node.search('get', '/regex-abc/123/comment/abc') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('regexp') + expect(res[0][1]['id']).toBe('123') + expect(res[0][1]['comment_id']).toBe('abc') + }) + it('/regexp-abc/abc', () => { + const [res] = node.search('get', '/regex-abc/abc') + expect(res.length).toBe(0) + }) + it('/regexp-abc/123/comment/123', () => { + const [res] = node.search('get', '/regex-abc/123/comment/123') + expect(res.length).toBe(0) + }) +}) + +describe('All', () => { + const node = new Node() + node.insert('ALL', '/all-methods', 'all methods') // ALL + it('/all-methods', () => { + let [res] = node.search('get', '/all-methods') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('all methods') + ;[res] = node.search('put', '/all-methods') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('all methods') + }) +}) + +describe('Special Wildcard', () => { + const node = new Node() + node.insert('ALL', '*', 'match all') + + it('/foo', () => { + const [res] = node.search('get', '/foo') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('match all') + }) + it('/hello', () => { + const [res] = node.search('get', '/hello') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('match all') + }) + it('/hello/foo', () => { + const [res] = node.search('get', '/hello/foo') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('match all') + }) +}) + +describe('Special Wildcard deeply', () => { + const node = new Node() + node.insert('ALL', '/hello/*', 'match hello') + it('/hello', () => { + const [res] = node.search('get', '/hello') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('match hello') + }) + it('/hello/foo', () => { + const [res] = node.search('get', '/hello/foo') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('match hello') + }) +}) + +describe('Default with wildcard', () => { + const node = new Node() + node.insert('ALL', '/api/*', 'fallback') + node.insert('ALL', '/api/abc', 'match api') + it('/api/abc', () => { + const [res] = node.search('get', '/api/abc') + expect(res.length).toBe(2) + expect(res[0][0]).toEqual('fallback') + expect(res[1][0]).toEqual('match api') + }) + it('/api/def', () => { + const [res] = node.search('get', '/api/def') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('fallback') + }) +}) + +describe('Multi match', () => { + describe('Basic', () => { + const node = new Node() + node.insert('get', '*', 'GET *') + node.insert('get', '/abc/*', 'GET /abc/*') + node.insert('get', '/abc/*/edf', 'GET /abc/*/edf') + node.insert('get', '/abc/edf', 'GET /abc/edf') + node.insert('get', '/abc/*/ghi/jkl', 'GET /abc/*/ghi/jkl') + it('get /abc/edf', () => { + const [res] = node.search('get', '/abc/edf') + expect(res.length).toBe(3) + expect(res[0][0]).toEqual('GET *') + expect(res[1][0]).toEqual('GET /abc/*') + expect(res[2][0]).toEqual('GET /abc/edf') + }) + it('get /abc/xxx/edf', () => { + const [res] = node.search('get', '/abc/xxx/edf') + expect(res.length).toBe(3) + expect(res[0][0]).toEqual('GET *') + expect(res[1][0]).toEqual('GET /abc/*') + expect(res[2][0]).toEqual('GET /abc/*/edf') + }) + it('get /', () => { + const [res] = node.search('get', '/') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('GET *') + }) + it('post /', () => { + const [res] = node.search('post', '/') + expect(res.length).toBe(0) + }) + it('get /abc/edf/ghi', () => { + const [res] = node.search('get', '/abc/edf/ghi') + expect(res.length).toBe(2) + expect(res[0][0]).toEqual('GET *') + expect(res[1][0]).toEqual('GET /abc/*') + }) + }) + describe('Blog', () => { + const node = new Node() + node.insert('get', '*', 'middleware a') // 0.1 + node.insert('ALL', '*', 'middleware b') // 0.2 <=== + node.insert('get', '/entry', 'get entries') // 1.3 + node.insert('post', '/entry/*', 'middleware c') // 1.4 <=== + node.insert('post', '/entry', 'post entry') // 1.5 <=== + node.insert('get', '/entry/:id', 'get entry') // 2.6 + node.insert('get', '/entry/:id/comment/:comment_id', 'get comment') // 4.7 + it('get /entry/123', async () => { + const [res] = node.search('get', '/entry/123') + expect(res.length).toBe(3) + expect(res[0][0]).toEqual('middleware a') + expect(res[0][1]['id']).toBe(undefined) + expect(res[1][0]).toEqual('middleware b') + expect(res[1][1]['id']).toBe(undefined) + expect(res[2][0]).toEqual('get entry') + expect(res[2][1]['id']).toBe('123') + }) + it('get /entry/123/comment/456', async () => { + const [res] = node.search('get', '/entry/123/comment/456') + expect(res.length).toBe(3) + expect(res[0][0]).toEqual('middleware a') + expect(res[0][1]['id']).toBe(undefined) + expect(res[0][1]['comment_id']).toBe(undefined) + expect(res[1][0]).toEqual('middleware b') + expect(res[1][1]['id']).toBe(undefined) + expect(res[1][1]['comment_id']).toBe(undefined) + expect(res[2][0]).toEqual('get comment') + expect(res[2][1]['id']).toBe('123') + expect(res[2][1]['comment_id']).toBe('456') + }) + it('post /entry', async () => { + const [res] = node.search('post', '/entry') + expect(res.length).toBe(3) + expect(res[0][0]).toEqual('middleware b') + expect(res[1][0]).toEqual('middleware c') + expect(res[2][0]).toEqual('post entry') + }) + it('delete /entry', async () => { + const [res] = node.search('delete', '/entry') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('middleware b') + }) + }) + describe('ALL', () => { + const node = new Node() + node.insert('ALL', '*', 'ALL *') + node.insert('ALL', '/abc/*', 'ALL /abc/*') + node.insert('ALL', '/abc/*/def', 'ALL /abc/*/def') + it('get /', () => { + const [res] = node.search('get', '/') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('ALL *') + }) + it('post /abc', () => { + const [res] = node.search('post', '/abc') + expect(res.length).toBe(2) + expect(res[0][0]).toEqual('ALL *') + expect(res[1][0]).toEqual('ALL /abc/*') + }) + it('delete /abc/xxx/def', () => { + const [res] = node.search('post', '/abc/xxx/def') + expect(res.length).toBe(3) + expect(res[0][0]).toEqual('ALL *') + expect(res[1][0]).toEqual('ALL /abc/*') + expect(res[2][0]).toEqual('ALL /abc/*/def') + }) + }) + describe('Regexp', () => { + const node = new Node() + node.insert('get', '/regex-abc/:id{[0-9]+}/*', 'middleware a') + node.insert('get', '/regex-abc/:id{[0-9]+}/def', 'regexp') + it('/regexp-abc/123/def', () => { + const [res] = node.search('get', '/regex-abc/123/def') + expect(res.length).toBe(2) + expect(res[0][0]).toEqual('middleware a') + expect(res[0][1]['id']).toBe('123') + expect(res[1][0]).toEqual('regexp') + expect(res[1][1]['id']).toBe('123') + }) + it('/regexp-abc/123', () => { + const [res] = node.search('get', '/regex-abc/123/ghi') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('middleware a') + }) + }) + describe('Trailing slash', () => { + const node = new Node() + node.insert('get', '/book', 'GET /book') + node.insert('get', '/book/:id', 'GET /book/:id') + it('get /book', () => { + const [res] = node.search('get', '/book') + expect(res.length).toBe(1) + }) + it('get /book/', () => { + const [res] = node.search('get', '/book/') + expect(res.length).toBe(0) + }) + }) + describe('Same path', () => { + const node = new Node() + node.insert('get', '/hey', 'Middleware A') + node.insert('get', '/hey', 'Middleware B') + it('get /hey', () => { + const [res] = node.search('get', '/hey') + expect(res.length).toBe(2) + expect(res[0][0]).toEqual('Middleware A') + expect(res[1][0]).toEqual('Middleware B') + }) + }) + describe('Including slashes', () => { + const node = new Node() + node.insert('get', '/js/:filename{[a-z0-9/]+.js}', 'any file') + node.insert('get', '/js/main.js', 'main.js') + it('get /js/main.js', () => { + const [res] = node.search('get', '/js/main.js') + expect(res.length).toBe(2) + expect(res[0][0]).toEqual('any file') + expect(res[0][1]).toEqual({ filename: 'main.js' }) + expect(res[1][0]).toEqual('main.js') + expect(res[1][1]).toEqual({}) + }) + it('get /js/chunk/123.js', () => { + const [res] = node.search('get', '/js/chunk/123.js') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('any file') + expect(res[0][1]).toEqual({ filename: 'chunk/123.js' }) + }) + it('get /js/chunk/nest/123.js', () => { + const [res] = node.search('get', '/js/chunk/nest/123.js') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('any file') + expect(res[0][1]).toEqual({ filename: 'chunk/nest/123.js' }) + }) + }) + describe('REST API', () => { + const node = new Node() + node.insert('get', '/users/:username{[a-z]+}', 'profile') + node.insert('get', '/users/:username{[a-z]+}/posts', 'posts') + it('get /users/hono', () => { + const [res] = node.search('get', '/users/hono') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('profile') + }) + it('get /users/hono/posts', () => { + const [res] = node.search('get', '/users/hono/posts') + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('posts') + }) + }) +}) + +describe('Duplicate param name', () => { + it('self', () => { + const node = new Node() + node.insert('get', '/:id/:id', 'foo') + const [res] = node.search('get', '/123/456') + expect(res.length).toBe(1) + expect(res[0][0]).toBe('foo') + expect(res[0][1]['id']).toBe('123') + }) + + describe('parent', () => { + const node = new Node() + node.insert('get', '/:id/:action', 'foo') + node.insert('get', '/posts/:id', 'bar') + node.insert('get', '/posts/:id/comments/:comment_id', 'comment') + + it('get /123/action', () => { + const [res] = node.search('get', '/123/action') + expect(res.length).toBe(1) + expect(res[0][0]).toBe('foo') + expect(res[0][1]).toEqual({ id: '123', action: 'action' }) + }) + }) + + it('get /posts/456 for comments', () => { + const node = new Node() + node.insert('get', '/posts/:id/comments/:comment_id', 'comment') + const [res] = node.search('get', '/posts/abc/comments/edf') + expect(res.length).toBe(1) + expect(res[0][0]).toBe('comment') + expect(res[0][1]).toEqual({ id: 'abc', comment_id: 'edf' }) + }) + + describe('child', () => { + const node = new Node() + node.insert('get', '/posts/:id', 'foo') + node.insert('get', '/:id/:action', 'bar') + it('get /posts/action', () => { + const [res] = node.search('get', '/posts/action') + expect(res.length).toBe(2) + expect(res[0][0]).toBe('foo') + expect(res[0][1]).toEqual({ id: 'action' }) + expect(res[1][0]).toBe('bar') + expect(res[1][1]).toEqual({ id: 'posts', action: 'action' }) + }) + }) + + describe('regular expression', () => { + const node = new Node() + node.insert('get', '/:id/:action{create|update}', 'foo') + node.insert('get', '/:id/:action{delete}', 'bar') + it('get /123/create', () => { + const [res] = node.search('get', '/123/create') + expect(res.length).toBe(1) + expect(res[0][0]).toBe('foo') + expect(res[0][1]).toEqual({ id: '123', action: 'create' }) + }) + it('get /123/delete', () => { + const [res] = node.search('get', '/123/delete') + expect(res.length).toBe(1) + expect(res[0][0]).toBe('bar') + expect(res[0][1]).toEqual({ id: '123', action: 'delete' }) + }) + }) +}) + +describe('Sort Order', () => { + describe('Basic', () => { + const node = new Node() + node.insert('get', '*', 'a') + node.insert('get', '/page', '/page') + node.insert('get', '/:slug', '/:slug') + + it('get /page', () => { + const [res] = node.search('get', '/page') + expect(res.length).toBe(3) + expect(res[0][0]).toEqual('a') + expect(res[1][0]).toEqual('/page') + expect(res[2][0]).toEqual('/:slug') + }) + }) + + describe('With Named path', () => { + const node = new Node() + node.insert('get', '*', 'a') + node.insert('get', '/posts/:id', '/posts/:id') + node.insert('get', '/:type/:id', '/:type/:id') + + it('get /posts/123', () => { + const [res] = node.search('get', '/posts/123') + expect(res.length).toBe(3) + expect(res[0][0]).toEqual('a') + expect(res[1][0]).toEqual('/posts/:id') + expect(res[2][0]).toEqual('/:type/:id') + }) + }) + + describe('With Wildcards', () => { + const node = new Node() + node.insert('get', '/api/*', '1st') + node.insert('get', '/api/*', '2nd') + node.insert('get', '/api/posts/:id', '3rd') + node.insert('get', '/api/*', '4th') + + it('get /api/posts/123', () => { + const [res] = node.search('get', '/api/posts/123') + expect(res.length).toBe(4) + expect(res[0][0]).toEqual('1st') + expect(res[1][0]).toEqual('2nd') + expect(res[2][0]).toEqual('3rd') + expect(res[3][0]).toEqual('4th') + }) + }) + + describe('With special Wildcard', () => { + const node = new Node() + node.insert('get', '/posts', '/posts') // 1.1 + node.insert('get', '/posts/*', '/posts/*') // 1.2 + node.insert('get', '/posts/:id', '/posts/:id') // 2.3 + + it('get /posts', () => { + const [res] = node.search('get', '/posts') + expect(res.length).toBe(2) + expect(res[0][0]).toEqual('/posts') + expect(res[1][0]).toEqual('/posts/*') + }) + }) + + describe('Complex', () => { + const node = new Node() + node.insert('get', '/api', 'a') // not match + node.insert('get', '/api/*', 'b') // match + node.insert('get', '/api/:type', 'c') // not match + node.insert('get', '/api/:type/:id', 'd') // match + node.insert('get', '/api/posts/:id', 'e') // match + node.insert('get', '/api/posts/123', 'f') // match + node.insert('get', '/*/*/:id', 'g') // match + node.insert('get', '/api/posts/*/comment', 'h') // not match + node.insert('get', '*', 'i') // match + node.insert('get', '*', 'j') // match + + it('get /api/posts/123', () => { + const [res] = node.search('get', '/api/posts/123') + expect(res.length).toBe(7) + expect(res[0][0]).toEqual('b') + expect(res[1][0]).toEqual('d') + expect(res[2][0]).toEqual('e') + expect(res[3][0]).toEqual('f') + expect(res[4][0]).toEqual('g') + expect(res[5][0]).toEqual('i') + expect(res[6][0]).toEqual('j') + }) + }) + + describe('Multi match', () => { + const node = new Node() + node.insert('get', '*', 'GET *') // 0.1 + node.insert('get', '/abc/*', 'GET /abc/*') // 1.2 + node.insert('get', '/abc/edf', 'GET /abc/edf') // 2.3 + node.insert('get', '/abc/*/ghi/jkl', 'GET /abc/*/ghi/jkl') // 4.4 + it('get /abc/edf', () => { + const [res] = node.search('get', '/abc/edf') + expect(res.length).toBe(3) + expect(res[0][0]).toEqual('GET *') + expect(res[1][0]).toEqual('GET /abc/*') + expect(res[2][0]).toEqual('GET /abc/edf') + }) + }) + + describe('Multi match', () => { + const node = new Node() + + node.insert('get', '/api/*', 'a') // 2.1 for /api/entry + node.insert('get', '/api/entry', 'entry') // 2.2 + node.insert('ALL', '/api/*', 'b') // 2.3 for /api/entry + + it('get /api/entry', async () => { + const [res] = node.search('get', '/api/entry') + expect(res.length).toBe(3) + expect(res[0][0]).toEqual('a') + expect(res[1][0]).toEqual('entry') + expect(res[2][0]).toEqual('b') + }) + }) + + describe('fallback', () => { + describe('Blog - failed', () => { + const node = new Node() + node.insert('post', '/entry', 'post entry') // 1.1 + node.insert('post', '/entry/*', 'fallback') // 1.2 + node.insert('get', '/entry/:id', 'get entry') // 2.3 + it('post /entry', async () => { + const [res] = node.search('post', '/entry') + expect(res.length).toBe(2) + expect(res[0][0]).toEqual('post entry') + expect(res[1][0]).toEqual('fallback') + }) + }) + }) + describe('page', () => { + const node = new Node() + node.insert('get', '/page', 'page') // 1.1 + node.insert('ALL', '/*', 'fallback') // 1.2 + it('get /page', async () => { + const [res] = node.search('get', '/page') + expect(res.length).toBe(2) + expect(res[0][0]).toEqual('page') + expect(res[1][0]).toEqual('fallback') + }) + }) +}) + +describe('star', () => { + const node = new Node() + node.insert('get', '/', '/') + node.insert('get', '/*', '/*') + node.insert('get', '*', '*') + + node.insert('get', '/x', '/x') + node.insert('get', '/x/*', '/x/*') + + it('top', async () => { + const [res] = node.search('get', '/') + expect(res.length).toBe(3) + expect(res[0][0]).toEqual('/') + expect(res[1][0]).toEqual('/*') + expect(res[2][0]).toEqual('*') + }) + + it('Under a certain path', async () => { + const [res] = node.search('get', '/x') + expect(res.length).toBe(4) + expect(res[0][0]).toEqual('/*') + expect(res[1][0]).toEqual('*') + expect(res[2][0]).toEqual('/x') + expect(res[3][0]).toEqual('/x/*') + }) +}) + +describe('Routing order With named parameters', () => { + const node = new Node() + node.insert('get', '/book/a', 'no-slug') + node.insert('get', '/book/:slug', 'slug') + node.insert('get', '/book/b', 'no-slug-b') + it('/book/a', () => { + const [res] = node.search('get', '/book/a') + expect(res).not.toBeNull() + expect(res.length).toBe(2) + expect(res[0][0]).toEqual('no-slug') + expect(res[0][1]).toEqual({}) + expect(res[1][0]).toEqual('slug') + expect(res[1][1]).toEqual({ slug: 'a' }) + }) + it('/book/foo', () => { + const [res] = node.search('get', '/book/foo') + expect(res).not.toBeNull() + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('slug') + expect(res[0][1]).toEqual({ slug: 'foo' }) + expect(res[0][1]['slug']).toBe('foo') + }) + it('/book/b', () => { + const [res] = node.search('get', '/book/b') + expect(res).not.toBeNull() + expect(res.length).toBe(2) + expect(res[0][0]).toEqual('slug') + expect(res[0][1]).toEqual({ slug: 'b' }) + expect(res[1][0]).toEqual('no-slug-b') + expect(res[1][1]).toEqual({}) + }) +}) + +describe('The same name is used for path params', () => { + describe('Basic', () => { + const node = new Node() + node.insert('get', '/:a/:b/:c', 'abc') + node.insert('get', '/:a/:b/:c/:d', 'abcd') + it('/1/2/3', () => { + const [res] = node.search('get', '/1/2/3') + expect(res).not.toBeNull() + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('abc') + expect(res[0][1]).toEqual({ a: '1', b: '2', c: '3' }) + }) + }) + + describe('Complex', () => { + const node = new Node() + node.insert('get', '/:a', 'a') + node.insert('get', '/:b/:a', 'ba') + it('/about/me', () => { + const [res] = node.search('get', '/about/me') + expect(res).not.toBeNull() + expect(res.length).toBe(1) + expect(res[0][0]).toEqual('ba') + expect(res[0][1]).toEqual({ b: 'about', a: 'me' }) + }) + }) + + describe('Complex with tails', () => { + const node = new Node() + node.insert('get', '/:id/:id2/comments', 'a') + node.insert('get', '/posts/:id/comments', 'b') + it('/posts/123/comments', () => { + const [res] = node.search('get', '/posts/123/comments') + expect(res).not.toBeNull() + expect(res.length).toBe(2) + expect(res[0][0]).toEqual('a') + expect(res[0][1]).toEqual({ id: 'posts', id2: '123' }) + expect(res[1][0]).toEqual('b') + expect(res[1][1]).toEqual({ id: '123' }) + }) + }) +}) diff --git a/spiceflow/src/trie-router/node.ts b/spiceflow/src/trie-router/node.ts new file mode 100644 index 00000000..55d0638c --- /dev/null +++ b/spiceflow/src/trie-router/node.ts @@ -0,0 +1,246 @@ +// Trie router ported from Hono (https://github.com/honojs/hono) — MIT license +import { Pattern, splitRoutingPath, getPattern, splitPath } from './url.js' +import { Params } from './utils.js' + +const METHOD_NAME_ALL = 'ALL' + +type HandlerSet = { + handler: T + possibleKeys: string[] + score: number +} + +type HandlerParamsSet = HandlerSet & { + params: Record +} + +const emptyParams = Object.create(null) + +export class Node { + #methods: Record>[] + + #children: Record> + #patterns: Pattern[] + #order: number = 0 + #params: Record = emptyParams + + constructor( + method?: string, + handler?: T, + children?: Record>, + ) { + this.#children = children || Object.create(null) + this.#methods = [] + if (method && handler) { + const m: Record> = Object.create(null) + m[method] = { handler, possibleKeys: [], score: 0 } + this.#methods = [m] + } + this.#patterns = [] + } + + insert(method: string, path: string, handler: T): Node { + this.#order = ++this.#order + + // eslint-disable-next-line @typescript-eslint/no-this-alias + let curNode: Node = this + const parts = splitRoutingPath(path) + + const possibleKeys: string[] = [] + + for (let i = 0, len = parts.length; i < len; i++) { + const p: string = parts[i] + const nextP = parts[i + 1] + const pattern = getPattern(p, nextP) + const key = Array.isArray(pattern) ? pattern[0] : p + + if (Object.keys(curNode.#children).includes(key)) { + curNode = curNode.#children[key] + const pattern = getPattern(p, nextP) + if (pattern) { + possibleKeys.push(pattern[1]) + } + continue + } + + curNode.#children[key] = new Node() + + if (pattern) { + curNode.#patterns.push(pattern) + possibleKeys.push(pattern[1]) + } + curNode = curNode.#children[key] + } + + const m: Record> = Object.create(null) + + const handlerSet: HandlerSet = { + handler, + possibleKeys: possibleKeys.filter((v, i, a) => a.indexOf(v) === i), + score: this.#order, + } + + m[method] = handlerSet + curNode.#methods.push(m) + + return curNode + } + + #getHandlerSets( + node: Node, + method: string, + nodeParams: Record, + params?: Record, + ): HandlerParamsSet[] { + const handlerSets: HandlerParamsSet[] = [] + for (let i = 0, len = node.#methods.length; i < len; i++) { + const m = node.#methods[i] + const handlerSet = (m[method] || + m[METHOD_NAME_ALL]) as HandlerParamsSet + const processedSet: Record = {} + if (handlerSet !== undefined) { + handlerSet.params = Object.create(null) + handlerSets.push(handlerSet) + if (nodeParams !== emptyParams || (params && params !== emptyParams)) { + for (let i = 0, len = handlerSet.possibleKeys.length; i < len; i++) { + const key = handlerSet.possibleKeys[i] + const processed = processedSet[handlerSet.score] + handlerSet.params[key] = + params?.[key] && !processed + ? params[key] + : (nodeParams[key] ?? params?.[key]) + processedSet[handlerSet.score] = true + } + } + } + } + return handlerSets + } + + search(method: string, path: string): [[T, Params][]] { + const handlerSets: HandlerParamsSet[] = [] + this.#params = emptyParams + + // eslint-disable-next-line @typescript-eslint/no-this-alias + const curNode: Node = this + let curNodes = [curNode] + const parts = splitPath(path) + const curNodesQueue: Node[][] = [] + + for (let i = 0, len = parts.length; i < len; i++) { + const part: string = parts[i] + const isLast = i === len - 1 + const tempNodes: Node[] = [] + + for (let j = 0, len2 = curNodes.length; j < len2; j++) { + const node = curNodes[j] + const nextNode = node.#children[part] + + if (nextNode) { + nextNode.#params = node.#params + if (isLast) { + // '/hello/*' => match '/hello' + if (nextNode.#children['*']) { + handlerSets.push( + ...this.#getHandlerSets( + nextNode.#children['*'], + method, + node.#params, + ), + ) + } + handlerSets.push( + ...this.#getHandlerSets(nextNode, method, node.#params), + ) + } else { + tempNodes.push(nextNode) + } + } + + for (let k = 0, len3 = node.#patterns.length; k < len3; k++) { + const pattern = node.#patterns[k] + const params = node.#params === emptyParams ? {} : { ...node.#params } + + // Wildcard + // '/hello/*/foo' => match /hello/bar/foo + if (pattern === '*') { + const astNode = node.#children['*'] + if (astNode) { + handlerSets.push( + ...this.#getHandlerSets(astNode, method, node.#params), + ) + astNode.#params = params + tempNodes.push(astNode) + } + continue + } + + if (part === '') { + continue + } + + const [key, name, matcher] = pattern + + const child = node.#children[key] + + // `/js/:filename{[a-z]+.js}` => match /js/chunk/123.js + const restPathString = parts.slice(i).join('/') + if (matcher instanceof RegExp) { + const m = matcher.exec(restPathString) + if (m) { + params[name] = m[0] + handlerSets.push( + ...this.#getHandlerSets(child, method, node.#params, params), + ) + + if (Object.keys(child.#children).length) { + child.#params = params + const componentCount = m[0].match(/\//)?.length ?? 0 + const targetCurNodes = (curNodesQueue[componentCount] ||= []) + targetCurNodes.push(child) + } + + continue + } + } + + if (matcher === true || matcher.test(part)) { + params[name] = part + if (isLast) { + handlerSets.push( + ...this.#getHandlerSets(child, method, params, node.#params), + ) + if (child.#children['*']) { + handlerSets.push( + ...this.#getHandlerSets( + child.#children['*'], + method, + params, + node.#params, + ), + ) + } + } else { + child.#params = params + tempNodes.push(child) + } + } + } + } + + curNodes = tempNodes.concat(curNodesQueue.shift() ?? []) + } + + if (handlerSets.length > 1) { + handlerSets.sort((a, b) => { + return a.score - b.score + }) + } + + return [ + handlerSets.map( + ({ handler, params }) => [handler, params] as [T, Params], + ), + ] + } +} diff --git a/spiceflow/src/trie-router/router.ts b/spiceflow/src/trie-router/router.ts new file mode 100644 index 00000000..42f675af --- /dev/null +++ b/spiceflow/src/trie-router/router.ts @@ -0,0 +1,27 @@ +import { Node } from './node.js' +import { checkOptionalParameter, Result } from './utils.js' + +export class TrieRouter { + name: string = 'TrieRouter' + #node: Node + + constructor() { + this.#node = new Node() + } + + add(method: string, path: string, handler: T) { + const results = checkOptionalParameter(path) + if (results) { + for (let i = 0, len = results.length; i < len; i++) { + this.#node.insert(method, results[i], handler) + } + return + } + + this.#node.insert(method, path, handler) + } + + match(method: string, path: string): Result { + return this.#node.search(method, path) + } +} diff --git a/spiceflow/src/trie-router/url.ts b/spiceflow/src/trie-router/url.ts new file mode 100644 index 00000000..e41079df --- /dev/null +++ b/spiceflow/src/trie-router/url.ts @@ -0,0 +1,305 @@ +/** + * @module + * URL utility. + */ + +export type Pattern = readonly [string, string, RegExp | true] | '*' + +export const splitPath = (path: string): string[] => { + const paths = path.split('/') + if (paths[0] === '') { + paths.shift() + } + return paths +} + +export const splitRoutingPath = (routePath: string): string[] => { + const { groups, path } = extractGroupsFromPath(routePath) + + const paths = splitPath(path) + return replaceGroupMarks(paths, groups) +} + +const extractGroupsFromPath = ( + path: string, +): { groups: [string, string][]; path: string } => { + const groups: [string, string][] = [] + + path = path.replace(/\{[^}]+\}/g, (match, index) => { + const mark = `@${index}` + groups.push([mark, match]) + return mark + }) + + return { groups, path } +} + +const replaceGroupMarks = ( + paths: string[], + groups: [string, string][], +): string[] => { + for (let i = groups.length - 1; i >= 0; i--) { + const [mark] = groups[i] + + for (let j = paths.length - 1; j >= 0; j--) { + if (paths[j].includes(mark)) { + paths[j] = paths[j].replace(mark, groups[i][1]) + break + } + } + } + + return paths +} + +const patternCache: { [key: string]: Pattern } = {} +export const getPattern = (label: string, next?: string): Pattern | null => { + // * => wildcard + // :id{[0-9]+} => ([0-9]+) + // :id => (.+) + + if (label === '*') { + return '*' + } + + const match = label.match(/^\:([^\{\}]+)(?:\{(.+)\})?$/) + if (match) { + const cacheKey = `${label}#${next}` + if (!patternCache[cacheKey]) { + if (match[2]) { + patternCache[cacheKey] = + next && next[0] !== ':' && next[0] !== '*' + ? [cacheKey, match[1], new RegExp(`^${match[2]}(?=/${next})`)] + : [label, match[1], new RegExp(`^${match[2]}$`)] + } else { + patternCache[cacheKey] = [label, match[1], true] + } + } + + return patternCache[cacheKey] + } + + return null +} + +type Decoder = (str: string) => string +export const tryDecode = (str: string, decoder: Decoder): string => { + try { + return decoder(str) + } catch { + return str.replace(/(?:%[0-9A-Fa-f]{2})+/g, (match) => { + try { + return decoder(match) + } catch { + return match + } + }) + } +} + +/** + * Try to apply decodeURI() to given string. + * If it fails, skip invalid percent encoding or invalid UTF-8 sequences, and apply decodeURI() to the rest as much as possible. + * @param str The string to decode. + * @returns The decoded string that sometimes contains undecodable percent encoding. + * @example + * tryDecodeURI('Hello%20World') // 'Hello World' + * tryDecodeURI('Hello%20World/%A4%A2') // 'Hello World/%A4%A2' + */ +const tryDecodeURI = (str: string) => tryDecode(str, decodeURI) + +export const getPath = (request: Request): string => { + const url = request.url + const start = url.indexOf('/', 8) + let i = start + for (; i < url.length; i++) { + const charCode = url.charCodeAt(i) + if (charCode === 37) { + // '%' + // If the path contains percent encoding, use `indexOf()` to find '?' and return the result immediately. + // Although this is a performance disadvantage, it is acceptable since we prefer cases that do not include percent encoding. + const queryIndex = url.indexOf('?', i) + const path = url.slice(start, queryIndex === -1 ? undefined : queryIndex) + return tryDecodeURI( + path.includes('%25') ? path.replace(/%25/g, '%2525') : path, + ) + } else if (charCode === 63) { + // '?' + break + } + } + return url.slice(start, i) +} + +export const getQueryStrings = (url: string): string => { + const queryIndex = url.indexOf('?', 8) + return queryIndex === -1 ? '' : '?' + url.slice(queryIndex + 1) +} + +export const getPathNoStrict = (request: Request): string => { + const result = getPath(request) + + // if strict routing is false => `/hello/hey/` and `/hello/hey` are treated the same + return result.length > 1 && result.at(-1) === '/' + ? result.slice(0, -1) + : result +} + +export const mergePath = (...paths: string[]): string => { + let p: string = '' + let endsWithSlash = false + + for (let path of paths) { + /* ['/hey/','/say'] => ['/hey', '/say'] */ + if (p.at(-1) === '/') { + p = p.slice(0, -1) + endsWithSlash = true + } + + /* ['/hey','say'] => ['/hey', '/say'] */ + if (path[0] !== '/') { + path = `/${path}` + } + + /* ['/hey/', '/'] => `/hey/` */ + if (path === '/' && endsWithSlash) { + p = `${p}/` + } else if (path !== '/') { + p = `${p}${path}` + } + + /* ['/', '/'] => `/` */ + if (path === '/' && p === '') { + p = '/' + } + } + + return p +} + +// Optimized +const _decodeURI = (value: string) => { + if (!/[%+]/.test(value)) { + return value + } + if (value.indexOf('+') !== -1) { + value = value.replace(/\+/g, ' ') + } + return value.indexOf('%') !== -1 ? decodeURIComponent_(value) : value +} + +const _getQueryParam = ( + url: string, + key?: string, + multiple?: boolean, +): + | string + | undefined + | Record + | string[] + | Record => { + let encoded + + if (!multiple && key && !/[%+]/.test(key)) { + // optimized for unencoded key + + let keyIndex = url.indexOf(`?${key}`, 8) + if (keyIndex === -1) { + keyIndex = url.indexOf(`&${key}`, 8) + } + while (keyIndex !== -1) { + const trailingKeyCode = url.charCodeAt(keyIndex + key.length + 1) + if (trailingKeyCode === 61) { + const valueIndex = keyIndex + key.length + 2 + const endIndex = url.indexOf('&', valueIndex) + return _decodeURI( + url.slice(valueIndex, endIndex === -1 ? undefined : endIndex), + ) + } else if (trailingKeyCode == 38 || isNaN(trailingKeyCode)) { + return '' + } + keyIndex = url.indexOf(`&${key}`, keyIndex + 1) + } + + encoded = /[%+]/.test(url) + if (!encoded) { + return undefined + } + // fallback to default routine + } + + const results: Record | Record = {} + encoded ??= /[%+]/.test(url) + + let keyIndex = url.indexOf('?', 8) + while (keyIndex !== -1) { + const nextKeyIndex = url.indexOf('&', keyIndex + 1) + let valueIndex = url.indexOf('=', keyIndex) + if (valueIndex > nextKeyIndex && nextKeyIndex !== -1) { + valueIndex = -1 + } + let name = url.slice( + keyIndex + 1, + valueIndex === -1 + ? nextKeyIndex === -1 + ? undefined + : nextKeyIndex + : valueIndex, + ) + if (encoded) { + name = _decodeURI(name) + } + + keyIndex = nextKeyIndex + + if (name === '') { + continue + } + + let value + if (valueIndex === -1) { + value = '' + } else { + value = url.slice( + valueIndex + 1, + nextKeyIndex === -1 ? undefined : nextKeyIndex, + ) + if (encoded) { + value = _decodeURI(value) + } + } + + if (multiple) { + if (!(results[name] && Array.isArray(results[name]))) { + results[name] = [] + } + ;(results[name] as string[]).push(value) + } else { + results[name] ??= value + } + } + + return key ? results[key] : results +} + +export const getQueryParam: ( + url: string, + key?: string, +) => string | undefined | Record = _getQueryParam as ( + url: string, + key?: string, +) => string | undefined | Record + +export const getQueryParams = ( + url: string, + key?: string, +): string[] | undefined | Record => { + return _getQueryParam(url, key, true) as + | string[] + | undefined + | Record +} + +// `decodeURIComponent` is a long name. +// By making it a function, we can use it commonly when minified, reducing the amount of code. +export const decodeURIComponent_ = decodeURIComponent diff --git a/spiceflow/src/trie-router/utils.ts b/spiceflow/src/trie-router/utils.ts new file mode 100644 index 00000000..aaacc479 --- /dev/null +++ b/spiceflow/src/trie-router/utils.ts @@ -0,0 +1,69 @@ +export const checkOptionalParameter = (path: string): string[] | null => { + /* + If path is `/api/animals/:type?` it will return: + [`/api/animals`, `/api/animals/:type`] + in other cases it will return null + */ + + if (!path.match(/\:.+\?$/)) { + return null + } + + const segments = path.split('/') + const results: string[] = [] + let basePath = '' + + segments.forEach((segment) => { + if (segment !== '' && !/\:/.test(segment)) { + basePath += '/' + segment + } else if (/\:/.test(segment)) { + if (/\?/.test(segment)) { + if (results.length === 0 && basePath === '') { + results.push('/') + } else { + results.push(basePath) + } + const optionalSegment = segment.replace('?', '') + basePath += '/' + optionalSegment + results.push(basePath) + } else { + basePath += '/' + segment + } + } + }) + + return results.filter((v, i, a) => a.indexOf(v) === i) +} + +/** + * Type representing a map of parameter indices. + */ +export type ParamIndexMap = Record +/** + * Type representing a stash of parameters. + */ +export type ParamStash = string[] +/** + * Type representing a map of parameters. + */ +export type Params = Record +/** + * Type representing the result of a route match. + * + * The result can be in one of two formats: + * An array of handlers with their corresponding parameter maps. + * + * Example: + * + * [[handler, params][]] + * ```typescript + * [ + * [ + * [middlewareA, {}], // '*' + * [funcA, {'id': '123'}], // '/user/:id/*' + * [funcB, {'id': '123', 'action': 'abc'}], // '/user/:id/:action' + * ] + * ] + * ``` + */ +export type Result = [[T, Params][]] diff --git a/spiceflow/src/types.test.ts b/spiceflow/src/types.test.ts index 4b1bb0e8..1a1f87bb 100644 --- a/spiceflow/src/types.test.ts +++ b/spiceflow/src/types.test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest' -import { createSpiceflowClient } from './client/index.ts' -import { Spiceflow } from './spiceflow.ts' -import { Prettify } from './types.ts' +import { createSpiceflowClient } from './client/index.js' +import { Spiceflow } from './spiceflow.js' +import { Prettify } from './types.js' test('`use` on non Spiceflow return', async () => { function nonSpiceflowReturn() { diff --git a/spiceflow/src/types.ts b/spiceflow/src/types.ts index 72e0b69e..18fca44b 100644 --- a/spiceflow/src/types.ts +++ b/spiceflow/src/types.ts @@ -1,14 +1,20 @@ // https://github.com/remorses/elysia/blob/main/src/types.ts#L6 - -import { StandardSchemaV1 } from '@standard-schema/spec' +import { StandardSchemaV1 } from './standard-schema.js' import z from 'zod' import type { OpenAPIV3 } from 'openapi-types' import { ZodTypeAny } from 'zod' -import type { Context, ErrorContext, MiddlewareContext } from './context.ts' -import { SPICEFLOW_RESPONSE, ValidationError } from './error.ts' -import { AnySpiceflow, Spiceflow } from './spiceflow.ts' +import type { + SpiceflowContext, + ErrorContext, + MiddlewareContext, +} from './context.js' +import { + SPICEFLOW_RESPONSE, + ValidationError, +} from './error.js' +import { AnySpiceflow, Spiceflow } from './spiceflow.js' export type MaybeArray = T | T[] export type MaybePromise = T | Promise @@ -16,26 +22,49 @@ export type MaybePromiseIterable = T | Promise | AsyncIterable export type ObjectValues = T[keyof T] -type IsPathParameter = Part extends `:${infer Parameter}` - ? Parameter - : Part extends `*` - ? '*' +type RequiredPathParameterForSegment< + Segment extends string, + IsLast extends boolean, +> = Segment extends `:${infer Parameter}?` + ? IsLast extends true + ? never + : `${Parameter}?` + : Segment extends `:${infer Parameter}` + ? Parameter : never +type OptionalPathParameterForSegment< + Segment extends string, + IsLast extends boolean, +> = Segment extends '*' + ? '*' + : Segment extends `:${infer Parameter}?` + ? IsLast extends true + ? Parameter + : never + : never + +type RequiredPathParameter = + Path extends `${infer Segment}/${infer Rest}` + ? RequiredPathParameterForSegment | + RequiredPathParameter + : RequiredPathParameterForSegment + +type OptionalPathParameter = + Path extends `${infer Segment}/${infer Rest}` + ? OptionalPathParameterForSegment | + OptionalPathParameter + : OptionalPathParameterForSegment + export type GetPathParameter = - Path extends `${infer A}/${infer B}` - ? IsPathParameter | GetPathParameter - : IsPathParameter + | RequiredPathParameter + | OptionalPathParameter export type ResolvePath = Prettify< { - [Param in GetPathParameter as Param extends `${string}?` - ? never - : Param]: string + [Param in RequiredPathParameter]: string } & { - [Param in GetPathParameter as Param extends `${infer OptionalParam}?` - ? OptionalParam - : never]?: string + [Param in OptionalPathParameter]?: string } > @@ -306,7 +335,7 @@ export type Handler< }, Path extends string = '', > = ( - context: Context, + context: SpiceflowContext, ) => MaybePromise< {} extends Route['response'] ? unknown @@ -357,8 +386,8 @@ export type InlineHandler< > = ( this: This, context: MacroContext extends Record - ? Prettify> - : Context, + ? Prettify> + : SpiceflowContext, ) => | ResponseLike | MaybePromiseIterable< @@ -432,7 +461,7 @@ export type VoidHandler< in out Singleton extends SingletonBase = { state: {} }, -> = (context: Context) => MaybePromise +> = (context: SpiceflowContext<'', Route, Singleton>) => MaybePromise export type TransformHandler< in out Route extends RouteSchema = {}, @@ -443,12 +472,12 @@ export type TransformHandler< > = { ( context: Prettify< - Context< + SpiceflowContext< + BasePath, Route, Omit & { resolve: {} - }, - BasePath + } > >, ): MaybePromise @@ -464,7 +493,7 @@ export type BodyHandler< context: Prettify< { contentType: string - } & Context + } & SpiceflowContext >, contentType: string, @@ -485,7 +514,7 @@ export type AfterResponseHandler< }, > = ( context: Prettify< - Context & { + SpiceflowContext<'', Route, Singleton> & { response: Route['response'] } >, @@ -503,6 +532,7 @@ export type ErrorHandler< }, > = ( context: ErrorContext< + '', Route, { state: Singleton['state'] @@ -582,16 +612,28 @@ export type LocalHook< type?: ContentType } -export type ComposedHandler = (context: Context) => MaybePromise +export type ComposedHandler = ( + context: SpiceflowContext, +) => MaybePromise -export interface InternalRoute { +export type ValidationFunction = (value: unknown) => StandardSchemaV1.Result | Promise> + +export type InternalRoute = { method: HTTPMethod path: string - composed: ComposedHandler | Response | null - handler: Handler + type: ContentType + handler: InlineHandler hooks: LocalHook + validateBody?: ValidationFunction + validateQuery?: ValidationFunction + validateParams?: ValidationFunction + kind?: NodeKind + id: string + // prefix: string } +export type NodeKind = 'page' | 'layout' | 'staticPage' | 'staticPageWithoutHandler' + export type AddPrefix = { [K in keyof T as Prefix extends string ? `${Prefix}${K & string}` : K]: T[K] } @@ -619,10 +661,10 @@ type _CreateClient< Property extends Record = {}, > = Path extends `${infer Start}/${infer Rest}` ? { - [x in Start]: _CreateClient + [x in Start extends '' ? 'index' : Start]: _CreateClient } : { - [x in Path]: Property + [x in Path extends '' ? 'index' : Path]: Property } export type CreateClient< @@ -630,9 +672,7 @@ export type CreateClient< Property extends Record = {}, > = Path extends `/${infer Rest}` ? _CreateClient - : Path extends '' - ? _CreateClient<'index', Property> - : _CreateClient + : _CreateClient export type ComposeSpiceflowResponse = Handle extends ( ...a: any[] @@ -865,13 +905,11 @@ export type HTTPHeaders = Record & { 'x-ua-compatible'?: string } -export type JoinPath = `${A}${B extends '/' - ? '/index' - : B extends '' +export type JoinPath = `${A}${B extends '' + ? B + : B extends `/${string}` ? B - : B extends `/${string}` - ? B - : B}` + : B}` export type PartialWithRequired = Partial> & Pick diff --git a/spiceflow/src/utils.ts b/spiceflow/src/utils.ts index 0bfcfb97..0aa6f49f 100644 --- a/spiceflow/src/utils.ts +++ b/spiceflow/src/utils.ts @@ -1,3 +1,5 @@ +import { redirect } from './react/errors.js' + // deno-lint-ignore no-explicit-any export const deepFreeze = (value: any) => { for (const key of Reflect.ownKeys(value)) { @@ -26,6 +28,10 @@ export function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)) } +export { redirect } + +export type Redirect = typeof redirect + export const StatusMap = { Continue: 100, 'Switching Protocols': 101, @@ -98,18 +104,6 @@ export const InvertedStatusMap = Object.fromEntries( export type StatusMap = typeof StatusMap export type InvertedStatusMap = typeof InvertedStatusMap -/** - * - * @param url URL to redirect to - * @param HTTP status code to send, - */ -export const redirect = ( - url: string, - status: 301 | 302 | 303 | 307 | 308 = 302, -) => Response.redirect(url, status) - -export type redirect = typeof redirect - export function isResponse(result: any): result is Response { if (result instanceof Response) { return true @@ -131,3 +125,8 @@ export function isResponse(result: any): result is Response { return false } + + +export function isTruthy(x: T | undefined | null | false): x is T { + return Boolean(x) +} \ No newline at end of file diff --git a/spiceflow/src/vite.tsx b/spiceflow/src/vite.tsx new file mode 100644 index 00000000..e7f03e69 --- /dev/null +++ b/spiceflow/src/vite.tsx @@ -0,0 +1,349 @@ +// Spiceflow Vite plugin: integrates @vitejs/plugin-rsc for RSC support, +// provides SSR middleware, virtual modules, and prerender support. +import fs from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' +import url from 'node:url' + +import rsc, { RscPluginOptions } from '@vitejs/plugin-rsc' +import { + type Plugin, + type PluginOption, + type RunnableDevEnvironment, + type UserConfig, + type ViteDevServer, +} from 'vite' +import { prerenderPlugin } from './react/prerender.js' + +const require = createRequire(import.meta.url) +const pluginRscRpcPath = require.resolve('@vitejs/plugin-rsc/utils/rpc') + +// Module-level so the timestamp is stable even if spiceflowPlugin() is called more than once +const buildTimestamp = Date.now().toString(36) + + +export function spiceflowPlugin({ + entry, +}: { + entry: string +}): PluginOption { + let server: ViteDevServer + let resolvedOutDir = 'dist' + let isCloudflareRuntime = false + const rscOptions: RscPluginOptions = { + entries: { + rsc: 'spiceflow/dist/react/entry.rsc', + ssr: 'spiceflow/dist/react/entry.ssr', + client: 'spiceflow/dist/react/entry.client', + }, + serverHandler: false as const, + loadModuleDevProxy: true, + + // Stable encryption key for server action closure args. Without this the key changes on + // every build/restart, breaking action calls from stale client bundles after a deploy. + defineEncryptionKey: 'process.env.RSC_ENCRYPTION_KEY', + // Catch invalid cross-environment imports at build time (e.g. importing a server-only + // module from a client component) instead of failing at runtime. + validateImports: true, + } + + return [ + rsc(rscOptions), + prerenderPlugin(), + + // Rewrite optimizeDeps entries so @vitejs/plugin-rsc vendor CJS files + // resolve through the spiceflow framework package (where the plugin is installed) + // rather than from the app root where the plugin isn't a direct dependency. + { + name: 'spiceflow:optimize-deps-rewrite', + configEnvironment(_name, config) { + if (!config.optimizeDeps?.include) return + config.optimizeDeps.include = config.optimizeDeps.include.map( + (entry) => { + if (entry.startsWith('@vitejs/plugin-rsc')) { + return `spiceflow > ${entry}` + } + return entry + }, + ) + }, + }, + + { + name: 'spiceflow:plugin-rsc-rpc-alias', + resolveId(source) { + if (source === '@vitejs/plugin-rsc/utils/rpc') { + return pluginRscRpcPath + } + }, + }, + + + + { + name: 'spiceflow:config', + config(userConfig, env) { + const userOnWarn = userConfig.build?.rollupOptions?.onwarn + const isCloudflare = hasPluginNamed(userConfig.plugins, 'vite-plugin-cloudflare') + if (isCloudflare) { + // Cloudflare child environments already expose worker-side module imports. + // Using plugin-rsc's Node dev proxy here makes child `ssr` call + // `.runner.import(...)` on a non-runnable CloudflareDevEnvironment. + rscOptions.loadModuleDevProxy = false + } + const outDir = userConfig.build?.outDir ?? 'dist' + return { + // SSR must live inside outDir/rsc/ because workerd only bundles files within the + // Worker's directory. The RSC code loads SSR via import.meta.viteRsc.loadModule + // which resolves to a relative import "../ssr/index.js" — if SSR is at outDir/ssr/ + // (sibling), the Worker can't reach it at runtime. + ...(isCloudflare ? { environments: { ssr: { build: { outDir: path.join(outDir, 'rsc/ssr') } } } } : {}), + // Replace process.env.NODE_ENV at build time so React uses its production + // bundle. Without this, the built output contains runtime checks like + // `"production" !== process.env.NODE_ENV` that always evaluate to the dev + // path, adding ~28% CPU overhead from debug stack traces and fake call sites. + define: env.command === 'build' + ? { 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production') } + : undefined, + build: { + rollupOptions: { + onwarn(warning, defaultHandler) { + // Suppress IMPORT_IS_UNDEFINED for virtual:app-entry — it uses + // `import * as entry` + re-export which Rollup can't statically verify, + // but the runtime check (`if (!entry.app) throw ...`) already covers it. + if ( + warning.code === 'IMPORT_IS_UNDEFINED' && + warning.id?.includes('\0virtual:app-entry') + ) { + return + } + if (userOnWarn) { + userOnWarn(warning, defaultHandler) + } else { + defaultHandler(warning) + } + }, + }, + }, + } + }, + configResolved(config) { + resolvedOutDir = config.build.outDir + isCloudflareRuntime = config.plugins.some((plugin) => + plugin.name.startsWith('vite-plugin-cloudflare:'), + ) + }, + }, + // Point optimizeDeps.entries at the user's app entry and spiceflow's own entries + // so Vite crawls the full import graph upfront instead of discovering deps late + // (which triggers re-optimization rounds + page reloads during dev). + // + // Also ensures Vite processes spiceflow through its transform pipeline (noExternal) + // so conditional package.json exports (react-server vs default) resolve correctly. + // Excludes spiceflow from client dep optimization so RSC client references + // (loaded via client-in-server-package-proxy from raw node_modules) share the same + // module instances as the entry.client imports. + { + name: 'spiceflow:optimize-deps', + configEnvironment(name, config) { + const entryGlob = entry.replace( + /\.[cm]?[jt]sx?$/, + '.{js,jsx,ts,tsx,mjs,mts,cjs,cts}', + ) + + config.optimizeDeps ??= {} + config.optimizeDeps.entries = mergeUnique( + toArray(config.optimizeDeps.entries), + [entryGlob], + ) + + // Each environment runs its own independent optimizer, so deps discovered + // late by the rsc/ssr optimizer still cause reloads even if the client + // optimizer finished cleanly. Explicitly include known CJS/late-discovered + // deps that spiceflow transitively imports so all three environments + // pre-bundle them upfront instead of finding them mid-request. + if (name === 'client') { + config.optimizeDeps.exclude = mergeUnique( + config.optimizeDeps.exclude, + ['spiceflow'], + ) + config.optimizeDeps.include = mergeUnique( + config.optimizeDeps.include, + [ + 'react', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + 'react-dom', + 'react-dom/client', + 'superjson', + 'history', + ], + ) + } + + if (name === 'rsc') { + addNoExternal(config, 'spiceflow') + config.optimizeDeps.include = mergeUnique( + config.optimizeDeps.include, + ['superjson', 'zod', 'history'], + ) + } + + if (name === 'ssr') { + addNoExternal(config, 'spiceflow') + config.optimizeDeps.include = mergeUnique( + config.optimizeDeps.include, + ['isbot', 'history', 'react-dom/server', 'react-dom/server.edge'], + ) + } + }, + }, + + // TODO: remove this workaround once @tailwindcss/vite releases the fix from + // https://github.com/tailwindlabs/tailwindcss/pull/19745 (merged but unreleased as of 4.2.1) + // + // Workaround: @tailwindcss/vite's hotUpdate hook sends a bare full-reload + // to the client when server-only files (like the app entry) change, because + // Tailwind scans them for class names. This breaks RSC HMR by causing a + // full page reload instead of letting rsc:update + router.refresh() handle it. + { + name: 'spiceflow:tailwind-hmr-fix', + configResolved(config) { + const twPlugin = config.plugins.find( + (p) => p.name === '@tailwindcss/vite:generate:serve', + ) + if (twPlugin) { + delete twPlugin.hotUpdate + } + }, + }, + + // SSR middleware for dev and preview servers + { + name: 'spiceflow:ssr-middleware', + configureServer(_server) { + // Cloudflare dev/preview already route requests through the worker entry. + // Installing the Node SSR middleware here breaks the supported dev flow + // because SSR then needs to call back into the non-runnable worker env. + if (isCloudflareRuntime) return + server = _server + return () => { + server.middlewares.use(async (req, res, next) => { + if (req.url?.includes('__inspect')) return next() + try { + const resolvedEntry = await server.environments.ssr.pluginContainer.resolveId( + 'spiceflow/dist/react/entry.ssr', + ) + if (!resolvedEntry) { + throw new Error('Failed to resolve spiceflow SSR entry') + } + const mod: any = await ( + server.environments.ssr as RunnableDevEnvironment + ).runner?.import(resolvedEntry.id) + const { createRequest, sendResponse } = await import('./react/fetch.js') + const request = createRequest(req, res) + const response = await mod.fetchHandler(request) + sendResponse(response, res) + } catch (e) { + next(e) + } + }) + } + }, + async configurePreviewServer(previewServer) { + // Preview should also go through the built worker entry when Cloudflare owns the runtime. + if (isCloudflareRuntime) return + const mod = await import(path.resolve(resolvedOutDir, 'ssr/index.js')) + const { createRequest, sendResponse } = await import('./react/fetch.js') + return () => { + previewServer.middlewares.use(async (req, res, next) => { + try { + const request = createRequest(req, res) + const response = await mod.fetchHandler(request) + sendResponse(response, res) + } catch (e) { + next(e) + } + }) + } + }, + }, + + // Build timestamp inlined as a constant. + // No runtime fs access needed, works on Node, Cloudflare, edge runtimes, etc. + createVirtualPlugin('virtual:spiceflow-deployment-id', () => { + return `export default ${JSON.stringify(buildTimestamp)}` + }), + // Resolves to user's app entry module. + // Re-exports `app` (named) and `default` (for Cloudflare Workers default export). + createVirtualPlugin('virtual:app-entry', () => { + return [ + `import * as entry from '${url.pathToFileURL(path.resolve(entry))}'`, + `if (!entry.app) throw new Error('[spiceflow] Your entry file must export a Spiceflow instance as "app". Example:\\n\\n export const app = new Spiceflow()\\n .page("/", async () => )\\n .listen(3000)\\n')`, + `export const app = entry.app`, + `export default entry.default`, + ].join('\n') + }), + + ] +} + +function hasPluginNamed( + plugins: UserConfig['plugins'], + pluginName: string, +): boolean { + if (!plugins) return false + + for (const plugin of plugins) { + if (!plugin) continue + if (Array.isArray(plugin)) { + if (hasPluginNamed(plugin, pluginName)) return true + continue + } + if ('name' in plugin && plugin.name === pluginName) { + return true + } + } + + return false +} + +function mergeUnique(base: T[] | undefined, add: T[]): T[] { + return Array.from(new Set([...(base ?? []), ...add])) +} + +function toArray(value: string | string[] | undefined): string[] { + if (!value) return [] + return Array.isArray(value) ? value : [value] +} + +function addNoExternal( + config: { resolve?: { noExternal?: unknown } }, + pkg: string, +) { + config.resolve ??= {} + const existing = config.resolve.noExternal + if (existing === true) return + // Preserve false (user explicitly disabled) — we still need spiceflow processed + const arr = Array.isArray(existing) + ? existing + : existing && existing !== false + ? [existing] + : [] + config.resolve.noExternal = Array.from(new Set([...arr, pkg])) +} + +function createVirtualPlugin(virtualName: string, load: Plugin['load']): Plugin { + const shortName = virtualName.replace('virtual:', '') + return { + name: `spiceflow:virtual-${shortName}`, + resolveId(source) { + return source === virtualName ? '\0' + virtualName : undefined + }, + load(id, options) { + if (id === '\0' + virtualName) { + return (load as Function).apply(this, [id, options]) + } + }, + } +} diff --git a/spiceflow/src/waitUntil.test.ts b/spiceflow/src/waitUntil.test.ts index 5d9d99b0..2250da69 100644 --- a/spiceflow/src/waitUntil.test.ts +++ b/spiceflow/src/waitUntil.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, vi } from 'vitest' -import { Spiceflow } from './spiceflow.ts' +import { Spiceflow } from './spiceflow.js' describe('waitUntil', () => { test('waitUntil is available in handler context', async () => { diff --git a/spiceflow/src/zod.test.ts b/spiceflow/src/zod.test.ts index 9bc603a8..830c76c8 100644 --- a/spiceflow/src/zod.test.ts +++ b/spiceflow/src/zod.test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest' import { z } from 'zod' -import { Spiceflow } from './spiceflow.ts' -import { req } from './utils.ts' +import { Spiceflow } from './spiceflow.js' +import { req } from './utils.js' test('body is parsed as json', async () => { let name = '' diff --git a/spiceflow/tsconfig.json b/spiceflow/tsconfig.json index 230a740f..bea90bbb 100644 --- a/spiceflow/tsconfig.json +++ b/spiceflow/tsconfig.json @@ -8,8 +8,9 @@ "moduleResolution": "NodeNext", "declarationMap": true, "sourceMap": true, + "jsx": "react-jsx", "resolveJsonModule": true, - + "useUnknownInCatchVariables": false, "outDir": "dist" }, "include": ["src"] diff --git a/spiceflow/vitest.config.js b/spiceflow/vitest.config.js index e29cdb11..f867790f 100644 --- a/spiceflow/vitest.config.js +++ b/spiceflow/vitest.config.js @@ -1,5 +1,5 @@ -// vite.config.ts -import { defineConfig } from 'vite' +// Vitest config for the spiceflow package. +import { defineConfig } from 'vitest/config' const execArgv = process.env.PROFILE ? ['--cpu-prof', '--cpu-prof-dir=./profiling'] @@ -9,15 +9,14 @@ export default defineConfig({ esbuild: { jsx: 'transform', }, + resolve: { + conditions: ['react-server'], + }, test: { exclude: ['**/dist/**', '**/esm/**', '**/node_modules/**', '**/e2e/**'], pool: 'threads', - poolOptions: { - threads: { - singleThread: true, - isolate: false, - execArgv, - }, - }, + isolate: false, + fileParallelism: false, + execArgv, }, }) diff --git a/website/app/components/SideBySide.tsx b/website/app/components/SideBySide.tsx index 44c49496..bd1f34e3 100644 --- a/website/app/components/SideBySide.tsx +++ b/website/app/components/SideBySide.tsx @@ -1,3 +1,5 @@ +'use client' + import { Children } from 'react' export function SideBySide({ children }: { children: React.ReactNode }) { diff --git a/website/app/components/TableOfContents.tsx b/website/app/components/TableOfContents.tsx index d78fd506..8508c034 100644 --- a/website/app/components/TableOfContents.tsx +++ b/website/app/components/TableOfContents.tsx @@ -1,4 +1,6 @@ -import { Link } from 'react-router' +'use client' + +import { Link } from 'spiceflow/react' import { Children } from 'react' type Toc = { @@ -28,7 +30,7 @@ export function TableOfContents({ {flatNodes.map((item) => { return (
  • - {item.value} + {item.value}
  • ) })} diff --git a/website/app/entry.client.tsx b/website/app/entry.client.tsx deleted file mode 100644 index 5943a4a5..00000000 --- a/website/app/entry.client.tsx +++ /dev/null @@ -1,13 +0,0 @@ -/** - * By default, Remix will handle hydrating your app on the client for you. - * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ - * For more information, see https://remix.run/file-conventions/entry.client - */ - -import { HydratedRouter } from 'react-router/dom' -import { startTransition, StrictMode } from 'react' -import { hydrateRoot } from 'react-dom/client' - -startTransition(() => { - hydrateRoot(document, ) -}) diff --git a/website/app/entry.server.tsx b/website/app/entry.server.tsx deleted file mode 100644 index 6292d0be..00000000 --- a/website/app/entry.server.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/** - * By default, Remix will handle generating the HTTP Response for you. - * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨ - * For more information, see https://remix.run/file-conventions/entry.server - */ -import { redirect } from 'react-router' -import type { AppLoadContext, EntryContext } from 'react-router' -import { ServerRouter } from 'react-router' -import { isbot } from 'isbot' -import dom from 'react-dom/server' - -export default async function handleRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - reactRouterContext: EntryContext, - // This is ignored so we can keep it in the template for visibility. Feel - // free to delete this parameter in your app if you're not using it! - // eslint-disable-next-line @typescript-eslint/no-unused-vars - loadContext: AppLoadContext, -) { - const body = await dom.renderToReadableStream( - , - { - signal: request.signal, - onError(error: unknown) { - // Log streaming rendering errors from inside the shell - console.error(error) - responseStatusCode = 500 - }, - }, - ) - - if (isbot(request.headers.get('user-agent') || '')) { - await body.allReady - } - - responseHeaders.set('Content-Type', 'text/html') - return new Response(body, { - headers: responseHeaders, - status: responseStatusCode, - }) -} diff --git a/website/app/global.css b/website/app/global.css index 7e4dcea9..778e1e2d 100644 --- a/website/app/global.css +++ b/website/app/global.css @@ -1,7 +1,6 @@ @import '@code-hike/mdx/styles'; -@tailwind base; -@tailwind components; -@tailwind utilities; +@import 'tailwindcss'; +@plugin '@tailwindcss/typography'; html, body, @@ -10,10 +9,52 @@ body, } html { - /* background: theme('colors.gray.50'); */ - /* make the scroll to section be a bit lower than the top */ scroll-padding-top: 40px; --max-width: 100%; + --font-serif: 'Source Serif 4', Georgia, serif; + color-scheme: light dark; +} + +.prose :is(h1, h2, h3, h4, h5, h6) { + font-family: var(--font-serif); + font-weight: 400; + line-height: 1.15; + margin-top: 1em; + margin-bottom: 0.3em; +} + +.prose h1 { font-size: 3em; } +.prose h2 { font-size: 2.4em; } +.prose h3 { font-size: 1.8em; } + +.prose p { + margin-top: 0.4em; + margin-bottom: 0.4em; +} + +.prose ul, .prose ol { + margin-top: 0.3em; + margin-bottom: 0.3em; +} + +.prose :is(h1, h2, h3, h4, h5, h6) + .ch-codeblock { + margin-top: 0.3em; +} + +.prose .ch-codeblock + :is(h1, h2, h3, h4, h5, h6) { + margin-top: 0.75em; +} + +body { + background-color: white; + color: #24292f; +} + +@media (prefers-color-scheme: dark) { + body { + background-color: #0d1117; + color: #c9d1d9; + } } .ch-codeblock code { @@ -23,11 +64,75 @@ html { .ch-codeblock { box-shadow: none !important; - border: 1px solid rgb(229 231 235) !important; + border: none !important; border-radius: 0px; + padding: 0 !important; + overflow: visible !important; + margin-top: 0.4em !important; + margin-bottom: 0.4em !important; +} + +.ch-codeblock pre { + overflow: visible !important; + padding: 0 !important; } -@media screen(md) { +.ch-code-wrapper { + padding: 0 !important; +} + +/* Remove Code Hike's internal 16px left offset for code lines. + Each line div uses translate(16px, Ypx) where Y varies per line. + Can't override transform without losing Y positioning, so + shift the parent container left by 16px to compensate. */ +.ch-code-scroll-content div[style*="margin-left"] { + margin-left: 0 !important; +} + +.ch-code-scroll-content { + margin-left: -16px; +} + +/* Dark mode: switch Code Hike to github-dark theme colors */ +@media (prefers-color-scheme: dark) { + [data-ch-theme] { + --ch-t-colorScheme: dark; + --ch-t-foreground: #c9d1d9; + --ch-t-background: #0d1117; + --ch-t-lighter-inlineBackground: #0d1117e6; + --ch-t-editor-background: #0d1117; + --ch-t-editor-foreground: #c9d1d9; + --ch-t-editor-lineHighlightBackground: #6e76811a; + --ch-t-editor-rangeHighlightBackground: #ffffff0b; + --ch-t-editor-infoForeground: #3794FF; + --ch-t-editor-selectionBackground: #264F78; + --ch-t-editorLineNumber-foreground: #6e7681; + } + + .ch-code-wrapper { + background-color: #0d1117 !important; + color: #c9d1d9 !important; + } + + .ch-inline-code > code { + background: #161b22 !important; + color: #c9d1d9 !important; + } + + /* Map github-light token colors to github-dark equivalents. + Code Hike serializes inline styles as rgb(), so selectors must match that format. */ + [data-ch-theme] span[style*="color: rgb(207, 34, 46)"] { color: #FF7B72 !important; } + [data-ch-theme] span[style*="color: rgb(5, 80, 174)"] { color: #79C0FF !important; } + [data-ch-theme] span[style*="color: rgb(10, 48, 105)"] { color: #A5D6FF !important; } + [data-ch-theme] span[style*="color: rgb(36, 41, 47)"] { color: #C9D1D9 !important; } + [data-ch-theme] span[style*="color: rgb(110, 119, 129)"]{ color: #8B949E !important; } + [data-ch-theme] span[style*="color: rgb(130, 80, 223)"] { color: #D2A8FF !important; } + [data-ch-theme] span[style*="color: rgb(149, 56, 0)"] { color: #FFA657 !important; } + [data-ch-theme] span[style*="color: rgb(17, 99, 41)"] { color: #7EE787 !important; } + [data-ch-theme] span[style*="color: rgb(9, 105, 218)"] { color: #58A6FF !important; } +} + +@media (min-width: 768px) { html { --max-width: 700px; } @@ -36,3 +141,12 @@ html { p { width: var(--max-width); } + +@utility prose-quoteless { + & blockquote p:first-of-type::before { + content: none; + } + & blockquote p:first-of-type::after { + content: none; + } +} diff --git a/website/app/global.d.ts b/website/app/global.d.ts index 697f9cf7..94948430 100644 --- a/website/app/global.d.ts +++ b/website/app/global.d.ts @@ -1,10 +1,11 @@ -// there is a global function fetchData() that returns a Promise - -declare function fetchData(): Promise<'hello'> - -// a module in ./page.js exists +declare module '*.mdx' { + import type { ComponentType } from 'react' + const component: ComponentType + export default component + export const tableOfContents: any[] +} -declare module './page' { - export const Page: React.FC - export default Page +declare module '*.mdx?raw' { + const content: string + export default content } diff --git a/website/app/main.tsx b/website/app/main.tsx new file mode 100644 index 00000000..119e485d --- /dev/null +++ b/website/app/main.tsx @@ -0,0 +1,104 @@ +// Spiceflow RSC website entry point. +// Renders the README as MDX docs with Code Hike syntax highlighting. +import './global.css' +import { Spiceflow } from 'spiceflow' +import { Link, ProgressBar } from 'spiceflow/react' +import { mcp } from 'spiceflow/mcp' +import { openapi } from 'spiceflow/openapi' +import readmeRaw from './readme.mdx?raw' +import { ReadmeContent } from './readme-content' + +const apiApp = new Spiceflow({ basePath: '/api' }) + .use(openapi()) + .use(mcp({ path: '/mcp' })) + .get('/', () => 'Hello, World!') + .get('/hello', () => 'Hello, World!') + .post('/echo', async ({ request }) => { + const body = await request.json() + return { echo: body } + }) + +export const app = new Spiceflow() + .use(({ request }) => { + const url = new URL(request.url) + const userAgent = request.headers.get('user-agent') || '' + + // Serve raw readme for AI agents + const isAIAgent = + userAgent.toLowerCase().includes('claude') || + userAgent.toLowerCase().includes('opencode') || + userAgent.toLowerCase().includes('anthropic') || + userAgent.toLowerCase().includes('ai-agent') || + request.headers.get('x-ai-agent') !== null || + (userAgent.includes('curl') && url.searchParams.get('agent') === 'ai') + + if (isAIAgent && (url.pathname === '/' || url.pathname === '')) { + return Response.redirect(new URL('/readme.md', url.origin).href, 302) + } + + // Handle API routes via sub-app before layout matching + if (url.pathname.startsWith('/api')) { + return apiApp.handle(request) + } + + // Serve llms.txt as plain text + if (url.pathname === '/llms.txt') { + return new Response(readmeRaw, { + headers: { 'Content-Type': 'text/plain' }, + }) + } + }) + .layout('/*', ({ children }) => { + return ( + + + + + + Spiceflow - The Type Safe TypeScript API Framework + + + + + {children} + + + ) + }) + .page('/', () => { + return ( +
    + ) + }) +export default { + async fetch(request: Request) { + return app.handle(request) + }, +} diff --git a/website/app/readme-content.tsx b/website/app/readme-content.tsx new file mode 100644 index 00000000..628b1ca3 --- /dev/null +++ b/website/app/readme-content.tsx @@ -0,0 +1,21 @@ +'use client' + +// Client component wrapper for MDX readme content. +// Code Hike and @mdx-js/react use createContext which requires client environment. +import { MDXProvider } from '@mdx-js/react' +import type { MDXComponents } from 'mdx/types' +import Readme from './readme.mdx' + +const mdxComponents: MDXComponents = { + MyCode({ codeblock }) { + return
    {codeblock.value}xxx
    + }, +} + +export function ReadmeContent() { + return ( + + + + ) +} diff --git a/website/app/routes/_mdx._index.mdx b/website/app/readme.mdx similarity index 74% rename from website/app/routes/_mdx._index.mdx rename to website/app/readme.mdx index e60d0dc1..9b444d72 100644 --- a/website/app/routes/_mdx._index.mdx +++ b/website/app/readme.mdx @@ -16,6 +16,7 @@ Spiceflow is a lightweight, type-safe API framework for building web services us - Can easily generate OpenAPI spec based on your routes - Native support for [Fern](https://github.com/fern-api/fern) to generate docs and SDKs (see example docs [here](https://remorses.docs.buildwithfern.com)) - Support for [Model Context Protocol](https://modelcontextprotocol.io/) to easily wire your app with LLMs +- Full-stack React framework with React Server Components (RSC), server actions, layouts, and automatic client code splitting - Type safe RPC client generation - Simple and intuitive API - Uses web standards for requests and responses @@ -132,7 +133,7 @@ const app = new Spiceflow() path: '/users/:id', params: z.object({ id: z.string(), - }), + }),w response: z.object({ id: z.string(), name: z.string(), @@ -360,12 +361,14 @@ async function exampleUsage() { } ``` -## Fetch Client +## Fetch Client (Recommended) + +`createSpiceflowFetch` is the recommended way to interact with a Spiceflow app. It uses a familiar `fetch(path, options)` interface instead of the proxy-based chainable API of `createSpiceflowClient`. It provides the same type safety for paths, params, query, body, and responses, but with a simpler and more predictable API. -`createSpiceflowFetch` is a simpler alternative to `createSpiceflowClient` that uses a familiar `fetch(path, options)` interface instead of the proxy-based chainable API. It provides the same type safety for paths, params, query, body, and responses. +Export the app type from your server code: ```ts -import { createSpiceflowFetch } from 'spiceflow/client' +// server.ts import { Spiceflow } from 'spiceflow' import { z } from 'zod' @@ -414,48 +417,61 @@ const app = new Spiceflow() }, }) -// Create the fetch client -const spiceflowFetch = createSpiceflowFetch('http://localhost:3000') +export type App = typeof app +``` + +Then use the `App` type on the client side without importing server code: + +```ts +// client.ts +import { createSpiceflowFetch } from 'spiceflow/client' +import type { App } from './server' + +const f = createSpiceflowFetch('http://localhost:3000') -// Simple GET — method defaults to GET -const { data, error } = await spiceflowFetch('/hello') +// Returns Error | Data — check with instanceof Error +const greeting = await f('/hello') +if (greeting instanceof Error) return greeting // early return on error +console.log(greeting) // 'Hello, World!' — TypeScript knows the type // POST with typed body -const { data: user } = await spiceflowFetch('/users', { +const user = await f('/users', { method: 'POST', body: { name: 'John', email: 'john@example.com' }, }) +if (user instanceof Error) return user +console.log(user.id, user.name) // fully typed // Path params — type-safe, required when path has :params -const { data: foundUser } = await spiceflowFetch('/users/:id', { +const foundUser = await f('/users/:id', { params: { id: '123' }, }) +if (foundUser instanceof Error) return foundUser // Query params — typed from route schema -const { data: searchResults } = await spiceflowFetch('/search', { +const searchResults = await f('/search', { query: { q: 'hello', page: 1 }, }) +if (searchResults instanceof Error) return searchResults // Streaming — returns AsyncGenerator for async generator routes -const { data: stream } = await spiceflowFetch('/stream') +const stream = await f('/stream') +if (stream instanceof Error) return stream for await (const chunk of stream) { console.log(chunk) // 'Start', 'Middle', 'End' } ``` -The fetch client returns the same `{ data, error, response, status, headers, url }` shape as `createSpiceflowClient`. It also supports the same configuration options (headers, retries, onRequest/onResponse hooks, custom fetch). +The fetch client returns `Error | Data` directly following the [errore](https://errore.org) convention — use `instanceof Error` to check for errors with Go-style early returns, then the happy path continues with the narrowed data type. No `{ data, error }` destructuring, no null checks. On error, the returned `SpiceflowFetchError` has `status`, `value` (the parsed error body), and `response` (the raw Response object) properties. -Passing an unknown URL or using `as any` falls back to untyped behavior, so it works as a drop-in for regular fetch when type safety isn't needed: - -```ts -const { data } = await spiceflowFetch('https://example.com/api/whatever' as any) -``` +The fetch client supports configuration options like headers, retries, onRequest/onResponse hooks, and custom fetch. You can also pass a Spiceflow app instance directly for server-side usage without network requests: ```ts -const spiceflowFetch = createSpiceflowFetch(app) -const { data } = await spiceflowFetch('/hello') +const f = createSpiceflowFetch(app) +const greeting = await f('/hello') +if (greeting instanceof Error) throw greeting ``` ### Path Matching - Supported Features @@ -602,6 +618,58 @@ const userPostPath = app.safePath('/users/:id/posts/:postId', { // Result: '/users/456/posts/abc' ``` +### Query Parameters + +When a route has a `query` schema, `safePath` accepts query parameters alongside path parameters in the same flat object. Query parameters are appended as a query string, and unknown keys are rejected at the type level: + +```ts +const app = new Spiceflow() + .route({ + method: 'GET', + path: '/search', + query: z.object({ q: z.string(), page: z.coerce.number() }), + handler({ query }) { + return { results: [], q: query.q } + }, + }) + .route({ + method: 'GET', + path: '/users/:id', + query: z.object({ fields: z.string() }), + handler({ params, query }) { + return { id: params.id, fields: query.fields } + }, + }) + +app.safePath('/search', { q: 'hello', page: 1 }) +// Result: '/search?q=hello&page=1' + +app.safePath('/users/:id', { id: '42', fields: 'name' }) +// Result: '/users/42?fields=name' + +// @ts-expect-error - 'invalid' is not a known query key +app.safePath('/search', { invalid: 'x' }) +``` + +### Standalone `createSafePath` + +If you need a path builder on the client side where you can't import server app code, use `createSafePath` with the `typeof app` generic: + +```ts +import { createSafePath } from 'spiceflow' +import type { App } from './server' // import only the type, not the runtime app + +const safePath = createSafePath() + +safePath('/users/:id', { id: '123' }) +// Result: '/users/123' + +safePath('/search', { q: 'hello', page: 1 }) +// Result: '/search?q=hello&page=1' +``` + +The returned function has the same type safety as `app.safePath` — it infers paths, params, and query schemas from the app type. The app argument is optional and not used at runtime, so you can call `createSafePath()` without passing any value. + ### OAuth Callback Example The `safePath` method is particularly useful when building callback URLs for OAuth flows, where you need to construct URLs dynamically based on user data or session information: @@ -774,6 +842,62 @@ new Spiceflow().use(({ request }) => { }) ``` +### Static Middleware + +Use `serveStatic()` to serve files from a directory: + +```ts +import { Spiceflow, serveStatic } from 'spiceflow' + +const app = new Spiceflow() + .use(serveStatic({ root: './public' })) + .route({ + method: 'GET', + path: '/health', + handler() { + return { ok: true } + }, + }) + .route({ + method: 'GET', + path: '/*', + handler() { + return new Response('Not Found', { status: 404 }) + }, + }) +``` + +Static middleware only serves `GET` and `HEAD` requests. It checks the exact file path first, and if the request points to a directory it tries `index.html` inside that directory. + +Priority rules: + +- Concrete routes win over static files. A route like `/health` is handled by the route even if `public/health` exists. +- Static files win over root catch-all routes like `/*` and `*`. This is useful for SPA fallbacks and custom 404 routes. +- If static does not find a file, the request falls through to the next matching route, so a `/*` fallback still runs when the asset is missing. +- When multiple static middlewares are registered, they are checked in registration order. The first middleware that finds a file wins. + +Example behavior: + +```text +request /logo.png + -> router matches `/*` + -> static checks `public/logo.png` + -> if file exists, static serves it + -> otherwise the `/*` route runs +``` + +Directory requests without an `index.html` fall through instead of throwing filesystem errors like `EISDIR`. + +You can stack multiple static roots: + +```ts +const app = new Spiceflow() + .use(serveStatic({ root: './public' })) + .use(serveStatic({ root: './dist/client' })) +``` + +In this example, `./public/logo.png` wins over `./dist/client/logo.png` because `./public` is registered first. + ## How errors are handled in Spiceflow client The Spiceflow client provides type-safe error handling by returning either a `data` or `error` property. When using the client: @@ -964,7 +1088,7 @@ const app = new Spiceflow().use(cors()).route({ ```ts import { Spiceflow } from 'spiceflow' -import { MiddlewareHandler } from 'spiceflow/dist/types' +import type { MiddlewareHandler } from 'spiceflow' const app = new Spiceflow() @@ -1661,3 +1785,297 @@ export const client: SpiceflowClient.Create = createSpiceflowClient( {}, ) ``` + +## React Framework (RSC) + +Spiceflow includes a full-stack React framework built on React Server Components (RSC). It uses Vite with `@vitejs/plugin-rsc` under the hood. Server components run on the server by default, and you use `"use client"` to mark interactive components that need to run in the browser. + +### Setup + +Install the dependencies and create a Vite config: + +```bash +npm install spiceflow react react-dom +``` + +```ts +// vite.config.ts +import { defineConfig } from 'vite' +import { spiceflowPlugin } from 'spiceflow/vite' + +export default defineConfig({ + plugins: [ + spiceflowPlugin({ + entry: './src/main.tsx', + }), + ], +}) +``` + +### Cloudflare RSC setup + +For Cloudflare Workers, keep the worker-specific SSR output and child environment wiring in Vite, then let your Worker default export delegate to `app.handle(request)`. + +```jsonc +// wrangler.jsonc +{ + "main": "spiceflow/cloudflare-entrypoint" +} +``` + +```ts +// vite.config.ts +import { cloudflare } from '@cloudflare/vite-plugin' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' +import { spiceflowPlugin } from 'spiceflow/vite' + +export default defineConfig({ + plugins: [ + react(), + spiceflowPlugin({ entry: './app/main.tsx' }), + cloudflare({ + viteEnvironment: { + name: 'rsc', + childEnvironments: ['ssr'], + }, + }), + ], +}) +``` + +```tsx +// app/main.tsx +import { Spiceflow } from 'spiceflow' + +export const app = new Spiceflow() + .page('/', async () => { + return
    Hello from Cloudflare RSC
    + }) + +export default { + fetch(request: Request) { + return app.handle(request) + }, +} +``` + +See [`cloudflare-example/`](cloudflare-example) for a complete working example. + +### App Entry (Server Component) + +The entry file defines your routes using `.page()` for pages and `.layout()` for layouts. This file runs in the RSC environment on the server. + +All routes registered with `.page()`, `.get()`, etc. are available in `app.safePath()` for type-safe URL building — including path params and query params. + +```tsx +// src/main.tsx +import { Spiceflow, serveStatic } from 'spiceflow' +import { Link } from 'spiceflow/react' +import { z } from 'zod' +import { Counter } from './app/counter' +import { Nav } from './app/nav' + +export const app = new Spiceflow() + .use(serveStatic({ root: './public' })) + .use(serveStatic({ root: './dist/client' })) // required to serve vite built static files + .layout('/*', async ({ children }) => { + return ( + + + + + +