@hulla/api-websocket 0.0.0-stage → 2.0.0
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/dist/index.d.ts +57 -0
- package/dist/index.js +605 -0
- package/package.json +42 -4
- package/README.md +0 -3
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { AdapterErrorInput, AdapterResponse } from "@hulla/api/adapters";
|
|
2
|
+
import { Awaitable, ServerAdapter, ServerContextInput, ServerExecutableFor } from "@hulla/api/server";
|
|
3
|
+
import { ClientTransport, ClientTransportRequest } from "@hulla/api/client";
|
|
4
|
+
import { Contract } from "@hulla/api";
|
|
5
|
+
//#region src/connection.d.ts
|
|
6
|
+
declare const WEBSOCKET_PROTOCOL: '@hulla/api-websocket/1';
|
|
7
|
+
type WebSocketTransportErrorCode = 'closed' | 'invalid-message' | 'remote-error';
|
|
8
|
+
declare class WebSocketTransportError extends Error {
|
|
9
|
+
readonly code: WebSocketTransportErrorCode;
|
|
10
|
+
constructor(code: WebSocketTransportErrorCode, message: string, options?: ErrorOptions);
|
|
11
|
+
}
|
|
12
|
+
type SocketEvent = {
|
|
13
|
+
readonly type: string;
|
|
14
|
+
readonly data?: unknown;
|
|
15
|
+
};
|
|
16
|
+
/** DOM WebSocket and ws both implement this EventTarget surface. The application owns the socket. */
|
|
17
|
+
type WebSocketLike = {
|
|
18
|
+
readonly readyState: number;
|
|
19
|
+
send(data: string): void;
|
|
20
|
+
addEventListener(type: 'open' | 'message' | 'close' | 'error', listener: (event: SocketEvent) => void): void;
|
|
21
|
+
removeEventListener(type: 'open' | 'message' | 'close' | 'error', listener: (event: SocketEvent) => void): void;
|
|
22
|
+
};
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/client.d.ts
|
|
25
|
+
type WebSocketTransport = ClientTransport & {
|
|
26
|
+
readonly ready: Promise<void>;
|
|
27
|
+
readonly closed: Promise<Error>;
|
|
28
|
+
/** Rejects active calls and releases listeners without closing the application's socket. */
|
|
29
|
+
readonly close: () => Promise<void>;
|
|
30
|
+
};
|
|
31
|
+
declare function webSocketTransport(socket: WebSocketLike): WebSocketTransport;
|
|
32
|
+
//#endregion
|
|
33
|
+
//#region src/server.d.ts
|
|
34
|
+
type WebSocketServerRequest = Omit<ClientTransportRequest, 'signal'> & {
|
|
35
|
+
readonly signal: AbortSignal;
|
|
36
|
+
};
|
|
37
|
+
type WebSocketAdapterContext = {
|
|
38
|
+
readonly socket: WebSocketLike;
|
|
39
|
+
readonly request: WebSocketServerRequest;
|
|
40
|
+
};
|
|
41
|
+
type WebSocketContextInput<ContractType extends Contract = Contract> = ServerContextInput<ContractType> & WebSocketAdapterContext;
|
|
42
|
+
type WebSocketServerErrorInput = Omit<AdapterErrorInput, 'request'> & WebSocketAdapterContext;
|
|
43
|
+
type WebSocketServerOptions = {
|
|
44
|
+
readonly onError?: (input: WebSocketServerErrorInput) => Awaitable<AdapterResponse | undefined | void>;
|
|
45
|
+
};
|
|
46
|
+
type WebSocketServer = {
|
|
47
|
+
readonly ready: Promise<void>;
|
|
48
|
+
readonly closed: Promise<Error>;
|
|
49
|
+
readonly close: () => Promise<void>;
|
|
50
|
+
};
|
|
51
|
+
type WebSocketAdapter = ServerAdapter<'websocket', WebSocketAdapterContext> & {
|
|
52
|
+
readonly mount: <const ContractType extends Contract, const Context extends object>(implementation: ServerExecutableFor<ContractType, Context, 'websocket', WebSocketAdapterContext>, options?: WebSocketServerOptions) => WebSocketServer;
|
|
53
|
+
};
|
|
54
|
+
/** Mount once per accepted socket; the application owns upgrades, authentication and socket lifetime. */
|
|
55
|
+
declare function webSocketAdapter(socket: WebSocketLike, defaults?: WebSocketServerOptions): WebSocketAdapter;
|
|
56
|
+
//#endregion
|
|
57
|
+
export { WEBSOCKET_PROTOCOL, type WebSocketAdapter, type WebSocketAdapterContext, type WebSocketContextInput, type WebSocketLike, type WebSocketServer, type WebSocketServerErrorInput, type WebSocketServerOptions, type WebSocketServerRequest, type WebSocketTransport, WebSocketTransportError, type WebSocketTransportErrorCode, webSocketAdapter, webSocketTransport };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
import { createAdapterHandler } from "@hulla/api/adapters";
|
|
2
|
+
import { assertAdapterContext, createServerAdapter } from "@hulla/api/server";
|
|
3
|
+
|
|
4
|
+
//#region src/wire.ts
|
|
5
|
+
function base64(bytes) {
|
|
6
|
+
let binary = "";
|
|
7
|
+
for (let offset = 0; offset < bytes.length; offset += 32768) binary += String.fromCharCode(...bytes.subarray(offset, offset + 32768));
|
|
8
|
+
return btoa(binary);
|
|
9
|
+
}
|
|
10
|
+
function bytes(value) {
|
|
11
|
+
if (typeof value !== "string") throw new TypeError("Invalid WebSocket binary value");
|
|
12
|
+
return Uint8Array.from(atob(value), (character) => character.charCodeAt(0));
|
|
13
|
+
}
|
|
14
|
+
async function encodeWire(value) {
|
|
15
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
16
|
+
async function encode(value) {
|
|
17
|
+
if (value === void 0) return ["undefined"];
|
|
18
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
|
19
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
20
|
+
if (typeof value !== "object" || value === null) throw new TypeError("WebSocket values must be JSON-compatible, bytes or FormData");
|
|
21
|
+
if (seen.has(value)) throw new TypeError("WebSocket values must not contain cycles");
|
|
22
|
+
seen.add(value);
|
|
23
|
+
try {
|
|
24
|
+
if (value instanceof Uint8Array) return ["bytes", base64(value)];
|
|
25
|
+
if (value instanceof FormData) {
|
|
26
|
+
const entries = [];
|
|
27
|
+
for (const [name, field] of value) entries.push([name, typeof field === "string" ? field : [
|
|
28
|
+
"file",
|
|
29
|
+
base64(new Uint8Array(await field.arrayBuffer())),
|
|
30
|
+
field.name,
|
|
31
|
+
field.type,
|
|
32
|
+
field.lastModified
|
|
33
|
+
]]);
|
|
34
|
+
return ["form-data", entries];
|
|
35
|
+
}
|
|
36
|
+
if (Array.isArray(value)) {
|
|
37
|
+
const entries = [];
|
|
38
|
+
for (const field of value) entries.push(await encode(field));
|
|
39
|
+
return ["array", entries];
|
|
40
|
+
}
|
|
41
|
+
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) throw new TypeError("WebSocket objects must be plain records; encode native values with a contract codec");
|
|
42
|
+
const entries = [];
|
|
43
|
+
for (const [name, field] of Object.entries(value)) entries.push([name, await encode(field)]);
|
|
44
|
+
return ["object", entries];
|
|
45
|
+
} finally {
|
|
46
|
+
seen.delete(value);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return JSON.stringify(await encode(value));
|
|
50
|
+
}
|
|
51
|
+
function decodeWire(text) {
|
|
52
|
+
function decode(value) {
|
|
53
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
|
|
54
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
55
|
+
if (!Array.isArray(value)) throw new TypeError("Invalid WebSocket wire value");
|
|
56
|
+
if (value.length === 1 && value[0] === "undefined") return void 0;
|
|
57
|
+
if (value.length !== 2) throw new TypeError("Invalid WebSocket wire container");
|
|
58
|
+
if (value[0] === "bytes") return bytes(value[1]);
|
|
59
|
+
const entries = value[1];
|
|
60
|
+
if (!Array.isArray(entries)) throw new TypeError("Invalid WebSocket wire entries");
|
|
61
|
+
if (value[0] === "array") return entries.map(decode);
|
|
62
|
+
if (value[0] === "object" || value[0] === "form-data") {
|
|
63
|
+
const record = {};
|
|
64
|
+
const form = value[0] === "form-data" ? new FormData() : void 0;
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string") throw new TypeError("Invalid WebSocket wire entry");
|
|
67
|
+
const [name, field] = entry;
|
|
68
|
+
if (form === void 0) {
|
|
69
|
+
if (Object.hasOwn(record, name)) throw new TypeError("Duplicate WebSocket object key");
|
|
70
|
+
Object.defineProperty(record, name, {
|
|
71
|
+
value: decode(field),
|
|
72
|
+
enumerable: true,
|
|
73
|
+
writable: true,
|
|
74
|
+
configurable: true
|
|
75
|
+
});
|
|
76
|
+
} else if (typeof field === "string") form.append(name, field);
|
|
77
|
+
else {
|
|
78
|
+
if (!Array.isArray(field) || field.length !== 5 || field[0] !== "file" || typeof field[2] !== "string" || typeof field[3] !== "string" || typeof field[4] !== "number") throw new TypeError("Invalid WebSocket multipart file");
|
|
79
|
+
form.append(name, new File([bytes(field[1])], field[2], {
|
|
80
|
+
type: field[3],
|
|
81
|
+
lastModified: field[4]
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return form ?? record;
|
|
86
|
+
}
|
|
87
|
+
throw new TypeError("Unknown WebSocket wire container");
|
|
88
|
+
}
|
|
89
|
+
return decode(JSON.parse(text));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
//#endregion
|
|
93
|
+
//#region src/connection.ts
|
|
94
|
+
const WEBSOCKET_PROTOCOL = "@hulla/api-websocket/1";
|
|
95
|
+
var WebSocketTransportError = class extends Error {
|
|
96
|
+
code;
|
|
97
|
+
constructor(code, message, options) {
|
|
98
|
+
super(message, options);
|
|
99
|
+
this.name = "WebSocketTransportError";
|
|
100
|
+
this.code = code;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
104
|
+
/** Installs listeners immediately and serializes asynchronous frame decoding to preserve message order. */
|
|
105
|
+
function connection(socket, receive, ended) {
|
|
106
|
+
let resolveClosed;
|
|
107
|
+
const closed = new Promise((resolve) => {
|
|
108
|
+
resolveClosed = resolve;
|
|
109
|
+
});
|
|
110
|
+
let terminal;
|
|
111
|
+
const opened = socket.readyState === 1;
|
|
112
|
+
let resolveReady;
|
|
113
|
+
let rejectReady;
|
|
114
|
+
const ready = new Promise((resolve, reject) => {
|
|
115
|
+
resolveReady = resolve;
|
|
116
|
+
rejectReady = reject;
|
|
117
|
+
});
|
|
118
|
+
ready.catch(() => {});
|
|
119
|
+
const open = () => {
|
|
120
|
+
resolveReady();
|
|
121
|
+
};
|
|
122
|
+
const close = () => stop(new WebSocketTransportError("closed", "WebSocket connection closed"));
|
|
123
|
+
const error = () => stop(new WebSocketTransportError("closed", "WebSocket connection failed"));
|
|
124
|
+
let decoding = Promise.resolve();
|
|
125
|
+
const message = (event) => {
|
|
126
|
+
decoding = decoding.then(async () => {
|
|
127
|
+
if (terminal) return;
|
|
128
|
+
const data = event.data;
|
|
129
|
+
const text = typeof data === "string" ? data : data instanceof Blob ? await data.text() : data instanceof ArrayBuffer ? new TextDecoder("utf-8", { fatal: true }).decode(data) : ArrayBuffer.isView(data) ? new TextDecoder("utf-8", { fatal: true }).decode(data) : void 0;
|
|
130
|
+
if (text === void 0) throw new TypeError("Unsupported WebSocket frame data");
|
|
131
|
+
const frame = decodeWire(text);
|
|
132
|
+
if (!isRecord(frame) || frame["protocol"] !== "@hulla/api-websocket/1" || typeof frame["type"] !== "string" || frame["type"] !== "close" && typeof frame["id"] !== "string") throw new TypeError("Invalid WebSocket protocol envelope");
|
|
133
|
+
if (!terminal) receive(frame);
|
|
134
|
+
}).catch((cause) => stop(new WebSocketTransportError("invalid-message", "Invalid WebSocket protocol message", { cause })));
|
|
135
|
+
};
|
|
136
|
+
function stop(reason = new WebSocketTransportError("closed", "WebSocket transport closed")) {
|
|
137
|
+
if (terminal) return;
|
|
138
|
+
terminal = reason;
|
|
139
|
+
resolveClosed(reason);
|
|
140
|
+
rejectReady(reason);
|
|
141
|
+
socket.removeEventListener("open", open);
|
|
142
|
+
socket.removeEventListener("message", message);
|
|
143
|
+
socket.removeEventListener("close", close);
|
|
144
|
+
socket.removeEventListener("error", error);
|
|
145
|
+
ended(reason);
|
|
146
|
+
}
|
|
147
|
+
socket.addEventListener("open", open);
|
|
148
|
+
socket.addEventListener("message", message);
|
|
149
|
+
socket.addEventListener("close", close);
|
|
150
|
+
socket.addEventListener("error", error);
|
|
151
|
+
if (opened) resolveReady();
|
|
152
|
+
else if (socket.readyState !== 0) close();
|
|
153
|
+
let writing = Promise.resolve();
|
|
154
|
+
return {
|
|
155
|
+
ready,
|
|
156
|
+
closed,
|
|
157
|
+
stop,
|
|
158
|
+
send(frame, active = () => true) {
|
|
159
|
+
const operation = writing.then(async () => {
|
|
160
|
+
if (terminal) throw terminal;
|
|
161
|
+
await ready;
|
|
162
|
+
if (!active()) return;
|
|
163
|
+
const encoded = await encodeWire({
|
|
164
|
+
...frame,
|
|
165
|
+
protocol: WEBSOCKET_PROTOCOL
|
|
166
|
+
});
|
|
167
|
+
if (terminal) throw terminal;
|
|
168
|
+
if (!active()) return;
|
|
169
|
+
if (socket.readyState !== 1) {
|
|
170
|
+
close();
|
|
171
|
+
throw terminal;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
socket.send(encoded);
|
|
175
|
+
} catch (cause) {
|
|
176
|
+
stop(new WebSocketTransportError("closed", "WebSocket send failed", { cause }));
|
|
177
|
+
throw terminal;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
writing = operation.catch(() => {});
|
|
181
|
+
return operation;
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
//#endregion
|
|
187
|
+
//#region src/client.ts
|
|
188
|
+
var RemoteStream = class {
|
|
189
|
+
link;
|
|
190
|
+
id;
|
|
191
|
+
finish;
|
|
192
|
+
#done = false;
|
|
193
|
+
#failed = false;
|
|
194
|
+
#error;
|
|
195
|
+
#waiters = [];
|
|
196
|
+
constructor(link, id, finish) {
|
|
197
|
+
this.link = link;
|
|
198
|
+
this.id = id;
|
|
199
|
+
this.finish = finish;
|
|
200
|
+
}
|
|
201
|
+
[Symbol.asyncIterator]() {
|
|
202
|
+
return this;
|
|
203
|
+
}
|
|
204
|
+
next() {
|
|
205
|
+
if (this.#failed) return Promise.reject(this.#error);
|
|
206
|
+
if (this.#done) return Promise.resolve({
|
|
207
|
+
done: true,
|
|
208
|
+
value: void 0
|
|
209
|
+
});
|
|
210
|
+
const pending = new Promise((resolve, reject) => this.#waiters.push({
|
|
211
|
+
resolve,
|
|
212
|
+
reject
|
|
213
|
+
}));
|
|
214
|
+
this.link.send({
|
|
215
|
+
type: "pull",
|
|
216
|
+
id: this.id
|
|
217
|
+
}).catch((error) => this.fail(error));
|
|
218
|
+
return pending;
|
|
219
|
+
}
|
|
220
|
+
async return() {
|
|
221
|
+
if (!this.#done) {
|
|
222
|
+
this.end();
|
|
223
|
+
await this.link.send({
|
|
224
|
+
type: "cancel",
|
|
225
|
+
id: this.id
|
|
226
|
+
}).catch(() => {});
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
done: true,
|
|
230
|
+
value: void 0
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
chunk(value) {
|
|
234
|
+
const waiter = this.#waiters.shift();
|
|
235
|
+
if (!(value instanceof Uint8Array) || !waiter) {
|
|
236
|
+
const error = new WebSocketTransportError("invalid-message", "Unsolicited or invalid WebSocket stream chunk");
|
|
237
|
+
waiter?.reject(error);
|
|
238
|
+
this.fail(error);
|
|
239
|
+
this.link.send({
|
|
240
|
+
type: "cancel",
|
|
241
|
+
id: this.id
|
|
242
|
+
}).catch(() => {});
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
waiter.resolve({
|
|
246
|
+
done: false,
|
|
247
|
+
value
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
end() {
|
|
251
|
+
if (this.#done) return;
|
|
252
|
+
this.#done = true;
|
|
253
|
+
this.finish();
|
|
254
|
+
for (const waiter of this.#waiters.splice(0)) waiter.resolve({
|
|
255
|
+
done: true,
|
|
256
|
+
value: void 0
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
fail(error) {
|
|
260
|
+
if (this.#done) return;
|
|
261
|
+
this.#failed = true;
|
|
262
|
+
this.#error = error;
|
|
263
|
+
this.#done = true;
|
|
264
|
+
this.finish();
|
|
265
|
+
for (const waiter of this.#waiters.splice(0)) waiter.reject(error);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
function headers(value) {
|
|
269
|
+
return isRecord(value) && Object.values(value).every((field) => typeof field === "string" || Array.isArray(field) && field.every((item) => typeof item === "string"));
|
|
270
|
+
}
|
|
271
|
+
const remoteError = (frame) => new WebSocketTransportError("remote-error", typeof frame["error"] === "string" ? frame["error"] : "Remote WebSocket operation failed");
|
|
272
|
+
function webSocketTransport(socket) {
|
|
273
|
+
const pending = /* @__PURE__ */ new Map();
|
|
274
|
+
const streams = /* @__PURE__ */ new Map();
|
|
275
|
+
let terminal;
|
|
276
|
+
let sequence = 0;
|
|
277
|
+
const prefix = globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
278
|
+
const ended = (error) => {
|
|
279
|
+
terminal = error;
|
|
280
|
+
for (const request of pending.values()) {
|
|
281
|
+
request.cleanup();
|
|
282
|
+
request.reject(error);
|
|
283
|
+
}
|
|
284
|
+
pending.clear();
|
|
285
|
+
for (const stream of [...streams.values()]) stream.fail(error);
|
|
286
|
+
};
|
|
287
|
+
const link = connection(socket, (frame) => {
|
|
288
|
+
if (frame.type === "close") {
|
|
289
|
+
link.stop(new WebSocketTransportError("closed", "Remote WebSocket server closed"));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const id = frame.id;
|
|
293
|
+
const stream = streams.get(id);
|
|
294
|
+
if (stream) {
|
|
295
|
+
if (frame.type === "chunk") stream.chunk(frame["chunk"]);
|
|
296
|
+
else if (frame.type === "end") stream.end();
|
|
297
|
+
else if (frame.type === "error") stream.fail(remoteError(frame));
|
|
298
|
+
else throw new TypeError("Unexpected WebSocket stream message");
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
const request = pending.get(id);
|
|
302
|
+
if (!request) return;
|
|
303
|
+
if (frame.type === "error") {
|
|
304
|
+
pending.delete(id);
|
|
305
|
+
request.cleanup();
|
|
306
|
+
request.reject(remoteError(frame));
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const value = frame["response"];
|
|
310
|
+
if (frame.type !== "response" || !isRecord(value) || !Number.isInteger(value["status"]) || !headers(value["headers"]) || !isRecord(value["body"]) || ![
|
|
311
|
+
"json",
|
|
312
|
+
"text",
|
|
313
|
+
"bytes",
|
|
314
|
+
"form-data",
|
|
315
|
+
"empty",
|
|
316
|
+
"raw",
|
|
317
|
+
"stream"
|
|
318
|
+
].includes(String(value["body"]["kind"]))) throw new TypeError("Invalid WebSocket response");
|
|
319
|
+
const body = value["body"];
|
|
320
|
+
pending.delete(id);
|
|
321
|
+
request.cleanup();
|
|
322
|
+
let remote;
|
|
323
|
+
if (body["kind"] === "stream") {
|
|
324
|
+
const abort = () => {
|
|
325
|
+
remote.fail(request.signal?.reason);
|
|
326
|
+
link.send({
|
|
327
|
+
type: "cancel",
|
|
328
|
+
id
|
|
329
|
+
}).catch(() => {});
|
|
330
|
+
};
|
|
331
|
+
remote = new RemoteStream(link, id, () => {
|
|
332
|
+
streams.delete(id);
|
|
333
|
+
request.signal?.removeEventListener("abort", abort);
|
|
334
|
+
});
|
|
335
|
+
streams.set(id, remote);
|
|
336
|
+
request.signal?.addEventListener("abort", abort, { once: true });
|
|
337
|
+
if (request.signal?.aborted) abort();
|
|
338
|
+
}
|
|
339
|
+
request.resolve({
|
|
340
|
+
status: value["status"],
|
|
341
|
+
headers: value["headers"],
|
|
342
|
+
native: frame,
|
|
343
|
+
dispose: async () => {
|
|
344
|
+
await remote?.return();
|
|
345
|
+
},
|
|
346
|
+
readBody: (kind) => {
|
|
347
|
+
if (body["kind"] !== kind) {
|
|
348
|
+
remote?.return();
|
|
349
|
+
throw new WebSocketTransportError("invalid-message", `Expected ${kind} WebSocket body, received ${String(body["kind"])}`);
|
|
350
|
+
}
|
|
351
|
+
return remote ?? body["value"];
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
}, ended);
|
|
355
|
+
const transport = (async (request) => {
|
|
356
|
+
if (terminal) throw terminal;
|
|
357
|
+
request.signal?.throwIfAborted();
|
|
358
|
+
await new Promise((resolve, reject) => {
|
|
359
|
+
const abort = () => {
|
|
360
|
+
request.signal?.removeEventListener("abort", abort);
|
|
361
|
+
reject(request.signal?.reason);
|
|
362
|
+
};
|
|
363
|
+
request.signal?.addEventListener("abort", abort, { once: true });
|
|
364
|
+
link.ready.then(resolve, reject).finally(() => request.signal?.removeEventListener("abort", abort));
|
|
365
|
+
if (request.signal?.aborted) abort();
|
|
366
|
+
});
|
|
367
|
+
if (terminal) throw terminal;
|
|
368
|
+
request.signal?.throwIfAborted();
|
|
369
|
+
const id = `${prefix}:${++sequence}`;
|
|
370
|
+
return new Promise((resolve, reject) => {
|
|
371
|
+
const abort = () => {
|
|
372
|
+
const active = pending.get(id);
|
|
373
|
+
if (!active) return;
|
|
374
|
+
pending.delete(id);
|
|
375
|
+
active.cleanup();
|
|
376
|
+
reject(request.signal?.reason);
|
|
377
|
+
link.send({
|
|
378
|
+
type: "cancel",
|
|
379
|
+
id
|
|
380
|
+
}).catch(() => {});
|
|
381
|
+
};
|
|
382
|
+
pending.set(id, {
|
|
383
|
+
resolve,
|
|
384
|
+
reject,
|
|
385
|
+
signal: request.signal,
|
|
386
|
+
cleanup: () => request.signal?.removeEventListener("abort", abort)
|
|
387
|
+
});
|
|
388
|
+
request.signal?.addEventListener("abort", abort, { once: true });
|
|
389
|
+
const { signal: _signal, ...input } = request;
|
|
390
|
+
link.send({
|
|
391
|
+
type: "request",
|
|
392
|
+
id,
|
|
393
|
+
request: input
|
|
394
|
+
}, () => pending.has(id)).catch((error) => {
|
|
395
|
+
const active = pending.get(id);
|
|
396
|
+
if (active) {
|
|
397
|
+
pending.delete(id);
|
|
398
|
+
active.cleanup();
|
|
399
|
+
reject(error);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
Object.defineProperties(transport, {
|
|
405
|
+
ready: {
|
|
406
|
+
enumerable: true,
|
|
407
|
+
value: link.ready
|
|
408
|
+
},
|
|
409
|
+
closed: {
|
|
410
|
+
enumerable: true,
|
|
411
|
+
value: link.closed
|
|
412
|
+
},
|
|
413
|
+
close: {
|
|
414
|
+
enumerable: true,
|
|
415
|
+
value: async () => {
|
|
416
|
+
if (!terminal && socket.readyState === 1) await link.send({ type: "close" }).catch(() => {});
|
|
417
|
+
link.stop();
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
return transport;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
//#endregion
|
|
425
|
+
//#region src/server.ts
|
|
426
|
+
function requestInput(value, signal) {
|
|
427
|
+
if (!isRecord(value) || typeof value["path"] !== "string" || typeof value["method"] !== "string" || !Array.isArray(value["key"]) || !value["key"].every((part) => typeof part === "string") || !isRecord(value["headers"]) || !Object.values(value["headers"]).every((header) => typeof header === "string")) throw new TypeError("Invalid WebSocket request");
|
|
428
|
+
if (value["body"] !== void 0 && (!isRecord(value["body"]) || ![
|
|
429
|
+
"json",
|
|
430
|
+
"text",
|
|
431
|
+
"bytes",
|
|
432
|
+
"form-data"
|
|
433
|
+
].includes(String(value["body"]["kind"])) || typeof value["body"]["contentType"] !== "string")) throw new TypeError("Invalid WebSocket request body");
|
|
434
|
+
return {
|
|
435
|
+
...value,
|
|
436
|
+
signal
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
function mount(socket, implementation, options) {
|
|
440
|
+
assertAdapterContext(implementation.context, "websocket");
|
|
441
|
+
const dispatch = createAdapterHandler(implementation, options.onError === void 0 ? {} : { onError: (input) => options.onError({
|
|
442
|
+
...input,
|
|
443
|
+
request: input.request,
|
|
444
|
+
socket
|
|
445
|
+
}) });
|
|
446
|
+
const active = /* @__PURE__ */ new Map();
|
|
447
|
+
let closed = false;
|
|
448
|
+
const cleanup = /* @__PURE__ */ new Set();
|
|
449
|
+
const cancel = (id, state) => {
|
|
450
|
+
if (state.cancelled) return Promise.resolve();
|
|
451
|
+
state.cancelled = true;
|
|
452
|
+
state.controller.abort(/* @__PURE__ */ new Error("WebSocket call cancelled"));
|
|
453
|
+
active.delete(id);
|
|
454
|
+
const operation = Promise.resolve().then(async () => {
|
|
455
|
+
await state.iterator?.return?.();
|
|
456
|
+
});
|
|
457
|
+
cleanup.add(operation);
|
|
458
|
+
operation.finally(() => cleanup.delete(operation)).catch(() => {});
|
|
459
|
+
return operation;
|
|
460
|
+
};
|
|
461
|
+
const ended = () => {
|
|
462
|
+
closed = true;
|
|
463
|
+
for (const [id, state] of active) cancel(id, state).catch(() => {});
|
|
464
|
+
};
|
|
465
|
+
const fail = (id, error) => link.send({
|
|
466
|
+
type: "error",
|
|
467
|
+
id,
|
|
468
|
+
error: error instanceof Error ? error.message : String(error)
|
|
469
|
+
});
|
|
470
|
+
async function pump(id, state) {
|
|
471
|
+
if (state.pumping || !state.iterator) return;
|
|
472
|
+
state.pumping = true;
|
|
473
|
+
try {
|
|
474
|
+
while (!state.cancelled && state.pulls > 0) {
|
|
475
|
+
state.pulls--;
|
|
476
|
+
const chunk = await state.iterator.next();
|
|
477
|
+
if (state.cancelled) break;
|
|
478
|
+
if (chunk.done) {
|
|
479
|
+
active.delete(id);
|
|
480
|
+
await link.send({
|
|
481
|
+
type: "end",
|
|
482
|
+
id
|
|
483
|
+
});
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (!(chunk.value instanceof Uint8Array)) throw new TypeError("WebSocket stream chunks must be Uint8Array");
|
|
487
|
+
await link.send({
|
|
488
|
+
type: "chunk",
|
|
489
|
+
id,
|
|
490
|
+
chunk: chunk.value
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
} catch (error) {
|
|
494
|
+
await cancel(id, state).catch(() => {});
|
|
495
|
+
if (!closed) await fail(id, error);
|
|
496
|
+
} finally {
|
|
497
|
+
state.pumping = false;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
async function execute(frame) {
|
|
501
|
+
const id = frame.id;
|
|
502
|
+
if (active.has(id)) throw new TypeError("Duplicate WebSocket request ID");
|
|
503
|
+
const state = {
|
|
504
|
+
controller: new AbortController(),
|
|
505
|
+
pulls: 0,
|
|
506
|
+
pumping: false,
|
|
507
|
+
cancelled: false
|
|
508
|
+
};
|
|
509
|
+
active.set(id, state);
|
|
510
|
+
try {
|
|
511
|
+
const request = requestInput(frame["request"], state.controller.signal);
|
|
512
|
+
const result = await dispatch({
|
|
513
|
+
request,
|
|
514
|
+
signal: request.signal,
|
|
515
|
+
contextInput: {
|
|
516
|
+
socket,
|
|
517
|
+
request
|
|
518
|
+
},
|
|
519
|
+
hostContext: {
|
|
520
|
+
socket,
|
|
521
|
+
request
|
|
522
|
+
},
|
|
523
|
+
method: request.method,
|
|
524
|
+
pathname: request.path,
|
|
525
|
+
headers: request.headers,
|
|
526
|
+
...request.query === void 0 ? {} : { query: request.query },
|
|
527
|
+
...request.body === void 0 ? {} : { body: {
|
|
528
|
+
value: request.body.value,
|
|
529
|
+
contentType: request.body.contentType
|
|
530
|
+
} }
|
|
531
|
+
});
|
|
532
|
+
const wireResult = result.body.kind === "form-data" ? {
|
|
533
|
+
...result,
|
|
534
|
+
headers: {
|
|
535
|
+
...result.headers,
|
|
536
|
+
"content-type": "multipart/form-data"
|
|
537
|
+
}
|
|
538
|
+
} : result;
|
|
539
|
+
if (result.body.kind === "stream") state.iterator = result.body.value[Symbol.asyncIterator]();
|
|
540
|
+
if (state.cancelled) {
|
|
541
|
+
await state.iterator?.return?.();
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
await link.send({
|
|
545
|
+
type: "response",
|
|
546
|
+
id,
|
|
547
|
+
response: result.body.kind === "stream" ? {
|
|
548
|
+
...wireResult,
|
|
549
|
+
body: { kind: "stream" }
|
|
550
|
+
} : wireResult
|
|
551
|
+
});
|
|
552
|
+
if (result.body.kind !== "stream") active.delete(id);
|
|
553
|
+
} catch (error) {
|
|
554
|
+
if (!state.cancelled) {
|
|
555
|
+
await cancel(id, state).catch(() => {});
|
|
556
|
+
if (!closed) await fail(id, error);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
const link = connection(socket, (frame) => {
|
|
561
|
+
if (frame.type === "close") {
|
|
562
|
+
link.stop();
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
const id = frame.id;
|
|
566
|
+
if (frame.type === "request") {
|
|
567
|
+
if (active.has(id)) throw new TypeError("Duplicate WebSocket request ID");
|
|
568
|
+
execute(frame).catch((error) => link.stop(error instanceof Error ? error : new Error(String(error))));
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const state = active.get(id);
|
|
572
|
+
if (frame.type === "cancel") {
|
|
573
|
+
if (state) cancel(id, state).catch(() => {});
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (frame.type === "pull") {
|
|
577
|
+
if (state?.iterator) {
|
|
578
|
+
state.pulls++;
|
|
579
|
+
pump(id, state).catch(() => {});
|
|
580
|
+
}
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
throw new TypeError("Unexpected WebSocket client message");
|
|
584
|
+
}, ended);
|
|
585
|
+
return {
|
|
586
|
+
ready: link.ready,
|
|
587
|
+
closed: link.closed,
|
|
588
|
+
async close() {
|
|
589
|
+
if (!closed && socket.readyState === 1) await link.send({ type: "close" }).catch(() => {});
|
|
590
|
+
link.stop();
|
|
591
|
+
await Promise.allSettled([...cleanup]);
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
/** Mount once per accepted socket; the application owns upgrades, authentication and socket lifetime. */
|
|
596
|
+
function webSocketAdapter(socket, defaults = {}) {
|
|
597
|
+
const configured = { ...defaults };
|
|
598
|
+
return createServerAdapter("websocket", { mount: (implementation, options) => mount(socket, implementation, {
|
|
599
|
+
...configured,
|
|
600
|
+
...options
|
|
601
|
+
}) });
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
//#endregion
|
|
605
|
+
export { WEBSOCKET_PROTOCOL, WebSocketTransportError, webSocketAdapter, webSocketTransport };
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hulla/api-websocket",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "WebSocket request and response-stream transport for @hulla/api",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/hulladev/api.git",
|
|
9
|
+
"directory": "packages/websocket"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public",
|
|
24
|
+
"registry": "https://registry.npmjs.org/"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsdown",
|
|
28
|
+
"clean": "rm -rf dist coverage",
|
|
29
|
+
"test": "vitest run",
|
|
30
|
+
"test:coverage": "vitest run --coverage",
|
|
31
|
+
"test:watch": "vitest",
|
|
32
|
+
"typecheck": "tsc --noEmit"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@hulla/api": "2.0.0",
|
|
36
|
+
"@types/node": "^26.2.0",
|
|
37
|
+
"@types/ws": "^8.18.1",
|
|
38
|
+
"ws": "^8.21.3",
|
|
39
|
+
"zod": "^4.4.3"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@hulla/api": "^2.0.0"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/README.md
DELETED