prxy.monster API-key BYOK is live. Start free

Module interface

A module is a TypeScript object with a stable shape. Two required fields, four optional hooks.

Install

pnpm add @prxy/module-sdk @prxy/shared-types

Hello world

import type { Module } from '@prxy/module-sdk';
 
export const hello: Module = {
  name: 'hello',
  version: '1.0.0',
 
  async pre(ctx) {
    ctx.logger.info('hello from a module');
    return { continue: true };
  },
};

That’s a complete, valid module. It does nothing useful but the gateway will load and run it.

The full interface

export interface Module {
  /** Stable module name. Must match the string used in PRXY_PIPE entries. */
  name: string;
 
  /** Semver. Surfaced in /v1/pipeline responses for debugging. */
  version: string;
 
  /** Optional: one-shot setup at gateway start. Runs once per process. */
  init?(storage: StorageAdapter): Promise<void>;
 
  /** Optional: pre-request hook. Can mutate the request or short-circuit. */
  pre?(ctx: RequestContext): Promise<PreResult>;
 
  /** Optional: per-chunk hook for streaming responses. */
  stream?(chunk: CanonicalChunk, ctx: ResponseContext): Promise<CanonicalChunk>;
 
  /** Optional: post-response hook. Side effects only. */
  post?(ctx: ResponseContext): Promise<void>;
}

RequestContext

Passed to pre. Read-write.

export interface RequestContext {
  request: CanonicalRequest;       // mutable — change anything
  metadata: Map<string, unknown>;  // shared across modules
  storage: StorageAdapter;         // KV / DB / blob
  apiKey: ApiKeyInfo;              // { id, userId, tier, ... }
  logger: Logger;                  // pino-style
  startTime: number;               // ms timestamp
}

ResponseContext

Passed to stream and post. Adds response and timing.

export interface ResponseContext extends RequestContext {
  response: CanonicalResponse;
  durationMs: number;
}

PreResult

The return type of pre. Two variants:

export type PreResult =
  | { continue: true }
  | { continue: false; response: CanonicalResponse };

Returning continue: false short-circuits the pipeline — remaining pre hooks and the provider call are skipped. Post hooks still run on the supplied response (this is how cost-guard returns 429s and how caches return hits).

A real example: a counter module

import type { Module } from '@prxy/module-sdk';
 
export function requestCounter(): Module {
  return {
    name: 'request-counter',
    version: '1.0.0',
 
    async init(storage) {
      // initialize the count if it doesn't exist
      const existing = await storage.kv.get('counter:total');
      if (!existing) await storage.kv.set('counter:total', '0');
    },
 
    async pre(ctx) {
      const total = await ctx.storage.kv.get('counter:total');
      ctx.metadata.set('request_number', Number.parseInt(total ?? '0', 10) + 1);
      return { continue: true };
    },
 
    async post(ctx) {
      // increment in the post hook so failed pre-hooks don't inflate the count
      const total = await ctx.storage.kv.get('counter:total');
      const n = Number.parseInt(total ?? '0', 10) + 1;
      await ctx.storage.kv.set('counter:total', n.toString());
      ctx.logger.info({ total: n }, 'request counted');
    },
  };
}

A real example: the cost-guard module

This is an abridged version of the built-in cost-guard module:

import type { Module } from '@prxy/module-sdk';
import { calculateActualCost, estimateRequestCost } from './lib/cost.js';
import { errorResponse } from './lib/errors.js';
 
export interface CostGuardConfig {
  perRequest?: number;
  perDay?: number;
  perMonth?: number;
}
 
export function costGuard(config: CostGuardConfig = {}): Module {
  return {
    name: 'cost-guard',
    version: '1.0.0',
 
    async pre(ctx) {
      const estimated = estimateRequestCost(ctx.request);
      ctx.metadata.set('cost.estimated', estimated);
 
      if (config.perRequest != null && estimated > config.perRequest) {
        return {
          continue: false,
          response: errorResponse('cost_limit_per_request', 'Request exceeds cap', {
            limit: config.perRequest, estimated, status: 429,
          }),
        };
      }
      // ...per-day, per-month checks omitted...
      return { continue: true };
    },
 
    async post(ctx) {
      if (ctx.response.stopReason === 'error') return;
      const actual = calculateActualCost(ctx.request, ctx.response);
      ctx.metadata.set('cost.actual', actual);
      // ...write spend counters to KV...
    },
  };
}

The same shape your custom modules will follow.

Where modules live

Module factory functions (the costGuard(config) shape) are the convention. Direct singleton exports work too — but a factory makes per-key config cleaner.

See also