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

@ape-egg/async-await-websockets

v3.5.0

Published

A async/await solution to websockets

Readme

aaw

aaw.korte.kim — documentation

Major update since v3.0.0+

Async-await-websockets is now running on Bun (https://bun.sh/). Until the most popular runtime hosts have support for Bun you'll have to run it on your own custom server or in a docker container.

async-await-websockets

  • ✅ Uses native websockets
    • CLIENT (https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API/Writing_WebSocket_client_applications)
    • SERVER (https://github.com/websockets/ws)
  • ✅ Enables async/await messaging from the client
  • ✅ Broadcast messages
  • ✅ Automatic reconnection
  • ✅ Rooms — targeted multicast to named subsets of connections
  • ✅ Client authentication (optional)

How to create your own server

  1. mkdir my-server
  2. cd my-server
  3. bun init
  4. Add to package.json
"scripts": {
  "dev": "bun --watch index.js"
},
  1. bun install async-await-websockets
  2. Create index.js with contents:
import aaw from "async-await-websockets";

aaw("events");
  1. mkdir events
  2. bun dev

Your server should now be reachable on ws://localhost:1337

Configuration

aaw(eventDir, services, port, log, auth, maxPayloadLength, tls)

eventDir (string)

Name of directory that holds your socket events.

Default: events

services (object)

Third party services that you need access to in your socket events (e.g. database connection). ws and room are always exposed and cannot be removed.

Default: { ws: [Websocket Object], room: [Room API] }

port (integer)

A port of your liking.

Default: 1337

log (function)

With the parameter signature (event, websocketKey, async, error, body, result) you can create custom server logging for all events called through root-directory.

Default: undefined

auth (object | boolean)

Optional authentication. false (the default) leaves aaw a pure transport; true enables it with the built-in SQLite store. See Authentication.

Default: false

maxPayloadLength (integer)

The largest inbound websocket frame the server accepts, in bytes. A frame over the limit never reaches a handler — Bun closes the connection (1006, Received too big message), which a client experiences as a dead socket and a reconnect loop if it retries the same send. The server names the closed frame and the configured limit on console.error, so the failure is loud where it can be seen. Raise the limit when your events carry large payloads; remember base64 is a third larger than the bytes it encodes.

Default: 16777216 (16 MiB, Bun's own default)

tls (object)

Passed as-is to Bun.serve's tls option (https://bun.sh/docs/api/http#tls), so the server speaks wss:// instead of ws://. An https:// page can only open a secure socket — browsers block ws:// from it as mixed content.

aaw("events", {}, 443, undefined, false, undefined, {
  cert: Bun.file("./cert.pem"),
  key: Bun.file("./key.pem"),
});

Default: undefined (plain ws://)

Your server

aaw returns an Bun websocket-instance (https://bun.sh/docs/api/websockets)

Each .js file in events is scanned and available with ws.sendAsync('dir/file')

This is the signature for any .js file within events:

export default async (body, services) => {
  const response = await services.mongo.insertSomething(body.id);
  services.ws.sendEvent('notify-about-insertion', { id: response.id });
  return response;
}

Omitting the async keyword will treat the event as a regular websocket event.

Authentication

Off by default — aaw stays the transport it has always been. Switch it on with a fifth argument and you get server-minted sessions, a folder convention for who may call what, and a SQLite user store you never have to configure.

aaw("events", { mongo }, 1337, log, true);

The folder convention

Events under auth/ require a session; everything else is open. No per-file flag, no central policy list — where the file sits is the rule, the same way its path is already its name.

events/health.js            → "health"           anyone
events/teamplay/list.js     → "teamplay/list"    anyone
events/auth/chat.js         → "auth/chat"        needs a session
events/auth/admin/seed.js   → "auth/admin/seed"  needs a session

Turning authentication off does not open those events — it makes them unreachable, and aaw says so at boot:

Authentication is off — 2 event(s) under auth/ are unreachable: [ "auth/chat", "auth/admin/seed" ]

A caller that tries anyway is told why, rather than being served:

Authentication is not enabled — auth/chat is unreachable

So forgetting to configure auth can never be the thing that exposes a protected event.

Connecting

The connection itself is free — anyone may open a socket. What a token buys is the right to call anything under auth/.

import aaw from "@ape-egg/async-await-websockets/client.js";

const ws = aaw("wss://example.com");

await ws.sendAsync("aaw/login", { email, password });
await ws.sendAsync("auth/chat", { id, text });
await ws.sendAsync("aaw/logout");

There is no separate login API — aaw's own events are called the way every other event is.

The session binds to the connection, so it is established once rather than re-proven on every message. Handlers receive it as identity:

export default async ({ id, text }, { identity, room }) => {
  room.emit(`teamplay:${id}`, "chat", { from: identity.email, text });
};

The client keeps the token from those events and replays it after an automatic reconnect, before open fires — so a first call made inside open cannot race a reconnect it never saw. If the session has expired by then the client emits unauthorized instead.

Store the token yourself to survive a page reload:

const ws = aaw("wss://example.com", { token: localStorage.token });
ws.on("unauthorized", () => delete localStorage.token);

Built-in events

aaw's own events live in the package's own events/aaw/ folder, one file per event, named by their path exactly like yours, and registered alongside yours when authentication is on. They cannot sit under auth/ themselves — a caller has to be able to log in before it holds anything to log in with. An event file of your own that collides stops the server at boot rather than being silently shadowed.

events/aaw/login.js                   → "aaw/login"
events/aaw/password/request-reset.js  → "aaw/password/request-reset"

They are ordinary event files. Each declares the provider it belongs to, so naming a different provider simply leaves it unregistered:

export const provider = "sqlite";

export default async ({ email, password }, { authenticate, auth: { store } }) => {
  const user = await store.verify(email, password);

  if (!user) throw Error("Invalid credentials");

  return authenticate(user);
};

| Event | Does | |---|---| | aaw/register | Create an account and bind a session | | aaw/login | Bind a session to this connection | | aaw/resume | Re-bind an existing token (what reconnects use) | | aaw/logout | End the session everywhere | | aaw/password/request-reset | Mint a reset token and hand it to onPasswordReset | | aaw/password/set-new | Consume a reset token and set a new password |

Reset tokens are crypto.randomUUID() with a real expiry, checked where the password actually changes. Delivering one is your app's business, so aaw hands it over and stays out of the mail:

import { Resend } from "resend";

const resend = new Resend(process.env.RESEND_API_KEY);

aaw("events", {}, 1337, undefined, {
  onPasswordReset: ({ user, token }) =>
    resend.emails.send({
      from: "Acme <[email protected]>",
      to: user.email,
      subject: "Reset your password",
      html: `<a href="https://acme.com/reset#${token}">Reset your password</a>`,
    }),
});

Any sender works the same way — Resend, Postmark, SES, SMTP, or your own queue. Without an onPasswordReset handler there is no way to deliver a token, so aaw/password/request-reset answers with an error rather than a success nobody can act on.

request-reset answers { ok: true } for a known and an unknown address alike, so it cannot be used to enumerate accounts. Passwords are hashed with Bun.password (Argon2id), which is what makes a guessed password cost the attacker real CPU on every attempt.

Permissions

An identity may carry allowed — globs matched against the event path. Without it, any session may call any protected event.

{ allowed: ["auth/teamplay/*"] }   // a player
{ allowed: ["*"] }                 // an admin

Providers

providers defaults to ["sqlite"], aaw's built-in store. Naming any other provider turns the built-in password login off unless you list it too.

aaw("events", {}, 1337, undefined, {
  providers: ["sqlite", { name: "google", clientId, clientSecret, start, callback }],
});

A social provider redirects a browser, so it arrives over HTTP rather than the socket — the only reason aaw ever answers a plain request. aaw owns the session half (find or create the user, link provider + subject, mint a token, redirect back with it in the URL fragment); a provider owns the handshake half, as two functions:

{
  name: "google",
  redirect: "/",                                  // token arrives as #token=…
  start: (request) => Response.redirect(authorizeUrl, 302),
  callback: async (request) => ({ subject, email }),   // verified profile
}

It is served at /auth/google and /auth/google/callback — HTTP paths, unrelated to the auth/ event folder. An address already registered here links to that account rather than colliding with it, which holds only because callback returns an address the provider verified.

No OAuth provider ships yet — the store, the routes and the contract are in place so one can be added as a small module, and so social login does not need a schema change later.

Bringing your own store

The SQLite store is a default, not a requirement. Pass store and aaw never opens a database — useful when users already live in mongo, or when "users" are API keys in a committed file.

aaw("events", { mongo }, 1337, undefined, {
  store: {
    findUser: (email) => …,
    verify: async (email, password) => identity | null,
    createSession: (user, ttl) => token,
    readSession: (token) => identity | null,
    endSession: (token) => …,
  },
});

A store only needs what the features you enable actually call.

Your own login event can bind a session directly, for credentials aaw knows nothing about:

export default async ({ license }, { authenticate }) =>
  authenticate(await lookupByLicense(license));

Rooms

Every event handler receives a room API alongside ws. Rooms are named subsets of connections you can multicast to — useful for chat channels, game lobbies, or any group of clients that should receive the same event. Membership is per-connection and clears automatically when a client disconnects.

export default (body, { ws, room }) => {
  room.join(body.channel);
  room.emit(body.channel, 'joined', { id: ws.data }, ws);
};

room API

  • room.join(name) — add the current connection to room name (created on demand).
  • room.leave(name) — remove the current connection from room name (room is deleted when empty).
  • room.emit(name, event, data, except?) — send [event, data] to every member of name, optionally skipping one connection (e.g. pass ws to exclude the sender). Returns the number of clients sent to.
  • room.size(name) — number of connections currently in room name.

emit is connection-agnostic, so a later callback (e.g. a timer) can multicast to a room after the triggering message has resolved.

Rooms carry no authentication

A socket receives a room's messages because a handler called room.join for it — nothing more. The guard only sees inbound calls, so it never inspects who is in a room. If a room carries data only some connections should see, gate the join: put the event that calls room.join under auth/, or guard it on identity. Otherwise a public event that joins a socket to a protected room lets that socket receive everything emitted to it, even though it could never write to it.

// events/auth/subscribe.js — only a session can join, so only a session receives
export default (body, { room }) => {
  room.join(`teamplay:${body.id}`);
};

Your client

npm install async-await-websockets

import aaw from 'async-await-websockets';

const ws = aaw('wss://websocket-server.url:1337');

ws.on('open', () => {
  (async () => {
    try {
      const result = await ws.sendAsync('example-async', { somedata: "for the backend" });
      console.info(result);
    } catch ({ error }) {
      console.error(error);
    }
  })();
});

ws.sendAsync parameters:

  • event name (string, required)
  • payload (any, default undefined)
  • timeout in ms (integer, default 3000)

Error handling

When calling ws.sendAsync('some-event') there are two possible failures:

  1. The call to your socket server timed out (happens on the client).
  2. The server threw an error because something went wrong.

In both cases sendAsync will throw an object that contains an error-message like so:

{
  error: "What went wrong"
}

Publishing

  • bun run publish:check — dry-run npm pack to preview the published tarball.
  • bun run release — runs the check, prompts for a new version (or keeps the current one), and publishes @ape-egg/async-await-websockets to npm.

Requires being logged in to npm (npm login). The script is named release rather than publish because npm runs a script called publish again as a lifecycle hook once npm publish succeeds, which republishes the same version and fails on the second attempt.