+3
-3
jest.config.js
+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
+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
+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
-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
+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
+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
+3
-2
src/client/index.jsx
src/client/index.tsx
-13
src/client/page-app.jsx
-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
-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
+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
+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
-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
+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
+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
+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
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
-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
+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
-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
+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
+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
+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
-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
+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
+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
+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
-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
+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
+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
-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
+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
-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
+1
-1
src/common/errors.spec.js
src/common/errors.spec.ts
+99
src/common/errors.ts
+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
-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
+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
-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
+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
+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
-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
+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
-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
+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
-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
+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
+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
-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
+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
+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
+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
src/server/routes-api/middleware.js
src/server/routes-api/middleware.ts
-7
src/server/routes-error.js
-7
src/server/routes-error.js
+6
src/server/routes-error.ts
+6
src/server/routes-error.ts
-66
src/server/routes-socket/handler-preauth.js
-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
+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
+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
+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
-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
+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
-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
+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
+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,