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