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

artesia

v0.0.5

Published

Lightweight HTTP server for Bun/Node

Downloads

54

Readme

artesia

Lightweight, fully-typed HTTP framework built on Node.js core.

npm version license bun node

Artesia is a minimal HTTP server framework powered by node:http and the find-my-way radix-trie router. It gives you typed path params, automatic body parsing, a clean middleware model, and expressive response helpers — without the overhead of a full framework.


Table of Contents


Installation

# npm
npm install artesia

# bun
bun add artesia

# yarn
yarn add artesia

Requirements: Bun ≥ 1.0 or Node.js ≥ 18.


Create a New Project

The fastest way to get started is with the create-artesia scaffolding CLI. It generates a ready-to-run project with your choice of language and package manager.

# bun
bunx artesia

# npm
npx artesia

# pnpm
pnpm dlx artesia

# yarn
yarn dlx artesia

The CLI will walk you through an interactive setup:

◆  create-http-app
│
◇  Project name
│  my-app
│
◇  Language
│  ● TypeScript (recommended)
│  ○ JavaScript
│
◇  Package manager
│  ● bun  (detected)
│  ○ pnpm
│  ○ yarn
│  ○ npm
│
◇  Install dependencies?
│  ● Yes  ○ No
│
└  Ready! Happy coding 🚀

The generated project structure looks like this:

my-app/
├── src/
│   └── index.ts        # entry point with example routes
├── package.json
├── tsconfig.json       # (TypeScript only)
└── .gitignore

The generated src/index.ts includes a basic working server to get you started immediately:

import { Artesia } from 'artesia'

const app = new Artesia()

app.get('/', (ctx) => {
  return { message: 'Hello World!' }
})

app.get('/users/:id', (ctx) => {
  const id = ctx.params.get('id')
  return { id, name: 'Alice' }
})

app.listen(3000)

Then run the dev server:

# bun
bun dev

# npm / pnpm / yarn
npm run dev

Quick Start

import { Artesia } from 'artesia'

const app = Artesia()

app
  .get('/', 'Hello World')
  .get('/json', () => ({ ok: true }))
  .get('/users/:id', (ctx) => ({
    id: ctx.params.get('id'), // ← fully type-safe
  }))
  .post('/echo', (ctx) => ctx.body)
  .listen(3000)
  .then(({ port }) => console.log(`Listening on http://localhost:${port}`))

You can also use the class directly:

import { Artesia } from 'artesia'

const app = new Artesia()

Both Artesia() and new Artesia() are identical — use whichever you prefer.


Core Concepts

Routing

Artesia exposes one method per HTTP verb, plus a generic add() for any method. All methods return this, so you can chain registrations.

app.get('/path', handler)
app.post('/path', handler)
app.put('/path', handler)
app.patch('/path', handler)
app.delete('/path', handler)
app.head('/path', handler)
app.options('/path', handler)
app.add('PURGE', '/path', handler) // any find-my-way HTTPMethod

Static string shorthand — if you pass a string as the first handler, GET returns it directly without creating a closure:

app.get('/', 'Hello World') // returns "Hello World" as text/plain

Multiple handlers per route — handlers run in order; the first one that returns a non-undefined value short-circuits the rest:

app.get(
  '/protected',
  (ctx) => {
    if (!ctx.headers.get('authorization')) {
      ctx.set.status(401)
      return { error: 'Unauthorized' }
    }
    // return undefined → continue to next handler
  },
  (ctx) => ({ data: 'secret' })
)

Path Parameters

Route params are defined with :paramName syntax and are accessible through ctx.params.get(). The type of ctx.params is inferred from the route path, so you get full auto-complete.

app.get('/users/:id', (ctx) => {
  const id = ctx.params.get('id') // string
  return { id }
})

app.get('/posts/:year/:slug', (ctx) => ({
  year: ctx.params.get('year'),
  slug: ctx.params.get('slug'),
}))

Wildcard routes use *:

app.get('/static/*', (ctx) => {
  const rest = ctx.params.get('*') // everything after /static/
  return { rest }
})

Query String

Access query parameters via the standard URLSearchParams API through ctx.searchParams:

app.get('/search', (ctx) => {
  const q    = ctx.searchParams.get('q')
  const page = ctx.searchParams.get('page') ?? '1'
  return { q, page }
})
// GET /search?q=artesia&page=2 → { q: "artesia", page: "2" }

Request Body

Artesia automatically parses the request body based on Content-Type. No configuration needed.

| Content-Type | ctx.body type | |---|---| | application/json | Parsed object / array / primitive | | application/x-www-form-urlencoded | Record<string, string \| string[]> | | multipart/form-data | Record<string, string> (fields); files in ctx.files | | text/* | string | | Anything else | Buffer | | GET, HEAD, OPTIONS | null |

// JSON body
app.post('/users', (ctx) => {
  const { name, email } = ctx.body as { name: string; email: string }
  return { created: true, name }
})

// URL-encoded form
app.post('/login', (ctx) => {
  const body = ctx.body as Record<string, string>
  return { username: body.username }
})

// Raw buffer (e.g. webhook payload)
app.post('/webhook', (ctx) => {
  const raw = ctx.rawBody // Buffer | null — always available alongside parsed body
  // verify HMAC, etc.
  return null
})

Response Serialization

Return any value from a handler — Artesia serializes it automatically:

| Return value | HTTP status | Content-Type | |---|---|---| | string | 200 | text/plain; charset=utf-8 | | object / array | 200 | application/json; charset=utf-8 | | Buffer | 200 | application/octet-stream | | null / undefined | 204 | (no body) | | ArtesiaResponse | as set | as set |

app.get('/text', () => 'hello')
app.get('/json', () => ({ ok: true }))
app.get('/empty', () => null) // → 204 No Content
app.get('/buffer', () => Buffer.from('raw bytes'))

Middleware

Register global middleware with app.use(). Each middleware receives ctx and a next function. Calling await next() passes control to the next middleware (and eventually the route handlers).

// Logging middleware
app.use(async (ctx, next) => {
  const start = Date.now()
  await next()
  console.log(`${ctx.method} ${ctx.pathname} ${Date.now() - start}ms`)
})

Guard / short-circuit — return a value without calling next() to halt the chain:

app.use((ctx) => {
  const token = ctx.headers.get('authorization')
  if (!token) {
    ctx.set.status(401)
    return { error: 'Unauthorized' }
  }
  // fall through
})

Sharing state across middleware — use ctx.store, a per-request Map<string, unknown>:

app.use(async (ctx, next) => {
  const user = await db.findUserByToken(ctx.cookie('token'))
  ctx.store.set('user', user)
  await next()
})

app.get('/me', (ctx) => ctx.store.get('user'))

Middleware execution order:

Request → mw[0] → mw[1] → ... → route handlers → mw[1] (after next) → mw[0] (after next) → Response

Sub-apps & Merging

Create isolated sub-routers with their own basePath, then mount them with .use() or .merge().

Middleware defined on a sub-app is scoped to that sub-app's routes only — it does not leak into the parent's other routes.

// users.ts
export const users = new Artesia({ basePath: '/users' })

users
  .use(requireAuth) // runs only for /users routes
  .get('/', () => listUsers())
  .get('/:id', (ctx) => getUser(ctx.params.get('id')))
  .post('/', (ctx) => createUser(ctx.body))
// app.ts
import { users } from './users'

const app = new Artesia({ basePath: '/api/v1' })

app
  .use(users)   // → routes mounted at /api/v1/users
  .listen(3000)

.use(subApp) and .merge(subApp) are equivalent — prefer .use() for consistency with the middleware API.


Context API (ctx)

The ctx object is the single argument passed to every handler and middleware.

Request Properties

ctx.request      // IncomingMessage — raw Node.js request object
ctx.method       // 'GET' | 'POST' | 'PUT' | ... (HTTPMethod)
ctx.pathname     // '/users/42' (no query string)
ctx.headers      // Headers (Web API)
ctx.searchParams // URLSearchParams
ctx.params       // ParamMap<Path> — typed by route path
ctx.body         // parsed body (see Body Parsing table)
ctx.rawBody      // Buffer | null
ctx.files        // ContextFile[] — uploaded files
ctx.store        // Map<string, unknown> — per-request shared state

Reading a request cookie:

const sessionId = ctx.cookie('session_id') // string | undefined

Response Helpers

ctx.html(content, status?)

Returns an HTML response with Content-Type: text/html; charset=utf-8.

app.get('/page', (ctx) =>
  ctx.html('<h1>Hello</h1>', 200)
)

ctx.file(filePath, options?)

Reads a file from disk and returns it with the correct MIME type (auto-detected from extension). Supports 30+ MIME types out of the box.

app.get('/download', (ctx) =>
  ctx.file('./uploads/report.pdf', {
    disposition: 'attachment',
    filename: 'report.pdf',
  })
)

app.get('/logo', (ctx) =>
  ctx.file('./public/logo.png') // disposition: 'inline' by default
)

| Option | Type | Default | Description | |---|---|---|---| | contentType | string | auto-detected | Override MIME type | | filename | string | — | Sets filename= in Content-Disposition | | disposition | 'inline' \| 'attachment' | 'inline' | Controls download behaviour |

ctx.redirect(path, status?)

Returns a redirect response.

app.get('/old', (ctx) => ctx.redirect('/new', 301))
app.get('/go', (ctx) => ctx.redirect('https://example.com'))

| Status | Meaning | |---|---| | 301 | Moved Permanently | | 302 | Found (default) | | 303 | See Other | | 307 | Temporary Redirect | | 308 | Permanent Redirect |


ctx.set — Mutating the Response

ctx.set provides a fluent API to configure the outgoing response before returning from a handler.

ctx.set.status(status)

Accepts either a numeric status code or an HTTP status text string (fully typed):

ctx.set.status(201)
ctx.set.status('Created')
ctx.set.status(404)
ctx.set.status('Not Found')
ctx.set.status("I'm a Teapot") // 418, yes really

ctx.set.headers(...)

Three call signatures:

// Single header
ctx.set.headers('x-request-id', 'abc-123')

// Object / record
ctx.set.headers({ 'x-foo': 'bar', 'cache-control': 'no-cache' })

// Map or Headers instance
ctx.set.headers(new Headers({ 'x-custom': 'value' }))

ctx.set.cookies(...)

Set one or multiple cookies with optional options:

// Single cookie
ctx.set.cookies('token', 'abc123', {
  httpOnly: true,
  secure: true,
  sameSite: 'lax',
  maxAge: 60 * 60 * 24, // 1 day in seconds
  path: '/',
})

// Multiple cookies
ctx.set.cookies(
  { session: 'xyz', theme: 'dark' },
  { httpOnly: true }
)

Cookie options:

| Option | Type | Description | |---|---|---| | maxAge | number | Max age in seconds | | expires | Date | Expiry date | | httpOnly | boolean | Prevents JS access | | secure | boolean | HTTPS only | | sameSite | 'strict' \| 'lax' \| 'none' | SameSite policy | | path | string | Cookie path | | domain | string | Cookie domain |


Server Options

const app = new Artesia({
  basePath: '/api/v1', // Prefix prepended to every route (trailing slash stripped automatically)
})

Custom Server Integration

Use app.handler to plug Artesia into an existing node:http server, HTTPS server, or any framework that accepts a standard request handler:

import { createServer } from 'node:http'
import { createServer as createHttps } from 'node:https'
import { readFileSync } from 'node:fs'
import { Artesia } from 'artesia'

const app = Artesia()
app.get('/health', () => ({ status: 'ok' }))

// HTTP
createServer(app.handler).listen(3000)

// HTTPS
createHttps(
  { key: readFileSync('key.pem'), cert: readFileSync('cert.pem') },
  app.handler
).listen(443)

File Uploads

Multipart form uploads are parsed automatically via busboy. Access uploaded files through ctx.files:

app.post('/upload', (ctx) => {
  const file = ctx.files[0]

  if (!file) {
    ctx.set.status(400)
    return { error: 'No file uploaded' }
  }

  console.log(file.fieldname)  // form field name
  console.log(file.filename)   // original filename
  console.log(file.mimetype)   // e.g. 'image/png'
  console.log(file.encoding)   // e.g. '7bit'
  console.log(file.size)       // byte length
  console.log(file.buffer)     // Buffer containing file data

  // write to disk, upload to S3, etc.
  return { uploaded: file.filename }
})

ContextFile shape:

interface ContextFile {
  fieldname: string
  filename:  string
  mimetype:  string
  encoding:  string
  buffer:    Buffer
  size:      number
}

Cookies

Reading cookies from the request:

app.get('/profile', (ctx) => {
  const session = ctx.cookie('session_id')
  if (!session) {
    ctx.set.status(401)
    return { error: 'Not authenticated' }
  }
  return { sessionId: session }
})

Setting cookies in the response via ctx.set.cookies():

app.post('/login', (ctx) => {
  // ... verify credentials
  ctx.set.cookies('session_id', 'generated-session-token', {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7, // 1 week
  })
  ctx.set.status(200)
  return { ok: true }
})

TypeScript

Artesia is written in TypeScript and ships with full type definitions. Path parameters are inferred from the route string at compile time:

import type { Handler } from 'artesia'

// Explicit typed handler
const getUser: Handler<'/users/:id'> = (ctx) => {
  //                                             ↑ ctx is Context<'/users/:id'>
  ctx.params.get('id')   // ✅ OK
  ctx.params.get('name') // ❌ Type error — 'name' not in params
}

app.get('/users/:id', getUser)

Exported types:

import type {
  Context,          // ctx passed to handlers
  Handler,          // (ctx: Context<Path>) => unknown | Promise<unknown>
  Middleware,       // (ctx: Context, next: () => Promise<void>) => unknown
  ArtesiaOptions,     // { basePath?: string }
  ArtesiaResponse,    // internal response object
  ContextFile,      // uploaded file shape
  CookieOptions,    // set-cookie options
  FileOptions,      // ctx.file() options
  ExtractParams,    // utility: extracts param keys from a path string
  ParamMap,         // typed Map for ctx.params
  HTTPMethod,       // 'GET' | 'POST' | 'PUT' | ...
  HttpStatusText,   // 'OK' | 'Not Found' | 'I'm a Teapot' | ...
  HttpRedirectType, // 301 | 302 | 303 | 307 | 308
  RouteDefinition,  // internal route record
  SetResponse,      // ctx.set interface
} from 'artesia'

import { HTTP_STATUS } from 'artesia' // Record<HttpStatusText, number>

Built-in Middleware

Artesia ships first-party middleware for the most common cross-cutting concerns. Import from artesia/middleware.


buildMiddleware

A typed helper for authoring middleware. Pass your function in and get it back as a correctly-typed Middleware — no need to import or annotate the type yourself.

import { buildMiddleware } from 'artesia/middleware'

const requireAuth = buildMiddleware((ctx, next) => {
  const token = ctx.headers.get('authorization')
  if (!token) {
    ctx.set.status(401)
    return { error: 'Unauthorized' }
  }
  return next()
})

app.use(requireAuth)

Works great for factory-pattern middleware too:

function rateLimit(maxRpm: number) {
  const counts = new Map<string, number>()

  return buildMiddleware((ctx, next) => {
    const ip = ctx.headers.get('x-forwarded-for') ?? 'unknown'
    const count = (counts.get(ip) ?? 0) + 1
    counts.set(ip, count)
    if (count > maxRpm) {
      ctx.set.status(429)
      return { error: 'Too Many Requests' }
    }
    return next()
  })
}

app.use(rateLimit(60))

cors

CORS (Cross-Origin Resource Sharing) middleware. Handles both actual requests and OPTIONS preflight requests.

import { cors } from 'artesia/middleware'

// Allow all origins (default)
app.use(cors())

// Restrict to specific origins
app.use(cors({
  origin: ['https://example.com', 'https://admin.example.com'],
  credentials: true,
}))

// Regex or custom predicate
app.use(cors({
  origin: /\.example\.com$/,
}))

CorsOptions:

| Option | Type | Default | Description | |---|---|---|---| | origin | '*' \| string \| string[] \| RegExp \| ((o: string) => boolean) | '*' | Allowed origin(s) | | methods | string[] | All standard methods | Allowed HTTP methods | | allowedHeaders | string[] | Reflects Access-Control-Request-Headers | Allowed request headers | | exposedHeaders | string[] | — | Headers exposed to the browser | | credentials | boolean | false | Allow cookies / credentials | | maxAge | number | 5 | Preflight cache TTL in seconds |

Note: When origin is set to a specific value and a request comes in with a non-matching origin, CORS headers are simply omitted — the browser will block the request. Non-CORS requests (no Origin header) pass through unaffected.


logger

HTTP request logger. Prints method, path, status code, and response time for every request.

import { logger } from 'artesia/middleware'

// Pretty coloured output (auto-detects TTY)
app.use(logger())

// Structured JSON logging
app.use(logger({
  log: (_, fields) => console.log(JSON.stringify(fields)),
}))

// Skip noisy endpoints
app.use(logger({
  skip: ({ pathname }) => pathname === '/health' || pathname.startsWith('/static/'),
}))

// No timestamp, plain text
app.use(logger({ timestamp: false, colorize: false }))

Sample output:

2025-01-15 12:34:56.789 GET     /users/42 → 200 (4ms)
2025-01-15 12:34:57.001 POST    /users → 201 (12ms)
2025-01-15 12:34:57.123 DELETE  /users/1 → 404 (2ms)

LoggerOptions:

| Option | Type | Default | Description | |---|---|---|---| | log | (line: string, fields: LogFields) => void | console.log | Custom log handler | | colorize | boolean | process.stdout.isTTY | Enable ANSI colours | | skip | (fields: { method, pathname }) => boolean | — | Predicate to skip logging | | timestamp | 'iso' \| 'time' \| false | 'iso' | Timestamp format |

LogFields:

interface LogFields {
  method:        string        // 'GET', 'POST', ...
  pathname:      string        // '/users/42'
  status:        number        // 200, 404, ...
  durationMs:    number        // response time in ms
  contentLength: string | null // Content-Length header (best-effort)
  timestamp:     string        // formatted timestamp string
}

API Reference

class Artesia

| Member | Signature | Description | |---|---|---| | constructor | (options?: ArtesiaOptions) | Create a new app instance | | get | (path, ...handlers) | Register GET route | | post | (path, ...handlers) | Register POST route | | put | (path, ...handlers) | Register PUT route | | patch | (path, ...handlers) | Register PATCH route | | delete | (path, ...handlers) | Register DELETE route | | head | (path, ...handlers) | Register HEAD route | | options | (path, ...handlers) | Register OPTIONS route | | add | (method, path, ...handlers) | Register route for any HTTP method | | use | (middleware: Middleware) | Add global middleware | | use | (app: Artesia) | Mount a sub-app (middleware scoped to its routes) | | merge | (other: Artesia) | Mount a sub-app — same as .use(subApp) | | listen | (port?, hostname?) | Start the HTTP server — returns Promise<{ port, server }> | | handler | (req, res) => Promise<void> | Raw Node.js request handler (for custom server integration) |

Artesia(options?)

Factory function — equivalent to new Artesia(options).


License

MIT © TiwPhiraphan