@wailsio/runtime 3.0.0-beta.1 → 3.0.0-beta.11

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.js CHANGED
@@ -31,7 +31,8 @@ import * as Android from "./android.js";
31
31
  import * as Updater from "./updater.js";
32
32
  import Window, { handleDragEnter, handleDragLeave, handleDragOver } from "./window.js";
33
33
  import * as WML from "./wml.js";
34
- export { Application, Browser, Call, Clipboard, Dialogs, Events, Flags, Screens, System, IOS, Android, Updater, Window, WML };
34
+ import { Stream, JSONStream, WailsSocket } from "./stream.js";
35
+ export { Application, Browser, Call, Clipboard, Dialogs, Events, Flags, Screens, System, IOS, Android, Updater, Window, WML, Stream, JSONStream, WailsSocket };
35
36
  /**
36
37
  * An internal utility consumed by the binding generator.
37
38
  *
package/dist/nanoid.js CHANGED
@@ -26,12 +26,31 @@
26
26
  // References to the brotli default dictionary:
27
27
  // `-26T`, `1983`, `40px`, `75px`, `bush`, `jack`, `mind`, `very`, and `wolf`
28
28
  const urlAlphabet = 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict';
29
+ // MODIFIED FOR WAILS: the upstream implementation draws from Math.random(),
30
+ // which is not a cryptographic source. These ids are used for the runtime
31
+ // client id, binding call ids, chunk ids, and desktop stream session ids. They
32
+ // are correlation identifiers rather than authentication capabilities, but a
33
+ // stronger source makes accidental collisions negligible even across many
34
+ // concurrently loaded runtimes. The alphabet is 64 characters, so masking a
35
+ // random byte with 63 is uniform — no rejection sampling needed.
29
36
  export function nanoid(size = 21) {
37
+ const source = typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function"
38
+ ? crypto
39
+ : null;
40
+ if (source) {
41
+ const bytes = new Uint8Array(size);
42
+ source.getRandomValues(bytes);
43
+ let id = '';
44
+ for (let i = 0; i < size; i++) {
45
+ id += urlAlphabet[bytes[i] & 63];
46
+ }
47
+ return id;
48
+ }
49
+ // Fallback for environments without Web Crypto (very old engines, some
50
+ // server-side rendering contexts). Ids remain unique but not unguessable.
30
51
  let id = '';
31
- // A compact alternative for `for (var i = 0; i < step; i++)`.
32
52
  let i = size | 0;
33
53
  while (i--) {
34
- // `| 0` is more compact and faster than `Math.floor()`.
35
54
  id += urlAlphabet[(Math.random() * 64) | 0];
36
55
  }
37
56
  return id;
package/dist/stream.js ADDED
@@ -0,0 +1,869 @@
1
+ /*
2
+ _ __ _ __
3
+ | | / /___ _(_) /____
4
+ | | /| / / __ `/ / / ___/
5
+ | |/ |/ / /_/ / / (__ )
6
+ |__/|__/\__,_/_/_/____/
7
+ The electron alternative for Go
8
+ (c) Lea Anthony 2019-present
9
+ */
10
+ /**
11
+ * GoStream — the WebSocket programming model without a listening socket.
12
+ *
13
+ * A WebSocket cannot be spoken over a custom URL scheme, so getting one inside
14
+ * a webview means binding a real TCP port. This speaks the same shape over the
15
+ * asset server the app already serves: Go→JS over one held poll per page, JS→Go
16
+ * over a normal POST.
17
+ *
18
+ * {@link Stream} returns synchronously with `readyState === CONNECTING`, the way
19
+ * `new WebSocket(url)` does, so a connection can be created at module scope:
20
+ *
21
+ * ```js
22
+ * export const Telemetry = Stream("telemetry"); // connects in the background
23
+ * ```
24
+ */
25
+ import { nanoid } from "./nanoid.js";
26
+ import { hasDOM } from "./environment.js";
27
+ const GENERATION_KEY = "__wails_stream_generation";
28
+ const GENERATION_NAME_MARKER = "\u001f__wails_stream_generation__:";
29
+ // window.name belongs to the browsing context rather than the Document, so it
30
+ // survives a reload even when Web Storage is disabled. Preserve any name the
31
+ // application assigned and reserve only a suffix for the Stream generation.
32
+ function namedPageGeneration() {
33
+ try {
34
+ const marker = window.name.lastIndexOf(GENERATION_NAME_MARKER);
35
+ if (marker < 0)
36
+ return 0;
37
+ const value = Number(window.name.slice(marker + GENERATION_NAME_MARKER.length));
38
+ return Number.isSafeInteger(value) && value > 0 ? value : 0;
39
+ }
40
+ catch (_a) {
41
+ return 0;
42
+ }
43
+ }
44
+ function rememberNamedPageGeneration(generation) {
45
+ try {
46
+ const marker = window.name.lastIndexOf(GENERATION_NAME_MARKER);
47
+ const applicationName = marker < 0 ? window.name : window.name.slice(0, marker);
48
+ window.name = applicationName + GENERATION_NAME_MARKER + generation;
49
+ }
50
+ catch (_a) {
51
+ // Some embedded browser policies can deny access to the browsing
52
+ // context name. The time origin remains the last-resort fallback.
53
+ }
54
+ }
55
+ // Request arrival order is not page order: a request started just before a
56
+ // navigation can reach Go after the replacement page's first request. Keep a
57
+ // monotonic counter in this top-level browsing context so Go can distinguish
58
+ // those page incarnations without trusting server scheduling order.
59
+ function nextPageGeneration() {
60
+ if (!hasDOM)
61
+ return 1;
62
+ // Anchor the value to the page's creation time as well as the stored
63
+ // counter. If application code clears sessionStorage, the next reload must
64
+ // still be newer than the generation Go has already retired; restarting
65
+ // from 1 would make every stream request receive 410 until the host exits.
66
+ // Performance.timeOrigin is absent on older WebKit releases that Wails
67
+ // still targets. Date.now keeps those engines on the protocol; the stored
68
+ // counter disambiguates reloads that happen within the same millisecond.
69
+ const origin = typeof performance !== "undefined" && Number.isFinite(performance.timeOrigin)
70
+ ? performance.timeOrigin
71
+ : Date.now();
72
+ const pageTime = Math.max(1, Math.floor(origin * 1000));
73
+ let current = namedPageGeneration();
74
+ try {
75
+ const raw = window.sessionStorage.getItem(GENERATION_KEY);
76
+ const stored = raw === null ? 0 : Number(raw);
77
+ if (Number.isSafeInteger(stored) && stored > current)
78
+ current = stored;
79
+ }
80
+ catch (_a) {
81
+ // Storage can be disabled by policy. window.name supplies the
82
+ // browsing-context counter in that case.
83
+ }
84
+ const next = Number.isSafeInteger(current) && current >= 0 && current < Number.MAX_SAFE_INTEGER
85
+ ? Math.max(current + 1, pageTime)
86
+ : pageTime;
87
+ try {
88
+ window.sessionStorage.setItem(GENERATION_KEY, String(next));
89
+ }
90
+ catch (_b) {
91
+ // The browsing-context fallback below is sufficient when storage is
92
+ // unavailable.
93
+ }
94
+ rememberNamedPageGeneration(next);
95
+ return next;
96
+ }
97
+ function streamGlobals() {
98
+ const fresh = () => ({
99
+ session: nanoid(),
100
+ generation: nextPageGeneration(),
101
+ nextConnID: 1,
102
+ connections: new Map(),
103
+ polling: false,
104
+ });
105
+ if (!hasDOM)
106
+ return fresh();
107
+ const w = window._wails = window._wails || {};
108
+ return (w.__stream = w.__stream || fresh());
109
+ }
110
+ const G = streamGlobals();
111
+ const HDR_SESSION = "x-wails-stream-session";
112
+ const HDR_GENERATION = "x-wails-stream-generation";
113
+ const HDR_CONN = "x-wails-stream-conn";
114
+ const HDR_KIND = "x-wails-stream-kind";
115
+ const HDR_NAME = "x-wails-stream-name";
116
+ const HDR_CHUNK = "x-wails-stream-chunk";
117
+ const HDR_CHUNK_INDEX = "x-wails-stream-chunk-index";
118
+ const HDR_CHUNK_TOTAL = "x-wails-stream-chunk-total";
119
+ const HDR_BATCH = "x-wails-stream-batch";
120
+ const KIND_DATA = 0;
121
+ const KIND_OPEN = 1;
122
+ const KIND_CLOSE = 2;
123
+ const KIND_ERROR = 3;
124
+ // Matches the runtime's own limit: stay under WebView2's ~2MB request body
125
+ // buffering limit in WebResourceRequested.
126
+ const CHUNK_THRESHOLD = 512 * 1024;
127
+ const MAX_BATCH_FRAMES = 4096;
128
+ // Backoff floor and ceiling for a failing poll. This is the "reasonable lower
129
+ // limit" on polling — an error backoff, not a steady-state interval. In normal
130
+ // operation the poll re-issues immediately, because the server hold has already
131
+ // provided whatever delay was appropriate.
132
+ const BACKOFF_MIN = 250;
133
+ const BACKOFF_MAX = 5000;
134
+ const textEncoder = new TextEncoder();
135
+ function streamURL(endpoint) {
136
+ return window.location.origin + "/wails/stream/" + endpoint;
137
+ }
138
+ class StreamRequestCancelled extends Error {
139
+ constructor() {
140
+ super("stream request cancelled");
141
+ this.name = "AbortError";
142
+ }
143
+ }
144
+ function sleep(ms, signal) {
145
+ if (!signal)
146
+ return new Promise((resolve) => setTimeout(resolve, ms));
147
+ if (signal.aborted)
148
+ return Promise.reject(new StreamRequestCancelled());
149
+ return new Promise((resolve, reject) => {
150
+ const onAbort = () => {
151
+ clearTimeout(timer);
152
+ reject(new StreamRequestCancelled());
153
+ };
154
+ const timer = setTimeout(() => {
155
+ signal.removeEventListener("abort", onAbort);
156
+ resolve();
157
+ }, ms);
158
+ signal.addEventListener("abort", onAbort, { once: true });
159
+ });
160
+ }
161
+ /**
162
+ * A stream connection. Implements the useful subset of the `WebSocket`
163
+ * interface, so the same application code works against a real WebSocket in
164
+ * server builds.
165
+ *
166
+ * One deliberate divergence from the standard: `binaryType` defaults to
167
+ * `"arraybuffer"` rather than `"blob"`, because stream frames are always
168
+ * binary and a Blob would force an extra async hop to read every message.
169
+ * Setting it to `"blob"` behaves as the standard describes.
170
+ */
171
+ export class WailsSocket extends EventTarget {
172
+ /** Bytes queued by send() that have not yet reached Go. */
173
+ get bufferedAmount() {
174
+ return this._buffered;
175
+ }
176
+ constructor(name) {
177
+ super();
178
+ this.CONNECTING = 0;
179
+ this.OPEN = 1;
180
+ this.CLOSING = 2;
181
+ this.CLOSED = 3;
182
+ /** Always empty: subprotocols and extensions are not negotiated. */
183
+ this.protocol = "";
184
+ this.extensions = "";
185
+ this.binaryType = "arraybuffer";
186
+ this.readyState = WailsSocket.CONNECTING;
187
+ // Declared for the type; the constructor replaces each with an accessor
188
+ // that registers a real listener. Calling them directly instead would make
189
+ // any wrapper (see JSONStream) receive both the raw and the decoded event.
190
+ this.onopen = null;
191
+ this.onmessage = null;
192
+ this.onclose = null;
193
+ this.onerror = null;
194
+ /**
195
+ * @internal Applied to every inbound payload before dispatch. JSONStream
196
+ * replaces it, so `onmessage` and `addEventListener("message", …)` see the
197
+ * same decoded value — intercepting only `onmessage` made the two disagree.
198
+ */
199
+ this._decode = (payload) => payload;
200
+ this._buffered = 0;
201
+ // Sends are serialised per connection: concurrent fetch() POSTs do not
202
+ // preserve order, and Go relies on send order being the order it observes.
203
+ this._chain = Promise.resolve();
204
+ this._pending = [];
205
+ this._flushing = false;
206
+ this._openAbort = new AbortController();
207
+ this._sendAbort = new AbortController();
208
+ this.name = name;
209
+ this._id = G.nextConnID++;
210
+ this.url = hasDOM ? streamURL("poll") + "#" + encodeURIComponent(name) : "";
211
+ for (const type of ["open", "message", "close", "error"]) {
212
+ defineHandlerProperty(this, type);
213
+ }
214
+ G.connections.set(this._id, this);
215
+ // Fire and forget: the ack arrives as an open frame on the poll, which
216
+ // is what moves readyState to OPEN.
217
+ this._chain = this._chain.then(() => postFrame(this._id, KIND_OPEN, new Uint8Array(0), name, this._openAbort.signal)).catch((err) => {
218
+ this._fail(err);
219
+ });
220
+ startPolling();
221
+ }
222
+ /**
223
+ * Send a frame to Go. Accepts anything a WebSocket does; strings are
224
+ * encoded as UTF-8, since Go receives every frame as a byte slice.
225
+ */
226
+ send(data) {
227
+ if (this.readyState === WailsSocket.CONNECTING) {
228
+ // Matches the WebSocket specification.
229
+ throw new DOMException("Still in CONNECTING state.", "InvalidStateError");
230
+ }
231
+ if (this.readyState !== WailsSocket.OPEN) {
232
+ return;
233
+ }
234
+ // Resolve now rather than inside the chain. Mutable binary inputs are
235
+ // copied synchronously so send() snapshots their bytes like a native
236
+ // WebSocket, even when a batch waits behind an in-flight request. A
237
+ // Blob is immutable and is therefore safe to read asynchronously once.
238
+ const immediate = toBytesSync(data);
239
+ const snapshot = immediate
240
+ ? Promise.resolve(immediate)
241
+ : toBytes(data, this._sendAbort.signal);
242
+ // close() may discard this promise before the flush has dequeued it.
243
+ // Attach a rejection observer immediately so cancelling an asynchronous
244
+ // Blob read cannot become an unhandled rejection; Promise.all still
245
+ // observes the original rejection when the flush already owns it.
246
+ void snapshot.catch(() => { });
247
+ // A Blob cannot be converted synchronously, but Blob.size is available
248
+ // now, so its bytes are still counted the moment send() returns. Waiting
249
+ // for arrayBuffer() to resolve let a large Blob slip past code using
250
+ // bufferedAmount for backpressure.
251
+ const blobSize = !immediate && typeof Blob !== "undefined" && data instanceof Blob ? data.size : 0;
252
+ if (blobSize) {
253
+ this._buffered += blobSize;
254
+ }
255
+ // Account for the bytes now, not when the chain reaches them. Reporting
256
+ // zero while frames wait behind an in-flight batch would tell an
257
+ // application using bufferedAmount for backpressure that it may keep
258
+ // sending.
259
+ if (immediate) {
260
+ this._buffered += immediate.byteLength;
261
+ }
262
+ this._pending.push(snapshot);
263
+ // Queue rather than post. Whatever accumulates while a request is in
264
+ // flight goes out together in the next one, so the per-request cost —
265
+ // a scheme-handler round trip, about eleven cgo calls on macOS — is
266
+ // divided across the batch. Under light load a batch is one frame and
267
+ // this behaves exactly as before; the batching only appears when the
268
+ // sender is outrunning the transport, which is when it is needed.
269
+ if (this._flushing)
270
+ return;
271
+ this._flushing = true;
272
+ this._chain = this._chain.then(async () => {
273
+ try {
274
+ while (this._pending.length > 0) {
275
+ // Bound the work and temporary promise array for one pass.
276
+ // postBatch applies the wire-size bound after resolution.
277
+ const queued = this._pending.splice(0, MAX_BATCH_FRAMES);
278
+ const batch = await Promise.all(queued);
279
+ // A remote close may arrive while a Blob is being read.
280
+ // The connection is already gone in that case, so do not
281
+ // issue a late POST for bytes the peer can no longer take.
282
+ if (this.readyState === WailsSocket.CLOSED) {
283
+ break;
284
+ }
285
+ const total = batch.reduce((n, b) => n + b.byteLength, 0);
286
+ try {
287
+ await postBatch(this._id, batch, this._sendAbort.signal);
288
+ }
289
+ finally {
290
+ // _closed resets the counter immediately. A request
291
+ // already in flight may finish afterwards, and must
292
+ // not drive bufferedAmount below zero.
293
+ this._buffered = Math.max(0, this._buffered - total);
294
+ }
295
+ }
296
+ }
297
+ finally {
298
+ this._flushing = false;
299
+ }
300
+ }).catch((err) => {
301
+ this._flushing = false;
302
+ this._fail(err);
303
+ });
304
+ }
305
+ /** Close the connection. Go's handler sees Receive fail and returns. */
306
+ close(code = 1000, reason = "") {
307
+ if (this.readyState === WailsSocket.CLOSING || this.readyState === WailsSocket.CLOSED) {
308
+ return;
309
+ }
310
+ this.readyState = WailsSocket.CLOSING;
311
+ this._pending.length = 0;
312
+ this._buffered = 0;
313
+ this._openAbort.abort();
314
+ // A data frame waiting for receiver capacity must not prevent the
315
+ // reserved close control from ever reaching Go. Cancel only the data
316
+ // retry path; the chain below still orders the close after that path
317
+ // has unwound, preserving normal send-before-close ordering.
318
+ this._sendAbort.abort();
319
+ this._chain = this._chain.then(() => postFrame(this._id, KIND_CLOSE, new Uint8Array(0))).catch(() => {
320
+ // The peer is already gone; the local close below is what matters.
321
+ }).then(() => {
322
+ this._closed(code, reason, true);
323
+ });
324
+ }
325
+ /** @internal Called when the open frame comes back from Go. */
326
+ _opened() {
327
+ if (this.readyState !== WailsSocket.CONNECTING)
328
+ return;
329
+ this.readyState = WailsSocket.OPEN;
330
+ this.dispatchEvent(new Event("open"));
331
+ }
332
+ /** @internal Called for each data frame addressed to this connection. */
333
+ _message(payload) {
334
+ if (this.readyState !== WailsSocket.OPEN)
335
+ return;
336
+ let data;
337
+ try {
338
+ data = this._decode(payload);
339
+ }
340
+ catch (_a) {
341
+ this.dispatchEvent(new Event("error"));
342
+ return;
343
+ }
344
+ if (data === payload && this.binaryType === "blob") {
345
+ data = new Blob([payload]);
346
+ }
347
+ this.dispatchEvent(new MessageEvent("message", { data }));
348
+ }
349
+ /** @internal */
350
+ _fail(err) {
351
+ // An in-flight send can reject after a clean close has already arrived.
352
+ // The socket's lifecycle is complete then: emitting error after close
353
+ // reverses the WebSocket event order and reports a false failure.
354
+ if (this.readyState === WailsSocket.CLOSED || this.readyState === WailsSocket.CLOSING)
355
+ return;
356
+ this.dispatchEvent(new Event("error"));
357
+ // 1006: closed abnormally, no close frame. Same code a real WebSocket
358
+ // reports when the connection drops without a handshake.
359
+ this._closed(1006, err instanceof Error ? err.message : String(err), false);
360
+ }
361
+ /** @internal */
362
+ _closed(code, reason, wasClean) {
363
+ var _a;
364
+ if (this.readyState === WailsSocket.CLOSED)
365
+ return;
366
+ this.readyState = WailsSocket.CLOSED;
367
+ this._openAbort.abort();
368
+ this._sendAbort.abort();
369
+ G.connections.delete(this._id);
370
+ if (G.connections.size === 0)
371
+ (_a = G.pollAbort) === null || _a === void 0 ? void 0 : _a.abort();
372
+ this._pending.length = 0;
373
+ this._buffered = 0;
374
+ const ev = typeof CloseEvent === "function"
375
+ ? new CloseEvent("close", { code, reason, wasClean })
376
+ : Object.assign(new Event("close"), { code, reason, wasClean });
377
+ this.dispatchEvent(ev);
378
+ }
379
+ }
380
+ WailsSocket.CONNECTING = 0;
381
+ WailsSocket.OPEN = 1;
382
+ WailsSocket.CLOSING = 2;
383
+ WailsSocket.CLOSED = 3;
384
+ /**
385
+ * Connect to a stream declared in Go with `app.HandleStream(name, handler)`.
386
+ *
387
+ * Returns immediately with `readyState === CONNECTING`; listen for `open`, or
388
+ * just call {@link WailsSocket.send} once open. Creating a connection at module
389
+ * scope is supported and is the intended shape for generated bindings.
390
+ */
391
+ export function Stream(name) {
392
+ var _a;
393
+ // Server builds install a factory returning a real WebSocket, since there
394
+ // is a listener there to upgrade on and nothing to emulate. Both objects
395
+ // present the same interface, so nothing above this line cares which it is.
396
+ if (hasDOM) {
397
+ const factory = (_a = window._wails) === null || _a === void 0 ? void 0 : _a.streamFactory;
398
+ if (typeof factory === "function") {
399
+ return factory(name);
400
+ }
401
+ }
402
+ return new WailsSocket(name);
403
+ }
404
+ // Defines an `on<type>` property that registers and unregisters a real event
405
+ // listener, the way the DOM defines them. Assigning replaces the previous
406
+ // handler; reading returns it.
407
+ function defineHandlerProperty(target, type) {
408
+ let current = null;
409
+ Object.defineProperty(target, "on" + type, {
410
+ get: () => current,
411
+ set(fn) {
412
+ if (current)
413
+ target.removeEventListener(type, current);
414
+ current = typeof fn === "function" ? fn : null;
415
+ if (current)
416
+ target.addEventListener(type, current);
417
+ },
418
+ configurable: true,
419
+ enumerable: true,
420
+ });
421
+ }
422
+ /**
423
+ * A {@link Stream} that speaks objects instead of bytes.
424
+ *
425
+ * `send(value)` marshals with `JSON.stringify`, and `ev.data` in an `onmessage`
426
+ * handler is the parsed object rather than an `ArrayBuffer`. Pairs with
427
+ * `StreamConn.SendJSON` / `ReceiveJSON` on the Go side.
428
+ *
429
+ * ```js
430
+ * const s = JSONStream("telemetry");
431
+ * s.onmessage = (ev) => console.log(ev.data.temperature);
432
+ * s.onopen = () => s.send({ subscribe: "sensors" });
433
+ * ```
434
+ *
435
+ * A frame that is not valid JSON raises an `error` event and is dropped, rather
436
+ * than throwing from the poll loop and taking the connection down with it.
437
+ */
438
+ export function JSONStream(name) {
439
+ const socket = Stream(name);
440
+ const decoder = new TextDecoder();
441
+ const parse = (payload) => JSON.parse(typeof payload === "string" ? payload : decoder.decode(payload));
442
+ if (socket instanceof WailsSocket) {
443
+ // Decoding through the hook rather than by wrapping onmessage means
444
+ // addEventListener listeners see the parsed object too.
445
+ socket._decode = (payload) => parse(payload);
446
+ }
447
+ else {
448
+ // Server builds get a native WebSocket, whose dispatch never consults
449
+ // _decode. Patch both listener styles on this instance so JSONStream
450
+ // means the same thing on either transport, which is the point of the
451
+ // shared API.
452
+ const native = socket;
453
+ native.binaryType = "arraybuffer";
454
+ const wrapped = new WeakMap();
455
+ const decodedEvents = new WeakMap();
456
+ const add = native.addEventListener.bind(native);
457
+ const remove = native.removeEventListener.bind(native);
458
+ const decodeEvent = (ev) => {
459
+ var _a;
460
+ if (decodedEvents.has(ev))
461
+ return (_a = decodedEvents.get(ev)) !== null && _a !== void 0 ? _a : null;
462
+ try {
463
+ const value = parse(ev.data);
464
+ const decoded = new MessageEvent("message", { data: value });
465
+ decodedEvents.set(ev, decoded);
466
+ return decoded;
467
+ }
468
+ catch (_b) {
469
+ decodedEvents.set(ev, null);
470
+ native.dispatchEvent(new Event("error"));
471
+ return null;
472
+ }
473
+ };
474
+ const decode = (listener) => {
475
+ const existing = wrapped.get(listener);
476
+ if (existing)
477
+ return existing;
478
+ const fn = (ev) => {
479
+ const decoded = decodeEvent(ev);
480
+ if (!decoded)
481
+ return;
482
+ if (typeof listener === "function")
483
+ listener.call(native, decoded);
484
+ else
485
+ listener.handleEvent(decoded);
486
+ };
487
+ wrapped.set(listener, fn);
488
+ return fn;
489
+ };
490
+ native.addEventListener = ((type, listener, opts) => {
491
+ add(type, type === "message" && listener ? decode(listener) : listener, opts);
492
+ });
493
+ native.removeEventListener = ((type, listener, opts) => {
494
+ const fn = type === "message" && listener ? wrapped.get(listener) : undefined;
495
+ remove(type, fn !== null && fn !== void 0 ? fn : listener, opts);
496
+ });
497
+ let handler = null;
498
+ Object.defineProperty(native, "onmessage", {
499
+ get: () => handler,
500
+ set(fn) {
501
+ if (handler)
502
+ native.removeEventListener("message", handler);
503
+ handler = typeof fn === "function" ? fn : null;
504
+ if (handler)
505
+ native.addEventListener("message", handler);
506
+ },
507
+ configurable: true,
508
+ enumerable: true,
509
+ });
510
+ }
511
+ const send = socket.send.bind(socket);
512
+ socket.send = (value) => send(JSON.stringify(value));
513
+ return socket;
514
+ }
515
+ // Synchronous conversion for everything but a Blob. Avoids a promise per send
516
+ // and, more importantly, lets bufferedAmount be exact the moment send() returns.
517
+ function toBytesSync(data) {
518
+ if (typeof data === "string") {
519
+ return textEncoder.encode(data);
520
+ }
521
+ if (typeof Blob !== "undefined" && data instanceof Blob) {
522
+ return null;
523
+ }
524
+ if (ArrayBuffer.isView(data)) {
525
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice();
526
+ }
527
+ return new Uint8Array(data).slice();
528
+ }
529
+ function abortable(promise, signal) {
530
+ if (!signal)
531
+ return promise;
532
+ if (signal.aborted)
533
+ return Promise.reject(new StreamRequestCancelled());
534
+ return new Promise((resolve, reject) => {
535
+ const onAbort = () => reject(new StreamRequestCancelled());
536
+ signal.addEventListener("abort", onAbort, { once: true });
537
+ promise.then((value) => {
538
+ signal.removeEventListener("abort", onAbort);
539
+ resolve(value);
540
+ }, (err) => {
541
+ signal.removeEventListener("abort", onAbort);
542
+ reject(err);
543
+ });
544
+ });
545
+ }
546
+ async function toBytes(data, signal) {
547
+ if (typeof data === "string") {
548
+ return textEncoder.encode(data);
549
+ }
550
+ if (typeof Blob !== "undefined" && data instanceof Blob) {
551
+ return new Uint8Array(await abortable(data.arrayBuffer(), signal));
552
+ }
553
+ if (ArrayBuffer.isView(data)) {
554
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice();
555
+ }
556
+ return new Uint8Array(data).slice();
557
+ }
558
+ async function postFrame(connID, kind, body, name, signal) {
559
+ const headers = {
560
+ [HDR_SESSION]: G.session,
561
+ [HDR_GENERATION]: String(G.generation),
562
+ [HDR_CONN]: String(connID),
563
+ [HDR_KIND]: String(kind),
564
+ "Content-Type": "application/octet-stream",
565
+ };
566
+ if (name !== undefined) {
567
+ headers[HDR_NAME] = name;
568
+ }
569
+ if (body.byteLength <= CHUNK_THRESHOLD) {
570
+ await postWithRetry(headers, body, signal);
571
+ return;
572
+ }
573
+ // Split oversized frames the same way the runtime splits oversized calls.
574
+ const chunkID = nanoid();
575
+ const total = Math.ceil(body.byteLength / CHUNK_THRESHOLD);
576
+ for (let i = 0; i < total; i++) {
577
+ const slice = body.subarray(i * CHUNK_THRESHOLD, (i + 1) * CHUNK_THRESHOLD);
578
+ await postWithRetry(Object.assign(Object.assign({}, headers), { [HDR_CHUNK]: chunkID, [HDR_CHUNK_INDEX]: String(i), [HDR_CHUNK_TOTAL]: String(total) }), slice, signal);
579
+ }
580
+ }
581
+ // A 429 means the Go handler has not taken delivery of what is already queued.
582
+ // Retrying the same frame keeps ordering (the send chain has not moved on) and
583
+ // leaves the request slot free, which is what stops a backed-up connection from
584
+ // starving the window's poll.
585
+ async function postWithRetry(headers, body, signal) {
586
+ // Never retry with zero delay. The receiver being behind is a condition
587
+ // that takes time to clear, and an immediate retry is a busy loop of
588
+ // fetches — each one a scheme-handler round trip, and on the host a cgo
589
+ // call. Start at 1 ms and ramp.
590
+ let wait = 1;
591
+ for (;;) {
592
+ if (signal === null || signal === void 0 ? void 0 : signal.aborted)
593
+ throw new StreamRequestCancelled();
594
+ const resp = await fetch(streamURL("send"), {
595
+ method: "POST",
596
+ headers,
597
+ body: body,
598
+ signal,
599
+ });
600
+ if (resp.ok)
601
+ return;
602
+ if (resp.status !== 429) {
603
+ throw new Error(await resp.text());
604
+ }
605
+ await sleep(wait, signal);
606
+ wait = Math.min(wait * 2, 50);
607
+ }
608
+ }
609
+ // postBatch sends several data frames for one connection in bounded requests.
610
+ // Body: count u32, then count x ( len u32 | payload ). The combined body stays
611
+ // within CHUNK_THRESHOLD: several individually small frames must not recreate
612
+ // the WebView2 body-size problem that chunking single large frames avoids.
613
+ async function postBatch(connID, frames, signal) {
614
+ let start = 0;
615
+ while (start < frames.length) {
616
+ if (frames[start].byteLength > CHUNK_THRESHOLD) {
617
+ await postFrame(connID, KIND_DATA, frames[start], undefined, signal);
618
+ start++;
619
+ continue;
620
+ }
621
+ let end = start;
622
+ let size = 4; // frame count
623
+ while (end < frames.length && end - start < MAX_BATCH_FRAMES) {
624
+ const frame = frames[end];
625
+ if (frame.byteLength > CHUNK_THRESHOLD || size + 4 + frame.byteLength > CHUNK_THRESHOLD) {
626
+ break;
627
+ }
628
+ size += 4 + frame.byteLength;
629
+ end++;
630
+ }
631
+ // A near-threshold frame fits as a normal POST body but not inside a
632
+ // batch once its count and length headers are included.
633
+ if (end === start) {
634
+ await postFrame(connID, KIND_DATA, frames[start], undefined, signal);
635
+ start++;
636
+ continue;
637
+ }
638
+ const group = frames.slice(start, end);
639
+ if (group.length === 1) {
640
+ await postFrame(connID, KIND_DATA, group[0], undefined, signal);
641
+ }
642
+ else {
643
+ await postBatchGroup(connID, group, signal);
644
+ }
645
+ start = end;
646
+ }
647
+ }
648
+ async function postBatchGroup(connID, frames, signal) {
649
+ var _a;
650
+ if (frames.length === 0 || frames.length > MAX_BATCH_FRAMES) {
651
+ throw new Error("invalid stream batch size");
652
+ }
653
+ let body = buildBatch(frames);
654
+ let sent = 0;
655
+ let wait = 1;
656
+ for (;;) {
657
+ if (signal === null || signal === void 0 ? void 0 : signal.aborted)
658
+ throw new StreamRequestCancelled();
659
+ const resp = await fetch(streamURL("send"), {
660
+ method: "POST",
661
+ headers: {
662
+ [HDR_SESSION]: G.session,
663
+ [HDR_GENERATION]: String(G.generation),
664
+ [HDR_CONN]: String(connID),
665
+ [HDR_KIND]: String(KIND_DATA),
666
+ [HDR_BATCH]: String(frames.length - sent),
667
+ "Content-Type": "application/octet-stream",
668
+ },
669
+ body: body,
670
+ signal,
671
+ });
672
+ if (resp.ok)
673
+ return;
674
+ if (resp.status !== 429) {
675
+ throw new Error(await resp.text());
676
+ }
677
+ // The receiver took a prefix of the batch. Resend only what is left,
678
+ // which keeps ordering without re-delivering anything.
679
+ const accepted = Number((_a = resp.headers.get(HDR_BATCH)) !== null && _a !== void 0 ? _a : 0);
680
+ const remaining = frames.length - sent;
681
+ if (!Number.isInteger(accepted) || accepted < 0 || accepted >= remaining) {
682
+ // Zero is valid (no progress), but an out-of-range acknowledgement
683
+ // is a protocol error. Keep zero on the retry path below.
684
+ if (accepted !== 0)
685
+ throw new Error("invalid stream batch acknowledgement");
686
+ }
687
+ sent += accepted;
688
+ body = buildBatch(frames.slice(sent));
689
+ await sleep(wait, signal);
690
+ wait = Math.min(wait * 2, 50);
691
+ }
692
+ }
693
+ function buildBatch(frames) {
694
+ let size = 4;
695
+ for (const f of frames)
696
+ size += 4 + f.byteLength;
697
+ const out = new Uint8Array(size);
698
+ const view = new DataView(out.buffer);
699
+ view.setUint32(0, frames.length);
700
+ let off = 4;
701
+ for (const f of frames) {
702
+ view.setUint32(off, f.byteLength);
703
+ off += 4;
704
+ out.set(f, off);
705
+ off += f.byteLength;
706
+ }
707
+ return out;
708
+ }
709
+ function startPolling() {
710
+ if (G.polling || !hasDOM)
711
+ return;
712
+ G.polling = true;
713
+ void pollLoop();
714
+ }
715
+ /**
716
+ * One poll for the whole page, shared by every connection.
717
+ *
718
+ * There is no polling interval and nothing adaptive here on purpose. The server
719
+ * holds the request until a frame exists, so delivery latency is already ~0 and
720
+ * a client-side interval could only add to it. Frames that arrive while a
721
+ * response is in flight simply accumulate and ride the next one, which makes
722
+ * the round trip itself the batching window — it widens as load rises without
723
+ * anything having to measure it.
724
+ */
725
+ async function pollLoop() {
726
+ let backoff = 0;
727
+ const abort = new AbortController();
728
+ G.pollAbort = abort;
729
+ while (G.connections.size > 0) {
730
+ try {
731
+ const resp = await fetch(streamURL("poll"), {
732
+ method: "GET",
733
+ headers: {
734
+ [HDR_SESSION]: G.session,
735
+ [HDR_GENERATION]: String(G.generation),
736
+ },
737
+ cache: "no-store",
738
+ signal: abort.signal,
739
+ });
740
+ if (resp.status === 410) {
741
+ // The session is gone: app shutting down, window closed, or we
742
+ // were reaped as unreachable. Nothing to retry.
743
+ closeAll(1001, "session closed");
744
+ break;
745
+ }
746
+ if (!resp.ok && resp.status !== 204) {
747
+ if (!isRetryablePollStatus(resp.status)) {
748
+ failAll("stream poll failed: " + resp.status);
749
+ break;
750
+ }
751
+ throw new Error("retryable stream poll failure: " + resp.status);
752
+ }
753
+ backoff = 0;
754
+ if (resp.status !== 204) {
755
+ let buf;
756
+ try {
757
+ buf = await resp.arrayBuffer();
758
+ }
759
+ catch (_a) {
760
+ // The Go queue was consumed when the successful response
761
+ // was produced. Retrying cannot recover a body that was
762
+ // interrupted after that point, so surface the loss and
763
+ // stop instead of silently continuing with missing frames.
764
+ failAll("stream poll response body failed");
765
+ break;
766
+ }
767
+ const frames = decodeFrames(buf);
768
+ if (frames === null) {
769
+ // A framing mismatch means a stale cached runtime against a
770
+ // newer Go side. Retrying cannot fix that, and letting it
771
+ // fall into the backoff below would spin silently forever,
772
+ // so fail every connection with a reason instead.
773
+ closeAll(1002, "unrecognised stream framing");
774
+ break;
775
+ }
776
+ deliver(frames);
777
+ }
778
+ // Re-poll immediately, on data and on an expired hold alike.
779
+ }
780
+ catch (err) {
781
+ if (abort.signal.aborted || G.connections.size === 0)
782
+ break;
783
+ backoff = backoff ? Math.min(backoff * 2, BACKOFF_MAX) : BACKOFF_MIN;
784
+ try {
785
+ await sleep(backoff, abort.signal);
786
+ }
787
+ catch (_b) {
788
+ break;
789
+ }
790
+ }
791
+ }
792
+ if (G.pollAbort === abort)
793
+ G.pollAbort = undefined;
794
+ G.polling = false;
795
+ // A connection opened while we were winding down: pick the loop back up.
796
+ if (G.connections.size > 0) {
797
+ startPolling();
798
+ }
799
+ }
800
+ function isRetryablePollStatus(status) {
801
+ return status === 408 || status === 425 || status === 429 || (status >= 500 && status <= 599);
802
+ }
803
+ // Validate the complete response before dispatching any part of it. A short
804
+ // platform response must not deliver a valid prefix and then throw RangeError:
805
+ // the Go queue has already been drained, so retrying cannot recover those
806
+ // frames. Treat the whole response as a protocol error instead.
807
+ function decodeFrames(buf) {
808
+ if (buf.byteLength < 9)
809
+ return null;
810
+ const dv = new DataView(buf);
811
+ if (dv.getUint8(0) !== 0x57 || dv.getUint8(1) !== 0x53 ||
812
+ dv.getUint8(2) !== 0x31 || dv.getUint8(3) !== 0x00 ||
813
+ (dv.getUint8(4) & ~1) !== 0) {
814
+ return null;
815
+ }
816
+ const count = dv.getUint32(5);
817
+ const frames = [];
818
+ let off = 9;
819
+ for (let i = 0; i < count; i++) {
820
+ if (off + 9 > buf.byteLength)
821
+ return null;
822
+ const connID = dv.getUint32(off);
823
+ off += 4;
824
+ const kind = dv.getUint8(off);
825
+ off += 1;
826
+ const len = dv.getUint32(off);
827
+ off += 4;
828
+ if (kind > KIND_ERROR || len > buf.byteLength - off)
829
+ return null;
830
+ const payload = buf.slice(off, off + len);
831
+ off += len;
832
+ frames.push({ connID, kind, payload });
833
+ }
834
+ return off === buf.byteLength ? frames : null;
835
+ }
836
+ function deliver(frames) {
837
+ for (const frame of frames) {
838
+ const connID = frame.connID;
839
+ const kind = frame.kind;
840
+ const payload = frame.payload;
841
+ const conn = G.connections.get(connID);
842
+ if (!conn)
843
+ continue;
844
+ switch (kind) {
845
+ case KIND_DATA:
846
+ conn._message(payload);
847
+ break;
848
+ case KIND_OPEN:
849
+ conn._opened();
850
+ break;
851
+ case KIND_CLOSE:
852
+ conn._closed(1000, "", true);
853
+ break;
854
+ case KIND_ERROR:
855
+ conn._fail(new Error(new TextDecoder().decode(payload)));
856
+ break;
857
+ }
858
+ }
859
+ }
860
+ function closeAll(code, reason) {
861
+ for (const conn of [...G.connections.values()]) {
862
+ conn._closed(code, reason, false);
863
+ }
864
+ }
865
+ function failAll(reason) {
866
+ for (const conn of [...G.connections.values()]) {
867
+ conn._fail(new Error(reason));
868
+ }
869
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wailsio/runtime",
3
3
  "type": "module",
4
- "version": "3.0.0-beta.1",
4
+ "version": "3.0.0-beta.11",
5
5
  "description": "Wails Runtime",
6
6
  "types": "types/index.d.ts",
7
7
  "exports": {
package/types/index.d.ts CHANGED
@@ -16,7 +16,8 @@ import * as Android from "./android.js";
16
16
  import * as Updater from "./updater.js";
17
17
  import Window from "./window.js";
18
18
  import * as WML from "./wml.js";
19
- export { Application, Browser, Call, Clipboard, Dialogs, Events, Flags, Screens, System, IOS, Android, Updater, Window, WML };
19
+ import { Stream, JSONStream, WailsSocket, type JSONSocket } from "./stream.js";
20
+ export { Application, Browser, Call, Clipboard, Dialogs, Events, Flags, Screens, System, IOS, Android, Updater, Window, WML, Stream, JSONStream, WailsSocket, type JSONSocket };
20
21
  /**
21
22
  * An internal utility consumed by the binding generator.
22
23
  *
@@ -0,0 +1,85 @@
1
+ /**
2
+ * A stream connection. Implements the useful subset of the `WebSocket`
3
+ * interface, so the same application code works against a real WebSocket in
4
+ * server builds.
5
+ *
6
+ * One deliberate divergence from the standard: `binaryType` defaults to
7
+ * `"arraybuffer"` rather than `"blob"`, because stream frames are always
8
+ * binary and a Blob would force an extra async hop to read every message.
9
+ * Setting it to `"blob"` behaves as the standard describes.
10
+ */
11
+ export declare class WailsSocket extends EventTarget {
12
+ static readonly CONNECTING = 0;
13
+ static readonly OPEN = 1;
14
+ static readonly CLOSING = 2;
15
+ static readonly CLOSED = 3;
16
+ readonly CONNECTING = 0;
17
+ readonly OPEN = 1;
18
+ readonly CLOSING = 2;
19
+ readonly CLOSED = 3;
20
+ /** The stream name this connection was opened against. */
21
+ readonly name: string;
22
+ readonly url: string;
23
+ /** Always empty: subprotocols and extensions are not negotiated. */
24
+ readonly protocol = "";
25
+ readonly extensions = "";
26
+ binaryType: "arraybuffer" | "blob";
27
+ readyState: number;
28
+ onopen: ((ev: Event) => void) | null;
29
+ onmessage: ((ev: MessageEvent) => void) | null;
30
+ onclose: ((ev: CloseEvent) => void) | null;
31
+ onerror: ((ev: Event) => void) | null;
32
+ private _buffered;
33
+ private _chain;
34
+ private _pending;
35
+ private _flushing;
36
+ private _openAbort;
37
+ private _sendAbort;
38
+ /** Bytes queued by send() that have not yet reached Go. */
39
+ get bufferedAmount(): number;
40
+ constructor(name: string);
41
+ /**
42
+ * Send a frame to Go. Accepts anything a WebSocket does; strings are
43
+ * encoded as UTF-8, since Go receives every frame as a byte slice.
44
+ */
45
+ send(data: string | ArrayBufferLike | ArrayBufferView | Blob): void;
46
+ /** Close the connection. Go's handler sees Receive fail and returns. */
47
+ close(code?: number, reason?: string): void;
48
+ }
49
+ /**
50
+ * Connect to a stream declared in Go with `app.HandleStream(name, handler)`.
51
+ *
52
+ * Returns immediately with `readyState === CONNECTING`; listen for `open`, or
53
+ * just call {@link WailsSocket.send} once open. Creating a connection at module
54
+ * scope is supported and is the intended shape for generated bindings.
55
+ */
56
+ export declare function Stream(name: string): WailsSocket | WebSocket;
57
+ /**
58
+ * The object-shaped view of a stream returned by {@link JSONStream}: `send`
59
+ * takes a value to stringify, and `ev.data` on a message is the parsed result.
60
+ */
61
+ export interface JSONSocket extends Omit<WailsSocket, "send" | "onmessage" | "addEventListener" | "removeEventListener"> {
62
+ send(value: unknown): void;
63
+ onmessage: ((ev: MessageEvent<any>) => void) | null;
64
+ addEventListener(type: "message", listener: ((this: JSONSocket, ev: MessageEvent<any>) => any) | EventListenerObject | null, options?: boolean | AddEventListenerOptions): void;
65
+ addEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions): void;
66
+ removeEventListener(type: "message", listener: ((this: JSONSocket, ev: MessageEvent<any>) => any) | EventListenerObject | null, options?: boolean | EventListenerOptions): void;
67
+ removeEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | EventListenerOptions): void;
68
+ }
69
+ /**
70
+ * A {@link Stream} that speaks objects instead of bytes.
71
+ *
72
+ * `send(value)` marshals with `JSON.stringify`, and `ev.data` in an `onmessage`
73
+ * handler is the parsed object rather than an `ArrayBuffer`. Pairs with
74
+ * `StreamConn.SendJSON` / `ReceiveJSON` on the Go side.
75
+ *
76
+ * ```js
77
+ * const s = JSONStream("telemetry");
78
+ * s.onmessage = (ev) => console.log(ev.data.temperature);
79
+ * s.onopen = () => s.send({ subscribe: "sensors" });
80
+ * ```
81
+ *
82
+ * A frame that is not valid JSON raises an `error` event and is dropped, rather
83
+ * than throwing from the poll loop and taking the connection down with it.
84
+ */
85
+ export declare function JSONStream(name: string): JSONSocket;