add and format with prettier

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