add and format with prettier

+19 -24
eslint.config.js
··· 1 - import { includeIgnoreFile } from '@eslint/compat' 1 + import {includeIgnoreFile} from '@eslint/compat' 2 2 import js from '@eslint/js' 3 3 import json from '@eslint/json' 4 4 import restrictedGlobals from 'confusing-browser-globals' 5 - import tsdoc from 'eslint-plugin-tsdoc' 5 + import prettier from 'eslint-plugin-prettier/recommended' 6 6 import react from 'eslint-plugin-react' 7 7 import reactHooks from 'eslint-plugin-react-hooks' 8 + import tsdoc from 'eslint-plugin-tsdoc' 8 9 import globals from 'globals' 10 + import path from 'node:path' 9 11 import tseslint from 'typescript-eslint' 10 - import path from 'node:path' 11 12 12 13 const gitignore = path.resolve(import.meta.dirname, '.gitignore') 13 14 ··· 18 19 { 19 20 name: 'javascript basics', 20 21 files: ['**/*.@(js|jsx|ts|tsx)'], 21 - 22 - plugins: {tsdoc}, 23 22 extends: [js.configs.recommended], 24 23 25 24 languageOptions: { ··· 32 31 rules: { 33 32 // eg, `open` is a global, but probably not really intended that way in our code 34 33 'no-restricted-globals': ['error', ...restrictedGlobals], 35 - 'no-unused-vars': ['warn', { varsIgnorePattern: '(?:^_)' }], 36 - 37 - 'tsdoc/syntax': 'warn' 34 + 'no-unused-vars': ['warn', {varsIgnorePattern: '(?:^_)'}], 38 35 }, 39 36 }, 40 37 ··· 42 39 name: 'typescript basics', 43 40 files: ['**/*.@(ts|tsx)'], 44 41 extends: [tseslint.configs.strictTypeChecked], 42 + plugins: {tsdoc}, 45 43 languageOptions: { 46 44 parserOptions: { 47 45 projectService: true, ··· 49 47 }, 50 48 }, 51 49 rules: { 52 - '@typescript-eslint/no-unused-vars': ['warn', { varsIgnorePattern: '(?:^_)' }], 50 + '@typescript-eslint/no-unused-vars': ['warn', {varsIgnorePattern: '(?:^_)'}], 53 51 '@typescript-eslint/no-unnecessary-condition': 'off', 54 52 '@typescript-eslint/restrict-template-expressions': 'off', 55 - } 53 + 'tsdoc/syntax': 'warn', 54 + }, 56 55 }, 57 56 58 57 { 59 58 name: 'node files', 60 - files: [ 61 - 'src/server/**/*.@(js|jsx|ts|tsx)', 62 - 'src/cmd/**/*.@(js|jsx|ts|tsx)' 63 - ], 59 + files: ['src/server/**/*.@(js|jsx|ts|tsx)', 'src/cmd/**/*.@(js|jsx|ts|tsx)'], 64 60 languageOptions: { 65 61 globals: { 66 62 ...globals.es2024, ··· 73 69 // mostly cribbed from preact's config, but that's not setup to handle eslint9 74 70 // https://github.com/preactjs/eslint-config-preact/blob/master/index.js 75 71 name: 'client files', 76 - files: [ 77 - 'src/client/**/*.@(js|jsx|ts|tsx)', 78 - ], 72 + files: ['src/client/**/*.@(js|jsx|ts|tsx)'], 79 73 languageOptions: { 80 74 globals: { 81 75 ...globals.es2024, ··· 96 90 // preact / jsx rules 97 91 'react/no-deprecated': 2, 98 92 'react/react-in-jsx-scope': 0, // handled this automatically 99 - 'react/display-name': [1, { ignoreTranspilerName: false }], 93 + 'react/display-name': [1, {ignoreTranspilerName: false}], 100 94 'react/jsx-no-bind': [ 101 95 1, 102 96 { ··· 109 103 'react/jsx-no-duplicate-props': 2, 110 104 'react/jsx-no-target-blank': 2, 111 105 'react/jsx-no-undef': 2, 112 - 'react/jsx-tag-spacing': [2, { beforeSelfClosing: 'always' }], 106 + 'react/jsx-tag-spacing': [2, {beforeSelfClosing: 'always'}], 113 107 'react/jsx-uses-react': 1, // debatable 114 108 'react/jsx-uses-vars': 2, 115 - 'react/jsx-key': [2, { checkFragmentShorthand: true }], 109 + 'react/jsx-key': [2, {checkFragmentShorthand: true}], 116 110 'react/self-closing-comp': 2, 117 111 'react/prefer-es6-class': 2, 118 112 'react/prefer-stateless-function': 1, ··· 143 137 files: ['**/*.json'], 144 138 ignores: ['package-lock.json'], 145 139 146 - plugins: { json }, 140 + plugins: {json}, 147 141 language: 'json/json', 148 - extends: [ 149 - json.configs.recommended 150 - ], 142 + extends: [json.configs.recommended], 151 143 }, 152 144 { 153 145 files: ['tsconfig.json'], 154 146 language: 'json/jsonc', 155 147 }, 148 + 149 + // prettier last, so it can turn everything off 150 + prettier, 156 151 )
+7 -18
jest.config.js
··· 1 1 import globToRegexp from 'glob-to-regexp' 2 2 import path from 'node:path' 3 - import { fileURLToPath } from 'node:url' 3 + import {fileURLToPath} from 'node:url' 4 4 import gitignore from 'parse-gitignore' 5 5 import * as tsjest from 'ts-jest' 6 6 ··· 9 9 const __dirname = path.dirname(__filename) 10 10 const ignorefile = path.resolve(__dirname, '.gitignore') 11 11 12 - const { patterns } = gitignore(ignorefile) 13 - return patterns 14 - .map(p => globToRegexp(p, { globstar: true }).source) 12 + const {patterns} = gitignore(ignorefile) 13 + return patterns.map((p) => globToRegexp(p, {globstar: true}).source) 15 14 } 16 15 17 16 export default { 18 17 testMatch: ['<rootDir>/src/**/*.spec.{ts,tsx}'], 19 - testPathIgnorePatterns: [ 20 - ...gitignorePatterns(), 21 - ], 18 + testPathIgnorePatterns: [...gitignorePatterns()], 22 19 23 20 cache: true, 24 21 cacheDirectory: path.join(import.meta.dirname, '.jestcache'), ··· 33 30 34 31 // use ts-jest preset for esm support 35 32 // but we have to tell it to look at jsx files too, not just tsx 36 - ...( 37 - tsjest.createJsWithTsEsmPreset() 38 - ), 33 + ...tsjest.createJsWithTsEsmPreset(), 39 34 40 35 // if node_modules are ESM, we need to _include_ them from 41 - transformIgnorePatterns: [ 42 - 'node_modules/(?!(nanoid|jose|preact|@preact)/)', 43 - ], 36 + transformIgnorePatterns: ['node_modules/(?!(nanoid|jose|preact|@preact)/)'], 44 37 45 - collectCoverageFrom: [ 46 - 'src/**/*.{ts,tsx}', 47 - '!src/**/*.spec.{ts,tsx}', 48 - '!src/**/node_modules/**', 49 - ], 38 + collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.spec.{ts,tsx}', '!src/**/node_modules/**'], 50 39 }
+1 -1
jest.setup.js
··· 1 - import { expect } from '@jest/globals' 1 + import {expect} from '@jest/globals' 2 2 3 3 // jest-dom matchers for better DOM assertions 4 4 import '@testing-library/jest-dom'
+2 -6
jsdoc.json
··· 4 4 "dictionaries": ["jsdoc"] 5 5 }, 6 6 "source": { 7 - "include": [ 8 - "src" 9 - ], 7 + "include": ["src"], 10 8 "includePattern": ".+\\.js(doc)?$", 11 9 "excludePattern": "(^|\\/|\\\\)_" 12 10 }, ··· 16 14 "destination": "./docs/", 17 15 "recurse": true 18 16 }, 19 - "plugins": [ 20 - "plugins/markdown" 21 - ], 17 + "plugins": ["plugins/markdown"], 22 18 "templates": { 23 19 "referenceTitle": "skypod", 24 20 "cleverLinks": true,
+104
package-lock.json
··· 37 37 "@types/ws": "^8.18.1", 38 38 "confusing-browser-globals": "^1.0.11", 39 39 "eslint": "^9.28.0", 40 + "eslint-config-prettier": "^10.1.5", 41 + "eslint-plugin-prettier": "^5.5.0", 40 42 "eslint-plugin-react": "^7.37.5", 41 43 "eslint-plugin-react-hooks": "^5.2.0", 42 44 "eslint-plugin-tsdoc": "^0.4.0", ··· 48 50 "jest-fixed-jsdom": "^0.0.9", 49 51 "jsdom": "^26.1.0", 50 52 "parse-gitignore": "^2.0.0", 53 + "prettier": "^3.5.3", 54 + "prettier-plugin-organize-imports": "^4.1.0", 51 55 "tidy-jsdoc-fork": "github:lygaret/tidy-jsdoc", 52 56 "ts-jest": "^29.4.0", 53 57 "typescript": "^5.8.3", ··· 7082 7086 } 7083 7087 } 7084 7088 }, 7089 + "node_modules/eslint-config-prettier": { 7090 + "version": "10.1.5", 7091 + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz", 7092 + "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==", 7093 + "dev": true, 7094 + "license": "MIT", 7095 + "bin": { 7096 + "eslint-config-prettier": "bin/cli.js" 7097 + }, 7098 + "funding": { 7099 + "url": "https://opencollective.com/eslint-config-prettier" 7100 + }, 7101 + "peerDependencies": { 7102 + "eslint": ">=7.0.0" 7103 + } 7104 + }, 7105 + "node_modules/eslint-plugin-prettier": { 7106 + "version": "5.5.0", 7107 + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.0.tgz", 7108 + "integrity": "sha512-8qsOYwkkGrahrgoUv76NZi23koqXOGiiEzXMrT8Q7VcYaUISR+5MorIUxfWqYXN0fN/31WbSrxCxFkVQ43wwrA==", 7109 + "dev": true, 7110 + "license": "MIT", 7111 + "dependencies": { 7112 + "prettier-linter-helpers": "^1.0.0", 7113 + "synckit": "^0.11.7" 7114 + }, 7115 + "engines": { 7116 + "node": "^14.18.0 || >=16.0.0" 7117 + }, 7118 + "funding": { 7119 + "url": "https://opencollective.com/eslint-plugin-prettier" 7120 + }, 7121 + "peerDependencies": { 7122 + "@types/eslint": ">=8.0.0", 7123 + "eslint": ">=8.0.0", 7124 + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", 7125 + "prettier": ">=3.0.0" 7126 + }, 7127 + "peerDependenciesMeta": { 7128 + "@types/eslint": { 7129 + "optional": true 7130 + }, 7131 + "eslint-config-prettier": { 7132 + "optional": true 7133 + } 7134 + } 7135 + }, 7085 7136 "node_modules/eslint-plugin-react": { 7086 7137 "version": "7.37.5", 7087 7138 "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", ··· 7459 7510 "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", 7460 7511 "dev": true, 7461 7512 "license": "MIT" 7513 + }, 7514 + "node_modules/fast-diff": { 7515 + "version": "1.3.0", 7516 + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", 7517 + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", 7518 + "dev": true, 7519 + "license": "Apache-2.0" 7462 7520 }, 7463 7521 "node_modules/fast-fifo": { 7464 7522 "version": "1.3.2", ··· 12685 12743 "license": "MIT", 12686 12744 "engines": { 12687 12745 "node": ">= 0.8.0" 12746 + } 12747 + }, 12748 + "node_modules/prettier": { 12749 + "version": "3.5.3", 12750 + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", 12751 + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", 12752 + "dev": true, 12753 + "license": "MIT", 12754 + "bin": { 12755 + "prettier": "bin/prettier.cjs" 12756 + }, 12757 + "engines": { 12758 + "node": ">=14" 12759 + }, 12760 + "funding": { 12761 + "url": "https://github.com/prettier/prettier?sponsor=1" 12762 + } 12763 + }, 12764 + "node_modules/prettier-linter-helpers": { 12765 + "version": "1.0.0", 12766 + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", 12767 + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", 12768 + "dev": true, 12769 + "license": "MIT", 12770 + "dependencies": { 12771 + "fast-diff": "^1.1.2" 12772 + }, 12773 + "engines": { 12774 + "node": ">=6.0.0" 12775 + } 12776 + }, 12777 + "node_modules/prettier-plugin-organize-imports": { 12778 + "version": "4.1.0", 12779 + "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.1.0.tgz", 12780 + "integrity": "sha512-5aWRdCgv645xaa58X8lOxzZoiHAldAPChljr/MT0crXVOWTZ+Svl4hIWlz+niYSlO6ikE5UXkN1JrRvIP2ut0A==", 12781 + "dev": true, 12782 + "license": "MIT", 12783 + "peerDependencies": { 12784 + "prettier": ">=2.0", 12785 + "typescript": ">=2.9", 12786 + "vue-tsc": "^2.1.0" 12787 + }, 12788 + "peerDependenciesMeta": { 12789 + "vue-tsc": { 12790 + "optional": true 12791 + } 12688 12792 } 12689 12793 }, 12690 12794 "node_modules/pretty-format": {
+4
package.json
··· 49 49 "@types/ws": "^8.18.1", 50 50 "confusing-browser-globals": "^1.0.11", 51 51 "eslint": "^9.28.0", 52 + "eslint-config-prettier": "^10.1.5", 53 + "eslint-plugin-prettier": "^5.5.0", 52 54 "eslint-plugin-react": "^7.37.5", 53 55 "eslint-plugin-react-hooks": "^5.2.0", 54 56 "eslint-plugin-tsdoc": "^0.4.0", ··· 60 62 "jest-fixed-jsdom": "^0.0.9", 61 63 "jsdom": "^26.1.0", 62 64 "parse-gitignore": "^2.0.0", 65 + "prettier": "^3.5.3", 66 + "prettier-plugin-organize-imports": "^4.1.0", 63 67 "tidy-jsdoc-fork": "github:lygaret/tidy-jsdoc", 64 68 "ts-jest": "^29.4.0", 65 69 "typescript": "^5.8.3",
+14
prettier.config.js
··· 1 + /** 2 + * @see https://prettier.io/docs/configuration 3 + * @type {import("prettier").Config} 4 + */ 5 + export default { 6 + printWidth: 100, 7 + tabWidth: 2, 8 + semi: false, 9 + singleQuote: true, 10 + trailingComma: 'all', 11 + bracketSpacing: false, 12 + 13 + plugins: ['prettier-plugin-organize-imports'], 14 + }
+15 -14
src/client/components/messenger.tsx
··· 1 - import { RealmConnection } from '#client/realm/connection.js' 2 - import { IdentID } from '#common/protocol' 3 - import { useState, useEffect, useCallback } from 'preact/hooks' 1 + import {RealmConnection} from '#client/realm/connection.js' 2 + import {IdentID} from '#common/protocol' 3 + import {useCallback, useEffect, useState} from 'preact/hooks' 4 4 5 5 export type MessengerProps = { 6 6 realmConnection: RealmConnection 7 7 } 8 8 9 - export const Messenger: preact.FunctionComponent<{ webrtcManager: RealmConnection }> = (props) => { 10 - const { webrtcManager } = props 9 + export const Messenger: preact.FunctionComponent<{webrtcManager: RealmConnection}> = (props) => { 10 + const {webrtcManager} = props 11 11 const [messages, setMessages] = useState<[IdentID, string][]>([]) 12 12 13 - const peerdata = useCallback((event: CustomEvent<{ remoteId: IdentID, data: unknown }>) => { 14 - setMessages([...messages, [event.detail.remoteId, `${event.detail.data}`]]) 15 - }, [messages]) 13 + const peerdata = useCallback( 14 + (event: CustomEvent<{remoteId: IdentID; data: unknown}>) => { 15 + setMessages([...messages, [event.detail.remoteId, `${event.detail.data}`]]) 16 + }, 17 + [messages], 18 + ) 16 19 17 20 const sendMessage = useCallback(() => { 18 - webrtcManager.broadcast('what\'s up friends?') 21 + webrtcManager.broadcast("what's up friends?") 19 22 }, [webrtcManager]) 20 23 21 24 useEffect(() => { 22 25 if (!webrtcManager) return 23 26 24 - webrtcManager.addEventListener('peerdata', peerdata as ((event: Event) => void)) 27 + webrtcManager.addEventListener('peerdata', peerdata as (event: Event) => void) 25 28 return () => { 26 - webrtcManager.removeEventListener('peerdata', peerdata as ((event: Event) => void)) 29 + webrtcManager.removeEventListener('peerdata', peerdata as (event: Event) => void) 27 30 } 28 31 }, [webrtcManager, peerdata]) 29 32 30 33 return ( 31 34 <div className="messages-list"> 32 35 <h3>Realm Messages</h3> 33 - <pre> 34 - { JSON.stringify(messages, null, 2) } 35 - </pre> 36 + <pre>{JSON.stringify(messages, null, 2)}</pre> 36 37 <div> 37 38 <textarea /> 38 39 <button onClick={sendMessage}>Send</button>
+19 -25
src/client/components/peer-list.tsx
··· 1 - import { useState, useEffect } from 'preact/hooks' 1 + import {useEffect, useState} from 'preact/hooks' 2 2 3 - import { PeerState, RealmConnection } from '#client/realm/connection' 4 - import { IdentID } from '#common/protocol' 3 + import {PeerState, RealmConnection} from '#client/realm/connection' 4 + import {IdentID} from '#common/protocol' 5 5 6 - export const PeerList: preact.FunctionComponent<{ webrtcManager: RealmConnection }> = (props) => { 7 - const { webrtcManager } = props 6 + export const PeerList: preact.FunctionComponent<{webrtcManager: RealmConnection}> = (props) => { 7 + const {webrtcManager} = props 8 8 const [peers, setPeers] = useState<Record<IdentID, PeerState>>({}) 9 9 10 10 useEffect(() => { ··· 38 38 return ( 39 39 <div className="peer-list"> 40 40 <h3>Realm Members</h3> 41 - { 42 - Object.keys(peers).length === 0 43 - ? <p>No other peers connected</p> 44 - : ( 45 - <ul> 46 - {Object.entries(peers).map(([peerId, state]) => ( 47 - <li key={peerId} className={`peer-item`}> 48 - <span className="peer-id">{peerId}</span> 49 - <ConnectionStatus state={state} /> 50 - </li> 51 - ))} 52 - </ul> 53 - ) 54 - } 41 + {Object.keys(peers).length === 0 ? ( 42 + <p>No other peers connected</p> 43 + ) : ( 44 + <ul> 45 + {Object.entries(peers).map(([peerId, state]) => ( 46 + <li key={peerId} className={`peer-item`}> 47 + <span className="peer-id">{peerId}</span> 48 + <ConnectionStatus state={state} /> 49 + </li> 50 + ))} 51 + </ul> 52 + )} 55 53 </div> 56 54 ) 57 55 } 58 56 59 - function ConnectionStatus({ state }: { state: PeerState }): preact.JSX.Element { 57 + function ConnectionStatus({state}: {state: PeerState}): preact.JSX.Element { 60 58 const getStatusIcon = () => { 61 59 if (state.connected) return '🟢' 62 60 if (state.destroyed) return '🔴' ··· 67 65 <div className="connection-status"> 68 66 <span className="status-icon">{getStatusIcon()}</span> 69 67 <code className="initiator"> 70 - {state.address.family} 71 - - 72 - {state.address.address} 73 - : 74 - {state.address.port} 68 + {state.address.family}-{state.address.address}:{state.address.port} 75 69 </code> 76 70 </div> 77 71 )
+3 -4
src/client/index.tsx
··· 1 - import { render } from 'preact' 1 + import {render} from 'preact' 2 2 3 - import { App } from './page-app' 4 3 import './index.css' 4 + import {App} from './page-app' 5 5 6 6 const root = document.getElementById('app') 7 - if (root == null) 8 - throw new TypeError('root isn\'t present?') 7 + if (root == null) throw new TypeError("root isn't present?") 9 8 10 9 render(<App />, root)
+2 -2
src/client/page-app.tsx
··· 1 - import { RealmConnectionProvider } from './realm/context' 2 - import { WebRTCDemo } from './webrtc-demo' 1 + import {RealmConnectionProvider} from './realm/context' 2 + import {WebRTCDemo} from './webrtc-demo' 3 3 4 4 export const App: preact.FunctionComponent = () => { 5 5 return (
+58 -67
src/client/realm/connection.ts
··· 1 - import { nanoid } from 'nanoid' 2 - import SimplePeer from 'simple-peer' 3 1 import WebSocket from 'isomorphic-ws' 4 - import { z } from 'zod/v4' 2 + import {nanoid} from 'nanoid' 3 + import SimplePeer from 'simple-peer' 4 + import {z} from 'zod/v4' 5 5 6 - import { generateSignableJwt, jwkExport } from '#common/crypto/jwks' 7 - import { normalizeError, normalizeProtocolError, ProtocolError } from '#common/errors' 8 - import { IdentID, parseJson, PreauthRegisterMessage, RealmBroadcastMessage, realmFromServerMessageSchema, RealmID, RealmRtcPeerWelcomeMessage, realmRtcPeerWelcomeMessageSchema, RealmRtcSignalMessage } from '#common/protocol' 9 - import { sendSocket, streamSocketJson, takeSocketJson } from '#common/socket' 6 + import {generateSignableJwt, jwkExport} from '#common/crypto/jwks' 7 + import {normalizeError, normalizeProtocolError, ProtocolError} from '#common/errors' 8 + import { 9 + IdentID, 10 + parseJson, 11 + PreauthRegisterMessage, 12 + RealmBroadcastMessage, 13 + realmFromServerMessageSchema, 14 + RealmID, 15 + RealmRtcPeerWelcomeMessage, 16 + realmRtcPeerWelcomeMessageSchema, 17 + RealmRtcSignalMessage, 18 + } from '#common/protocol' 19 + import {sendSocket, streamSocketJson, takeSocketJson} from '#common/socket' 10 20 11 21 /** the state of a specific peer */ 12 22 export interface PeerState { 13 - 14 23 /** true if the peer connection is active */ 15 24 connected: boolean 16 25 ··· 19 28 20 29 /** the peer's address */ 21 30 address: ReturnType<SimplePeer.Instance['address']> 22 - 23 31 } 24 32 25 33 export interface RealmIdentity { ··· 30 38 31 39 /** A connection manager */ 32 40 export class RealmConnection extends EventTarget { 33 - 34 41 #url: string 35 42 #identity: RealmIdentity 36 43 ··· 54 61 } 55 62 56 63 #handleSocketOpen: WebSocket['onopen'] = async () => { 57 - if (this.#socket == undefined) 58 - throw new Error('socket open handler called with no socket?') 64 + if (this.#socket == undefined) throw new Error('socket open handler called with no socket?') 59 65 60 66 try { 61 67 console.debug('realm connection, socket loop open') ··· 66 72 67 73 const pubkey = await jwkExport.parseAsync(this.#identity.keypair.publicKey) 68 74 this.#socket.send( 69 - await this.#signJwt({ msg: 'preauth.register', pubkey } as PreauthRegisterMessage), 75 + await this.#signJwt({msg: 'preauth.register', pubkey} as PreauthRegisterMessage), 70 76 ) 71 77 72 78 // the next message should be a welcome message ··· 75 81 let welcome: RealmRtcPeerWelcomeMessage 76 82 try { 77 83 welcome = await takeSocketJson(this.#socket, realmRtcPeerWelcomeMessageSchema) 78 - } 79 - catch (exc) { 84 + } catch (exc) { 80 85 const err = normalizeError(exc) 81 - throw new ProtocolError('failure on authentication', 401, { cause: err }) 86 + throw new ProtocolError('failure on authentication', 401, {cause: err}) 82 87 } 83 88 84 - this.#dispatchCustomEvent('wsauth', { welcome }) 89 + this.#dispatchCustomEvent('wsauth', {welcome}) 85 90 86 91 // we initiate connections outbound when starting up 87 92 // we never initiate connections to other peers otherwise ··· 98 103 99 104 // not a known rtc specific message, so we don't capture it 100 105 if (!parse.success) { 101 - this.#dispatchCustomEvent('wsdata', { data }) 106 + this.#dispatchCustomEvent('wsdata', {data}) 102 107 continue 103 108 } 104 109 ··· 106 111 switch (parse.data.msg) { 107 112 case 'realm.rtc.peer-joined': 108 113 // new peers connect to us, we don't connect to them 109 - this.#dispatchCustomEvent('peerjoined', { identid: parse.data.identid }) 114 + this.#dispatchCustomEvent('peerjoined', {identid: parse.data.identid}) 110 115 continue 111 116 112 117 case 'realm.rtc.peer-left': 113 118 // peer is gone, disconnect 114 119 this.disconnectPeer(parse.data.identid) 115 - this.#dispatchCustomEvent('peerleft', { identid: parse.data.identid }) 120 + this.#dispatchCustomEvent('peerleft', {identid: parse.data.identid}) 116 121 continue 117 122 118 123 case 'realm.rtc.signal': { ··· 132 137 } 133 138 } 134 139 } 135 - } 136 - catch (exc) { 140 + } catch (exc) { 137 141 const err = normalizeProtocolError(exc) 138 142 139 143 console.error('realm connection, socket loop error', err) 140 - this.#dispatchCustomEvent('wserror', { error: err }) 141 - } 142 - finally { 144 + this.#dispatchCustomEvent('wserror', {error: err}) 145 + } finally { 143 146 console.debug('realm connection, socket loop ended') 144 147 this.destroy() 145 148 } 146 149 } 147 150 148 151 #handleSocketError: WebSocket['onerror'] = (exc) => { 149 - this.#dispatchCustomEvent('wserror', { error: normalizeProtocolError(exc) }) 152 + this.#dispatchCustomEvent('wserror', {error: normalizeProtocolError(exc)}) 150 153 this.destroy() 151 154 } 152 155 ··· 162 165 destroy() { 163 166 console.debug('realm connection #destroy!') 164 167 165 - if (this.connected) 166 - this.#socket.close() 168 + if (this.connected) this.#socket.close() 167 169 168 - for (const peer of this.#peers.values()) 169 - peer.destroy() 170 + for (const peer of this.#peers.values()) peer.destroy() 170 171 171 172 this.#peers.clear() 172 173 this.#nonces.clear() 173 174 } 174 175 175 176 #dispatchCustomEvent(type: string, detail?: object) { 176 - this.dispatchEvent(new CustomEvent(type, { detail })) 177 + this.dispatchEvent(new CustomEvent(type, {detail})) 177 178 } 178 179 179 180 /** generates and signs a JWT scoped to this identity/realm containing the given payload */ ··· 192 193 return peer 193 194 } 194 195 195 - peer = new RealmConnectionPeer( 196 - this, 197 - nanoid(), 198 - this.#identity.identid, 199 - remoteid, 200 - initiator, 201 - ) 196 + peer = new RealmConnectionPeer(this, nanoid(), this.#identity.identid, remoteid, initiator) 202 197 203 198 peer.on('connect', () => { 204 199 console.log(`connected to ${remoteid}`) 205 200 206 - this.#dispatchCustomEvent('peeropen', { remoteid }) 201 + this.#dispatchCustomEvent('peeropen', {remoteid}) 207 202 }) 208 203 209 204 peer.on('close', () => { ··· 211 206 212 207 this.#peers.delete(remoteid) 213 208 this.#nonces.delete(remoteid) 214 - this.#dispatchCustomEvent('peerclose', { remoteid }) 209 + this.#dispatchCustomEvent('peerclose', {remoteid}) 215 210 }) 216 211 217 212 peer.on('error', (err) => { 218 213 console.error(`Error with peer ${remoteid}:`, err) 219 214 220 - this.#dispatchCustomEvent('peererror', { remoteid, error: err }) 215 + this.#dispatchCustomEvent('peererror', {remoteid, error: err}) 221 216 }) 222 217 223 218 peer.on('message', (data: unknown) => { 224 - this.#dispatchCustomEvent('peerdata', { remoteid, data }) 219 + this.#dispatchCustomEvent('peerdata', {remoteid, data}) 225 220 }) 226 221 227 222 this.#peers.set(remoteid, peer) ··· 241 236 const peer = this.#peers.get(identid) 242 237 if (peer && peer.connected) { 243 238 peer.send(JSON.stringify(data)) 244 - } 245 - else { 239 + } else { 246 240 throw new Error(`Not connected to peer: ${identid}`) 247 241 } 248 242 } ··· 283 277 284 278 return states 285 279 } 286 - 287 280 } 288 281 289 - const peerPingSchema = z.object({ type: z.literal('ping'), timestamp: z.number() }) 282 + const peerPingSchema = z.object({type: z.literal('ping'), timestamp: z.number()}) 290 283 291 284 /** 292 285 * a peer belonging to the connection manager 293 286 */ 294 287 export class RealmConnectionPeer extends SimplePeer { 295 - 296 288 #connection: RealmConnection 297 289 298 290 initiator: boolean ··· 300 292 remoteid: IdentID 301 293 nonce: string 302 294 303 - constructor(connection: RealmConnection, nonce: string, localid: IdentID, remoteid: IdentID, initiator: boolean) { 295 + constructor( 296 + connection: RealmConnection, 297 + nonce: string, 298 + localid: IdentID, 299 + remoteid: IdentID, 300 + initiator: boolean, 301 + ) { 304 302 super({ 305 303 initiator, 306 304 config: { 307 305 iceServers: [ 308 - { urls: 'stun:stun.l.google.com:19302' }, 309 - { urls: 'stun:stun1.l.google.com:19302' }, 306 + {urls: 'stun:stun.l.google.com:19302'}, 307 + {urls: 'stun:stun1.l.google.com:19302'}, 310 308 ], 311 309 }, 312 310 }) ··· 323 321 } 324 322 325 323 #handlePeerSignal = (e: SimplePeer.SignalData) => { 326 - this.#connection.sendToServer( 327 - { 328 - msg: 'realm.rtc.signal', 329 - initiator: this.initiator, 330 - sender: this.localid, 331 - recipient: this.remoteid, 332 - payload: JSON.stringify(e), 333 - } satisfies RealmRtcSignalMessage, 334 - ) 324 + this.#connection.sendToServer({ 325 + msg: 'realm.rtc.signal', 326 + initiator: this.initiator, 327 + sender: this.localid, 328 + recipient: this.remoteid, 329 + payload: JSON.stringify(e), 330 + } satisfies RealmRtcSignalMessage) 335 331 } 336 332 337 333 #handlePeerData = (chunk: string) => { 338 334 try { 339 335 const ping = parseJson.pipe(peerPingSchema).safeParse(chunk) 340 336 if (ping.success) { 341 - this.send( 342 - JSON.stringify({ type: 'pong', timestamp: ping.data.timestamp }), 343 - ) 344 - } 345 - else { 337 + this.send(JSON.stringify({type: 'pong', timestamp: ping.data.timestamp})) 338 + } else { 346 339 this.emit('message', chunk) 347 340 } 348 - } 349 - catch (err) { 341 + } catch (err) { 350 342 console.error('Failed to parse message:', err) 351 343 } 352 344 } 353 - 354 345 }
+5 -8
src/client/realm/context.tsx
··· 1 - import { createContext } from 'preact' 2 - import { useCallback, useEffect, useState } from 'preact/hooks' 1 + import {createContext} from 'preact' 2 + import {useCallback, useEffect, useState} from 'preact/hooks' 3 3 4 - import { RealmConnection, RealmIdentity } from '#client/realm/connection.js' 4 + import {RealmConnection, RealmIdentity} from '#client/realm/connection.js' 5 5 6 6 interface RealmConnectionContext { 7 7 realm?: RealmConnection ··· 15 15 url: string 16 16 children: preact.ComponentChildren 17 17 }> = (props) => { 18 - 19 - const [identity$, setIdentity$] = useState<RealmIdentity>() 18 + const [identity$, setIdentity$] = useState<RealmIdentity>() 20 19 const [connection$, setConnection$] = useState<RealmConnection>() 21 20 22 21 const connect = useCallback(() => { 23 22 if (connection$) return 24 23 if (!identity$) return 25 24 26 - setConnection$( 27 - new RealmConnection(props.url, identity$) 28 - ) 25 + setConnection$(new RealmConnection(props.url, identity$)) 29 26 }, [connection$, identity$, props.url]) 30 27 31 28 const disconnect = useCallback(() => {
+1 -1
src/client/webrtc-demo.css
··· 144 144 .system-message .from { 145 145 font-weight: bold; 146 146 margin: 0 5px; 147 - } 147 + }
+17 -18
src/client/webrtc-demo.tsx
··· 1 - import { useEffect, useContext, useCallback } from 'preact/hooks' 1 + import {useCallback, useContext, useEffect} from 'preact/hooks' 2 2 3 - import { RealmConnectionContext } from '#client/realm/context' 4 - import { PeerList } from '#client/components/peer-list' 3 + import {Messenger} from '#client/components/messenger' 4 + import {PeerList} from '#client/components/peer-list' 5 + import {RealmConnectionContext} from '#client/realm/context' 5 6 7 + import {generateSigningJwkPair} from '#common/crypto/jwks' 6 8 import * as protocol from '#common/protocol' 7 - import { generateSigningJwkPair } from '#common/crypto/jwks' 8 - import { Messenger } from './components/messenger' 9 - import { Callback } from '#common/types.js' 9 + import {Callback} from '#common/types.js' 10 10 11 - function attachEventListener<CB extends Callback>(target: EventTarget, name: string, listener: CB): CB { 11 + function attachEventListener<CB extends Callback>( 12 + target: EventTarget, 13 + name: string, 14 + listener: CB, 15 + ): CB { 12 16 target.addEventListener(name, listener) 13 17 return listener 14 18 } 15 19 16 20 export const WebRTCDemo: preact.FunctionComponent = () => { 17 21 const context = useContext(RealmConnectionContext) 18 - if (!context) 19 - throw new Error('expected to be called inside realm connection context!') 22 + if (!context) throw new Error('expected to be called inside realm connection context!') 20 23 21 24 useEffect(() => { 22 25 if (!context.realm) return ··· 74 77 const identid = protocol.IdentBrand.generate() 75 78 const keypair = await generateSigningJwkPair() 76 79 77 - context.setIdentity({ realmid, identid, keypair }) 80 + context.setIdentity({realmid, identid, keypair}) 78 81 } 79 82 80 83 go().catch((e: unknown) => { ··· 86 89 <div className="webrtc-demo"> 87 90 <h1>WebRTC Demo</h1> 88 91 89 - <button onClick={connect}> 90 - Connect 91 - </button> 92 + <button onClick={connect}>Connect</button> 92 93 93 94 <div> 94 95 Identity: 95 - <pre>{ JSON.stringify(context.identity, null, 2) }</pre> 96 + <pre>{JSON.stringify(context.identity, null, 2)}</pre> 96 97 </div> 97 98 98 99 <div className="connection-info"> ··· 101 102 {context.realm?.connected ? '🟢 Connected' : '🔴 Disconnected'} 102 103 </p> 103 104 <pre> 104 - <code> 105 - { JSON.stringify(context.realm, null, 2) } 106 - </code> 105 + <code>{JSON.stringify(context.realm, null, 2)}</code> 107 106 </pre> 108 107 </div> 109 108 110 - { context.realm && ( 109 + {context.realm && ( 111 110 <div className="demo-layout"> 112 111 <div className="demo-section"> 113 112 <PeerList webrtcManager={context.realm} />
+2 -2
src/cmd/register-ident.ts
··· 1 1 #!/usr/bin/env node 2 2 3 - import { generateSignableJwt, generateSigningJwkPair, jwkExport } from '#common/crypto/jwks' 4 - import { IdentBrand, RealmBrand } from '#common/protocol' 3 + import {generateSignableJwt, generateSigningJwkPair, jwkExport} from '#common/crypto/jwks' 4 + import {IdentBrand, RealmBrand} from '#common/protocol' 5 5 6 6 async function generateRegistrationJWT(realm?: string) { 7 7 const keypair = await generateSigningJwkPair()
+6 -6
src/cmd/server.ts
··· 1 - import { dirname, join } from 'path' 2 - import { fileURLToPath } from 'url' 3 - import { parseArgs } from 'util' 1 + import {dirname, join} from 'path' 2 + import {fileURLToPath} from 'url' 3 + import {parseArgs} from 'util' 4 4 5 - import { buildServer } from '#server/index' 5 + import {buildServer} from '#server/index' 6 6 7 7 const __filename = fileURLToPath(import.meta.url) 8 8 const __dirname = dirname(__filename) ··· 10 10 const args = parseArgs({ 11 11 args: process.argv.slice(2), 12 12 options: { 13 - port: { type: 'string', default: '3001' }, 14 - host: { type: 'string', default: '127.0.0.1' }, 13 + port: {type: 'string', default: '3001'}, 14 + host: {type: 'string', default: '127.0.0.1'}, 15 15 }, 16 16 }) 17 17
+7 -3
src/common/async/aborts.ts
··· 20 20 } 21 21 22 22 controller.signal.addEventListener('abort', cancel) 23 - return { signal: controller.signal, cancel } 23 + return {signal: controller.signal, cancel} 24 24 } 25 25 26 26 /** ··· 46 46 } 47 47 48 48 signal.addEventListener('abort', handler) 49 - cleanups.push(() => { signal.removeEventListener('abort', handler); }) 49 + cleanups.push(() => { 50 + signal.removeEventListener('abort', handler) 51 + }) 50 52 } 51 53 52 54 controller.signal.addEventListener('abort', () => { 53 - cleanups.forEach(cb => { cb(); }) 55 + cleanups.forEach((cb) => { 56 + cb() 57 + }) 54 58 }) 55 59 56 60 return controller.signal
+1 -3
src/common/async/blocking-atom.ts
··· 1 - import { Semaphore } from './semaphore.js' 1 + import {Semaphore} from './semaphore.js' 2 2 3 3 /** 4 4 * simple blocking atom, for waiting for a value. 5 5 * cribbed mostly from {@link https://github.com/ComFreek/async-playground} 6 6 */ 7 7 export class BlockingAtom<T> { 8 - 9 8 #item: T | undefined 10 9 #sema: Semaphore 11 10 ··· 39 38 40 39 signal?.throwIfAborted() 41 40 } 42 - 43 41 }
+2 -5
src/common/async/blocking-queue.ts
··· 1 - import { Semaphore } from './semaphore.js' 1 + import {Semaphore} from './semaphore.js' 2 2 3 3 /** 4 4 * simple blocking queue, for turning streams into async pulls. 5 5 * cribbed mostly from {@link https://github.com/ComFreek/async-playground} 6 6 */ 7 7 export class BlockingQueue<T> { 8 - 9 8 #sema: Semaphore 10 9 #maxsize?: number 11 10 #items: T[] ··· 62 61 63 62 #poll() { 64 63 const item = this.#items.length > 0 && this.#items.shift() 65 - if (item) 66 - return item 64 + if (item) return item 67 65 68 66 throw Error('no elements') 69 67 } 70 - 71 68 }
+12 -6
src/common/async/semaphore.ts
··· 3 3 * cribbed mostly from {@link https://github.com/ComFreek/async-playground} 4 4 */ 5 5 export class Semaphore { 6 - 7 6 #counter: number = 0 8 - #resolvers: Array<((taken: boolean) => void)> = [] 7 + #resolvers: Array<(taken: boolean) => void> = [] 9 8 10 9 constructor(count = 0) { 11 10 this.#counter = count ··· 20 19 */ 21 20 take(signal?: AbortSignal): Promise<boolean> { 22 21 return new Promise((resolve) => { 23 - if (signal?.aborted) { resolve(false); return; } 22 + if (signal?.aborted) { 23 + resolve(false) 24 + return 25 + } 24 26 25 27 // if there's resources available, use them 26 28 27 29 this.#counter-- 28 - if (this.#counter >= 0) { resolve(true); return; } 30 + if (this.#counter >= 0) { 31 + resolve(true) 32 + return 33 + } 29 34 30 35 // otherwise add to pending 31 36 // and explicitly remove the resolver from the list on abort ··· 58 63 if (this.#resolvers.length > 0) { 59 64 const resolver = this.#resolvers.shift() 60 65 if (resolver) 61 - queueMicrotask(() => { resolver(true); }) 66 + queueMicrotask(() => { 67 + resolver(true) 68 + }) 62 69 } 63 70 } 64 - 65 71 }
+2 -3
src/common/async/sleep.ts
··· 8 8 9 9 // not sure why this error is coming up 10 10 // eslint-disable-next-line @typescript-eslint/no-invalid-void-type 11 - const { resolve, reject, promise } = Promise.withResolvers<void>() 11 + const {resolve, reject, promise} = Promise.withResolvers<void>() 12 12 const timeout = setTimeout(resolve, ms) 13 13 14 - if (!signal) 15 - return promise 14 + if (!signal) return promise 16 15 17 16 const abortHandler = () => { 18 17 clearTimeout(timeout)
+1 -3
src/common/breaker.ts
··· 1 - import { Callback } from "#common/types" 1 + import {Callback} from '#common/types' 2 2 3 3 /** 4 4 * A Breaker, which allows creating wrapped functions which will only be executed before ··· 25 25 * ``` 26 26 */ 27 27 export class Breaker { 28 - 29 28 #tripped: boolean 30 29 #onTripped?: () => void 31 30 ··· 77 76 } 78 77 }) as CB 79 78 } 80 - 81 79 }
+15 -13
src/common/crypto/cipher.ts
··· 1 - import { base64url } from 'jose' 2 - import { nanoid } from 'nanoid' 1 + import {base64url} from 'jose' 2 + import {nanoid} from 'nanoid' 3 3 4 - const encrAlgo = { name: 'AES-GCM', length: 256 } 5 - const deriveAlgo = { name: 'PBKDF2', hash: 'SHA-256' } 4 + const encrAlgo = {name: 'AES-GCM', length: 256} 5 + const deriveAlgo = {name: 'PBKDF2', hash: 'SHA-256'} 6 6 7 7 function orRandom(s: string): string { 8 8 return s ?? nanoid(10) ··· 11 11 function asUint8Array(s: string | Uint8Array): Uint8Array { 12 12 if (s instanceof Uint8Array) { 13 13 return s 14 - } 15 - else if (typeof s === 'string') { 14 + } else if (typeof s === 'string') { 16 15 return new TextEncoder().encode(s) 17 16 } 18 17 ··· 29 28 * @param iterations - number of iterations for pbkdf 30 29 * @returns the derived crypto key 31 30 */ 32 - async function deriveKey(passwordStr: string, saltStr: string, nonceStr: string, iterations: number = 100000): Promise<CryptoKey> { 31 + async function deriveKey( 32 + passwordStr: string, 33 + saltStr: string, 34 + nonceStr: string, 35 + iterations: number = 100000, 36 + ): Promise<CryptoKey> { 33 37 const encoder = new TextEncoder() 34 38 35 39 const password = encoder.encode(`${passwordStr}-pass-cryptosystem`) ··· 40 44 const derivedsalt = new Uint8Array([...salt, ...nonce]) 41 45 42 46 return await crypto.subtle.deriveKey( 43 - { ...deriveAlgo, salt: derivedsalt, iterations }, 47 + {...deriveAlgo, salt: derivedsalt, iterations}, 44 48 derivedkey, 45 49 encrAlgo, 46 50 false, ··· 57 61 * another HMAC. 58 62 */ 59 63 export class Cipher { 60 - 61 64 /** 62 65 * creates a cipher with key derivation. 63 66 * ··· 90 93 * @param data - the data to encrypte 91 94 * @returns a url-safe base64 encoded encrypted string. 92 95 */ 93 - async encrypt(data: (string | Uint8Array)): Promise<string> { 96 + async encrypt(data: string | Uint8Array): Promise<string> { 94 97 const iv = crypto.getRandomValues(new Uint8Array(12)) 95 98 const encoded = asUint8Array(data) 96 - const encrypted = await crypto.subtle.encrypt({ ...encrAlgo, iv }, this.#cryptokey, encoded) 99 + const encrypted = await crypto.subtle.encrypt({...encrAlgo, iv}, this.#cryptokey, encoded) 97 100 98 101 // output = [iv + encrypted] which gives us the auth tag 99 102 ··· 123 126 // Extract IV and encrypted data (which includes auth tag) 124 127 const iv = combined.slice(0, 12) 125 128 const encrypted = combined.slice(12) 126 - return await crypto.subtle.decrypt({ ...encrAlgo, iv }, this.#cryptokey, encrypted) 129 + return await crypto.subtle.decrypt({...encrAlgo, iv}, this.#cryptokey, encrypted) 127 130 } 128 - 129 131 }
+1 -3
src/common/crypto/errors.ts
··· 1 - import { BaseError, BaseErrorOpts } from '#common/errors.js' 1 + import {BaseError, BaseErrorOpts} from '#common/errors.js' 2 2 3 3 /** Common base class for errors in the crypto module */ 4 4 export class CryptoError extends BaseError {} 5 5 6 6 /** Thrown when failing to verify a JWT signature */ 7 7 export class JWTBadSignatureError extends CryptoError { 8 - 9 8 constructor(options?: BaseErrorOpts) { 10 9 super('could not verify token signature', options) 11 10 } 12 - 13 11 }
+10 -18
src/common/crypto/jwks.ts
··· 1 1 import * as jose from 'jose' 2 - import { z } from 'zod/v4' 3 - import { CryptoError } from './errors.js' 2 + import {z} from 'zod/v4' 3 + import {CryptoError} from './errors.js' 4 4 5 - const subtleSignAlgo = { name: 'ECDSA', namedCurve: 'P-256' } 6 - const joseSignAlgo = { name: 'ES256' } 5 + const subtleSignAlgo = {name: 'ECDSA', namedCurve: 'P-256'} 6 + const joseSignAlgo = {name: 'ES256'} 7 7 8 8 const jwkEcPublicSchema = z.object({ 9 9 kty: z.literal('EC'), ··· 24 24 * @see https://www.rfc-editor.org/rfc/rfc7517 25 25 * @see https://github.com/panva/jose/blob/main/src/types.d.ts#L2 26 26 */ 27 - export const jwkSchema: z.ZodType<jose.JWK> = z.union([ 28 - jwkEcPublicSchema, 29 - jwkEcPrivateSchema, 30 - ]) 27 + export const jwkSchema: z.ZodType<jose.JWK> = z.union([jwkEcPublicSchema, jwkEcPrivateSchema]) 31 28 32 29 /** 33 30 * zod transform from JWK to CryptoKey ··· 45 42 message: 'symmetric keys unsupported', 46 43 input: val, 47 44 }) 48 - } 49 - else { 45 + } else { 50 46 ctx.issues.push({ 51 47 code: 'custom', 52 48 message: 'not a valid JWK object', 53 49 input: val, 54 50 }) 55 51 } 56 - } 57 - catch (e) { 52 + } catch (e) { 58 53 ctx.issues.push({ 59 54 code: 'custom', 60 55 message: `could not import JWK object: ${e}`, ··· 77 72 message: 'non-extractable key!', 78 73 input: val, 79 74 }) 80 - } 81 - catch (e) { 75 + } catch (e) { 82 76 ctx.issues.push({ 83 77 code: 'custom', 84 78 message: `could not export JWK object: ${e}`, ··· 94 88 */ 95 89 export async function generateSigningJwkPair(): Promise<CryptoKeyPair> { 96 90 const pair = await crypto.subtle.generateKey(subtleSignAlgo, true, ['sign', 'verify']) 97 - if (!('publicKey' in pair)) 98 - throw new CryptoError('keypair returned a single key!?') 91 + if (!('publicKey' in pair)) throw new CryptoError('keypair returned a single key!?') 99 92 100 93 return pair 101 94 } ··· 105 98 * @returns a properly configured jwt signer, with the payload provided 106 99 */ 107 100 export function generateSignableJwt(payload: jose.JWTPayload): jose.SignJWT { 108 - return new jose.SignJWT(payload) 109 - .setProtectedHeader({ alg: joseSignAlgo.name }) 101 + return new jose.SignJWT(payload).setProtectedHeader({alg: joseSignAlgo.name}) 110 102 }
+28 -25
src/common/crypto/jwts.ts
··· 1 + import {JWTBadSignatureError} from '#common/crypto/errors' 2 + import {normalizeError} from '#common/errors' 1 3 import * as jose from 'jose' 2 - import { z } from 'zod/v4' 3 - import { JWTBadSignatureError } from '#common/crypto/errors' 4 - import { normalizeError } from '#common/errors' 4 + import {z} from 'zod/v4' 5 5 6 - const signAlgo = { name: 'ES256' } 6 + const signAlgo = {name: 'ES256'} 7 7 8 8 interface JWTToken { 9 9 token: string ··· 20 20 * schema describing a decoded JWT. 21 21 * **important** - this does no claims validation, only decoding from string to JWT! 22 22 */ 23 - export const jwtSchema: z.ZodType<JWTToken, string> = z.jwt({ abort: true }).transform((token, ctx) => { 24 - try { 25 - const claims = jose.decodeJwt(token) 26 - return { claims, token } 27 - } 28 - catch (e) { 29 - ctx.issues.push({ 30 - code: 'custom', 31 - message: `error while decoding token: ${e}`, 32 - input: token, 33 - }) 23 + export const jwtSchema: z.ZodType<JWTToken, string> = z 24 + .jwt({abort: true}) 25 + .transform((token, ctx) => { 26 + try { 27 + const claims = jose.decodeJwt(token) 28 + return {claims, token} 29 + } catch (e) { 30 + ctx.issues.push({ 31 + code: 'custom', 32 + message: `error while decoding token: ${e}`, 33 + input: token, 34 + }) 34 35 35 - return z.NEVER 36 - } 37 - }) 36 + return z.NEVER 37 + } 38 + }) 38 39 39 40 /** 40 41 * schema describing a verified payload in a JWT. 41 42 * **important** - this does no claims validation, only decoding from string to JWT! 42 43 */ 43 44 export const jwtPayload = <T>(schema: z.ZodType<T>): z.ZodType<JWTTokenPayload<T>> => { 44 - const parser = z.looseObject({ payload: schema }) 45 + const parser = z.looseObject({payload: schema}) 45 46 46 47 return jwtSchema.transform(async (payload, ctx) => { 47 48 const result = await parser.safeParseAsync(payload.claims) 48 - if (result.success) 49 - return { ...payload, payload: result.data.payload } 49 + if (result.success) return {...payload, payload: result.data.payload} 50 50 51 51 result.error.issues.forEach((iss) => { 52 52 ctx.issues.push(iss) ··· 65 65 * @returns a verified payload 66 66 * @throws if the signature is not valid 67 67 */ 68 - export async function verifyJwtToken(jwt: string, pubkey: CryptoKey, options: VerifyOpts = {}): Promise<jose.JWTPayload> { 68 + export async function verifyJwtToken( 69 + jwt: string, 70 + pubkey: CryptoKey, 71 + options: VerifyOpts = {}, 72 + ): Promise<jose.JWTPayload> { 69 73 try { 70 74 const result = await jose.jwtVerify(jwt, pubkey, { 71 75 algorithms: [signAlgo.name], ··· 73 77 }) 74 78 75 79 return result.payload 76 - } 77 - catch (exc) { 80 + } catch (exc) { 78 81 const err = normalizeError(exc) 79 - throw new JWTBadSignatureError({ cause: err }) 82 + throw new JWTBadSignatureError({cause: err}) 80 83 } 81 84 } 82 85
+2 -6
src/common/errors.spec.ts
··· 1 - import { describe, it, expect } from '@jest/globals' 1 + import {describe, expect, it} from '@jest/globals' 2 2 3 - import { 4 - ProtocolError, 5 - isProtocolError, 6 - normalizeProtocolError, 7 - } from '#common/errors' 3 + import {ProtocolError, isProtocolError, normalizeProtocolError} from '#common/errors' 8 4 9 5 describe('isProtocolError', () => { 10 6 it('identifies protocol errors', () => {
+10 -20
src/common/errors.ts
··· 1 - import { prettifyError, ZodError } from 'zod/v4' 1 + import {prettifyError, ZodError} from 'zod/v4' 2 2 3 3 const StatusCodes: Record<number, string> = { 4 4 400: 'Bad Request', ··· 23 23 * only difference is that we explicitly type cause to be Error 24 24 */ 25 25 export class BaseError extends Error { 26 - 27 26 /** the cause of the error */ 28 27 declare cause?: Error 29 28 30 29 constructor(message: string, options?: BaseErrorOpts) { 31 30 super(message, options) 32 - if (options?.cause) 33 - this.cause = normalizeError(options.cause) 31 + if (options?.cause) this.cause = normalizeError(options.cause) 34 32 } 35 - 36 33 } 37 34 38 35 /** Common base class for Websocket Errors */ 39 36 export class ProtocolError extends BaseError { 40 - 41 37 /** the HTTP status code representing this error */ 42 38 status: number 43 39 ··· 48 44 this.name = this.constructor.name 49 45 this.status = status 50 46 } 51 - 52 47 } 53 48 54 49 /** Check if an error is a protocol error with optional status check */ 55 50 export function isProtocolError(e: Error, status?: number): e is ProtocolError { 56 - return (e instanceof ProtocolError && (status === undefined || e.status == status)) 51 + return e instanceof ProtocolError && (status === undefined || e.status == status) 57 52 } 58 53 59 54 /** ··· 61 56 * passes through input that is already protocol errors. 62 57 */ 63 58 export function normalizeProtocolError(cause: unknown): ProtocolError { 64 - if (cause instanceof ProtocolError) 65 - return cause 59 + if (cause instanceof ProtocolError) return cause 66 60 67 - if (cause instanceof ZodError) 68 - return new ProtocolError(prettifyError(cause), 400, { cause }) 61 + if (cause instanceof ZodError) return new ProtocolError(prettifyError(cause), 400, {cause}) 69 62 70 63 if (cause instanceof Error || cause instanceof DOMException) { 71 - if (cause.name === 'TimeoutError') 72 - return new ProtocolError('operation timed out', 408, { cause }) 64 + if (cause.name === 'TimeoutError') return new ProtocolError('operation timed out', 408, {cause}) 73 65 74 - if (cause.name === 'AbortError') 75 - return new ProtocolError('operation was aborted', 499, { cause }) 66 + if (cause.name === 'AbortError') return new ProtocolError('operation was aborted', 499, {cause}) 76 67 77 - return new ProtocolError(cause.message, 500, { cause }) 68 + return new ProtocolError(cause.message, 500, {cause}) 78 69 } 79 70 80 71 // fallback, unknown 81 - const options = cause == undefined ? undefined : { cause: normalizeError(cause) } 72 + const options = cause == undefined ? undefined : {cause: normalizeError(cause)} 82 73 return new ProtocolError(`Error! ${cause}`, 500, options) 83 74 } 84 75 ··· 90 81 * passes through input that is already an Error object. 91 82 */ 92 83 export function normalizeError(failure: unknown): Error { 93 - if (failure instanceof Error) 94 - return failure 84 + if (failure instanceof Error) return failure 95 85 96 86 return new NormalizedError(`unnormalized failure ${failure}`) 97 87 }
+12 -15
src/common/protocol.ts
··· 3 3 export * from './protocol/messages-preauth' 4 4 export * from './protocol/messages-realm' 5 5 6 - import { z } from 'zod/v4' 6 + import {z} from 'zod/v4' 7 7 8 8 /** A zod transformer for parsing json */ 9 - export const parseJson: z.ZodTransform<unknown, string> = z.transform( 10 - (input, ctx) => { 11 - try { 12 - return JSON.parse(input) as unknown 13 - } 14 - catch { 15 - ctx.issues.push({ 16 - code: 'custom', 17 - input, 18 - message: 'input could not be parsed as JSON', 19 - }) 9 + export const parseJson: z.ZodTransform<unknown, string> = z.transform((input, ctx) => { 10 + try { 11 + return JSON.parse(input) as unknown 12 + } catch { 13 + ctx.issues.push({ 14 + code: 'custom', 15 + input, 16 + message: 'input could not be parsed as JSON', 17 + }) 20 18 21 - return z.NEVER 22 - } 19 + return z.NEVER 23 20 } 24 - ) 21 + })
+1 -1
src/common/protocol/brands.ts
··· 1 - import { Brand } from '#common/schema/brand' 1 + import {Brand} from '#common/schema/brand' 2 2 3 3 const ident = Symbol('ident') 4 4 export const IdentBrand = new Brand<typeof ident>(ident, 'ident')
+2 -2
src/common/protocol/messages-preauth.ts
··· 1 - import { z } from 'zod/v4' 2 - import { jwkSchema } from '#common/crypto/jwks' 1 + import {jwkSchema} from '#common/crypto/jwks' 2 + import {z} from 'zod/v4' 3 3 4 4 /** zod schema for `preauth.authn` message */ 5 5 export const preauthRegisterMessageSchema = z.object({
+4 -7
src/common/protocol/messages-realm.ts
··· 1 - import { z } from 'zod/v4' 1 + import {z} from 'zod/v4' 2 2 3 - import { IdentBrand } from './brands' 4 - import { responseOkSchema } from './messages' 3 + import {IdentBrand} from './brands' 4 + import {responseOkSchema} from './messages' 5 5 6 6 /** 7 7 * zod schema for `realm.broadcast` message ··· 13 13 export const realmBroadcastMessageSchema = z.object({ 14 14 msg: z.literal('realm.broadcast'), 15 15 payload: z.any(), 16 - recipients: z.union([ 17 - z.boolean(), 18 - z.array(IdentBrand.schema), 19 - ]).default(false), 16 + recipients: z.union([z.boolean(), z.array(IdentBrand.schema)]).default(false), 20 17 }) 21 18 22 19 export type RealmBroadcastMessage = z.infer<typeof realmBroadcastMessageSchema>
+1 -1
src/common/protocol/messages.ts
··· 1 - import { z } from 'zod/v4' 1 + import {z} from 'zod/v4' 2 2 3 3 /** zod schema for `ok` message */ 4 4 export const responseOkSchema = z.object({
+4 -6
src/common/schema/brand.ts
··· 1 - import { nanoid } from 'nanoid' 2 - import { z } from 'zod/v4' 1 + import {nanoid} from 'nanoid' 2 + import {z} from 'zod/v4' 3 3 4 - export type Branded<T, B> = T & { __brand: B } 4 + export type Branded<T, B> = T & {__brand: B} 5 5 6 6 /** 7 7 * A brand creates identifiers that are typesafe by construction, 8 8 * and shouldn't be able to be passed to the wrong resource type. 9 9 */ 10 10 export class Brand<B extends symbol> { 11 - 12 11 #prefix: string 13 12 #length: number 14 13 #schema ··· 36 35 } 37 36 38 37 /** @returns a boolean if the string is valid */ 39 - validate(input: string): input is Branded<string, B> { 38 + validate(input: string): input is Branded<string, B> { 40 39 return input != null && typeof input === 'string' && this.#schema.safeParse(input).success 41 40 } 42 - 43 41 }
+35 -27
src/common/socket.ts
··· 1 - import WebSocket, { ErrorEvent, MessageEvent } from 'isomorphic-ws' 1 + import WebSocket, {ErrorEvent, MessageEvent} from 'isomorphic-ws' 2 2 3 - import { combineSignals } from '#common/async/aborts' 4 - import { BlockingAtom } from '#common/async/blocking-atom' 5 - import { BlockingQueue } from '#common/async/blocking-queue' 6 - import { Breaker } from '#common/breaker' 7 - import { normalizeError, ProtocolError } from '#common/errors' 8 - import { z } from 'zod/v4' 3 + import {combineSignals} from '#common/async/aborts' 4 + import {BlockingAtom} from '#common/async/blocking-atom' 5 + import {BlockingQueue} from '#common/async/blocking-queue' 6 + import {Breaker} from '#common/breaker' 7 + import {normalizeError, ProtocolError} from '#common/errors' 8 + import {z} from 'zod/v4' 9 9 10 - import { parseJson } from './protocol.js' 10 + import {parseJson} from './protocol.js' 11 11 12 12 /** Send some data in JSON format down the wire. */ 13 13 export function sendSocket(ws: WebSocket, data: unknown): void { ··· 41 41 const error = new AbortController() 42 42 const multisignal = combineSignals(error.signal, signal) 43 43 44 - const onMessage = breaker.tripThen((m: MessageEvent) => { atom.set(m.data); }) 45 - const onError = breaker.tripThen((e: unknown) => { error.abort(e); }) 46 - const onClose = breaker.tripThen(() => { error.abort('closed'); }) 44 + const onMessage = breaker.tripThen((m: MessageEvent) => { 45 + atom.set(m.data) 46 + }) 47 + const onError = breaker.tripThen((e: unknown) => { 48 + error.abort(e) 49 + }) 50 + const onClose = breaker.tripThen(() => { 51 + error.abort('closed') 52 + }) 47 53 48 54 try { 49 55 ws.addEventListener('message', onMessage) ··· 53 59 const data = await atom.get(multisignal) 54 60 if (!data) { 55 61 const cause = normalizeError(multisignal.reason) 56 - throw new ProtocolError('socket read aborted', 408, { cause }) 62 + throw new ProtocolError('socket read aborted', 408, {cause}) 57 63 } 58 64 59 65 return data 60 - } 61 - finally { 66 + } finally { 62 67 ws.removeEventListener('message', onMessage) 63 68 ws.removeEventListener('error', onError) 64 69 ws.removeEventListener('close', onClose) ··· 73 78 * @param signal - an abort signal to cancel the block 74 79 * @returns the message off the socket 75 80 */ 76 - export async function takeSocketJson<T>(ws: WebSocket, schema: z.ZodType<T>, signal?: AbortSignal): Promise<T> { 81 + export async function takeSocketJson<T>( 82 + ws: WebSocket, 83 + schema: z.ZodType<T>, 84 + signal?: AbortSignal, 85 + ): Promise<T> { 77 86 const data = await takeSocket(ws, signal) 78 87 return parseJson.pipe(schema).parseAsync(data) 79 88 } ··· 81 90 /** stream configuration options */ 82 91 83 92 interface ConfigProps { 84 - maxDepth: number 85 - signal?: AbortSignal 93 + maxDepth: number 94 + signal?: AbortSignal 86 95 } 87 96 88 97 export const STREAM_CONFIG_DEFAULT: ConfigProps = { 89 - maxDepth: 1000 98 + maxDepth: 1000, 90 99 } 91 100 92 101 // symbols for iteration protocol 93 102 94 103 const yield$ = Symbol('yield$') 95 104 const error$ = Symbol('error$') 96 - const end$ = Symbol('end$') 105 + const end$ = Symbol('end$') 97 106 98 - type StreamYield = 99 - | [typeof yield$, unknown] 100 - | [typeof error$, Error] 101 - | [typeof end$] 107 + type StreamYield = [typeof yield$, unknown] | [typeof error$, Error] | [typeof end$] 102 108 103 109 /** 104 110 * Given a websocket, stream messages off the socket as an async generator. ··· 121 127 * ``` 122 128 */ 123 129 export async function* streamSocket(ws: WebSocket, config_?: Partial<ConfigProps>) { 124 - const { signal, ...config } = { ...STREAM_CONFIG_DEFAULT, ...(config_ || {}) } 130 + const {signal, ...config} = {...STREAM_CONFIG_DEFAULT, ...(config_ || {})} 125 131 signal?.throwIfAborted() 126 132 127 133 // await incoming messages without blocking ··· 183 189 return 184 190 } 185 191 } 186 - } 187 - finally { 192 + } finally { 188 193 ws.removeEventListener('message', onMessage) 189 194 ws.removeEventListener('error', onError) 190 195 ws.removeEventListener('close', onClose) ··· 195 200 * exactly stream socket, but will additionally apply a json decoding 196 201 * messages not validating will end the stream with an error 197 202 */ 198 - export async function* streamSocketJson(ws: WebSocket, config?: Partial<ConfigProps>): AsyncGenerator { 203 + export async function* streamSocketJson( 204 + ws: WebSocket, 205 + config?: Partial<ConfigProps>, 206 + ): AsyncGenerator { 199 207 for await (const message of streamSocket(ws, config)) { 200 208 yield parseJson.parseAsync(message) 201 209 }
+1 -4
src/common/strict-map.ts
··· 1 1 /** A map with methods to ensure key presence and safe update. */ 2 2 export class StrictMap<K, V> extends Map<K, V> { 3 - 4 3 /** 5 4 * Get a value from the map, throwing if missing 6 5 * @throws Error if the key is not present in the map ··· 30 29 31 30 if (next === undefined) { 32 31 this.delete(key) 33 - } 34 - else { 32 + } else { 35 33 this.set(key, next) 36 34 } 37 35 } 38 - 39 36 }
+1 -1
src/common/types.ts
··· 1 - import { NEVER } from 'zod/v4' 1 + import {NEVER} from 'zod/v4' 2 2 3 3 /** A callback function, with arbitrary arguments; use `Parameters` to extract them. */ 4 4 // eslint-disable-next-line @typescript-eslint/no-explicit-any
+14 -10
src/server/index.ts
··· 1 1 import express from 'express' 2 2 import * as http from 'http' 3 3 4 - import { WebSocketServer } from 'ws' 4 + import {WebSocketServer} from 'ws' 5 5 6 - import { apiRouter } from './routes-api/middleware' 7 - import { socketHandler } from './routes-socket/handler' 8 - import { makeStaticMiddleware, makeSpaMiddleware } from './routes-static' 9 - import { notFoundHandler } from './routes-error' 6 + import {apiRouter} from './routes-api/middleware' 7 + import {notFoundHandler} from './routes-error' 8 + import {socketHandler} from './routes-socket/handler' 9 + import {makeSpaMiddleware, makeStaticMiddleware} from './routes-static' 10 10 11 11 /** 12 12 * configures an http server which hosts the SPA and websocket endpoint ··· 25 25 app.use('/api', apiRouter) 26 26 27 27 // Static file serving 28 - app.use(makeStaticMiddleware({ root, index: 'index.html' })) 29 - app.use(makeSpaMiddleware({ root, index: 'index.html' })) 28 + app.use(makeStaticMiddleware({root, index: 'index.html'})) 29 + app.use(makeSpaMiddleware({root, index: 'index.html'})) 30 30 31 31 // 404 handler 32 32 app.use(notFoundHandler) 33 33 34 34 // WebSocket handling 35 - const wss = new WebSocketServer({ server, path: '/stream' }) 35 + const wss = new WebSocketServer({server, path: '/stream'}) 36 36 wss.on('connection', (ws) => { 37 37 socketHandler(ws) 38 - .catch((e: unknown) => { console.error('uncaught error from websocket', e) }) 39 - .finally(() => { console.log('socket handler complete') }) 38 + .catch((e: unknown) => { 39 + console.error('uncaught error from websocket', e) 40 + }) 41 + .finally(() => { 42 + console.log('socket handler complete') 43 + }) 40 44 }) 41 45 42 46 return server
+1 -1
src/server/routes-api/middleware.ts
··· 1 - import { Router } from 'express' 1 + import {Router} from 'express' 2 2 3 3 export const apiRouter = Router() 4 4
+19 -14
src/server/routes-socket/handler-preauth.ts
··· 1 1 import WebSocket from 'isomorphic-ws' 2 2 3 - import { combineSignals, timeoutSignal } from '#common/async/aborts' 4 - import { jwkImport } from '#common/crypto/jwks' 5 - import { jwtPayload, verifyJwtToken } from '#common/crypto/jwts' 6 - import { normalizeError, ProtocolError } from '#common/errors' 7 - import { IdentBrand, IdentID, preauthMessageSchema, RealmBrand, RealmID } from '#common/protocol' 8 - import { takeSocket } from '#common/socket' 3 + import {combineSignals, timeoutSignal} from '#common/async/aborts' 4 + import {jwkImport} from '#common/crypto/jwks' 5 + import {jwtPayload, verifyJwtToken} from '#common/crypto/jwts' 6 + import {normalizeError, ProtocolError} from '#common/errors' 7 + import {IdentBrand, IdentID, preauthMessageSchema, RealmBrand, RealmID} from '#common/protocol' 8 + import {takeSocket} from '#common/socket' 9 9 10 10 import * as realms from './state' 11 11 ··· 14 14 * - if the realm does not exist (by realm id), we create a new one, and add the identity (success). 15 15 * - if the realm /does/ exist, we verify the message is a signed JWT our already registered pubkey. 16 16 */ 17 - export async function preauthHandler(ws: WebSocket, signal?: AbortSignal): Promise<realms.AuthenticatedIdentity> { 17 + export async function preauthHandler( 18 + ws: WebSocket, 19 + signal?: AbortSignal, 20 + ): Promise<realms.AuthenticatedIdentity> { 18 21 const timeout = timeoutSignal(3000) 19 22 const combinedSignal = combineSignals(signal, timeout.signal) 20 23 ··· 33 36 } 34 37 35 38 return await authenticatePreauth(realmid, identid, jwt.token) 36 - } 37 - finally { 39 + } finally { 38 40 timeout.cancel() 39 41 } 40 42 } 41 43 42 - async function authenticatePreauth(realmid: RealmID, identid: IdentID, token: string): Promise<realms.AuthenticatedIdentity> { 44 + async function authenticatePreauth( 45 + realmid: RealmID, 46 + identid: IdentID, 47 + token: string, 48 + ): Promise<realms.AuthenticatedIdentity> { 43 49 try { 44 50 const realm = realms.realmMap.require(realmid) 45 51 const pubkey = realm.identities.require(identid) ··· 47 53 // at this point we no langer care about the payload 48 54 // but this throws as a side-effect if the token is invalid 49 55 await verifyJwtToken(token, pubkey) 50 - return { realmid, realm, identid, pubkey } 51 - } 52 - catch (exc) { 56 + return {realmid, realm, identid, pubkey} 57 + } catch (exc) { 53 58 const err = normalizeError(exc) 54 - throw new ProtocolError('jwt verification failed', 401, { cause: err }) 59 + throw new ProtocolError('jwt verification failed', 401, {cause: err}) 55 60 } 56 61 }
+21 -15
src/server/routes-socket/handler-realm.ts
··· 1 - import { WebSocket } from 'isomorphic-ws' 1 + import {WebSocket} from 'isomorphic-ws' 2 2 3 - import { normalizeProtocolError, ProtocolError } from '#common/errors' 4 - import { sendSocket, streamSocket } from '#common/socket.js' 3 + import {normalizeProtocolError, ProtocolError} from '#common/errors' 5 4 import * as protocol from '#common/protocol' 5 + import {sendSocket, streamSocket} from '#common/socket.js' 6 6 import * as realm from '#server/routes-socket/state' 7 7 8 8 /** 9 9 * ance we've retrieved authentication details, we go into the main realm loop. 10 10 * read messages as they come in and dispatch actions. 11 11 */ 12 - export async function realmHandler(ws: WebSocket, auth: realm.AuthenticatedIdentity, signal?: AbortSignal) { 12 + export async function realmHandler( 13 + ws: WebSocket, 14 + auth: realm.AuthenticatedIdentity, 15 + signal?: AbortSignal, 16 + ) { 13 17 realmBroadcast(auth, buildRtcPeerJoined(auth)) 14 18 sendSocket(ws, buildRtcPeerWelcome(auth)) 15 19 16 - const parser = protocol.parseJson 17 - .pipe(protocol.realmToServerMessageSchema) 20 + const parser = protocol.parseJson.pipe(protocol.realmToServerMessageSchema) 18 21 19 22 try { 20 - for await (const data of streamSocket(ws, { signal })) { 23 + for await (const data of streamSocket(ws, {signal})) { 21 24 try { 22 25 const msg = await parser.parseAsync(data) 23 26 switch (msg.msg) { ··· 33 36 console.error('unknown message!', msg) 34 37 throw new ProtocolError(`unknown message type!`, 400) 35 38 } 36 - } 37 - catch (exc) { 39 + } catch (exc) { 38 40 const error = normalizeProtocolError(exc) 39 - if (error.status >= 500) 40 - throw error 41 + if (error.status >= 500) throw error 41 42 42 43 if (ws.readyState === ws.OPEN) { 43 44 sendSocket(ws, buildRealmError(error)) 44 45 } 45 46 } 46 47 } 47 - } 48 - finally { 48 + } finally { 49 49 console.log('client left!', auth) 50 50 realmBroadcast(auth, buildRtcPeerLeft(auth)) 51 51 } 52 52 } 53 53 54 - function buildRtcPeerWelcome(auth: realm.AuthenticatedIdentity): protocol.RealmRtcPeerWelcomeMessage { 54 + function buildRtcPeerWelcome( 55 + auth: realm.AuthenticatedIdentity, 56 + ): protocol.RealmRtcPeerWelcomeMessage { 55 57 return { 56 58 ok: true, 57 59 msg: 'realm.rtc.peer-welcome', ··· 91 93 * when false, send to the whole realm, excluding self 92 94 * when an array of recipients, send to those recipients explicitly 93 95 */ 94 - function realmBroadcast(auth: realm.AuthenticatedIdentity, payload: unknown, recipients: protocol.IdentID[] | boolean = false) { 96 + function realmBroadcast( 97 + auth: realm.AuthenticatedIdentity, 98 + payload: unknown, 99 + recipients: protocol.IdentID[] | boolean = false, 100 + ) { 95 101 const echo = recipients === true || Array.isArray(recipients) 96 102 const recips = Array.isArray(recipients) ? recipients : Array.from(auth.realm.identities.keys()) 97 103
+10 -15
src/server/routes-socket/handler.ts
··· 1 - import { format } from 'node:util' 2 1 import WebSocket from 'isomorphic-ws' 2 + import {format} from 'node:util' 3 3 4 - import { normalizeError, normalizeProtocolError } from '#common/errors' 4 + import {normalizeError, normalizeProtocolError} from '#common/errors' 5 5 6 - import { preauthHandler } from './handler-preauth' 7 - import { realmHandler } from './handler-realm' 8 - import { attachSocket, detachSocket } from './state' 6 + import {preauthHandler} from './handler-preauth' 7 + import {realmHandler} from './handler-realm' 8 + import {attachSocket, detachSocket} from './state' 9 9 10 10 /** when the socket connects, we drive our protocol through handlers. */ 11 11 export async function socketHandler(ws: WebSocket) { ··· 16 16 try { 17 17 attachSocket(auth.realm, auth.identid, ws) 18 18 await realmHandler(ws, auth) 19 - } 20 - finally { 19 + } finally { 21 20 detachSocket(auth.realm, auth.identid, ws) 22 21 } 23 - } 24 - catch (exc) { 22 + } catch (exc) { 25 23 const error = normalizeError(exc) 26 24 const failure = normalizeProtocolError(error) 27 25 28 26 if (failure.status >= 500) { 29 27 console.error('fatal:', error) 30 - if (error.cause) 31 - console.error('cause:', error.cause) 28 + if (error.cause) console.error('cause:', error.cause) 32 29 } 33 30 34 31 const message = format('Error: %s', failure.message) 35 - if (ws.readyState === ws.OPEN) 36 - ws.send(message) 37 - } 38 - finally { 32 + if (ws.readyState === ws.OPEN) ws.send(message) 33 + } finally { 39 34 if (ws.readyState !== ws.CLOSED) { 40 35 ws.send(`kthxbye\n`) 41 36 ws.close()
+9 -5
src/server/routes-socket/state.ts
··· 1 1 import WebSocket from 'isomorphic-ws' 2 2 3 - import { IdentID, RealmID } from '#common/protocol.js' 4 - import { StrictMap } from '#common/strict-map' 3 + import {IdentID, RealmID} from '#common/protocol.js' 4 + import {StrictMap} from '#common/strict-map' 5 5 6 6 /** An authenticated identity; only handed out in response to successful authentication. */ 7 7 export interface AuthenticatedIdentity { ··· 29 29 * @param registrantkey - the public key of the registrant 30 30 * @returns a registered realm, possibly newly created with the registrant 31 31 */ 32 - export function ensureRegisteredRealm(realmid: RealmID, registrantid: IdentID, registrantkey: CryptoKey): Realm { 32 + export function ensureRegisteredRealm( 33 + realmid: RealmID, 34 + registrantid: IdentID, 35 + registrantkey: CryptoKey, 36 + ): Realm { 33 37 const realm = realmMap.ensure(realmid, () => ({ 34 38 realmid, 35 39 sockets: new StrictMap(), ··· 42 46 } 43 47 44 48 export function attachSocket(realm: Realm, ident: IdentID, socket: WebSocket) { 45 - realm.sockets.update(ident, ss => (ss ? [...ss, socket] : [socket])) 49 + realm.sockets.update(ident, (ss) => (ss ? [...ss, socket] : [socket])) 46 50 } 47 51 48 52 export function detachSocket(realm: Realm, ident: IdentID, socket: WebSocket) { 49 53 realm.sockets.update(ident, (sockets) => { 50 - const next = sockets?.filter(s => s !== socket) 54 + const next = sockets?.filter((s) => s !== socket) 51 55 return next?.length ? next : undefined 52 56 }) 53 57 }
+4 -3
src/server/routes-static.ts
··· 1 1 import express from 'express' 2 - import { join } from 'path' 2 + import {join} from 'path' 3 3 4 4 interface StaticOpts { 5 5 root: string ··· 13 13 * @returns a new middleware 14 14 */ 15 15 export function makeStaticMiddleware(opts: StaticOpts): express.RequestHandler { 16 - return express.static(opts.root, { index: opts.index }) 16 + return express.static(opts.root, {index: opts.index}) 17 17 } 18 18 19 19 /** ··· 25 25 export function makeSpaMiddleware(opts: StaticOpts): express.RequestHandler { 26 26 return (req, res, next) => { 27 27 if (req.method === 'GET' && req.accepts('text/html')) { 28 - res.sendFile(join(opts.root, opts.index)); return; 28 + res.sendFile(join(opts.root, opts.index)) 29 + return 29 30 } 30 31 31 32 next() // otherwise
+2 -10
tsconfig.json
··· 42 42 "types": ["node"], 43 43 "lib": ["es2024", "DOM", "DOM.Iterable", "DOM.AsyncIterable"] 44 44 }, 45 - "include": [ 46 - "src/**/*", 47 - "vite.config.js", 48 - "eslint.config.js" 49 - ], 50 - "exclude": [ 51 - "node_modules", 52 - "dist", 53 - "tmp" 54 - ] 45 + "include": ["src/**/*", "vite.config.js", "eslint.config.js"], 46 + "exclude": ["node_modules", "dist", "tmp"] 55 47 }
+3 -3
vite.config.js
··· 1 1 import preact from '@preact/preset-vite' 2 - import { resolve } from 'node:path' 3 - import { defineConfig } from 'vite' 4 - import { nodePolyfills } from 'vite-plugin-node-polyfills' 2 + import {resolve} from 'node:path' 3 + import {defineConfig} from 'vite' 4 + import {nodePolyfills} from 'vite-plugin-node-polyfills' 5 5 // import checker from 'vite-plugin-checker' 6 6 7 7 // https://vite.dev/config/