@greendrake/rpc-server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +150 -0
- package/package.json +45 -0
- package/src/arguments.ts +33 -0
- package/src/auth.ts +29 -0
- package/src/dispatcher.ts +75 -0
- package/src/errors.ts +19 -0
- package/src/http.ts +62 -0
- package/src/main.ts +10 -0
- package/src/nonce.ts +28 -0
- package/src/registry.ts +131 -0
- package/src/types.ts +125 -0
- package/src/ws.ts +144 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eugene Greendrake
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# @greendrake/rpc-server
|
|
2
|
+
|
|
3
|
+
The server half of the [`@greendrake/rpc`](../rpc) wire format: one dispatch table, an HTTP endpoint and a WebSocket endpoint over it, per-method auth and argument validation, and a connection registry that pushes to clients unasked. **Runs on [bun](https://bun.sh)** — `Bun.serve` owns the sockets. The protocol core (dispatch, auth, nonce, validation) is pure functions over the wire types and touches no runtime API.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add @greendrake/rpc-server
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
TypeScript source ships as-is (`src/main.ts` is the entry; no build step, no `.d.ts`). Extend `@greendrake/dev-config/tsconfig/base.json` with `"types": ["bun"]` — base rather than the node config because `@greendrake/rpc`, the client this serves and whose wire types it imports, is the same code a browser runs and needs the DOM lib.
|
|
12
|
+
|
|
13
|
+
## A service, whole
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { ConnRegistry, clientIp, createDispatcher, httpHandler, keyAuthResolver, memoryNonceStore, method, websocketHandler } from '@greendrake/rpc-server'
|
|
17
|
+
import { z } from 'zod'
|
|
18
|
+
|
|
19
|
+
const registry = new ConnRegistry()
|
|
20
|
+
|
|
21
|
+
const methods = {
|
|
22
|
+
'user.get': method({
|
|
23
|
+
auth: 'user',
|
|
24
|
+
args: z.object({ id: z.string() }),
|
|
25
|
+
handler: (ctx, args) => users.get(args.id, ctx.signal)
|
|
26
|
+
}),
|
|
27
|
+
'user.list': method({ auth: 'admin', handler: () => users.all() })
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const dispatcher = createDispatcher(methods, {
|
|
31
|
+
auth: keyAuthResolver(process.env.ADMIN_API_KEY!, { userId: 'operator', isAdmin: true, phantom: false }),
|
|
32
|
+
nonces: memoryNonceStore(5 * 60 * 1000)
|
|
33
|
+
})
|
|
34
|
+
const api = httpHandler(dispatcher)
|
|
35
|
+
const ws = websocketHandler(dispatcher, registry)
|
|
36
|
+
|
|
37
|
+
Bun.serve({
|
|
38
|
+
port: 8080,
|
|
39
|
+
websocket: ws.handlers,
|
|
40
|
+
async fetch(request, server) {
|
|
41
|
+
const { pathname } = new URL(request.url)
|
|
42
|
+
if (pathname === '/v1/ws') return (await ws.upgrade(request, server)) ? undefined : new Response(null, { status: 400 })
|
|
43
|
+
if (pathname === '/v1/api') return api(request, clientIp(request, server))
|
|
44
|
+
return new Response(null, { status: 404 })
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
[`@greendrake/server`](../server) wraps that last call with health probes, CORS and a graceful-shutdown sequence; nothing here requires it.
|
|
50
|
+
|
|
51
|
+
## Methods
|
|
52
|
+
|
|
53
|
+
A `MethodDef` is what a method requires of its caller, what its argument looks like, and what runs:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
interface MethodDef<A = void, R = unknown> {
|
|
57
|
+
auth: 'none' | 'user' | 'admin'
|
|
58
|
+
allowPhantom?: boolean
|
|
59
|
+
args?: StandardSchemaV1<unknown, A>
|
|
60
|
+
handler(ctx: HandlerContext, args: A): R | Promise<R>
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**A method with no `args` schema takes no argument.** That is the rule, not an omission: validation is where an argument's type comes from, so a handler cannot be handed a value nothing has checked. A method taking a free-form payload declares a permissive schema and says so.
|
|
65
|
+
|
|
66
|
+
`method()` is identity at runtime and exists for inference — written inline in a table, a definition has nowhere to get its argument type from, because the table's own type erases it. Wrap each entry in it and the schema's output type reaches the handler's parameter.
|
|
67
|
+
|
|
68
|
+
`HandlerContext` carries `auth`, `locale`, `clientIp` and a `signal` that aborts when the caller goes away — an HTTP client disconnecting, a WebSocket closing. Pass it to whatever I/O the handler does, so work nobody is waiting for is abandoned rather than run to completion.
|
|
69
|
+
|
|
70
|
+
### Argument validation
|
|
71
|
+
|
|
72
|
+
`args` takes any [Standard Schema](https://standardschema.dev): zod, valibot and arktype all implement it, and this package depends on none of them. A failure answers `INVALID_ARGUMENT` with the issues in `error_details`:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{ "success": false, "error": "INVALID_ARGUMENT", "error_details": [{ "field": "filters.0.from", "message": "Expected number" }] }
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`field` is the issue's path joined with dots, which is what a form keys its per-field messages by.
|
|
79
|
+
|
|
80
|
+
### Authentication
|
|
81
|
+
|
|
82
|
+
`AuthResolver` turns a credential into an `AuthInfo { userId, isAdmin, phantom }`, or `null` where it names nobody — a stranger, not a refusal, since what a method requires of a principal is the dispatcher's business. `userId` is `unknown`: it is the application's own id type, and nothing here reads it.
|
|
83
|
+
|
|
84
|
+
`keyAuthResolver(key, info)` is the static-key pattern — one pre-shared secret standing for one identity, for a dashboard behind an operator gate or a deployment tool. An empty key matches nothing.
|
|
85
|
+
|
|
86
|
+
The auth rules, in the order a refusal is decided:
|
|
87
|
+
|
|
88
|
+
| `auth` | Refuses with |
|
|
89
|
+
| --- | --- |
|
|
90
|
+
| `'none'` | nothing |
|
|
91
|
+
| `'user'` | `AUTH_REQUIRED` for an anonymous caller |
|
|
92
|
+
| `'admin'` | `AUTH_REQUIRED`, then `ADMIN_REQUIRED` |
|
|
93
|
+
|
|
94
|
+
A phantom — a provisional principal the application has not fully registered — is additionally refused with `PHANTOM_NOT_ALLOWED` unless the method sets `allowPhantom`.
|
|
95
|
+
|
|
96
|
+
### Nonces
|
|
97
|
+
|
|
98
|
+
A request carrying a `nonce` runs once: `NonceStore.checkAndSet` answers false for a repeat, and the call is refused with `DUPLICATE_REQUEST`. `memoryNonceStore(ttlMs)` is one process's worth — behind a load balancer the replicas would each keep their own set, so use a shared store (Redis `SET NX PX` implements the interface in a few lines).
|
|
99
|
+
|
|
100
|
+
## Transports
|
|
101
|
+
|
|
102
|
+
`httpHandler(dispatcher, { codec? })` returns `(request, clientIp) => Promise<Response>`: POST only (405 otherwise), the credential from the `Authorization` header (`Bearer <token>` or a bare pre-shared key), and one call per request. `clientIp(request, server)` reads `X-Forwarded-For`, then `X-Real-Ip`, then the socket.
|
|
103
|
+
|
|
104
|
+
`websocketHandler(dispatcher, registry, { onOpen?, onClose? })` returns `{ upgrade, handlers }`. The handshake credential is the `token` query parameter — the only channel a browser's WebSocket constructor leaves open, having no headers, and one a reconnect carries again for free. Calls dispatch concurrently, correlated by the `id` the client puts on each frame.
|
|
105
|
+
|
|
106
|
+
The exception is `auth.token` and `auth.logout`, the in-band frames `@greendrake/rpc`'s `WsTransport` sends: they change who every later call on that socket is made by, so each goes on a per-connection gate the calls wait behind. Without it a call sent right after a bind would race it and come back `UNAUTHENTICATED`.
|
|
107
|
+
|
|
108
|
+
A codec applies to everything on the wire, in both directions, for a service whose clients obscure or encrypt their payloads: `new ConnRegistry(codec)` for the socket, `httpHandler(dispatcher, { codec })` for the endpoint.
|
|
109
|
+
|
|
110
|
+
## Pushing
|
|
111
|
+
|
|
112
|
+
`ConnRegistry` holds every open socket and indexes them by principal:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
registry.emitToUser(userId, 'thread.updated', { id }) // {type:'event', event, data} to that user's sockets
|
|
116
|
+
registry.broadcastEvent('service.state', status) // …to every socket
|
|
117
|
+
registry.broadcastData('telemetry', reading) // the bare {type, data} form
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Push-producing code takes the `EventSink` interface (`emitToUser` + `broadcastEvent`) rather than the class, so it can be driven by anything. On the client, `@greendrake/rpc`'s `liveBinding` — or `@greendrake/vue-api`'s `useLive` — subscribes to these, folds each push in or re-reads, and re-reads again after a dropped socket returns.
|
|
121
|
+
|
|
122
|
+
## Error codes
|
|
123
|
+
|
|
124
|
+
Codes, not sentences: the client keys on them. A handler's own outcomes are `throw new ApiError(code, details?)`; anything else it throws is a fault, logged and answered `INTERNAL_ERROR`.
|
|
125
|
+
|
|
126
|
+
| Code | Meaning |
|
|
127
|
+
| --- | --- |
|
|
128
|
+
| `UNKNOWN_METHOD` | no such method in the table |
|
|
129
|
+
| `INVALID_JSON` | the body or frame did not parse |
|
|
130
|
+
| `INVALID_ARGUMENT` | the argument failed the method's schema |
|
|
131
|
+
| `AUTH_REQUIRED` | the method needs a caller and there is none |
|
|
132
|
+
| `ADMIN_REQUIRED` | the caller is not an admin |
|
|
133
|
+
| `PHANTOM_NOT_ALLOWED` | the caller is provisional and this method is not open to one |
|
|
134
|
+
| `UNAUTHENTICATED` | an `auth.token` frame named nobody |
|
|
135
|
+
| `AUTH_NOT_CONFIGURED` | an auth frame reached a service with no resolver |
|
|
136
|
+
| `DUPLICATE_REQUEST` | this nonce has already been used |
|
|
137
|
+
| `INTERNAL_ERROR` | a handler faulted |
|
|
138
|
+
|
|
139
|
+
By convention a code ending `_NOT_FOUND` becomes a `NotFoundError` client-side, which is the one class `@greendrake/rpc` maps specially. Nothing here enforces it.
|
|
140
|
+
|
|
141
|
+
## The pair's typing
|
|
142
|
+
|
|
143
|
+
`ClientMethods<M>` turns a dispatch table into the method map a client hands `RpcClient` or `ApiClient`:
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
export const methods = { /* … */ }
|
|
147
|
+
export type ApiMethods = ClientMethods<typeof methods>
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Declared once, on the side that defines the methods: a method whose argument shape or result changes stops compiling in whatever calls it. `examples/service-pair` is a working pair built this way.
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@greendrake/rpc-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "RPC server over JSON for bun: method dispatch with auth, nonce and Standard Schema argument validation, HTTP and WebSocket transports, connection registry and server push",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Eugene Greendrake <eugene@greendrake.nz>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/greendrake/lib.git",
|
|
10
|
+
"directory": "rpc-server"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/greendrake/lib/tree/main/rpc-server#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/greendrake/lib/issues"
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "src/main.ts",
|
|
18
|
+
"types": "src/main.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./src/main.ts",
|
|
22
|
+
"default": "./src/main.ts"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src"
|
|
27
|
+
],
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"check": "tsc --noEmit"
|
|
34
|
+
},
|
|
35
|
+
"prettier": "@greendrake/dev-config/prettier",
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@greendrake/rpc": "0.1.0",
|
|
38
|
+
"@standard-schema/spec": "^1.1.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@greendrake/dev-config": "0.1.0",
|
|
42
|
+
"@types/bun": "^1.3.5",
|
|
43
|
+
"typescript": "6.0.3"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/arguments.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec'
|
|
2
|
+
import { ApiError } from './errors'
|
|
3
|
+
|
|
4
|
+
// A method takes one argument, and the envelope carries it two ways: wrapped
|
|
5
|
+
// in an array, which is what @greendrake/rpc sends, or as the value itself,
|
|
6
|
+
// which is what a hand-written caller or another language's client tends to
|
|
7
|
+
// send. Both unwrap to the same thing.
|
|
8
|
+
export const unwrapArguments = (raw: unknown): unknown => {
|
|
9
|
+
if (raw === undefined || raw === null) {
|
|
10
|
+
return undefined
|
|
11
|
+
}
|
|
12
|
+
return Array.isArray(raw) ? raw[0] : raw
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Validates the unwrapped argument against a method's schema. A failure is an
|
|
16
|
+
// ApiError carrying every issue, so a form can mark each offending field
|
|
17
|
+
// rather than showing one message for the whole submission.
|
|
18
|
+
export const validateArguments = async <A>(schema: StandardSchemaV1<unknown, A>, value: unknown): Promise<A> => {
|
|
19
|
+
const result = await schema['~standard'].validate(value)
|
|
20
|
+
if (result.issues) {
|
|
21
|
+
// An issue's path — `['filters', 0, 'from']` — becomes the dotted name a
|
|
22
|
+
// form field is keyed by. Segments arrive bare or wrapped in a `{ key }`
|
|
23
|
+
// object; both name the same step.
|
|
24
|
+
throw new ApiError(
|
|
25
|
+
'INVALID_ARGUMENT',
|
|
26
|
+
result.issues.map(issue => ({
|
|
27
|
+
field: (issue.path ?? []).map(segment => String(typeof segment === 'object' ? segment.key : segment)).join('.'),
|
|
28
|
+
message: issue.message
|
|
29
|
+
}))
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
return result.value
|
|
33
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { AuthInfo, AuthResolver } from './types'
|
|
2
|
+
|
|
3
|
+
// A single pre-shared secret — the static API key pattern: a trusted
|
|
4
|
+
// non-browser caller (an admin dashboard behind a webserver-level gate, a
|
|
5
|
+
// deployment tool, an MCP server) authenticates with one configured key
|
|
6
|
+
// instead of a per-user token. The identity a match yields is the
|
|
7
|
+
// application's, so the whole principal is supplied here.
|
|
8
|
+
//
|
|
9
|
+
// An empty key matches nothing: an unconfigured secret must not turn into a
|
|
10
|
+
// hole that lets everyone in.
|
|
11
|
+
export const keyAuthResolver = (key: string, info: AuthInfo): AuthResolver => ({
|
|
12
|
+
resolveToken: token => Promise.resolve(key !== '' && token === key ? info : null)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
// The credential out of an Authorization header. Two forms are on the wire:
|
|
16
|
+
// `Bearer <token>`, and a bare pre-shared key with no scheme at all (what the
|
|
17
|
+
// key-auth callers above send). Any other scheme is not a credential this
|
|
18
|
+
// understands, and yields none.
|
|
19
|
+
export const bearerToken = (request: Request): string => {
|
|
20
|
+
const header = request.headers.get('authorization')
|
|
21
|
+
if (!header) {
|
|
22
|
+
return ''
|
|
23
|
+
}
|
|
24
|
+
const space = header.indexOf(' ')
|
|
25
|
+
if (space === -1) {
|
|
26
|
+
return header
|
|
27
|
+
}
|
|
28
|
+
return header.slice(0, space).toLowerCase() === 'bearer' ? header.slice(space + 1) : ''
|
|
29
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { unwrapArguments, validateArguments } from './arguments'
|
|
2
|
+
import { ApiError } from './errors'
|
|
3
|
+
import type { AuthResolver, CallContext, HandlerContext, IncomingRequest, LocaleResolver, Methods, NonceStore, RpcResponse } from './types'
|
|
4
|
+
|
|
5
|
+
export interface DispatcherOptions {
|
|
6
|
+
// Absent means the service has no notion of identity: every method it
|
|
7
|
+
// serves must be `auth: 'none'`, and an in-band auth frame answers
|
|
8
|
+
// AUTH_NOT_CONFIGURED.
|
|
9
|
+
auth?: AuthResolver
|
|
10
|
+
// Absent means nonces are carried but not checked — fine for a
|
|
11
|
+
// single-caller service, not for one exposed to retries it cannot afford
|
|
12
|
+
// to apply twice.
|
|
13
|
+
nonces?: NonceStore
|
|
14
|
+
locale?: LocaleResolver
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// The engine both transports share: everything between a parsed envelope and a
|
|
18
|
+
// response, and nothing about how either got there.
|
|
19
|
+
export interface Dispatcher {
|
|
20
|
+
// Exposed because the transports resolve credentials of their own — a
|
|
21
|
+
// bearer header, a query parameter at the WebSocket handshake, an in-band
|
|
22
|
+
// auth frame — before there is a request to dispatch.
|
|
23
|
+
readonly auth?: AuthResolver
|
|
24
|
+
dispatch(request: IncomingRequest, context: CallContext): Promise<RpcResponse>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const failure = (error: string, details?: unknown): RpcResponse => ({
|
|
28
|
+
success: false,
|
|
29
|
+
error,
|
|
30
|
+
error_details: details
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
export const createDispatcher = (methods: Methods, options: DispatcherOptions = {}): Dispatcher => ({
|
|
34
|
+
auth: options.auth,
|
|
35
|
+
async dispatch(request, context) {
|
|
36
|
+
try {
|
|
37
|
+
// A repeat of a call that already ran is refused before anything
|
|
38
|
+
// else: the point of the nonce is that its effect happens once.
|
|
39
|
+
if (request.nonce && options.nonces && !(await options.nonces.checkAndSet(request.nonce))) {
|
|
40
|
+
return failure('DUPLICATE_REQUEST')
|
|
41
|
+
}
|
|
42
|
+
// Own properties only: a method named `toString` or `constructor`
|
|
43
|
+
// is not a method this service serves.
|
|
44
|
+
if (!Object.hasOwn(methods, request.method)) {
|
|
45
|
+
return failure('UNKNOWN_METHOD')
|
|
46
|
+
}
|
|
47
|
+
const def = methods[request.method]
|
|
48
|
+
if (def.auth !== 'none') {
|
|
49
|
+
if (context.auth.userId === null) {
|
|
50
|
+
return failure('AUTH_REQUIRED')
|
|
51
|
+
}
|
|
52
|
+
if (def.auth === 'admin' && !context.auth.isAdmin) {
|
|
53
|
+
return failure('ADMIN_REQUIRED')
|
|
54
|
+
}
|
|
55
|
+
if (context.auth.phantom && !def.allowPhantom) {
|
|
56
|
+
return failure('PHANTOM_NOT_ALLOWED')
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const ctx: HandlerContext = { ...context, locale: options.locale?.(request.locale) ?? '' }
|
|
60
|
+
const args = def.args ? await validateArguments(def.args, unwrapArguments(request.arguments)) : undefined
|
|
61
|
+
return { success: true, data: await def.handler(ctx, args) }
|
|
62
|
+
} catch (e) {
|
|
63
|
+
if (e instanceof ApiError) {
|
|
64
|
+
return failure(e.code, e.details)
|
|
65
|
+
}
|
|
66
|
+
// A request whose caller has already gone fails in whatever way the
|
|
67
|
+
// abandoned I/O reports; that is the disconnect, not a fault of the
|
|
68
|
+
// service, and its response is discarded anyway.
|
|
69
|
+
if (!context.signal.aborted) {
|
|
70
|
+
console.error(`[rpc-server] ${request.method}:`, e)
|
|
71
|
+
}
|
|
72
|
+
return failure('INTERNAL_ERROR')
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
})
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// A business outcome the caller is meant to see: `code` becomes the response's
|
|
2
|
+
// `error`, `details` its `error_details` (the structured payload a handler
|
|
3
|
+
// attaches alongside a code — the per-field list argument validation produces,
|
|
4
|
+
// the conflicting record's id, whatever the method's contract says).
|
|
5
|
+
//
|
|
6
|
+
// Anything else a handler throws is a fault, not an outcome: it is logged and
|
|
7
|
+
// answered INTERNAL_ERROR, so an unforeseen failure never leaks its internals
|
|
8
|
+
// to a client.
|
|
9
|
+
export class ApiError extends Error {
|
|
10
|
+
readonly code: string
|
|
11
|
+
readonly details?: unknown
|
|
12
|
+
|
|
13
|
+
constructor(code: string, details?: unknown) {
|
|
14
|
+
super(code)
|
|
15
|
+
this.name = 'ApiError'
|
|
16
|
+
this.code = code
|
|
17
|
+
this.details = details
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Server } from 'bun'
|
|
2
|
+
import { identityCodec } from '@greendrake/rpc'
|
|
3
|
+
import { bearerToken } from './auth'
|
|
4
|
+
import type { Dispatcher } from './dispatcher'
|
|
5
|
+
import { ANONYMOUS } from './types'
|
|
6
|
+
import type { Codec, IncomingRequest, RpcResponse } from './types'
|
|
7
|
+
|
|
8
|
+
export interface HttpHandlerOptions {
|
|
9
|
+
// Applied to the request body and the response body. Identity by default;
|
|
10
|
+
// a service whose clients obscure or encrypt their payloads supplies the
|
|
11
|
+
// same codec both ends run.
|
|
12
|
+
codec?: Codec
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Where the call came from, as the proxies in front of this service report it.
|
|
16
|
+
// A service behind nothing still gets the socket's own address.
|
|
17
|
+
export const clientIp = <T>(request: Request, server: Server<T>): string => {
|
|
18
|
+
const forwarded = request.headers.get('x-forwarded-for')
|
|
19
|
+
if (forwarded) {
|
|
20
|
+
// The left-most entry is the original client; the rest are the proxies
|
|
21
|
+
// it passed through.
|
|
22
|
+
return forwarded.split(',')[0].trim()
|
|
23
|
+
}
|
|
24
|
+
return request.headers.get('x-real-ip') ?? server.requestIP(request)?.address ?? ''
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The single-endpoint HTTP transport: one POST carries one call. Returns the
|
|
28
|
+
// handler a `Bun.serve` fetch routes its API path to.
|
|
29
|
+
export const httpHandler = (dispatcher: Dispatcher, options: HttpHandlerOptions = {}): ((request: Request, ip: string) => Promise<Response>) => {
|
|
30
|
+
const codec = options.codec ?? identityCodec
|
|
31
|
+
// A codec's output is not JSON any more, and calling it JSON misleads
|
|
32
|
+
// everything that sniffs the body — devtools, proxies, CDNs.
|
|
33
|
+
const contentType = options.codec ? 'text/plain; charset=utf-8' : 'application/json'
|
|
34
|
+
const respond = (response: RpcResponse): Response => new Response(codec.encode(JSON.stringify(response)), { headers: { 'content-type': contentType } })
|
|
35
|
+
|
|
36
|
+
return async (request, ip) => {
|
|
37
|
+
if (request.method !== 'POST') {
|
|
38
|
+
return new Response(null, { status: 405 })
|
|
39
|
+
}
|
|
40
|
+
const token = bearerToken(request)
|
|
41
|
+
// A token nobody recognises leaves the caller anonymous rather than
|
|
42
|
+
// refused: what that costs them is decided per method, by the rule the
|
|
43
|
+
// method declares.
|
|
44
|
+
const auth = (token && (await dispatcher.auth?.resolveToken(token, request.signal))) || ANONYMOUS
|
|
45
|
+
|
|
46
|
+
let parsed: IncomingRequest
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(codec.decode(await request.text())) as IncomingRequest
|
|
49
|
+
} catch {
|
|
50
|
+
return respond({ success: false, error: 'INVALID_JSON' })
|
|
51
|
+
}
|
|
52
|
+
// No `type` on an HTTP response: the field exists to tell a WebSocket
|
|
53
|
+
// frame's kind apart, and here there is only one kind.
|
|
54
|
+
return respond(
|
|
55
|
+
await dispatcher.dispatch(parsed, {
|
|
56
|
+
auth,
|
|
57
|
+
clientIp: ip,
|
|
58
|
+
signal: request.signal
|
|
59
|
+
})
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { ANONYMOUS, method, type AuthInfo, type AuthRequirement, type AuthResolver, type CallContext, type ClientMethods, type DataFrame, type EventFrame, type HandlerContext, type IncomingRequest, type LocaleResolver, type MethodDef, type Methods, type NonceStore, type WsResponse } from './types'
|
|
2
|
+
export { ApiError } from './errors'
|
|
3
|
+
export { unwrapArguments, validateArguments } from './arguments'
|
|
4
|
+
export { createDispatcher, type Dispatcher, type DispatcherOptions } from './dispatcher'
|
|
5
|
+
export { bearerToken, keyAuthResolver } from './auth'
|
|
6
|
+
export { memoryNonceStore } from './nonce'
|
|
7
|
+
export { clientIp, httpHandler, type HttpHandlerOptions } from './http'
|
|
8
|
+
export { websocketHandler, type RpcWebSocket, type WebSocketHandlerOptions } from './ws'
|
|
9
|
+
export { ConnRegistry, type Connection, type ConnectionData, type EventSink } from './registry'
|
|
10
|
+
export type { Codec, RpcRequest, RpcResponse } from './types'
|
package/src/nonce.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { NonceStore } from './types'
|
|
2
|
+
|
|
3
|
+
// In-process nonce store: a Map swept on insert, so it needs no timer and no
|
|
4
|
+
// teardown. One process only — behind a load balancer the replicas would each
|
|
5
|
+
// keep their own set and a retry landing elsewhere would run twice, so a
|
|
6
|
+
// multi-instance deployment needs a shared store (Redis `SET NX PX` with the
|
|
7
|
+
// same TTL implements this interface in a few lines).
|
|
8
|
+
export const memoryNonceStore = (ttlMs: number): NonceStore => {
|
|
9
|
+
// Insertion order is expiry order, the TTL being constant, so the sweep
|
|
10
|
+
// stops at the first entry still alive instead of walking the whole map.
|
|
11
|
+
const expiries = new Map<string, number>()
|
|
12
|
+
return {
|
|
13
|
+
checkAndSet: nonce => {
|
|
14
|
+
const now = Date.now()
|
|
15
|
+
for (const [seen, expiry] of expiries) {
|
|
16
|
+
if (expiry > now) {
|
|
17
|
+
break
|
|
18
|
+
}
|
|
19
|
+
expiries.delete(seen)
|
|
20
|
+
}
|
|
21
|
+
if (expiries.has(nonce)) {
|
|
22
|
+
return Promise.resolve(false)
|
|
23
|
+
}
|
|
24
|
+
expiries.set(nonce, now + ttlMs)
|
|
25
|
+
return Promise.resolve(true)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { ServerWebSocket } from 'bun'
|
|
2
|
+
import { identityCodec } from '@greendrake/rpc'
|
|
3
|
+
import type { AuthInfo, Codec, ServerFrame } from './types'
|
|
4
|
+
|
|
5
|
+
// Per-socket state, carried as Bun's websocket `data`.
|
|
6
|
+
export interface ConnectionData {
|
|
7
|
+
// Who the socket is bound as: the query-parameter credential it opened
|
|
8
|
+
// with, or whatever an in-band auth frame has since made it.
|
|
9
|
+
auth: AuthInfo
|
|
10
|
+
// Aborted when the socket closes, so every handler still running on it
|
|
11
|
+
// learns its caller is gone.
|
|
12
|
+
controller: AbortController
|
|
13
|
+
clientIp: string
|
|
14
|
+
// Serialises the frames that change who the connection is against the
|
|
15
|
+
// calls that depend on the answer. See the WebSocket handler.
|
|
16
|
+
gate: Promise<unknown>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type Connection = ServerWebSocket<ConnectionData>
|
|
20
|
+
|
|
21
|
+
// What a subsystem needs in order to tell clients something happened, without
|
|
22
|
+
// knowing what a connection is. Push-producing code takes this, not the
|
|
23
|
+
// registry.
|
|
24
|
+
export interface EventSink {
|
|
25
|
+
emitToUser(userId: unknown, event: string, data: unknown): void
|
|
26
|
+
broadcastEvent(event: string, data: unknown): void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Every open socket, and the index from principal to the sockets bound as it —
|
|
30
|
+
// a user with three tabs has three. Holding all of them as well as the index
|
|
31
|
+
// is what lets a broadcast reach anonymous connections too, which a per-user
|
|
32
|
+
// index alone cannot.
|
|
33
|
+
export class ConnRegistry implements EventSink {
|
|
34
|
+
readonly #connections = new Set<Connection>()
|
|
35
|
+
readonly #byUser = new Map<unknown, Set<Connection>>()
|
|
36
|
+
// Where each connection is currently filed, so a principal that changes
|
|
37
|
+
// mid-connection can be re-indexed without the caller tracking what it
|
|
38
|
+
// used to be.
|
|
39
|
+
readonly #filedUnder = new Map<Connection, unknown>()
|
|
40
|
+
// Public because the WebSocket handler decodes inbound frames with it:
|
|
41
|
+
// one codec governs the socket in both directions, and it is configured
|
|
42
|
+
// here, once.
|
|
43
|
+
readonly codec: Codec
|
|
44
|
+
|
|
45
|
+
constructor(codec: Codec = identityCodec) {
|
|
46
|
+
this.codec = codec
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Idempotent, and the one way a connection's principal reaches the index:
|
|
50
|
+
// called when a socket opens and again whenever an auth frame changes who
|
|
51
|
+
// it is.
|
|
52
|
+
register(conn: Connection): void {
|
|
53
|
+
this.#connections.add(conn)
|
|
54
|
+
const userId = conn.data.auth.userId
|
|
55
|
+
if (this.#filedUnder.get(conn) === userId) {
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
this.#unfile(conn)
|
|
59
|
+
if (userId === null) {
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
let bound = this.#byUser.get(userId)
|
|
63
|
+
if (!bound) {
|
|
64
|
+
bound = new Set()
|
|
65
|
+
this.#byUser.set(userId, bound)
|
|
66
|
+
}
|
|
67
|
+
bound.add(conn)
|
|
68
|
+
this.#filedUnder.set(conn, userId)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
unregister(conn: Connection): void {
|
|
72
|
+
this.#connections.delete(conn)
|
|
73
|
+
this.#unfile(conn)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#unfile(conn: Connection): void {
|
|
77
|
+
if (!this.#filedUnder.has(conn)) {
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
const userId = this.#filedUnder.get(conn)
|
|
81
|
+
this.#filedUnder.delete(conn)
|
|
82
|
+
const bound = this.#byUser.get(userId)
|
|
83
|
+
if (!bound) {
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
bound.delete(conn)
|
|
87
|
+
if (bound.size === 0) {
|
|
88
|
+
this.#byUser.delete(userId)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The one place a frame becomes bytes: responses and pushes leave a socket
|
|
93
|
+
// the same way, so a codec is configured once rather than per call site.
|
|
94
|
+
send(conn: Connection, frame: ServerFrame): void {
|
|
95
|
+
conn.send(this.codec.encode(JSON.stringify(frame)))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
sendToUser(userId: unknown, frame: ServerFrame): void {
|
|
99
|
+
this.#byUser.get(userId)?.forEach(conn => this.send(conn, frame))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
emitToUser(userId: unknown, event: string, data: unknown): void {
|
|
103
|
+
this.sendToUser(userId, {
|
|
104
|
+
type: 'event',
|
|
105
|
+
event,
|
|
106
|
+
data
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// To every open socket, whoever it is bound as — for state the whole
|
|
111
|
+
// service shares rather than anything addressed to a user.
|
|
112
|
+
broadcastEvent(event: string, data: unknown): void {
|
|
113
|
+
this.#connections.forEach(conn =>
|
|
114
|
+
this.send(conn, {
|
|
115
|
+
type: 'event',
|
|
116
|
+
event,
|
|
117
|
+
data
|
|
118
|
+
})
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The bare `{type, data}` form, for a push whose own type names the
|
|
123
|
+
// payload and needs no event envelope.
|
|
124
|
+
broadcastData(type: string, data: unknown): void {
|
|
125
|
+
this.#connections.forEach(conn => this.send(conn, { type, data }))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
get size(): number {
|
|
129
|
+
return this.#connections.size
|
|
130
|
+
}
|
|
131
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec'
|
|
2
|
+
import type { RpcRequest, RpcResponse } from '@greendrake/rpc'
|
|
3
|
+
|
|
4
|
+
// Who is calling, as the application's resolver reports it. `userId` is opaque
|
|
5
|
+
// — the application's own id type — and null for a caller no credential named,
|
|
6
|
+
// which is the one thing every auth rule but 'none' turns away.
|
|
7
|
+
export interface AuthInfo {
|
|
8
|
+
userId: unknown
|
|
9
|
+
isAdmin: boolean
|
|
10
|
+
phantom: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// The principal a request carries until a credential says otherwise. Not a
|
|
14
|
+
// stand-in for "unknown": a resolver that ran and matched nothing yields this
|
|
15
|
+
// too, because a token nobody recognises makes its bearer a stranger, not an
|
|
16
|
+
// error.
|
|
17
|
+
export const ANONYMOUS: AuthInfo = {
|
|
18
|
+
userId: null,
|
|
19
|
+
isAdmin: false,
|
|
20
|
+
phantom: false
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Turns a credential into a principal, or null where it names nobody. What a
|
|
24
|
+
// method then requires of that principal is the dispatcher's business, so this
|
|
25
|
+
// never refuses: it only reports.
|
|
26
|
+
export interface AuthResolver {
|
|
27
|
+
resolveToken(token: string, signal: AbortSignal): Promise<AuthInfo | null>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Replay protection for the calls that carry a nonce: true the first time one
|
|
31
|
+
// is seen, false for a repeat.
|
|
32
|
+
export interface NonceStore {
|
|
33
|
+
checkAndSet(nonce: string): Promise<boolean>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Maps the language tag a client asked for onto one the application actually
|
|
37
|
+
// ships. Which locales exist and how a near miss resolves is the application's
|
|
38
|
+
// policy; this package only carries the value.
|
|
39
|
+
export type LocaleResolver = (requested: string | undefined) => string
|
|
40
|
+
|
|
41
|
+
// What the transport knows about a call before it dispatches.
|
|
42
|
+
export interface CallContext {
|
|
43
|
+
auth: AuthInfo
|
|
44
|
+
clientIp: string
|
|
45
|
+
// Aborted when the caller goes away — an HTTP client disconnecting, a
|
|
46
|
+
// WebSocket closing. Handlers pass it to whatever I/O they do, so work
|
|
47
|
+
// nobody is waiting for is abandoned rather than run to completion.
|
|
48
|
+
signal: AbortSignal
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface HandlerContext extends CallContext {
|
|
52
|
+
// Empty unless the dispatcher was given a LocaleResolver.
|
|
53
|
+
locale: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type AuthRequirement = 'none' | 'user' | 'admin'
|
|
57
|
+
|
|
58
|
+
// One method: what it requires of the caller, what shape its argument has, and
|
|
59
|
+
// what runs. `A` defaults to void — a method with no `args` schema takes none,
|
|
60
|
+
// and ClientMethods turns that into a zero-argument call.
|
|
61
|
+
export interface MethodDef<A = void, R = unknown> {
|
|
62
|
+
auth: AuthRequirement
|
|
63
|
+
// Let a phantom — a provisional principal the application has not fully
|
|
64
|
+
// registered — through. Off by default: a method reached by one says so.
|
|
65
|
+
allowPhantom?: boolean
|
|
66
|
+
// Validates the single argument and types the handler's. Any Standard
|
|
67
|
+
// Schema validator (zod, valibot, arktype) qualifies; failures answer
|
|
68
|
+
// INVALID_ARGUMENT with the per-field issues in `error_details`.
|
|
69
|
+
args?: StandardSchemaV1<unknown, A>
|
|
70
|
+
// Declared as a method rather than a property so the map below can hold
|
|
71
|
+
// definitions of every argument type: parameter bivariance is what makes a
|
|
72
|
+
// MethodDef<Something> a MethodDef<unknown>.
|
|
73
|
+
handler(ctx: HandlerContext, args: A): R | Promise<R>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A dispatch table. The erased argument type is what every concrete MethodDef
|
|
77
|
+
// widens to, so a table mixing schemas and bare methods is one value.
|
|
78
|
+
export type Methods = Record<string, MethodDef<unknown, unknown>>
|
|
79
|
+
|
|
80
|
+
// Identity at runtime; inference is its whole job. A definition written inline
|
|
81
|
+
// in a table has nowhere to get its argument type from — the table's own type
|
|
82
|
+
// erases it — so the schema's output reaches the handler's parameter through
|
|
83
|
+
// this. A definition with no schema takes no argument, and says so.
|
|
84
|
+
export const method = <A = void, R = unknown>(def: MethodDef<A, R>): MethodDef<A, R> => def
|
|
85
|
+
|
|
86
|
+
type MethodArgs<A> = [A] extends [void] ? [] : [args: A]
|
|
87
|
+
|
|
88
|
+
// The client-side view of a dispatch table: the method map a dashboard hands
|
|
89
|
+
// `ApiClient<…>`, derived from the definitions rather than restated beside
|
|
90
|
+
// them. The pair's typing meets here — a method whose argument shape or result
|
|
91
|
+
// changes server-side stops compiling in the frontend that calls it.
|
|
92
|
+
export type ClientMethods<M extends Methods> = {
|
|
93
|
+
[K in keyof M]: M[K] extends MethodDef<infer A, infer R> ? (...args: MethodArgs<A>) => Awaited<R> : never
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The WebSocket transport's additions to the wire: an id correlating a
|
|
97
|
+
// response with its request, and the frames the server pushes unasked.
|
|
98
|
+
export interface WsResponse extends RpcResponse {
|
|
99
|
+
type: 'response'
|
|
100
|
+
id?: string
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface EventFrame {
|
|
104
|
+
type: 'event'
|
|
105
|
+
event: string
|
|
106
|
+
data: unknown
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface DataFrame {
|
|
110
|
+
type: string
|
|
111
|
+
data: unknown
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The wire types both halves share, re-exported so a server's modules have one
|
|
115
|
+
// place to import from.
|
|
116
|
+
export type { Codec, RpcRequest, RpcResponse } from '@greendrake/rpc'
|
|
117
|
+
|
|
118
|
+
// Everything that goes out on a socket, so the registry's send path is typed
|
|
119
|
+
// rather than `unknown`.
|
|
120
|
+
export type ServerFrame = WsResponse | EventFrame | DataFrame
|
|
121
|
+
|
|
122
|
+
// A request as it arrives: the shared envelope plus the WebSocket's id.
|
|
123
|
+
export interface IncomingRequest extends RpcRequest {
|
|
124
|
+
id?: string
|
|
125
|
+
}
|
package/src/ws.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { Server, WebSocketHandler } from 'bun'
|
|
2
|
+
import { unwrapArguments } from './arguments'
|
|
3
|
+
import type { Dispatcher } from './dispatcher'
|
|
4
|
+
import { clientIp } from './http'
|
|
5
|
+
import type { Connection, ConnectionData, ConnRegistry } from './registry'
|
|
6
|
+
import { ANONYMOUS } from './types'
|
|
7
|
+
import type { AuthInfo, IncomingRequest, RpcResponse } from './types'
|
|
8
|
+
|
|
9
|
+
export interface WebSocketHandlerOptions {
|
|
10
|
+
// Run once the socket can be written to. A service that greets a new
|
|
11
|
+
// connection with its current state does it here.
|
|
12
|
+
onOpen?: (conn: Connection) => void
|
|
13
|
+
// Run when the socket has gone, before it leaves the registry.
|
|
14
|
+
onClose?: (conn: Connection) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RpcWebSocket {
|
|
18
|
+
// Resolves the handshake credential and hands the socket to Bun. False
|
|
19
|
+
// when Bun refused the upgrade, which is the fetch handler's cue to answer
|
|
20
|
+
// the request itself.
|
|
21
|
+
upgrade(request: Request, server: Server<ConnectionData>): Promise<boolean>
|
|
22
|
+
// Pass to `Bun.serve`'s `websocket`.
|
|
23
|
+
handlers: WebSocketHandler<ConnectionData>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The credential a socket presents at the handshake, as a query parameter —
|
|
27
|
+
// the only channel a browser's WebSocket constructor leaves open, having no
|
|
28
|
+
// headers. Reconnects carry it again for free, which is why a dashboard with a
|
|
29
|
+
// static key needs no in-band handshake at all.
|
|
30
|
+
const TOKEN_PARAM = 'token'
|
|
31
|
+
|
|
32
|
+
// The WebSocket transport: many calls over one socket, correlated by id, plus
|
|
33
|
+
// the pushes the server sends unasked. Requests dispatch concurrently — a slow
|
|
34
|
+
// one must not hold up the socket — with one exception, below.
|
|
35
|
+
export const websocketHandler = (dispatcher: Dispatcher, registry: ConnRegistry, options: WebSocketHandlerOptions = {}): RpcWebSocket => {
|
|
36
|
+
const reply = (conn: Connection, id: string | undefined, response: RpcResponse): void =>
|
|
37
|
+
registry.send(conn, {
|
|
38
|
+
type: 'response',
|
|
39
|
+
id,
|
|
40
|
+
...response
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
// Who the connection is from here on: the index follows, and the frame that
|
|
44
|
+
// asked is answered once it does.
|
|
45
|
+
const bindAs = (conn: Connection, request: IncomingRequest, auth: AuthInfo): void => {
|
|
46
|
+
conn.data.auth = auth
|
|
47
|
+
registry.register(conn)
|
|
48
|
+
reply(conn, request.id, { success: true })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const authenticate = async (conn: Connection, request: IncomingRequest): Promise<void> => {
|
|
52
|
+
if (!dispatcher.auth) {
|
|
53
|
+
reply(conn, request.id, { success: false, error: 'AUTH_NOT_CONFIGURED' })
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
const token = unwrapArguments(request.arguments)
|
|
57
|
+
if (typeof token !== 'string') {
|
|
58
|
+
reply(conn, request.id, { success: false, error: 'INVALID_ARGUMENT' })
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
const auth = await dispatcher.auth.resolveToken(token, conn.data.controller.signal)
|
|
62
|
+
if (!auth || auth.userId === null) {
|
|
63
|
+
reply(conn, request.id, { success: false, error: 'UNAUTHENTICATED' })
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
bindAs(conn, request, auth)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const logout = (conn: Connection, request: IncomingRequest): void => bindAs(conn, request, ANONYMOUS)
|
|
70
|
+
|
|
71
|
+
// The frames that change who a connection is, rather than asking it to do
|
|
72
|
+
// something. @greendrake/rpc's WsTransport sends exactly these. A Map, so a
|
|
73
|
+
// client naming an inherited property does not reach a function.
|
|
74
|
+
const authFrames = new Map<string, (conn: Connection, request: IncomingRequest) => void | Promise<void>>([
|
|
75
|
+
['auth.token', authenticate],
|
|
76
|
+
['auth.logout', logout]
|
|
77
|
+
])
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
async upgrade(request, server) {
|
|
81
|
+
const token = new URL(request.url).searchParams.get(TOKEN_PARAM)
|
|
82
|
+
const resolved = token && dispatcher.auth ? await dispatcher.auth.resolveToken(token, request.signal) : null
|
|
83
|
+
const data: ConnectionData = {
|
|
84
|
+
auth: resolved ?? ANONYMOUS,
|
|
85
|
+
controller: new AbortController(),
|
|
86
|
+
clientIp: clientIp(request, server),
|
|
87
|
+
gate: Promise.resolve()
|
|
88
|
+
}
|
|
89
|
+
return server.upgrade(request, { data })
|
|
90
|
+
},
|
|
91
|
+
handlers: {
|
|
92
|
+
open(conn) {
|
|
93
|
+
registry.register(conn)
|
|
94
|
+
options.onOpen?.(conn)
|
|
95
|
+
},
|
|
96
|
+
message(conn, raw) {
|
|
97
|
+
let request: IncomingRequest
|
|
98
|
+
try {
|
|
99
|
+
request = JSON.parse(registry.codec.decode(String(raw))) as IncomingRequest
|
|
100
|
+
} catch {
|
|
101
|
+
reply(conn, undefined, { success: false, error: 'INVALID_JSON' })
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
const bind = authFrames.get(request.method)
|
|
105
|
+
if (bind) {
|
|
106
|
+
// An auth frame changes who every later call on this socket
|
|
107
|
+
// is made by, so it goes on the gate the calls wait behind:
|
|
108
|
+
// a request sent right after one must not race it and come
|
|
109
|
+
// back UNAUTHENTICATED. The chain is kept fulfilled — a
|
|
110
|
+
// rejected gate would silently strand every frame behind it.
|
|
111
|
+
conn.data.gate = conn.data.gate
|
|
112
|
+
.then(() => bind(conn, request))
|
|
113
|
+
.catch((e: unknown) => {
|
|
114
|
+
console.error(`[rpc-server] ${request.method}:`, e)
|
|
115
|
+
reply(conn, request.id, { success: false, error: 'INTERNAL_ERROR' })
|
|
116
|
+
})
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
void conn.data.gate
|
|
120
|
+
.then(() =>
|
|
121
|
+
dispatcher.dispatch(request, {
|
|
122
|
+
auth: conn.data.auth,
|
|
123
|
+
clientIp: conn.data.clientIp,
|
|
124
|
+
signal: conn.data.controller.signal
|
|
125
|
+
})
|
|
126
|
+
)
|
|
127
|
+
.then(response => {
|
|
128
|
+
// The socket went while the handler ran: the response
|
|
129
|
+
// has nowhere to go, and Bun would throw writing it.
|
|
130
|
+
if (!conn.data.controller.signal.aborted) {
|
|
131
|
+
reply(conn, request.id, response)
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
},
|
|
135
|
+
close(conn) {
|
|
136
|
+
// Every handler still running on this socket learns its caller
|
|
137
|
+
// is gone, and abandons whatever I/O it is waiting on.
|
|
138
|
+
conn.data.controller.abort()
|
|
139
|
+
options.onClose?.(conn)
|
|
140
|
+
registry.unregister(conn)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|