@hediet/linkrpc 0.0.1

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.
Files changed (39) hide show
  1. package/README.md +383 -0
  2. package/dist/chunks/_empty-crypto-Bi0tGx5K.js +8 -0
  3. package/dist/chunks/boundedTrafficSubscription-1L592xc7.js +1126 -0
  4. package/dist/chunks/boundedTrafficSubscription-1L592xc7.js.map +1 -0
  5. package/dist/chunks/hub.interfaces-BzWfsVT2.js +526 -0
  6. package/dist/chunks/hub.interfaces-BzWfsVT2.js.map +1 -0
  7. package/dist/chunks/hubAccess-DwTZPiI8.d.ts +79 -0
  8. package/dist/chunks/hubAccess-DwTZPiI8.d.ts.map +1 -0
  9. package/dist/chunks/hubFacade-CQflVkVC.js +85 -0
  10. package/dist/chunks/hubFacade-CQflVkVC.js.map +1 -0
  11. package/dist/chunks/hubFacade-Dkgw2pTi.d.ts +101 -0
  12. package/dist/chunks/hubFacade-Dkgw2pTi.d.ts.map +1 -0
  13. package/dist/chunks/linkRpcConnection-CtlQmetO.d.ts +3623 -0
  14. package/dist/chunks/linkRpcConnection-CtlQmetO.d.ts.map +1 -0
  15. package/dist/chunks/rolldown-runtime-4LSo1kEK.js +17 -0
  16. package/dist/chunks/src-D3NUIwyo.js +7795 -0
  17. package/dist/chunks/src-D3NUIwyo.js.map +1 -0
  18. package/dist/hub/client/index.d.ts +50 -0
  19. package/dist/hub/client/index.d.ts.map +1 -0
  20. package/dist/hub/client/index.js +97 -0
  21. package/dist/hub/client/index.js.map +1 -0
  22. package/dist/hub/common/index.d.ts +1189 -0
  23. package/dist/hub/common/index.d.ts.map +1 -0
  24. package/dist/hub/common/index.js +267 -0
  25. package/dist/hub/common/index.js.map +1 -0
  26. package/dist/index.d.ts +6 -0
  27. package/dist/index.js +7 -0
  28. package/dist/inspection/index.d.ts +66 -0
  29. package/dist/inspection/index.d.ts.map +1 -0
  30. package/dist/inspection/index.js +6 -0
  31. package/dist/node.d.ts +548 -0
  32. package/dist/node.d.ts.map +1 -0
  33. package/dist/node.js +1087 -0
  34. package/dist/node.js.map +1 -0
  35. package/dist/web.d.ts +44 -0
  36. package/dist/web.d.ts.map +1 -0
  37. package/dist/web.js +58 -0
  38. package/dist/web.js.map +1 -0
  39. package/package.json +59 -0
package/README.md ADDED
@@ -0,0 +1,383 @@
1
+ # @hediet/linkrpc
2
+
3
+ Typed, multiplexed RPC over a single bidirectional connection.
4
+
5
+ linkrpc is a **specialization of [JSON-RPC 2.0](https://www.jsonrpc.org/specification)**: every
6
+ message on the wire is a valid JSON-RPC message, and linkrpc adds just enough on top to let many
7
+ strongly-typed services share one connection — an addressing grammar, built-in reflection,
8
+ content-addressed interface identity, and optional layers for signing and capabilities.
9
+
10
+ ## How linkrpc works
11
+
12
+ ```mermaid
13
+ sequenceDiagram
14
+ actor App as Consumer application
15
+ participant Caller as Consumer LinkRPC node
16
+ participant Hub as Hub (optional intermediary)
17
+ participant Provider as Provider LinkRPC node
18
+ participant Service as Service handlers
19
+
20
+ Note over Caller,Provider: Reliable, ordered, bidirectional transport<br/>NDJSON, WebSocket, or stdio
21
+ Caller->>Hub: hubrpc.directory::list / hubrpc.schemas::get
22
+ Hub-->>Caller: Services, interface schemas, and hashes
23
+
24
+ opt The target call is capability-gated
25
+ Caller->>Hub: hubAccess::request
26
+ Hub-->>Caller: Scoped signed capabilities
27
+ end
28
+
29
+ App->>Caller: Typed call
30
+ Caller->>Hub: JSON-RPC request<br/>service::interface::member<br/>+ optional signature and capabilities
31
+ Hub->>Hub: If required, verify identity and capability<br/>Route and rewrite correlation id
32
+ Hub->>Provider: Forwarded request
33
+ Provider->>Service: Validate params and invoke member
34
+
35
+ opt Long-running or bidirectional call
36
+ Service-->>Provider: Typed stream payload
37
+ Provider-->>Hub: $stream::send(requestId)
38
+ Hub-->>Caller: $stream::send(rewritten requestId)
39
+ Caller-->>App: Progress or partial result
40
+ end
41
+
42
+ Service-->>Provider: Typed result or enumerated error
43
+ Provider-->>Hub: JSON-RPC response
44
+ Hub-->>Caller: Correlated response
45
+ Caller-->>App: Typed result
46
+
47
+ Note over Caller,Provider: Caller and provider are per-call roles.<br/>Either node may play both roles concurrently.
48
+ ```
49
+
50
+ The **nodes** are the protocol participants: each is one endpoint of one bidirectional connection.
51
+ The **hub** is optional; on a direct connection the consumer node talks to the provider node without
52
+ the routing and access-broker steps. A **service** is a concrete provider of one or more typed
53
+ interfaces. Reflection exposes those interfaces at the connection boundary, while signatures identify
54
+ the caller and capabilities authorize specific calls. Optional metadata stays inside reserved
55
+ `$hubrpc`-prefixed `params` members, so peers that only implement the Core profile can still handle
56
+ ungated calls.
57
+
58
+ You describe an interface once with [zod](https://zod.dev) schemas. The same definition gives you a
59
+ fully-typed client *and* type-checked server handlers, and validates params and results at runtime.
60
+
61
+ ```ts
62
+ // shared.ts
63
+ import { defineInterface, requestType, notificationType } from '@hediet/linkrpc';
64
+ import { z } from 'zod';
65
+
66
+ export const greeter = defineInterface(
67
+ { id: 'test.greeter', description: 'Greets people.' },
68
+ {
69
+ hello: requestType(
70
+ z.object({ name: z.string() }),
71
+ z.object({ greeting: z.string() }),
72
+ ),
73
+ shout: notificationType(z.object({ msg: z.string() })),
74
+ },
75
+ );
76
+ ```
77
+
78
+ Put the interface in `shared.ts`, then serve it over a WebSocket:
79
+
80
+ ```ts
81
+ // server.ts
82
+ import { LinkRpcConnection } from '@hediet/linkrpc';
83
+ import { WebSocketServer } from '@hediet/linkrpc-hub/hub/server/node';
84
+ import { greeter } from './shared.js';
85
+
86
+ const token = process.env.LINKRPC_TOKEN;
87
+ if (!token) throw new Error('LINKRPC_TOKEN is required');
88
+
89
+ const listener = await WebSocketServer.start({
90
+ host: '127.0.0.1',
91
+ port: 7878,
92
+ isTokenAccepted: async candidate => candidate === token,
93
+ });
94
+
95
+ listener.setConnectionHandler(transport => {
96
+ const connection = LinkRpcConnection.fromTransport(transport);
97
+ connection.register(greeter, {
98
+ hello: async ({ name }) => ({ greeting: `Hi, ${name}!` }),
99
+ shout: ({ msg }) => console.log('heard:', msg),
100
+ });
101
+ });
102
+ ```
103
+
104
+ Connect from another process — the generated proxy remains fully typed:
105
+
106
+ ```ts
107
+ // client.ts
108
+ import { LinkRpcConnection } from '@hediet/linkrpc';
109
+ import {
110
+ openWebSocket,
111
+ runInitializeHandshake,
112
+ WebSocketTransport,
113
+ } from '@hediet/linkrpc/node';
114
+ import { greeter } from './shared.js';
115
+
116
+ const token = process.env.LINKRPC_TOKEN;
117
+ if (!token) throw new Error('LINKRPC_TOKEN is required');
118
+
119
+ const socket = await openWebSocket('ws://127.0.0.1:7878');
120
+ const transport = new WebSocketTransport(socket);
121
+ await runInitializeHandshake(transport, {
122
+ kind: 'client',
123
+ token,
124
+ });
125
+
126
+ const client = LinkRpcConnection.fromTransport(transport);
127
+ const g = client.get(greeter); // typed proxy
128
+ await g.hello({ name: 'world' }); // → { greeting: 'Hi, world!' }
129
+ g.shout({ msg: 'boom' }); // fire-and-forget notification
130
+ ```
131
+
132
+ Pass the wrong shape and TypeScript stops you at compile time; if a bad value reaches the wire
133
+ anyway, the provider rejects it with a JSON-RPC `-32602 invalidParams`.
134
+
135
+ ## Reflection — discoverability built in
136
+
137
+ A connection is self-describing. Call `enableReflection()` and it exposes three standard interfaces
138
+ backed by its live registry, so a peer (or the [`linkrpc` CLI](../linkrpc-cli)) can explore and call
139
+ it with **no prior knowledge** — list the services, fetch their schemas, generate a typed client at
140
+ runtime:
141
+
142
+ - **`hubrpc.directory`** — which services and interfaces this connection serves (each with its `id@hash`).
143
+ - **`hubrpc.schemas`** — the full schema for any advertised interface.
144
+ - **`hubrpc.defaults`** — the connection's preset service/interface, if any (`setPreset(iface)`).
145
+
146
+ ```ts
147
+ connection.register(greeter, handlers);
148
+ connection.setPreset(greeter); // lets callers use the bare `hello` form
149
+ connection.enableReflection(); // directory / schemas / defaults, for free
150
+ ```
151
+
152
+ Because the served contract is observable from the boundary, generic tooling — explorers, the CLI,
153
+ conformance checkers — works against any endpoint without compiled-in knowledge of its interfaces.
154
+
155
+ ## Streaming
156
+
157
+ Any request can carry in-flight, bidirectionally-correlated stream messages — input from the caller
158
+ *and* progress or partial results from the provider, on the same call. Declare a payload schema per
159
+ direction with `.withStream({ client, server })`:
160
+
161
+ ```ts
162
+ const transcribe = defineInterface({ id: 'acme.voice' }, {
163
+ // client streams audio chunks; server streams partial transcripts; returns the final text.
164
+ session: requestType(z.object({ lang: z.string() }), z.object({ text: z.string() }))
165
+ .withStream({
166
+ client: z.object({ audio: z.string() }), // client → server
167
+ server: z.object({ partial: z.string() }), // server → client
168
+ }),
169
+ });
170
+
171
+ // provider: consume client messages, emit server messages, return the final result.
172
+ server.register(transcribe, {
173
+ session: async ({ lang }, _ctx, stream) => {
174
+ let text = '';
175
+ stream.onMessage(({ audio }) => { // client → server
176
+ text += decode(audio, lang);
177
+ stream.send({ partial: text }); // server → client
178
+ });
179
+ await untilSilence();
180
+ return { text };
181
+ },
182
+ });
183
+
184
+ // caller: send input as it arrives, observe partials, await the final result.
185
+ const call = client.get(transcribe).session(
186
+ { lang: 'en' },
187
+ { onMessage: ({ partial }) => console.log('…', partial) }, // server → client
188
+ );
189
+ await call.send({ audio: chunk1 }); // client → server
190
+ await call.send({ audio: chunk2 });
191
+ const { text } = await call; // final result
192
+ ```
193
+
194
+ Declare only `server` for provider→caller progress, only `client` for caller→provider input, or
195
+ both for a full duplex session. Cancellation (`call.cancel()`), keepalive pings, and idle-timeout
196
+ handling are wired in for you; a handler observes cancellation via an `AbortSignal`.
197
+
198
+ ## Identity & capabilities
199
+
200
+ On an untrusted link — a shared hub, a sandboxed extension — you often need to know *who* is calling
201
+ and *what they're allowed to do*. linkrpc layers both on top of the same JSON-RPC envelope, and both
202
+ are optional: a plain call needs neither.
203
+
204
+ **Signing (identity).** A call can be Ed25519-signed by a **principal**. The signature covers the
205
+ method, params, a freshness timestamp, and a one-time nonce, so a provider can authenticate the
206
+ caller and reject replays or tampered forwards. Principals are managed for you
207
+ (`createManagedPrincipal`, `loadOrCreateIdentity`); on Node, `connectToHub({ principal })` signs
208
+ outgoing calls transparently.
209
+
210
+ **Capabilities (authorization).** A **capability** is a signed grant from an issuer to an audience
211
+ listing exactly which calls are permitted — optionally narrowed to specific services, interfaces,
212
+ param values, or even one exact call. Capabilities delegate by chaining (each link can only narrow
213
+ the authority it received), and a provider runs the **gate** — verify identity → resolve the chain →
214
+ check permission — admitting the call only if some presented capability permits it. It's
215
+ fail-closed: with no permitting capability, a gated call is refused with `-32401 permissionRequired`.
216
+
217
+ Here is a complete capability-gated notes service. First create the identities and issue authority:
218
+
219
+ ```ts
220
+ import {
221
+ defineInterface,
222
+ invoke,
223
+ issueCapability,
224
+ KeypairSigningIdentity,
225
+ prefix,
226
+ requestType,
227
+ TransportPair,
228
+ } from '@hediet/linkrpc';
229
+ import { object, string } from 'zod/mini';
230
+
231
+ const notes = defineInterface({ id: 'demo.notes' }, {
232
+ read: requestType(
233
+ object({ id: string() }),
234
+ object({ contents: string() }),
235
+ ),
236
+ });
237
+
238
+ const adminId = await KeypairSigningIdentity.generateNew();
239
+ const appId = await KeypairSigningIdentity.generateNew();
240
+ const capability = await issueCapability(adminId, {
241
+ audience: appId.publicSigningIdentity,
242
+ permissions: [
243
+ invoke('notes', notes, 'read', {
244
+ id: prefix('public/'),
245
+ }),
246
+ ],
247
+ });
248
+
249
+ // Capabilities are portable JSON, not callbacks or shared process state.
250
+ const capabilityJson = JSON.stringify(capability);
251
+ const transport = new TransportPair();
252
+ const notesById = new Map([
253
+ ['public/roadmap', 'Ship it!'],
254
+ ['private/payroll', 'Classified'],
255
+ ]);
256
+ ```
257
+
258
+ The server gates its incoming transport, so rejected calls never reach its handlers:
259
+
260
+ ```ts
261
+ import { LinkRpcConnection } from '@hediet/linkrpc';
262
+ import { withFullyQualifiedCallGate } from '@hediet/linkrpc-hub/hub/server';
263
+
264
+ const serverTransport = withFullyQualifiedCallGate(transport.b, {
265
+ requireCapability: true,
266
+ trustedRoots: [adminId.publicSigningIdentity],
267
+ });
268
+ const server = LinkRpcConnection.fromTransport(serverTransport);
269
+ let handlerCalls = 0;
270
+
271
+ server.register(notes, {
272
+ read: ({ id }) => {
273
+ handlerCalls++;
274
+ return { contents: notesById.get(id) ?? 'Not found' };
275
+ },
276
+ }, { serviceId: 'notes' });
277
+ ```
278
+
279
+ The client parses the capability as ordinary JSON, then a signing channel attaches it to every call:
280
+
281
+ ```ts
282
+ import {
283
+ LinkRpcConnection,
284
+ JsonRpcChannel,
285
+ Principal,
286
+ SigningSender,
287
+ type SignedCapability,
288
+ } from '@hediet/linkrpc';
289
+
290
+ const receivedCapability: SignedCapability = JSON.parse(capabilityJson);
291
+ const appPrincipal = await Principal.create(appId, [receivedCapability]);
292
+ const signedChannel = SigningSender.wrapChannel(
293
+ JsonRpcChannel.create(transport.a),
294
+ { principal: appPrincipal },
295
+ );
296
+ const client = new LinkRpcConnection(signedChannel);
297
+ const notesClient = client.service('notes').get(notes);
298
+
299
+ await notesClient.read({ id: 'public/roadmap' }); // allowed
300
+ await notesClient.read({ id: 'private/payroll' })
301
+ .catch(error => console.log(error.code)); // -32401 permissionRequired
302
+ handlerCalls; // 1
303
+ ```
304
+
305
+ The service-scoped client emits `notes::demo.notes::read`, so the gate checks it. Root-addressed calls
306
+ terminate at the connection root and always bypass this forwarded-call gate.
307
+
308
+ This is what lets a [hub](../linkrpc-hub) broker calls between mutually-distrusting participants: it
309
+ hands each consumer a scoped capability and enforces it on every routed call.
310
+
311
+ ## A layered protocol
312
+
313
+ linkrpc is built in additive layers. Lower layers stand alone; higher ones ride in reserved
314
+ `$hubrpc`-prefixed members that a peer who doesn't implement them treats as opaque. That's what lets
315
+ a minimal node and a fully-secured node interoperate on any call that needs no gated authority.
316
+
317
+ | Layer | What it adds |
318
+ |---|---|
319
+ | Messages | JSON-RPC envelope, `::` method grammar, error codes |
320
+ | Transport | framed whole-message channel + endpoint URIs |
321
+ | Interfaces | schema format, JSON Schema subset, the interface hash |
322
+ | Reflection | `hubrpc.directory` / `.schemas` / `.defaults` |
323
+ | Streaming *(optional)* | in-flight correlated stream messages |
324
+ | Identity *(optional)* | Ed25519-signed calls (principals, freshness + replay protection) |
325
+ | Capabilities *(optional)* | signed grants + the authorization gate (`permits`) |
326
+
327
+ The full normative protocol — every layer, wire field, and conformance rule — is specified in
328
+ the [LinkRPC specification](../../../spec) (start at [`00-overview.md`](../../../spec/00-overview.md)). The
329
+ hub model and design notes live in [`docs/`](./docs).
330
+
331
+ ## Interface identity & schema tooling
332
+
333
+ Every interface has an identity of the form `test.greeter@<hash>`, where the hash is derived from
334
+ the *normalized* schema of its members. Two peers agree on an interface only when their contracts
335
+ are structurally identical, so a mismatch surfaces up front as a hash disagreement rather than a
336
+ decode error three calls later. The hashing is defined byte-for-byte (RFC 8785 JCS → SHA-256,
337
+ truncated) so independent implementations in any language agree. Because the schema is just data,
338
+ you get tooling for free:
339
+
340
+ - **`computeInterfaceHash(schema)`** — the stable `@hash` for a schema.
341
+ - **`isAssignable(a, b)`** — structural compatibility: is every value of interface `A` accepted by `B`?
342
+ - **`generateTsInterface(schema)`** — emit a `.ts` client/server typing from a schema fetched at runtime.
343
+
344
+ ## Transports & entry points
345
+
346
+ The core (`@hediet/linkrpc`) is environment-agnostic. Platform transports live behind subpath
347
+ exports so browser bundles never pull in Node built-ins:
348
+
349
+ | Import | Provides |
350
+ |---|---|
351
+ | `@hediet/linkrpc` | interfaces, connection, schema/hash, identity, capabilities |
352
+ | `@hediet/linkrpc/node` | NDJSON sockets, WebSocket, stdio, `connectToHub`, endpoint-URI parsing |
353
+ | `@hediet/linkrpc/web` | `WindowMessageTransport` (iframe / web worker) |
354
+ | `@hediet/linkrpc/hub/common`, `/hub/client` | hub-facing interfaces and client helpers |
355
+
356
+ On Node, a single call resolves an endpoint from the environment and dials it:
357
+
358
+ ```ts
359
+ import { connectToHub } from '@hediet/linkrpc/node';
360
+ import { directoryInterface } from '@hediet/linkrpc';
361
+
362
+ // reads LINKRPC_ENDPOINT (unix:/npipe:/ws:/wss:/cmd:) and LINKRPC_TOKEN
363
+ const hub = await connectToHub();
364
+ const dir = hub.connection.get(directoryInterface);
365
+ console.log(await dir.list({}));
366
+ ```
367
+
368
+ ## Companion packages
369
+
370
+ | Package | Role |
371
+ |---|---|
372
+ | **`@hediet/linkrpc`** (this package) | the core library |
373
+ | [`@hediet/linkrpc-cli`](../linkrpc-cli) | generic CLI / terminal UI for any endpoint |
374
+ | [`@hediet/linkrpc-hub`](../linkrpc-hub) | standalone WebSocket hub that routes between participants |
375
+
376
+ ## Development
377
+
378
+ ```sh
379
+ pnpm --filter @hediet/linkrpc build
380
+ pnpm --filter @hediet/linkrpc test
381
+ ```
382
+
383
+ `zod@^4` is a peer dependency.
@@ -0,0 +1,8 @@
1
+ /*---------------------------------------------------------------------------------------------
2
+ * Copyright (c) Microsoft Corporation. All rights reserved.
3
+ * Licensed under the MIT License. See License.txt in the project root for license information.
4
+ *--------------------------------------------------------------------------------------------*/
5
+ //#region \0empty-crypto
6
+ const webcrypto = void 0;
7
+ //#endregion
8
+ export { webcrypto };