convert from jsdoc to typescript with tsx

+3 -3
jest.config.js
··· 22 22 23 23 /** @type {jest_types.Config} */ 24 24 export default { 25 - testMatch: ['<rootDir>/src/**/*.spec.{js,jsx}'], 25 + testMatch: ['<rootDir>/src/**/*.spec.{ts,tsx}'], 26 26 testPathIgnorePatterns: [ 27 27 ...gitignorePatterns(), 28 28 ], ··· 50 50 ], 51 51 52 52 collectCoverageFrom: [ 53 - 'src/**/*.{js,jsx}', 54 - '!src/**/*.spec.{js,jsx}', 53 + 'src/**/*.{ts,tsx}', 54 + '!src/**/*.spec.{ts,tsx}', 55 55 '!src/**/node_modules/**', 56 56 ], 57 57 }
+41 -27
package-lock.json
··· 13 13 "nanoid": "^5.1.5", 14 14 "preact": "^10.26.9", 15 15 "simple-peer": "^9.11.1", 16 + "tsx": "^4.19.2", 16 17 "ws": "^8.18.2", 17 18 "zod": "~3.25" 18 19 }, ··· 830 831 "cpu": [ 831 832 "ppc64" 832 833 ], 833 - "dev": true, 834 834 "license": "MIT", 835 835 "optional": true, 836 836 "os": [ ··· 847 847 "cpu": [ 848 848 "arm" 849 849 ], 850 - "dev": true, 851 850 "license": "MIT", 852 851 "optional": true, 853 852 "os": [ ··· 864 863 "cpu": [ 865 864 "arm64" 866 865 ], 867 - "dev": true, 868 866 "license": "MIT", 869 867 "optional": true, 870 868 "os": [ ··· 881 879 "cpu": [ 882 880 "x64" 883 881 ], 884 - "dev": true, 885 882 "license": "MIT", 886 883 "optional": true, 887 884 "os": [ ··· 898 895 "cpu": [ 899 896 "arm64" 900 897 ], 901 - "dev": true, 902 898 "license": "MIT", 903 899 "optional": true, 904 900 "os": [ ··· 915 911 "cpu": [ 916 912 "x64" 917 913 ], 918 - "dev": true, 919 914 "license": "MIT", 920 915 "optional": true, 921 916 "os": [ ··· 932 927 "cpu": [ 933 928 "arm64" 934 929 ], 935 - "dev": true, 936 930 "license": "MIT", 937 931 "optional": true, 938 932 "os": [ ··· 949 943 "cpu": [ 950 944 "x64" 951 945 ], 952 - "dev": true, 953 946 "license": "MIT", 954 947 "optional": true, 955 948 "os": [ ··· 966 959 "cpu": [ 967 960 "arm" 968 961 ], 969 - "dev": true, 970 962 "license": "MIT", 971 963 "optional": true, 972 964 "os": [ ··· 983 975 "cpu": [ 984 976 "arm64" 985 977 ], 986 - "dev": true, 987 978 "license": "MIT", 988 979 "optional": true, 989 980 "os": [ ··· 1000 991 "cpu": [ 1001 992 "ia32" 1002 993 ], 1003 - "dev": true, 1004 994 "license": "MIT", 1005 995 "optional": true, 1006 996 "os": [ ··· 1017 1007 "cpu": [ 1018 1008 "loong64" 1019 1009 ], 1020 - "dev": true, 1021 1010 "license": "MIT", 1022 1011 "optional": true, 1023 1012 "os": [ ··· 1034 1023 "cpu": [ 1035 1024 "mips64el" 1036 1025 ], 1037 - "dev": true, 1038 1026 "license": "MIT", 1039 1027 "optional": true, 1040 1028 "os": [ ··· 1051 1039 "cpu": [ 1052 1040 "ppc64" 1053 1041 ], 1054 - "dev": true, 1055 1042 "license": "MIT", 1056 1043 "optional": true, 1057 1044 "os": [ ··· 1068 1055 "cpu": [ 1069 1056 "riscv64" 1070 1057 ], 1071 - "dev": true, 1072 1058 "license": "MIT", 1073 1059 "optional": true, 1074 1060 "os": [ ··· 1085 1071 "cpu": [ 1086 1072 "s390x" 1087 1073 ], 1088 - "dev": true, 1089 1074 "license": "MIT", 1090 1075 "optional": true, 1091 1076 "os": [ ··· 1102 1087 "cpu": [ 1103 1088 "x64" 1104 1089 ], 1105 - "dev": true, 1106 1090 "license": "MIT", 1107 1091 "optional": true, 1108 1092 "os": [ ··· 1119 1103 "cpu": [ 1120 1104 "arm64" 1121 1105 ], 1122 - "dev": true, 1123 1106 "license": "MIT", 1124 1107 "optional": true, 1125 1108 "os": [ ··· 1136 1119 "cpu": [ 1137 1120 "x64" 1138 1121 ], 1139 - "dev": true, 1140 1122 "license": "MIT", 1141 1123 "optional": true, 1142 1124 "os": [ ··· 1153 1135 "cpu": [ 1154 1136 "arm64" 1155 1137 ], 1156 - "dev": true, 1157 1138 "license": "MIT", 1158 1139 "optional": true, 1159 1140 "os": [ ··· 1170 1151 "cpu": [ 1171 1152 "x64" 1172 1153 ], 1173 - "dev": true, 1174 1154 "license": "MIT", 1175 1155 "optional": true, 1176 1156 "os": [ ··· 1187 1167 "cpu": [ 1188 1168 "x64" 1189 1169 ], 1190 - "dev": true, 1191 1170 "license": "MIT", 1192 1171 "optional": true, 1193 1172 "os": [ ··· 1204 1183 "cpu": [ 1205 1184 "arm64" 1206 1185 ], 1207 - "dev": true, 1208 1186 "license": "MIT", 1209 1187 "optional": true, 1210 1188 "os": [ ··· 1221 1199 "cpu": [ 1222 1200 "ia32" 1223 1201 ], 1224 - "dev": true, 1225 1202 "license": "MIT", 1226 1203 "optional": true, 1227 1204 "os": [ ··· 1238 1215 "cpu": [ 1239 1216 "x64" 1240 1217 ], 1241 - "dev": true, 1242 1218 "license": "MIT", 1243 1219 "optional": true, 1244 1220 "os": [ ··· 6934 6910 "version": "0.25.5", 6935 6911 "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", 6936 6912 "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", 6937 - "dev": true, 6938 6913 "hasInstallScript": true, 6939 6914 "license": "MIT", 6940 6915 "bin": { ··· 7851 7826 "version": "2.3.3", 7852 7827 "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 7853 7828 "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 7854 - "dev": true, 7855 7829 "hasInstallScript": true, 7856 7830 "license": "MIT", 7857 7831 "optional": true, ··· 8019 7993 "url": "https://github.com/sponsors/ljharb" 8020 7994 } 8021 7995 }, 7996 + "node_modules/get-tsconfig": { 7997 + "version": "4.10.1", 7998 + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", 7999 + "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", 8000 + "license": "MIT", 8001 + "dependencies": { 8002 + "resolve-pkg-maps": "^1.0.0" 8003 + }, 8004 + "funding": { 8005 + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" 8006 + } 8007 + }, 8022 8008 "node_modules/github-from-package": { 8023 8009 "version": "0.0.0", 8024 8010 "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", ··· 13256 13242 "node": ">=4" 13257 13243 } 13258 13244 }, 13245 + "node_modules/resolve-pkg-maps": { 13246 + "version": "1.0.0", 13247 + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", 13248 + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", 13249 + "license": "MIT", 13250 + "funding": { 13251 + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" 13252 + } 13253 + }, 13259 13254 "node_modules/ret": { 13260 13255 "version": "0.2.2", 13261 13256 "resolved": "https://registry.npmjs.org/ret/-/ret-0.2.2.tgz", ··· 15016 15011 "dev": true, 15017 15012 "license": "0BSD", 15018 15013 "optional": true 15014 + }, 15015 + "node_modules/tsx": { 15016 + "version": "4.20.3", 15017 + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz", 15018 + "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", 15019 + "license": "MIT", 15020 + "dependencies": { 15021 + "esbuild": "~0.25.0", 15022 + "get-tsconfig": "^4.7.5" 15023 + }, 15024 + "bin": { 15025 + "tsx": "dist/cli.mjs" 15026 + }, 15027 + "engines": { 15028 + "node": ">=18.0.0" 15029 + }, 15030 + "optionalDependencies": { 15031 + "fsevents": "~2.3.3" 15032 + } 15019 15033 }, 15020 15034 "node_modules/tty-browserify": { 15021 15035 "version": "0.0.1",
+12 -12
package.json
··· 25 25 "nanoid": "^5.1.5", 26 26 "preact": "^10.26.9", 27 27 "simple-peer": "^9.11.1", 28 + "tsx": "^4.19.2", 28 29 "ws": "^8.18.2", 29 30 "zod": "~3.25" 30 31 }, ··· 75 76 "build": { 76 77 "command": "vite build", 77 78 "files": [ 78 - "src/client/**/*.{js,jsx}", 79 - "src/client/*.{js,jsx}", 80 - "src/common/**/*.js", 81 - "src/common/*.js", 82 - "!src/**/*.spec.{js,jsx}" 79 + "src/client/**/*.{ts,tsx}", 80 + "src/client/*.{ts,tsx}", 81 + "src/common/**/*.ts", 82 + "src/common/*.ts", 83 + "!src/**/*.spec.{ts,tsx}" 83 84 ], 84 85 "output": [ 85 86 "./dist/**" ··· 88 89 "lint": { 89 90 "command": "eslint --color --cache --cache-location .eslintcache .", 90 91 "files": [ 91 - "src/**/*.js", 92 + "src/**/*.{ts,tsx}", 92 93 "eslint.config.js", 93 94 ".eslintignore" 94 95 ], ··· 97 98 "test": { 98 99 "command": "jest --cache --cacheDirectory .jestcache", 99 100 "files": [ 100 - "src/**/*.js", 101 - "src/**/*.jsx", 101 + "src/**/*.{ts,tsx}", 102 102 "jest.config.js", 103 103 "jest.setup.js" 104 104 ], ··· 109 109 "types": { 110 110 "command": "tsc --build --pretty --noEmit", 111 111 "files": [ 112 - "src/**/*.js", 112 + "src/**/*.{ts,tsx}", 113 113 "tsconfig.json" 114 114 ], 115 115 "output": [ ··· 119 119 "docs": { 120 120 "command": "jsdoc -c jsdoc.json", 121 121 "files": [ 122 - "src/**/*.{js,jsx}", 123 - "!src/**/*.spec.{js,jsx}", 122 + "src/**/*.{ts,tsx}", 123 + "!src/**/*.spec.{ts,tsx}", 124 124 "jsdoc.json" 125 125 ], 126 126 "output": [ ··· 137 137 }, 138 138 "run:server": { 139 139 "service": true, 140 - "command": "node --watch --watch-preserve-output src/cmd/server.js" 140 + "command": "tsx --watch src/cmd/server.ts" 141 141 }, 142 142 "start:tests": { 143 143 "service": true,
-51
src/client/components/messenger.jsx
··· 1 - // @ts-nocheck 2 - 3 - import * as connection_types from '#client/realm/connection.js' 4 - import * as protocol_types from '#common/protocol.js' 5 - import { useState, useEffect, useCallback } from 'preact/hooks' 6 - 7 - /** 8 - * @param {object} props list props 9 - * @param {connection_types.RealmConnection} props.webrtcManager connection manager 10 - * @returns {preact.JSX.Element} the peerlist component 11 - */ 12 - export function Messenger({ webrtcManager }) { 13 - const [messages, setMessages] = useState(/** @type {Array<any>} */ []) 14 - 15 - const peerdata = useCallback( 16 - /** @type {function(CustomEvent): void} */ 17 - (event) => { 18 - setMessages([...messages, [event.detail.remoteId, event.detail.data]]) 19 - }, 20 - [messages], 21 - ) 22 - 23 - const sendMessage = useCallback( 24 - () => { 25 - webrtcManager.broadcast('what\'s up friends?') 26 - }, 27 - [webrtcManager], 28 - ) 29 - 30 - useEffect(() => { 31 - if (!webrtcManager) return 32 - 33 - webrtcManager.addEventListener('peerdata', peerdata) 34 - return () => { 35 - webrtcManager.removeEventListener('peerdata', peerdata) 36 - } 37 - }, [webrtcManager, peerdata]) 38 - 39 - return ( 40 - <div className="messages-list"> 41 - <h3>Realm Messages</h3> 42 - <pre> 43 - { JSON.stringify(messages, null, 2) } 44 - </pre> 45 - <div> 46 - <textarea /> 47 - <button onClick={sendMessage}>Send</button> 48 - </div> 49 - </div> 50 - ) 51 - }
+41
src/client/components/messenger.tsx
··· 1 + import { RealmConnection } from '#client/realm/connection.js' 2 + import { useState, useEffect, useCallback } from 'preact/hooks' 3 + 4 + export type MessengerProps = { 5 + realmConnection: RealmConnection 6 + } 7 + 8 + export const Messenger: preact.FunctionComponent<{ webrtcManager: RealmConnection }> = (props) => { 9 + const { webrtcManager } = props 10 + const [messages, setMessages] = useState<any[]>([]) 11 + 12 + const peerdata = useCallback((event: CustomEvent) => { 13 + setMessages([...messages, [event.detail.remoteId, event.detail.data]]) 14 + }, [messages]) 15 + 16 + const sendMessage = useCallback(() => { 17 + webrtcManager.broadcast('what\'s up friends?') 18 + }, [webrtcManager]) 19 + 20 + useEffect(() => { 21 + if (!webrtcManager) return 22 + 23 + webrtcManager.addEventListener('peerdata', peerdata as ((event: Event) => void)) 24 + return () => { 25 + webrtcManager.removeEventListener('peerdata', peerdata as ((event: Event) => void)) 26 + } 27 + }, [webrtcManager, peerdata]) 28 + 29 + return ( 30 + <div className="messages-list"> 31 + <h3>Realm Messages</h3> 32 + <pre> 33 + { JSON.stringify(messages, null, 2) } 34 + </pre> 35 + <div> 36 + <textarea /> 37 + <button onClick={sendMessage}>Send</button> 38 + </div> 39 + </div> 40 + ) 41 + }
+8 -19
src/client/components/peer-list.jsx src/client/components/peer-list.tsx
··· 1 - import * as connection_types from '#client/realm/connection.js' 2 - import * as protocol_types from '#common/protocol.js' 3 1 import { useState, useEffect } from 'preact/hooks' 4 2 5 - /** 6 - * @param {object} props list props 7 - * @param {connection_types.RealmConnection} props.webrtcManager connection manager 8 - * @returns {preact.JSX.Element} the peerlist component 9 - */ 10 - export function PeerList({ webrtcManager }) { 11 - const [peers, setPeers] = useState( 12 - /** @type {Record<protocol_types.IdentID, connection_types.PeerState>} */ ({}), 13 - ) 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>>({}) 14 9 15 10 useEffect(() => { 16 11 if (!webrtcManager) return ··· 49 44 : ( 50 45 <ul> 51 46 {Object.entries(peers).map(([peerId, state]) => ( 52 - <li key={peerId} className={`peer-item ${state.state}`}> 47 + <li key={peerId} className={`peer-item`}> 53 48 <span className="peer-id">{peerId}</span> 54 49 <ConnectionStatus state={state} /> 55 50 </li> ··· 61 56 ) 62 57 } 63 58 64 - /** 65 - * @private 66 - * @param {object} props status props 67 - * @param {connection_types.PeerState} props.state current state 68 - * @returns {preact.JSX.Element} the status component 69 - */ 70 - function ConnectionStatus({ state }) { 59 + function ConnectionStatus({ state }: { state: PeerState }): preact.JSX.Element { 71 60 const getStatusIcon = () => { 72 61 if (state.connected) return '🟢' 73 62 if (state.destroyed) return '🔴'
+3 -2
src/client/index.jsx src/client/index.tsx
··· 1 + import { render } from 'preact' 2 + 3 + import { App } from './page-app' 1 4 import './index.css' 2 - import { App } from './page-app.jsx' 3 - import { render } from 'preact' 4 5 5 6 const root = document.getElementById('app') 6 7 if (root == null)
-13
src/client/page-app.jsx
··· 1 - import { RealmConnectionProvider } from './realm/context.jsx' 2 - import { WebRTCDemo } from './webrtc-demo.jsx' 3 - 4 - /** 5 - * @returns {preact.JSX.Element} app wrapper component 6 - */ 7 - export function App() { 8 - return ( 9 - <RealmConnectionProvider url="ws://localhost:3001/stream"> 10 - <WebRTCDemo /> 11 - </RealmConnectionProvider> 12 - ) 13 - }
-52
src/client/page-app.spec.jsx
··· 1 - /** @jest-environment jest-fixed-jsdom */ 2 - 3 - import { describe, it, expect } from '@jest/globals' 4 - 5 - import { App } from '#client/page-app.jsx' 6 - import { Fragment } from 'preact/jsx-runtime' 7 - 8 - describe('App component', () => { 9 - it('creates JSX structure', () => { 10 - const component = App() 11 - expect(component).toHaveProperty('type') 12 - expect(component).toHaveProperty('props') 13 - }) 14 - 15 - it('returns expected element structure', () => { 16 - const component = App() 17 - 18 - // Check the JSX structure without full rendering 19 - expect(component.type).toBe(Fragment) 20 - expect(component.props.children[0]).toHaveProperty('type', 'h1') 21 - expect(component.props.children[0].props.children).toBe('whatever') 22 - }) 23 - }) 24 - 25 - describe('Client-side DOM environment', () => { 26 - it('has access to DOM globals', () => { 27 - // This test demonstrates that jsdom environment provides DOM APIs 28 - expect(typeof document).toBe('object') 29 - expect(typeof window).toBe('object') 30 - expect(typeof Element).toBe('function') 31 - }) 32 - 33 - it('can create and manipulate DOM elements', () => { 34 - const div = document.createElement('div') 35 - div.textContent = 'test content' 36 - 37 - expect(div.tagName).toBe('DIV') 38 - expect(div.textContent).toBe('test content') 39 - }) 40 - 41 - it('can simulate user interactions', () => { 42 - const button = document.createElement('button') 43 - let clicked = false 44 - 45 - button.addEventListener('click', () => { 46 - clicked = true 47 - }) 48 - 49 - button.click() 50 - expect(clicked).toBe(true) 51 - }) 52 - })
+10
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 ( 6 + <RealmConnectionProvider url="ws://localhost:3001/stream"> 7 + <WebRTCDemo /> 8 + </RealmConnectionProvider> 9 + ) 10 + }
+53 -115
src/client/realm/connection.js src/client/realm/connection.ts
··· 3 3 import { nanoid } from 'nanoid' 4 4 import SimplePeer from 'simple-peer' 5 5 6 - import { generateSignableJwt, jwkExport } from '#common/crypto/jwks.js' 7 - import { normalizeError, normalizeProtocolError, ProtocolError } from '#common/errors.js' 8 - import { parseJson, realmFromServerMessageSchema, realmRtcPeerWelcomeMessageSchema } from '#common/protocol.js' 9 - import { sendSocket, streamSocket, streamSocketJson, takeSocketJson } from '#common/socket.js' 6 + import { generateSignableJwt, jwkExport } from '#common/crypto/jwks' 7 + import { normalizeError, normalizeProtocolError, ProtocolError } from '#common/errors' 8 + import { IdentID, RealmBroadcastMessage, realmFromServerMessageSchema, RealmID, RealmRtcPeerWelcomeMessage, realmRtcPeerWelcomeMessageSchema, RealmRtcSignalMessage } from '#common/protocol' 9 + import { sendSocket, streamSocketJson, takeSocketJson } from '#common/socket' 10 10 11 - import * as protocol_types from '#common/protocol.js' 11 + /** the state of a specific peer */ 12 + export interface PeerState { 12 13 13 - /** 14 - * @typedef {object} PeerState 15 - * @property {ReturnType<SimplePeer.Instance['address']>} address peer address 16 - * @property {boolean} connected true if the peer connection is active 17 - * @property {boolean} destroyed true if the peer connection has been destroyed 18 - * @property {string} state a simple status string 19 - */ 14 + /** true if the peer connection is active */ 15 + connected: boolean 20 16 21 - /** 22 - * @typedef {object} RealmIdentity 23 - * @property {protocol_types.RealmID} realmid 24 - * @property {protocol_types.IdentID} identid 25 - * @property {CryptoKeyPair} keypair 26 - */ 17 + /** true if the peer connection has been destroyed */ 18 + destroyed: boolean 27 19 28 - /** 29 - * A connection manager 30 - */ 31 - export class RealmConnection extends EventTarget { 20 + /** the peer's address */ 21 + address: ReturnType<SimplePeer.Instance['address']> 32 22 33 - /** @type {string} */ 34 - #url 23 + } 35 24 36 - /** @type {RealmIdentity} */ 37 - #identity 25 + export interface RealmIdentity { 26 + realmid: RealmID 27 + identid: IdentID 28 + keypair: CryptoKeyPair 29 + } 38 30 39 - /** @type {WebSocket} */ 40 - #socket 31 + /** A connection manager */ 32 + export class RealmConnection extends EventTarget { 41 33 42 - /** @type {Map<protocol_types.IdentID, SimplePeer.Instance>} */ 43 - #peers 34 + #url: string 35 + #identity: RealmIdentity 44 36 45 - /** @type {Map<protocol_types.IdentID, string>} */ 46 - #nonces 37 + #socket: WebSocket 38 + #peers: Map<IdentID, SimplePeer.Instance> 39 + #nonces: Map<IdentID, string> 47 40 48 - /** 49 - * @param {string} url the realm server we're connecting to 50 - * @param {RealmIdentity} identity this connection's realm identity 51 - */ 52 - constructor(url, identity) { 41 + constructor(url: string, identity: RealmIdentity) { 53 42 super() 54 43 55 44 this.#url = url ··· 64 53 this.#socket.onerror = this.#handleSocketError 65 54 } 66 55 67 - /** @type {WebSocket['onopen']} */ 68 - #handleSocketOpen = async () => { 56 + #handleSocketOpen: WebSocket['onopen'] = async () => { 69 57 if (this.#socket == undefined) 70 58 throw new Error('socket open handler called with no socket?') 71 59 ··· 87 75 // the next message should be a welcome message 88 76 // this will throw (authenticated) otherwise 89 77 90 - /** @type {protocol_types.RealmRtcPeerWelcomeMessage} */ 91 - let welcome 78 + let welcome: RealmRtcPeerWelcomeMessage 92 79 try { 93 80 welcome = await takeSocketJson(this.#socket, realmRtcPeerWelcomeMessageSchema) 94 81 } ··· 161 148 } 162 149 } 163 150 164 - /** @type {WebSocket['onerror']} */ 165 - #handleSocketError = async (exc) => { 151 + #handleSocketError: WebSocket['onerror'] = async (exc) => { 166 152 this.#dispatchCustomEvent('wserror', { error: normalizeProtocolError(exc) }) 167 153 this.destroy() 168 154 } 169 155 170 156 /** @type {WebSocket['onclose']} */ 171 - #handleSocketClose = async () => { 157 + #handleSocketClose: WebSocket['onclose'] = async () => { 172 158 this.#dispatchCustomEvent('wsclose') 173 159 this.destroy() 174 160 } ··· 190 176 this.#nonces.clear() 191 177 } 192 178 193 - /** 194 - * dispatch a `CustomEvent` 195 - * 196 - * @param {string} type the event type (for addEVentListener(type)) 197 - * @param {object} [detail] the details object to include 198 - */ 199 - #dispatchCustomEvent(type, detail) { 179 + #dispatchCustomEvent(type: string, detail?: object) { 200 180 this.dispatchEvent(new CustomEvent(type, { detail })) 201 181 } 202 182 203 - /** 204 - * generates and signs a JWT scoped to this identity/realm containing the given payload 205 - * 206 - * @param {object} payload the payload to embed in the JWT 207 - * @returns {Promise<string>} a signed JWT for sending upstream 208 - */ 209 - async #signJwt(payload) { 183 + /** generates and signs a JWT scoped to this identity/realm containing the given payload */ 184 + async #signJwt(payload: object): Promise<string> { 210 185 return await generateSignableJwt({ 211 186 aud: this.#identity.realmid, 212 187 iss: this.#identity.identid, ··· 214 189 }).sign(this.#identity.keypair.privateKey) 215 190 } 216 191 217 - /** 218 - * @private 219 - * @param {protocol_types.IdentID} remoteid the identity we're connecting to 220 - * @param {boolean} initiator are we the initiator of this connection? 221 - * @returns {SimplePeer.Instance} the configured peer for the identid 222 - */ 223 - connectToPeer(remoteid, initiator) { 192 + connectToPeer(remoteid: IdentID, initiator: boolean): SimplePeer.Instance { 224 193 let peer = this.#peers.get(remoteid) 225 194 if (peer) { 226 195 console.log(`already connected to ${remoteid}`) ··· 263 232 return peer 264 233 } 265 234 266 - /** @param {protocol_types.IdentID} identid the recipient to disconnect from */ 267 - disconnectPeer(identid) { 235 + disconnectPeer(identid: IdentID) { 268 236 const peer = this.#peers.get(identid) 269 237 if (peer) { 270 238 peer.destroy() ··· 273 241 } 274 242 } 275 243 276 - /** 277 - * @param {protocol_types.IdentID} identid the recipient to send data to 278 - * @param {any} data the data to send via peer connection 279 - */ 280 - sendToPeer(identid, data) { 244 + sendToPeer(identid: IdentID, data: unknown) { 281 245 const peer = this.#peers.get(identid) 282 246 if (peer && peer.connected) { 283 247 peer.send(JSON.stringify(data)) ··· 287 251 } 288 252 } 289 253 290 - /** 291 - * @param {unknown} data to send along the socket to the realm server 292 - */ 293 - sendToServer(data) { 254 + sendToServer(data: unknown) { 294 255 sendSocket(this.#socket, data) 295 256 } 296 257 297 - /** @param {any} data the data to send via peer connection */ 298 - broadcast(data) { 258 + broadcast(data: unknown) { 299 259 const message = JSON.stringify(data) 300 260 for (const [_, peer] of this.#peers) { 301 261 if (peer.connected) { ··· 304 264 } 305 265 } 306 266 307 - /** @param {any} data the data to send via server */ 308 - broadcastViaServer(data) { 309 - /** @type {protocol_types.RealmBroadcastMessage} */ 310 - const resp = { 267 + broadcastViaServer(data: unknown) { 268 + const resp: RealmBroadcastMessage = { 311 269 msg: 'realm.broadcast', 312 270 payload: data, 313 271 recipients: false, 314 272 } 273 + 315 274 sendSocket(this.#socket, resp) 316 275 } 317 276 318 277 /** 319 278 * @returns {Record<protocol_types.IdentID, PeerState>} the current peer state mapping 320 279 */ 321 - getPeerStates() { 322 - /** @type {Record<protocol_types.IdentID, PeerState>} */ 323 - const states = {} 324 - 280 + getPeerStates(): Record<IdentID, PeerState> { 281 + const states: Record<IdentID, PeerState> = {} 325 282 for (const [peerId, peer] of this.#peers) { 326 283 states[peerId] = { 327 284 address: peer.address(), 328 285 connected: peer.connected, 329 286 destroyed: peer.destroyed, 330 - state: peer.connected ? 'connected' : 'disconnected', 331 287 } 332 288 } 333 289 ··· 341 297 */ 342 298 export class RealmConnectionPeer extends SimplePeer { 343 299 344 - /** @type {RealmConnection} */ 345 - #connection 300 + #connection: RealmConnection 346 301 347 - /** @type {boolean} */ 348 - initiator 349 - 350 - /** @type {protocol_types.IdentID} */ 351 - localid 352 - 353 - /** @type {protocol_types.IdentID} */ 354 - remoteid 355 - 356 - /** @type {string} */ 357 - nonce 302 + initiator: boolean 303 + localid: IdentID 304 + remoteid: IdentID 305 + nonce: string 358 306 359 - /** 360 - * @param {RealmConnection} connection the connection that owns this peer 361 - * @param {string} nonce a uniquely identifying string for this peer 362 - * @param {protocol_types.IdentID} localid the local identity 363 - * @param {protocol_types.IdentID} remoteid the remote identity 364 - * @param {boolean} initiator whether we're initiating the connection 365 - */ 366 - constructor(connection, nonce, localid, remoteid, initiator) { 307 + constructor(connection: RealmConnection, nonce: string, localid: IdentID, remoteid: IdentID, initiator: boolean) { 367 308 super({ 368 309 initiator, 369 310 config: { ··· 385 326 this.on('data', this.#handlePeerData) 386 327 } 387 328 388 - /** @type {function(SimplePeer.SignalData): void} */ 389 - #handlePeerSignal = (e) => { 329 + #handlePeerSignal = (e: SimplePeer.SignalData) => { 390 330 this.#connection.sendToServer( 391 - /** @type {protocol_types.RealmRtcSignalMessage} */ 392 - ({ 331 + { 393 332 msg: 'realm.rtc.signal', 394 333 initiator: this.initiator, 395 334 sender: this.localid, 396 335 recipient: this.remoteid, 397 336 payload: JSON.stringify(e), 398 - }), 337 + } satisfies RealmRtcSignalMessage, 399 338 ) 400 339 } 401 340 402 - /** @type {function(string): void} */ 403 - #handlePeerData = (chunk) => { 341 + #handlePeerData = (chunk: string) => { 404 342 try { 405 343 const parsed = JSON.parse(chunk) 406 344 switch (parsed.type) {
-75
src/client/realm/context.jsx
··· 1 - import { createContext } from 'preact' 2 - import * as preact_types from 'preact' 3 - import * as connection_types from '#client/realm/connection.js' 4 - 5 - import { RealmConnection } from '#client/realm/connection.js' 6 - import { useCallback, useEffect, useState } from 'preact/hooks' 7 - 8 - /** 9 - * @typedef {object} RealmConnectionContext 10 - * @property {RealmConnection | null} realm the realm server we're connected to 11 - * @property {connection_types.RealmIdentity | null} identity the realm identity we're connected as 12 - * @property {function(connection_types.RealmIdentity): void} setIdentity 13 - * a callback to set the current identity 14 - */ 15 - 16 - export const RealmConnectionContext 17 - = createContext(/** @type {RealmConnectionContext | null} */ (null)) 18 - 19 - /** 20 - * @typedef {object} RealmConnectionProviderProps 21 - * @property {string} url the realm server to connect to 22 - * @property {preact_types.ComponentChildren} children the children to render 23 - */ 24 - 25 - /** 26 - * @type {preact.FunctionComponent<RealmConnectionProviderProps>} 27 - */ 28 - export const RealmConnectionProvider = (props) => { 29 - const [identity$, setIdentity$] 30 - = useState(/** @type {connection_types.RealmIdentity | null} */ (null)) 31 - 32 - const [connection$, setConnection$] 33 - = useState(/** @type {RealmConnection | null} */ (null)) 34 - 35 - const connect = useCallback( 36 - () => { 37 - if (connection$) return 38 - if (!identity$) return 39 - 40 - const connection = new RealmConnection(props.url, identity$) 41 - setConnection$(connection) 42 - }, 43 - [connection$, identity$, props.url], 44 - ) 45 - 46 - const disconnect = useCallback( 47 - () => { 48 - connection$?.destroy() 49 - }, 50 - [connection$], 51 - ) 52 - 53 - useEffect( 54 - () => { 55 - console.log('use effect in provider', identity$) 56 - // connect on mount, or identity change 57 - if (identity$) connect() 58 - 59 - // disconnect on unsubscribe 60 - return disconnect 61 - }, 62 - [connect, disconnect, identity$], 63 - ) 64 - 65 - return ( 66 - <RealmConnectionContext.Provider 67 - children={props.children} 68 - value={{ 69 - realm: connection$, 70 - identity: identity$, 71 - setIdentity: setIdentity$, 72 - }} 73 - /> 74 - ) 75 - }
+54
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 8 + identity?: RealmIdentity 9 + setIdentity: (ident: RealmIdentity) => void 10 + } 11 + 12 + export const RealmConnectionContext = createContext<RealmConnectionContext | null>(null) 13 + 14 + export const RealmConnectionProvider: preact.FunctionComponent<{ 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(() => { 32 + connection$?.destroy() 33 + }, [connection$]) 34 + 35 + useEffect(() => { 36 + console.log('use effect in provider', identity$) 37 + // connect on mount, or identity change 38 + if (identity$) connect() 39 + 40 + // disconnect on unsubscribe 41 + return disconnect 42 + }, [connect, disconnect, identity$]) 43 + 44 + return ( 45 + <RealmConnectionContext.Provider 46 + children={props.children} 47 + value={{ 48 + realm: connection$, 49 + identity: identity$, 50 + setIdentity: setIdentity$, 51 + }} 52 + /> 53 + ) 54 + }
+7 -19
src/client/webrtc-demo.jsx src/client/webrtc-demo.tsx
··· 1 - // @ts-nocheck 2 - 3 1 import { useEffect, useContext, useCallback } from 'preact/hooks' 4 2 5 - import { RealmConnectionContext } from '#client/realm/context.jsx' 6 - import { PeerList } from '#client/components/peer-list.jsx' 3 + import { RealmConnectionContext } from '#client/realm/context' 4 + import { PeerList } from '#client/components/peer-list' 7 5 8 - import * as protocol from '#common/protocol.js' 9 - import { generateSigningJwkPair } from '#common/crypto/jwks.js' 6 + import * as protocol from '#common/protocol' 7 + import { generateSigningJwkPair } from '#common/crypto/jwks' 10 8 import { Messenger } from './components/messenger' 9 + import { Callback } from '#common/types.js' 11 10 12 - /** 13 - * @private 14 - * @param {EventTarget} target event target to attach 15 - * @param {string} name the name of the event to listen to 16 - * @param {function(): void} listener the listener to add 17 - * @returns {function(): void} the listener, for removing later 18 - */ 19 - function attachEventListener(target, name, listener) { 11 + function attachEventListener<CB extends Callback>(target: EventTarget, name: string, listener: CB): CB { 20 12 target.addEventListener(name, listener) 21 13 return listener 22 14 } 23 15 24 - /** 25 - * @private 26 - * @returns {preact.JSX.Element} 27 - */ 28 - export function WebRTCDemo() { 16 + export const WebRTCDemo: preact.FunctionComponent = () => { 29 17 const context = useContext(RealmConnectionContext) 30 18 if (!context) 31 19 throw new Error('expected to be called inside realm connection context!')
+3 -7
src/cmd/register-ident.js src/cmd/register-ident.ts
··· 1 1 #!/usr/bin/env node 2 2 3 - import { generateSignableJwt, generateSigningJwkPair, jwkExport } from '#common/crypto/jwks.js' 4 - import { IdentBrand, RealmBrand } from '#common/protocol.js' 3 + import { generateSignableJwt, generateSigningJwkPair, jwkExport } from '#common/crypto/jwks' 4 + import { IdentBrand, RealmBrand } from '#common/protocol' 5 5 6 - /** 7 - * @private 8 - * @param {string} realm the realm to register against 9 - */ 10 - async function generateRegistrationJWT(realm) { 6 + async function generateRegistrationJWT(realm?: string) { 11 7 const keypair = await generateSigningJwkPair() 12 8 const realmid = realm ?? RealmBrand.generate() 13 9 const identid = IdentBrand.generate()
+2 -2
src/cmd/server.js src/cmd/server.ts
··· 2 2 import { fileURLToPath } from 'url' 3 3 import { parseArgs } from 'util' 4 4 5 - import { buildServer } from '#server/index.js' 5 + import { buildServer } from '#server/index' 6 6 7 7 const __filename = fileURLToPath(import.meta.url) 8 8 const __dirname = dirname(__filename) ··· 21 21 22 22 buildServer(root).listen(port, host, () => { 23 23 console.log(` 24 - skypod ⚡️ http://${host}:${port} 24 + skypod ⚡️ http://${host}:${port} 25 25 `) 26 26 })
-66
src/common/async/aborts.js
··· 1 - /** 2 - * @typedef {object} TimeoutSignal 3 - * @property {AbortSignal} signal the ticking signal 4 - * @property {VoidFunction} cancel a cleanup function, to cancel the timer 5 - */ 6 - 7 - import * as common_types from '#common/types.js' 8 - 9 - /** 10 - * returns a new abort signal which will abort after some timeout, unless cancelled. 11 - * better than AbortSignal.timeout because it consistently aborts with a timeout error 12 - * 13 - * @param {number} ms - timeout in milliseconds 14 - * @returns {TimeoutSignal} a cancellable timeout abort signal. 15 - */ 16 - export function timeoutSignal(ms) { 17 - const controller = new AbortController() 18 - 19 - const timeout = setTimeout(() => { 20 - controller.abort( 21 - new DOMException('Operation timed out', 'TimeoutError'), 22 - ) 23 - }, ms) 24 - 25 - const cancel = () => { 26 - clearTimeout(timeout) 27 - controller.signal.removeEventListener('abort', cancel) 28 - } 29 - 30 - controller.signal.addEventListener('abort', cancel) 31 - return { signal: controller.signal, cancel } 32 - } 33 - 34 - /** 35 - * @param {Array<AbortSignal | undefined>} signals the list of signals to combine 36 - * @returns {AbortSignal} a combined signal, which will abort when any given signal does 37 - */ 38 - export function combineSignals(...signals) { 39 - /** @type {Array<common_types.VoidCallback>} */ 40 - const cleanups = [] 41 - const controller = new AbortController() 42 - 43 - for (const signal of signals) { 44 - if (signal == undefined) continue 45 - 46 - if (signal.aborted) { 47 - controller.abort(signal.reason) 48 - return controller.signal 49 - } 50 - 51 - const handler = () => { 52 - if (!controller.signal.aborted) { 53 - controller.abort(signal.reason) 54 - } 55 - } 56 - 57 - signal.addEventListener('abort', handler) 58 - cleanups.push(() => signal.removeEventListener('abort', handler)) 59 - } 60 - 61 - controller.signal.addEventListener('abort', () => { 62 - cleanups.forEach(cb => cb()) 63 - }) 64 - 65 - return controller.signal 66 - }
+57
src/common/async/aborts.ts
··· 1 + export interface TimeoutSignal { 2 + signal: AbortSignal 3 + cancel: () => void 4 + } 5 + 6 + /** 7 + * returns a new abort signal which will abort after some timeout, unless cancelled. 8 + * better than AbortSignal.timeout because it consistently aborts with a timeout error 9 + */ 10 + export function timeoutSignal(ms: number): TimeoutSignal { 11 + const controller = new AbortController() 12 + 13 + const timeout = setTimeout(() => { 14 + controller.abort(new DOMException('Operation timed out', 'TimeoutError')) 15 + }, ms) 16 + 17 + const cancel = () => { 18 + clearTimeout(timeout) 19 + controller.signal.removeEventListener('abort', cancel) 20 + } 21 + 22 + controller.signal.addEventListener('abort', cancel) 23 + return { signal: controller.signal, cancel } 24 + } 25 + 26 + /** 27 + * @param signals the list of signals to combine 28 + * @returns a combined signal, which will abort when any given signal does 29 + */ 30 + export function combineSignals(...signals: Array<AbortSignal | undefined>): AbortSignal { 31 + const cleanups: Array<() => void> = [] 32 + const controller = new AbortController() 33 + 34 + for (const signal of signals) { 35 + if (signal == undefined) continue 36 + 37 + if (signal.aborted) { 38 + controller.abort(signal.reason) 39 + return controller.signal 40 + } 41 + 42 + const handler = () => { 43 + if (!controller.signal.aborted) { 44 + controller.abort(signal.reason) 45 + } 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 57 + }
-52
src/common/async/blocking-atom.js
··· 1 - /** @module common/async */ 2 - 3 - import { Semaphore } from './semaphore.js' 4 - 5 - /** 6 - * simple blocking atom, for waiting for a value. 7 - * cribbed mostly from {@link https://github.com/ComFreek/async-playground} 8 - * 9 - * @template T - the type we're holding 10 - */ 11 - export class BlockingAtom { 12 - 13 - /** @type {T | undefined} */ 14 - #item 15 - 16 - /** @type {Semaphore} */ 17 - #sema 18 - 19 - constructor() { 20 - this.#sema = new Semaphore() 21 - this.#item = undefined 22 - } 23 - 24 - /** 25 - * puts an item into the atom and unblocks an awaiter. 26 - * 27 - * @param {T} item the item to put into the atom 28 - */ 29 - set(item) { 30 - this.#item = item 31 - this.#sema.free() 32 - } 33 - 34 - /** 35 - * tries to get the item from the atom, and blocks until available. 36 - * 37 - * @example 38 - * if (await atom.take()) 39 - * console.log('got it!') 40 - * 41 - * @param {AbortSignal | undefined} signal - an abort signal to cancel the await 42 - * @returns {Promise<T | undefined>} a promise for the item, or undefined if something aborted. 43 - */ 44 - async get(signal) { 45 - if (await this.#sema.take(signal)) { 46 - return this.#item 47 - } 48 - 49 - signal?.throwIfAborted() 50 - } 51 - 52 - }
+45
src/common/async/blocking-atom.ts
··· 1 + /** @module common/async */ 2 + 3 + import { Semaphore } from './semaphore.js' 4 + 5 + /** 6 + * simple blocking atom, for waiting for a value. 7 + * cribbed mostly from {@link https://github.com/ComFreek/async-playground} 8 + * 9 + * @template T - the type we're holding 10 + */ 11 + export class BlockingAtom<T> { 12 + 13 + #item: T | undefined 14 + #sema: Semaphore 15 + 16 + constructor() { 17 + this.#sema = new Semaphore() 18 + this.#item = undefined 19 + } 20 + 21 + /** puts an item into the atom and unblocks an awaiter. */ 22 + set(item: T) { 23 + this.#item = item 24 + this.#sema.free() 25 + } 26 + 27 + /** 28 + * tries to get the item from the atom, and blocks until available. 29 + * 30 + * @example 31 + * if (await atom.take()) 32 + * console.log('got it!') 33 + * 34 + * @param signal - an abort signal to cancel the await 35 + * @returns a promise for the item, or undefined if something aborted. 36 + */ 37 + async get(signal: AbortSignal | undefined): Promise<T | undefined> { 38 + if (await this.#sema.take(signal)) { 39 + return this.#item 40 + } 41 + 42 + signal?.throwIfAborted() 43 + } 44 + 45 + }
+13 -28
src/common/async/blocking-queue.js src/common/async/blocking-queue.ts
··· 5 5 /** 6 6 * simple blocking queue, for turning streams into async pulls. 7 7 * cribbed mostly from {@link https://github.com/ComFreek/async-playground} 8 - * 9 - * @template T 10 8 */ 11 - export class BlockingQueue { 9 + export class BlockingQueue<T> { 12 10 13 - /** @type {Semaphore} */ 14 - #sema 15 - 16 - /** @type {T[]} */ 17 - #items 18 - 19 - /** @type {number | undefined} */ 20 - #maxsize 11 + #sema: Semaphore 12 + #maxsize?: number 13 + #items: T[] 21 14 22 15 constructor(maxsize = 1000) { 23 16 this.#sema = new Semaphore() 24 - this.#items = [] 25 17 this.#maxsize = maxsize ? maxsize : undefined 18 + this.#items = [] 26 19 } 27 20 28 21 /** @returns {number} how deep is the queue? */ 29 - get depth() { 22 + get depth(): number { 30 23 return this.#items.length 31 24 } 32 25 33 - /** 34 - * place one or more items on the queue, to be picked up by awaiters. 35 - * 36 - * @param {...T} elements the items to place on the queue. 37 - */ 38 - prequeue(...elements) { 26 + /** place one or more items on the queue, to be picked up by awaiters. */ 27 + prequeue(...elements: T[]) { 39 28 for (const el of elements.reverse()) { 40 29 if (this.#maxsize && this.#items.length >= this.#maxsize) { 41 30 throw Error('out of room') ··· 46 35 } 47 36 } 48 37 49 - /** 50 - * place one or more items on the queue, to be picked up by awaiters. 51 - * 52 - * @param {...T} elements the items to place on the queue. 53 - */ 54 - enqueue(...elements) { 38 + /** place one or more items on the queue, to be picked up by awaiters. */ 39 + enqueue(...elements: T[]) { 55 40 for (const el of elements) { 56 41 if (this.#maxsize && this.#items.length >= this.#maxsize) { 57 42 throw Error('out of room') ··· 65 50 /** 66 51 * block while waiting for an item off the queue. 67 52 * 68 - * @param {AbortSignal} [signal] a signal to use for aborting the block. 69 - * @returns {Promise<T>} the item off the queue; rejects if aborted. 53 + * @param [signal] a signal to use for aborting the block. 54 + * @returns the item off the queue; rejects if aborted. 70 55 */ 71 - async dequeue(signal) { 56 + async dequeue(signal?: AbortSignal): Promise<T> { 72 57 if (await this.#sema.take(signal)) { 73 58 return this.#poll() 74 59 }
+7 -14
src/common/async/semaphore.js src/common/async/semaphore.ts
··· 6 6 */ 7 7 export class Semaphore { 8 8 9 - /** @type { number } */ 10 - #counter = 0 11 - 12 - /** @type {Array<function(boolean): void>} */ 13 - #resolvers = [] 9 + #counter: number = 0 10 + #resolvers: Array<((taken: boolean) => void)> = [] 14 11 15 12 constructor(count = 0) { 16 13 this.#counter = count ··· 20 17 * try to take from the semaphore, reducing it's count 21 18 * if the semaphore is empty, blocks until available, or the given signal aborts. 22 19 * 23 - * @param {AbortSignal | undefined} signal a signal to use to abort the block 24 - * @returns {Promise<boolean>} true if the semaphore was successfully taken, false if aborted. 20 + * @param signal a signal to use to abort the block 21 + * @returns true if the semaphore was successfully taken, false if aborted. 25 22 */ 26 - take(signal) { 23 + take(signal?: AbortSignal): Promise<boolean> { 27 24 return new Promise((resolve) => { 28 25 if (signal?.aborted) return resolve(false) 29 26 ··· 48 45 }) 49 46 } 50 47 51 - /** 52 - * try to take from the semaphore, reducing it's count, *without blocking*. 53 - * 54 - * @returns {boolean} true if the semaphore was taken, false otherwise. 55 - */ 56 - poll() { 48 + /** try to take from the semaphore, reducing it's count, *without blocking*. */ 49 + poll(): boolean { 57 50 if (this.#counter <= 0) return false 58 51 59 52 this.#counter--
-28
src/common/async/sleep.js
··· 1 - /** @module common/async */ 2 - 3 - /** 4 - * @param {number} ms the number of ms to sleep 5 - * @param {AbortSignal} [signal] an aptional abort signal, to cancel the sleep 6 - * @returns {Promise<void>} 7 - * a promise that resolves after given amount of time, and is interruptable with an abort signal. 8 - */ 9 - export function sleep(ms, signal) { 10 - signal?.throwIfAborted() 11 - 12 - const { resolve, reject, promise } = Promise.withResolvers() 13 - const timeout = setTimeout(resolve, ms) 14 - 15 - if (signal) { 16 - const abortHandler = () => { 17 - clearTimeout(timeout) 18 - reject(signal.reason) 19 - } 20 - 21 - signal.addEventListener('abort', abortHandler) 22 - promise.finally(() => { 23 - signal.removeEventListener('abort', abortHandler) 24 - }) 25 - } 26 - 27 - return promise 28 - }
+27
src/common/async/sleep.ts
··· 1 + /** @module common/async */ 2 + 3 + /** 4 + * @param ms the number of ms to sleep 5 + * @param [signal] an aptional abort signal, to cancel the sleep 6 + * @returns a promise that resolves after given amount of time, and is interruptable with an abort signal. 7 + */ 8 + export function sleep(ms: number, signal?: AbortSignal): Promise<void> { 9 + signal?.throwIfAborted() 10 + 11 + const { resolve, reject, promise } = Promise.withResolvers<void>() 12 + const timeout = setTimeout(resolve, ms) 13 + 14 + if (signal) { 15 + const abortHandler = () => { 16 + clearTimeout(timeout) 17 + reject(signal.reason) 18 + } 19 + 20 + signal.addEventListener('abort', abortHandler) 21 + promise.finally(() => { 22 + signal.removeEventListener('abort', abortHandler) 23 + }) 24 + } 25 + 26 + return promise 27 + }
+15 -18
src/common/breaker.js src/common/breaker.ts
··· 1 1 /** @module common/async */ 2 2 3 - import * as common_types from '#common/types.js' 3 + import { Callback } from "#common/types" 4 4 5 5 /** 6 6 * A Breaker, which allows creating wrapped functions which will only be executed before ··· 26 26 */ 27 27 export class Breaker { 28 28 29 - /** @type {undefined | common_types.VoidCallback} */ 30 - #onTripped 31 - 32 - /** @type {boolean} */ 33 - #tripped 29 + #tripped: boolean 30 + #onTripped?: () => void 34 31 35 32 /** 36 - * @param {common_types.VoidCallback} [onTripped] 33 + * @param [onTripped] 37 34 * an optional callback, called when the breaker is tripped, /before/ any wrapped functions. 38 35 */ 39 - constructor(onTripped) { 36 + constructor(onTripped?: () => void) { 40 37 this.#tripped = false 41 38 this.#onTripped = onTripped 42 39 } 43 40 44 - /** @returns {boolean} true if the breaker has already tripped */ 45 - tripped() { 41 + /** @returns true if the breaker has already tripped */ 42 + tripped(): boolean { 46 43 return this.#tripped 47 44 } 48 45 ··· 50 47 * wrap the given callback in a function that will trip the breaker before it's called. 51 48 * any subsequent calls to the wrapped function will be no-ops. 52 49 * 53 - * @param {common_types.Callback} fn the function to be wrapped in the breaker 54 - * @returns {common_types.Callback} a wrapped function, controlled by the breaker 50 + * @param fn the function to be wrapped in the breaker 51 + * @returns a wrapped function, controlled by the breaker 55 52 */ 56 - tripThen(fn) { 57 - return (...args) => { 53 + tripThen<CB extends Callback>(fn: CB): CB { 54 + return ((...args: Parameters<CB>): void => { 58 55 if (!this.#tripped) { 59 56 this.#tripped = true 60 57 ··· 62 59 this.#onTripped?.() 63 60 fn(...args) 64 61 } 65 - } 62 + }) as CB 66 63 } 67 64 68 65 /** ··· 72 69 * @param {common_types.Callback} fn the function to be wrapped in the breaker 73 70 * @returns {common_types.Callback} a wrapped function, controlled by the breaker 74 71 */ 75 - untilTripped(fn) { 76 - return (...args) => { 72 + untilTripped<CB extends Callback>(fn: CB): CB { 73 + return ((...args: Parameters<CB>): void => { 77 74 if (!this.#tripped) { 78 75 // TODO: if these throw, what to do? 79 76 fn(...args) 80 77 } 81 - } 78 + }) as CB 82 79 } 83 80 84 81 }
+26 -38
src/common/crypto/cipher.js src/common/crypto/cipher.ts
··· 6 6 const encrAlgo = { name: 'AES-GCM', length: 256 } 7 7 const deriveAlgo = { name: 'PBKDF2', hash: 'SHA-256' } 8 8 9 - /** 10 - * @private 11 - * @param {string} [s] a possibly undefined or null input string 12 - * @returns {string} either the input or a medium-resistent random string 13 - */ 14 - function orRandom(s) { 9 + function orRandom(s: string): string { 15 10 return s ?? nanoid(10) 16 11 } 17 12 18 - /** 19 - * @private 20 - * @param {string|Uint8Array} s a string or uint8array 21 - * @returns {Uint8Array} either the input or a medium-resistent random string 22 - */ 23 - function asUint8Array(s) { 13 + function asUint8Array(s: string | Uint8Array): Uint8Array { 24 14 if (s instanceof Uint8Array) { 25 15 return s 26 16 } 27 - 28 - if (typeof s === 'string') { 17 + else if (typeof s === 'string') { 29 18 return new TextEncoder().encode(s) 30 19 } 31 20 ··· 38 27 * 39 28 * @private 40 29 * 41 - * @param {string} passwordStr a password for derivation 42 - * @param {string} saltStr a salt for derivation 43 - * @param {string} nonceStr a nonce for derivation 44 - * @param {number} [iterations] number of iterations for pbkdf 45 - * @returns {Promise<CryptoKey>} the derived crypto key 30 + * @param passwordStr a password for derivation 31 + * @param saltStr a salt for derivation 32 + * @param nonceStr a nonce for derivation 33 + * @param [iterations] number of iterations for pbkdf 34 + * @returns the derived crypto key 46 35 */ 47 - async function deriveKey(passwordStr, saltStr, nonceStr, iterations = 100000) { 36 + async function deriveKey(passwordStr: string, saltStr: string, nonceStr: string, iterations: number = 100000): Promise<CryptoKey> { 48 37 const encoder = new TextEncoder() 49 38 50 39 const password = encoder.encode(`${passwordStr}-pass-cryptosystem`) ··· 79 68 * any missing parameter (password/salt/nonce) is replaced with a random value, 80 69 * but if a stable password/salt/nonce is given, the derived keys will be stable. 81 70 * 82 - * @param {string} [passwordStr] a password for derivation 83 - * @param {string} [saltStr] a salt for derivation 84 - * @param {string} [nonceStr] a nonce for derivation 85 - * @returns {Promise<Cipher>} the derived {@link Cipher} 71 + * @param [passwordStr] a password for derivation 72 + * @param [saltStr] a salt for derivation 73 + * @param [nonceStr] a nonce for derivation 74 + * @returns the derived {@link Cipher} 86 75 */ 87 - static async derive(passwordStr, saltStr, nonceStr) { 76 + static async derive(passwordStr: string, saltStr: string, nonceStr: string): Promise<Cipher> { 88 77 const cryptokey = await deriveKey(orRandom(passwordStr), orRandom(saltStr), orRandom(nonceStr)) 89 78 return new this(cryptokey) 90 79 } 91 80 92 - /** @type {CryptoKey} */ 93 - #cryptokey 81 + #cryptokey: CryptoKey 94 82 95 83 /** 96 84 * import a cipher from an aleady existing {@link CryptoKey}. 97 85 * does _not_ ensure that the imported key will work with our preferred encryption 98 86 * 99 - * @param {CryptoKey} cryptokey the key to import into a Cipher 87 + * @param cryptokey the key to import into a Cipher 100 88 */ 101 - constructor(cryptokey) { 89 + constructor(cryptokey: CryptoKey) { 102 90 this.#cryptokey = cryptokey 103 91 } 104 92 105 93 /** 106 - * @param {(string | Uint8Array)} data the data to encrypte 107 - * @returns {Promise<string>} a url-safe base64 encoded encrypted string. 94 + * @param data the data to encrypte 95 + * @returns a url-safe base64 encoded encrypted string. 108 96 */ 109 - async encrypt(data) { 97 + async encrypt(data: (string | Uint8Array)): Promise<string> { 110 98 const iv = crypto.getRandomValues(new Uint8Array(12)) 111 99 const encoded = asUint8Array(data) 112 100 const encrypted = await crypto.subtle.encrypt({ ...encrAlgo, iv }, this.#cryptokey, encoded) ··· 121 109 } 122 110 123 111 /** 124 - * @param {string} encryptedData a base64 encoded string, previously encrypted with this cipher. 125 - * @returns {Promise<string>} the decrypted output, decoded into utf-8 text. 112 + * @param encryptedData a base64 encoded string, previously encrypted with this cipher. 113 + * @returns the decrypted output, decoded into utf-8 text. 126 114 */ 127 - async decryptText(encryptedData) { 115 + async decryptText(encryptedData: string): Promise<string> { 128 116 const plainbytes = await this.decryptBytes(encryptedData) 129 117 return new TextDecoder().decode(plainbytes) 130 118 } 131 119 132 120 /** 133 - * @param {string} encryptedData a base64 encoded string, previously encrypted with this cipher. 134 - * @returns {Promise<ArrayBuffer>} the decrypted output, as an array buffer of bytes. 121 + * @param encryptedData a base64 encoded string, previously encrypted with this cipher. 122 + * @returns the decrypted output, as an array buffer of bytes. 135 123 */ 136 - async decryptBytes(encryptedData) { 124 + async decryptBytes(encryptedData: string): Promise<ArrayBuffer> { 137 125 const combined = base64url.decode(encryptedData) 138 126 139 127 // Extract IV and encrypted data (which includes auth tag)
-18
src/common/crypto/errors.js
··· 1 - import { BaseError } from '#common/errors.js' 2 - import * as errors_types from '#common/errors.js' 3 - 4 - /** Common base class for errors in the crypto module */ 5 - export class CryptoError extends BaseError { 6 - } 7 - 8 - /** Thrown when failing to verify a JWT signature */ 9 - export class JWTBadSignatureError extends CryptoError { 10 - 11 - /** 12 - * @param {errors_types.BaseErrorOpts} [options] options to pass upstream 13 - */ 14 - constructor(options) { 15 - super('could not verify token signature', options) 16 - } 17 - 18 - }
+13
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 + }
+9 -16
src/common/crypto/jwks.js src/common/crypto/jwks.ts
··· 27 27 * 28 28 * @see https://www.rfc-editor.org/rfc/rfc7517 29 29 * @see https://github.com/panva/jose/blob/main/src/types.d.ts#L2 30 - * @type {z.ZodType<JWK>} 31 30 */ 32 - export const jwkSchema = z.union([ 31 + export const jwkSchema: z.ZodType<jose.JWK> = z.union([ 33 32 jwkEcPublicSchema, 34 33 jwkEcPrivateSchema, 35 34 ]) 36 35 37 36 /** 38 37 * zod transform from JWK to CryptoKey 39 - * 40 - * @type {z.ZodTransform<CryptoKey, JWK>} 41 38 */ 42 - export const jwkImport = z.transform(async (val, ctx) => { 39 + export const jwkImport: z.ZodTransform<CryptoKey, jose.JWK> = z.transform(async (val, ctx) => { 43 40 try { 44 41 if (typeof val === 'object' && val !== null) { 45 42 const key = await jose.importJWK(val, joseSignAlgo.name) ··· 72 69 return z.NEVER 73 70 }) 74 71 75 - /** 76 - * zod transform from exportable CryptoKey to JWK 77 - * 78 - * @type {z.ZodTransform<JWK, CryptoKey>} 79 - */ 80 - export const jwkExport = z.transform(async (val, ctx) => { 72 + /** zod transform from exportable CryptoKey to JWK */ 73 + export const jwkExport: z.ZodTransform<jose.JWK, CryptoKey> = z.transform(async (val, ctx) => { 81 74 try { 82 75 if (val.extractable) { 83 76 return await jose.exportJWK(val) ··· 101 94 }) 102 95 103 96 /** 104 - * @returns {Promise<CryptoKeyPair>} a newly generated, signing compatible keypair 97 + * @returns a newly generated, signing compatible keypair 105 98 */ 106 - export async function generateSigningJwkPair() { 99 + export async function generateSigningJwkPair(): Promise<CryptoKeyPair> { 107 100 const pair = await crypto.subtle.generateKey(subtleSignAlgo, true, ['sign', 'verify']) 108 101 if (!('publicKey' in pair)) 109 102 throw new CryptoError('keypair returned a single key!?') ··· 112 105 } 113 106 114 107 /** 115 - * @param {jose.JWTPayload} payload the payload to sign 116 - * @returns {jose.SignJWT} a properly configured jwt signer, with the payload provided 108 + * @param payload the payload to sign 109 + * @returns a properly configured jwt signer, with the payload provided 117 110 */ 118 - export function generateSignableJwt(payload) { 111 + export function generateSignableJwt(payload: jose.JWTPayload): jose.SignJWT { 119 112 return new jose.SignJWT(payload) 120 113 .setProtectedHeader({ alg: joseSignAlgo.name }) 121 114 }
-102
src/common/crypto/jwts.js
··· 1 - /** @module common/crypto */ 2 - 3 - import * as jose from 'jose' 4 - import { z } from 'zod/v4' 5 - import { JWTBadSignatureError } from '#common/crypto/errors.js' 6 - import { normalizeError } from '#common/errors.js' 7 - 8 - const signAlgo = { name: 'ES256' } 9 - 10 - /** 11 - * @typedef {object} JWTToken 12 - * @property {string} token the still-encoded JWT, for later verification 13 - * @property {jose.JWTPayload} claims the decoded JWT payload, for later verification 14 - */ 15 - 16 - /** 17 - * @template T 18 - * @typedef {object} JWTTokenPayload 19 - * @property {string} token the still-encoded JWT, for later verification 20 - * @property {jose.JWTPayload} claims the decoded JWT payload, for later verification 21 - * @property {T} payload the validate payload type, extracted from a payloadkey 22 - */ 23 - 24 - /** 25 - * schema describing a decoded JWT. 26 - * **important** - this does no claims validation, only decoding from string to JWT! 27 - * 28 - * @type {z.ZodType<JWTToken, string>} 29 - */ 30 - export const jwtSchema = z.jwt({ abort: true }).transform((token, ctx) => { 31 - try { 32 - const claims = jose.decodeJwt(token) 33 - return { claims, token } 34 - } 35 - catch (e) { 36 - ctx.issues.push({ 37 - code: 'custom', 38 - message: `error while decoding token: ${e}`, 39 - input: token, 40 - }) 41 - 42 - return z.NEVER 43 - } 44 - }) 45 - 46 - /** 47 - * schema describing a verified payload in a JWT. 48 - * **important** - this does no claims validation, only decoding from string to JWT! 49 - * 50 - * @template T 51 - * @param {z.ZodType<T>} schema the schema to extract from the payload 52 - * @returns {z.ZodType<JWTTokenPayload<T>>} transformer 53 - */ 54 - export const jwtPayload = (schema) => { 55 - const parser = z.looseObject({ payload: schema }) 56 - 57 - return jwtSchema.transform(async (payload, ctx) => { 58 - const result = await parser.safeParseAsync(payload.claims) 59 - if (result.success) 60 - return { ...payload, payload: result.data.payload } 61 - 62 - result.error.issues.forEach((iss) => { 63 - ctx.issues.push(iss) 64 - }) 65 - 66 - return z.NEVER 67 - }) 68 - } 69 - 70 - /** @typedef {Partial<Omit<jose.JWTVerifyOptions, 'algorithms'>>} VerifyOpts */ 71 - 72 - /** 73 - * @param {string} jwt the (still encoded) token to verify 74 - * @param {CryptoKey} pubkey the key with which to verify the token 75 - * @param {VerifyOpts} [options] the key with which to verify the token 76 - * @returns {Promise<jose.JWTPayload>} a verified payload 77 - * @throws {JWTBadSignatureError} if the signature is not valid 78 - */ 79 - export async function verifyJwtToken(jwt, pubkey, options = {}) { 80 - try { 81 - const result = await jose.jwtVerify(jwt, pubkey, { 82 - algorithms: [signAlgo.name], 83 - ...options, 84 - }) 85 - 86 - return result.payload 87 - } 88 - catch (exc) { 89 - const err = normalizeError(exc) 90 - throw new JWTBadSignatureError({ cause: err }) 91 - } 92 - } 93 - 94 - /** 95 - * generate a fingerprint for the given crypto key 96 - * 97 - * @param {CryptoKey} key the key to fingerprint 98 - * @returns {Promise<string>} the sha256 fingerprint of the key 99 - */ 100 - export async function fingerprintKey(key) { 101 - return await jose.calculateJwkThumbprint(key, 'sha256') 102 - }
+93
src/common/crypto/jwts.ts
··· 1 + /** @module common/crypto */ 2 + 3 + import * as jose from 'jose' 4 + import { z } from 'zod/v4' 5 + import { JWTBadSignatureError } from '#common/crypto/errors' 6 + import { normalizeError } from '#common/errors' 7 + 8 + const signAlgo = { name: 'ES256' } 9 + 10 + interface JWTToken { 11 + token: string 12 + claims: jose.JWTPayload 13 + } 14 + 15 + interface JWTTokenPayload<T> { 16 + token: string 17 + claims: jose.JWTPayload 18 + payload: T 19 + } 20 + 21 + /** 22 + * schema describing a decoded JWT. 23 + * **important** - this does no claims validation, only decoding from string to JWT! 24 + */ 25 + export const jwtSchema: z.ZodType<JWTToken, string> = z.jwt({ abort: true }).transform((token, ctx) => { 26 + try { 27 + const claims = jose.decodeJwt(token) 28 + return { claims, token } 29 + } 30 + catch (e) { 31 + ctx.issues.push({ 32 + code: 'custom', 33 + message: `error while decoding token: ${e}`, 34 + input: token, 35 + }) 36 + 37 + return z.NEVER 38 + } 39 + }) 40 + 41 + /** 42 + * schema describing a verified payload in a JWT. 43 + * **important** - this does no claims validation, only decoding from string to JWT! 44 + */ 45 + export const jwtPayload = <T>(schema: z.ZodType<T>): z.ZodType<JWTTokenPayload<T>> => { 46 + const parser = z.looseObject({ payload: schema }) 47 + 48 + return jwtSchema.transform(async (payload, ctx) => { 49 + const result = await parser.safeParseAsync(payload.claims) 50 + if (result.success) 51 + return { ...payload, payload: result.data.payload } 52 + 53 + result.error.issues.forEach((iss) => { 54 + ctx.issues.push(iss) 55 + }) 56 + 57 + return z.NEVER 58 + }) 59 + } 60 + 61 + type VerifyOpts = Partial<Omit<jose.JWTVerifyOptions, 'algorithms'>> 62 + 63 + /** 64 + * @param jwt the (still encoded) token to verify 65 + * @param pubkey the key with which to verify the token 66 + * @param [options] the key with which to verify the token 67 + * @returns a verified payload 68 + * @throws if the signature is not valid 69 + */ 70 + export async function verifyJwtToken(jwt: string, pubkey: CryptoKey, options: VerifyOpts = {}): Promise<jose.JWTPayload> { 71 + try { 72 + const result = await jose.jwtVerify(jwt, pubkey, { 73 + algorithms: [signAlgo.name], 74 + ...options, 75 + }) 76 + 77 + return result.payload 78 + } 79 + catch (exc) { 80 + const err = normalizeError(exc) 81 + throw new JWTBadSignatureError({ cause: err }) 82 + } 83 + } 84 + 85 + /** 86 + * generate a fingerprint for the given crypto key 87 + * 88 + * @param key the key to fingerprint 89 + * @returns the sha256 fingerprint of the key 90 + */ 91 + export async function fingerprintKey(key: CryptoKey): Promise<string> { 92 + return await jose.calculateJwkThumbprint(key, 'sha256') 93 + }
-127
src/common/errors.js
··· 1 - /** @module common */ 2 - 3 - import { prettifyError, ZodError } from 'zod/v4' 4 - 5 - /** 6 - * @private 7 - * @type {Record<number, string>} 8 - */ 9 - const StatusCodes = { 10 - 400: 'Bad Request', 11 - 401: 'Unauthorized', 12 - 403: 'Forbidden', 13 - 404: 'Not Found', 14 - 408: 'Request Timeout', 15 - 409: 'Conflict', 16 - 429: 'Too Many Requests', 17 - 499: 'Client Closed Request', 18 - 500: 'Internal Server Error', 19 - } 20 - 21 - /** 22 - * @typedef {object} BaseErrorOpts 23 - * @property {Error | undefined} cause the cause of the error. 24 - */ 25 - 26 - /** 27 - * Common base class for Skypod Errors 28 - * only difference is that we explicitly type cause to be Error 29 - * 30 - * @augments Error 31 - * @property {Error | undefined} cause the cause of the error. 32 - */ 33 - // cause is called out in order to get a known type 34 - export class BaseError extends Error { 35 - 36 - /** 37 - * @param {string} message a "details" message representing this error 38 - * @param {BaseErrorOpts} [options] a previous error we're wrapping 39 - */ 40 - constructor(message, options) { 41 - super(message, options) 42 - 43 - if (options?.cause) 44 - this.cause = normalizeError(options.cause) 45 - } 46 - 47 - } 48 - 49 - /** 50 - * Common base class for Websocket Errors 51 - * 52 - * @augments BaseError 53 - */ 54 - export class ProtocolError extends BaseError { 55 - 56 - /** 57 - * @param {string} message a "details" message representing this error 58 - * @param {number} status the HTTP status code representing this error 59 - * @param {BaseErrorOpts} [options] a previous error we're wrapping 60 - */ 61 - constructor(message, status, options) { 62 - const statusText = StatusCodes[status] || 'Unknown' 63 - super(`${status} ${statusText}: ${message}`, options) 64 - 65 - this.name = this.constructor.name 66 - this.status = status 67 - } 68 - 69 - } 70 - 71 - /** 72 - * @param {Error} e the error to check 73 - * @param {number} [status] the protocol status to check, or any protocol error if undefined. 74 - * @returns {boolean} true if the given error is a protocol error with the given status. 75 - */ 76 - export function isProtocolError(e, status) { 77 - return (e instanceof ProtocolError && (status === undefined || e.status == status)) 78 - } 79 - 80 - /** 81 - * Normalizes the given error into a protocol error; passes through input that is already 82 - * protocol errors. 83 - * 84 - * @param {unknown} cause some caught error 85 - * @returns {ProtocolError} an apporpriate protocol error 86 - */ 87 - export function normalizeProtocolError(cause) { 88 - if (cause instanceof ProtocolError) 89 - return cause 90 - 91 - if (cause instanceof ZodError) 92 - return new ProtocolError(prettifyError(cause), 400, { cause }) 93 - 94 - if (cause instanceof Error || cause instanceof DOMException) { 95 - if (cause.name === 'TimeoutError') 96 - return new ProtocolError('operation timed out', 408, { cause }) 97 - 98 - if (cause.name === 'AbortError') 99 - return new ProtocolError('operation was aborted', 499, { cause }) 100 - 101 - return new ProtocolError(cause.message, 500, { cause }) 102 - } 103 - 104 - // fallback, unknown 105 - const options = cause == undefined ? undefined : { cause: normalizeError(cause) } 106 - return new ProtocolError(`Error! ${cause}`, 500, options) 107 - } 108 - 109 - /** 110 - * Error wrapper for unknown errors (not an Error?) 111 - * 112 - * @augments Error 113 - */ 114 - export class NormalizedError extends Error {} 115 - 116 - /** 117 - * wrap the given failure error into an error; passes through input that is already an Error object. 118 - * 119 - * @param {unknown} failure some caught error 120 - * @returns {Error} an apporpriate error 121 - */ 122 - export function normalizeError(failure) { 123 - if (failure instanceof Error) 124 - return failure 125 - 126 - return new NormalizedError(`unnormalized failure ${failure}`) 127 - }
+1 -1
src/common/errors.spec.js src/common/errors.spec.ts
··· 4 4 ProtocolError, 5 5 isProtocolError, 6 6 normalizeProtocolError, 7 - } from '#common/errors.js' 7 + } from '#common/errors' 8 8 9 9 describe('isProtocolError', () => { 10 10 it('identifies protocol errors', () => {
+99
src/common/errors.ts
··· 1 + /** @module common */ 2 + 3 + import { prettifyError, ZodError } from 'zod/v4' 4 + 5 + const StatusCodes: Record<number, string> = { 6 + 400: 'Bad Request', 7 + 401: 'Unauthorized', 8 + 403: 'Forbidden', 9 + 404: 'Not Found', 10 + 408: 'Request Timeout', 11 + 409: 'Conflict', 12 + 429: 'Too Many Requests', 13 + 499: 'Client Closed Request', 14 + 500: 'Internal Server Error', 15 + } 16 + 17 + /** Base error options interface */ 18 + export interface BaseErrorOpts { 19 + /** the cause of the error */ 20 + cause?: Error 21 + } 22 + 23 + /** 24 + * Common base class for Skypod Errors 25 + * only difference is that we explicitly type cause to be Error 26 + */ 27 + export class BaseError extends Error { 28 + 29 + /** the cause of the error */ 30 + declare cause?: Error 31 + 32 + constructor(message: string, options?: BaseErrorOpts) { 33 + super(message, options) 34 + if (options?.cause) 35 + this.cause = normalizeError(options.cause) 36 + } 37 + 38 + } 39 + 40 + /** Common base class for Websocket Errors */ 41 + export class ProtocolError extends BaseError { 42 + 43 + /** the HTTP status code representing this error */ 44 + status: number 45 + 46 + constructor(message: string, status: number, options?: BaseErrorOpts) { 47 + const statusText = StatusCodes[status] || 'Unknown' 48 + super(`${status} ${statusText}: ${message}`, options) 49 + 50 + this.name = this.constructor.name 51 + this.status = status 52 + } 53 + 54 + } 55 + 56 + /** Check if an error is a protocol error with optional status check */ 57 + export function isProtocolError(e: Error, status?: number): e is ProtocolError { 58 + return (e instanceof ProtocolError && (status === undefined || e.status == status)) 59 + } 60 + 61 + /** 62 + * Normalizes the given error into a protocol error 63 + * passes through input that is already protocol errors. 64 + */ 65 + export function normalizeProtocolError(cause: unknown): ProtocolError { 66 + if (cause instanceof ProtocolError) 67 + return cause 68 + 69 + if (cause instanceof ZodError) 70 + return new ProtocolError(prettifyError(cause), 400, { cause }) 71 + 72 + if (cause instanceof Error || cause instanceof DOMException) { 73 + if (cause.name === 'TimeoutError') 74 + return new ProtocolError('operation timed out', 408, { cause }) 75 + 76 + if (cause.name === 'AbortError') 77 + return new ProtocolError('operation was aborted', 499, { cause }) 78 + 79 + return new ProtocolError(cause.message, 500, { cause }) 80 + } 81 + 82 + // fallback, unknown 83 + const options = cause == undefined ? undefined : { cause: normalizeError(cause) } 84 + return new ProtocolError(`Error! ${cause}`, 500, options) 85 + } 86 + 87 + /** Error wrapper for unknown errors (not an Error?) */ 88 + export class NormalizedError extends Error {} 89 + 90 + /** 91 + * wrap the given failure error into an error 92 + * passes through input that is already an Error object. 93 + */ 94 + export function normalizeError(failure: unknown): Error { 95 + if (failure instanceof Error) 96 + return failure 97 + 98 + return new NormalizedError(`unnormalized failure ${failure}`) 99 + }
-28
src/common/protocol.js
··· 1 - /** @module common/protocol */ 2 - 3 - export * from './protocol/brands.js' 4 - export * from './protocol/messages.js' 5 - export * from './protocol/messages-preauth.js' 6 - export * from './protocol/messages-realm.js' 7 - 8 - import { z } from 'zod/v4' 9 - 10 - /** 11 - * A zod transformer for parsing Json 12 - * 13 - * @type {z.ZodTransform<object, string>} 14 - */ 15 - export const parseJson = z.transform((input, ctx) => { 16 - try { 17 - return JSON.parse(input) 18 - } 19 - catch { 20 - ctx.issues.push({ 21 - code: 'custom', 22 - input, 23 - message: 'input could not be parsed as JSON', 24 - }) 25 - 26 - return z.NEVER 27 - } 28 - })
+26
src/common/protocol.ts
··· 1 + /** @module common/protocol */ 2 + 3 + export * from './protocol/brands' 4 + export * from './protocol/messages' 5 + export * from './protocol/messages-preauth' 6 + export * from './protocol/messages-realm' 7 + 8 + import { z } from 'zod/v4' 9 + 10 + /** A zod transformer for parsing json */ 11 + export const parseJson: z.ZodTransform<unknown, string> = z.transform( 12 + (input, ctx) => { 13 + try { 14 + return JSON.parse(input) 15 + } 16 + catch { 17 + ctx.issues.push({ 18 + code: 'custom', 19 + input, 20 + message: 'input could not be parsed as JSON', 21 + }) 22 + 23 + return z.NEVER 24 + } 25 + } 26 + )
-8
src/common/protocol/brands.js
··· 1 - import { z as z_types } from 'zod/v4' 2 - import { Brand } from '#common/schema/brand.js' 3 - 4 - export const IdentBrand = new Brand('ident') 5 - /** @typedef {z_types.infer<typeof IdentBrand.schema>} IdentID */ 6 - 7 - export const RealmBrand = new Brand('realm') 8 - /** @typedef {z_types.infer<typeof RealmBrand.schema>} RealmID */
+9
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') 5 + export type IdentID = ReturnType<typeof IdentBrand.generate> 6 + 7 + const realm = Symbol('realm') 8 + export const RealmBrand = new Brand<typeof realm>(realm, 'realm') 9 + export type RealmID = ReturnType<typeof RealmBrand.generate>
+4 -4
src/common/protocol/messages-preauth.js src/common/protocol/messages-preauth.ts
··· 1 1 /** @module common/protocol */ 2 2 3 3 import { z } from 'zod/v4' 4 - import { jwkSchema } from '#common/crypto/jwks.js' 4 + import { jwkSchema } from '#common/crypto/jwks' 5 5 6 6 /** zod schema for `preauth.authn` message */ 7 7 export const preauthRegisterMessageSchema = z.object({ 8 8 msg: z.literal('preauth.register'), 9 9 pubkey: jwkSchema, 10 10 }) 11 - /** @typedef {z.infer<typeof preauthRegisterMessageSchema>} PreauthRegisterMessage */ 11 + export type PreauthRegisterMessage = z.infer<typeof preauthRegisterMessageSchema> 12 12 13 13 /** zod schema for `preauth.authn` message */ 14 14 export const preauthAuthnMessageSchema = z.object({ 15 15 msg: z.literal('preauth.authn'), 16 16 }) 17 - /** @typedef {z.infer<typeof preauthAuthnMessageSchema>} PreauthAuthnMessage */ 17 + export type PreauthAuthnMessage = z.infer<typeof preauthAuthnMessageSchema> 18 18 19 19 /** zod schema for any `preauth` messages */ 20 20 export const preauthMessageSchema = z.discriminatedUnion('msg', [ 21 21 preauthRegisterMessageSchema, 22 22 preauthAuthnMessageSchema, 23 23 ]) 24 - /** @typedef {z.infer<typeof preauthMessageSchema>} PreauthMessage */ 24 + export type PreauthMessage = z.infer<typeof preauthMessageSchema>
-63
src/common/protocol/messages-realm.js
··· 1 - import { z } from 'zod/v4' 2 - import { IdentBrand } from './brands.js' 3 - import { okResponseSchema } from './messages.js' 4 - 5 - /** 6 - * zod schema for `realm.broadcast` message 7 - * recipients = true, include self in broadcast 8 - * recipients = false, exclude self in broadcast (default) 9 - * recipients = [], use these exact recipients 10 - */ 11 - export const realmBroadcastMessageSchema = z.object({ 12 - msg: z.literal('realm.broadcast'), 13 - payload: z.any(), 14 - recipients: z.union([ 15 - z.boolean(), 16 - z.array(IdentBrand.schema), 17 - ]).default(false), 18 - }) 19 - /** @typedef {z.infer<typeof realmBroadcastMessageSchema>} RealmBroadcastMessage */ 20 - 21 - // rtc messages 22 - 23 - export const realmRtcSignalMessageSchema = z.object({ 24 - msg: z.literal('realm.rtc.signal'), 25 - payload: z.string(), 26 - sender: IdentBrand.schema, 27 - recipient: IdentBrand.schema, 28 - initiator: z.boolean(), 29 - }) 30 - /** @typedef {z.infer<typeof realmRtcSignalMessageSchema>} RealmRtcSignalMessage */ 31 - 32 - export const realmRtcPeerWelcomeMessageSchema = okResponseSchema.extend({ 33 - msg: z.literal('realm.rtc.peer-welcome'), 34 - peers: z.array(IdentBrand.schema), 35 - }) 36 - /** @typedef {z.infer<typeof realmRtcPeerWelcomeMessageSchema>} RealmRtcPeerWelcomeMessage */ 37 - 38 - export const realmRtcPeerJoinedMessageSchema = okResponseSchema.extend({ 39 - msg: z.literal('realm.rtc.peer-joined'), 40 - identid: IdentBrand.schema, 41 - }) 42 - /** @typedef {z.infer<typeof realmRtcPeerJoinedMessageSchema>} RealmRtcPeerJoinedMessage */ 43 - 44 - export const realmRtcPeerLeftMessageSchema = okResponseSchema.extend({ 45 - msg: z.literal('realm.rtc.peer-left'), 46 - identid: IdentBrand.schema, 47 - }) 48 - /** @typedef {z.infer<typeof realmRtcPeerLeftMessageSchema>} RealmRtcPeerLeftMessage */ 49 - 50 - /// useful unions 51 - 52 - export const realmToServerMessageSchema = z.discriminatedUnion('msg', [ 53 - realmBroadcastMessageSchema, 54 - realmRtcSignalMessageSchema, 55 - ]) 56 - /** @typedef {z.infer<typeof realmToServerMessageSchema>} RealmToServerMessage */ 57 - 58 - export const realmFromServerMessageSchema = z.discriminatedUnion('msg', [ 59 - realmRtcPeerJoinedMessageSchema, 60 - realmRtcPeerLeftMessageSchema, 61 - realmRtcSignalMessageSchema, 62 - ]) 63 - /** @typedef {z.infer<typeof realmFromServerMessageSchema>} RealmFromServerMessage */
+68
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 8 + * 9 + * recipients = true, include self in broadcast 10 + * recipients = false, exclude self in broadcast (default) 11 + * recipients = [], use these exact recipients 12 + */ 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> 23 + 24 + // rtc messages 25 + 26 + export const realmRtcSignalMessageSchema = z.object({ 27 + msg: z.literal('realm.rtc.signal'), 28 + payload: z.string(), 29 + sender: IdentBrand.schema, 30 + recipient: IdentBrand.schema, 31 + initiator: z.boolean(), 32 + }) 33 + 34 + export const realmRtcPeerWelcomeMessageSchema = responseOkSchema.extend({ 35 + msg: z.literal('realm.rtc.peer-welcome'), 36 + peers: z.array(IdentBrand.schema), 37 + }) 38 + 39 + export const realmRtcPeerJoinedMessageSchema = responseOkSchema.extend({ 40 + msg: z.literal('realm.rtc.peer-joined'), 41 + identid: IdentBrand.schema, 42 + }) 43 + 44 + export const realmRtcPeerLeftMessageSchema = responseOkSchema.extend({ 45 + msg: z.literal('realm.rtc.peer-left'), 46 + identid: IdentBrand.schema, 47 + }) 48 + 49 + export type RealmRtcSignalMessage = z.infer<typeof realmRtcSignalMessageSchema> 50 + export type RealmRtcPeerWelcomeMessage = z.infer<typeof realmRtcPeerWelcomeMessageSchema> 51 + export type RealmRtcPeerJoinedMessage = z.infer<typeof realmRtcPeerJoinedMessageSchema> 52 + export type RealmRtcPeerLeftMessage = z.infer<typeof realmRtcPeerLeftMessageSchema> 53 + 54 + /// useful unions 55 + 56 + export const realmToServerMessageSchema = z.discriminatedUnion('msg', [ 57 + realmBroadcastMessageSchema, 58 + realmRtcSignalMessageSchema, 59 + ]) 60 + 61 + export const realmFromServerMessageSchema = z.discriminatedUnion('msg', [ 62 + realmRtcPeerJoinedMessageSchema, 63 + realmRtcPeerLeftMessageSchema, 64 + realmRtcSignalMessageSchema, 65 + ]) 66 + 67 + export type RealmToServerMessage = z.infer<typeof realmToServerMessageSchema> 68 + export type RealmFromServerMessage = z.infer<typeof realmFromServerMessageSchema>
-15
src/common/protocol/messages.js
··· 1 - import { z } from 'zod/v4' 2 - 3 - /** zod schema for `ok` message */ 4 - export const okResponseSchema = z.object({ 5 - ok: z.literal(true), 6 - }) 7 - /** @typedef {z.infer<typeof okResponseSchema>} OkResponse */ 8 - 9 - /** zod schema for `error` message */ 10 - export const errorResponseSchema = z.object({ 11 - ok: z.literal(false), 12 - status: z.number(), 13 - message: z.string(), 14 - }) 15 - /** @typedef {z.infer<typeof errorResponseSchema>} ErrorResponse */
+15
src/common/protocol/messages.ts
··· 1 + import { z } from 'zod/v4' 2 + 3 + /** zod schema for `ok` message */ 4 + export const responseOkSchema = z.object({ 5 + ok: z.literal(true), 6 + }) 7 + export type ResponseOk = z.infer<typeof responseOkSchema> 8 + 9 + /** zod schema for `error` message */ 10 + export const responseErrorSchema = z.object({ 11 + ok: z.literal(false), 12 + status: z.number(), 13 + message: z.string(), 14 + }) 15 + export type ErrorResponse = z.infer<typeof responseErrorSchema>
-69
src/common/schema/brand.js
··· 1 - /** @module common/schema */ 2 - 3 - import { nanoid } from 'nanoid' 4 - import { z } from 'zod/v4' 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 { 11 - 12 - /** 13 - * @private 14 - * @typedef {z.core.$ZodBranded<z.ZodString, symbol>} BrandSchema 15 - * @typedef {z.infer<BrandSchema>} BrandId 16 - */ 17 - 18 - /** @type {string} */ 19 - #prefix 20 - 21 - /** @type {number} */ 22 - #length 23 - 24 - /** @type {BrandSchema} */ 25 - #schema 26 - 27 - /** 28 - * @param {string} prefix string prefix for the identifier, eg. "ident", "acct") 29 - * @param {number} [length] the length of the random identifier part 30 - */ 31 - constructor(prefix, length = 16) { 32 - const brand = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape regex chars 33 - const pattern = new RegExp(`^${brand}-[A-Za-z0-9_-]{${length.toString()}}$`) 34 - 35 - this.#prefix = brand 36 - this.#length = length 37 - this.#schema = z.string().regex(pattern).brand(Symbol(brand)) 38 - } 39 - 40 - get schema() { 41 - return this.#schema 42 - } 43 - 44 - /** @returns {BrandId} a generated identifier */ 45 - generate() { 46 - const id = nanoid(this.#length) 47 - return this.#schema.parse(`${this.#prefix}-${id}`) 48 - } 49 - 50 - /** 51 - * @param {unknown} input the input to validate 52 - * @returns {BrandId} a valid identifier 53 - * @throws {z.ZodError} if the input could not be parsed 54 - */ 55 - parse(input) { 56 - return this.#schema.parse(input) 57 - } 58 - 59 - /** 60 - * @param {unknown} input the input to validate 61 - * @returns {boolean} true if a valid identifier, false otherwise 62 - */ 63 - validate(input) { 64 - return input != null 65 - && typeof input === 'string' 66 - && this.#schema.safeParse(input).success 67 - } 68 - 69 - }
+45
src/common/schema/brand.ts
··· 1 + /** @module common/schema */ 2 + 3 + import { nanoid } from 'nanoid' 4 + import { z } from 'zod/v4' 5 + 6 + export type Branded<T, B> = T & { __brand: B } 7 + 8 + /** 9 + * A brand creates identifiers that are typesafe by construction, 10 + * and shouldn't be able to be passed to the wrong resource type. 11 + */ 12 + export class Brand<B extends symbol> { 13 + 14 + #prefix: string 15 + #length: number 16 + #schema 17 + 18 + constructor(brand: B, prefix: string, length = 16) { 19 + prefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // escape regex chars 20 + const pattern = new RegExp(`^${prefix}-[A-Za-z0-9_-]{${length.toString()}}$`) 21 + 22 + this.#prefix = prefix 23 + this.#length = length 24 + this.#schema = z.string().regex(pattern).brand(brand) 25 + } 26 + 27 + get schema(): z.ZodType<Branded<string, B>> { 28 + return this.#schema as unknown as z.ZodType<Branded<string, B>> 29 + } 30 + 31 + generate() { 32 + const id = nanoid(this.#length) 33 + return this.#schema.parse(`${this.#prefix}-${id}`) as Branded<string, B> 34 + } 35 + 36 + parse(input: unknown): Branded<string, B> { 37 + return this.#schema.parse(input) as Branded<string, B> 38 + } 39 + 40 + /** @return a boolean if the string is valid */ 41 + validate(input: string): input is Branded<string, B> { 42 + return input != null && typeof input === 'string' && this.#schema.safeParse(input).success 43 + } 44 + 45 + }
+42 -71
src/common/socket.js src/common/socket.ts
··· 1 1 /** @module common/socket */ 2 2 3 - import * as z_types from 'zod/v4' 4 - 5 - import { combineSignals } from '#common/async/aborts.js' 6 - import { BlockingAtom } from '#common/async/blocking-atom.js' 7 - import { BlockingQueue } from '#common/async/blocking-queue.js' 8 - import { Breaker } from '#common/breaker.js' 9 - import { ProtocolError } from '#common/errors.js' 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' 10 9 11 10 import { parseJson } from './protocol.js' 12 11 13 - /** 14 - * Send some data in JSON format down the wire. 15 - * 16 - * @param {WebSocket} ws the socket to send on 17 - * @param {unknown} data the data to send 18 - */ 19 - export function sendSocket(ws, data) { 12 + /** Send some data in JSON format down the wire. */ 13 + export function sendSocket(ws: WebSocket, data: unknown): void { 20 14 ws.send(JSON.stringify(data)) 21 15 } 22 16 ··· 35 29 * if (ws.readyState !== ws.CLOSED) 36 30 * ws.close(); 37 31 * } 38 - * 39 - * @param {WebSocket} ws the socket to read 40 - * @param {AbortSignal} [signal] an abort signal to cancel the block 41 - * @returns {Promise<unknown>} the message off the socket 42 32 */ 43 - export async function takeSocket(ws, signal) { 33 + export async function takeSocket(ws: WebSocket, signal?: AbortSignal): Promise<unknown> { 44 34 signal?.throwIfAborted() 45 35 46 36 const atom = new BlockingAtom() ··· 75 65 /** 76 66 * exactly take socket, but will additionally apply a json decoding 77 67 * 78 - * @template T the schema's type 79 - * @param {WebSocket} ws the socket to read 80 - * @param {z_types.ZodSchema<T>} schema an a schema to execute 81 - * @param {AbortSignal} [signal] an abort signal to cancel the block 82 - * @returns {Promise<T>} the message off the socket 68 + * @param ws the socket to read 69 + * @param schema an a schema to execute 70 + * @param [signal] an abort signal to cancel the block 71 + * @returns the message off the socket 83 72 */ 84 - export async function takeSocketJson(ws, schema, signal) { 73 + export async function takeSocketJson<T>(ws: WebSocket, schema: z.ZodSchema<T>, signal?: AbortSignal): Promise<T> { 85 74 const data = await takeSocket(ws, signal) 86 75 return parseJson.pipe(schema).parseAsync(data) 87 76 } 88 77 89 - /** 90 - * stream configuration options 91 - * 92 - * @typedef ConfigProps 93 - * @property {number} maxDepth max depth of the queue, after which messages are dropped 94 - * @property {AbortSignal} [signal] an abort signal to cancel the block 95 - */ 78 + /** stream configuration options */ 96 79 97 - /** @type {ConfigProps} */ 98 - export const STREAM_CONFIG_DEFAULT = { maxDepth: 1000 } 80 + interface ConfigProps { 81 + maxDepth: number 82 + signal?: AbortSignal 83 + } 84 + 85 + export const STREAM_CONFIG_DEFAULT: ConfigProps = { 86 + maxDepth: 1000 87 + } 99 88 100 89 // symbols for iteration protocol 90 + 101 91 const yield$ = Symbol('yield$') 102 92 const error$ = Symbol('error$') 103 - const end$ = Symbol('end$') 93 + const end$ = Symbol('end$') 94 + 95 + type StreamYield = 96 + | [typeof yield$, unknown] 97 + | [typeof error$, Error] 98 + | [typeof end$] 104 99 105 100 /** 106 101 * Given a websocket, stream messages off the socket as an async generator. ··· 119 114 * if (ws.readyState !== ws.CLOSED) 120 115 * ws.close(); 121 116 * } 122 - * 123 - * 124 - * 125 - * @param {WebSocket} ws the socket to read 126 - * @param {Partial<ConfigProps>} [config_] stream configuration to merge into defaults 127 - * @yields {unknown} messages from the socket 128 117 */ 129 - export async function* streamSocket(ws, config_) { 130 - const { signal, ...config } = { ...STREAM_CONFIG_DEFAULT, ...config_ } 118 + export async function* streamSocket(ws: WebSocket, config_?: Partial<ConfigProps>) { 119 + const { signal, ...config } = { ...STREAM_CONFIG_DEFAULT, ...(config_ || {}) } 131 120 signal?.throwIfAborted() 132 121 133 122 // await incoming messages without blocking 134 - /** @type {BlockingQueue<Array<*>>} */ 135 - const queue = new BlockingQueue(config.maxDepth) 123 + const queue = new BlockingQueue<StreamYield>(config.maxDepth) 136 124 137 125 // if true, we're ignoring incoming messages until we drop the queue 138 126 let inBackoffMode = false ··· 143 131 144 132 // define callback functions (need to be able to reference for removing them) 145 133 146 - /** @type {function(MessageEvent): void} */ 147 - const onMessage = breaker.untilTripped((m) => { 134 + const onMessage = breaker.untilTripped((m: MessageEvent) => { 148 135 if (inBackoffMode) { 149 136 console.warn('ignoring incoming message due to backpressure protection!') 150 137 return ··· 157 144 } 158 145 }) 159 146 160 - /** @type {function(Event): void} */ 161 147 // todo: why are we getting this on client shutdown instead of onClose? 162 - const onError = breaker.tripThen((e) => { 163 - if (e.message === 'Unexpected EOF') { 164 - queue.enqueue([end$]) 165 - } 166 - else { 167 - queue.enqueue([error$, e]) 168 - } 148 + const onError = breaker.tripThen((e: Event) => { 149 + queue.enqueue([error$, normalizeError(e)]) 169 150 }) 170 151 171 - /** @type {function(): void} */ 172 152 const onClose = breaker.tripThen(() => { 173 153 queue.enqueue([end$]) 174 154 }) ··· 209 189 /** 210 190 * exactly stream socket, but will additionally apply a json decoding 211 191 * messages not validating will end the stream with an error 212 - * 213 - * @param {WebSocket} ws the socket to read 214 - * @param {Partial<ConfigProps>} [config] stream configuration to merge into defaults 215 - * @returns {AsyncGenerator<unknown>} an async generator 216 - * @yields the message off the socket 217 192 */ 218 - export async function* streamSocketJson(ws, config) { 193 + export async function* streamSocketJson(ws: WebSocket, config?: Partial<ConfigProps>): AsyncGenerator<unknown> { 219 194 for await (const message of streamSocket(ws, config)) { 220 195 yield parseJson.parseAsync(message) 221 196 } ··· 224 199 /** 225 200 * exactly stream socket, but will additionally apply a json decoding 226 201 * messages not validating will end the stream with an error 227 - * 228 - * @template T the schema's type 229 - * @param {WebSocket} ws the socket to read 230 - * @param {z_types.ZodSchema<T>} schema an a schema to execute 231 - * @param {Partial<ConfigProps>} [config] stream configuration to merge into defaults 232 - * @returns {AsyncGenerator<T>} an async generator 233 - * @yields the message off the socket 234 202 */ 235 - export async function* streamSocketSchema(ws, schema, config) { 203 + export async function* streamSocketSchema<T>( 204 + ws: WebSocket, 205 + schema: z.ZodSchema<T>, 206 + config?: Partial<ConfigProps>, 207 + ): AsyncGenerator<T> { 236 208 const parser = parseJson.pipe(schema) 237 - 238 209 for await (const message of streamSocket(ws, config)) { 239 210 yield await parser.parseAsync(message) 240 211 }
-54
src/common/strict-map.js
··· 1 - /** @module common */ 2 - 3 - /** 4 - * A map with methods to ensure key presence and safe update. 5 - * 6 - * @template K, V 7 - * @augments {Map<K, V>} 8 - */ 9 - export class StrictMap extends Map { 10 - 11 - /** 12 - * @param {K} key to lookup in the map, throwing is missing 13 - * @returns {V} the value from the map 14 - * @throws {Error} if the key is not present in the map 15 - */ 16 - require(key) { 17 - if (!this.has(key)) throw Error(`key is required but not in the map`) 18 - 19 - const value = /** @type {V} */ (this.get(key)) 20 - return value 21 - } 22 - 23 - /** 24 - * @param {K} key to lookup in the map 25 - * @param {function(): V} maker a callback which will create the value in the map if not present. 26 - * @returns {V} the value from the map, possibly newly created by {maker} 27 - */ 28 - ensure(key, maker) { 29 - if (!this.has(key)) { 30 - this.set(key, maker()) 31 - } 32 - 33 - return /** @type {V} */ (this.get(key)) 34 - } 35 - 36 - /** 37 - * @param {K} key to update in the map 38 - * @param {function(V=): V | undefined} update 39 - * function which returns the new value for the map 40 - * if the return value is REMOVE_KEY, the whole entry in the map will be removed. 41 - */ 42 - update(key, update) { 43 - const prev = this.get(key) 44 - const next = update(prev) 45 - 46 - if (next === undefined) { 47 - this.delete(key) 48 - } 49 - else { 50 - this.set(key, next) 51 - } 52 - } 53 - 54 - }
+39
src/common/strict-map.ts
··· 1 + /** @module common */ 2 + 3 + /** A map with methods to ensure key presence and safe update. */ 4 + export class StrictMap<K, V> extends Map<K, V> { 5 + 6 + /** 7 + * Get a value from the map, throwing if missing 8 + * @throws {Error} if the key is not present in the map 9 + */ 10 + require(key: K): V { 11 + if (!this.has(key)) throw Error(`key is required but not in the map`) 12 + 13 + const value = this.get(key)! 14 + return value 15 + } 16 + 17 + /** Get a value from the map, creating it if not present */ 18 + ensure(key: K, maker: () => V): V { 19 + if (!this.has(key)) { 20 + this.set(key, maker()) 21 + } 22 + 23 + return this.get(key)! 24 + } 25 + 26 + /** Update a value in the map, removing if undefined is returned */ 27 + update(key: K, update: (prev?: V) => V | undefined): void { 28 + const prev = this.get(key) 29 + const next = update(prev) 30 + 31 + if (next === undefined) { 32 + this.delete(key) 33 + } 34 + else { 35 + this.set(key, next) 36 + } 37 + } 38 + 39 + }
+2 -4
src/common/types.js src/common/types.ts
··· 4 4 5 5 /** 6 6 * A callback function, with arbitrary arguments; use {Parameters} to extract them. 7 - * 8 - * @typedef {function(...*): void} Callback 9 7 */ 8 + export type Callback = (...args: any[]) => void 10 9 11 10 /** 12 11 * A callback function with no arguments. 13 - * 14 - * @typedef {function(): void} VoidCallback 15 12 */ 13 + export type VoidCallback = () => void 16 14 17 15 const output = NEVER 18 16 export default output
+7 -9
src/server/index.js src/server/index.ts
··· 2 2 import * as http from 'http' 3 3 import { WebSocketServer } from 'ws' 4 4 5 - import { apiRouter } from './routes-api/middleware.js' 6 - import { socketHandler } from './routes-socket/handler.js' 7 - import { makeStaticMiddleware, makeSpaMiddleware } from './routes-static.js' 8 - import { notFoundHandler } from './routes-error.js' 9 - 10 - /** @typedef {typeof http.IncomingMessage} Input */ 5 + import { apiRouter } from './routes-api/middleware' 6 + import { socketHandler } from './routes-socket/handler' 7 + import { makeStaticMiddleware, makeSpaMiddleware } from './routes-static' 8 + import { notFoundHandler } from './routes-error' 11 9 12 10 /** 13 11 * configures an http server which hosts the SPA and websocket endpoint 14 12 * 15 - * @param {string} root the path to the root public/ directory 16 - * @returns {http.Server<Input>} a configured server 13 + * @param root the path to the root public/ directory 14 + * @returns a configured server 17 15 */ 18 - export function buildServer(root) { 16 + export function buildServer(root: string): http.Server<typeof http.IncomingMessage> { 19 17 const app = express() 20 18 const server = http.createServer(app) 21 19
src/server/routes-api/middleware.js src/server/routes-api/middleware.ts
-7
src/server/routes-error.js
··· 1 - import * as express_types from 'express' 2 - 3 - /** @type {express_types.RequestHandler} */ 4 - export const notFoundHandler = (req, res) => { 5 - console.log(req.url) 6 - res.status(404).send('wut') 7 - }
+6
src/server/routes-error.ts
··· 1 + import * as express from 'express' 2 + 3 + export const notFoundHandler: express.RequestHandler = (req, res) => { 4 + console.log(req.url) 5 + res.status(404).send('wut') 6 + }
-66
src/server/routes-socket/handler-preauth.js
··· 1 - import { combineSignals, timeoutSignal } from '#common/async/aborts.js' 2 - import { jwkImport } from '#common/crypto/jwks.js' 3 - import { jwtPayload, verifyJwtToken } from '#common/crypto/jwts.js' 4 - import { normalizeError, ProtocolError } from '#common/errors.js' 5 - import { IdentBrand, preauthMessageSchema, RealmBrand } from '#common/protocol.js' 6 - import { takeSocket } from '#common/socket.js' 7 - 8 - import * as protocol_types from '#common/protocol.js' 9 - import * as realms from './state.js' 10 - 11 - /** 12 - * immediately after the socket connects, we up to 3 seconds for a valid authentication message. 13 - * - if the realm does not exist (by realm id), we create a new one, and add the identity (success). 14 - * - if the realm /does/ exist, we verify the message is a signed JWT our already registered pubkey. 15 - * 16 - * @param {WebSocket} ws the websocket we're controlling 17 - * @param {AbortSignal} [signal] a signal to abort blocking on the socket 18 - * @returns {Promise<realms.AuthenticatedConnection>} the now authenticated identity 19 - */ 20 - export async function preauthHandler(ws, signal) { 21 - const timeout = timeoutSignal(3000) 22 - const combinedSignal = combineSignals(signal, timeout.signal) 23 - 24 - try { 25 - const data = await takeSocket(ws, combinedSignal) 26 - 27 - // if any of the parsing fails, it'll throw a zod error 28 - const jwt = await jwtPayload(preauthMessageSchema).parseAsync(data) 29 - const identid = IdentBrand.parse(jwt.claims.iss) 30 - const realmid = RealmBrand.parse(jwt.claims.aud) 31 - 32 - // if we're registering, make sure the realm exists 33 - if (jwt.payload.msg === 'preauth.register') { 34 - const registrantkey = await jwkImport.parseAsync(jwt.payload.pubkey) 35 - realms.ensureRegisteredRealm(realmid, identid, registrantkey) 36 - } 37 - 38 - return authenticatePreauth(realmid, identid, jwt.token) 39 - } 40 - finally { 41 - timeout.cancel() 42 - } 43 - } 44 - 45 - /** 46 - * @param {protocol_types.RealmID} realmid the realm id to lookup 47 - * @param {protocol_types.IdentID} identid the identity id to authenticate against 48 - * @param {string} token the (still encoded) JWT to verify 49 - * @returns {Promise<realms.AuthenticatedConnection>} an authenticated connection from this token 50 - * @throws {ProtocolError} when the token isn't validly signed or the identity is unrecognized 51 - */ 52 - async function authenticatePreauth(realmid, identid, token) { 53 - try { 54 - const realm = realms.realmMap.require(realmid) 55 - const pubkey = realm.identities.require(identid) 56 - 57 - // at this point we no langer care about the payload 58 - // but this throws as a side-effect if the token is invalid 59 - await verifyJwtToken(token, pubkey) 60 - return { realmid, realm, identid, pubkey } 61 - } 62 - catch (exc) { 63 - const err = normalizeError(exc) 64 - throw new ProtocolError('jwt verification failed', 401, { cause: err }) 65 - } 66 - }
+54
src/server/routes-socket/handler-preauth.ts
··· 1 + import { combineSignals, timeoutSignal } from '#common/async/aborts' 2 + import { jwkImport } from '#common/crypto/jwks' 3 + import { jwtPayload, verifyJwtToken } from '#common/crypto/jwts' 4 + import { normalizeError, ProtocolError } from '#common/errors' 5 + import { IdentBrand, IdentID, preauthMessageSchema, RealmBrand, RealmID } from '#common/protocol' 6 + import { takeSocket } from '#common/socket' 7 + 8 + import * as realms from './state' 9 + 10 + /** 11 + * immediately after the socket connects, we up to 3 seconds for a valid authentication message. 12 + * - if the realm does not exist (by realm id), we create a new one, and add the identity (success). 13 + * - if the realm /does/ exist, we verify the message is a signed JWT our already registered pubkey. 14 + */ 15 + export async function preauthHandler(ws: WebSocket, signal?: AbortSignal): Promise<realms.AuthenticatedIdentity> { 16 + const timeout = timeoutSignal(3000) 17 + const combinedSignal = combineSignals(signal, timeout.signal) 18 + 19 + try { 20 + const data = await takeSocket(ws, combinedSignal) 21 + 22 + // if any of the parsing fails, it'll throw a zod error 23 + const jwt = await jwtPayload(preauthMessageSchema).parseAsync(data) 24 + const identid = IdentBrand.parse(jwt.claims.iss) 25 + const realmid = RealmBrand.parse(jwt.claims.aud) 26 + 27 + // if we're registering, make sure the realm exists 28 + if (jwt.payload.msg === 'preauth.register') { 29 + const registrantkey = await jwkImport.parseAsync(jwt.payload.pubkey) 30 + realms.ensureRegisteredRealm(realmid, identid, registrantkey) 31 + } 32 + 33 + return authenticatePreauth(realmid, identid, jwt.token) 34 + } 35 + finally { 36 + timeout.cancel() 37 + } 38 + } 39 + 40 + async function authenticatePreauth(realmid: RealmID, identid: IdentID, token: string): Promise<realms.AuthenticatedIdentity> { 41 + try { 42 + const realm = realms.realmMap.require(realmid) 43 + const pubkey = realm.identities.require(identid) 44 + 45 + // at this point we no langer care about the payload 46 + // but this throws as a side-effect if the token is invalid 47 + await verifyJwtToken(token, pubkey) 48 + return { realmid, realm, identid, pubkey } 49 + } 50 + catch (exc) { 51 + const err = normalizeError(exc) 52 + throw new ProtocolError('jwt verification failed', 401, { cause: err }) 53 + } 54 + }
+14 -36
src/server/routes-socket/handler-realm.js src/server/routes-socket/handler-realm.ts
··· 1 - import { normalizeProtocolError, ProtocolError } from '#common/errors.js' 2 - import { realmToServerMessageSchema, parseJson } from '#common/protocol.js' 3 - import { sendSocket, streamSocket } from '#common/socket.js' 1 + import { normalizeProtocolError, ProtocolError } from '#common/errors' 4 2 5 - import * as protocol_types from '#common/protocol.js' 6 - import * as realm_types from '#server/routes-socket/state.js' 3 + import * as protocol from '#common/protocol' 4 + import { sendSocket, streamSocket } from '#common/socket.js' 5 + import * as realm from '#server/routes-socket/state' 7 6 8 7 /** 9 8 * ance we've retrieved authentication details, we go into the main realm loop. 10 9 * read messages as they come in and dispatch actions. 11 - * 12 - * @param {WebSocket} ws the websockt we're handling 13 - * @param {realm_types.AuthenticatedConnection} auth the authenticated identity we're representing 14 - * @param {AbortSignal} [signal] an optional signal to abort the blocking loop 15 10 */ 16 - export async function realmHandler(ws, auth, signal) { 11 + export async function realmHandler(ws: WebSocket, auth: realm.AuthenticatedIdentity, signal?: AbortSignal) { 17 12 realmBroadcast(auth, buildRtcPeerJoined(auth)) 18 13 sendSocket(ws, buildRtcPeerWelcome(auth)) 19 14 15 + const parser = protocol.parseJson 16 + .pipe(protocol.realmToServerMessageSchema) 17 + 20 18 try { 21 19 for await (const data of streamSocket(ws, { signal })) { 22 20 try { 23 - const msg = await parseJson.pipe(realmToServerMessageSchema).parseAsync(data) 21 + const msg = await parser.parseAsync(data) 24 22 switch (msg.msg) { 25 23 case 'realm.broadcast': 26 24 realmBroadcast(auth, msg.payload, msg.recipients) ··· 51 49 } 52 50 } 53 51 54 - /** 55 - * @private 56 - * @param {realm_types.AuthenticatedConnection} auth the current identity 57 - * @returns {protocol_types.RealmRtcPeerWelcomeMessage} a realm welcome response 58 - */ 59 - function buildRtcPeerWelcome(auth) { 52 + function buildRtcPeerWelcome(auth: realm.AuthenticatedIdentity): protocol.RealmRtcPeerWelcomeMessage { 60 53 return { 61 54 ok: true, 62 55 msg: 'realm.rtc.peer-welcome', ··· 64 57 } 65 58 } 66 59 67 - /** 68 - * @private 69 - * @param {realm_types.AuthenticatedConnection} auth the current identity 70 - * @returns {protocol_types.RealmRtcPeerJoinedMessage} a realm status response 71 - */ 72 - function buildRtcPeerJoined(auth) { 60 + function buildRtcPeerJoined(auth: realm.AuthenticatedIdentity): protocol.RealmRtcPeerJoinedMessage { 73 61 return { 74 62 ok: true, 75 63 msg: 'realm.rtc.peer-joined', ··· 77 65 } 78 66 } 79 67 80 - /** 81 - * @private 82 - * @param {realm_types.AuthenticatedConnection} auth the current identity 83 - * @returns {protocol_types.RealmRtcPeerLeftMessage} a realm status response 84 - */ 85 - function buildRtcPeerLeft(auth) { 68 + function buildRtcPeerLeft(auth: realm.AuthenticatedIdentity): protocol.RealmRtcPeerLeftMessage { 86 69 return { 87 70 ok: true, 88 71 msg: 'realm.rtc.peer-left', ··· 90 73 } 91 74 } 92 75 93 - /** 94 - * @private 95 - * @param {ProtocolError} error the error to report 96 - * @returns {protocol_types.ErrorResponse} a realm error response 97 - */ 98 - function buildRealmError(error) { 76 + function buildRealmError(error: ProtocolError): protocol.ErrorResponse { 99 77 return { 100 78 ok: false, 101 79 message: error.message, ··· 112 90 * when false, send to the whole realm, excluding self 113 91 * when an array of recipients, send to those recipients explicitly 114 92 */ 115 - function realmBroadcast(auth, payload, recipients) { 93 + function realmBroadcast(auth: realm.AuthenticatedIdentity, payload: unknown, recipients: protocol.IdentID[] | boolean = false) { 116 94 const echo = recipients === true || Array.isArray(recipients) 117 95 const recips = Array.isArray(recipients) ? recipients : Array.from(auth.realm.identities.keys()) 118 96
+6 -10
src/server/routes-socket/handler.js src/server/routes-socket/handler.ts
··· 1 1 import { format } from 'node:util' 2 2 3 - import { normalizeError, normalizeProtocolError } from '#common/errors.js' 3 + import { normalizeError, normalizeProtocolError } from '#common/errors' 4 4 5 - import { preauthHandler } from './handler-preauth.js' 6 - import { realmHandler } from './handler-realm.js' 7 - import { attachSocket, detachSocket } from './state.js' 5 + import { preauthHandler } from './handler-preauth' 6 + import { realmHandler } from './handler-realm' 7 + import { attachSocket, detachSocket } from './state' 8 8 9 - /** 10 - * when the socket connects, we drive our protocol through handlers. 11 - * 12 - * @param {WebSocket} ws the websocket to control 13 - */ 14 - export async function socketHandler(ws) { 9 + /** when the socket connects, we drive our protocol through handlers. */ 10 + export async function socketHandler(ws: WebSocket) { 15 11 console.log('WebSocket connection established') 16 12 17 13 try {
-67
src/server/routes-socket/state.js
··· 1 - import { StrictMap } from '#common/strict-map.js' 2 - import * as protocol_types from '#common/protocol.js' 3 - 4 - /** 5 - * An authenticated identity; only handed out in response to successful authentication. 6 - * 7 - * @typedef {object} AuthenticatedConnection 8 - * @property {protocol_types.RealmID} realmid the realm this connection is associated with 9 - * @property {protocol_types.IdentID} identid the identity this connection is associated with 10 - * @property {Realm} realm the actual realm from the realm map 11 - * @property {CryptoKey} pubkey the connected identity's public key 12 - */ 13 - 14 - /** 15 - * @typedef {object} Realm 16 - * @property {protocol_types.RealmID} realmid the realm's id 17 - * @property {StrictMap<protocol_types.IdentID, Array<WebSocket>>} sockets 18 - * a map of identity id to open sockets. 19 - * @property {StrictMap<protocol_types.IdentID, CryptoKey>} identities 20 - * a map of identity id to the realm's known public key for that identity. 21 - */ 22 - 23 - /** @type {StrictMap<protocol_types.RealmID, Realm>} */ 24 - export const realmMap = new StrictMap() 25 - 26 - /** 27 - * gets or creates a registered realm, with the given identity and key 28 - * as initial registrants in a newly created realm. If the realm already 29 - * exists, it's not changed. 30 - * 31 - * @param {protocol_types.RealmID} realmid the realm id to ensure exists 32 - * @param {protocol_types.IdentID} registrantid the identity id of the registrant 33 - * @param {CryptoKey} registrantkey the public key of the registrant 34 - * @returns {Realm} a registered realm, possibly newly created with the registrant 35 - */ 36 - export function ensureRegisteredRealm(realmid, registrantid, registrantkey) { 37 - const realm = realmMap.ensure(realmid, () => ({ 38 - realmid, 39 - sockets: new StrictMap(), 40 - identities: new StrictMap([[registrantid, registrantkey]]), 41 - })) 42 - 43 - // hack for now, allow any registration to work 44 - realm.identities.ensure(registrantid, () => registrantkey) 45 - return realm 46 - } 47 - 48 - /** 49 - * @param {Realm} realm the realm on which to attach the socket 50 - * @param {protocol_types.IdentID} ident the identity which owns the socket 51 - * @param {WebSocket} socket the socket to attach 52 - */ 53 - export function attachSocket(realm, ident, socket) { 54 - realm.sockets.update(ident, ss => (ss ? [...ss, socket] : [socket])) 55 - } 56 - 57 - /** 58 - * @param {Realm} realm the realm from which to detach the socket 59 - * @param {protocol_types.IdentID} ident the identity which owns the socket 60 - * @param {WebSocket} socket the socket to dettach 61 - */ 62 - export function detachSocket(realm, ident, socket) { 63 - realm.sockets.update(ident, (sockets) => { 64 - const next = sockets?.filter(s => s !== socket) 65 - return next?.length ? next : undefined 66 - }) 67 - }
+51
src/server/routes-socket/state.ts
··· 1 + import { IdentID, RealmID } from '#common/protocol.js' 2 + import { StrictMap } from '#common/strict-map' 3 + 4 + /** An authenticated identity; only handed out in response to successful authentication. */ 5 + export interface AuthenticatedIdentity { 6 + realm: Realm 7 + realmid: RealmID 8 + identid: IdentID 9 + pubkey: CryptoKey 10 + } 11 + 12 + export interface Realm { 13 + realmid: RealmID 14 + sockets: StrictMap<IdentID, WebSocket[]> 15 + identities: StrictMap<IdentID, CryptoKey> 16 + } 17 + 18 + export const realmMap = new StrictMap<RealmID, Realm>() 19 + 20 + /** 21 + * gets or creates a registered realm, with the given identity and key 22 + * as initial registrants in a newly created realm. If the realm already 23 + * exists, it's not changed. 24 + * 25 + * @param realmid the realm id to ensure exists 26 + * @param registrantid the identity id of the registrant 27 + * @param registrantkey the public key of the registrant 28 + * @returns a registered realm, possibly newly created with the registrant 29 + */ 30 + export function ensureRegisteredRealm(realmid: RealmID, registrantid: IdentID, registrantkey: CryptoKey): Realm { 31 + const realm = realmMap.ensure(realmid, () => ({ 32 + realmid, 33 + sockets: new StrictMap(), 34 + identities: new StrictMap([[registrantid, registrantkey]]), 35 + })) 36 + 37 + // hack for now, allow any registration to work 38 + realm.identities.ensure(registrantid, () => registrantkey) 39 + return realm 40 + } 41 + 42 + export function attachSocket(realm: Realm, ident: IdentID, socket: WebSocket) { 43 + realm.sockets.update(ident, ss => (ss ? [...ss, socket] : [socket])) 44 + } 45 + 46 + export function detachSocket(realm: Realm, ident: IdentID, socket: WebSocket) { 47 + realm.sockets.update(ident, (sockets) => { 48 + const next = sockets?.filter(s => s !== socket) 49 + return next?.length ? next : undefined 50 + }) 51 + }
-35
src/server/routes-static.js
··· 1 - import * as express_types from 'express' 2 - import express from 'express' 3 - import { join } from 'path' 4 - 5 - /** 6 - * @typedef {object} StaticOpts 7 - * @property {string} root the root directory to staticly server 8 - * @property {string} index the index file to use if given a directory or spa path 9 - */ 10 - 11 - /** 12 - * returns a configured static middleware 13 - * 14 - * @param {StaticOpts} opts options for corfiguring the middleware 15 - * @returns {express_types.RequestHandler} a new middleware 16 - */ 17 - export function makeStaticMiddleware(opts) { 18 - return express.static(opts.root, { index: opts.index }) 19 - } 20 - 21 - /** 22 - * returns the index file for any GET request for text/html it matches 23 - * 24 - * @param {StaticOpts} opts options for configuring the middleware 25 - * @returns {express_types.RequestHandler} a new middleware 26 - */ 27 - export function makeSpaMiddleware(opts) { 28 - return (req, res, next) => { 29 - if (req.method === 'GET' && req.accepts('text/html')) { 30 - return res.sendFile(join(opts.root, opts.index)) 31 - } 32 - 33 - next() // otherwise 34 - } 35 - }
+33
src/server/routes-static.ts
··· 1 + import express from 'express' 2 + import { join } from 'path' 3 + 4 + interface StaticOpts { 5 + root: string 6 + index: string 7 + } 8 + 9 + /** 10 + * returns a configured static middleware 11 + * 12 + * @param opts options for corfiguring the middleware 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 + /** 20 + * returns the index file for any GET request for text/html it matches 21 + * 22 + * @param opts options for configuring the middleware 23 + * @returns a new middleware 24 + */ 25 + export function makeSpaMiddleware(opts: StaticOpts): express.RequestHandler { 26 + return (req, res, next) => { 27 + if (req.method === 'GET' && req.accepts('text/html')) { 28 + return res.sendFile(join(opts.root, opts.index)) 29 + } 30 + 31 + next() // otherwise 32 + } 33 + }
+3 -4
tsconfig.json
··· 1 1 { 2 2 "compilerOptions": { 3 - // Type checking for JavaScript files 4 - "allowJs": true, 5 - "checkJs": true, 3 + // tsx compatibility 4 + "moduleDetection": "force", 6 5 "noEmit": true, 7 6 8 7 // Modern JavaScript support 9 8 "target": "es2024", 10 - "module": "ESNext", 9 + "module": "Preserve", 11 10 "moduleResolution": "bundler", 12 11 "allowSyntheticDefaultImports": true, 13 12 "esModuleInterop": true,