npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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 arcway

Create a project:

my-app/
├── arcway.config.js
├── api/
│   └── users/
│       └── index.js
└── migrations/
    └── 20260101000000-create-users.js

arcway.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 dev

This 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.js

Files 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.

Mail

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 maximum
  • X-RateLimit-Remaining — remaining requests in window
  • X-RateLimit-Reset — Unix timestamp when window resets
  • Retry-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 requests
  • app.db — direct database access for setup/assertions
  • app.run(fn) — execute a function with infrastructure context
  • app.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.json

Routes 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 page

Pages are React components with automatic hydration, layout nesting, and client-side navigation.

Boot Sequence

When arcway dev or arcway start runs, the framework:

  1. Loads environment files (.env, .env.local, .env.{mode})
  2. Loads arcway.config.js
  3. Connects to the database and runs migrations
  4. Initializes Redis, queue, cache, file, and mail drivers
  5. Creates event bus and registers listeners
  6. Discovers and registers jobs
  7. Discovers routes and middleware
  8. Builds pages (if pages/ exists)
  9. Creates and starts HTTP server
  10. Starts job runner (cron scheduler + continuous jobs)

Graceful shutdown reverses the process: stops the job runner, drains connections, and closes the server.