@myphp-plugins/websocket 0.0.0-stage → 1.28.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.
Files changed (3) hide show
  1. package/index.ts +333 -0
  2. package/package.json +19 -4
  3. package/README.md +0 -3
package/index.ts ADDED
@@ -0,0 +1,333 @@
1
+ /**
2
+ * The WebSocket plugin of MyPHP (mygo's @mygo-plugins/websocket): a
3
+ * `WebSocket` whose connection PHP makes, with the API of the browser's.
4
+ * Unlike the browser's, it may send any header with the opening
5
+ * handshake, such as Authorization or Cookie, and is not subject to the
6
+ * page's Content Security Policy. The PHP side must use the plugin:
7
+ *
8
+ * ```php
9
+ * App::use(WebSocket::plugin()); // MyPHP\Plugins\WebSocket\WebSocket
10
+ * ```
11
+ *
12
+ * ```ts
13
+ * import { WebSocket } from "@myphp-plugins/websocket";
14
+ *
15
+ * const ws = new WebSocket("wss://example.com/live", [], {
16
+ * headers: { authorization: `Bearer ${token}` },
17
+ * });
18
+ * ws.onmessage = (e) => console.log(e.data);
19
+ * ```
20
+ *
21
+ * @module
22
+ */
23
+ import { Channel, call, isCallError } from "myphp-runtime";
24
+
25
+ /** Options of a {@link WebSocket}, beyond the browser's. */
26
+ export interface WebSocketOptions {
27
+ /** Headers of the opening handshake, e.g. Authorization or Origin. */
28
+ headers?: HeadersInit;
29
+ }
30
+
31
+ /** What happens to the connection, from PHP. */
32
+ interface PhpEvent {
33
+ type: "open" | "text" | "binary" | "close";
34
+ protocol?: string;
35
+ text?: string;
36
+ /** Base64. */
37
+ data?: string;
38
+ code?: number;
39
+ reason?: string;
40
+ clean?: boolean;
41
+ error?: string;
42
+ }
43
+
44
+ /** A message for PHP. */
45
+ type PhpMessage =
46
+ | { type: "text"; text: string }
47
+ | { type: "binary"; data: string }
48
+ | { type: "close"; code: number; reason: string }
49
+ | { type: "skip" };
50
+
51
+ export interface WebSocketEventMap {
52
+ open: Event;
53
+ message: MessageEvent;
54
+ error: Event;
55
+ close: CloseEvent;
56
+ }
57
+
58
+ type Handler<E> = ((this: WebSocket, ev: E) => unknown) | null;
59
+
60
+ const CONNECTING = 0;
61
+ const OPEN = 1;
62
+ const CLOSING = 2;
63
+ const CLOSED = 3;
64
+
65
+ const encoder = new TextEncoder();
66
+
67
+ /**
68
+ * A WebSocket connection made by PHP, with the API of the browser's
69
+ * `WebSocket`. The third argument sets headers of the opening handshake.
70
+ */
71
+ export class WebSocket extends EventTarget {
72
+ static readonly CONNECTING = CONNECTING;
73
+ static readonly OPEN = OPEN;
74
+ static readonly CLOSING = CLOSING;
75
+ static readonly CLOSED = CLOSED;
76
+ declare readonly CONNECTING: 0;
77
+ declare readonly OPEN: 1;
78
+ declare readonly CLOSING: 2;
79
+ declare readonly CLOSED: 3;
80
+
81
+ /** The URL of the connection, with a ws: or wss: scheme. */
82
+ readonly url: string;
83
+ /** How binary messages arrive: as a `Blob`, or an `ArrayBuffer`. */
84
+ binaryType: BinaryType = "blob";
85
+
86
+ #id: string;
87
+ #state: number = CONNECTING;
88
+ #protocol = "";
89
+ #buffered = 0;
90
+ #seq = 0;
91
+ /** Why the connection failed, when the page failed it. */
92
+ #failed: string | undefined;
93
+ #events: Channel<PhpEvent>;
94
+ #handlers = new Map<string, { fn: Handler<any> }>();
95
+
96
+ constructor(url: string | URL, protocols: string | string[] = [], options: WebSocketOptions = {}) {
97
+ super();
98
+ const base = typeof location !== "undefined" ? location.href : undefined;
99
+ let u: URL;
100
+ try {
101
+ u = new URL(url, base);
102
+ } catch {
103
+ throw new DOMException(`Invalid URL: ${url}`, "SyntaxError");
104
+ }
105
+ if (u.protocol === "http:") u.protocol = "ws:";
106
+ else if (u.protocol === "https:") u.protocol = "wss:";
107
+ if (u.protocol !== "ws:" && u.protocol !== "wss:") {
108
+ throw new DOMException(`The URL's scheme must be ws or wss, not ${u.protocol.slice(0, -1)}`, "SyntaxError");
109
+ }
110
+ if (u.hash) throw new DOMException("The URL contains a fragment", "SyntaxError");
111
+ const list = typeof protocols === "string" ? [protocols] : [...protocols];
112
+ if (new Set(list).size !== list.length) {
113
+ throw new DOMException("The subprotocols contain duplicates", "SyntaxError");
114
+ }
115
+ this.url = u.href;
116
+ this.#id = globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2) + Date.now().toString(36);
117
+ this.#events = new Channel<PhpEvent>();
118
+ this.#events.onmessage = (e) => this.#receive(e);
119
+ call("plugin:websocket.connect", {
120
+ id: this.#id,
121
+ url: this.url,
122
+ protocols: list,
123
+ headers: [...new Headers(options.headers)],
124
+ }, this.#events).then(
125
+ () => this.#finish(1006, "", false, this.#failed ?? "connection lost"),
126
+ (err: unknown) => this.#finish(1006, "", false, this.#failed ?? failure(err)),
127
+ );
128
+ }
129
+
130
+ /** The state of the connection: CONNECTING, OPEN, CLOSING or CLOSED. */
131
+ get readyState(): number {
132
+ return this.#state;
133
+ }
134
+
135
+ /** The subprotocol the server chose, once open. */
136
+ get protocol(): string {
137
+ return this.#protocol;
138
+ }
139
+
140
+ /** The extensions in use: none. */
141
+ get extensions(): string {
142
+ return "";
143
+ }
144
+
145
+ /** Bytes sent with `send` that PHP has not written yet. */
146
+ get bufferedAmount(): number {
147
+ return this.#buffered;
148
+ }
149
+
150
+ get onopen(): Handler<Event> {
151
+ return this.#handler("open");
152
+ }
153
+ set onopen(fn: Handler<Event>) {
154
+ this.#setHandler("open", fn);
155
+ }
156
+ get onmessage(): Handler<MessageEvent> {
157
+ return this.#handler("message");
158
+ }
159
+ set onmessage(fn: Handler<MessageEvent>) {
160
+ this.#setHandler("message", fn);
161
+ }
162
+ get onerror(): Handler<Event> {
163
+ return this.#handler("error");
164
+ }
165
+ set onerror(fn: Handler<Event>) {
166
+ this.#setHandler("error", fn);
167
+ }
168
+ get onclose(): Handler<CloseEvent> {
169
+ return this.#handler("close");
170
+ }
171
+ set onclose(fn: Handler<CloseEvent>) {
172
+ this.#setHandler("close", fn);
173
+ }
174
+
175
+ /** Sends a message: text, or binary data. Throws while connecting. */
176
+ send(data: string | ArrayBufferLike | ArrayBufferView | Blob): void {
177
+ if (this.#state === CONNECTING) throw new DOMException("The connection is not open yet", "InvalidStateError");
178
+ let size: number;
179
+ let message: PhpMessage | Promise<PhpMessage>;
180
+ if (typeof data === "string") {
181
+ const bytes = encoder.encode(data);
182
+ size = bytes.length;
183
+ message = { type: "text", text: data };
184
+ } else if (typeof Blob !== "undefined" && data instanceof Blob) {
185
+ size = data.size;
186
+ message = data.arrayBuffer().then(
187
+ (b): PhpMessage => ({ type: "binary", data: toBase64(new Uint8Array(b)) }),
188
+ (): PhpMessage => ({ type: "skip" }),
189
+ );
190
+ } else {
191
+ const bytes = ArrayBuffer.isView(data)
192
+ ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
193
+ : new Uint8Array(data as ArrayBufferLike);
194
+ size = bytes.length;
195
+ message = { type: "binary", data: toBase64(bytes) };
196
+ }
197
+ this.#buffered += size;
198
+ if (this.#state !== OPEN) return; // discarded, as browsers do
199
+ const settle = () => (this.#buffered -= size);
200
+ this.#send(message).then(settle, settle);
201
+ }
202
+
203
+ /**
204
+ * Closes the connection with a code (1000, or 3000 to 4999) and a reason
205
+ * of at most 123 bytes.
206
+ */
207
+ close(code?: number, reason?: string): void {
208
+ if (code !== undefined && code !== 1000 && !(code >= 3000 && code <= 4999)) {
209
+ throw new DOMException(`Invalid close code ${code}`, "InvalidAccessError");
210
+ }
211
+ const why = reason ?? "";
212
+ if (encoder.encode(why).length > 123) throw new DOMException("The close reason is over 123 bytes", "SyntaxError");
213
+ if (this.#state === CLOSING || this.#state === CLOSED) return;
214
+ if (this.#state === CONNECTING) {
215
+ this.#state = CLOSING;
216
+ this.#failed = "closed before the connection was established";
217
+ this.#events.close(); // stops the handshake
218
+ setTimeout(() => this.#finish(1006, "", false, this.#failed));
219
+ return;
220
+ }
221
+ this.#state = CLOSING;
222
+ this.#send({ type: "close", code: code ?? (why ? 1000 : 1005), reason: why }).catch(() => {});
223
+ }
224
+
225
+ #send(message: PhpMessage | Promise<PhpMessage>): Promise<void> {
226
+ const seq = ++this.#seq;
227
+ return Promise.resolve(message).then((m) => call<void>("plugin:websocket.send", this.#id, seq, m));
228
+ }
229
+
230
+ #receive(e: PhpEvent): void {
231
+ switch (e.type) {
232
+ case "open":
233
+ if (this.#state !== CONNECTING) return;
234
+ this.#state = OPEN;
235
+ this.#protocol = e.protocol ?? "";
236
+ this.dispatchEvent(new Event("open"));
237
+ return;
238
+ case "text":
239
+ case "binary": {
240
+ if (this.#state !== OPEN) return;
241
+ let data: unknown = e.text ?? "";
242
+ if (e.type === "binary") {
243
+ const bytes = fromBase64(e.data ?? "");
244
+ data = this.binaryType === "arraybuffer" ? bytes.buffer : new Blob([bytes as Uint8Array<ArrayBuffer>]);
245
+ }
246
+ this.dispatchEvent(new MessageEvent("message", { data, origin: new URL(this.url).origin }));
247
+ return;
248
+ }
249
+ case "close":
250
+ this.#finish(e.code ?? 1005, e.reason ?? "", e.clean ?? false, e.error);
251
+ }
252
+ }
253
+
254
+ #finish(code: number, reason: string, wasClean: boolean, error?: string): void {
255
+ if (this.#state === CLOSED) return;
256
+ this.#state = CLOSED;
257
+ this.#events.close();
258
+ if (error) {
259
+ console.error(`WebSocket connection to '${this.url}' failed: ${error}`);
260
+ this.dispatchEvent(new Event("error"));
261
+ }
262
+ this.dispatchEvent(closeEvent(code, reason, wasClean));
263
+ }
264
+
265
+ #handler(type: string): Handler<any> {
266
+ return this.#handlers.get(type)?.fn ?? null;
267
+ }
268
+
269
+ // An event handler is a listener added when it is first set, which calls
270
+ // whatever handler is set when the event fires.
271
+ #setHandler(type: string, fn: Handler<any>): void {
272
+ let entry = this.#handlers.get(type);
273
+ if (!entry) {
274
+ const e = (entry = { fn: null as Handler<any> });
275
+ this.#handlers.set(type, e);
276
+ this.addEventListener(type, (ev) => e.fn?.call(this, ev));
277
+ }
278
+ entry.fn = typeof fn === "function" ? fn : null;
279
+ }
280
+ }
281
+
282
+ for (const [name, value] of Object.entries({ CONNECTING, OPEN, CLOSING, CLOSED })) {
283
+ Object.defineProperty(WebSocket.prototype, name, { value, enumerable: true });
284
+ }
285
+
286
+ export interface WebSocket {
287
+ addEventListener<K extends keyof WebSocketEventMap>(
288
+ type: K,
289
+ listener: (this: WebSocket, ev: WebSocketEventMap[K]) => unknown,
290
+ options?: boolean | AddEventListenerOptions,
291
+ ): void;
292
+ addEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions): void;
293
+ removeEventListener<K extends keyof WebSocketEventMap>(
294
+ type: K,
295
+ listener: (this: WebSocket, ev: WebSocketEventMap[K]) => unknown,
296
+ options?: boolean | EventListenerOptions,
297
+ ): void;
298
+ removeEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | EventListenerOptions): void;
299
+ }
300
+
301
+ export default WebSocket;
302
+
303
+ function closeEvent(code: number, reason: string, wasClean: boolean): CloseEvent {
304
+ if (typeof CloseEvent !== "undefined") return new CloseEvent("close", { code, reason, wasClean });
305
+ return Object.assign(new Event("close"), { code, reason, wasClean }) as CloseEvent;
306
+ }
307
+
308
+ /** Why the connection failed. */
309
+ function failure(err: unknown): string {
310
+ if (isCallError(err) && err.message.includes("plugin is not used")) {
311
+ return "the PHP side does not use the WebSocket plugin: call App::use(WebSocket::plugin()) before App::run";
312
+ }
313
+ return err instanceof Error ? err.message : String(err);
314
+ }
315
+
316
+ function toBase64(bytes: Uint8Array): string {
317
+ const native = (bytes as { toBase64?: () => string }).toBase64;
318
+ if (native) return native.call(bytes);
319
+ let s = "";
320
+ for (let i = 0; i < bytes.length; i += 0x8000) {
321
+ s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000) as unknown as number[]);
322
+ }
323
+ return btoa(s);
324
+ }
325
+
326
+ function fromBase64(s: string): Uint8Array {
327
+ const native = (Uint8Array as { fromBase64?: (s: string) => Uint8Array }).fromBase64;
328
+ if (native) return native(s);
329
+ const bin = atob(s);
330
+ const bytes = new Uint8Array(bin.length);
331
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
332
+ return bytes;
333
+ }
package/package.json CHANGED
@@ -1,6 +1,21 @@
1
1
  {
2
2
  "name": "@myphp-plugins/websocket",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.28.0",
4
+ "description": "The page side of MyPHP's WebSocket plugin: a WebSocket with the browser's API whose connections PHP makes, over myphp-runtime",
5
+ "type": "module",
6
+ "exports": "./index.ts",
7
+ "types": "./index.ts",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/owner888/myphp.git",
11
+ "directory": "src/Plugins/WebSocket/client"
12
+ },
13
+ "license": "MIT",
14
+ "files": ["index.ts"],
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "peerDependencies": {
19
+ "myphp-runtime": "^1.28.0"
20
+ }
21
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.