@vampgg/utils 1.0.0-beta.7 → 1.0.0-beta.9

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/README.md CHANGED
@@ -5,6 +5,16 @@ Low-level utilities for building bebop/tempo-based applications. Provides the sh
5
5
  - **bebop** — binary serialization library for fast, compact data interchange
6
6
  - **tempo** — typed RPC layer built on top of bebop
7
7
 
8
+ ## Optional peer dependencies
9
+
10
+ Three entry points need a package that `@vampgg/utils` does not install. Add the one you import:
11
+
12
+ | Entry point | Install |
13
+ | ------------------------------------------------------------------- | ----------------------- |
14
+ | `@vampgg/utils/pino-logger` | `pino` |
15
+ | `@vampgg/utils/ws-channel` | `websocket-ts` |
16
+ | `@vampgg/utils/extension-channel`, `@vampgg/utils/extension-router` | `webextension-polyfill` |
17
+
8
18
  ## Development
9
19
 
10
20
  ```bash
@@ -25,8 +25,9 @@ declare abstract class CoreChannel extends BaseChannel {
25
25
  /** Per-call response listeners keyed by messageId (response demultiplexing). */
26
26
  readonly events: EventEmitter<Record<string, (message: Message) => void>>;
27
27
  /**
28
- * Reject callbacks for in-flight unary / client-stream calls, keyed by
29
- * messageId. Lets close()/error() settle parked requests instead of hanging.
28
+ * Failure callbacks for every in-flight call (unary, client-stream, and the
29
+ * server/duplex streams), keyed by messageId. Lets close() and a dropped
30
+ * connection settle them instead of leaving them parked.
30
31
  */
31
32
  readonly pending: Map<string, (cause: unknown) => void>;
32
33
  protected closed: boolean;
@@ -74,6 +75,12 @@ declare abstract class CoreChannel extends BaseChannel {
74
75
  * explicit caller.
75
76
  */
76
77
  close(reason?: TempoError): void;
78
+ /**
79
+ * Fail every in-flight call and stream with `reason` without disposing the
80
+ * channel. A transport that can reconnect calls this when its connection
81
+ * drops; the next call reconnects.
82
+ */
83
+ protected failInFlight(reason: TempoError): void;
77
84
  removeCredential(): Promise<void>;
78
85
  getCredential(): Promise<Credential | undefined>;
79
86
  /**
@@ -25,8 +25,9 @@ var CoreChannel = class extends BaseChannel {
25
25
  /** Per-call response listeners keyed by messageId (response demultiplexing). */
26
26
  events = new EventEmitter();
27
27
  /**
28
- * Reject callbacks for in-flight unary / client-stream calls, keyed by
29
- * messageId. Lets close()/error() settle parked requests instead of hanging.
28
+ * Failure callbacks for every in-flight call (unary, client-stream, and the
29
+ * server/duplex streams), keyed by messageId. Lets close() and a dropped
30
+ * connection settle them instead of leaving them parked.
30
31
  */
31
32
  pending = /* @__PURE__ */ new Map();
32
33
  closed = false;
@@ -80,11 +81,20 @@ var CoreChannel = class extends BaseChannel {
80
81
  const err = reason ?? new TempoError(TempoStatusCode.CANCELLED, "channel disposed");
81
82
  this.closeReason = err;
82
83
  this.onClosing(err);
83
- for (const reject of this.pending.values()) reject(err);
84
- this.pending.clear();
84
+ this.failInFlight(err);
85
85
  this.events.removeAllListeners();
86
86
  this.closeTransport();
87
87
  }
88
+ /**
89
+ * Fail every in-flight call and stream with `reason` without disposing the
90
+ * channel. A transport that can reconnect calls this when its connection
91
+ * drops; the next call reconnects.
92
+ */
93
+ failInFlight(reason) {
94
+ const failures = [...this.pending.values()];
95
+ this.pending.clear();
96
+ for (const fail of failures) fail(reason);
97
+ }
88
98
  async removeCredential() {}
89
99
  async getCredential() {}
90
100
  /**
@@ -180,8 +190,16 @@ var CoreChannel = class extends BaseChannel {
180
190
  fetchServerStream(init, context, method, options) {
181
191
  const messageId = init.messageId;
182
192
  return createEventIterator(({ emit, cancel, error }) => {
193
+ if (this.closed) {
194
+ error(this.closeReason ?? new TempoError(TempoStatusCode.CANCELLED, "channel disposed"));
195
+ return;
196
+ }
183
197
  let terminated = false;
184
198
  let onAbort;
199
+ this.pending.set(messageId, (cause) => {
200
+ terminated = true;
201
+ error(cause);
202
+ });
185
203
  const eventHandler = async (message) => {
186
204
  try {
187
205
  if (message.status === TempoStatusCode.CANCELLED) {
@@ -204,6 +222,7 @@ var CoreChannel = class extends BaseChannel {
204
222
  this.events.on(messageId, eventHandler);
205
223
  this.sendFrame(init);
206
224
  return () => {
225
+ this.pending.delete(messageId);
207
226
  this.events.off(messageId, eventHandler);
208
227
  if (onAbort) options?.controller?.signal.removeEventListener("abort", onAbort);
209
228
  if (terminated || this.closed) return;
@@ -225,8 +244,16 @@ var CoreChannel = class extends BaseChannel {
225
244
  data: this.serializeStreamItem(method, value)
226
245
  }));
227
246
  }, ({ emit, cancel, error }) => {
247
+ if (this.closed) {
248
+ error(this.closeReason ?? new TempoError(TempoStatusCode.CANCELLED, "channel disposed"));
249
+ return;
250
+ }
228
251
  let terminated = false;
229
252
  let onAbort;
253
+ this.pending.set(messageId, (cause) => {
254
+ terminated = true;
255
+ error(cause);
256
+ });
230
257
  const eventHandler = async (message) => {
231
258
  try {
232
259
  if (message.status === TempoStatusCode.CANCELLED) {
@@ -248,6 +275,7 @@ var CoreChannel = class extends BaseChannel {
248
275
  }
249
276
  this.events.on(messageId, eventHandler);
250
277
  return () => {
278
+ this.pending.delete(messageId);
251
279
  this.events.off(messageId, eventHandler);
252
280
  if (onAbort) options?.controller?.signal.removeEventListener("abort", onAbort);
253
281
  if (this.cancelDuplexOnCleanup && !terminated && !this.closed) this.sendFrame(Message({
@@ -177,7 +177,6 @@ var TempoExtensionRouter = class extends ExtensionBaseRouter {
177
177
  const send = (message) => this.send(ctx.sender, message);
178
178
  await this.core.processRequest(request, response, ctx, {
179
179
  send,
180
- stampErrorResponse: true,
181
180
  decorateResponse: (message) => {
182
181
  message.msg = "";
183
182
  },
@@ -47,9 +47,13 @@ interface ProcessTransport {
47
47
  invokeServerStream?(request: Message, context: ServerContext, method: BebopMethodAny): Promise<AsyncGenerator<BebopRecord, void, unknown>>;
48
48
  /** Mutate the response envelope after the OK headers are populated. */
49
49
  decorateResponse?(response: Message): void;
50
- /** Stamp messageId/timestamp/methodId from the request onto error frames. */
51
- stampErrorResponse?: boolean;
52
50
  }
51
+ /**
52
+ * Returned by the stream invokers for a frame that continues an already-open
53
+ * client or duplex stream. The frame is delivered to the open invocation, and
54
+ * no response is sent for it: the call that opened the stream owns the reply.
55
+ */
56
+ declare const CONTINUATION: unique symbol;
53
57
  /** Options for {@link RouterCore.writeStreamFrames}. */
54
58
  interface StreamWriteOptions {
55
59
  generator: AsyncGenerator<BebopRecord, void, unknown>;
@@ -94,9 +98,9 @@ declare class RouterCore {
94
98
  constructor(host: RouterCoreHost);
95
99
  private setAuthContext;
96
100
  invokeUnaryMethod(request: Message, context: ServerContext, method: BebopMethodAny, contentType: BebopContentType): Promise<BebopRecord>;
97
- invokeClientStreamMethod(request: Message, context: ServerContext, method: BebopMethodAny, contentType: BebopContentType): Promise<BebopRecord>;
101
+ invokeClientStreamMethod(request: Message, context: ServerContext, method: BebopMethodAny, contentType: BebopContentType): Promise<BebopRecord | typeof CONTINUATION>;
98
102
  invokeServerStreamMethod(request: Message, context: ServerContext, method: BebopMethodAny, contentType: BebopContentType): Promise<AsyncGenerator<BebopRecord, void, unknown>>;
99
- invokeDuplexStreamMethod(request: Message, context: ServerContext, method: BebopMethodAny, contentType: BebopContentType): Promise<AsyncGenerator<BebopRecord, void, unknown>>;
103
+ invokeDuplexStreamMethod(request: Message, context: ServerContext, method: BebopMethodAny, contentType: BebopContentType): Promise<AsyncGenerator<BebopRecord, void, unknown> | typeof CONTINUATION>;
100
104
  /**
101
105
  * Emit a synthetic CANCELLED to every in-flight client/duplex stream so its
102
106
  * generator ends, and drop the tracking entries (connection teardown).
@@ -5,6 +5,12 @@ import { EventEmitter } from "tseep";
5
5
  import { ServerContext } from "@tempojs/server";
6
6
  //#region src/router-core.ts
7
7
  /**
8
+ * Returned by the stream invokers for a frame that continues an already-open
9
+ * client or duplex stream. The frame is delivered to the open invocation, and
10
+ * no response is sent for it: the call that opened the stream owns the reply.
11
+ */
12
+ const CONTINUATION = Symbol("continuation");
13
+ /**
8
14
  * Parse the request's custom-metadata header into a {@link Metadata} (the
9
15
  * default incoming metadata for the worker/extension routers).
10
16
  */
@@ -55,10 +61,9 @@ var RouterCore = class {
55
61
  await this.setAuthContext(request, context);
56
62
  if (this.host.getHooks() !== void 0) await this.host.getHooks().executeRequestHooks(context);
57
63
  const messageId = request.messageId;
58
- const stream = this.clientStreams.get(messageId);
59
- if (stream) {
64
+ if (this.clientStreams.has(messageId)) {
60
65
  this.events.emit(messageId, request);
61
- return await stream;
66
+ return CONTINUATION;
62
67
  }
63
68
  const generator = () => {
64
69
  return createEventIterator(({ emit, cancel }) => {
@@ -102,10 +107,9 @@ var RouterCore = class {
102
107
  await this.setAuthContext(request, context);
103
108
  if (this.host.getHooks() !== void 0) await this.host.getHooks().executeRequestHooks(context);
104
109
  const messageId = request.messageId;
105
- const stream = this.clientStreams.get(messageId);
106
- if (stream) {
110
+ if (this.clientStreams.has(messageId)) {
107
111
  this.events.emit(messageId, request);
108
- return stream;
112
+ return CONTINUATION;
109
113
  }
110
114
  if (!TempoUtil.isAsyncGeneratorFunction(method.invoke.bind(method))) throw new TempoError(TempoStatusCode.INTERNAL, "service method incorrect: method must be async generator");
111
115
  const generator = () => {
@@ -215,15 +219,21 @@ var RouterCore = class {
215
219
  case MethodType.Unary:
216
220
  record = await this.invokeUnaryMethod(request, serverContext, method, contentType);
217
221
  break;
218
- case MethodType.ClientStream:
219
- record = await this.invokeClientStreamMethod(request, serverContext, method, contentType);
222
+ case MethodType.ClientStream: {
223
+ const result = await this.invokeClientStreamMethod(request, serverContext, method, contentType);
224
+ if (result === CONTINUATION) return;
225
+ record = result;
220
226
  break;
227
+ }
221
228
  case MethodType.ServerStream:
222
229
  recordGenerator = transport.invokeServerStream ? await transport.invokeServerStream(request, serverContext, method) : await this.invokeServerStreamMethod(request, serverContext, method, contentType);
223
230
  break;
224
- case MethodType.DuplexStream:
225
- recordGenerator = await this.invokeDuplexStreamMethod(request, serverContext, method, contentType);
231
+ case MethodType.DuplexStream: {
232
+ const result = await this.invokeDuplexStreamMethod(request, serverContext, method, contentType);
233
+ if (result === CONTINUATION) return;
234
+ recordGenerator = result;
226
235
  break;
236
+ }
227
237
  default: throw new TempoError(TempoStatusCode.INTERNAL, "service method incorrect: unknown method type");
228
238
  }
229
239
  const outgoingCredential = serverContext.outgoingCredential;
@@ -261,19 +271,17 @@ var RouterCore = class {
261
271
  if (e.status === TempoStatusCode.INTERNAL) this.host.logger.critical(e.message, void 0, e);
262
272
  else this.host.logger.error(message, void 0, e);
263
273
  } else if (e instanceof Error) {
264
- message = e.message;
265
- this.host.logger.error(message, void 0, e);
274
+ this.host.logger.error(e.message, void 0, e);
275
+ message = this.host.transmitInternalErrors === true ? e.message : "internal error";
266
276
  }
267
277
  const hooks = this.host.getHooks();
268
278
  if (e instanceof Error && hooks !== void 0) await hooks.executeErrorHooks(context, e);
269
279
  this.clientStreams.delete(request.messageId);
270
280
  response.status = status;
271
281
  response.msg = message;
272
- if (transport.stampErrorResponse) {
273
- response.messageId = request.messageId;
274
- response.timestamp = request.timestamp;
275
- response.methodId = request.methodId;
276
- }
282
+ response.messageId = request.messageId;
283
+ response.timestamp = request.timestamp;
284
+ response.methodId = request.methodId;
277
285
  await transport.send(response);
278
286
  }
279
287
  }
@@ -50,7 +50,6 @@ var TempoWorkerRouter = class extends BaseRouter {
50
50
  };
51
51
  await this.core.processRequest(request, response, env, {
52
52
  send,
53
- stampErrorResponse: true,
54
53
  writeStreamFrames: async (generator, resp, method) => {
55
54
  const messageId = request.messageId;
56
55
  const streamInfo = {
@@ -1,7 +1,6 @@
1
1
  import { Message } from "./bebop.mjs";
2
2
  import { CoreChannel } from "./channel-core.mjs";
3
3
  import { CallCredential, TempoChannelOptions } from "@tempojs/client";
4
- import * as _tempojs_common0 from "@tempojs/common";
5
4
  import { BebopContentType, Credential } from "@tempojs/common";
6
5
  import { Websocket, WebsocketOptions } from "websocket-ts";
7
6
 
@@ -27,7 +26,7 @@ declare class TempoWSChannel extends CoreChannel {
27
26
  private readonly maxSendMessageSize;
28
27
  private readonly _url;
29
28
  private readonly _wsOptions;
30
- get log(): _tempojs_common0.TempoLogger;
29
+ get log(): import("@tempojs/common").TempoLogger;
31
30
  /**
32
31
  * Constructs a new TempoChannel instance.
33
32
  *
@@ -91,21 +91,21 @@ var TempoWSChannel = class TempoWSChannel extends CoreChannel {
91
91
  this.logger.trace(`opened TempoWSChannel for ${this._url.href} / ${this.userAgent}`);
92
92
  });
93
93
  this.ws.addEventListener(WebsocketEvent.close, (ws) => {
94
- if (this.closed) return;
94
+ if (this.closed || ws !== this.ws) return;
95
95
  if (ws.lastConnection === void 0) return;
96
96
  this.logger.trace(`closed TempoWSChannel for ${this._url.href} / ${this.userAgent}`);
97
- this.close(new TempoError(TempoStatusCode.UNAVAILABLE, "channel closed"));
97
+ this.failInFlight(new TempoError(TempoStatusCode.UNAVAILABLE, "connection closed"));
98
98
  });
99
99
  this.ws.addEventListener(WebsocketEvent.error, (ws) => {
100
- if (this.closed) return;
100
+ if (this.closed || ws !== this.ws) return;
101
101
  if (ws.lastConnection === void 0) return;
102
- this.close(new TempoError(TempoStatusCode.UNAVAILABLE, "channel transport error"));
102
+ this.failInFlight(new TempoError(TempoStatusCode.UNAVAILABLE, "connection error"));
103
103
  });
104
104
  this.ws.addEventListener(WebsocketEvent.message, (_ws, ev) => {
105
105
  const data = ev.data;
106
106
  const byteLength = data instanceof ArrayBuffer ? data.byteLength : data.length;
107
107
  if (byteLength > this.maxReceiveMessageSize) {
108
- this.logger.error(`inbound frame ${byteLength}B exceeds maxReceiveMessageSize ${this.maxReceiveMessageSize}B; dropping`);
108
+ this.failInFlight(new TempoError(TempoStatusCode.RESOURCE_EXHAUSTED, `inbound frame ${byteLength}B exceeds maxReceiveMessageSize ${this.maxReceiveMessageSize}B`));
109
109
  return;
110
110
  }
111
111
  let message;
@@ -153,9 +153,10 @@ var TempoWSChannel = class TempoWSChannel extends CoreChannel {
153
153
  await new Promise((r) => setTimeout(r, 100));
154
154
  this._reconnect();
155
155
  }
156
+ if (this.closed) throw this.closeReason ?? new TempoError(TempoStatusCode.CANCELLED, "channel disposed");
156
157
  const state = this.ws.readyState;
157
158
  if (state === 1) return;
158
- if (state === 2 || state === 3) throw new TempoError(TempoStatusCode.UNAVAILABLE, "socket is closing or closed");
159
+ if (state === 2 || state === 3) this._reconnect();
159
160
  try {
160
161
  await Deadline.after(5, "seconds").executeWithinDeadline(() => new Promise((resolve, reject) => {
161
162
  const teardown = () => {
@@ -107,10 +107,7 @@ var TempoWsRouter = class extends BaseRouter {
107
107
  connection.core.removeAllListeners();
108
108
  }
109
109
  async process(req, response, env) {
110
- if (req.byteLength > this.maxReceiveMessageSize) {
111
- this.logger.error(`inbound frame ${req.byteLength}B exceeds maxReceiveMessageSize ${this.maxReceiveMessageSize}B; dropping`);
112
- return;
113
- }
110
+ if (req.byteLength > this.maxReceiveMessageSize) throw new TempoError(TempoStatusCode.RESOURCE_EXHAUSTED, `inbound frame ${req.byteLength}B exceeds maxReceiveMessageSize ${this.maxReceiveMessageSize}B`);
114
111
  const request = Message.decode(new Uint8Array(req));
115
112
  const [, ws] = env;
116
113
  const connection = this.connection(ws);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vampgg/utils",
3
- "version": "1.0.0-beta.7",
3
+ "version": "1.0.0-beta.9",
4
4
  "description": "Tempo RPC, Bebop serialization, transports, and async primitives for @vampgg.",
5
5
  "homepage": "https://github.com/sammccord/vamp/tree/main/packages/utils#readme",
6
6
  "bugs": {
@@ -44,10 +44,7 @@
44
44
  "@tempojs/common": "^0.0.12",
45
45
  "@tempojs/server": "^0.0.12",
46
46
  "bebop": "3.2.3",
47
- "pino": "^10.3.1",
48
- "tseep": "^1.3.1",
49
- "webextension-polyfill": "^0.12.0",
50
- "websocket-ts": "^2.2.1"
47
+ "tseep": "^1.3.1"
51
48
  },
52
49
  "devDependencies": {
53
50
  "@types/bun": "^1.3.14",
@@ -55,9 +52,28 @@
55
52
  "@types/webextension-polyfill": "^0.12.5",
56
53
  "@typescript/native-preview": "7.0.0-dev.20260315.1",
57
54
  "bebop-tools": "^3.2.3",
55
+ "pino": "^10.3.1",
58
56
  "typescript": "^5.9.3",
59
57
  "vite-plus": "^0.1.24",
60
- "vitest": "npm:@voidzero-dev/vite-plus-test@latest"
58
+ "vitest": "npm:@voidzero-dev/vite-plus-test@0.1.24",
59
+ "webextension-polyfill": "^0.12.0",
60
+ "websocket-ts": "^2.2.1"
61
+ },
62
+ "peerDependencies": {
63
+ "pino": "^10.3.1",
64
+ "webextension-polyfill": "^0.12.0",
65
+ "websocket-ts": "^2.2.1"
66
+ },
67
+ "peerDependenciesMeta": {
68
+ "pino": {
69
+ "optional": true
70
+ },
71
+ "webextension-polyfill": {
72
+ "optional": true
73
+ },
74
+ "websocket-ts": {
75
+ "optional": true
76
+ }
61
77
  },
62
78
  "scripts": {
63
79
  "build": "bebopc build && vp pack",