@vampgg/utils 1.0.0-beta.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/LICENSE +21 -0
- package/README.md +467 -0
- package/dist/async-queue.d.mts +68 -0
- package/dist/async-queue.mjs +156 -0
- package/dist/bebop.d.mts +37 -0
- package/dist/bebop.mjs +434 -0
- package/dist/context-logger.d.mts +11 -0
- package/dist/context-logger.mjs +31 -0
- package/dist/create-duplex-iterator.d.mts +16 -0
- package/dist/create-duplex-iterator.mjs +36 -0
- package/dist/create-event-iterator.d.mts +39 -0
- package/dist/create-event-iterator.mjs +67 -0
- package/dist/debug-logger.d.mts +43 -0
- package/dist/debug-logger.mjs +122 -0
- package/dist/error.d.mts +27 -0
- package/dist/error.mjs +49 -0
- package/dist/extension-channel.d.mts +96 -0
- package/dist/extension-channel.mjs +377 -0
- package/dist/extension-router.d.mts +154 -0
- package/dist/extension-router.mjs +375 -0
- package/dist/memory-storage.d.mts +12 -0
- package/dist/memory-storage.mjs +18 -0
- package/dist/pino-logger.d.mts +44 -0
- package/dist/pino-logger.mjs +127 -0
- package/dist/sync-storage.d.mts +11 -0
- package/dist/sync-storage.mjs +18 -0
- package/dist/worker-channel.d.mts +99 -0
- package/dist/worker-channel.mjs +419 -0
- package/dist/worker-router.d.mts +26 -0
- package/dist/worker-router.mjs +254 -0
- package/dist/ws-channel.d.mts +108 -0
- package/dist/ws-channel.mjs +520 -0
- package/dist/ws-router.d.mts +52 -0
- package/dist/ws-router.mjs +278 -0
- package/package.json +73 -0
- package/schema/error.bop +7 -0
- package/schema/index.bop +2 -0
- package/schema/message.bop +12 -0
- package/schema/pool.bop +16 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import { Message } from "./bebop.mjs";
|
|
2
|
+
import { createEventIterator } from "./create-event-iterator.mjs";
|
|
3
|
+
import { createDuplexIterator } from "./create-duplex-iterator.mjs";
|
|
4
|
+
import { ConsoleLogger, Deadline, TempoError, TempoStatusCode, TempoUtil, TempoVersion, parseCredential } from "@tempojs/common";
|
|
5
|
+
import { BaseChannel, InsecureChannelCredential } from "@tempojs/client";
|
|
6
|
+
import { EventEmitter } from "tseep";
|
|
7
|
+
import { Websocket, WebsocketEvent } from "websocket-ts";
|
|
8
|
+
//#region src/ws-channel.ts
|
|
9
|
+
var ExistingWebSocket = class extends Websocket {
|
|
10
|
+
constructor(ws, protocols, options) {
|
|
11
|
+
super(ws.url, protocols, options);
|
|
12
|
+
this._underlyingWebsocket = ws;
|
|
13
|
+
}
|
|
14
|
+
tryConnect() {
|
|
15
|
+
this._underlyingWebsocket.addEventListener(WebsocketEvent.open, this.handleOpenEvent);
|
|
16
|
+
this._underlyingWebsocket.addEventListener(WebsocketEvent.close, this.handleCloseEvent);
|
|
17
|
+
this._underlyingWebsocket.addEventListener(WebsocketEvent.error, this.handleErrorEvent);
|
|
18
|
+
this._underlyingWebsocket.addEventListener(WebsocketEvent.message, this.handleMessageEvent);
|
|
19
|
+
return this._underlyingWebsocket;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Represents a Tempo channel for communication with a remote server.
|
|
24
|
+
*/
|
|
25
|
+
var TempoWSChannel = class TempoWSChannel extends BaseChannel {
|
|
26
|
+
static defaultMaxRetryAttempts = 5;
|
|
27
|
+
static defaultMaxReceiveMessageSize = 1024 * 1024 * 4;
|
|
28
|
+
static defaultMaxSendMessageSize = 1024 * 1024 * 4;
|
|
29
|
+
static defaultCredential = InsecureChannelCredential.create();
|
|
30
|
+
static defaultContentType = "bebop";
|
|
31
|
+
ws;
|
|
32
|
+
events = new EventEmitter();
|
|
33
|
+
pending = /* @__PURE__ */ new Map();
|
|
34
|
+
closed = false;
|
|
35
|
+
isSecure;
|
|
36
|
+
credential;
|
|
37
|
+
userAgent;
|
|
38
|
+
maxReceiveMessageSize;
|
|
39
|
+
maxSendMessageSize;
|
|
40
|
+
_url;
|
|
41
|
+
_wsOptions;
|
|
42
|
+
get log() {
|
|
43
|
+
return this.logger;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Constructs a new TempoChannel instance.
|
|
47
|
+
*
|
|
48
|
+
* @param {URL} target - The target URL for the channel.
|
|
49
|
+
* @param {TempoWSChannelOptions} options - The configuration options for the channel.
|
|
50
|
+
* @protected
|
|
51
|
+
*/
|
|
52
|
+
constructor(target, options) {
|
|
53
|
+
const url = target instanceof URL ? target : new URL(target.url);
|
|
54
|
+
super(url, options.logger ?? new ConsoleLogger("TempoWSChannel"), options.contentType ?? TempoWSChannel.defaultContentType);
|
|
55
|
+
this.logger.trace("creating new TempoWSChannel");
|
|
56
|
+
this._url = url;
|
|
57
|
+
this._wsOptions = options;
|
|
58
|
+
this.isSecure = target.protocol === "https:" || target.protocol === "wss:";
|
|
59
|
+
this.credential = options.credential ?? TempoWSChannel.defaultCredential;
|
|
60
|
+
if (!this.isSecure && !(this.credential instanceof InsecureChannelCredential) && options.unsafeUseInsecureChannelCallCredential !== true) throw new Error("Cannot use secure credential with insecure channel");
|
|
61
|
+
this.maxReceiveMessageSize = options.maxReceiveMessageSize ?? TempoWSChannel.defaultMaxReceiveMessageSize;
|
|
62
|
+
this.maxSendMessageSize = options.maxSendMessageSize ?? TempoWSChannel.defaultMaxSendMessageSize;
|
|
63
|
+
this.userAgent = TempoUtil.buildUserAgent("javascript", TempoVersion, void 0, { runtime: TempoUtil.getEnvironmentName() });
|
|
64
|
+
this._setupWebSocket(target);
|
|
65
|
+
this.logger.trace(`created new TempoWSChannel for ${url.href} / ${this.userAgent}`);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Creates a new TempoChannel instance for the specified address.
|
|
69
|
+
*
|
|
70
|
+
* @param {string | URL} address - The target address as a string or URL object.
|
|
71
|
+
* @param {TempoChannelOptions} [options] - Optional configuration options for the channel.
|
|
72
|
+
* @returns {TempoChannel} - A new TempoChannel instance.
|
|
73
|
+
*/
|
|
74
|
+
static forWebSocket(target, options) {
|
|
75
|
+
options ??= {};
|
|
76
|
+
return new TempoWSChannel(target, options);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Creates a new TempoChannel instance for the specified address.
|
|
80
|
+
*
|
|
81
|
+
* @param {string | URL} address - The target address as a string or URL object.
|
|
82
|
+
* @param {TempoChannelOptions} [options] - Optional configuration options for the channel.
|
|
83
|
+
* @returns {TempoChannel} - A new TempoChannel instance.
|
|
84
|
+
*/
|
|
85
|
+
static forAddress(address, options) {
|
|
86
|
+
if (!address) throw new Error("no address");
|
|
87
|
+
if (typeof address === "string") address = new URL(address);
|
|
88
|
+
options ??= {};
|
|
89
|
+
return new TempoWSChannel(address, options);
|
|
90
|
+
}
|
|
91
|
+
_setupWebSocket(target) {
|
|
92
|
+
if (target instanceof URL) this.ws = new Websocket(target.toString(), [], this._wsOptions);
|
|
93
|
+
else this.ws = new ExistingWebSocket(target, [], this._wsOptions);
|
|
94
|
+
this.ws.binaryType = this._wsOptions.binaryType || "arraybuffer";
|
|
95
|
+
this.ws.addEventListener(WebsocketEvent.open, () => {
|
|
96
|
+
this.logger.trace(`opened TempoWSChannel for ${this._url.href} / ${this.userAgent}`);
|
|
97
|
+
});
|
|
98
|
+
this.ws.addEventListener(WebsocketEvent.close, (ws) => {
|
|
99
|
+
if (this.closed) return;
|
|
100
|
+
if (ws.lastConnection === void 0) return;
|
|
101
|
+
this.logger.trace(`closed TempoWSChannel for ${this._url.href} / ${this.userAgent}`);
|
|
102
|
+
this.close(new TempoError(TempoStatusCode.UNAVAILABLE, "channel closed"));
|
|
103
|
+
});
|
|
104
|
+
this.ws.addEventListener(WebsocketEvent.error, (ws) => {
|
|
105
|
+
if (this.closed) return;
|
|
106
|
+
if (ws.lastConnection === void 0) return;
|
|
107
|
+
this.close(new TempoError(TempoStatusCode.UNAVAILABLE, "channel transport error"));
|
|
108
|
+
});
|
|
109
|
+
this.ws.addEventListener(WebsocketEvent.message, (_ws, ev) => {
|
|
110
|
+
const byteLength = typeof ev.data === "string" ? ev.data.length : ev.data.byteLength;
|
|
111
|
+
if (byteLength > this.maxReceiveMessageSize) {
|
|
112
|
+
this.logger.error(`inbound frame ${byteLength}B exceeds maxReceiveMessageSize ${this.maxReceiveMessageSize}B; dropping`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
let message;
|
|
116
|
+
if (typeof ev.data === "string") message = Message(JSON.parse(ev.data));
|
|
117
|
+
else message = Message(Message.decode(new Uint8Array(ev.data)));
|
|
118
|
+
const messageId = message.messageId;
|
|
119
|
+
this.events.emit(messageId, message);
|
|
120
|
+
this.logger.trace(`received new message ${messageId}`);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
_reconnect() {
|
|
124
|
+
this.ws.close();
|
|
125
|
+
this._setupWebSocket(new URL(this._url.href));
|
|
126
|
+
}
|
|
127
|
+
async removeCredential() {
|
|
128
|
+
await this.credential.removeCredential();
|
|
129
|
+
}
|
|
130
|
+
async getCredential() {
|
|
131
|
+
return await this.credential.getCredential();
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Executes a function with retries according to the provided retry policy.
|
|
135
|
+
* The function will be retried if it fails with a TempoError and its status code is included in the retryableStatusCodes of the retry policy.
|
|
136
|
+
* If a deadline is provided, the deadline for each attempt will be managed by the provided deadline, but the deadline will not be reset upon each retry.
|
|
137
|
+
*
|
|
138
|
+
* @template T - The type of the result returned by the function.
|
|
139
|
+
* @param {((retryAttempt: number) => Promise<T>)} func - A function that returns a Promise with a result. The function will receive a number indicating the current retry attempt.
|
|
140
|
+
* @param {RetryPolicy} retryPolicy - An object defining the retry policy, including maxAttempts, initialBackoff, maxBackoff, backoffMultiplier, and retryableStatusCodes.
|
|
141
|
+
* @param {Deadline} [deadline] - An optional deadline object that manages the timeout for each attempt.
|
|
142
|
+
* @param {AbortController} [abortController] - An optional AbortController instance to cancel the function execution.
|
|
143
|
+
* @returns {Promise<T>} - A Promise that resolves with the result of the function if it completes within the deadline and retry policy constraints.
|
|
144
|
+
* @throws {Error} - If the function execution fails and the error does not match the retry policy, or if the maximum number of attempts is reached without a successful result.
|
|
145
|
+
*/
|
|
146
|
+
async executeWithRetry(func, retryPolicy, deadline, abortController) {
|
|
147
|
+
let attempt = 0;
|
|
148
|
+
let lastError;
|
|
149
|
+
const execute = deadline ? (retryAttempt) => deadline.executeWithinDeadline(async () => await func(retryAttempt), abortController) : (retryAttempt) => func(retryAttempt);
|
|
150
|
+
while (attempt < retryPolicy.maxAttempts) try {
|
|
151
|
+
return await execute(attempt);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (!(error instanceof Error)) throw new TempoError(TempoStatusCode.UNKNOWN, `unexpected error`, { data: error });
|
|
154
|
+
lastError = error;
|
|
155
|
+
if (!(error instanceof TempoError) || !retryPolicy.retryableStatusCodes.includes(error.status)) throw error;
|
|
156
|
+
const backoffWithJitter = Math.min(retryPolicy.initialBackoff.multiply(Math.pow(retryPolicy.backoffMultiplier, attempt)).totalMilliseconds, retryPolicy.maxBackoff.totalMilliseconds) * (Math.random() * .5 + .75);
|
|
157
|
+
await new Promise((resolve) => setTimeout(resolve, backoffWithJitter));
|
|
158
|
+
attempt++;
|
|
159
|
+
}
|
|
160
|
+
if (abortController && lastError !== void 0 && !(lastError instanceof Error && lastError.name === "AbortError") && !(lastError instanceof TempoError && lastError.status === TempoStatusCode.ABORTED)) abortController.abort();
|
|
161
|
+
return Promise.reject(lastError || new TempoError(TempoStatusCode.DEADLINE_EXCEEDED, "Failed to execute function with retry policy"));
|
|
162
|
+
}
|
|
163
|
+
async fetchUnary(init, options) {
|
|
164
|
+
const messageId = init.messageId;
|
|
165
|
+
let listener;
|
|
166
|
+
let onAbort;
|
|
167
|
+
const cleanup = () => {
|
|
168
|
+
this.events.off(messageId, listener);
|
|
169
|
+
if (onAbort) options?.controller?.signal.removeEventListener("abort", onAbort);
|
|
170
|
+
this.pending.delete(messageId);
|
|
171
|
+
};
|
|
172
|
+
return await new Promise((resolve, reject) => {
|
|
173
|
+
listener = (message) => resolve(message);
|
|
174
|
+
this.pending.set(messageId, reject);
|
|
175
|
+
if (options?.controller) {
|
|
176
|
+
onAbort = () => reject(new TempoError(TempoStatusCode.ABORTED, "RPC fetch aborted", {}));
|
|
177
|
+
options.controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
178
|
+
}
|
|
179
|
+
this.events.on(messageId, listener);
|
|
180
|
+
this.send(init);
|
|
181
|
+
}).finally(cleanup);
|
|
182
|
+
}
|
|
183
|
+
async fetchClientStream(init, method, generator, options) {
|
|
184
|
+
const messageId = init.messageId;
|
|
185
|
+
let listener;
|
|
186
|
+
let onAbort;
|
|
187
|
+
const cleanup = () => {
|
|
188
|
+
this.events.off(messageId, listener);
|
|
189
|
+
if (onAbort) options?.controller?.signal.removeEventListener("abort", onAbort);
|
|
190
|
+
this.pending.delete(messageId);
|
|
191
|
+
};
|
|
192
|
+
return await new Promise((resolve, reject) => {
|
|
193
|
+
listener = (message) => resolve(message);
|
|
194
|
+
this.pending.set(messageId, reject);
|
|
195
|
+
if (options?.controller) {
|
|
196
|
+
onAbort = () => reject(new TempoError(TempoStatusCode.ABORTED, "RPC fetch aborted", {}));
|
|
197
|
+
options.controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
198
|
+
}
|
|
199
|
+
this.events.on(messageId, listener);
|
|
200
|
+
runGenerator.call(this).catch(reject);
|
|
201
|
+
async function runGenerator() {
|
|
202
|
+
for await (const value of generator()) this.send(Message({
|
|
203
|
+
...init,
|
|
204
|
+
data: new Uint8Array(method.serialize(value))
|
|
205
|
+
}));
|
|
206
|
+
this.send(Message({
|
|
207
|
+
...init,
|
|
208
|
+
status: TempoStatusCode.CANCELLED,
|
|
209
|
+
data: new Uint8Array()
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
}).finally(cleanup);
|
|
213
|
+
}
|
|
214
|
+
fetchServerStream(init, context, method, options) {
|
|
215
|
+
const messageId = init.messageId;
|
|
216
|
+
return createEventIterator(({ emit, cancel, error }) => {
|
|
217
|
+
let terminated = false;
|
|
218
|
+
let onAbort;
|
|
219
|
+
const eventHandler = async (message) => {
|
|
220
|
+
try {
|
|
221
|
+
if (message.status === TempoStatusCode.CANCELLED) {
|
|
222
|
+
terminated = true;
|
|
223
|
+
cancel();
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
await this.processResponseHeaders(message, context, method.type);
|
|
227
|
+
const requestData = message.data;
|
|
228
|
+
const record = method.deserialize(requestData);
|
|
229
|
+
if (this.hooks !== void 0) await this.hooks.executeDecodeHooks(context, record);
|
|
230
|
+
emit(record);
|
|
231
|
+
} catch (e) {
|
|
232
|
+
error(e);
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
if (options?.controller) {
|
|
236
|
+
onAbort = () => error(new TempoError(TempoStatusCode.ABORTED, "RPC fetch aborted", {}));
|
|
237
|
+
options.controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
238
|
+
}
|
|
239
|
+
this.events.on(messageId, eventHandler);
|
|
240
|
+
this.send(init);
|
|
241
|
+
return () => {
|
|
242
|
+
this.events.off(messageId, eventHandler);
|
|
243
|
+
if (onAbort) options?.controller?.signal.removeEventListener("abort", onAbort);
|
|
244
|
+
if (!terminated && !this.closed) this.send(Message({
|
|
245
|
+
messageId,
|
|
246
|
+
methodId: init.methodId,
|
|
247
|
+
status: TempoStatusCode.CANCELLED,
|
|
248
|
+
data: new Uint8Array(),
|
|
249
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
250
|
+
}));
|
|
251
|
+
};
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
fetchDuplexStream(init, context, method, generator, options) {
|
|
255
|
+
const messageId = init.messageId;
|
|
256
|
+
return createDuplexIterator(generator(), (value) => {
|
|
257
|
+
this.send(Message({
|
|
258
|
+
...init,
|
|
259
|
+
data: new Uint8Array(method.serialize(value))
|
|
260
|
+
}));
|
|
261
|
+
}, ({ emit, cancel, error }) => {
|
|
262
|
+
let terminated = false;
|
|
263
|
+
let onAbort;
|
|
264
|
+
const eventHandler = async (message) => {
|
|
265
|
+
try {
|
|
266
|
+
if (message.status === TempoStatusCode.CANCELLED) {
|
|
267
|
+
terminated = true;
|
|
268
|
+
cancel();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
await this.processResponseHeaders(message, context, method.type);
|
|
272
|
+
const requestData = message.data;
|
|
273
|
+
const record = method.deserialize(requestData);
|
|
274
|
+
if (this.hooks !== void 0) await this.hooks.executeDecodeHooks(context, record);
|
|
275
|
+
emit(record);
|
|
276
|
+
} catch (e) {
|
|
277
|
+
error(e);
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
if (options?.controller) {
|
|
281
|
+
onAbort = () => error(new TempoError(TempoStatusCode.ABORTED, "RPC fetch aborted", {}));
|
|
282
|
+
options.controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
283
|
+
}
|
|
284
|
+
this.events.on(messageId, eventHandler);
|
|
285
|
+
return () => {
|
|
286
|
+
this.events.off(messageId, eventHandler);
|
|
287
|
+
if (onAbort) options?.controller?.signal.removeEventListener("abort", onAbort);
|
|
288
|
+
if (!terminated && !this.closed) this.send(Message({
|
|
289
|
+
messageId,
|
|
290
|
+
methodId: init.methodId,
|
|
291
|
+
status: TempoStatusCode.CANCELLED,
|
|
292
|
+
data: new Uint8Array(),
|
|
293
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
294
|
+
}));
|
|
295
|
+
};
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Creates a `RequestInit` object for a given payload, context, method and optional call options.
|
|
300
|
+
* This object can be used to make an HTTP request using the Fetch API.
|
|
301
|
+
*
|
|
302
|
+
* @private
|
|
303
|
+
* @param {Uint8Array} payload - The payload to be sent in the request.
|
|
304
|
+
* @param {ClientContext} context - The context of the client making the request.
|
|
305
|
+
* @param {MethodInfo<BebopRecord, BebopRecord>} method - Information about the method being called.
|
|
306
|
+
* @param {CallOptions | undefined} options - Optional configuration for the call.
|
|
307
|
+
* @returns {Promise<RequestInit>} A Promise resolving to the created `RequestInit` object.
|
|
308
|
+
* @throws {TempoError} Throws an error if there's a problem while getting the credential header.
|
|
309
|
+
*/
|
|
310
|
+
async createRequest(payload, _context, method, options) {
|
|
311
|
+
const requestInit = {
|
|
312
|
+
messageId: crypto.randomUUID(),
|
|
313
|
+
methodId: method.id,
|
|
314
|
+
data: new Uint8Array(payload),
|
|
315
|
+
timestamp: /* @__PURE__ */ new Date()
|
|
316
|
+
};
|
|
317
|
+
if (options?.deadline) requestInit.deadline = new Date(options.deadline.toUnixTimestamp());
|
|
318
|
+
const credentialHeader = await this.credential.getHeader();
|
|
319
|
+
if (credentialHeader) requestInit.authorization = credentialHeader.value;
|
|
320
|
+
return Message(requestInit);
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Processes the headers of the response from the server, validating their integrity and correctness.
|
|
324
|
+
* Also sets the incoming metadata from the response headers to the provided context.
|
|
325
|
+
*
|
|
326
|
+
* @private
|
|
327
|
+
* @param {Response} response - The response received from the server.
|
|
328
|
+
* @param {ClientContext} context - The context of the client making the request.
|
|
329
|
+
* @param {MethodType} methodType - The type of method being called.
|
|
330
|
+
* @throws {TempoError} Throws an error if any validation checks fail or if there's a problem parsing or storing credentials.
|
|
331
|
+
*/
|
|
332
|
+
async processResponseHeaders(response, _context, _methodType) {
|
|
333
|
+
const statusCode = response.status;
|
|
334
|
+
if (statusCode === void 0) throw new TempoError(TempoStatusCode.UNKNOWN, "tempo-status missing from response.");
|
|
335
|
+
if (statusCode !== TempoStatusCode.OK && statusCode !== TempoStatusCode.CANCELLED) {
|
|
336
|
+
let tempoMessage = response.msg;
|
|
337
|
+
if (!tempoMessage) tempoMessage = "unknown error";
|
|
338
|
+
throw new TempoError(statusCode, tempoMessage);
|
|
339
|
+
}
|
|
340
|
+
const responseCredential = response.credential;
|
|
341
|
+
if (responseCredential) {
|
|
342
|
+
const credential = parseCredential(responseCredential);
|
|
343
|
+
if (!credential) throw new TempoError(TempoStatusCode.INVALID_ARGUMENT, "unable to parse credentials received on 'tempo-credential' header");
|
|
344
|
+
await this.credential.storeCredential(credential);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
async waitForOpen() {
|
|
348
|
+
const maxAttempts = 2;
|
|
349
|
+
let lastError;
|
|
350
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
351
|
+
if (attempt > 0) {
|
|
352
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
353
|
+
this._reconnect();
|
|
354
|
+
}
|
|
355
|
+
const state = this.ws.readyState;
|
|
356
|
+
if (state === 1) return;
|
|
357
|
+
if (state === 2 || state === 3) throw new TempoError(TempoStatusCode.UNAVAILABLE, "socket is closing or closed");
|
|
358
|
+
try {
|
|
359
|
+
await Deadline.after(5, "seconds").executeWithinDeadline(() => new Promise((resolve, reject) => {
|
|
360
|
+
const teardown = () => {
|
|
361
|
+
this.ws.removeEventListener(WebsocketEvent.open, onOpen);
|
|
362
|
+
this.ws.removeEventListener(WebsocketEvent.error, onError);
|
|
363
|
+
this.ws.removeEventListener(WebsocketEvent.close, onClose);
|
|
364
|
+
};
|
|
365
|
+
const onOpen = () => {
|
|
366
|
+
teardown();
|
|
367
|
+
resolve();
|
|
368
|
+
};
|
|
369
|
+
const onError = (ws, event) => {
|
|
370
|
+
teardown();
|
|
371
|
+
const message = event.message;
|
|
372
|
+
const detail = typeof message === "string" ? message : event.type;
|
|
373
|
+
reject(new TempoError(TempoStatusCode.UNAVAILABLE, `socket error before open: ${detail}`));
|
|
374
|
+
};
|
|
375
|
+
const onClose = () => {
|
|
376
|
+
teardown();
|
|
377
|
+
reject(new TempoError(TempoStatusCode.UNAVAILABLE, "socket closed before open"));
|
|
378
|
+
};
|
|
379
|
+
this.ws.addEventListener(WebsocketEvent.open, onOpen);
|
|
380
|
+
this.ws.addEventListener(WebsocketEvent.error, onError);
|
|
381
|
+
this.ws.addEventListener(WebsocketEvent.close, onClose);
|
|
382
|
+
}));
|
|
383
|
+
return;
|
|
384
|
+
} catch (err) {
|
|
385
|
+
lastError = err;
|
|
386
|
+
if (attempt < maxAttempts - 1 && err instanceof TempoError && err.message.startsWith("socket error before open")) continue;
|
|
387
|
+
throw err;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
throw lastError ?? new TempoError(TempoStatusCode.UNAVAILABLE, "socket error before open");
|
|
391
|
+
}
|
|
392
|
+
send(message) {
|
|
393
|
+
const frame = Message.encode(message);
|
|
394
|
+
if (frame.length > this.maxSendMessageSize) throw new TempoError(TempoStatusCode.INVALID_ARGUMENT, `outbound frame ${frame.length}B exceeds maxSendMessageSize ${this.maxSendMessageSize}B`);
|
|
395
|
+
this.ws.send(frame);
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Tear down the channel: reject every in-flight unary/client-stream call,
|
|
399
|
+
* remove all event listeners, and close the socket. Idempotent and safe to
|
|
400
|
+
* invoke from the socket `close`/`error` events or by an explicit caller.
|
|
401
|
+
*/
|
|
402
|
+
close(reason) {
|
|
403
|
+
if (this.closed) return;
|
|
404
|
+
this.closed = true;
|
|
405
|
+
const err = reason ?? new TempoError(TempoStatusCode.CANCELLED, "channel disposed");
|
|
406
|
+
for (const reject of this.pending.values()) reject(err);
|
|
407
|
+
this.pending.clear();
|
|
408
|
+
this.events.removeAllListeners();
|
|
409
|
+
try {
|
|
410
|
+
this.ws.close();
|
|
411
|
+
} catch {}
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* {@inheritDoc BaseChannel.startUnary}
|
|
415
|
+
*/
|
|
416
|
+
async startUnary(request, context, method, options) {
|
|
417
|
+
try {
|
|
418
|
+
await this.waitForOpen();
|
|
419
|
+
const requestData = method.serialize(request);
|
|
420
|
+
if (this.hooks !== void 0) await this.hooks.executeRequestHooks(context);
|
|
421
|
+
const requestInit = await this.createRequest(requestData, context, method, options);
|
|
422
|
+
let response;
|
|
423
|
+
if (options?.retryPolicy) response = await this.executeWithRetry(async (retryAttempt) => {
|
|
424
|
+
if (retryAttempt > 0) requestInit.previousAttempts = retryAttempt;
|
|
425
|
+
return await this.fetchUnary(requestInit);
|
|
426
|
+
}, options.retryPolicy, options.deadline, options.controller);
|
|
427
|
+
else if (options?.deadline) response = await options.deadline.executeWithinDeadline(async () => {
|
|
428
|
+
return await this.fetchUnary(requestInit, options);
|
|
429
|
+
}, options.controller);
|
|
430
|
+
else response = await this.fetchUnary(requestInit, options);
|
|
431
|
+
await this.processResponseHeaders(response, context, method.type);
|
|
432
|
+
if (this.hooks !== void 0) await this.hooks.executeResponseHooks(context);
|
|
433
|
+
const record = method.deserialize(response.data);
|
|
434
|
+
if (this.hooks !== void 0) await this.hooks.executeDecodeHooks(context, record);
|
|
435
|
+
return record;
|
|
436
|
+
} catch (e) {
|
|
437
|
+
if (this.hooks !== void 0 && e instanceof Error) this.hooks.executeErrorHooks(context, e);
|
|
438
|
+
if (e instanceof TempoError) throw e;
|
|
439
|
+
if (e instanceof Error) if (e.name === "AbortError") throw new TempoError(TempoStatusCode.ABORTED, "RPC fetch aborted", e);
|
|
440
|
+
else throw new TempoError(TempoStatusCode.UNKNOWN, "an unknown error occurred", e);
|
|
441
|
+
throw new TempoError(TempoStatusCode.UNKNOWN, "an unknown error occurred", { data: e });
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* {@inheritDoc BaseChannel.startClientStream}
|
|
446
|
+
*/
|
|
447
|
+
async startClientStream(generator, context, method, options) {
|
|
448
|
+
try {
|
|
449
|
+
await this.waitForOpen();
|
|
450
|
+
if (this.hooks !== void 0) await this.hooks.executeRequestHooks(context);
|
|
451
|
+
const requestInit = await this.createRequest(new Uint8Array(), context, method, options);
|
|
452
|
+
let response;
|
|
453
|
+
if (options?.deadline) response = await options.deadline.executeWithinDeadline(async () => {
|
|
454
|
+
return await this.fetchClientStream(requestInit, method, generator, options);
|
|
455
|
+
}, options.controller);
|
|
456
|
+
else response = await this.fetchClientStream(requestInit, method, generator, options);
|
|
457
|
+
await this.processResponseHeaders(response, context, method.type);
|
|
458
|
+
const record = this.deserializeResponse(response.data, method);
|
|
459
|
+
if (this.hooks !== void 0) await this.hooks.executeDecodeHooks(context, record);
|
|
460
|
+
return record;
|
|
461
|
+
} catch (e) {
|
|
462
|
+
if (this.hooks !== void 0 && e instanceof Error) this.hooks.executeErrorHooks(context, e);
|
|
463
|
+
if (e instanceof TempoError) throw e;
|
|
464
|
+
if (e instanceof Error) if (e.name === "AbortError") throw new TempoError(TempoStatusCode.ABORTED, "RPC fetch aborted", e);
|
|
465
|
+
else throw new TempoError(TempoStatusCode.UNKNOWN, "an unknown error occurred", e);
|
|
466
|
+
throw new TempoError(TempoStatusCode.UNKNOWN, "an unknown error occurred", { data: e });
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* {@inheritDoc BaseChannel.startServerStream}
|
|
471
|
+
*/
|
|
472
|
+
async startServerStream(request, context, method, options) {
|
|
473
|
+
try {
|
|
474
|
+
await this.waitForOpen();
|
|
475
|
+
const requestData = method.serialize(request);
|
|
476
|
+
if (this.hooks !== void 0) await this.hooks.executeRequestHooks(context);
|
|
477
|
+
const requestInit = await this.createRequest(requestData, context, method, options);
|
|
478
|
+
let response;
|
|
479
|
+
if (options?.retryPolicy) response = await this.executeWithRetry(async (retryAttempt) => {
|
|
480
|
+
if (retryAttempt > 0) requestInit.previousAttempts = retryAttempt;
|
|
481
|
+
return this.fetchServerStream(requestInit, context, method, options);
|
|
482
|
+
}, options.retryPolicy, options.deadline, options.controller);
|
|
483
|
+
else if (options?.deadline) response = await options.deadline.executeWithinDeadline(async () => {
|
|
484
|
+
return this.fetchServerStream(requestInit, context, method, options);
|
|
485
|
+
}, options.controller);
|
|
486
|
+
else response = this.fetchServerStream(requestInit, context, method, options);
|
|
487
|
+
return response;
|
|
488
|
+
} catch (e) {
|
|
489
|
+
if (this.hooks !== void 0 && e instanceof Error) this.hooks.executeErrorHooks(context, e);
|
|
490
|
+
if (e instanceof TempoError) throw e;
|
|
491
|
+
if (e instanceof Error) if (e.name === "AbortError") throw new TempoError(TempoStatusCode.ABORTED, "RPC fetch aborted", e);
|
|
492
|
+
else throw new TempoError(TempoStatusCode.UNKNOWN, "an unknown error occurred", e);
|
|
493
|
+
throw new TempoError(TempoStatusCode.UNKNOWN, "an unknown error occurred", { data: e });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* {@inheritDoc BaseChannel.startDuplexStream}
|
|
498
|
+
*/
|
|
499
|
+
async startDuplexStream(generator, context, method, options) {
|
|
500
|
+
try {
|
|
501
|
+
await this.waitForOpen();
|
|
502
|
+
if (this.hooks !== void 0) await this.hooks.executeRequestHooks(context);
|
|
503
|
+
const requestInit = await this.createRequest(new Uint8Array(), context, method, options);
|
|
504
|
+
let response;
|
|
505
|
+
if (options?.deadline) response = await options.deadline.executeWithinDeadline(async () => {
|
|
506
|
+
return this.fetchDuplexStream(requestInit, context, method, generator, options);
|
|
507
|
+
}, options.controller);
|
|
508
|
+
else response = this.fetchDuplexStream(requestInit, context, method, generator, options);
|
|
509
|
+
return response;
|
|
510
|
+
} catch (e) {
|
|
511
|
+
if (this.hooks !== void 0 && e instanceof Error) await this.hooks.executeErrorHooks(context, e);
|
|
512
|
+
if (e instanceof TempoError) throw e;
|
|
513
|
+
if (e instanceof Error) if (e.name === "AbortError") throw new TempoError(TempoStatusCode.ABORTED, "RPC fetch aborted", e);
|
|
514
|
+
else throw new TempoError(TempoStatusCode.UNKNOWN, "an unknown error occurred", e);
|
|
515
|
+
throw new TempoError(TempoStatusCode.UNKNOWN, "an unknown error occurred", { data: e });
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
//#endregion
|
|
520
|
+
export { TempoWSChannel };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Message } from "./bebop.mjs";
|
|
2
|
+
import { TempoLogger } from "@tempojs/common";
|
|
3
|
+
import { AuthInterceptor, BaseRouter, ServiceRegistry, TempoRouterConfiguration } from "@tempojs/server";
|
|
4
|
+
|
|
5
|
+
//#region src/ws-router.d.ts
|
|
6
|
+
interface GenericWs {
|
|
7
|
+
send(message: string | Blob | BufferSource): void;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Client-metadata keys under which {@link TempoWsRouter.process} exposes the
|
|
11
|
+
* current call's message/method ids to a service handler (read via
|
|
12
|
+
* `context.clientMetadata`). A streaming handler persists these so the
|
|
13
|
+
* hibernation-safe broadcast path can re-frame server->client pushes.
|
|
14
|
+
*/
|
|
15
|
+
declare const STREAM_MESSAGE_ID_KEY = "tempo-message-id";
|
|
16
|
+
declare const STREAM_METHOD_ID_KEY = "tempo-method-id";
|
|
17
|
+
/**
|
|
18
|
+
* Build an encoded server-stream frame identical to the one {@link TempoWsRouter}
|
|
19
|
+
* emits from a streaming handler: status OK, the given `methodId`/`messageId`, and
|
|
20
|
+
* the already-serialized record bytes `data`. Used by the hibernation-safe
|
|
21
|
+
* broadcast path to push frames over a live socket WITHOUT a live generator — the
|
|
22
|
+
* client's server-stream iterator matches them by `messageId` exactly like
|
|
23
|
+
* generator-emitted frames. `messageId` MUST be the original observe call's id
|
|
24
|
+
* (a GUID); `methodId` its method id.
|
|
25
|
+
*/
|
|
26
|
+
declare function encodeServerStreamFrame(opts: {
|
|
27
|
+
methodId: number;
|
|
28
|
+
messageId: string;
|
|
29
|
+
data: Uint8Array;
|
|
30
|
+
}): Uint8Array;
|
|
31
|
+
declare class TempoWsRouter<Context, Ws extends GenericWs = WebSocket, Env extends [Context, Ws] = [Context, Ws]> extends BaseRouter<ArrayBuffer, Env, Message> {
|
|
32
|
+
private readonly events;
|
|
33
|
+
private readonly clientStreams;
|
|
34
|
+
private readonly serverStreams;
|
|
35
|
+
private readonly _streamEncodeCache;
|
|
36
|
+
constructor(logger: TempoLogger, registry: ServiceRegistry, configuration?: TempoRouterConfiguration, authInterceptor?: AuthInterceptor);
|
|
37
|
+
private setAuthContext;
|
|
38
|
+
private invokeUnaryMethod;
|
|
39
|
+
private invokeClientStreamMethod;
|
|
40
|
+
private invokeServerStreamMethod;
|
|
41
|
+
private invokeDuplexStreamMethod;
|
|
42
|
+
/**
|
|
43
|
+
* Tear down all open streams for this connection (driven by the DO socket
|
|
44
|
+
* close/error lifecycle). Emits a synthetic CANCELLED to each in-flight
|
|
45
|
+
* client/duplex stream so its generator ends, then clears all listeners.
|
|
46
|
+
*/
|
|
47
|
+
closeConnection(): Promise<void>;
|
|
48
|
+
process(req: ArrayBuffer, response: Message, env: Env): Promise<void>;
|
|
49
|
+
handle(_request: ArrayBuffer, _env: Env): Promise<Message>;
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
52
|
+
export { STREAM_MESSAGE_ID_KEY, STREAM_METHOD_ID_KEY, TempoWsRouter, encodeServerStreamFrame };
|