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

astromatico

v0.3.0

Published

Common utilities for Astro projects. Today: api route helpers with schema validation, authorization, error handling and response wrapping.

Readme


The ones worth writing once instead of again in the next project. So far that is the api route helpers: schema validation, authorization, error handling and response wrapping, so a route handler is the one thing it should be — a function from a request to a value.

// src/pages/api/clients/[name].ts
import { EndpointError, route } from 'astromatico'
import { z } from 'astro/zod'

export const GET = route({ path: z.object({ name: z.string() }) }, async (context) => {
  const client = await db.clients.byName(context.path.name)
  if (!client) throw new EndpointError('Client not found', 404)

  return client
})

That answers 200 with the client as json, 404 with { code: 404, error: 'Client not found' }, and 400 with the issues if the path does not validate — none of which the handler spells out.

Install

npm i astromatico
# or
bun add astromatico

astro is a peer dependency. Nothing else is: astromatico imports only Astro's types and the zod that Astro already ships, so there is no second copy of anything.

What route does

It wraps an Astro APIRoute and takes four jobs off the handler:

  1. Authorizes the caller against the route's requires, once you have told it how — 401 with no actor, 403 when it is not allowed. See Permissions.
  2. Validates body, query and path against the schemas you declare, and puts the parsed values on the context. A failure answers 400 with the issues.
  3. Wraps the return value in a Response. Return a value and it becomes 200; return a Response and it is sent untouched.
  4. Turns thrown errors into answers. Anything carrying a 4xx/5xx status becomes that status.
  5. Answers 500 for everything else, after handing the error to your onError.

Validation

export const POST = route(
  {
    body: z.object({ name: z.string(), code: z.string() }),
    query: z.object({ dryRun: z.coerce.boolean().optional() }),
    path: z.object({ clientName: z.string() }),
  },
  (context) => {
    context.body // { name: string; code: string }
    context.query // { dryRun?: boolean }
    context.path // { clientName: string }
  },
)

The body is read according to its content-type: application/json, multipart/form-data and application/x-www-form-urlencoded are understood. A route that only validates a body can pass the schema directly:

export const POST = route(z.object({ name: z.string() }), (context) => create(context.body))

The response

The handler returns the payload; route builds the Response:

export const GET = route(() => ({ ok: true })) // 200 {"ok":true}
export const GET = route(() => [1, 2, 3]) // 200 [1,2,3]

A route that answers with another status declares it once, next to its schemas, instead of repeating it at every return:

export const POST = route({ body: schema, res: res.created }, (context) => create(context.body))
// 201

res holds the builders. Any of them that takes a payload can be a res option — res.ok (the default), res.created, res.notFound, and so on. res.noContent and res.redirect cannot: their first argument is not the payload, and the types say so.

When a route needs to answer differently depending on what it finds, it returns a Response itself and route passes it through:

export const GET = route(async (context) => {
  const user = await me(context)
  return user ? user : res.unauthorized()
})

Errors

Throw EndpointError to answer with a status and a message:

throw new EndpointError('Adjuntá un archivo de imagen', 400)

Add a third argument to say which input the message is about, and the answer carries it so a form can put the message on that field:

throw new EndpointError(`A client with code "${code}" already exists`, 409, 'code')
// 409 { code: 409, error: 'A client with code "..." already exists', field: 'code' }

You do not have to use that class. Any error carrying a numeric 4xx/5xx status is answered with it, so errors from your own layers or from another library work without extending anything:

class DomainError extends Error {
  status = 409
}

Anything else is a bug, not an answer: it becomes 500 and is handed to onError.

Logging

The default onError is console.error. To report through a request-scoped logger, make your own route once and use it everywhere:

// src/lib/server/route.ts
import { createRoute } from 'astromatico'

export const route = createRoute({
  onError: (error, context) => context.locals.logger.error({ err: error }, 'unhandled route error'),
})

The envelope

Successful answers are the payload, untouched. Error answers add the status to the body as code:

| helper | status | body | | --- | --- | --- | | res.ok(data) | 200 | data | | res.created(data) | 201 | data | | res.noContent() | 204 | — | | res.bad(data?) | 400 | { code, ...data } | | res.unauthorized(data?) | 401 | { code, ...data } | | res.forbidden(data?) | 403 | { code, ...data } | | res.notFound(data?) | 404 | { code, ...data } | | res.unprocessable(data) | 422 | { code, ...data } | | res.serverError(data?) | 500 | { code, ...data } | | res.err(data) | 400 | { code, ...data } | | res.json(data, options?) | yours | { code, ...data } | | res.redirect(url) | 302 | — |

A payload that is not an object is wrapped so the body stays json: a string becomes { message }, anything else { data }.

API

| export | what it is | | --- | --- | | route | defines an APIRoute with validation, error handling and response wrapping | | createRoute(config) | the same, with your error reporting and your permission model | | res | the Response builders | | EndpointError | throw to answer with a status, a message and optionally a field | | isStatusError | whether a throwable names a 4xx/5xx status | | ValidatedContext, RouteHandler, RouteOptions, Responder, RouteConfig, AuthConfig | the types, for building your own wrappers on top |

Those last types are the extension point, for wrappers of your own.

Permissions

An app's idea of who is calling, and of what they may do, is its own. astromatico does not define either — it asks for two functions and then owns the rest:

// src/lib/server/route.ts
export const route = createRoute<Session, Permission>({
  onError: (error, context) => context.locals.logger.error({ err: error }, 'unhandled route error'),
  auth: {
    actor: (context) => context.locals.session,
    can: (session, required) => can(session.permissions, required),
  },
})

A route then states what it needs alongside its schemas, and the handler stays a plain function:

export const POST = route(
  { body: createClientSchema, requires: 'client:write', res: res.created },
  (context) => create(context.actor, context.body),
)

No actor answers 401, one that can rejects answers 403, and neither is spelled out at the call site. requires is passed to can untouched, so its type is yours — a string, a union, an array, an object; astromatico never inspects it.

The actor is typed by whether the route requires anything. With requires, it was resolved and checked before the handler ran, so context.actor is your actor. Without it, the actor is still resolved and handed over, but it may be null and the types say so:

route({ requires: 'client:write' }, (c) => c.actor.login) // Session
route((c) => c.actor?.login) // Session | null

That is what removes the nullable dance from handlers that only ever run authorized.

Authorization runs before validation. A caller who is not allowed is not told what the schemas would have rejected.

onAnonymous and onDenied override the two answers when res.unauthorized() and res.forbidden() are not the bodies you want:

auth: {
  actor, can,
  onAnonymous: () => res.json({ error: 'Iniciá sesión' }, { status: 401 }),
  onDenied: (_context, required) => res.json({ error: `Te falta ${required}` }, { status: 403 }),
}

actor is called once per request whenever auth is configured, requires or not. If resolving it is expensive, cache it inside your own function.

License

MIT