diff --git a/.agents/skills/vite-plugin-rsc/SKILL.md b/.agents/skills/vite-plugin-rsc/SKILL.md new file mode 100644 index 0000000..25977a3 --- /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 + "`, + ) + }) + + 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 0000000..a85e255 --- /dev/null +++ b/spiceflow/src/react/transform.test.ts @@ -0,0 +1,55 @@ +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('') + }) +}) diff --git a/spiceflow/src/react/transform.ts b/spiceflow/src/react/transform.ts new file mode 100644 index 0000000..d06783e --- /dev/null +++ b/spiceflow/src/react/transform.ts @@ -0,0 +1,123 @@ +// ported from https://github.com/devongovett/rsc-html-stream/blob/main/server.js + +const encoder = new TextEncoder() +const trailerBody = '' + +export function injectRSCPayload({ + rscStream, + appendToHead, +}: { + rscStream?: ReadableStream + appendToHead?: string +}) { + let decoder = new TextDecoder() + let resolveFlightDataPromise: (value: void) => void + let flightDataPromise = new Promise( + (resolve) => (resolveFlightDataPromise = resolve), + ) + let startedRSC = false + let addedHead = false + + // Buffer all HTML chunks enqueued during the current tick of the event loop (roughly) + // 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. + let buffered: Uint8Array[] = [] + let timeout: ReturnType | null = null + function flushBufferedChunks( + controller: TransformStreamDefaultController, + ) { + for (let chunk of buffered) { + let buf = decoder.decode(chunk) + // TODO this relies on html document not having a newline after , can easily break? but react dom currently returns it always together so it's fine? + if (buf.endsWith(trailerBody)) { + buf = buf.slice(0, -trailerBody.length) + } + // TODO what if the user includes in the html document content? + if (!addedHead && appendToHead && buf.includes('')) { + buf = buf.replace('', appendToHead + '\n') + addedHead = true + } + controller.enqueue(encoder.encode(buf)) + } + + buffered.length = 0 + timeout = null + } + + return new TransformStream({ + transform(chunk, controller) { + buffered.push(chunk) + if (timeout) { + return + } + + timeout = setTimeout(async () => { + flushBufferedChunks(controller) + if (!startedRSC) { + startedRSC = true + writeRSCStream(rscStream, controller) + .catch((err) => controller.error(err)) + .then(() => resolveFlightDataPromise()) + } + }, 0) + }, + async flush(controller) { + await flightDataPromise + if (timeout) { + clearTimeout(timeout) + flushBufferedChunks(controller) + } + controller.enqueue(encoder.encode('')) + }, + }) +} + +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(btoa(String.fromCodePoint(...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 +/// + +declare module 'virtual:app-styles' { + const cssUrls: string[] + export default cssUrls +} + +declare module 'virtual:app-entry' { + import type { Spiceflow } from 'spiceflow' + const app: Spiceflow + export default app +} diff --git a/spiceflow/src/react/utils/fetch.ts b/spiceflow/src/react/utils/fetch.ts new file mode 100644 index 0000000..f0f6283 --- /dev/null +++ b/spiceflow/src/react/utils/fetch.ts @@ -0,0 +1,70 @@ +import type { IncomingMessage, ServerResponse } from 'node:http' +import { Readable } from 'node:stream' + +export function createRequest( + req: IncomingMessage, + res: ServerResponse, +): Request { + const abortController = new AbortController() + res.once('close', () => { + if (req.destroyed) { + abortController.abort() + } + }) + + const headers = new Headers() + for (const [k, v] of Object.entries(req.headers)) { + if (k.startsWith(':')) { + continue + } + if (typeof v === 'string') { + headers.set(k, v) + } else if (Array.isArray(v)) { + v.forEach((v) => headers.append(k, v)) + } + } + + return new Request( + new URL( + req.url || '/', + `${headers.get('x-forwarded-proto') ?? 'http'}://${ + req.headers.host || 'unknown.local' + }`, + ), + { + method: req.method, + body: + req.method === 'GET' || req.method === 'HEAD' + ? null + : (Readable.toWeb(req) as any), + headers, + signal: abortController.signal, + // @ts-ignore for undici + duplex: 'half', + }, + ) +} + +export function sendResponse(response: Response, res: ServerResponse) { + const headers = Object.fromEntries(response.headers) + if (headers['set-cookie']) { + delete headers['set-cookie'] + res.setHeader('set-cookie', response.headers.getSetCookie()) + } + res.writeHead(response.status, response.statusText, headers) + + if (response.body) { + const abortController = new AbortController() + res.once('close', () => abortController.abort()) + res.once('error', () => abortController.abort()) + Readable.fromWeb(response.body as any, { + signal: abortController.signal, + }).pipe(res) + } else { + res.end() + } +} + +export function fromWebToNodeReadable(stream: ReadableStream) { + return Readable.fromWeb(stream as any) +} diff --git a/spiceflow/src/simple.benchmark.ts b/spiceflow/src/simple.benchmark.ts index e5d3195..fe80410 100644 --- a/spiceflow/src/simple.benchmark.ts +++ b/spiceflow/src/simple.benchmark.ts @@ -1,6 +1,6 @@ import { bench } from 'vitest' -import { Spiceflow } from './spiceflow.ts' +import { Spiceflow } from './spiceflow.js' bench('Spiceflow basic routing', async () => { const app = new Spiceflow() diff --git a/spiceflow/src/spiceflow.test.ts b/spiceflow/src/spiceflow.test.ts index 169c863..e7bf93f 100644 --- a/spiceflow/src/spiceflow.test.ts +++ b/spiceflow/src/spiceflow.test.ts @@ -1,8 +1,8 @@ import { test, describe, expect } from 'vitest' -import { bfs, cloneDeep, Spiceflow } from './spiceflow.ts' +import { bfs, cloneDeep, extractWildcardParam, Spiceflow } from './spiceflow.js' 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( @@ -502,6 +505,93 @@ 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('missing route is not found', async () => { const res = await new Spiceflow() @@ -1291,12 +1381,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( @@ -1720,12 +1810,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 () => { @@ -1763,3 +1853,27 @@ test('/* priority - more specific routes always win', async () => { expect(generalCatchRes.status).toBe(200) 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') +}) diff --git a/spiceflow/src/spiceflow.ts b/spiceflow/src/spiceflow.tsx similarity index 70% rename from spiceflow/src/spiceflow.ts rename to spiceflow/src/spiceflow.tsx index a922c22..4e8cb3d 100644 --- a/spiceflow/src/spiceflow.ts +++ b/spiceflow/src/spiceflow.tsx @@ -1,24 +1,28 @@ +import type { ReactFormState } from 'react-dom/client' + import { copy } from 'copy-anything' 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,29 @@ import { SingletonBase, TypeSchema, UnwrapRoute, -} from './types.ts' +} from './types.js' -import OriginalRouter from '@medley/router' +import { createElement } from 'react' import { ZodType } from 'zod' +import { isAsyncIterable, isResponse, isTruthy, redirect } from './utils.js' + +import { + FlightData, + LayoutContent, +} from './react/components.js' +import { + getErrorContext, + isNotFoundError, + isRedirectError, +} from './react/errors.js' +import { TrieRouter } from './trie-router/router.js' +import { decodeURIComponent_ } from './trie-router/url.js' +import { Result } from './trie-router/utils.js' import { StandardSchemaV1 } from '@standard-schema/spec' 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 { SpiceflowContext, MiddlewareContext } from './context.js' let globalIndex = 0 @@ -55,30 +71,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 }) @@ -103,7 +95,7 @@ export class Spiceflow< const out RoutePaths extends string = '', > { private id: number = globalIndex++ - private router: MedleyRouter = new OriginalRouter() + private router: TrieRouter = new TrieRouter() private middlewares: Function[] = [] private onErrorHandlers: OnError[] = [] private routes: InternalRoute[] = [] @@ -141,6 +133,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, @@ -149,6 +161,7 @@ export class Spiceflow< handler, ...rest }: Partial) { + const kind = rest.kind let bodySchema: TypeSchema = hooks?.request || hooks?.body let validateBody = getValidateFunction(bodySchema) let validateQuery = getValidateFunction(hooks?.query) @@ -163,9 +176,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 || '', @@ -174,16 +190,47 @@ 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.find(([route]) => route.path.includes('*'))?.[0] || + 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 + let originalPath = path // remove trailing slash which can cause problems path = path.replace(/\/$/, '') || '/' + const result = bfsFind(this, (app) => { app.topLevelApp = root let prefix = this.joinBasePaths( @@ -197,63 +244,58 @@ export class Spiceflow< pathWithoutPrefix = path.replace(prefix, '') || '/' } - const medleyRoute = app.router.find(pathWithoutPrefix) - if (!medleyRoute) { + const matchedRoutes = app.router.match(method, pathWithoutPrefix) + if (!matchedRoutes?.length) { foundApp = app return } - let internalRoute: InternalRoute = medleyRoute.store[method] - - if (internalRoute) { - const params = medleyRoute.params || {} + // Get all matched routes + const routes = matchedRoutes[0].map(([route, params], index) => ({ + app, + route, + params: this.getAllDecodedParams(matchedRoutes, originalPath, index), + })) - const res = { - app, - internalRoute: internalRoute, - params, - } - return res + if (routes.length) { + return routes } + + // TODO what is this shit? if (method === 'HEAD') { - let internalRouteGet: InternalRoute = medleyRoute.store['GET'] - if (!internalRouteGet?.handler) { - return - } - return { - app, - internalRoute: { - hooks: {}, - handler: async (c) => { - const response = await internalRouteGet.handler(c) - if (isResponse(response)) { - return new Response('', { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }) - } - return new Response(null, { status: 200 }) + const matched = app.router.match('GET', pathWithoutPrefix) + if (matched) { + return [ + { + app, + route: { + hooks: {}, + handler: (c) => { + return new Response(null, { status: 200 }) + }, + method, + path, + } as InternalRoute, + params: this.getAllDecodedParams(matched, originalPath, 0), }, - method, - path, - } as InternalRoute, - params: medleyRoute.params, + ] } } }) 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: {}, + }, + ] ) } @@ -284,6 +326,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 @@ -291,10 +337,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 = @@ -816,6 +864,80 @@ export class Spiceflow< return this as any } + page< + const Path extends string, + const LocalSchema extends InputSchema, + const Schema extends UnwrapRoute, + const Handle extends InlineHandler< + Schema, + Singleton, + JoinPath + >, + >( + path: Path, + handler: Handle, + ): Spiceflow { + const routeConfig = { + path, + handler: handler, + kind: 'page' as const, + } + this.add({ ...routeConfig, method: 'GET' }) + this.add({ ...routeConfig, path: path + '.rsc', 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< + Schema, + Singleton, + JoinPath + >, + >( + path: Path, + handler?: Handle, + ): Spiceflow { + let kind: NodeKind = 'staticPage' + if (!handler) { + kind = 'staticPageWithoutHandler' + } + const routeConfig = { + path, + handler: handler, + kind, + } + this.add({ ...routeConfig, method: 'GET' }) + this.add({ ...routeConfig, path: path + '.rsc', method: 'GET' }) + return this as any + } + layout< + const Path extends string, + const LocalSchema extends InputSchema, + const Schema extends UnwrapRoute, + const Handle extends InlineHandler< + Schema, + Singleton, + JoinPath + >, + >( + path: Path, + handler: Handle, + ): Spiceflow { + const routeConfig = { + path, + handler: handler, + + kind: 'layout' as const, + } + this.add({ ...routeConfig, method: 'GET' }) + this.add({ ...routeConfig, path: path + '.rsc', method: 'GET' }) + this.add({ ...routeConfig, method: 'POST' }) + return this as any + } + private scoped?: Scoped = true as Scoped use( @@ -862,73 +984,275 @@ 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('@vitejs/plugin-rsc/rsc') + + const [pageRoutes, layoutRoutes] = partition( + reactRoutes, + (x) => x.route.kind === 'page' || x.route.kind === 'staticPage', + ) + const pageRoute = pickBestRoute(pageRoutes) + if (!pageRoute) { + return new Response('Not Found', { status: 404 }) + } + + let Page = pageRoute?.route?.handler as any + let 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, + } + 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 stream = renderToReadableStream( + { + root, + returnValue, + formState, + actionError, + } satisfies ServerPayload, + { + // 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) { + console.error('[spiceflow:renderToReadableStream]', error) + return error?.digest || error?.message + }, + signal: request.signal, + }, + ) + + return new Response(stream, { + 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 - let path = u.pathname + u.search - const defaultContext = { + let path = u.pathname + let onErrorHandlers: OnError[] = [] + + // 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, + onErrorHandlers, + error: spiceflowError, + request, + }) + }) + return this.waitUntilFn(wrappedPromise) + } + + const context = { redirect, - error: null, + state: customState || cloneDeep(this.defaultState), + query: parseQuery((u.search || '').slice(1)), + request, path, + params: {}, + waitUntil: wrappedWaitUntil, } const root = this.topLevelApp || this - let onErrorHandlers: OnError[] = [] - const route = this.match(request.method, path) + const routes = this.match(request.method, path) + + const [nonReactRoutes, reactRoutes] = partition( + routes, + (x) => !x.route.kind, + ) + let index = 0 + if (reactRoutes.length) { + const appsInScope = this.getAppsInScope(reactRoutes[0].app) + 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() + } + } + const response = await next() + + return response + } + 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) - let { - params: _params, - app: { defaultState }, - } = route const middlewares = appsInScope.flatMap((x) => x.middlewares) + let { params: _params } = route - let state = customState || copy(defaultState) + let content = route?.route?.hooks?.content - let content = route?.internalRoute?.hooks?.content - - if (route.internalRoute?.validateBody) { + if (route?.route?.validateBody) { // TODO don't clone the request let typedRequest = request instanceof SpiceflowRequest ? request : new SpiceflowRequest(u, request) - typedRequest.validateBody = route.internalRoute?.validateBody + typedRequest.validateBody = route?.route?.validateBody request = typedRequest + context.request = typedRequest } - 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, - error: spiceflowError, - request, - }) - }) - return this.waitUntilFn(wrappedPromise) - } + context['params'] = _params - let context = { - ...defaultContext, - request, - state, - path, - query: parseQuery((u.search || '').slice(1)), - params: _params, - redirect, - waitUntil: wrappedWaitUntil, - } satisfies MiddlewareContext let handlerResponse: Response | undefined async function getResForError(err: any) { + const errCtx = getErrorContext(err) + const redirectInfo = isRedirectError(errCtx) + if (redirectInfo) { + return new Response(redirectInfo.location, { + status: errCtx!.status, + headers: errCtx!.headers, + }) + } + if (isNotFoundError(errCtx)) { + return new Response(JSON.stringify('not found'), { + status: 404, + }) + } if (isResponse(err)) return err let res = await self.runErrorHandlers({ context, @@ -936,6 +1260,7 @@ export class Spiceflow< error: err, request, }) + if (isResponse(res)) return res let status = err?.status ?? err?.statusCode ?? 500 @@ -971,11 +1296,7 @@ export class Spiceflow< if (!result && index < middlewares.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) { @@ -984,28 +1305,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) @@ -1035,7 +1352,7 @@ export class Spiceflow< private async turnHandlerResultIntoResponse( result: any, - route: InternalRoute, + route?: InternalRoute, request?: Request, ): Promise { // if user returns a promise, await it @@ -1047,7 +1364,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( @@ -1116,11 +1433,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: errCtx!.headers, + }) + } } } } @@ -1488,7 +1814,6 @@ export function bfs(tree: AnySpiceflow) { return nodes } - export type AnySpiceflow = Spiceflow export function isZodSchema(value: unknown): value is ZodType { @@ -1569,6 +1894,97 @@ 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 + } + + // Get all segments after wildcard index from url + const wildcardSegments = urlParts.slice(wildcardIndex) + 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 namedParamCount = parts.filter((p) => p.startsWith(':')).length + const segmentCount = parts.length + return { wildcardCount, namedParamCount, 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. Fewer named params wins (static > :param) + if (spec.namedParamCount < bestSpec.namedParamCount) { + best = routes[i] + bestSpec = spec + continue + } + if (spec.namedParamCount > bestSpec.namedParamCount) continue + // 3. More segments wins (longer match) + if (spec.segmentCount > bestSpec.segmentCount) { + best = routes[i] + bestSpec = spec + continue + } + if (spec.segmentCount < bestSpec.segmentCount) continue + // 4. Same pattern shape: last registered wins (override) + best = routes[i] + bestSpec = spec + } + return best +} + +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/static-node.ts b/spiceflow/src/static-node.ts index 3e310e1..d9f0ad6 100644 --- a/spiceflow/src/static-node.ts +++ b/spiceflow/src/static-node.ts @@ -1,7 +1,7 @@ import { stat } from 'fs/promises' import fs from 'fs' -import { ServeStaticOptions, serveStatic as baseServeStatic } from './static.ts' -import { MiddlewareHandler } from './types.ts' +import { ServeStaticOptions, serveStatic as baseServeStatic } from './static.js' +import { MiddlewareHandler } from './types.js' export const serveStatic = (options: ServeStaticOptions): MiddlewareHandler => { const getContent = (path: string) => { diff --git a/spiceflow/src/static.benchmark.ts b/spiceflow/src/static.benchmark.ts index 9b166e0..8cb2ea4 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 e51c003..743a25f 100644 --- a/spiceflow/src/static.ts +++ b/spiceflow/src/static.ts @@ -1,5 +1,5 @@ -import { MiddlewareHandler } from './types.ts' -import { isResponse } from './utils.ts' +import { MiddlewareHandler } from './types.js' +import { isResponse } from './utils.js' type Env = {} type Context = {} diff --git a/spiceflow/src/stream.test.ts b/spiceflow/src/stream.test.ts index 125a452..bb59f0a 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 diff --git a/spiceflow/src/trie-router/node.test.ts b/spiceflow/src/trie-router/node.test.ts new file mode 100644 index 0000000..a8c0501 --- /dev/null +++ b/spiceflow/src/trie-router/node.test.ts @@ -0,0 +1,932 @@ +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, + }, + ], + ], + ] + `) + }) +}) + +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 0000000..55d0638 --- /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 0000000..42f675a --- /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 0000000..e41079d --- /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 0000000..aaacc47 --- /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 4b1bb0e..1a1f87b 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 72e0b69..5e2e180 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 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 @@ -306,7 +312,7 @@ export type Handler< }, Path extends string = '', > = ( - context: Context, + context: SpiceflowContext, ) => MaybePromise< {} extends Route['response'] ? unknown @@ -357,8 +363,8 @@ export type InlineHandler< > = ( this: This, context: MacroContext extends Record - ? Prettify> - : Context, + ? Prettify> + : SpiceflowContext, ) => | ResponseLike | MaybePromiseIterable< @@ -432,7 +438,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 +449,12 @@ export type TransformHandler< > = { ( context: Prettify< - Context< + SpiceflowContext< + BasePath, Route, Omit & { resolve: {} - }, - BasePath + } > >, ): MaybePromise @@ -464,7 +470,7 @@ export type BodyHandler< context: Prettify< { contentType: string - } & Context + } & SpiceflowContext >, contentType: string, @@ -485,7 +491,7 @@ export type AfterResponseHandler< }, > = ( context: Prettify< - Context & { + SpiceflowContext<'', Route, Singleton> & { response: Route['response'] } >, @@ -503,6 +509,7 @@ export type ErrorHandler< }, > = ( context: ErrorContext< + '', Route, { state: Singleton['state'] @@ -582,16 +589,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] } diff --git a/spiceflow/src/utils.ts b/spiceflow/src/utils.ts index 0bfcfb9..0aa6f49 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 0000000..73da080 --- /dev/null +++ b/spiceflow/src/vite.tsx @@ -0,0 +1,222 @@ +// Spiceflow Vite plugin: integrates @vitejs/plugin-rsc for RSC support, +// adds auto "use client" injection for client-by-default behavior, +// provides SSR middleware, virtual modules, and prerender support. +import assert from 'node:assert' +import fs from 'node:fs' +import path from 'node:path' +import url from 'node:url' + +import rsc from '@vitejs/plugin-rsc' +import react from '@vitejs/plugin-react' +import { + type Manifest, + type Plugin, + type PluginOption, + type RunnableDevEnvironment, + type ViteDevServer, +} from 'vite' +import { collectStyleUrls } from './react/css.js' +import { prerenderPlugin } from './react/prerender.js' + +const EXTENSIONS_TO_INJECT = new Set([ + '.js', + '.jsx', + '.ts', + '.tsx', + '.mjs', + '.mts', + '.cjs', + '.cts', +]) + +const DIRECTIVE_RE = /^['"]use (client|server)['"]/m + +export function spiceflowPlugin({ + entry, +}: { + entry: string +}): PluginOption { + let server: ViteDevServer + let browserManifest: Manifest + let rscManifest: Manifest + const resolvedEntry = path.resolve(entry) + + return [ + rsc({ + entries: { + rsc: 'spiceflow/dist/react/entry.rsc', + ssr: 'spiceflow/dist/react/entry.ssr', + client: 'spiceflow/dist/react/entry.client', + }, + serverHandler: false, + rscCssTransform: false, + // 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, + }), + react(), + prerenderPlugin(), + + // Auto "use client" injection: makes all user source files client components by default. + // Only framework internals, the app entry, node_modules, and *.server.* files are excluded. + // TODO remove this + { + name: 'spiceflow:auto-use-client', + enforce: 'pre', + transform(code, id) { + if (DIRECTIVE_RE.test(code)) return + const cleanId = id.split('?')[0] + const ext = path.extname(cleanId) + if (!EXTENSIONS_TO_INJECT.has(ext)) return + if (id.includes('node_modules')) return + if (id.includes('/spiceflow/')) return + if (cleanId === resolvedEntry) return + if (path.basename(cleanId).includes('.server.')) return + return { code: `"use client";\n${code}`, map: null } + }, + }, + + // 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 + }, + ) + }, + }, + + // Add the Node launcher as an additional SSR build input + { + name: 'spiceflow:config', + config: () => ({ + environments: { + ssr: { + build: { + rollupOptions: { + input: { + server: 'spiceflow/dist/react/launchers/node', + }, + }, + }, + }, + }, + }), + }, + + // Write dist/node.js production entry point after build + { + name: 'spiceflow:write-node-entry', + enforce: 'post', + apply: 'build', + closeBundle: { + sequential: true, + async handler() { + await fs.promises.mkdir(path.resolve('dist'), { recursive: true }) + await fs.promises.writeFile( + path.resolve('dist/node.js'), + `import('./ssr/node.js')`, + ) + }, + }, + }, + + // SSR middleware for dev and preview servers + { + name: 'spiceflow:ssr-middleware', + configureServer(_server) { + server = _server + return () => { + server.middlewares.use(async (req, res, next) => { + if (req.url?.includes('__inspect')) return next() + try { + const mod: any = await ( + server.environments.ssr as RunnableDevEnvironment + ).runner.import('spiceflow/dist/react/entry.ssr') + await mod.default(req, res) + } catch (e) { + next(e) + } + }) + } + }, + async configurePreviewServer(previewServer) { + const mod = await import(path.resolve('dist/ssr/index.js')) + return () => { + previewServer.middlewares.use(async (req, res, next) => { + try { + await mod.default(req, res) + } catch (e) { + next(e) + } + }) + } + }, + }, + + // virtual:app-entry — resolves to user's app entry module + createVirtualPlugin('app-entry', () => { + return `export {default} from '${url.pathToFileURL(path.resolve(entry))}'` + }), + + // virtual:app-styles — collects CSS URLs for SSR injection + createVirtualPlugin('app-styles', async function () { + if (this.environment.mode !== 'dev') { + const rscCss = Object.values(rscManifest || {}).flatMap( + (x) => x.css || [], + ) + const clientCss = Object.values(browserManifest || {}).flatMap( + (x) => x.css || [], + ) + const allStyles = [...rscCss, ...clientCss] + .filter(Boolean) + .map((s) => (s && s.startsWith('/') ? s : '/' + s)) + return `export default ${JSON.stringify(allStyles)}` + } + const allStyles = await collectStyleUrls(server.environments['rsc'], { + entries: [entry], + }) + const code = `export default ${JSON.stringify(allStyles)}\n\n` + return code + `if (import.meta.hot) { import.meta.hot.accept() }` + }), + + // Capture Vite manifests during build for CSS collection + { + name: 'spiceflow:capture-manifests', + writeBundle(_options, bundle) { + const manifestAsset = bundle['.vite/manifest.json'] + if (!manifestAsset || manifestAsset.type !== 'asset') return + assert(typeof manifestAsset.source === 'string') + const manifest = JSON.parse(manifestAsset.source) + if (this.environment.name === 'client') browserManifest = manifest + if (this.environment.name === 'rsc') rscManifest = manifest + }, + }, + ] +} + +function createVirtualPlugin(name: string, load: Plugin['load']): Plugin { + const virtualName = 'virtual:' + name + return { + name: `spiceflow:virtual-${name}`, + 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 5d9d99b..2250da6 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 9bc603a..830c76c 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 230a740..bea90bb 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 e29cdb1..c3f5b76 100644 --- a/spiceflow/vitest.config.js +++ b/spiceflow/vitest.config.js @@ -1,5 +1,6 @@ // vite.config.ts import { defineConfig } from 'vite' +import { spiceflowPlugin } from './dist/vite' const execArgv = process.env.PROFILE ? ['--cpu-prof', '--cpu-prof-dir=./profiling'] @@ -9,9 +10,19 @@ export default defineConfig({ esbuild: { jsx: 'transform', }, + // plugins: [ + // spiceflowPlugin({ + // // options + // }), + // ], + resolve: { + conditions: ['react-server'], + }, + test: { exclude: ['**/dist/**', '**/esm/**', '**/node_modules/**', '**/e2e/**'], pool: 'threads', + // updateSnapshot: true, poolOptions: { threads: { singleThread: true,