Spiceflow Framework

repository·main·Indexed 41 days ago

https://github.com/remorses/spiceflow

A type-safe API and React Server Components (RSC) framework designed for simplicity and cross-runtime compatibility across Node.js, Bun, and Cloudflare Workers. It supports defining API routes and server-rendered UI in a single application, featuring a Vite plugin for full-stack React integration and a federation system for consuming RSC payloads in various host applications.

Tokens
124.1K
Snippets
343
Records
396
Agent score
48%

What's inside Spiceflow

  1. Optimize Federation for Streaming and Suspense

    main

    The way you structure your encodeFederationPayload call determines whether your content streams incrementally or buffers entirely.

    Top-level JSX (Buffered)

    If you pass a React element directly to encodeFederationPayload, the entire flight stream is buffered. The consumer receives SSR HTML for instant display, but Suspense boundaries inside the element will not stream incrementally. The client waits for all promises to resolve before showing content.

    Object with JSX fields (Streaming)

    If you pass a plain object containing JSX, the flight rows stream incrementally. Suspense fallbacks arrive immediately, and resolved content streams in as it becomes available. This is the recommended pattern for slow data fetches or AI responses.

    Comparison:

    PatternSSR PreviewIncremental StreamingUse Case
    encodeFederationPayload(<JSX />)Yes (Instant HTML)No (Buffered)Static/Fast content
    encodeFederationPayload({ key: <JSX /> })NoYesSlow data/AI/Suspense

    Example: Streaming via Object

    Remote Side:

    app.get('/api/dashboard', async () => {
      return await encodeFederationPayload({
        chart: (
          <Suspense fallback={<div>Loading...</div>}>
            <SlowChart />
          </Suspense>
        ),
      })
    })

    Client Side:

    'use client'
    import { useEffect, useState } from 'react'
    import { decodeFederationPayload } from 'spiceflow/react'
    
    export function Dashboard() {
      const [chart, setChart] = useState<React.ReactNode>(null)
    
      useEffect(() => {
        fetch('/api/dashboard')
          .then((res) => decodeFederationPayload<{ chart: React.ReactNode }>(res))
          .then((decoded) => setChart(decoded.chart))
      }, [])
    
      return <div>{chart}</div>
    }
  2. How Federation works to compose Spiceflow apps

    main

    Federation allows you to compose multiple Spiceflow applications at the React Server Component (RSC) level using a remote and host model.

    • Remote App: The producer. It exposes components by returning an encodeFederationPayload(...) response.
    • Host App: The consumer. It embeds remote components into its own UI with full SSR, hydration, and client interactivity.

    Remote components can be fetched from a Spiceflow API, an external ESM module (like esm.sh), or a direct JS URL (like Framer). The host uses RenderFederatedPayload to render these components, which must be wrapped in a <Suspense> boundary to handle loading states during federation or module loading.

    import { Suspense } from 'react'
    import { RenderFederatedPayload } from 'spiceflow/react'
    
    // Example: Fetching a remote component
    const response = await fetch('https://my-remote.com/api/chart?props=' + encodeURIComponent(JSON.stringify({
      dataSource: 'revenue',
    })))
    
    <Suspense fallback={<div>Loading...</div>}>
      <RenderFederatedPayload response={response} />
    </Suspense>
  3. Dependency Injection using State

    main

    Spiceflow allows you to register dependencies (like database clients, KV stores, or auth services) using the .state(key, value) method. In tests, you can inject test doubles (fakes/mocks) by passing a state object to createSpiceflowFetch(app, { state }).

    This approach avoids module mocking or patching globals. Because state is deep-cloned per request by default, overrides provided via createSpiceflowFetch or app.handle(req, { state }) only affect that specific call, ensuring test isolation.

    // main.test.ts
    import { test, expect } from 'vitest'
    import { createSpiceflowFetch } from 'spiceflow/client'
    import { app } from './main.js'
    
    // In-memory KV for tests
    const store = new Map<string, any>()
    const fakeKV = {
      async get(key: string) { return store.get(key) },
      async put(key: string, value: any) { store.set(key, value) },
    }
    
    const testUser = { id: 'u1', name: 'Tommy' }
    
    // Typed fetch with test state injected
    const f = createSpiceflowFetch(app, {
      state: { kv: fakeKV, user: testUser },
    })
    
    test('authenticated user can create and list projects', async () => {
      store.clear()
    
      await f('/api/projects', {
        method: 'POST',
        body: { id: 'p1', name: 'My App' },
      })
    
      expect(store.get('project:p1')).toEqual({ id: 'p1', name: 'My App' })
    })
  4. Understand the structure of route changes

    main

    When compare() detects breaking changes, it populates the changedRoutes array. Each entry in this array is a Changed route object containing:

    • method (string): HTTP method.
    • path (string): Route path.
    • sourceSchema (object): The original route schema.
    • targetSchema (object): The new route schema.
    • changes (array): A list of Route change objects.

    Route change objects describe what specifically changed within the route. The type field identifies the component:

    • parameter: Changes to query, header, path, or cookie parameters.
    • requestBody: Changes to the request body.
    • responseBody: Changes to response bodies (includes statusCode and mediaType).
    • responseHeader: Changes to response headers.

    Each change object includes an action (added, deleted, or changed) and a comment describing the modification.

  5. Secure Server Actions and Routes

    main

    Server actions ("use server") and API routes (.get(), .post(), etc.) are public POST endpoints. They can be called by any HTTP client, not just your application's UI.

    Security Requirement: Every server action that mutates data, creates resources, or reads user-specific data MUST explicitly authenticate and authorize the request (e.g., by reading a session from cookies or a bearer token from headers). Do not rely on CSRF protection alone to secure your logic.

    'use server'
    
    import { getActionRequest } from 'spiceflow'
    import { getUser } from './auth'
    
    export async function deleteProject(id: string) {
      const { request } = getActionRequest()
      const user = await getUser(request)
      
      if (!user) throw new Error('Not authenticated')
      if (!user.canDelete(id)) throw new Error('Not authorized')
      
      await db.project.delete({ where: { id } })
    }
  6. SpiceflowRequest Properties

    main

    The request object provided to handlers and middleware is a SpiceflowRequest, which extends the standard Web Request API with the following additional properties:

    • request.parsedUrl: A lazily cached URL object (provides access to .pathname, .searchParams, etc.).
    • request.json() / request.formData(): Parses and validates the body against the route's Zod schema in one step, returning typed data.
    • request.originalUrl: The raw transport URL before Spiceflow normalizes .rsc pathnames.
  7. Optimize Code Splitting for Client Components

    main

    Code splitting for client components is automatic in Spiceflow. Each file marked with 'use client' becomes a separate chunk.

    Avoid Barrel Files: Do not use a single 'use client' barrel file (e.g., src/components/index.tsx) to re-export multiple components. This forces all exported components into a single large chunk.

    Best Practice: Place the 'use client' directive in each individual component file, and use a standard barrel file (without the 'use client' directive) to re-export them. This ensures each component is its own chunk and is only loaded when needed.

    // BAD — one big chunk for everything
    // src/components/index.tsx
    'use client'
    export { Chart } from './chart'
    export { Map } from './map'
    
    // GOOD — each component is its own chunk
    // src/components/chart.tsx
    'use client'
    export function Chart() { ... }
    
    // src/components/map.tsx
    'use client'
    export function Map() { ... }
    
    // src/components/index.tsx (No 'use client' here)
    export { Chart } from './chart'
    export { Map } from './map'
  8. Understand autocomplete limitations in the app chain

    main

    While the TypeScript compiler (tsc) provides full validation for all routes, IDE autocomplete (IntelliSense) has a known limitation when working inside the .page() builder chain:

    • Inside the chain: Autocomplete may only show routes defined above the current position in the builder chain. This is because the language server resolves types incrementally.
    • Outside the chain: In separate component files or server components, you will get full autocomplete for all registered routes.

    Recommendation: For the best developer experience, use router.href() within component files rather than inside the initial new Spiceflow().page(...) chain to ensure full autocomplete functionality.

  9. Ensure proper cancellation in Federated RSC streaming

    main

    When working with Federated React Server Components (RSC), ensure that cancellation is wired through both the outer SSE Response stream and the inner Flight stream/parser.

    If either the cancel() or return() cleanup methods are missing, aborted decodes will continue reading in the background. This leads to background work leaks even if the streaming appears to function correctly during successful (happy-path) requests.

  10. Handle array query parameters

    main

    Spiceflow supports array query parameters using repeated keys in the URL (e.g., ?tag=a&tag=b). If your schema defines a field as z.array(), Spiceflow automatically wraps single values into an array. For example, ?tag=react will be parsed as ['react'].

    export const app = new Spiceflow().route({
      method: 'GET',
      path: '/api/posts',
      query: z.object({
        tag: z.array(z.string()),
        limit: z.number().optional(),
      }),
      handler({ query }) {
        // query.tag is always string[], even with a single ?tag=react
        return getPostsByTags(query.tag)
      },
    })
  11. Understand the Cloudflare storage isolation model

    main

    Spiceflow testing on Cloudflare follows a per-test-file isolation model:

    1. Isolation: Each test file gets a fresh storage snapshot (D1, KV, R2, Durable Objects). Writes in one file do not affect another.
    2. Concurrency: Different files run concurrently with their own isolated storage.
    3. Shared State: Tests within the same file share state. If you need per-test isolation within a single file, you must manually clean up (e.g., DELETE FROM table) in beforeEach or afterEach.
    4. Workarounds:
      • Shared state across files: Run Vitest with --max-workers=1 --no-isolate.
      • WebSockets + Durable Objects: These do not work with per-file isolation; use --max-workers=1 --no-isolate.
  12. Place <Head> in server-side handlers only

    main

    The <Head> component is server-only. It does not render anything to the DOM; instead, it records its children during the React Server Components (RSC) render phase so Spiceflow can build the document head.

    Rules for <Head>:

    1. Never use <Head> inside a 'use client' component. Doing so will cause the build to fail or throw a runtime error.
    2. Always place <Head> inside .page() or .layout() handlers.
    3. To update the document title from the browser after the initial load, use document.title inside a useEffect hook in a client component.
    .page('/', async () => {
      return (
        <>
          <Head>
            <Head.Title>Dashboard | My App</Head.Title>
            <Head.Meta name="description" content="Your account overview." />
          </Head>
          <InteractiveDashboard />
        </>
      )
    })