Arcwayv0.3.0

Routing

File-based routing, route handlers, middleware, and non-JSON responses

File-Based Route Discovery

Routes are discovered from api/**/*.js. The file path relative to the api/ folder determines the URL pattern. The prefix is configurable via api.pathPrefix in arcway.config.js (default: '/api'):

File PathURL Pattern (with default /api prefix)
api/users/index.js/api/users
api/users/search.js/api/users/search
api/users/[id].js/api/users/:id
api/users/[id]/projects.js/api/users/:id/projects
api/billing/invoices/index.js/api/billing/invoices
api/billing/invoices/[invoiceId].js/api/billing/invoices/:invoiceId
api/files/[...path].js/api/files/*path (catch-all)

Path Prefix:

  • Default: '/api' — routes are served at /api/users, /api/users/:id, etc.
  • Set api: { pathPrefix: '/v1' } for versioned APIs: /v1/users, /v1/users/:id
  • Set api: { pathPrefix: '' } to serve routes without a prefix: /users, /users/:id

Rules:

  • The file path within api/ maps to the URL (after the prefix)
  • index.js maps to the directory root
  • [param] in filenames become :param path parameters — merged into ctx.req.query
  • [...rest] catch-all segments match one or more path segments
  • [[...rest]] optional catch-all matches zero or more path segments
  • Files starting with _ are skipped (reserved for middleware)
  • Static routes take priority over parameterized routes, which take priority over catch-all routes

Catch-All Routes

Use [...param] to match the rest of the URL path. The matched segments are available as an array in ctx.req.query:

// api/files/[...path].js → matches /api/files/images/photo.png
export const GET = {
  handler: async (ctx) => {
    const { log } = ctx;
    const filePath = ctx.req.query.path; // ['images', 'photo.png']
    log.info('File requested', { path: filePath.join('/') });
    return { data: { file: filePath.join('/') } };
  },
};

Use [[...param]] (double brackets) for an optional catch-all that also matches the parent path:

// api/docs/[[...slug]].js → matches /api/docs AND /api/docs/guide/intro
export const GET = {
  handler: async (ctx) => {
    const slug = ctx.req.query.slug; // undefined for /api/docs, ['guide', 'intro'] for /api/docs/guide/intro
    return { data: { slug: slug ?? [] } };
  },
};

HTTP Methods

Export named constants for each HTTP method you want to handle:

// api/users/[id].js
export const GET = {
  schema: {
    query: { id: /^\d+$/ },
  },
  handler: async (ctx) => {
    const { db } = ctx;
    const user = await db('users').where('id', Number(ctx.req.query.id)).first();
    if (!user) {
      return { status: 404, error: { code: 'NOT_FOUND', message: 'User not found' } };
    }
    return { data: user };
  },
};

export const PUT = {
  schema: {
    query: { id: /^\d+$/ },
    body: {
      'name?': 'string >= 1',
      'email?': 'string.email',
    },
  },
  handler: async (ctx) => {
    const { db } = ctx;
    const id = Number(ctx.req.query.id);
    await db('users').where('id', id).update(ctx.req.body);
    const user = await db('users').where('id', id).first();
    return { data: user };
  },
};

export const DELETE = {
  schema: {
    query: { id: /^\d+$/ },
  },
  handler: async (ctx) => {
    const { db } = ctx;
    await db('users').where('id', Number(ctx.req.query.id)).delete();
    return { status: 204, data: null };
  },
};

Supported methods: GET, POST, PUT, PATCH, DELETE.


Route Handlers

Handler Context

Route handlers receive a ctx object containing both infrastructure and request data:

// ctx contains:
{
  // Infrastructure
  db,       // Knex database connection
  events,   // Event emitter (emit, subscribe)
  queue,    // Persistent queue (push, pop, remove)
  cache,    // Key-value cache (get, set, delete, wrap)
  files,    // File storage (write, read, delete, list, exists)
  mail,     // Email (send, queue)
  log,      // Logger (debug, info, warn, error)

  // Request
  req: {
    id,         // UUID or from X-Request-Id header
    ip,         // Client IP address (respects trustProxy config)
    method,     // HTTP method
    path,       // URL path
    query,      // Path params + query string (merged, validated)
    body,       // Request body (parsed JSON, validated)
    rawBody,    // Raw request body string (POST/PUT/PATCH/DELETE only)
    headers,    // Request headers (flattened)
    cookies,    // Parsed Cookie header
    session,    // Unsealed session data (if configured)
  },
}

Path parameters are merged into ctx.req.query. A route file api/users/[id].js accessed at /users/42?expand=profile produces ctx.req.query = { id: '42', expand: 'profile' }. Path params take precedence over query string params with the same name.

Route Config

Each exported method constant is a route config object:

{
  schema: {
    query: { ... },         // ArkType schema for query + path params
    body: { ... },          // ArkType schema for request body
  },
  parseBody: true,          // Default: true. Set false to skip JSON parsing (ctx.req.body = raw string)
  meta: {
    summary: 'Get user',    // For OpenAPI docs
    description: '...',
    tags: ['users'],
  },
  handler: async (ctx) => { ... },
}

Route Response

Handlers return a response object:

{
  status: 200,              // Default: 200 (success) or 400 (error)
  data: { ... },            // Sent as the response body directly
  error: {                  // Wrapped in { error: ... }
    code: 'NOT_FOUND',
    message: 'User not found',
    details: { ... },       // Optional
  },
  headers: { ... },         // Custom response headers
  session: { userId: 42 },  // Set session cookie
  // session: null           // Clear session cookie
}

Response format:

  • Success: the value of data is sent directly as the response body, with status 200
  • Error: { "error": { "code": "...", "message": "..." } } with status 400
  • Handler exceptions return 500 with a generic error (no internal details exposed)

Non-JSON Responses

To return non-JSON content, set a custom Content-Type header:

// api/reports/export.js
export const GET = {
  handler: async (ctx) => {
    const csv = 'name,email\nAlice,alice@test.com\nBob,bob@test.com';
    return {
      data: csv,
      headers: {
        'Content-Type': 'text/csv',
        'Content-Disposition': 'attachment; filename="report.csv"',
      },
    };
  },
};

Supported data types in response.data:

Data TypeBehavior
stringSent as-is with Content-Length
BufferSent as raw binary with correct Content-Length
Readable (Node.js stream)Piped directly to the HTTP response
ReadableStream (Web stream)Converted to Node.js Readable and piped

Schema Validation with ArkType

export const POST = {
  schema: {
    query: {
      'expand?': "'profile' | 'posts'",
    },
    body: {
      name: 'string >= 1 & string <= 100',
      email: 'string.email',
      'age?': 'number.integer > 0',
    },
  },
  handler: async (ctx) => {
    // ctx.req.body is validated and coerced
    const { name, email } = ctx.req.body;
    // ...
  },
};

When validation fails, the framework returns 400 with:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid request body",
    "details": { }
  }
}

Middleware

_middleware.js Convention

Place _middleware.js files in the api/ directory tree. They apply to all routes at that path level and below, cascading from parent to child.

api/
├── _middleware.js               # Applies to ALL routes (global)
├── users/
│   ├── _middleware.js           # Applies to all /users/* routes
│   ├── index.js                 # GET/POST /users
│   ├── [id].js                  # GET/PUT/DELETE /users/:id
│   └── admin/
│       ├── _middleware.js        # Applies to /users/admin/* (runs after parent)
│       └── index.js              # GET /users/admin
└── billing/
    └── invoices/
        └── index.js              # GET /billing/invoices

Middleware Config

Middleware exports an object (or array of objects) with a handler function. The handler receives the same ctx object as route handlers. Return a response to short-circuit, or return undefined to continue.

// api/users/_middleware.js
export default {
  handler: async (ctx) => {
    const { log } = ctx;
    log.info(`${ctx.req.id}: incoming ${ctx.req.method} ${ctx.req.path}`);
    // Return nothing — continue to next middleware or handler
  },
};

Schema Validation in Middleware

Middleware can declare schema to validate query or body before the handler runs:

// api/_middleware.js — require API key in query string
export default {
  schema: {
    query: { apiKey: 'string >= 1' },
  },
  handler: async (ctx) => {
    const { log } = ctx;
    log.info(`API key: ${ctx.req.query.apiKey}`);
  },
};

Short-Circuit (e.g., Auth)

Return a response to block the request from reaching the handler:

// api/admin/_middleware.js
export default {
  handler: async (ctx) => {
    if (!ctx.req.session?.userId) {
      return {
        status: 401,
        error: { code: 'UNAUTHORIZED', message: 'Login required' },
      };
    }
    // Return nothing — continue to handler
  },
};

Multiple Middleware

Export an array of configs to apply multiple middleware in order:

// api/users/_middleware.js
const logging = {
  handler: async (ctx) => {
    const { log } = ctx;
    log.info(`${ctx.req.id}: incoming`);
  },
};

const requireAuth = {
  handler: async (ctx) => {
    if (!ctx.req.session?.userId) {
      return { status: 401, error: { code: 'UNAUTHORIZED', message: 'Login required' } };
    }
  },
};

export default [logging, requireAuth];

Method-Specific Middleware

Use named exports (GET, POST, PUT, PATCH, DELETE) to apply middleware only to specific HTTP methods. The default export still applies to all methods:

// api/_middleware.js
// Default: runs for ALL methods
export default {
  handler: async (ctx) => {
    const { log } = ctx;
    log.info('Request received');
  },
};

// Only runs for POST requests
export const POST = {
  handler: async (ctx) => {
    const { log } = ctx;
    if (!ctx.req.body) {
      return { status: 400, error: { code: 'MISSING_BODY', message: 'Request body required' } };
    }
    log.info('POST body validated');
  },
};

// Only runs for DELETE requests
export const DELETE = {
  handler: async (ctx) => {
    if (ctx.req.session?.role !== 'admin') {
      return { status: 403, error: { code: 'FORBIDDEN', message: 'Admin access required for deletion' } };
    }
  },
};

Method-specific middleware runs after the default middleware. You can use only method exports without a default export, or combine both.

Premade Middleware

Arcway ships ready-to-use middleware via arcway/middlewares:

requireSession

Checks that ctx.req.session has a specific key (default: userId). Returns 401 if missing.

import { requireSession } from 'arcway/middlewares';

// api/_middleware.js — require login for all routes
export default {
  handler: requireSession(),
};

// Custom session key
export default {
  handler: requireSession({ sessionKey: 'accountId' }),
};

// Custom error
export default {
  handler: requireSession({
    status: 403,
    errorCode: 'LOGIN_REQUIRED',
    message: 'Please log in first',
  }),
};

corsMiddleware

Per-prefix CORS settings (for global CORS, use server.cors in arcway.config.js):

import { corsMiddleware } from 'arcway/middlewares';

export default {
  handler: corsMiddleware({
    origin: ['https://app.example.com'],
    credentials: true,
  }),
};

On this page