Skip to content

EreoJS Plugin

Integration plugin for EreoJS framework with automatic routing and WebSocket support.

Import

ts
import { rpcPlugin } from '@ereo/rpc'
import type {
  RPCPluginOptions,
  RPCPluginResult,
  BunWebSocketConfig,
  RPCPlugin,
} from '@ereo/rpc'

rpcPlugin

Creates an EreoJS plugin for RPC integration.

Signature

ts
function rpcPlugin<T extends RouterDef>(
  options: RPCPluginOptions<T>
): RPCPluginResult

Type Definitions

ts
interface RPCPluginOptions<T extends RouterDef = RouterDef> {
  /** The router instance */
  router: Router<T>
  /** Endpoint path for HTTP and WebSocket (default: '/api/rpc') */
  endpoint?: string
}

interface RPCPluginResult extends RPCPlugin {
  /** Get WebSocket handler config for Bun.serve() */
  getWebSocketConfig(): BunWebSocketConfig<WSConnectionData>
  /** Upgrade a request to WebSocket */
  upgradeToWebSocket(server: any, request: Request, ctx: any): boolean
}

interface RPCPlugin {
  name: string
  runtimeMiddleware?: Array<
    (request: Request, context: any, next: () => Promise<Response>) => Response | Promise<Response>
  >
  virtualModules?: Record<string, string>
}

Parameters

OptionTypeDefaultDescription
routerRouter<T>RequiredThe RPC router instance
endpointstring'/api/rpc'URL path for RPC requests

Returns

An RPCPluginResult with:

  • name - Plugin name ('@ereo/rpc')
  • runtimeMiddleware - Middleware for HTTP request handling
  • virtualModules - Virtual module for typed client
  • getWebSocketConfig() - WebSocket handler configuration
  • upgradeToWebSocket() - WebSocket upgrade helper

Examples

Basic Setup

ts
import { createRouter, rpcPlugin } from '@ereo/rpc'
import { createContext } from '@ereo/core'

const api = createRouter({
  health: procedure.query(() => ({ status: 'ok' })),
})

const rpc = rpcPlugin({
  router: api,
  endpoint: '/api/rpc',
})

Bun.serve({
  port: 3000,

  fetch(request, server) {
    const ctx = createContext(request)

    // Handle WebSocket upgrade
    if (rpc.upgradeToWebSocket(server, request, ctx)) {
      return undefined
    }

    // Handle HTTP
    const url = new URL(request.url)
    if (url.pathname === '/api/rpc') {
      return api.handler(request, ctx)
    }

    return new Response('Not Found', { status: 404 })
  },

  websocket: rpc.getWebSocketConfig(),
})

With EreoJS Config

ts
// ereo.config.ts
import { defineConfig } from '@ereo/core'
import { rpcPlugin } from '@ereo/rpc'
import { api } from './api/router'

export default defineConfig({
  plugins: [
    rpcPlugin({
      router: api,
      endpoint: '/api/rpc',
    }),
  ],
})

Custom Endpoint

ts
const rpc = rpcPlugin({
  router: api,
  endpoint: '/rpc/v1',
})

// Requests go to /rpc/v1
// WebSocket upgrades at ws://host/rpc/v1

getWebSocketConfig

Returns the WebSocket handler configuration for Bun.serve().

Signature

ts
getWebSocketConfig(): BunWebSocketConfig<WSConnectionData>

Type Definitions

ts
interface BunWebSocketConfig<T> {
  message: (ws: any, message: string | Buffer) => void | Promise<void>
  open?: (ws: any) => void | Promise<void>
  close?: (ws: any) => void | Promise<void>
  drain?: (ws: any) => void
}

interface WSConnectionData {
  subscriptions: Map<string, AbortController>
  ctx: any
  originalRequest?: Request
}

Returns

WebSocket handler object with:

  • message - Handles incoming messages (subscribe/unsubscribe/ping)
  • open - Initializes subscription tracking on connect
  • close - Cleans up subscriptions on disconnect

Example

ts
const rpc = rpcPlugin({ router: api })

Bun.serve({
  fetch(request, server) { /* ... */ },

  // Pass WebSocket config directly to Bun
  websocket: rpc.getWebSocketConfig(),
})

upgradeToWebSocket

Upgrades an HTTP request to a WebSocket connection for subscriptions.

Signature

ts
upgradeToWebSocket(server: any, request: Request, ctx: any): boolean

Parameters

ParameterTypeDescription
serveranyBun server instance
requestRequestIncoming HTTP request
ctxanyApplication context

Returns

true if the request was upgraded to WebSocket, false otherwise.

Behavior

  1. Checks if request path matches the configured endpoint
  2. Checks for Upgrade: websocket header
  3. Creates WSConnectionData with context and empty subscription map
  4. Calls server.upgrade(request, { data }) to upgrade the connection
  5. Returns true if upgrade was successful

Example

ts
const rpc = rpcPlugin({ router: api, endpoint: '/api/rpc' })

Bun.serve({
  fetch(request, server) {
    const ctx = createContext(request)

    // Check for WebSocket upgrade first
    if (rpc.upgradeToWebSocket(server, request, ctx)) {
      return undefined // Request was upgraded, no response needed
    }

    // Handle normal HTTP requests
    return handleRequest(request, ctx)
  },

  websocket: rpc.getWebSocketConfig(),
})

WebSocket Authentication Pattern

When upgrading to WebSocket, the original HTTP request is preserved in ws.data.originalRequest. This enables authentication in subscription middleware.

How It Works

ts
// During upgrade, the original request is stored
const data: WSConnectionData = {
  subscriptions: new Map(),
  ctx,
  originalRequest: request,  // Preserved for middleware access
}

server.upgrade(request, { data })

Accessing Auth in Subscriptions

Subscription middleware receives the original request, allowing access to cookies and auth headers:

ts
// Session-based auth middleware
const sessionAuth = createAuthMiddleware(async (ctx) => {
  // ctx.request is the original HTTP upgrade request
  const cookies = ctx.request.headers.get('Cookie')
  const sessionId = cookies?.match(/session=([^;]+)/)?.[1]

  if (!sessionId) return null

  const session = await db.sessions.findUnique({
    where: { id: sessionId },
    include: { user: true },
  })

  if (!session || session.expiresAt < new Date()) return null
  return session.user
})

// Use in subscription
const protectedSubscription = procedure
  .use(sessionAuth)
  .subscription(async function* ({ user }) {
    // user is authenticated via session cookie
    for await (const event of userEvents.on(user.id)) {
      yield event
    }
  })

Same-origin requests: Cookies are automatically included in WebSocket upgrade requests when:

  • The WebSocket URL has the same origin as the page
  • Cookies are set with appropriate SameSite policy

Cross-origin requests: Require explicit credential handling:

ts
// Client-side: Include credentials
const ws = new WebSocket(wsUrl)
// Note: WebSocket API doesn't support credentials option directly
// Use the same origin or token-based auth for cross-origin

// Alternative: Token-based auth
const wsUrl = `wss://api.example.com/rpc?token=${authToken}`

Example: Token-Based Auth for WebSocket

ts
// Server middleware
const tokenAuth = createAuthMiddleware(async (ctx) => {
  // Check Authorization header first (HTTP requests)
  let token = ctx.request.headers.get('Authorization')?.replace('Bearer ', '')

  // Fall back to query parameter (WebSocket upgrade)
  if (!token) {
    const url = new URL(ctx.request.url)
    token = url.searchParams.get('token')
  }

  if (!token) return null
  return verifyJWT(token)
})

Manual WebSocket Upgrade

For more control, you can handle upgrades manually:

ts
Bun.serve({
  fetch(request, server) {
    const url = new URL(request.url)
    const ctx = createContext(request)

    // Manual WebSocket handling
    if (
      url.pathname === '/api/rpc' &&
      request.headers.get('Upgrade')?.toLowerCase() === 'websocket'
    ) {
      const success = server.upgrade(request, {
        data: {
          subscriptions: new Map(),
          ctx,
          originalRequest: request,
        },
      })

      if (success) return undefined
      return new Response('WebSocket upgrade failed', { status: 500 })
    }

    // HTTP handling
    if (url.pathname === '/api/rpc') {
      return api.handler(request, ctx)
    }

    return new Response('Not Found', { status: 404 })
  },

  websocket: api.websocket,
})

Virtual Modules

The plugin provides a virtual module for automatic client configuration.

Module: virtual:ereo-rpc-client

ts
// Generated virtual module content
import { createClient } from '@ereo/rpc/client'

const wsProtocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsEndpoint = typeof window !== 'undefined'
  ? wsProtocol + '//' + window.location.host + '/api/rpc'
  : 'ws://localhost:3000/api/rpc'

export const rpc = createClient({
  httpEndpoint: '/api/rpc',
  wsEndpoint,
})

Usage

ts
// In your client code
import { rpc } from 'virtual:ereo-rpc-client'

// Fully configured client with automatic endpoint detection
const user = await rpc.users.me.query()

TypeScript Support

For type safety, augment the virtual module:

ts
// vite-env.d.ts or types.d.ts
declare module 'virtual:ereo-rpc-client' {
  import type { InferClient } from '@ereo/rpc'
  import type { Api } from './api/router'

  export const rpc: InferClient<Api['_def']>
}

Runtime Middleware

The plugin includes runtime middleware for request routing.

Behavior

  1. Checks if request path matches the endpoint
  2. For WebSocket upgrades, returns a 101 response with special header
  3. For HTTP requests, delegates to the router handler
  4. For non-matching requests, calls next() to continue

Integration with EreoJS

ts
// The middleware is automatically registered when using the plugin
const rpc = rpcPlugin({ router: api })

// Middleware function signature:
async function rpcMiddleware(
  request: Request,
  context: any,
  next: () => Promise<Response>
): Promise<Response> {
  const url = new URL(request.url)

  if (url.pathname === endpoint) {
    // Handle WebSocket upgrade request
    if (request.headers.get('Upgrade')?.toLowerCase() === 'websocket') {
      return new Response(null, {
        status: 101,
        headers: { 'X-Ereo-RPC-Upgrade': 'websocket' },
      })
    }

    // Handle HTTP request
    return router.handler(request, context)
  }

  return next()
}

Complete Server Example

ts
// server.ts
import { createContext } from '@ereo/core'
import { createRouter, rpcPlugin, procedure } from '@ereo/rpc'
import { z } from 'zod'

// Define router
const api = createRouter({
  health: procedure.query(() => ({ status: 'ok', time: Date.now() })),

  users: {
    me: authedProcedure.query(({ user }) => user),
    list: adminProcedure.query(async () => db.users.findMany()),
  },

  notifications: {
    onNew: authedProcedure.subscription(async function* ({ user }) {
      for await (const notification of notificationEvents.on(user.id)) {
        yield notification
      }
    }),
  },
})

export type Api = typeof api

// Create plugin
const rpc = rpcPlugin({
  router: api,
  endpoint: '/api/rpc',
})

// Start server
Bun.serve({
  port: process.env.PORT ?? 3000,

  fetch(request, server) {
    const ctx = createContext(request)

    // 1. Try WebSocket upgrade
    if (rpc.upgradeToWebSocket(server, request, ctx)) {
      return undefined
    }

    const url = new URL(request.url)

    // 2. Handle RPC endpoint
    if (url.pathname === '/api/rpc') {
      return api.handler(request, ctx)
    }

    // 3. Serve static files or other routes
    return serveStatic(request) ?? new Response('Not Found', { status: 404 })
  },

  websocket: rpc.getWebSocketConfig(),
})

console.log('Server running on http://localhost:3000')

Released under the MIT License.