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