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

@aweftjs/server

v0.1.1

Published

Connections and requests behind a gate: a listener the application supplies, one socket carrying a sync link and a call channel, and the modules' hooks run for whoever the gate allows.

Readme

@aweftjs/server

Connections and requests behind a gate. A listener you supply hands over web-standard requests and WebSocket handshakes; the server asks the gate who each one is and what it may reach, turns each accepted socket into a sync link plus a call channel, and runs the hooks of the modules the gate allows. It opens nothing, mints nothing, and knows no user.

Quickstart

import { auth, paths } from '@aweftjs/auth';
import { fromDirectory } from '@aweftjs/modules/node';
import { createServer } from '@aweftjs/server';
import { node } from '@aweftjs/server/node';
import { createStore, memoryDriver } from '@aweftjs/store';

const store = createStore({ driver: memoryDriver(), declare: { ...paths } });

const server = createServer({
	sources: [fromDirectory('./modules'), auth],
	store,
	gate: 'auth/Gate',
	listener: node({ port: 8080 }),
});

await server.start();

That is the whole boot, and everything else your application does is a module in ./modules. The sources say where modules come from, and start loads every module every one of them lists, in dependency order: there is no load list, and a file you drop in that directory is running after the next boot. The gate says who may reach what, as an object or as the name of a module that is one; there is no default. The listener is where connections come from; node() ships, and the contract is small enough to write for another runtime. store is optional and is the only thing this package hands a module.

The props rule, which is a guarantee, not a habit: the platform hands your factories store and nothing else. There is no props option and no way to add one, so anything your application makes (a rules table, a scheduler, a document you hold open, a client of another service) is a module, and the modules that need it name it in deps. That is what gives you the ordering for free: a module that opens a document is built before the module that shares it, because it said so. server.loader is the loader this built, for follow, for a test, and for loading or unloading while the server runs.

A microservice that knows nothing about users installs no auth, keeps no store, and types the one word:

import { createServer, open } from '@aweftjs/server';
const server = createServer({ sources: [fromDirectory('./modules')], gate: open, listener: node({ port: 8081 }) });

stop() ends every connection, stops the listener, and then unloads every module in reverse load order, so each module's stop runs after nothing can reach it. A stop that throws reaches handlers.failed under its module's name and the rest still unload, so one module that cannot let go does not strand the ones underneath it.

The gate

A gate is any object with two functions. A module instance is one; an object literal is one; open is one.

interface Gate<C> {
	identify(request: Request, peer: { address }): { context: C } | { refused: Refusal[] };
	access({ name, instance }, context: C): Refusal[];     // empty allows
}

identify runs once per connection (at the handshake, before any socket opens) and once per HTTP request. What it answers as context reaches every hook as an argument; a refusal answers 401 with the reasons, and for a handshake no socket is ever opened. access runs before a module sees a connection, a call or a request, and its reasons refuse the call, 403 the request, or skip the hook. Both may be asynchronous. A throw out of either is a defect: 500, and reported (see below), never a refusal.

A gate may be named instead of passed. gate: 'auth/Gate' is the name of one of the modules your sources list, and start reads it off the loader. A name that is not loaded, or whose instance has no identify and access, is refused at start with reason missing.

A composed gate is a module. To keep the battery's policy and add a rule of your own, write a module that deps on the gate you are wrapping and name yours:

export const deps = ['auth/Gate'];

export default ({ imports }) => ({
	identify: (request, peer) => imports.Gate.identify(request, peer),
	access: async (module, context) => {
		const reasons = await imports.Gate.access(module, context);
		if (reasons.length > 0) return reasons;
		return module.instance.admin === true && context.user !== admin
			? [{ code: 'not-admin', message: `${module.name} is for the administrator` }]
			: [];
	},
});

There is no compose and no chain of gates: two policies in a row is one function calling another, which a module already is. A rule about a name a person holds needs no composing: @aweftjs/auth's gate reads needs: 'admin' off a module itself. Composition is for a rule of another kind, such as an address list or a tenant.

The server interprets nothing in the context and reads nothing off a module for the gate. @aweftjs/auth's gate reads a module's public: true and treats absent as private; that is that gate's word, and a gate you write may read another or none. A gate with no session in it fits in ten lines:

const allowlist = (addresses: string[]): Gate<{ address: string }> => ({
	identify: (_request, peer) => peer.address !== undefined && addresses.includes(peer.address)
		? { context: { address: peer.address } }
		: { refused: [{ code: 'address', message: `${String(peer.address)} is not on the list` }] },
	access: () => [],
});

Calls between modules through imports are not gated: modules in one process trust each other. The gate stands between the outside and a module.

What a module can carry

The server reads five things off a loaded module's instance, all optional:

export default ({ store }) => ({
	// Once per connection the gate lets this module see. Return the function run when it ends.
	connection: async ({ link, request, context, close }) => {
		const board = await store.open(`board:${context.user}`);
		link.share('board', board.root, { accept: (commit) => mine(commit) ? [] : [{ code: 'not-yours', message: '' }] });
		return () => store.close(board);
	},
	// An `ask` from the client naming this module, after the gate allowed it.
	call: async (args, context, { progress }) => { progress('reading'); return report(args); },
	// HTTP, keyed by exact method and path.
	routes: { 'GET /api/export': async (request, context) => new Response(await csv(context.user)) },
	// An HTTP request no route matched. A `Response` answers it; `undefined` declines it.
	request: async (request) => serve(new URL(request.url).pathname),
	// What the server did, every event, after the fact.
	observe: (event, context) => { if (event.kind === 'call') count(event.name, event.ms); },
});

A document a hook shares is an @aweftjs/core observable: createObject, createArray and createMap make one, atomic groups writes into one commit, and a plain object or array in a slot is refused. The store's open hands back one ready to share.

connection hooks run in load order, which is dependency order, for the modules the gate allows; the functions they return run in reverse when the connection ends. A call is answered once every hook has run, so state a hook sets up is there for call. close() ends the connection from inside a hook, which is how a middleware module kills one. A module reloaded while a connection is open keeps that connection's hook state on the instance that made it, and its end functions with it; calls and routes go to the new instance. A share on a connection's link requires accept: the hole where any signed-in client writes anywhere is refused before anything crosses (no-accept). open is the handlers for the trusted case. accept is handed the arriving commit before it applies, so the document still reads as it was and a rule reads commit.deltas; the delta shape and the refused payload the other end gets are in @aweftjs/sync's README, under "Your rules go in accept", which is where that behaviour is tested.

call answers ask(name, args, { progress, timeout }) from the other end: progress streams back before the result, a throw answers with its reason and message. A module that is not loaded or has no call is missing; one the gate refuses is refused with the reasons.

routes are matched on METHOD /path exactly, the query aside; two loaded modules declaring one key are refused at start() by both names, and a conflict a later load introduces answers 500 to the request that meets it. No match is 404. A route answers with a Response; anything else is 500 and reported as not-a-response.

request is the rule for what no route matched, for the answers a table of exact paths cannot hold: a directory of files, or anything under one prefix. Every loaded module that declares it is asked, in load order, behind the gate, and the first Response is the request's answer; undefined declines and the next module is asked. A module the gate refuses is skipped rather than answered on the spot, so a public module still answers under a private one loaded before it. When every module declines the request is 404, as it was; when the gate refused one along the way and nothing answered it is 403 carrying that refusal's reasons, the same shape a refused route gets, so a private site does not read as an empty one. That body is JSON, { reasons: [{ code, message }] }. A hook that throws is 500, reported under its module's name, and it ends the walk: no module after it is asked, because a defect is not a decline. One that answers something that is not a Response is 500 and reported as not-a-response (design 248).

observe hears what the server did (design 260). Every loaded module that declares it is handed every event, in load order: connection (a socket the gate let in, with the handshake request) and closed (with ms since it opened); call (the module asked for and its instance when one is loaded, the args as sent, the outcome as { result } or { error }, and ms), for asks the server refused itself too, where the error is its missing, refused or closed; request (method, path, status, ms, and name, the module whose route or request hook answered, absent when the server answered itself; never the body); refused (a commit a share's accept turned away, with the topic and the reasons; an accept that throws is a refusal with the one reason accept-threw, as sync reads it); failed (what reaches handlers.failed). context is what identify answered for that connection or request, and the same reference reaches every hook and every event of one connection, so an observer that tells connections apart keys on it. The hook is not gated, because the server is telling its own modules what it did. Nothing waits for an observer and nothing reads what it answers: a throw or a rejection is reported under the observer's name and emitted to no one, so an observer that throws on every event does not chase its own tail, and the event it threw on is unaffected. args and result are the caller's own objects, not copies.

The client

One socket carries both the link (binary) and the requests (text). @aweftjs/client opens it and holds both:

import { createClient } from '@aweftjs/client';

const client = createClient({ url: 'wss://app.example/' });

const board = await client.share('board').ready;
const report = await client.ask('notes/Export', { month: '2026-09' }, { progress: (p) => bar.set(p) });

That package attaches the link and the request channel the moment the socket is made, so the share and the ask above reach the server even though they are written while the socket is still connecting. It has to, for the reason this section already gives: the server's hooks run at the handshake and its first frames are on the wire before the client's open event fires, and a message that arrives before anything listens is lost, on every WebSocket implementation there is.

Identity is fixed for a connection's life: it is what identify said at the handshake. After signing in or out over HTTP, the client reconnects. A browser cannot set a cookie on an open socket, and @aweftjs/auth/client does that reconnect for the page.

The listener

interface Listener {
	start({ request, socket }): Promise<void>;
	stop(): Promise<void>;
}

request(request, peer) answers a Request with a Response. socket(request, peer) is the handshake: it answers a Response to refuse the upgrade with that status, or a function to hand the opened socket to. peer is { address }, because a web-standard Request carries no address and a gate that keys on one has nowhere else to read it.

node(options) on @aweftjs/server/node ships: Node's http plus ws, the one runtime dependency in the stack. node({ port, host }) owns a server and closes it on stop; node({ server }) answers on a server you made (TLS, or a shared port) and never closes it. heartbeatMs pings every open socket and terminates one that does not answer, and nothing pings without it. maxPayload bounds a WebSocket message and an HTTP body alike (a declared length over it is 413, a body that crosses it is cut off): 1 MiB with nothing set, and Infinity is the one way to remove the bound. forwarded is for a listener behind a proxy you trust: true reads the scheme from x-forwarded-proto and the peer address from the last entry of x-forwarded-for, the one that proxy appended, since the first entry is whatever the client wrote; 'x-real-ip' reads the address from that header instead, for a proxy that sets it. Off, both headers are ignored. A TRACE is answered 405 before any handler sees it. port is readable after start, so port: 0 works in a test.

A listener for another runtime proves itself with listenerChecks() from @aweftjs/testing, the way a store driver or a sandbox runner does:

for (const c of listenerChecks()) test(c.name, () => c.run(() => {
	const listener = myListener();
	return { listener, url: () => myUrl(listener) };
}));

Before the gate

Two things are checked before identify runs, so a refused request costs no gate work, and each has a value here that one option changes.

createServer({
	sources, store, gate, listener,
	limits: { requests: { count: 600, windowMs: 60_000 } },   // the value with nothing set
	origins: ['https://app.example'],                         // beside the request's own host
});

limits.requests counts requests and handshakes per peer address over a sliding window; over the count, the answer is 429 with a Retry-After in seconds and the reason limit, and for a handshake no socket opens. limits: { requests: false } removes the count. The address is the listener's word: behind a proxy, start the Node listener with forwarded, or the proxy is the one address every request shares. Clients behind one address share one count. The count is coarse on purpose; the sign-in route keeps its own attempt counts (@aweftjs/auth).

origins is the Origin rule. A request carrying an Origin header is refused with 403 and the reason origin when that origin's host is not the request's own host, port included, and the request is a handshake or has a method other than GET, HEAD or OPTIONS. A request with no Origin header passes: that is a client that is not a browser, and it holds no cookie a browser set. A list adds origins a page may send from; 'any' removes the rule. The browser's own SameSite rule already keeps the cookie off a cross-site request; this rule is what stops a sign-in forged from another site, which needs no cookie, and it holds when an application widens the cookie.

sliding({ count, windowMs }) is exported, the counter behind the limit, for a module that counts something of its own: take(key) answers { ok: true } or { ok: false, retryAfter }, and clear(key) forgets a key.

Every answer the server gives carries X-Content-Type-Options: nosniff unless the module set the header itself.

When something throws

A call that throws a refusal, an error carrying a reason, answers its caller with it and is not reported: the caller heard what the module meant it to. A call that throws anything else answers its caller failed with the words the call failed and nothing of the error, and is reported: a module's own bug names files and values the caller has no business reading. Everything else reaches handlers.failed(name, error) on createServer too: a connection hook that throws (the connection is closed), an end function that throws (the rest still run), a route that throws (a bare 500), the gate that throws (500), and a route conflict met by a request (500). Without a handler, a call's error is written to the console and the process goes on, because any client can reach a public call; every other failure is raised where nothing catches it, and the process says so and ends. Pass a handler in production.

What this package never decides

Who is on a connection, whether it lives, and who may reach a module: the gate's. Who may write a commit: accept, per share. What a module is for and what it holds: the module's, and what it needs is deps. Users, sessions, cookies: @aweftjs/auth, or whatever you load instead. The two bounds above have values so that a server forgotten about is still bounded; every other limit or interval is yours.

It does decide one thing about modules, and only one: everything your sources list is loaded at start and unloaded at stop. Which modules exist is still yours, and so is anything you load or unload through server.loader while it runs.

When a client sends bytes that are not a frame, the link ends and the connection with it: the end functions run and the socket closes, so the client hears.

Every error this package raises carries a reason: missing (createServer without one of its three, or a gate named that is not a loaded gate), not-an-option (loader or props passed to createServer, which builds its own loader), route-conflict, no-accept, not-a-response, started. The design notes are in docs/design/ 071 to 073, 240 and 241.

The design notes

A design NNN above is the note of that number in docs/design/, which says what was decided, why, what it costs, and what would reverse it.