@cero-base/core 0.8.9 → 1.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.
package/src/lib/utils.js CHANGED
@@ -3,6 +3,8 @@ import { Readable } from 'streamx'
3
3
  import z32 from 'z32'
4
4
  import { encode, decode, isValid } from 'hypercore-id-encoding'
5
5
 
6
+ import { ROLE_PERMS, RANK } from './constants.js'
7
+
6
8
  /**
7
9
  * Generate a short opaque id (z32-encoded 16 random bytes).
8
10
  *
@@ -33,6 +35,21 @@ export const toKey = decode
33
35
  */
34
36
  export const isKeyId = isValid
35
37
 
38
+ /**
39
+ * Whether `role` is granted `perm` under the default policy.
40
+ *
41
+ * @param {string} role
42
+ * @param {string} perm
43
+ * @returns {boolean}
44
+ */
45
+ export function can(role, perm) {
46
+ const perms = ROLE_PERMS[role]
47
+ return !!perms && (perms.includes('*') || perms.includes(perm))
48
+ }
49
+
50
+ export const grants = (a, b) => RANK[a] != null && RANK[b] != null && RANK[a] >= RANK[b]
51
+ export const outranks = (a, b) => RANK[a] != null && RANK[b] != null && RANK[a] > RANK[b]
52
+
36
53
  /**
37
54
  * Stream-of-snapshots primitive. Couples a `get` (returns the latest value)
38
55
  * with a `watch` (re-fires on change) and emits the newest snapshot every
@@ -41,24 +58,38 @@ export const isKeyId = isValid
41
58
  * @template T
42
59
  * @param {object} args
43
60
  * @param {() => Promise<T> | T} args.get
44
- * @param {(fn: () => void) => (() => void) | void} args.watch Returns an unsubscribe.
61
+ * @param {(fn: () => void) => (() => void) | void} args.watch
45
62
  * @returns {import('streamx').Readable<T>}
46
63
  */
47
64
  export function subscribe({ get, watch }) {
48
65
  let stop = null
66
+ let version = 0
67
+ let last
49
68
  const stream = new Readable({
50
69
  destroy(cb) {
51
70
  Promise.resolve(stop?.()).then(() => cb(null), cb)
52
71
  }
53
72
  })
73
+
74
+ const emit = (data) => {
75
+ const snap = JSON.stringify(data)
76
+ if (snap === last) return
77
+ last = snap
78
+ stream.push(data)
79
+ }
80
+
54
81
  const push = async () => {
55
82
  if (stream.destroyed) return
83
+ const v = ++version
56
84
  try {
57
- stream.push(await get())
85
+ const data = await get()
86
+ if (v !== version || stream.destroyed) return
87
+ emit(data)
58
88
  } catch (e) {
59
89
  stream.destroy(e)
60
90
  }
61
91
  }
92
+
62
93
  stop = watch(push)
63
94
  push()
64
95
  return stream
@@ -227,5 +227,5 @@ function replicateInto(core, stream) {
227
227
  }
228
228
 
229
229
  function isTopic(x) {
230
- return (b4a.isBuffer(x) || x instanceof Uint8Array) && x.length === 32
230
+ return b4a.isBuffer(x) && x.length === 32
231
231
  }
@@ -9,7 +9,7 @@ import { Invite } from './invite.js'
9
9
  import { getEncoding } from '../lib/spec/index.js'
10
10
  import { CeroError } from '../lib/errors.js'
11
11
 
12
- const STATUS_OK = 0
12
+ const STATUS_ACCEPTED = 0
13
13
  const STATUS_DENIED = 1
14
14
 
15
15
  // Wire envelope carried in BlindPairing's `additional.data` on the confirm response.
@@ -25,11 +25,13 @@ const Response = getEncoding('@cero/confirm')
25
25
  * @property {Uint8Array} [topic] Optional override topic; defaults to `identity.publicKey`.
26
26
  * @property {any} [inviteEncoding] compact-encoding type for the invite payload `data`.
27
27
  * @property {any} [joinerEncoding] compact-encoding type for the joiner-supplied `userData`.
28
+ * @property {(err: Error) => void} [onerror] Called when a background candidate fails to process.
28
29
  *
29
30
  * @typedef {object} CreateInviteOpts
30
31
  * @property {string} [role] Role tag bound into the invite.
31
32
  * @property {number} [expiresIn] TTL in ms; `0`/omitted = no expiry.
32
33
  * @property {any} [data] Arbitrary payload (encoded via `inviteEncoding` when set).
34
+ * @property {boolean} [multiUse] Reusable until expiry/revoke; default `false` (consumed on first settle).
33
35
  *
34
36
  * @typedef {object} JoinOpts
35
37
  * @property {any} [userData] Payload sent with the candidate request.
@@ -42,7 +44,7 @@ const Response = getEncoding('@cero/confirm')
42
44
  *
43
45
  * @typedef {{ key: Uint8Array, encryptionKey: Uint8Array | null, additional: Uint8Array | null }} JoinResult
44
46
  *
45
- * @typedef {object} CandidateOpts
47
+ * @typedef {object} RequestOpts
46
48
  * @property {Pairing} pairing Owning Pairing instance.
47
49
  * @property {any} req blind-pairing candidate request being answered.
48
50
  * @property {Invite} invite Invite the candidate paired against.
@@ -59,7 +61,14 @@ const Response = getEncoding('@cero/confirm')
59
61
  */
60
62
  export class Pairing extends ReadyResource {
61
63
  /** @param {PairingOpts} [opts] */
62
- constructor({ network, identity, topic, inviteEncoding = null, joinerEncoding = null } = {}) {
64
+ constructor({
65
+ network,
66
+ identity,
67
+ topic,
68
+ inviteEncoding = null,
69
+ joinerEncoding = null,
70
+ onerror = safetyCatch
71
+ } = {}) {
63
72
  super()
64
73
  if (!network) throw CeroError.REQUIRED('network')
65
74
  if (!identity) throw CeroError.REQUIRED('identity')
@@ -69,6 +78,7 @@ export class Pairing extends ReadyResource {
69
78
  this.topic = topic || identity.publicKey
70
79
  this.inviteEncoding = inviteEncoding
71
80
  this.joinerEncoding = joinerEncoding
81
+ this._onerror = onerror
72
82
 
73
83
  this._blind = null
74
84
  this._member = null
@@ -83,7 +93,7 @@ export class Pairing extends ReadyResource {
83
93
 
84
94
  this._member = this._blind.addMember({
85
95
  discoveryKey: discoveryKey(this.topic),
86
- onadd: (req) => this._onCandidate(req).catch(safetyCatch)
96
+ onadd: (req) => this._onCandidate(req).catch(this._onerror)
87
97
  })
88
98
  }
89
99
 
@@ -112,7 +122,7 @@ export class Pairing extends ReadyResource {
112
122
  */
113
123
  static inviteTopic(invite) {
114
124
  try {
115
- return Invite.parse(invite).blind.discoveryKey
125
+ return Invite.parse(invite).discoveryKey
116
126
  } catch {
117
127
  return null
118
128
  }
@@ -124,7 +134,7 @@ export class Pairing extends ReadyResource {
124
134
  * @param {CreateInviteOpts} [opts]
125
135
  * @returns {Promise<string>}
126
136
  */
127
- async createInvite({ role = '', expiresIn = 0, data = null } = {}) {
137
+ async createInvite({ role = '', expiresIn = 0, data = null, multiUse = false } = {}) {
128
138
  if (this.closing || this.closed) throw CeroError.CLOSED('Pairing')
129
139
  if (!this.opened) await this.ready()
130
140
 
@@ -138,7 +148,7 @@ export class Pairing extends ReadyResource {
138
148
  role,
139
149
  expiresIn,
140
150
  data,
141
- blindInvite: blind.invite,
151
+ blind: blind.invite,
142
152
  encoding: this.inviteEncoding
143
153
  })
144
154
 
@@ -147,7 +157,8 @@ export class Pairing extends ReadyResource {
147
157
  id: blind.id,
148
158
  seed: blind.seed,
149
159
  publicKey: blind.publicKey,
150
- invite
160
+ invite,
161
+ multiUse
151
162
  })
152
163
 
153
164
  return invite.toString()
@@ -202,8 +213,8 @@ export class Pairing extends ReadyResource {
202
213
 
203
214
  if (invite.expired) throw CeroError.EXPIRED()
204
215
 
205
- const encodedUserData = encodeUserData(userData, this.joinerEncoding)
206
- const candidate = new JoinerCandidate(this, invite, encodedUserData, timeout)
216
+ const data = encodeUserData(userData, this.joinerEncoding)
217
+ const candidate = new Candidate(this, invite, data, timeout)
207
218
  return candidate.start()
208
219
  }
209
220
 
@@ -250,6 +261,10 @@ export class Pairing extends ReadyResource {
250
261
  const id = b4a.toString(req.inviteId, 'hex')
251
262
  const record = this._invites.get(id)
252
263
  if (!record) return
264
+ if (record.invite.expired) {
265
+ this._invites.delete(id)
266
+ return
267
+ }
253
268
 
254
269
  try {
255
270
  req.open(record.publicKey)
@@ -260,29 +275,31 @@ export class Pairing extends ReadyResource {
260
275
 
261
276
  const userData = decodeUserData(req.userData, this.joinerEncoding)
262
277
 
263
- const candidate = new Candidate({
278
+ const request = new Request({
264
279
  pairing: this,
265
280
  req,
266
281
  invite: record.invite,
267
282
  seed: record.seed,
268
283
  userData,
269
284
  onsettle: () => {
270
- this._invites.delete(id)
285
+ if (!record.multiUse) this._invites.delete(id)
271
286
  }
272
287
  })
273
288
 
274
- this.emit('candidate', candidate)
289
+ this.emit('candidate', request)
275
290
  }
276
291
  }
277
292
 
278
293
  /**
279
- * Internal — single incoming candidate awaiting host accept/deny. Created by
280
- * `Pairing` and emitted on the `'candidate'` event so the host can decide.
294
+ * Internal — a pairing request from an incoming candidate, awaiting the host's
295
+ * accept/deny. Built in `_onCandidate` and emitted on the `'candidate'` event so
296
+ * the host can decide via `confirm` / `deny`. (The candidate is the joiner; this
297
+ * is the host's handle to answer it.)
281
298
  *
282
299
  * @property {boolean} _settled Whether confirm/deny has already run.
283
300
  */
284
- class Candidate {
285
- /** @param {CandidateOpts} opts */
301
+ class Request {
302
+ /** @param {RequestOpts} opts */
286
303
  constructor({ pairing, req, invite, seed, userData, onsettle }) {
287
304
  this.pairing = pairing
288
305
  this._req = req
@@ -302,36 +319,15 @@ class Candidate {
302
319
  * @returns {Promise<void>}
303
320
  */
304
321
  async confirm({ key, encryptionKey = null, additional = null } = {}) {
322
+ if (this._settled) return
305
323
  if (!key || key.length !== 32) throw CeroError.INVALID('key must be a 32-byte buffer')
306
- if (encryptionKey !== null && encryptionKey !== undefined && encryptionKey.length !== 32) {
324
+ if (encryptionKey && encryptionKey.length !== 32) {
307
325
  throw CeroError.INVALID('encryptionKey must be a 32-byte buffer')
308
326
  }
309
- if (
310
- additional !== null &&
311
- additional !== undefined &&
312
- !b4a.isBuffer(additional) &&
313
- !(additional instanceof Uint8Array)
314
- ) {
327
+ if (additional && !b4a.isBuffer(additional)) {
315
328
  throw CeroError.INVALID('additional must be a buffer')
316
329
  }
317
- if (this._settled) return
318
- this._settled = true
319
- this._onsettle()
320
-
321
- const data = c.encode(Response, {
322
- status: STATUS_OK,
323
- reason: '',
324
- key,
325
- encryptionKey: encryptionKey || null,
326
- extra: additional || null
327
- })
328
- const signature = sign(data, keyPair(this._seed).secretKey)
329
-
330
- this._req.confirm({
331
- key: this.pairing.topic,
332
- encryptionKey: undefined,
333
- additional: { data, signature }
334
- })
330
+ this._respond({ status: STATUS_ACCEPTED, reason: '', key, encryptionKey, extra: additional })
335
331
  }
336
332
 
337
333
  /**
@@ -342,18 +338,22 @@ class Candidate {
342
338
  */
343
339
  async deny(reason = '') {
344
340
  if (this._settled) return
345
- this._settled = true
346
- this._onsettle()
347
-
348
- const data = c.encode(Response, {
341
+ this._respond({
349
342
  status: STATUS_DENIED,
350
- reason: String(reason || ''),
343
+ reason,
351
344
  key: null,
352
345
  encryptionKey: null,
353
346
  extra: null
354
347
  })
355
- const signature = sign(data, keyPair(this._seed).secretKey)
348
+ }
356
349
 
350
+ // sign the envelope with the per-invite seed and answer the candidate
351
+ // (callers guard idempotency before this runs)
352
+ _respond(envelope) {
353
+ this._settled = true
354
+ this._onsettle()
355
+ const data = c.encode(Response, envelope)
356
+ const signature = sign(data, keyPair(this._seed).secretKey)
357
357
  this._req.confirm({
358
358
  key: this.pairing.topic,
359
359
  encryptionKey: undefined,
@@ -363,8 +363,9 @@ class Candidate {
363
363
  }
364
364
 
365
365
  /**
366
- * Internal — joiner-side handshake state machine. One-shot: created by
367
- * `Pairing.join()`, settled exactly once via `_done` or `_fail`.
366
+ * Internal — the joiner side: a candidate's handshake state machine (this *is* the
367
+ * blind-pairing `addCandidate`). One-shot: created by `Pairing.join()`, settled
368
+ * exactly once via `_done` or `_fail`.
368
369
  *
369
370
  * @property {any} _candidate blind-pairing addCandidate handle (null until started).
370
371
  * @property {ReturnType<typeof setTimeout> | null} _timer Pending timeout, if any.
@@ -372,7 +373,7 @@ class Candidate {
372
373
  * @property {((err: Error) => void) | null} _reject Promise rejecter, set in `start`.
373
374
  * @property {boolean} _settled Whether the handshake has already settled.
374
375
  */
375
- class JoinerCandidate {
376
+ class Candidate {
376
377
  /**
377
378
  * @param {Pairing} pairing
378
379
  * @param {Invite} invite
@@ -409,7 +410,7 @@ class JoinerCandidate {
409
410
 
410
411
  try {
411
412
  this._candidate = this.pairing._blind.addCandidate({
412
- invite: this.invite.blindInvite,
413
+ invite: this.invite.blind,
413
414
  userData: this.userData,
414
415
  onadd: (result) => this._done(result)
415
416
  })
@@ -466,10 +467,10 @@ class JoinerCandidate {
466
467
  }
467
468
  }
468
469
 
469
- function encodeUserData(userData, encoding) {
470
- if (userData === null || userData === undefined) return null
471
- if (encoding) return c.encode(encoding, userData)
472
- if (b4a.isBuffer(userData) || userData instanceof Uint8Array) return userData
470
+ function encodeUserData(data, encoding) {
471
+ if (data == null) return null
472
+ if (encoding) return c.encode(encoding, data)
473
+ if (b4a.isBuffer(data)) return data
473
474
  throw CeroError.INVALID('userData must be a buffer when no joinerEncoding is provided')
474
475
  }
475
476
 
@@ -17,7 +17,7 @@ const SignBody = getEncoding('@cero/invite-body')
17
17
  * @property {string} role Optional role tag baked into the signed body.
18
18
  * @property {number} expires Absolute expiry timestamp; `0` means never.
19
19
  * @property {Uint8Array | null} data Encoded payload (raw or via `encoding`).
20
- * @property {Uint8Array} blindInvite Raw blind-pairing invite handed to candidates.
20
+ * @property {Uint8Array} blind Raw blind-pairing invite handed to candidates.
21
21
  * @property {Uint8Array} [sig] Ed25519 signature over the canonical body.
22
22
  * @property {string | null} [_str] Cached z32 string form.
23
23
  *
@@ -27,7 +27,7 @@ const SignBody = getEncoding('@cero/invite-body')
27
27
  * @property {string} role Optional role tag for the joiner.
28
28
  * @property {number} expiresIn TTL in ms from now; `0` means never expires.
29
29
  * @property {any} data Caller payload; encoded via `encoding` when provided.
30
- * @property {Uint8Array} blindInvite Raw blind-pairing invite to wrap.
30
+ * @property {Uint8Array} blind Raw blind-pairing invite to wrap.
31
31
  * @property {any} [encoding] compact-encoding type used to encode `data`.
32
32
  *
33
33
  * @typedef {object} ParseInviteOpts
@@ -40,7 +40,7 @@ const SignBody = getEncoding('@cero/invite-body')
40
40
  * authenticated by the joiner before any handshake happens.
41
41
  *
42
42
  * @property {any} [_rawData] Decoded payload kept alongside the encoded `data` for convenience.
43
- * @property {any} _blind Lazily-decoded blind-pairing invite view (cache).
43
+ * @property {Uint8Array} _discoveryKey Cached discovery key of the wrapped blind invite.
44
44
  */
45
45
  export class Invite {
46
46
  /** @param {InviteFields} fields */
@@ -50,12 +50,12 @@ export class Invite {
50
50
  this.role = fields.role
51
51
  this.expires = fields.expires
52
52
  this.data = fields.data
53
- this.blindInvite = fields.blindInvite
53
+ this.blind = fields.blind
54
54
  this.sig = fields.sig
55
55
  this._str = fields._str || null
56
56
 
57
- // decoded blind-pairing invite info (cached)
58
- this._blind = null
57
+ // cached discovery key of the wrapped blind invite
58
+ this._discoveryKey = null
59
59
  }
60
60
 
61
61
  /**
@@ -68,13 +68,15 @@ export class Invite {
68
68
  }
69
69
 
70
70
  /**
71
- * Lazily-decoded view of the underlying blind-pairing invite.
71
+ * Discovery key of the wrapped blind-pairing invite — the topic it targets.
72
72
  *
73
- * @returns {any}
73
+ * @returns {Uint8Array}
74
74
  */
75
- get blind() {
76
- if (!this._blind) this._blind = BlindPairing.decodeInvite(this.blindInvite)
77
- return this._blind
75
+ get discoveryKey() {
76
+ if (!this._discoveryKey) {
77
+ this._discoveryKey = BlindPairing.decodeInvite(this.blind).discoveryKey
78
+ }
79
+ return this._discoveryKey
78
80
  }
79
81
 
80
82
  /**
@@ -105,18 +107,17 @@ export class Invite {
105
107
  * @param {CreateInviteOpts} opts
106
108
  * @returns {Invite}
107
109
  */
108
- static create({ secretKey, publicKey, role, expiresIn, data, blindInvite, encoding }) {
110
+ static create({ secretKey, publicKey, role, expiresIn, data, blind, encoding }) {
109
111
  if (!secretKey || secretKey.length !== 64)
110
112
  throw CeroError.INVALID('secretKey must be a 64-byte buffer')
111
113
  if (!publicKey || publicKey.length !== 32)
112
114
  throw CeroError.INVALID('publicKey must be a 32-byte buffer')
113
- if (!b4a.isBuffer(blindInvite) && !(blindInvite instanceof Uint8Array)) {
114
- throw CeroError.INVALID('blindInvite must be a buffer')
115
+ if (!b4a.isBuffer(blind)) {
116
+ throw CeroError.INVALID('blind invite must be a buffer')
115
117
  }
116
118
 
117
119
  const expires = expiresIn ? Date.now() + expiresIn : 0
118
- const encoded =
119
- data === undefined || data === null ? null : encoding ? c.encode(encoding, data) : data
120
+ const encoded = data == null ? null : encoding ? c.encode(encoding, data) : data
120
121
 
121
122
  const fields = {
122
123
  version: VERSION,
@@ -124,7 +125,7 @@ export class Invite {
124
125
  role: role || '',
125
126
  expires,
126
127
  data: encoded,
127
- blindInvite
128
+ blind
128
129
  }
129
130
 
130
131
  const body = c.encode(SignBody, fields)
@@ -190,7 +191,7 @@ export class Invite {
190
191
  try {
191
192
  const buf = z32.decode(str)
192
193
  const fields = c.decode(Envelope, buf)
193
- if (fields.blindInvite) BlindPairing.decodeInvite(fields.blindInvite)
194
+ if (fields.blind) BlindPairing.decodeInvite(fields.blind)
194
195
  return true
195
196
  } catch {
196
197
  return false
package/src/rpc/client.js CHANGED
@@ -1,45 +1,7 @@
1
- import ReadyResource from 'ready-resource'
2
- import safetyCatch from 'safety-catch'
3
- import FramedStream from 'framed-stream'
4
-
5
- import { bindCodec } from './index.js'
1
+ import { RPCPeer } from './peer.js'
6
2
 
7
3
  /**
8
- * Client-side RPC adapter. Mirror of `RPCServer` wraps an IPC duplex
9
- * in a length-framed stream and constructs the spec's hrpc binding ready
10
- * to issue requests. Frames are paused until `_open` runs so handlers
11
- * registered before `ready()` see a clean slate.
4
+ * Client-side RPC adapter issues requests over the framed IPC stream.
5
+ * All wiring lives in {@link RPCPeer}; this is the client-named role.
12
6
  */
13
- export class RPCClient extends ReadyResource {
14
- /**
15
- * @param {any} ipc Duplex IPC stream (e.g. a socket or pipe).
16
- * @param {import('./index.js').Spec} spec Spec object exposing `rpc` and `schema`.
17
- */
18
- constructor(ipc, spec) {
19
- super()
20
- if (!ipc) throw new TypeError('ipc is required')
21
- if (!spec) throw new TypeError('spec is required')
22
- if (!spec.rpc) throw new TypeError('spec.rpc is required')
23
-
24
- this.ipc = ipc
25
- this.spec = spec
26
- this.framed = new FramedStream(ipc)
27
- this.framed.pause()
28
- this.rpc = new spec.rpc(this.framed)
29
- }
30
-
31
- async _open() {
32
- if (!this.spec.codec) bindCodec(this.spec)
33
- this.framed.resume()
34
- }
35
-
36
- async _close() {
37
- if (this.framed && !this.framed.destroyed) {
38
- try {
39
- this.framed.destroy()
40
- } catch (err) {
41
- safetyCatch(err)
42
- }
43
- }
44
- }
45
- }
7
+ export class RPCClient extends RPCPeer {}
package/src/rpc/index.js CHANGED
@@ -2,6 +2,7 @@ import b4a from 'b4a'
2
2
  import c from 'compact-encoding'
3
3
 
4
4
  import { getEncoding } from '../lib/spec/index.js'
5
+ import { CeroError } from '../lib/errors.js'
5
6
 
6
7
  export { RPCServer } from './server.js'
7
8
  export { RPCClient } from './client.js'
@@ -44,9 +45,9 @@ const DEFAULT_CREATE = getEncoding('@cero/create')
44
45
  * @returns {Spec}
45
46
  */
46
47
  export function bindCodec(spec) {
47
- if (!spec) throw new TypeError('spec is required')
48
+ if (!spec) throw CeroError.REQUIRED('spec')
48
49
  const schema = spec.schema
49
- if (!schema) throw new TypeError('spec.schema is required')
50
+ if (!schema) throw CeroError.REQUIRED('spec.schema')
50
51
 
51
52
  const ns = spec.meta?.ns
52
53
  const ROWS = ns ? `@${ns}/rows` : null
@@ -83,8 +84,11 @@ export function bindCodec(spec) {
83
84
  if (!buf || buf.length === 0) return undefined
84
85
  const env = decodeEnvelope(schema, QUERY, DEFAULT_QUERY, buf)
85
86
  const out = {}
86
- // Hyperschema fills optional uint/bool with 0/false defaults; only
87
- // include fields the encoder actually wrote (truthy).
87
+ // Hyperschema fills optional uint/bool with 0/false defaults, so we can only
88
+ // recover fields the encoder actually wrote (truthy). KNOWN LIMITATION: a
89
+ // `limit: 0` query (degenerate — "return nothing") is indistinguishable from
90
+ // "no limit" over the wire and is treated as unset. Falsy cursors (gt: '')
91
+ // are harmless — an empty-string bound matches everything anyway.
88
92
  if (env.gt) out.gt = env.gt
89
93
  if (env.gte) out.gte = env.gte
90
94
  if (env.lt) out.lt = env.lt
@@ -0,0 +1,46 @@
1
+ import ReadyResource from 'ready-resource'
2
+ import safetyCatch from 'safety-catch'
3
+ import FramedStream from 'framed-stream'
4
+
5
+ import { bindCodec } from './index.js'
6
+ import { CeroError } from '../lib/errors.js'
7
+
8
+ /**
9
+ * Shared RPC peer: wraps an IPC duplex in a length-framed stream and constructs
10
+ * the spec's hrpc binding. Frames are paused until `_open` runs so handlers
11
+ * registered before `ready()` see a clean slate. `RPCClient` and `RPCServer`
12
+ * are thin named subclasses — identical wiring, distinct roles.
13
+ */
14
+ export class RPCPeer extends ReadyResource {
15
+ /**
16
+ * @param {any} ipc Duplex IPC stream (e.g. a socket or pipe).
17
+ * @param {import('./index.js').Spec} spec Spec object exposing `rpc` and `schema`.
18
+ */
19
+ constructor(ipc, spec) {
20
+ super()
21
+ if (!ipc) throw CeroError.REQUIRED('ipc')
22
+ if (!spec) throw CeroError.REQUIRED('spec')
23
+ if (!spec.rpc) throw CeroError.REQUIRED('spec.rpc')
24
+
25
+ this.ipc = ipc
26
+ this.spec = spec
27
+ this.framed = new FramedStream(ipc)
28
+ this.framed.pause()
29
+ this.rpc = new spec.rpc(this.framed)
30
+ }
31
+
32
+ async _open() {
33
+ if (!this.spec.codec) bindCodec(this.spec)
34
+ this.framed.resume()
35
+ }
36
+
37
+ async _close() {
38
+ if (this.framed && !this.framed.destroyed) {
39
+ try {
40
+ this.framed.destroy()
41
+ } catch (err) {
42
+ safetyCatch(err)
43
+ }
44
+ }
45
+ }
46
+ }
package/src/rpc/server.js CHANGED
@@ -1,45 +1,7 @@
1
- import ReadyResource from 'ready-resource'
2
- import safetyCatch from 'safety-catch'
3
- import FramedStream from 'framed-stream'
4
-
5
- import { bindCodec } from './index.js'
1
+ import { RPCPeer } from './peer.js'
6
2
 
7
3
  /**
8
- * Server-side RPC adapter. Wraps an IPC duplex in a length-framed stream
9
- * and instantiates the spec's hrpc binding once `bindCodec` has populated
10
- * envelope encoders. Reads are paused until `_open` runs so the
11
- * application has a chance to register handlers before frames flow.
4
+ * Server-side RPC adapter serves the spec's handlers over the framed IPC
5
+ * stream. All wiring lives in {@link RPCPeer}; this is the server-named role.
12
6
  */
13
- export class RPCServer extends ReadyResource {
14
- /**
15
- * @param {any} ipc Duplex IPC stream (e.g. a socket or pipe).
16
- * @param {import('./index.js').Spec} spec Spec object exposing `rpc` and `schema`.
17
- */
18
- constructor(ipc, spec) {
19
- super()
20
- if (!ipc) throw new TypeError('ipc is required')
21
- if (!spec) throw new TypeError('spec is required')
22
- if (!spec.rpc) throw new TypeError('spec.rpc is required')
23
-
24
- this.ipc = ipc
25
- this.spec = spec
26
- this.framed = new FramedStream(ipc)
27
- this.framed.pause()
28
- this.rpc = new spec.rpc(this.framed)
29
- }
30
-
31
- async _open() {
32
- if (!this.spec.codec) bindCodec(this.spec)
33
- this.framed.resume()
34
- }
35
-
36
- async _close() {
37
- if (this.framed && !this.framed.destroyed) {
38
- try {
39
- this.framed.destroy()
40
- } catch (err) {
41
- safetyCatch(err)
42
- }
43
- }
44
- }
45
- }
7
+ export class RPCServer extends RPCPeer {}