···2424}
25252626/**
2727- * @param signals the list of signals to combine
2727+ * @param signals - the list of signals to combine
2828 * @returns a combined signal, which will abort when any given signal does
2929 */
3030export function combineSignals(...signals: Array<AbortSignal | undefined>): AbortSignal {
···4646 }
47474848 signal.addEventListener('abort', handler)
4949- cleanups.push(() => signal.removeEventListener('abort', handler))
4949+ cleanups.push(() => { signal.removeEventListener('abort', handler); })
5050 }
51515252 controller.signal.addEventListener('abort', () => {
5353- cleanups.forEach(cb => cb())
5353+ cleanups.forEach(cb => { cb(); })
5454 })
55555656 return controller.signal
+4-6
src/common/async/blocking-atom.ts
···11-/** @module common/async */
22-31import { Semaphore } from './semaphore.js'
4253/**
64 * simple blocking atom, for waiting for a value.
75 * cribbed mostly from {@link https://github.com/ComFreek/async-playground}
88- *
99- * @template T - the type we're holding
106 */
117export class BlockingAtom<T> {
128···2824 * tries to get the item from the atom, and blocks until available.
2925 *
3026 * @example
3131- * if (await atom.take())
3232- * console.log('got it!')
2727+ * ```
2828+ * if (await atom.take())
2929+ * console.log('got it!')
3030+ * ```
3331 *
3432 * @param signal - an abort signal to cancel the await
3533 * @returns a promise for the item, or undefined if something aborted.
+2-4
src/common/async/blocking-queue.ts
···11-/** @module common/async */
22-31import { Semaphore } from './semaphore.js'
4253/**
···1816 this.#items = []
1917 }
20182121- /** @returns {number} how deep is the queue? */
1919+ /** @returns the depth of the queue */
2220 get depth(): number {
2321 return this.#items.length
2422 }
···5048 /**
5149 * block while waiting for an item off the queue.
5250 *
5353- * @param [signal] a signal to use for aborting the block.
5151+ * @param signal - a signal to use for aborting the block.
5452 * @returns the item off the queue; rejects if aborted.
5553 */
5654 async dequeue(signal?: AbortSignal): Promise<T> {
+5-6
src/common/async/semaphore.ts
···11-/** @module common/async */
22-31/**
42 * Simple counting semaphore, for blocking async ops.
53 * cribbed mostly from {@link https://github.com/ComFreek/async-playground}
···1715 * try to take from the semaphore, reducing it's count
1816 * if the semaphore is empty, blocks until available, or the given signal aborts.
1917 *
2020- * @param signal a signal to use to abort the block
1818+ * @param signal - a signal to use to abort the block
2119 * @returns true if the semaphore was successfully taken, false if aborted.
2220 */
2321 take(signal?: AbortSignal): Promise<boolean> {
2422 return new Promise((resolve) => {
2525- if (signal?.aborted) return resolve(false)
2323+ if (signal?.aborted) { resolve(false); return; }
26242725 // if there's resources available, use them
28262927 this.#counter--
3030- if (this.#counter >= 0) return resolve(true)
2828+ if (this.#counter >= 0) { resolve(true); return; }
31293230 // otherwise add to pending
3331 // and explicitly remove the resolver from the list on abort
···59576058 if (this.#resolvers.length > 0) {
6159 const resolver = this.#resolvers.shift()
6262- resolver && queueMicrotask(() => resolver(true))
6060+ if (resolver)
6161+ queueMicrotask(() => { resolver(true); })
6362 }
6463 }
6564
+13-14
src/common/async/sleep.ts
···11-/** @module common/async */
22-31/**
44- * @param ms the number of ms to sleep
55- * @param [signal] an aptional abort signal, to cancel the sleep
22+ * @param ms - the number of ms to sleep
33+ * @param signal - an aptional abort signal, to cancel the sleep
64 * @returns a promise that resolves after given amount of time, and is interruptable with an abort signal.
75 */
86export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
97 signal?.throwIfAborted()
10899+ // not sure why this error is coming up
1010+ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
1111 const { resolve, reject, promise } = Promise.withResolvers<void>()
1212 const timeout = setTimeout(resolve, ms)
13131414- if (signal) {
1515- const abortHandler = () => {
1616- clearTimeout(timeout)
1717- reject(signal.reason)
1818- }
1414+ if (!signal)
1515+ return promise
19162020- signal.addEventListener('abort', abortHandler)
2121- promise.finally(() => {
2222- signal.removeEventListener('abort', abortHandler)
2323- })
1717+ const abortHandler = () => {
1818+ clearTimeout(timeout)
1919+ reject(signal.reason)
2420 }
25212626- return promise
2222+ signal.addEventListener('abort', abortHandler)
2323+ return promise.finally(() => {
2424+ signal.removeEventListener('abort', abortHandler)
2525+ })
2726}
+7-7
src/common/breaker.ts
···11-/** @module common/async */
22-31import { Callback } from "#common/types"
4253/**
···75 * the breaker is tripped.
86 *
97 * @example
88+ * ```
109 * const breaker = makeBreaker()
1110 *
1211 * state.addEventHandler('finish', breaker.tripThen((e) => {
···2322 * // this will only be allowed to run many times
2423 * // but not *after* any of the _once_ wrappers has been called
2524 * })
2525+ * ```
2626 */
2727export class Breaker {
2828···3030 #onTripped?: () => void
31313232 /**
3333- * @param [onTripped]
3434- * an optional callback, called when the breaker is tripped, /before/ any wrapped functions.
3333+ * @param onTripped -
3434+ * an optional callback, called when the breaker is tripped, /before/ any wrapped functions.
3535 */
3636 constructor(onTripped?: () => void) {
3737 this.#tripped = false
···4747 * wrap the given callback in a function that will trip the breaker before it's called.
4848 * any subsequent calls to the wrapped function will be no-ops.
4949 *
5050- * @param fn the function to be wrapped in the breaker
5050+ * @param fn - the function to be wrapped in the breaker
5151 * @returns a wrapped function, controlled by the breaker
5252 */
5353 tripThen<CB extends Callback>(fn: CB): CB {
···6666 * wrap the given callback in a function that check the breaker before it's called.
6767 * once the breaker has been tripped, calls to the wrapped function will be no-ops.
6868 *
6969- * @param {common_types.Callback} fn the function to be wrapped in the breaker
7070- * @returns {common_types.Callback} a wrapped function, controlled by the breaker
6969+ * @param fn - the function to be wrapped in the breaker
7070+ * @returns a wrapped function, controlled by the breaker
7171 */
7272 untilTripped<CB extends Callback>(fn: CB): CB {
7373 return ((...args: Parameters<CB>): void => {
+11-15
src/common/crypto/cipher.ts
···11-/** @module common/crypto */
22-31import { base64url } from 'jose'
42import { nanoid } from 'nanoid'
53···2523 * Derive a key given PBKDF inputs; so long as all of the inputs are stable, the key will
2624 * be the same across derivations.
2725 *
2828- * @private
2929- *
3030- * @param passwordStr a password for derivation
3131- * @param saltStr a salt for derivation
3232- * @param nonceStr a nonce for derivation
3333- * @param [iterations] number of iterations for pbkdf
2626+ * @param passwordStr - a password for derivation
2727+ * @param saltStr - a salt for derivation
2828+ * @param nonceStr - a nonce for derivation
2929+ * @param iterations - number of iterations for pbkdf
3430 * @returns the derived crypto key
3531 */
3632async function deriveKey(passwordStr: string, saltStr: string, nonceStr: string, iterations: number = 100000): Promise<CryptoKey> {
···6864 * any missing parameter (password/salt/nonce) is replaced with a random value,
6965 * but if a stable password/salt/nonce is given, the derived keys will be stable.
7066 *
7171- * @param [passwordStr] a password for derivation
7272- * @param [saltStr] a salt for derivation
7373- * @param [nonceStr] a nonce for derivation
6767+ * @param passwordStr - a password for derivation
6868+ * @param saltStr - a salt for derivation
6969+ * @param nonceStr - a nonce for derivation
7470 * @returns the derived {@link Cipher}
7571 */
7672 static async derive(passwordStr: string, saltStr: string, nonceStr: string): Promise<Cipher> {
···8480 * import a cipher from an aleady existing {@link CryptoKey}.
8581 * does _not_ ensure that the imported key will work with our preferred encryption
8682 *
8787- * @param cryptokey the key to import into a Cipher
8383+ * @param cryptokey - the key to import into a Cipher
8884 */
8985 constructor(cryptokey: CryptoKey) {
9086 this.#cryptokey = cryptokey
9187 }
92889389 /**
9494- * @param data the data to encrypte
9090+ * @param data - the data to encrypte
9591 * @returns a url-safe base64 encoded encrypted string.
9692 */
9793 async encrypt(data: (string | Uint8Array)): Promise<string> {
···109105 }
110106111107 /**
112112- * @param encryptedData a base64 encoded string, previously encrypted with this cipher.
108108+ * @param encryptedData - a base64 encoded string, previously encrypted with this cipher.
113109 * @returns the decrypted output, decoded into utf-8 text.
114110 */
115111 async decryptText(encryptedData: string): Promise<string> {
···118114 }
119115120116 /**
121121- * @param encryptedData a base64 encoded string, previously encrypted with this cipher.
117117+ * @param encryptedData - a base64 encoded string, previously encrypted with this cipher.
122118 * @returns the decrypted output, as an array buffer of bytes.
123119 */
124120 async decryptBytes(encryptedData: string): Promise<ArrayBuffer> {
+1-5
src/common/crypto/jwks.ts
···11-/** @module common/crypto */
22-31import * as jose from 'jose'
42import { z } from 'zod/v4'
53import { CryptoError } from './errors.js'
66-77-/** @typedef {jose.JWK} JWK */
8495const subtleSignAlgo = { name: 'ECDSA', namedCurve: 'P-256' }
106const joseSignAlgo = { name: 'ES256' }
···105101}
106102107103/**
108108- * @param payload the payload to sign
104104+ * @param payload - the payload to sign
109105 * @returns a properly configured jwt signer, with the payload provided
110106 */
111107export function generateSignableJwt(payload: jose.JWTPayload): jose.SignJWT {
+4-6
src/common/crypto/jwts.ts
···11-/** @module common/crypto */
22-31import * as jose from 'jose'
42import { z } from 'zod/v4'
53import { JWTBadSignatureError } from '#common/crypto/errors'
···6159type VerifyOpts = Partial<Omit<jose.JWTVerifyOptions, 'algorithms'>>
62606361/**
6464- * @param jwt the (still encoded) token to verify
6565- * @param pubkey the key with which to verify the token
6666- * @param [options] the key with which to verify the token
6262+ * @param jwt - the (still encoded) token to verify
6363+ * @param pubkey - the key with which to verify the token
6464+ * @param options - the key with which to verify the token
6765 * @returns a verified payload
6866 * @throws if the signature is not valid
6967 */
···8583/**
8684 * generate a fingerprint for the given crypto key
8785 *
8888- * @param key the key to fingerprint
8686+ * @param key - the key to fingerprint
8987 * @returns the sha256 fingerprint of the key
9088 */
9189export async function fingerprintKey(key: CryptoKey): Promise<string> {
-2
src/common/errors.ts
···11-/** @module common */
22-31import { prettifyError, ZodError } from 'zod/v4'
4253const StatusCodes: Record<number, string> = {
···11-/** @module common/protocol */
22-31import { z } from 'zod/v4'
42import { jwkSchema } from '#common/crypto/jwks'
53
+1-3
src/common/schema/brand.ts
···11-/** @module common/schema */
22-31import { nanoid } from 'nanoid'
42import { z } from 'zod/v4'
53···3735 return this.#schema.parse(input) as Branded<string, B>
3836 }
39374040- /** @return a boolean if the string is valid */
3838+ /** @returns a boolean if the string is valid */
4139 validate(input: string): input is Branded<string, B> {
4240 return input != null && typeof input === 'string' && this.#schema.safeParse(input).success
4341 }
+19-14
src/common/socket.ts
···11-/** @module common/socket */
11+import WebSocket, { ErrorEvent, MessageEvent } from 'isomorphic-ws'
2233import { combineSignals } from '#common/async/aborts'
44import { BlockingAtom } from '#common/async/blocking-atom'
···1818 * Given a websocket, wait and take a single message off and return it.
1919 *
2020 * @example
2121+ * ```
2122 * const ws = new WebSocket("wss://example.com/stream")
2223 * const timeout = timeoutSignal(5000)
2324 *
···2930 * if (ws.readyState !== ws.CLOSED)
3031 * ws.close();
3132 * }
3333+ * ```
3234 */
3335export async function takeSocket(ws: WebSocket, signal?: AbortSignal): Promise<unknown> {
3436 signal?.throwIfAborted()
···3941 const error = new AbortController()
4042 const multisignal = combineSignals(error.signal, signal)
41434242- const onMessage = breaker.tripThen(m => atom.set(m.data))
4343- const onError = breaker.tripThen(e => error.abort(e))
4444- const onClose = breaker.tripThen(() => error.abort('closed'))
4444+ const onMessage = breaker.tripThen((m: MessageEvent) => { atom.set(m.data); })
4545+ const onError = breaker.tripThen((e: unknown) => { error.abort(e); })
4646+ const onClose = breaker.tripThen(() => { error.abort('closed'); })
45474648 try {
4749 ws.addEventListener('message', onMessage)
···50525153 const data = await atom.get(multisignal)
5254 if (!data) {
5353- throw new ProtocolError('socket read aborted', 408, multisignal?.reason)
5555+ const cause = normalizeError(multisignal.reason)
5656+ throw new ProtocolError('socket read aborted', 408, { cause })
5457 }
55585659 return data
···6568/**
6669 * exactly take socket, but will additionally apply a json decoding
6770 *
6868- * @param ws the socket to read
6969- * @param schema an a schema to execute
7070- * @param [signal] an abort signal to cancel the block
7171+ * @param ws - the socket to read
7272+ * @param schema - an a schema to execute
7373+ * @param signal - an abort signal to cancel the block
7174 * @returns the message off the socket
7275 */
7373-export async function takeSocketJson<T>(ws: WebSocket, schema: z.ZodSchema<T>, signal?: AbortSignal): Promise<T> {
7676+export async function takeSocketJson<T>(ws: WebSocket, schema: z.ZodType<T>, signal?: AbortSignal): Promise<T> {
7477 const data = await takeSocket(ws, signal)
7578 return parseJson.pipe(schema).parseAsync(data)
7679}
···101104 * Given a websocket, stream messages off the socket as an async generator.
102105 *
103106 * @example
107107+ * ```ts
104108 * const ws = new WebSocket("wss://example.com/stream")
105109 * const timeout = timeoutSignal(5000)
106110 *
···114118 * if (ws.readyState !== ws.CLOSED)
115119 * ws.close();
116120 * }
121121+ * ```
117122 */
118123export async function* streamSocket(ws: WebSocket, config_?: Partial<ConfigProps>) {
119124 const { signal, ...config } = { ...STREAM_CONFIG_DEFAULT, ...(config_ || {}) }
···123128 const queue = new BlockingQueue<StreamYield>(config.maxDepth)
124129125130 // if true, we're ignoring incoming messages until we drop the queue
126126- let inBackoffMode = false
131131+ let inBackoffMode: boolean = false
127132 const backoffThresh = Math.floor(config.maxDepth * 0.9)
128133129134 // we don't want to keep processing after we've been closed
···145150 })
146151147152 // todo: why are we getting this on client shutdown instead of onClose?
148148- const onError = breaker.tripThen((e: Event) => {
149149- queue.enqueue([error$, normalizeError(e)])
153153+ const onError = breaker.tripThen((e: ErrorEvent) => {
154154+ queue.enqueue([error$, normalizeError(e.error)])
150155 })
151156152157 const onClose = breaker.tripThen(() => {
···190195 * exactly stream socket, but will additionally apply a json decoding
191196 * messages not validating will end the stream with an error
192197 */
193193-export async function* streamSocketJson(ws: WebSocket, config?: Partial<ConfigProps>): AsyncGenerator<unknown> {
198198+export async function* streamSocketJson(ws: WebSocket, config?: Partial<ConfigProps>): AsyncGenerator {
194199 for await (const message of streamSocket(ws, config)) {
195200 yield parseJson.parseAsync(message)
196201 }
···202207 */
203208export async function* streamSocketSchema<T>(
204209 ws: WebSocket,
205205- schema: z.ZodSchema<T>,
210210+ schema: z.ZodType<T>,
206211 config?: Partial<ConfigProps>,
207212): AsyncGenerator<T> {
208213 const parser = parseJson.pipe(schema)
+3-3
src/common/strict-map.ts
···11-/** @module common */
22-31/** A map with methods to ensure key presence and safe update. */
42export class StrictMap<K, V> extends Map<K, V> {
5364 /**
75 * Get a value from the map, throwing if missing
88- * @throws {Error} if the key is not present in the map
66+ * @throws Error if the key is not present in the map
97 */
108 require(key: K): V {
119 if (!this.has(key)) throw Error(`key is required but not in the map`)
12101111+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1312 const value = this.get(key)!
1413 return value
1514 }
···2019 this.set(key, maker())
2120 }
22212222+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
2323 return this.get(key)!
2424 }
2525
+2-5
src/common/types.ts
···11-/** @module common */
22-31import { NEVER } from 'zod/v4'
4255-/**
66- * A callback function, with arbitrary arguments; use {Parameters} to extract them.
77- */
33+/** A callback function, with arbitrary arguments; use `Parameters` to extract them. */
44+// eslint-disable-next-line @typescript-eslint/no-explicit-any
85export type Callback = (...args: any[]) => void
96107/**
+11-3
src/server/index.ts
···11import express from 'express'
22import * as http from 'http'
33+34import { WebSocketServer } from 'ws'
4556import { apiRouter } from './routes-api/middleware'
···1011/**
1112 * configures an http server which hosts the SPA and websocket endpoint
1213 *
1313- * @param root the path to the root public/ directory
1414+ * @param root - the path to the root public/ directory
1415 * @returns a configured server
1516 */
1616-export function buildServer(root: string): http.Server<typeof http.IncomingMessage> {
1717+export function buildServer(root: string): http.Server {
1718 const app = express()
1919+2020+ // not sure why this error is coming up
2121+ // eslint-disable-next-line @typescript-eslint/no-misused-promises
1822 const server = http.createServer(app)
19232024 // API routes
···29333034 // WebSocket handling
3135 const wss = new WebSocketServer({ server, path: '/stream' })
3232- wss.on('connection', socketHandler)
3636+ wss.on('connection', (ws) => {
3737+ socketHandler(ws)
3838+ .catch((e: unknown) => { console.error('uncaught error from websocket', e) })
3939+ .finally(() => { console.log('socket handler complete') })
4040+ })
33413442 return server
3543}
···11+import { WebSocket } from 'isomorphic-ws'
22+13import { normalizeProtocolError, ProtocolError } from '#common/errors'
22-44+import { sendSocket, streamSocket } from '#common/socket.js'
35import * as protocol from '#common/protocol'
44-import { sendSocket, streamSocket } from '#common/socket.js'
56import * as realm from '#server/routes-socket/state'
6778/**
···2930 continue
30313132 default:
3232- throw new ProtocolError(`unknown message type: ${msg}`, 400)
3333+ console.error('unknown message!', msg)
3434+ throw new ProtocolError(`unknown message type!`, 400)
3335 }
3436 }
3537 catch (exc) {
···8284}
83858486/**
8585- * @private
8686- * @param {realm_types.AuthenticatedConnection} auth the current identity
8787- * @param {unknown} payload the payload to send
8888- * @param {protocol_types.IdentID[] | boolean} [recipients]
8989- * when true, send to the whole realm, including self
9090- * when false, send to the whole realm, excluding self
9191- * when an array of recipients, send to those recipients explicitly
8787+ * @param auth - the current identity
8888+ * @param payload - the payload to send
8989+ * @param recipients - an optional list of recpipents
9090+ * when true, send to the whole realm, including self
9191+ * when false, send to the whole realm, excluding self
9292+ * when an array of recipients, send to those recipients explicitly
9293 */
9394function realmBroadcast(auth: realm.AuthenticatedIdentity, payload: unknown, recipients: protocol.IdentID[] | boolean = false) {
9495 const echo = recipients === true || Array.isArray(recipients)
+1
src/server/routes-socket/handler.ts
···11import { format } from 'node:util'
22+import WebSocket from 'isomorphic-ws'
2334import { normalizeError, normalizeProtocolError } from '#common/errors'
45
+5-3
src/server/routes-socket/state.ts
···11+import WebSocket from 'isomorphic-ws'
22+13import { IdentID, RealmID } from '#common/protocol.js'
24import { StrictMap } from '#common/strict-map'
35···2224 * as initial registrants in a newly created realm. If the realm already
2325 * exists, it's not changed.
2426 *
2525- * @param realmid the realm id to ensure exists
2626- * @param registrantid the identity id of the registrant
2727- * @param registrantkey the public key of the registrant
2727+ * @param realmid - the realm id to ensure exists
2828+ * @param registrantid - the identity id of the registrant
2929+ * @param registrantkey - the public key of the registrant
2830 * @returns a registered realm, possibly newly created with the registrant
2931 */
3032export function ensureRegisteredRealm(realmid: RealmID, registrantid: IdentID, registrantkey: CryptoKey): Realm {
+3-3
src/server/routes-static.ts
···99/**
1010 * returns a configured static middleware
1111 *
1212- * @param opts options for corfiguring the middleware
1212+ * @param opts - options for corfiguring the middleware
1313 * @returns a new middleware
1414 */
1515export function makeStaticMiddleware(opts: StaticOpts): express.RequestHandler {
···1919/**
2020 * returns the index file for any GET request for text/html it matches
2121 *
2222- * @param opts options for configuring the middleware
2222+ * @param opts - options for configuring the middleware
2323 * @returns a new middleware
2424 */
2525export function makeSpaMiddleware(opts: StaticOpts): express.RequestHandler {
2626 return (req, res, next) => {
2727 if (req.method === 'GET' && req.accepts('text/html')) {
2828- return res.sendFile(join(opts.root, opts.index))
2828+ res.sendFile(join(opts.root, opts.index)); return;
2929 }
30303131 next() // otherwise