@vampgg/utils 1.0.0-beta.2 → 1.0.0-beta.4

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.
@@ -1,9 +1,8 @@
1
1
  import { Message } from "./bebop.mjs";
2
- import { createEventIterator } from "./create-event-iterator.mjs";
3
- import { Deadline, Metadata, MethodType, TempoError, TempoStatusCode, TempoUtil, TempoVersion, stringifyCredential } from "@tempojs/common";
4
- import { EventEmitter } from "tseep";
5
- import { tabs } from "webextension-polyfill";
6
- import { ServerContext, TempoRouterConfiguration } from "@tempojs/server";
2
+ import { RouterCore } from "./router-core.mjs";
3
+ import { Metadata, TempoError, TempoStatusCode, TempoUtil, TempoVersion } from "@tempojs/common";
4
+ import { runtime, tabs } from "webextension-polyfill";
5
+ import { TempoRouterConfiguration } from "@tempojs/server";
7
6
  //#region src/extension-router.ts
8
7
  /**
9
8
  * Interface defining the configuration options for a TempoRouter instance.
@@ -39,6 +38,10 @@ var TempoExtensionRouterConfiguration = class {
39
38
  */
40
39
  maxRetryAttempts;
41
40
  /**
41
+ * Optional flag to indicate whether the tempo version, runtime, and variant should be exposed in responses via X-Powered-By (and if the GET endpoint should be enabled). Defaults to false.
42
+ */
43
+ exposeTempo;
44
+ /**
42
45
  * Constructs a new instance of TempoRouterConfiguration with default values.
43
46
  */
44
47
  constructor() {
@@ -146,230 +149,80 @@ var ExtensionBaseRouter = class {
146
149
  }
147
150
  };
148
151
  var TempoExtensionRouter = class extends ExtensionBaseRouter {
149
- events = new EventEmitter();
150
- clientStreams = /* @__PURE__ */ new Map();
152
+ core;
151
153
  constructor(logger, registry, configuration = new TempoRouterConfiguration(), authInterceptor) {
152
154
  super(logger, registry, configuration, authInterceptor);
153
- }
154
- async setAuthContext(request, context) {
155
- const authHeader = request.authorization;
156
- if (authHeader !== void 0 && this.authInterceptor !== void 0) {
157
- const authContext = await this.authInterceptor.intercept(context, authHeader);
158
- if (authContext !== void 0) context.authContext = authContext;
159
- }
160
- }
161
- async invokeUnaryMethod(request, context, method) {
162
- await this.setAuthContext(request, context);
163
- if (this.hooks !== void 0) await this.hooks.executeRequestHooks(context);
164
- const record = this.deserializeRequest(request.data, method, "bebop");
165
- if (this.hooks !== void 0) await this.hooks.executeDecodeHooks(context, record);
166
- return await method.invoke(record, context);
167
- }
168
- async invokeClientStreamMethod(request, context, method) {
169
- await this.setAuthContext(request, context);
170
- if (this.hooks !== void 0) await this.hooks.executeRequestHooks(context);
171
- const messageId = request.messageId;
172
- if (this.clientStreams.has(messageId)) {
173
- const invocation = this.clientStreams.get(messageId);
174
- this.events.emit(messageId, request);
175
- return await invocation;
176
- }
177
- const generator = () => {
178
- return createEventIterator(({ emit, cancel }) => {
179
- const eventHandler = async (message) => {
180
- if (this.hooks !== void 0) await this.hooks.executeRequestHooks(context);
181
- if (message.status === TempoStatusCode.CANCELLED) {
182
- cancel();
183
- return;
184
- }
185
- const requestData = message.data;
186
- const record = this.deserializeRequest(requestData, method, "bebop");
187
- if (this.hooks !== void 0) await this.hooks.executeDecodeHooks(context, record);
188
- emit(record);
189
- };
190
- this.events.on(messageId, eventHandler);
191
- return () => {
192
- this.events.off(messageId, eventHandler);
193
- this.clientStreams.delete(messageId);
194
- };
195
- });
196
- };
197
- const invocation = method.invoke(generator, context);
198
- this.clientStreams.set(messageId, invocation);
199
- this.events.emit(messageId, request);
200
- return await invocation.finally(() => {
201
- this.clientStreams.delete(messageId);
155
+ this.core = new RouterCore({
156
+ logger,
157
+ maxRetryAttempts: this.maxRetryAttempts,
158
+ transmitInternalErrors: this.transmitInternalErrors,
159
+ authInterceptor,
160
+ getMethod: (methodId) => this.registry.getMethod(methodId),
161
+ getHooks: () => this.hooks,
162
+ deserializeRequest: (data, method, contentType) => this.deserializeRequest(data, method, contentType),
163
+ serializeResponse: (record, method, contentType) => this.serializeResponse(record, method, contentType)
202
164
  });
203
165
  }
204
- async invokeServerStreamMethod(request, context, method) {
205
- await this.setAuthContext(request, context);
206
- if (this.hooks !== void 0) await this.hooks.executeRequestHooks(context);
207
- const requestData = request.data;
208
- const record = this.deserializeRequest(requestData, method, "bebop");
209
- if (!TempoUtil.isAsyncGeneratorFunction(method.invoke.bind(method))) throw new TempoError(TempoStatusCode.INTERNAL, "service method incorrect: method must be async generator");
210
- if (this.hooks !== void 0) await this.hooks.executeDecodeHooks(context, record);
211
- return method.invoke(record, context);
212
- }
213
- async invokeDuplexStreamMethod(request, context, method) {
214
- await this.setAuthContext(request, context);
215
- if (this.hooks !== void 0) await this.hooks.executeRequestHooks(context);
216
- const messageId = request.messageId;
217
- if (this.clientStreams.has(messageId)) {
218
- const invocation = this.clientStreams.get(messageId);
219
- this.events.emit(messageId, request);
220
- return invocation;
221
- }
222
- if (!TempoUtil.isAsyncGeneratorFunction(method.invoke.bind(method))) throw new TempoError(TempoStatusCode.INTERNAL, "service method incorrect: method must be async generator");
223
- const generator = () => {
224
- return createEventIterator(({ emit, cancel }) => {
225
- const eventHandler = async (message) => {
226
- if (this.hooks !== void 0) await this.hooks.executeRequestHooks(context);
227
- if (message.status === TempoStatusCode.CANCELLED) {
228
- cancel();
229
- return;
230
- }
231
- const requestData = message.data;
232
- const record = this.deserializeRequest(requestData, method, "bebop");
233
- if (this.hooks !== void 0) await this.hooks.executeDecodeHooks(context, record);
234
- emit(record);
235
- };
236
- this.events.on(messageId, eventHandler);
237
- this.events.emit(messageId, request);
238
- return () => {
239
- this.events.off(messageId, eventHandler);
240
- this.clientStreams.delete(messageId);
241
- };
242
- });
243
- };
244
- const invocation = method.invoke(generator, context);
245
- this.clientStreams.set(messageId, invocation);
246
- return invocation;
247
- }
248
166
  /**
249
167
  * Tear down all open streams for this connection. Emits a synthetic CANCELLED
250
168
  * to each in-flight client/duplex stream so its generator ends, then clears
251
169
  * all listeners.
252
170
  */
253
171
  async closeConnection() {
254
- for (const id of [...this.clientStreams.keys()]) {
255
- this.events.emit(id, Message({
256
- messageId: id,
257
- status: TempoStatusCode.CANCELLED,
258
- data: new Uint8Array()
259
- }));
260
- this.clientStreams.delete(id);
261
- }
262
- this.events.removeAllListeners();
172
+ this.core.cancelClientStreams();
173
+ this.core.removeAllListeners();
263
174
  }
264
175
  async process(req, response, ctx) {
265
- let request = Message(req);
266
- let context;
267
- try {
268
- const methodId = request.methodId;
269
- const method = this.registry.getMethod(methodId);
270
- if (!method) throw new TempoError(TempoStatusCode.NOT_FOUND, `no service is registered which contains a method of '${methodId}'`);
271
- const metadataHeader = request.customMetadata;
272
- const metadata = metadataHeader ? Metadata.fromHttpHeader(metadataHeader) : new Metadata();
273
- const previousAttempts = request.previousAttempts;
274
- if (previousAttempts !== void 0 && previousAttempts > this.maxRetryAttempts) throw new TempoError(TempoStatusCode.RESOURCE_EXHAUSTED, "max retry attempts exceeded");
275
- let deadline;
276
- const deadlineHeader = request.deadline;
277
- if (deadlineHeader !== void 0) deadline = Deadline.fromUnixTimestamp(deadlineHeader.valueOf());
278
- if (deadline !== void 0 && deadline.isExpired()) throw new TempoError(TempoStatusCode.DEADLINE_EXCEEDED, "incoming request has already exceeded its deadline");
279
- const outgoingMetadata = new Metadata();
280
- const incomingContext = {
281
- headers: new Headers(),
282
- metadata
283
- };
284
- if (deadline !== void 0) incomingContext.deadline = deadline;
285
- context = new ServerContext(incomingContext, { metadata: outgoingMetadata }, ctx);
286
- const handleRequest = async () => {
287
- let recordGenerator = void 0;
288
- let record;
289
- switch (method.type) {
290
- case MethodType.Unary:
291
- record = await this.invokeUnaryMethod(request, context, method);
292
- break;
293
- case MethodType.ClientStream:
294
- record = await this.invokeClientStreamMethod(request, context, method);
295
- break;
296
- case MethodType.ServerStream:
297
- recordGenerator = await this.invokeServerStreamMethod(request, context, method);
298
- break;
299
- case MethodType.DuplexStream:
300
- recordGenerator = await this.invokeDuplexStreamMethod(request, context, method);
301
- break;
302
- default: throw new TempoError(TempoStatusCode.INTERNAL, "service method incorrect: unknown method type");
303
- }
304
- const outgoingCredential = context.outgoingCredential;
305
- if (outgoingCredential) response.credential = stringifyCredential(outgoingCredential);
306
- response.methodId = request.methodId;
307
- response.messageId = request.messageId;
308
- response.status = TempoStatusCode.OK;
309
- response.timestamp = /* @__PURE__ */ new Date();
310
- response.msg = "";
311
- if (this.hooks !== void 0) await this.hooks.executeResponseHooks(context);
312
- outgoingMetadata.freeze();
313
- if (outgoingMetadata.size() > 0) response.customMetadata = outgoingMetadata.data;
314
- if (recordGenerator !== void 0) {
315
- const writeFrames = async () => {
316
- for await (const value of recordGenerator) {
317
- const responseData = this.serializeResponse(value, method, "bebop");
318
- response.data = new Uint8Array(responseData);
319
- await this.send(ctx.sender, response);
320
- }
321
- response.data = new Uint8Array();
322
- response.status = TempoStatusCode.CANCELLED;
323
- await this.send(ctx.sender, response);
324
- };
325
- if (deadline) await deadline.executeWithinDeadline(writeFrames);
326
- else await writeFrames();
327
- } else {
328
- if (record === void 0) throw new TempoError(TempoStatusCode.INTERNAL, "service method did not return a record");
329
- const responseData = this.serializeResponse(record, method, "bebop");
330
- response.data = new Uint8Array(responseData);
331
- await this.send(ctx.sender, response);
332
- }
333
- };
334
- if (deadline !== void 0) await deadline.executeWithinDeadline(handleRequest);
335
- else await handleRequest();
336
- } catch (e) {
337
- let status = TempoStatusCode.UNKNOWN;
338
- let message = "unknown error";
339
- if (e instanceof TempoError) {
340
- status = e.status;
341
- message = e.message;
342
- if (e.status === TempoStatusCode.INTERNAL && this.transmitInternalErrors !== true) message = "internal error";
343
- if (e.status === TempoStatusCode.INTERNAL) this.logger.critical(e.message, void 0, e);
344
- else this.logger.error(message, void 0, e);
345
- } else if (e instanceof Error) {
346
- message = e.message;
347
- this.logger.error(message, void 0, e);
348
- }
349
- if (e instanceof Error && this.hooks !== void 0) await this.hooks.executeErrorHooks(context, e);
350
- this.clientStreams.delete(request.messageId);
351
- response.status = status;
352
- response.msg = message;
353
- response.messageId = request.messageId;
354
- response.timestamp = request.timestamp;
355
- response.methodId = request.methodId;
356
- await this.send(ctx.sender, response);
357
- }
176
+ const request = Message(req);
177
+ const send = (message) => this.send(ctx.sender, message);
178
+ await this.core.processRequest(request, response, ctx, {
179
+ send,
180
+ stampErrorResponse: true,
181
+ decorateResponse: (message) => {
182
+ message.msg = "";
183
+ },
184
+ writeStreamFrames: (generator, resp, method) => this.core.writeStreamFrames({
185
+ generator,
186
+ response: resp,
187
+ method,
188
+ send
189
+ })
190
+ });
358
191
  }
359
192
  async send(sender, message) {
360
- if (!sender.tab?.id || sender.tab?.discarded === true) {
361
- this.logger.warn(`ignoring message to discarded tab`, { tab: sender.tab });
362
- return;
363
- }
193
+ const payload = Array.from(Message.encode(message));
364
194
  try {
365
- await tabs.sendMessage(sender.tab.id, Array.apply(null, Message.encode(message)));
195
+ if (sender.tab?.id !== void 0 && sender.tab.discarded !== true) await tabs.sendMessage(sender.tab.id, payload);
196
+ else await runtime.sendMessage(payload);
366
197
  } catch (e) {
367
- this.logger.error("failed to send message, tab is likely inactive", {}, e);
198
+ this.logger.debug("extension reply not delivered; receiver likely gone", { tab: sender.tab }, e);
368
199
  }
369
200
  }
370
201
  async handle(_req, _ctx) {
371
202
  return Message({});
372
203
  }
373
204
  };
205
+ /**
206
+ * Inbound guard + dispatch for the background/service-worker `runtime.onMessage`
207
+ * hook, which the router does not install itself. Only frames from this
208
+ * extension that decode to a Message with a registered methodId are processed;
209
+ * everything else (other extensions, reply frames, malformed traffic) is
210
+ * ignored. Replies travel back through the router's send path, so this never
211
+ * uses `sendResponse`.
212
+ */
213
+ function createExtensionListener(router, registry) {
214
+ return (raw, sender) => {
215
+ if (sender.id !== runtime.id) return void 0;
216
+ if (!Array.isArray(raw)) return void 0;
217
+ let message;
218
+ try {
219
+ message = Message(Message.decode(new Uint8Array(raw)));
220
+ } catch {
221
+ return;
222
+ }
223
+ if (message.methodId === void 0 || !registry.getMethod(message.methodId)) return;
224
+ router.process(message, Message({}), { sender });
225
+ };
226
+ }
374
227
  //#endregion
375
- export { ExtensionBaseRouter, TempoExtensionRouter, TempoExtensionRouterConfiguration };
228
+ export { ExtensionBaseRouter, TempoExtensionRouter, TempoExtensionRouterConfiguration, createExtensionListener };
@@ -0,0 +1,121 @@
1
+ import { Message } from "./bebop.mjs";
2
+ import { BebopRecord } from "bebop";
3
+ import { BebopContentType, HookRegistry, Metadata, TempoLogger } from "@tempojs/common";
4
+ import { EventEmitter } from "tseep";
5
+ import { AuthInterceptor, BebopMethodAny, ServerContext } from "@tempojs/server";
6
+
7
+ //#region src/router-core.d.ts
8
+ /**
9
+ * The slice of a concrete router (`BaseRouter` / `ExtensionBaseRouter`
10
+ * subclass) that {@link RouterCore} needs. Supplied as delegate closures from
11
+ * the router's constructor because the underlying members are `protected`.
12
+ */
13
+ interface RouterCoreHost {
14
+ readonly logger: TempoLogger;
15
+ readonly maxRetryAttempts: number;
16
+ readonly transmitInternalErrors: boolean;
17
+ readonly authInterceptor?: AuthInterceptor;
18
+ /** Resolve a method from the router's service registry. */
19
+ getMethod(methodId: number): BebopMethodAny | undefined;
20
+ /** Read the router's current hook registry (set later via `useHooks`). */
21
+ getHooks(): HookRegistry<ServerContext, any> | undefined;
22
+ deserializeRequest(requestData: Uint8Array, method: BebopMethodAny, contentType: BebopContentType): BebopRecord;
23
+ serializeResponse(response: BebopRecord, method: BebopMethodAny, contentType: BebopContentType): Uint8Array;
24
+ }
25
+ /**
26
+ * The transport-specific pieces of one `process()` invocation, built fresh per
27
+ * request so the callbacks can close over the transport handle (socket, worker
28
+ * port, extension sender) and the request envelope.
29
+ */
30
+ interface ProcessTransport {
31
+ /** Deliver a fully-populated response envelope to the client. */
32
+ send(response: Message): void | Promise<void>;
33
+ /**
34
+ * Pump a server-stream / duplex generator into response frames (usually via
35
+ * {@link RouterCore.writeStreamFrames}). Runs inside the request deadline.
36
+ */
37
+ writeStreamFrames(generator: AsyncGenerator<BebopRecord, void, unknown>, response: Message, method: BebopMethodAny): Promise<void>;
38
+ /**
39
+ * Build the incoming request {@link Metadata} for the ServerContext.
40
+ * Defaults to parsing the request's custom-metadata header.
41
+ */
42
+ buildIncomingMetadata?(request: Message): Metadata;
43
+ /**
44
+ * Override the server-stream invocation (the WebSocket router layers
45
+ * CANCELLED handling and generator tracking on top of the shared one).
46
+ */
47
+ invokeServerStream?(request: Message, context: ServerContext, method: BebopMethodAny): Promise<AsyncGenerator<BebopRecord, void, unknown>>;
48
+ /** Mutate the response envelope after the OK headers are populated. */
49
+ decorateResponse?(response: Message): void;
50
+ /** Stamp messageId/timestamp/methodId from the request onto error frames. */
51
+ stampErrorResponse?: boolean;
52
+ }
53
+ /** Options for {@link RouterCore.writeStreamFrames}. */
54
+ interface StreamWriteOptions {
55
+ generator: AsyncGenerator<BebopRecord, void, unknown>;
56
+ response: Message;
57
+ method: BebopMethodAny;
58
+ contentType?: BebopContentType;
59
+ /** Deliver one populated frame to the client. */
60
+ send: (response: Message) => void | Promise<void>;
61
+ /**
62
+ * Serialize one record into owned payload bytes. The default copies out of
63
+ * bebop's shared write buffer; the WebSocket router substitutes its
64
+ * encode-once broadcast cache.
65
+ */
66
+ serializePayload?: (value: BebopRecord) => Uint8Array;
67
+ /** Checked before each frame; `true` stops the pump (worker cancellation). */
68
+ shouldStop?: () => boolean;
69
+ /** Send the terminal frame even when the generator throws (worker semantics). */
70
+ alwaysTerminate?: boolean;
71
+ /** Runs after the pump ends, before the terminal frame (tracking-map reaping). */
72
+ onEnd?: () => void;
73
+ }
74
+ /**
75
+ * Shared server-side routing core for the worker, WebSocket, and
76
+ * browser-extension routers.
77
+ *
78
+ * Owns the per-connection stream state (response demultiplexing events and
79
+ * in-flight client/duplex streams) plus every transport-agnostic step of
80
+ * dispatching one framed request: auth interception, hook execution, the four
81
+ * RPC invocation shapes, response population, deadline enforcement, and error
82
+ * mapping. Each concrete router composes an instance and passes the genuinely
83
+ * transport-specific bits per request via {@link ProcessTransport}.
84
+ *
85
+ * Internal to `@vampgg/utils`; the public routers are `TempoWorkerRouter`,
86
+ * `TempoWsRouter`, and `TempoExtensionRouter`.
87
+ */
88
+ declare class RouterCore {
89
+ private readonly host;
90
+ /** Per-stream frame listeners keyed by messageId. */
91
+ readonly events: EventEmitter<Record<string, (message: Message) => void>>;
92
+ /** In-flight client/duplex stream invocations keyed by messageId. */
93
+ readonly clientStreams: Map<string, Promise<any>>;
94
+ constructor(host: RouterCoreHost);
95
+ private setAuthContext;
96
+ invokeUnaryMethod(request: Message, context: ServerContext, method: BebopMethodAny, contentType: BebopContentType): Promise<BebopRecord>;
97
+ invokeClientStreamMethod(request: Message, context: ServerContext, method: BebopMethodAny, contentType: BebopContentType): Promise<BebopRecord>;
98
+ 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>>;
100
+ /**
101
+ * Emit a synthetic CANCELLED to every in-flight client/duplex stream so its
102
+ * generator ends, and drop the tracking entries (connection teardown).
103
+ */
104
+ cancelClientStreams(): void;
105
+ /** Drop every per-stream frame listener (connection teardown). */
106
+ removeAllListeners(): void;
107
+ /**
108
+ * Pump a server-stream / duplex generator into response frames and emit the
109
+ * terminal CANCELLED frame that tells the client the stream is complete.
110
+ */
111
+ writeStreamFrames(opts: StreamWriteOptions): Promise<void>;
112
+ /**
113
+ * Dispatch one decoded request envelope: resolve the method, enforce retry
114
+ * and deadline limits, build the {@link ServerContext}, invoke the method by
115
+ * type, populate and deliver the response, and map any failure onto an error
116
+ * frame. Transport-specific behavior is injected via `transport`.
117
+ */
118
+ processRequest(request: Message, response: Message, env: unknown, transport: ProcessTransport, contentType?: BebopContentType): Promise<void>;
119
+ }
120
+ //#endregion
121
+ export { ProcessTransport, RouterCore, RouterCoreHost, StreamWriteOptions };