arcway
v0.1.26
Published
A convention-based framework for building modular monoliths with strict domain boundaries.
Readme
Arcway
A convention-based JavaScript framework for building full-stack applications. File-system conventions discover routes, jobs, events, and middleware automatically — no manual wiring required.
Quick Start
npm install arcwayCreate a project:
my-app/
├── arcway.config.js
├── api/
│ └── users/
│ └── index.js
└── migrations/
└── 20260101000000-create-users.jsarcway.config.js
export default {
database: {
client: 'sqlite',
connection: './dev.db',
},
};api/users/index.js
import { type } from 'arcway';
export const GET = {
handler: async (ctx) => {
const users = await ctx.db('users').select('*');
return { data: users };
},
};
export const POST = {
schema: {
body: type({
name: 'string >= 1',
email: 'string.email',
}),
},
handler: async (ctx) => {
const [id] = await ctx.db('users').insert(ctx.req.body);
await ctx.events.emit('users/created', { userId: id });
return { status: 201, data: { id } };
},
};Start the dev server:
npx arcway devThis boots the full stack: database, migrations, route discovery, event bus, job runner, and HTTP server.
CLI Commands
| Command | Description |
| ------------------------------- | ------------------------------------------------------------------ |
| arcway dev | Start development server (console logging, CORS enabled) |
| arcway start | Start production server (JSON logging, health check at /health) |
| arcway build [outDir] | Build pages for production |
| arcway seed | Run database seed files from seeds/ |
| arcway docs [outFile] | Generate OpenAPI spec from route schemas (default: openapi.json) |
| arcway test [--watch] [pattern] | Run tests |
| arcway lint | Check for boundary violations |
| arcway migrate | Run database migrations |
| arcway schema | Inspect database schema |
Project Structure
project-root/
├── arcway.config.js # Framework configuration
├── api/ # HTTP route handlers (file-based routing)
│ ├── users/
│ │ ├── index.js # GET /users, POST /users
│ │ ├── [id].js # GET /users/:id, PUT /users/:id, DELETE /users/:id
│ │ └── _middleware.js # Middleware for all /users/* routes
│ └── projects/
│ ├── index.js # GET /projects, POST /projects
│ └── [id].js # GET /projects/:id, PUT /projects/:id
├── listeners/ # Event subscribers (folder path = event name)
│ └── users/
│ └── created.js # Handles 'users/created' event
├── hooks/ # Lifecycle hooks (init / ready / shutdown)
│ ├── init.js # Runs before listeners/jobs/routers are wired
│ ├── ready.js # Runs after the server is listening
│ └── shutdown.js # Runs at the top of graceful shutdown
├── jobs/ # Background job definitions
│ └── send-welcome-email.js
├── migrations/ # Database migrations (timestamp-ordered)
│ └── 20260101000000-create-users.js
├── seeds/ # Database seed files
│ └── 001_users.js
├── pages/ # SSR pages (optional)
│ ├── _layout.jsx # Root layout
│ ├── index.jsx # Home page
│ └── about.jsx # About page
└── lib/ # Shared logic (no framework magic)
└── users.jsFiles starting with _ (except _middleware.js and _layout.jsx) are excluded from route/job/listener discovery.
Configuration
// arcway.config.js
export default {
server: {
port: 3000,
shutdownTimeoutMs: 10_000,
maxBodySize: 26_214_400, // 25 MB
},
api: {
pathPrefix: '', // Prefix all API routes (e.g., '/api')
cors: {
origin: ['https://app.com'],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400,
},
},
database: {
client: 'postgres', // 'sqlite', 'postgres', 'mysql'
connection: 'postgres://user:pass@localhost/mydb',
hooks: { // Forwarded verbatim to knex — postProcessResponse,
// wrapIdentifier, asyncStackTraces, log, debug.
postProcessResponse: (result, ctx) => {
if (ctx?.table === 'users' && Array.isArray(result)) {
return result.map((row) => ({ ...row, createdAt: new Date(row.created_at) }));
}
return result;
},
},
},
session: {
password: 'at-least-32-character-secret-here!!',
cookieName: 'arcway.session',
ttl: 86400,
},
queue: {
driver: 'redis', // 'knex' (default) or 'redis'
redis: { url: 'redis://localhost:6379' },
},
cache: {
driver: 'redis', // 'memory' (default) or 'redis'
defaultTtlMs: 60_000,
redis: { url: 'redis://localhost:6379' },
},
events: {
driver: 'redis', // 'memory' (default) or 'redis'
redis: { url: 'redis://localhost:6379' },
},
files: {
driver: 's3', // 'local' (default) or 's3'
s3: {
bucket: 'my-bucket',
region: 'us-east-1',
},
},
mail: {
driver: 'smtp', // 'smtp' or 'console'
from: '[email protected]',
host: 'smtp.example.com',
port: 587,
auth: { user: 'user', pass: 'pass' },
},
jobs: {
pollIntervalMs: 60_000,
},
logger: {
level: 'info', // 'debug', 'info', 'warn', 'error'
},
};CORS
CORS behavior by default:
- Development (
arcway dev): Permissive — all origins allowed. - Production (
arcway start): Disabled — no CORS headers unless configured.
Configure with api.cors:
true— enable permissive CORS in any mode.false— disable CORS entirely.- Object — use specific settings.
Subsystem Toggles
Any subsystem can be disabled:
export default {
api: { enabled: false },
pages: { enabled: false },
jobs: { enabled: false },
events: { enabled: false },
mcp: { enabled: false },
websocket: { enabled: false },
mail: { enabled: false },
};Routes
Route files live in api/ and map to URL patterns by file path:
| File | URL Pattern |
| --------------------------- | ----------------------- |
| api/users/index.js | /users |
| api/users/[id].js | /users/:id |
| api/users/[id]/posts.js | /users/:id/posts |
| api/admin/settings.js | /admin/settings |
Export named constants for each HTTP method:
import { type } from 'arcway';
export const GET = {
schema: {
query: type({ 'page?': 'number' }),
},
meta: {
summary: 'List users',
tags: ['users'],
},
handler: async (ctx) => {
const page = ctx.req.query.page ?? 1;
const users = await ctx.db('users').select('*').limit(20).offset((page - 1) * 20);
return { data: users };
},
};
export const POST = {
schema: {
body: type({
name: 'string >= 1',
email: 'string.email',
}),
},
handler: async (ctx) => {
const { name, email } = ctx.req.body;
const [id] = await ctx.db('users').insert({ name, email });
await ctx.events.emit('users/created', { userId: id });
return { status: 201, data: { id } };
},
};Database hooks & per-table decoding
Knex's postProcessResponse hook (plus wrapIdentifier, log, debug, and
asyncStackTraces) can be set under database.hooks in arcway.config.js and
are forwarded to the underlying knex instance.
To make postProcessResponse useful without threading context at every call
site, arcway auto-stamps .queryContext({ table }) on every ctx.db(name)
builder. Your hook can key on ctx.table to decode rows centrally:
// arcway.config.js
database: {
hooks: {
postProcessResponse: (result, ctx) => {
if (!ctx?.table || !Array.isArray(result)) return result;
const decode = decoders[ctx.table];
return decode ? result.map(decode) : result;
},
},
},ctx.db.raw(...), ctx.db({ alias: ... }), and subquery builders are not
stamped — hooks should pass those through with if (!ctx?.table) return result.
To carry additional context alongside the auto-stamp, merge instead of replace:
const builder = ctx.db('orders');
builder.queryContext({ ...builder.queryContext(), userId: ctx.req.session.userId });Handler Context
Every route handler receives a ctx object with infrastructure and request data:
// ctx contains:
{
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)
req: {
requestId, // Unique request ID
method, // HTTP method
path, // URL path
query, // Validated query params (includes URL params like :id)
body, // Validated request body
headers, // Request headers
cookies, // Parsed cookies
session, // Session data (if configured)
},
}Route Response
Handlers return a response object:
{
status: 200, // HTTP status (default: 200, or 400 if error)
data: { ... }, // Wrapped in { data: ... }
error: { // Wrapped in { error: ... }
code: 'NOT_FOUND',
message: 'User not found',
},
headers: { ... }, // Custom response headers
session: { userId: 42 }, // Set session (requires session config)
// session: null // Clear session
}Validation with ArkType
Arcway uses ArkType for schema validation:
import { type } from 'arcway';
export const POST = {
schema: {
body: type({
name: 'string >= 1',
email: 'string.email',
'age?': 'number > 0',
}),
},
handler: async (ctx) => {
// ctx.req.body is validated and coerced
const { name, email, age } = ctx.req.body;
// ...
},
};Invalid requests automatically return 400 with a VALIDATION_ERROR code and field-level error details.
Middleware
Place _middleware.js files in api/ directories. Middleware applies to all routes at that level and below.
Middleware must export an object (or array of objects) with a handler function:
// api/_middleware.js — applies to all routes
export default {
handler: async (ctx) => {
const start = Date.now();
console.log(`${ctx.req.method} ${ctx.req.path} (${Date.now() - start}ms)`);
// return undefined to continue to next middleware/handler
},
};// api/admin/_middleware.js — applies to /admin/* only
export default {
handler: async (ctx) => {
if (ctx.req.headers['authorization'] !== 'Bearer valid-token') {
// Return a response to short-circuit the chain
return {
status: 401,
error: { code: 'UNAUTHORIZED', message: 'Invalid token' },
};
}
// return undefined to continue
},
};Middleware can also have schemas and export an array:
import { type } from 'arcway';
export default [
{
schema: { query: type({ 'apiKey': 'string' }) },
handler: async (ctx) => {
if (!isValidKey(ctx.req.query.apiKey)) {
return { status: 403, error: { code: 'FORBIDDEN', message: 'Bad API key' } };
}
},
},
{ handler: async (ctx) => { /* logging */ } },
];Middleware chains execute outermost-first. Return a response to short-circuit; return undefined to continue.
Events
Emit events from any handler, listener, or job via ctx.events:
await ctx.events.emit('users/created', { userId: 42 });Listeners
Listener files live in listeners/. The folder path maps to the event name:
| File | Event |
| ------------------------------ | --------------- |
| listeners/users/created.js | users/created |
| listeners/orders/updated.js | orders/updated |
Listeners export a default function that receives a context object:
// listeners/users/created.js
export default async (ctx) => {
const { event, db } = ctx;
// event.name = 'users/created'
// event.payload = { userId: 42 }
await db('billing_accounts').insert({
user_id: event.payload.userId,
plan: 'free',
});
};The listener context includes all infrastructure (db, events, cache, queue, files, mail, log) plus event: { name, payload }.
Lifecycle Hooks
Lifecycle hooks live in the top-level hooks/ directory. Each file default-exports an async function that receives the real, mutable appContext — the same object downstream subsystems (listeners, jobs, routes) read services from at call time. Mutations (e.g. wrapping appContext.db) persist for the lifetime of the process.
project-root/
└── hooks/
├── init.js — after infrastructure, before listeners/jobs/routers are wired
├── ready.js — after the HTTP server is listening and the job runner has started
└── shutdown.js — at the top of graceful shutdown, before any teardown// hooks/init.js — wrap services, register global resources
export default async (appContext) => {
appContext.db = wrapWithTenancy(appContext.db);
};
// hooks/ready.js — post-boot work (warmups, one-shot recovery)
export default async (appContext) => {
await recoverOrphanedTasks(appContext);
};
// hooks/shutdown.js — flush and cleanup before infrastructure is destroyed
export default async (appContext) => {
await appContext.cache.flush();
};Hooks are optional — missing files are silently skipped. An existing file must default-export a function; otherwise boot fails fast.
Jobs
Background jobs support one-off queuing and cron scheduling.
// jobs/generate-invoice.js
import { type } from 'arcway';
export default {
name: 'generate-invoice',
schema: type({ userId: 'number', month: 'string' }),
retries: 3,
schedule: '0 0 1 * *', // First of each month (cron)
handler: async (ctx) => {
const { userId, month } = ctx.payload;
// Generate invoice...
},
};Job handlers receive a context object with all infrastructure plus payload:
// ctx contains: { db, events, cache, queue, files, mail, log, payload }Continuous Jobs
Jobs with schedule: 'continuous' run in a loop until stopped:
export default {
name: 'process-queue',
schedule: 'continuous',
handler: async (ctx) => {
// Runs repeatedly. Backoff is applied on errors.
},
};Enqueue Jobs Programmatically
From any route handler or listener:
await ctx.queue.push('generate-invoice', { userId: 42, month: '2025-01' });Failed jobs retry with exponential backoff (1s, 2s, 4s, 8s...).
Queue
Persistent queue for background processing:
// Push work
await ctx.queue.push('email-send', { to: '[email protected]', body: '...' });
// Pop and process (typically in a job handler)
const items = await ctx.queue.pop('email-send', 10);
for (const item of items) {
await sendEmail(item.data);
await ctx.queue.remove([item.id]);
}Drivers: knex (default, database-backed) or redis.
Cache
Key-value cache with TTL support:
// Set with TTL
await ctx.cache.set('user:42', userData, 60_000);
// Get
const cached = await ctx.cache.get('user:42');
// Cache-aside pattern
const user = await ctx.cache.wrap(
'user:42',
async () => ctx.db('users').where({ id: 42 }).first(),
60_000,
);
// Delete
await ctx.cache.delete('user:42');Drivers: memory (default) or redis.
File Storage
File operations via ctx.files:
// Write
await ctx.files.write('avatars/user-42.png', imageBuffer);
// Read
const data = await ctx.files.read('avatars/user-42.png');
// List
const files = await ctx.files.list('avatars/');
// Check existence
const exists = await ctx.files.exists('avatars/user-42.png');
// Delete
await ctx.files.delete('avatars/user-42.png');Drivers: local (default, filesystem) or s3.
Send email via ctx.mail:
await ctx.mail.send({
to: '[email protected]',
subject: 'Welcome!',
html: '<h1>Hello</h1>',
});
// Or queue for background sending
await ctx.mail.queue({
to: '[email protected]',
subject: 'Invoice',
html: '<p>Your invoice is attached.</p>',
});Drivers: smtp or console (logs to stdout).
Rate Limiting
Rate limiting is available as a middleware factory:
// api/_middleware.js
import { createRateLimitMiddleware, MemoryRateLimitStore } from 'arcway';
const store = new MemoryRateLimitStore();
export default createRateLimitMiddleware(
{
max: 100, // 100 requests
windowMs: 60_000, // per minute
},
store,
);The middleware uses a sliding window algorithm. By default, it keys on the client IP from X-Forwarded-For or X-Real-IP headers.
Response headers are added automatically:
X-RateLimit-Limit— configured maximumX-RateLimit-Remaining— remaining requests in windowX-RateLimit-Reset— Unix timestamp when window resetsRetry-After— seconds until retry (on 429 responses only)
Stores: MemoryRateLimitStore (built-in) or RedisRateLimitStore.
Sessions
Configure sessions in arcway.config.js:
export default {
session: {
password: 'at-least-32-characters-long-secret!',
cookieName: 'arcway.session',
ttl: 86400,
},
};Set session data from a route handler by returning session:
export const POST = {
handler: async (ctx) => {
const user = await authenticate(ctx.req.body);
return {
data: user,
session: { userId: user.id, role: user.role },
};
},
};Read session in subsequent requests via ctx.req.session. Clear by returning session: null.
Testing
Arcway provides Arcway.test() for integration testing with an in-memory SQLite database:
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { Arcway } from 'arcway';
describe('users API', () => {
let app;
beforeAll(async () => {
app = await Arcway.test({ rootDir: './my-app' });
});
afterAll(() => app.shutdown());
it('creates a user', async () => {
const res = await app.request('POST', '/users', {
body: { name: 'Alice', email: '[email protected]' },
});
expect(res.status).toBe(201);
expect(res.body.data.name).toBe('Alice');
});
it('lists users', async () => {
const res = await app.request('GET', '/users');
expect(res.status).toBe(200);
expect(res.body.data).toHaveLength(1);
});
});Arcway.test() boots the full application with SQLite in-memory, random port, and MCP disabled. It returns:
app.request(method, path, opts)— make HTTP requestsapp.db— direct database access for setup/assertionsapp.run(fn)— execute a function with infrastructure contextapp.shutdown()— clean up
Unit Test Stubs
For unit testing without booting the full app:
import { createTestContext } from 'arcway';
const { ctx, db, cleanup } = await createTestContext('mytest', {
migrationsDir: './migrations',
});
// ctx has: { db, events, queue, cache, files, mail, log }
// All infrastructure is stubbed in-memory
await cleanup();Individual stubs are also available:
import {
createEventStub,
createQueueStub,
createCacheStub,
createFilesStub,
createMailStub,
createLoggerStub,
} from 'arcway';Seeds
Seed files live in seeds/ and run in alphabetical order:
// seeds/001_users.js
export default async function seed(db) {
await db('users')
.insert([
{ id: 1, name: 'Alice', email: '[email protected]' },
{ id: 2, name: 'Bob', email: '[email protected]' },
])
.onConflict('id')
.merge();
}Run with arcway seed. Migrations execute first to ensure the schema is current.
OpenAPI Generation
arcway docs generates an OpenAPI 3.0 spec from route schemas:
arcway docs openapi.jsonRoutes with meta and schema fields produce documented endpoints:
export const GET = {
schema: {
query: type({ id: /^\d+$/ }),
},
meta: {
summary: 'Get user by ID',
description: 'Returns a single user record.',
tags: ['users'],
},
handler: async (ctx) => { /* ... */ },
};Pages (SSR)
Arcway supports server-side rendered React pages with file-based routing:
pages/
├── _layout.jsx # Root layout (wraps all pages)
├── index.jsx # / route
├── about.jsx # /about route
├── blog/
│ ├── _layout.jsx # Blog layout (wraps blog pages)
│ ├── index.jsx # /blog route
│ └── [slug].jsx # /blog/:slug route
└── _404.jsx # Custom 404 pagePages are React components with automatic hydration, layout nesting, and client-side navigation.
Boot Sequence
When arcway dev or arcway start runs, the framework:
- Loads environment files (
.env,.env.local,.env.{mode}) - Loads
arcway.config.js - Connects to the database and runs migrations
- Initializes Redis, queue, cache, file, and mail drivers
- Creates event bus and registers listeners
- Discovers and registers jobs
- Discovers routes and middleware
- Builds pages (if
pages/exists) - Creates and starts HTTP server
- Starts job runner (cron scheduler + continuous jobs)
Graceful shutdown reverses the process: stops the job runner, drains connections, and closes the server.
