From e8a10bd39ecec100b56ea5dd2c33f81a1f197b3d Mon Sep 17 00:00:00 2001 From: Toshit Chawda Date: Mon, 23 Mar 2026 16:05:12 -0700 Subject: [PATCH 1/9] revamp networking api with epoxy --- src/puter-js/index.d.ts | 3 +- src/puter-js/src/index.js | 35 +- .../src/modules/networking/PSocket.js | 363 +++++++++++++----- .../src/modules/networking/PSocket.test.js | 161 -------- src/puter-js/src/modules/networking/PTLS.js | 137 ------- .../src/modules/networking/PWispHandler.js | 113 ------ .../modules/networking/PWispHandler.test.js | 151 -------- src/puter-js/src/modules/networking/epoxy.js | 101 +++++ src/puter-js/src/modules/networking/index.js | 95 +++++ .../src/modules/networking/parsers.js | 159 -------- .../src/modules/networking/requests.js | 341 ++++------------ src/puter-js/src/modules/networking/types.js | 2 +- 12 files changed, 531 insertions(+), 1130 deletions(-) delete mode 100644 src/puter-js/src/modules/networking/PSocket.test.js delete mode 100644 src/puter-js/src/modules/networking/PTLS.js delete mode 100644 src/puter-js/src/modules/networking/PWispHandler.js delete mode 100644 src/puter-js/src/modules/networking/PWispHandler.test.js create mode 100644 src/puter-js/src/modules/networking/epoxy.js create mode 100644 src/puter-js/src/modules/networking/index.js delete mode 100644 src/puter-js/src/modules/networking/parsers.js diff --git a/src/puter-js/index.d.ts b/src/puter-js/index.d.ts index 39be88a7a2..50074acf51 100644 --- a/src/puter-js/index.d.ts +++ b/src/puter-js/index.d.ts @@ -150,8 +150,7 @@ export type { // -- puter.net -- export type { Networking, SocketEvent } from './types/modules/networking/types.js'; -export type { PSocket } from './types/modules/networking/PSocket.js'; -export type { PTLSSocket } from './types/modules/networking/PTLS.js'; +export type { PSocket, PTLSSocket } from './types/modules/networking/PSocket.js'; // -- puter.os -- diff --git a/src/puter-js/src/index.js b/src/puter-js/src/index.js index 423ebade93..9331fe012b 100644 --- a/src/puter-js/src/index.js +++ b/src/puter-js/src/index.js @@ -16,9 +16,7 @@ import { PuterJSFileSystemModule } from './modules/FileSystem/index.js'; import FSItem from './modules/FSItem.js'; import { Hosting } from './modules/hosting/index.js'; import { KV } from './modules/kv/index.js'; -import { PSocket } from './modules/networking/PSocket.js'; -import { PTLSSocket } from './modules/networking/PTLS.js'; -import { pFetch } from './modules/networking/requests.js'; +import { netAPI } from './modules/networking/index.js'; import { OS } from './modules/os/index.js'; import { Perms } from './modules/perms/index.js'; import PuterDialog from './modules/PuterDialog.js'; @@ -732,36 +730,7 @@ export class Puter { })(); /** @type {import('./modules/networking/types.js').Networking} */ - this.net = { - /** - * Mints a relay URL (server + single-use token) for speaking - * the Wisp v1 protocol directly, which is what the sockets - * below do for you. - * - * @returns {Promise} - */ - generateWispV1URL: async () => { - const { token: wispToken, server: wispServer } = await ( - await fetchUrl( - `${this.APIOrigin}/wisp/relay-token/create`, - { - method: 'POST', - includePuterAuth: true, - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({}), - }, - ) - ).json(); - return `${wispServer}/${wispToken}/`; - }, - Socket: PSocket, - tls: { - TLSSocket: PTLSSocket, - }, - fetch: pFetch, - }; + this.net = netAPI; // Initialize network connectivity monitoring and cache purging this.initNetworkMonitoring(); diff --git a/src/puter-js/src/modules/networking/PSocket.js b/src/puter-js/src/modules/networking/PSocket.js index d33eb8d6b1..da2fb1d485 100644 --- a/src/puter-js/src/modules/networking/PSocket.js +++ b/src/puter-js/src/modules/networking/PSocket.js @@ -1,14 +1,31 @@ import EventListener from '../../lib/EventListener.js'; -import { fetchUrl } from '../../lib/networkUtils.js'; -import { errors } from './parsers.js'; -import { PWispHandler } from './PWispHandler.js'; -const texten = new TextEncoder(); -const requireAuth = true; +import { clearEpoxyClientCache, getEpoxyClient } from './index.js'; -export let wispInfo = { - server: 'wss://puter.cafe/', // Unused currently - handler: undefined, -}; +const textEncoder = new TextEncoder(); + +function normalizeWriteData (data) { + if ( typeof data === 'string' ) { + return textEncoder.encode(data); + } + + if ( data instanceof ArrayBuffer ) { + return new Uint8Array(data); + } + + if ( ArrayBuffer.isView(data) ) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + } + + throw new Error('Invalid data type (not TypedArray, ArrayBuffer or String).'); +} + +function normalizeErrorReason (reason) { + if ( reason instanceof Error ) { + return reason.message; + } + + return String(reason); +} /** @typedef {import('./types.js').SocketEvent} SocketEvent */ @@ -35,77 +52,47 @@ export let wispInfo = { * @extends {EventListener} */ export class PSocket extends EventListener { - _events = new Map(); - _streamID; - /** - * @param {string} host hostname or IP address of the server to connect to - * @param {number} port port to connect to on that server - */ - constructor (host, port) { + #host; + #port; + #useTls; + + #reader; + #writer; + + #open = false; + #closing = false; + #closed = false; + #pendingWrites = []; + + constructor (host, port, options = {}) { super(['data', 'drain', 'open', 'error', 'close', 'tlsdata', 'tlsopen', 'tlsclose']); - (async () => { - if ( !puter.authToken && puter.env === 'web' && requireAuth ) { - try { - await puter.ui.authenticateWithPuter(); + this.#host = host; + this.#port = Number(port); + this.#useTls = Boolean(options.tls); - } catch (e) { - // if authentication fails, throw an error - throw (e); - } - } - if ( ! wispInfo.handler ) { - // first launch -- lets init the socket - const { token: wispToken, server: wispServer } = (await (await fetchUrl(`${puter.APIOrigin }/wisp/relay-token/create`, { - method: 'POST', - includePuterAuth: !! puter.authToken, - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({}), - })).json()); - - wispInfo.handler = new PWispHandler(wispServer, wispToken); - // Wait for websocket to fully open - try { - await new Promise((res, rej) => { - wispInfo.handler.onReady = res; - wispInfo.handler.onError = rej; - }); - } catch (e) { - // Drop the dead handler so the next socket redials instead - // of registering streams on a relay that never opened. - wispInfo.handler = undefined; - throw e; - } - } + void this.#connect(); + } - const callbacks = { - dataCallBack: (data) => { - this.emit('data', data); - }, - closeCallBack: (reason) => { - if ( reason !== 0x02 ) { - this.emit('error', new Error(errors[reason])); - this.emit('close', true); - return; - } - this.emit('close', false); - }, - }; - - this._streamID = wispInfo.handler.register(host, port, callbacks); - setTimeout(() => { - this.emit('open', undefined); - }, 0); - - })().catch((e) => { - // Nothing awaits this body, so a failure to connect has to reach - // the caller as an 'error' event rather than an unhandled rejection. - this.emit('error', e instanceof Error ? e : new Error(String(e))); - this.emit('close', true); - }); + /** + * Registers a handler for a socket event. On a TLS socket the plain + * `'open'`, `'data'` and `'close'` names are accepted as aliases of the + * `tls`-prefixed events, so the same handler code works against either + * socket type. + * + * @template {SocketEvent} K + * @param {K} event + * @param {(data: PSocketEventMap[K]) => void} callback + * @returns {this | undefined} + */ + on (event, callback) { + if ( this.#useTls && (event === 'open' || event === 'data' || event === 'close') ) { + return super.on(`tls${event}`, callback); + } + + return super.on(event, callback); } + /** * Registers a handler for a socket event, the same as `on`. * @@ -114,38 +101,208 @@ export class PSocket extends EventListener { * @returns {void} */ addListener (...args) { - this.on(...args); + return this.on(...args); } - /** - * Writes data to the socket, invoking `callback` once it has been handed - * to the relay. Throws if `data` is not a string, `ArrayBuffer`, or typed - * array. - * - * @param {ArrayBuffer | ArrayBufferView | string} data - * @param {() => void} [callback] - * @returns {void} - */ write (data, callback) { - if ( data.buffer ) { // TypedArray - wispInfo.handler.write(this._streamID, data); - if ( callback ) callback(); - } else if ( data.resize ) { // ArrayBuffer - wispInfo.handler.write(this._streamID, new Uint8Array(data)); - if ( callback ) callback(); - } else if ( typeof (data) === 'string' ) { - wispInfo.handler.write(this._streamID, texten.encode(data)); - if ( callback ) callback(); - } else { - throw new Error('Invalid data type (not TypedArray, ArrayBuffer or String!!)'); + const payload = normalizeWriteData(data); + + if ( this.#closed ) { + throw new Error('Socket is already closed.'); + } + + if ( ! this.#writer ) { + this.#pendingWrites.push({ payload, callback }); + return; } + + void this.#writePayload(payload, callback); } - /** - * Closes the TCP connection. - * - * @returns {void} - */ + close () { - wispInfo.handler.close(this._streamID); + if ( this.#closing || this.#closed ) { + return; + } + + this.#closing = true; + void this.#closeStreams(false); + } + + async #connect () { + try { + await this.#connectWithClient(false); + } catch { + try { + await this.#connectWithClient(true); + } catch ( retryError ) { + clearEpoxyClientCache(); + this.#emitErrorAndClose(retryError); + } + } + } + + async #connectWithClient (refresh) { + if ( this.#closing || this.#closed ) { + return; + } + + const client = await getEpoxyClient({ refresh }); + const stream = await this.#openStream(client); + + if ( this.#closing || this.#closed ) { + try { + await stream.read.cancel(); + } catch { + // ignored + } + try { + await stream.write.abort(); + } catch { + // ignored + } + return; + } + + this.#reader = stream.read.getReader(); + this.#writer = stream.write.getWriter(); + this.#open = true; + + this.emit(this.#eventName('open')); + await this.#flushPendingWrites(); + void this.#readLoop(); + } + + async #openStream (client) { + if ( this.#useTls ) { + return await client.connectTls(this.#host, this.#port); + } + + return await client.connect(this.#host, this.#port); + } + + async #flushPendingWrites () { + while ( this.#pendingWrites.length && !this.#closed && !this.#closing ) { + const { payload, callback } = this.#pendingWrites.shift(); + await this.#writePayload(payload, callback); + } + } + + async #writePayload (payload, callback) { + if ( !this.#writer || this.#closed || this.#closing ) { + return; + } + + try { + await this.#writer.write(payload); + if ( callback ) { + callback(); + } + } catch ( error ) { + clearEpoxyClientCache(); + this.#emitErrorAndClose(error); + } + } + + async #readLoop () { + if ( ! this.#reader ) { + return; + } + + try { + while ( !this.#closing && !this.#closed ) { + const { done, value } = await this.#reader.read(); + if ( done ) { + break; + } + + if ( value ) { + this.emit(this.#eventName('data'), value); + } + } + + this.#emitClose(false); + } catch ( error ) { + if ( this.#closing ) { + this.#emitClose(false); + } else { + clearEpoxyClientCache(); + this.#emitErrorAndClose(error); + } + } finally { + try { + this.#reader.releaseLock(); + } catch { + // ignored + } + } + } + + async #closeStreams (hadError) { + this.#pendingWrites = []; + + if ( ! this.#open ) { + this.#emitClose(hadError); + return; + } + + try { + if ( this.#reader ) { + await this.#reader.cancel(); + } + } catch { + // ignored + } + + try { + if ( this.#writer ) { + await this.#writer.close(); + } + } catch { + // ignored + } + + try { + if ( this.#writer ) { + this.#writer.releaseLock(); + } + } catch { + // ignored + } + + this.#open = false; + this.#emitClose(hadError); + } + + #emitErrorAndClose (reason) { + if ( this.#closed ) { + return; + } + + this.emit('error', normalizeErrorReason(reason)); + this.#closing = true; + void this.#closeStreams(true); + } + + #emitClose (hadError) { + if ( this.#closed ) { + return; + } + + this.#closed = true; + this.emit(this.#eventName('close'), Boolean(hadError)); + } + + #eventName (event) { + if ( this.#useTls && (event === 'open' || event === 'data' || event === 'close') ) { + return `tls${event}`; + } + + return event; + } +} + +export class PTLSSocket extends PSocket { + constructor (host, port) { + super(host, port, { tls: true }); } -} \ No newline at end of file +} diff --git a/src/puter-js/src/modules/networking/PSocket.test.js b/src/puter-js/src/modules/networking/PSocket.test.js deleted file mode 100644 index d133ad4430..0000000000 --- a/src/puter-js/src/modules/networking/PSocket.test.js +++ /dev/null @@ -1,161 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -// The relay handshake is the thing under test, so both the token fetch and the -// wisp handler are stubbed: no network, no websocket. -const mockFetchUrl = vi.fn(); -vi.mock('../../lib/networkUtils.js', () => ({ - fetchUrl: (...args) => mockFetchUrl(...args), -})); - -const handlerInstances = []; -class FakeWispHandler { - onReady = undefined; - onError = undefined; - - constructor (url, auth) { - this.url = url; - this.auth = auth; - this.write = vi.fn(); - this.close = vi.fn(); - this.register = vi.fn(() => 7); - handlerInstances.push(this); - } -} -vi.mock('./PWispHandler.js', () => ({ - PWispHandler: class { - constructor (...args) { - return new FakeWispHandler(...args); - } - }, -})); - -const { PSocket, wispInfo } = await import('./PSocket.js'); - -const tokenResponse = () => Promise.resolve({ - json: () => Promise.resolve({ token: 'wisp-token', server: 'wss://relay.test/' }), -}); - -// Lets a test act after the constructor's async body has reached its await. -const flush = () => new Promise(r => setTimeout(r, 0)); - -const origPuter = globalThis.puter; - -beforeEach(() => { - handlerInstances.length = 0; - mockFetchUrl.mockReset().mockImplementation(tokenResponse); - wispInfo.handler = undefined; - globalThis.puter = { authToken: 'tok', APIOrigin: 'https://api.test', env: 'nodejs' }; -}); - -afterEach(() => { - wispInfo.handler = undefined; - globalThis.puter = origPuter; -}); - -describe('PSocket relay handshake', () => { - it('opens once the handler reports ready', async () => { - const socket = new PSocket('example.com', 80); - const onOpen = vi.fn(); - socket.on('open', onOpen); - - await flush(); - handlerInstances[0].onReady(); - // `open` is emitted from a setTimeout of its own, one tick after the - // constructor body resumes. - await flush(); - await flush(); - - expect(onOpen).toHaveBeenCalledTimes(1); - expect(handlerInstances[0].register).toHaveBeenCalledWith( - 'example.com', 80, expect.any(Object), - ); - }); - - // The reject parameter was typo'd `req` and never called, so a failed - // handshake hung instead of surfacing. - it('emits error instead of hanging when the handshake fails', async () => { - const socket = new PSocket('example.com', 80); - const onError = vi.fn(); - const onClose = vi.fn(); - socket.on('error', onError); - socket.on('close', onClose); - - await flush(); - handlerInstances[0].onError(new Error('relay unreachable')); - await flush(); - - expect(onError).toHaveBeenCalledTimes(1); - expect(onError.mock.calls[0][0]).toBeInstanceOf(Error); - expect(onError.mock.calls[0][0].message).toBe('relay unreachable'); - expect(onClose).toHaveBeenCalledWith(true); - // The stream is never registered on a relay that failed to open. - expect(handlerInstances[0].register).not.toHaveBeenCalled(); - }); - - it('drops the dead handler so a later socket redials', async () => { - const first = new PSocket('example.com', 80); - first.on('error', () => {}); - await flush(); - handlerInstances[0].onError(new Error('relay unreachable')); - await flush(); - - expect(wispInfo.handler).toBeUndefined(); - - const second = new PSocket('example.com', 80); - second.on('error', () => {}); - await flush(); - - expect(handlerInstances).toHaveLength(2); - }); -}); - -describe('PSocket write', () => { - const connected = async () => { - const socket = new PSocket('example.com', 80); - socket.on('error', () => {}); - await flush(); - handlerInstances[0].onReady(); - await flush(); - return socket; - }; - - it('writes a typed array through the relay handler', async () => { - const socket = await connected(); - const payload = new Uint8Array([1, 2, 3]); - - socket.write(payload); - - expect(handlerInstances[0].write).toHaveBeenCalledWith(7, payload); - }); - - // This branch called `data.write(...)` — a method ArrayBuffers do not have - // — so every ArrayBuffer write threw instead of reaching the relay. - it('writes an ArrayBuffer through the relay handler', async () => { - const socket = await connected(); - const buffer = new Uint8Array([4, 5, 6]).buffer; - const callback = vi.fn(); - - expect(() => socket.write(buffer, callback)).not.toThrow(); - - expect(handlerInstances[0].write).toHaveBeenCalledTimes(1); - const [streamID, sent] = handlerInstances[0].write.mock.calls[0]; - expect(streamID).toBe(7); - expect(Array.from(sent)).toEqual([4, 5, 6]); - expect(callback).toHaveBeenCalledTimes(1); - }); - - it('encodes a string before writing', async () => { - const socket = await connected(); - - socket.write('hi'); - - const [, sent] = handlerInstances[0].write.mock.calls[0]; - expect(Array.from(sent)).toEqual([104, 105]); - }); - - it('throws on an unsupported data type', async () => { - const socket = await connected(); - - expect(() => socket.write(42)).toThrow(/Invalid data type/); - }); -}); diff --git a/src/puter-js/src/modules/networking/PTLS.js b/src/puter-js/src/modules/networking/PTLS.js deleted file mode 100644 index 8c9b0cdb07..0000000000 --- a/src/puter-js/src/modules/networking/PTLS.js +++ /dev/null @@ -1,137 +0,0 @@ -/** - * This file uses https://github.com/MercuryWorkshop/rustls-wasm authored by GitHub:@r58Playz under the MIT License - */ - -import { PSocket } from './PSocket.js'; - -let rustls = undefined; - -/** - * A TLS-protected TCP socket in the browser. Same interface as `PSocket`, but - * the connection is encrypted and its events are `'tls'`-prefixed. Construct - * it with `puter.net.tls.TLSSocket(hostname, port)`. - */ -export class PTLSSocket extends PSocket { - /** @param {...unknown} args a `(host, port)` pair, as `PSocket` takes */ - constructor (...args) { - super(...args); - super.on('open', (async () => { - if ( ! rustls ) { - // Safari exists unfortunately without good ReadableStream support. Until that is fixed we need this. - if ( ! globalThis.ReadableByteStreamController ) { - await import( /* webpackIgnore: true */ 'https://unpkg.com/web-streams-polyfill@3.0.2/dist/polyfill.js'); - } - rustls = (await import( /* webpackIgnore: true */ 'https://puter-net.b-cdn.net/rustls.js')); - await rustls.default('https://puter-net.b-cdn.net/rustls.wasm'); - } - - let cancelled = false; - const readable = new ReadableStream({ - /** - * - * @param {ReadableStreamDefaultController} controller - */ - start: (controller) => { - super.on('data', (data) => { - controller.enqueue(data.buffer); - }); - super.on('close', () => { - if ( ! cancelled ) - { - controller.close(); - } - }); - - }, - pull: (controller) => { - - }, - cancel: () => { - cancelled = true; - }, - - }); - - const writable = new WritableStream({ - write: (chunk) => { - super.write(chunk); - }, - abort: () => { - super.close(); - }, - close: () => { - super.close(); - }, - }); - - let read, write; - try { - const TLSConnnection = await rustls.connect_tls(readable, writable, args[0]); - read = TLSConnnection.read; - write = TLSConnnection.write; - } catch (e) { - this.emit('error', new Error(`TLS Handshake failed: ${ e}`)); - return; - } - - this.writer = write.getWriter(); - // writer.write("GET / HTTP/1.1\r\nHost: google.com\r\n\r\n"); - let reader = read.getReader(); - let done = false; - this.emit('tlsopen', undefined); - try { - while ( !done ) { - const { done: readerDone, value } = await reader.read(); - done = readerDone; - if ( ! done ) { - this.emit('tlsdata', value); - } - } - this.emit('tlsclose', false); - } catch (e) { - this.emit('error', e); - this.emit('tlsclose', true); - } - - })); - } - /** - * Registers a handler for a socket event. `'data'`, `'open'` and - * `'close'` are accepted as aliases of the `tls`-prefixed events, so the - * same handler code works against either socket type. - * - * @template {import('./types.js').SocketEvent} K - * @param {K} event - * @param {(data: import('./PSocket.js').PSocketEventMap[K]) => void} callback - * @returns {this | undefined} - */ - on (event, callback) { - if ( event === 'data' || event === 'open' || event === 'close' ) { - return super.on(`tls${ event}`, callback); - } else { - return super.on(event, callback); - } - } - - /** - * Writes data through the TLS session, invoking `callback` once it has - * been flushed. Throws if `data` is not a string, `ArrayBuffer`, or typed - * array. - * - * @param {ArrayBuffer | ArrayBufferView | string} data - * @param {() => void} [callback] - * @returns {void} - */ - write (data, callback) { - if ( data.buffer ) { // TypedArray - this.writer.write(data.slice(0).buffer).then(callback); - } else if ( data.resize ) { // ArrayBuffer - this.writer.write(data).then(callback); - } else if ( typeof (data) === 'string' ) { - this.writer.write(data).then(callback); - } else { - throw new Error('Invalid data type (not TypedArray, ArrayBuffer or String!!)'); - } - } - -} \ No newline at end of file diff --git a/src/puter-js/src/modules/networking/PWispHandler.js b/src/puter-js/src/modules/networking/PWispHandler.js deleted file mode 100644 index 072ca73ba2..0000000000 --- a/src/puter-js/src/modules/networking/PWispHandler.js +++ /dev/null @@ -1,113 +0,0 @@ -import { CLOSE, CONNECT, DATA, CONTINUE, INFO, TCP, UDP, createWispPacket, parseIncomingPacket, textde } from './parsers.js'; - -export class PWispHandler { - _ws; - _nextStreamID = 1; - _bufferMax; - // Set once the relay answers the handshake with CONTINUE on stream 0. - // Decides whether a close is a dropped connection (reconnect) or a - // handshake that never completed (report failure). - _ready = false; - onReady = undefined; - onError = undefined; - streamMap = new Map(); - constructor (wispURL, puterAuth) { - const setup = () => { - this._ws = new WebSocket(wispURL); - this._ws.binaryType = 'arraybuffer'; - this._ws.onerror = () => { - this._fail(new Error(`Wisp relay connection failed: ${wispURL}`)); - }; - this._ws.onclose = () => { - if ( this._ready ) { - // Pass the function itself: `setTimeout(setup(), 1000)` - // would reconnect immediately and schedule nothing. - setTimeout(setup, 1000); - return; - } - this._fail(new Error('Wisp relay closed before the handshake completed')); - }; - this._ws.onmessage = (event) => { - const parsed = parseIncomingPacket(new Uint8Array(event.data)); - switch ( parsed.packetType ) { - case DATA: - this.streamMap.get(parsed.streamID).dataCallBack(parsed.payload.slice(0)); // return a copy for the user to do as they please - break; - case CONTINUE: - if ( parsed.streamID === 0 ) { - this._bufferMax = parsed.remainingBuffer; - this._ready = true; - if ( this.onReady ) { - this.onReady(); - } - return; - } - this.streamMap.get(parsed.streamID).buffer = parsed.remainingBuffer; - this._continue(parsed.streamID); - break; - case CLOSE: - if ( parsed.streamID !== 0 ) - { - this.streamMap.get(parsed.streamID).closeCallBack(parsed.reason); - } - break; - case INFO: - puterAuth && this._ws.send(createWispPacket({ - packetType: INFO, - streamID: 0, - puterAuth, - })); - break; - } - }; - }; - setup(); - } - // Reports a connection-level failure once, so a caller waiting on the - // handshake is rejected rather than left hanging. - _fail (error) { - const onError = this.onError; - this.onError = undefined; - if ( onError ) onError(error); - } - _continue (streamID) { - const queue = this.streamMap.get(streamID).queue; - for ( let i = 0; i < queue.length; i++ ) { - this.write(streamID, queue.shift()); - } - } - register (host, port, callbacks) { - const streamID = this._nextStreamID++; - this.streamMap.set(streamID, { queue: [], streamID, buffer: this._bufferMax, dataCallBack: callbacks.dataCallBack, closeCallBack: callbacks.closeCallBack }); - this._ws.send(createWispPacket({ - packetType: CONNECT, - streamType: TCP, - streamID: streamID, - hostname: host, - port: port, - })); - return streamID; - } - - write (streamID, data) { - const streamData = this.streamMap.get(streamID); - if ( streamData.buffer > 0 ) { - streamData.buffer--; - - this._ws.send(createWispPacket({ - packetType: DATA, - streamID: streamID, - payload: data, - })); - } else { - streamData.queue.push(data); - } - } - close (streamID) { - this._ws.send(createWispPacket({ - packetType: CLOSE, - streamID: streamID, - reason: 0x02, - })); - } -} \ No newline at end of file diff --git a/src/puter-js/src/modules/networking/PWispHandler.test.js b/src/puter-js/src/modules/networking/PWispHandler.test.js deleted file mode 100644 index 2b38901dab..0000000000 --- a/src/puter-js/src/modules/networking/PWispHandler.test.js +++ /dev/null @@ -1,151 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { CONTINUE, DATA, createWispPacket, parseIncomingPacket } from './parsers.js'; -import { PWispHandler } from './PWispHandler.js'; - -// Fake WebSocket standing in for the relay connection. Records every instance -// so reconnects are observable, and every frame sent so writes are. -class FakeWebSocket { - static instances = []; - - constructor (url) { - this.url = url; - this.sent = []; - this.readyState = 0; - FakeWebSocket.instances.push(this); - } - - send (data) { - this.sent.push(data); - } - - close () {} -} - -const latest = () => FakeWebSocket.instances.at(-1); - -// Hand a wisp packet to the handler the way the browser would. -const deliver = (packet) => latest().onmessage({ data: packet.buffer }); - -const handshake = (remainingBuffer = 4) => - deliver(createWispPacket({ packetType: CONTINUE, streamID: 0, remainingBuffer })); - -const origWebSocket = globalThis.WebSocket; - -beforeEach(() => { - vi.useFakeTimers(); - FakeWebSocket.instances = []; - globalThis.WebSocket = FakeWebSocket; -}); - -afterEach(() => { - vi.useRealTimers(); - globalThis.WebSocket = origWebSocket; -}); - -describe('PWispHandler handshake', () => { - it('signals ready once the relay answers CONTINUE on stream 0', () => { - const handler = new PWispHandler('wss://relay.test/', 'token'); - const onReady = vi.fn(); - handler.onReady = onReady; - - handshake(7); - - expect(onReady).toHaveBeenCalledTimes(1); - expect(handler._bufferMax).toBe(7); - }); - - // Without an `onerror` handler a failed connection left every caller - // waiting on the handshake hanging forever. - it('reports a connection error to onError', () => { - const handler = new PWispHandler('wss://relay.test/', 'token'); - const onError = vi.fn(); - handler.onError = onError; - - expect(typeof latest().onerror).toBe('function'); - latest().onerror(new Event('error')); - - expect(onError).toHaveBeenCalledTimes(1); - expect(onError.mock.calls[0][0]).toBeInstanceOf(Error); - }); - - it('reports a close that happens before the handshake completes', () => { - const handler = new PWispHandler('wss://relay.test/', 'token'); - const onError = vi.fn(); - handler.onError = onError; - - latest().onclose(); - - expect(onError).toHaveBeenCalledTimes(1); - expect(onError.mock.calls[0][0].message).toMatch(/before the handshake/); - }); - - it('reports a connection failure only once', () => { - const handler = new PWispHandler('wss://relay.test/', 'token'); - const onError = vi.fn(); - handler.onError = onError; - - latest().onerror(new Event('error')); - latest().onclose(); - - expect(onError).toHaveBeenCalledTimes(1); - }); -}); - -describe('PWispHandler reconnect', () => { - // `setTimeout(setup(), 1000)` reconnected synchronously and scheduled - // `undefined`, so the 1s backoff never applied. - it('reconnects one second after an established connection drops', () => { - new PWispHandler('wss://relay.test/', 'token'); - handshake(); - expect(FakeWebSocket.instances).toHaveLength(1); - - latest().onclose(); - - // Nothing yet: the reconnect is scheduled, not immediate. - expect(FakeWebSocket.instances).toHaveLength(1); - - vi.advanceTimersByTime(1000); - - expect(FakeWebSocket.instances).toHaveLength(2); - expect(latest().url).toBe('wss://relay.test/'); - }); - - it('does not reconnect when the handshake never completed', () => { - new PWispHandler('wss://relay.test/', 'token'); - - latest().onclose(); - vi.advanceTimersByTime(5000); - - expect(FakeWebSocket.instances).toHaveLength(1); - }); -}); - -describe('PWispHandler backpressure', () => { - // `this._continue()` was called with no argument, so the drain path did - // `streamMap.get(undefined).queue` and threw. - it('drains the queue for the stream the CONTINUE names', () => { - const handler = new PWispHandler('wss://relay.test/', 'token'); - handshake(1); - - const streamID = handler.register('example.com', 80, { - dataCallBack: vi.fn(), closeCallBack: vi.fn(), - }); - const sentAfterRegister = latest().sent.length; - - // Exhaust the credit, so the next write is queued rather than sent. - handler.write(streamID, new Uint8Array([1])); - handler.write(streamID, new Uint8Array([2])); - expect(handler.streamMap.get(streamID).queue).toHaveLength(1); - expect(latest().sent).toHaveLength(sentAfterRegister + 1); - - expect(() => deliver(createWispPacket({ - packetType: CONTINUE, streamID, remainingBuffer: 4, - }))).not.toThrow(); - - expect(handler.streamMap.get(streamID).queue).toHaveLength(0); - const flushed = parseIncomingPacket(latest().sent.at(-1)); - expect(flushed.packetType).toBe(DATA); - expect(flushed.streamID).toBe(streamID); - expect(Array.from(flushed.payload)).toEqual([2]); - }); -}); diff --git a/src/puter-js/src/modules/networking/epoxy.js b/src/puter-js/src/modules/networking/epoxy.js new file mode 100644 index 0000000000..097a1f5c35 --- /dev/null +++ b/src/puter-js/src/modules/networking/epoxy.js @@ -0,0 +1,101 @@ +let EPOXY_BASE = 'https://epoxy.puter.com/0265590'; +let epoxyRuntimePromise; + +const textEncoder = new TextEncoder(); + +function getEpoxyBase () { + const overriddenBase = + globalThis.PUTER_EPOXY_BASE || globalThis.PUTER_EPOXY_BASE_ENV; + const base = overriddenBase || EPOXY_BASE; + return base.endsWith('/') ? base.slice(0, -1) : base; +} + +async function getEpoxyRuntime () { + if ( epoxyRuntimePromise ) { + return epoxyRuntimePromise; + } + + epoxyRuntimePromise = (async () => { + const base = getEpoxyBase(); + const runtime = await import(/* webpackIgnore: true */ `${base}/full.js`); + const wasmResponse = await fetch(`${base}/full.wasm`); + if ( ! wasmResponse.ok ) { + throw new Error( + `Failed to load epoxy wasm (HTTP ${wasmResponse.status} ${wasmResponse.statusText}).`, + ); + } + await runtime.init({ module_or_path: wasmResponse }); + return runtime; + })(); + + try { + return await epoxyRuntimePromise; + } catch ( error ) { + epoxyRuntimePromise = undefined; + throw error; + } +} + +function createPuterPasswordBuilder (runtime, wispToken) { + class PuterPasswordExt extends runtime.JsProtocolExtension { + constructor (required, toSend) { + super(0x02, [], []); + this.required = required; + this.toSend = toSend; + } + + encode () { + if ( ! this.toSend ) { + return new Uint8Array(); + } + + const [_user, _pw] = this.toSend; + const user = textEncoder.encode(_user); + const pw = textEncoder.encode(_pw); + + const buffer = new Uint8Array(3 + user.byteLength + pw.byteLength); + buffer[0] = user.byteLength; + new DataView(buffer.buffer).setUint16(1, pw.byteLength, true); + buffer.set(user, 3); + buffer.set(pw, 3 + user.byteLength); + + return buffer; + } + } + + class PuterPasswordExtBuilder extends runtime.JsProtocolExtensionBuilder { + constructor (toSend) { + super(0x02); + this.toSend = toSend; + } + + buildFromBytes (bytes) { + return new PuterPasswordExt(bytes[0] !== 0); + } + + buildToExtension () { + return new PuterPasswordExt(undefined, this.toSend); + } + } + + return new PuterPasswordExtBuilder(['', wispToken]); +} + +export let initEpoxy = async ({ wispToken, wispServer }) => { + if ( !wispServer || !wispToken ) { + throw new Error('Both wispServer and wispToken are required to initialize networking.'); + } + + const runtime = await getEpoxyRuntime(); + + const provider = new runtime.WispSocketProvider( + new runtime.WebSocketJsProvider(), + wispServer, + () => [ + { builders: [createPuterPasswordBuilder(runtime, wispToken)] }, + [0x02], + ], + ); + + return new runtime.EpoxyClient(provider); +}; diff --git a/src/puter-js/src/modules/networking/index.js b/src/puter-js/src/modules/networking/index.js new file mode 100644 index 0000000000..8c80ea328b --- /dev/null +++ b/src/puter-js/src/modules/networking/index.js @@ -0,0 +1,95 @@ +import { initEpoxy } from './epoxy.js'; +import { PSocket, PTLSSocket } from './PSocket.js'; +import { pFetch } from './requests.js'; + +let cachedEpoxyClientPromise; +let cachedEpoxyClientKey; + +function getPuterInstance () { + const puter = globalThis.puter; + if ( ! puter ) { + throw new Error('Puter runtime is not initialized yet.'); + } + return puter; +} + +function getWispRequestHeaders () { + const puter = getPuterInstance(); + const headers = { + 'Content-Type': 'application/json', + }; + + if ( puter.authToken ) { + headers.Authorization = `Bearer ${puter.authToken}`; + } + + return headers; +} + +function getClientCacheKey () { + const puter = getPuterInstance(); + return `${puter.APIOrigin}::${puter.authToken || ''}`; +} + +export async function getWispCredentials () { + const puter = getPuterInstance(); + const response = await fetch(`${puter.APIOrigin}/wisp/relay-token/create`, { + method: 'POST', + headers: getWispRequestHeaders(), + body: JSON.stringify({}), + }); + + if ( ! response.ok ) { + throw new Error( + `Failed to create relay token (HTTP ${response.status} ${response.statusText}).`, + ); + } + + const { token: wispToken, server: wispServer } = await response.json(); + if ( !wispToken || !wispServer ) { + throw new Error('Relay token endpoint returned an invalid response.'); + } + + return { wispToken, wispServer }; +} + +export async function generateWispV1URL () { + const { wispServer, wispToken } = await getWispCredentials(); + return `${wispServer}/${wispToken}/`; +} + +export async function getEpoxyClient ({ refresh = false } = {}) { + const nextKey = getClientCacheKey(); + if ( refresh || !cachedEpoxyClientPromise || cachedEpoxyClientKey !== nextKey ) { + cachedEpoxyClientKey = nextKey; + cachedEpoxyClientPromise = (async () => { + const { wispToken, wispServer } = await getWispCredentials(); + return await initEpoxy({ wispToken, wispServer }); + })(); + + cachedEpoxyClientPromise.catch(() => { + if ( cachedEpoxyClientKey === nextKey ) { + cachedEpoxyClientPromise = undefined; + cachedEpoxyClientKey = undefined; + } + }); + } + + return await cachedEpoxyClientPromise; +} + +export function clearEpoxyClientCache () { + cachedEpoxyClientPromise = undefined; + cachedEpoxyClientKey = undefined; +} + +export let netAPI = { + async generateWispV1URL () { + return await generateWispV1URL(); + }, + Socket: PSocket, + tls: { + TLSSocket: PTLSSocket, + }, + fetch: pFetch, +}; diff --git a/src/puter-js/src/modules/networking/parsers.js b/src/puter-js/src/modules/networking/parsers.js deleted file mode 100644 index f2f53ccd82..0000000000 --- a/src/puter-js/src/modules/networking/parsers.js +++ /dev/null @@ -1,159 +0,0 @@ -/* eslint-disable no-unreachable */ -/* eslint-disable no-case-declarations */ -// PACKET TYPES -export const CONNECT = 0x01; -export const DATA = 0x02; -export const CONTINUE = 0x03; -export const CLOSE = 0x04; -export const INFO = 0x05; - -// STREAM TYPES -export const TCP = 0x01; -export const UDP = 0x02; - -// Frequently used objects -export const textde = new TextDecoder(); -const texten = new TextEncoder(); -export const errors = { - 0x01: 'Reason unspecified or unknown. Returning a more specific reason should be preferred.' - , 0x03: 'Unexpected stream closure due to a network error.' - , 0x41: 'Stream creation failed due to invalid information. This could be sent if the destination was a reserved address or the port is invalid.' - , 0x42: 'Stream creation failed due to an unreachable destination host. This could be sent if the destination is an domain which does not resolve to anything.' - , 0x43: 'Stream creation timed out due to the destination server not responding.' - , 0x44: 'Stream creation failed due to the destination server refusing the connection.' - , 0x47: 'TCP data transfer timed out.' - , 0x48: 'Stream destination address/domain is intentionally blocked by the proxy server.' - , 0x49: 'Connection throttled by the server.', -}; - -/** - * @typedef {{packetType: number, streamID: number, streamType?: number, port?: number, hostname?: string, payload?: Uint8Array, reason?: number, remainingBuffer?: number}} ParsedWispPacket - */ - -/** - * Parses a wisp packet fully - * - * @param {Uint8Array} data - * @returns {ParsedWispPacket} Packet Info - */ - -export function parseIncomingPacket (data) { - const view = new DataView(data.buffer, data.byteOffset); - const packetType = view.getUint8(0); - const streamID = view.getUint32(1, true); - switch ( packetType ) { // Packet payload starts at Offset 5 - case CONNECT: - const streamType = view.getUint8(5); - const port = view.getUint16(6, true); - const hostname = textde.decode(data.subarray(8, data.length)); - return { packetType, streamID, streamType, port, hostname }; - break; - case DATA: - const payload = data.subarray(5, data.length); - return { packetType, streamID, payload }; - break; - case CONTINUE: - const remainingBuffer = view.getUint32(5, true); - return { packetType, streamID, remainingBuffer }; - break; - case CLOSE: - const reason = view.getUint8(5); - return { packetType, streamID, reason }; - break; - case INFO: - const infoObj = {}; - infoObj['version_major'] = view.getUint8(5); - infoObj['version_minor'] = view.getUint8(6); - - let ptr = 7; - while ( ptr < data.length ) { - const extType = view.getUint8(ptr); - const extLength = view.getUint32(ptr + 1, true); - const payload = data.subarray(ptr + 5, ptr + 5 + extLength); - infoObj[extType] = payload; - ptr += 5 + extLength; - } - return { packetType, streamID, infoObj }; - break; - } -} -/** - * creates a wisp packet fully - * - * @param {ParsedWispPacket} instructions - * @returns {Uint8Array} Constructed Packet - */ - -export function createWispPacket (instructions) { - let size = 5; - switch ( instructions.packetType ) { // Pass 1: determine size of packet - case CONNECT: - instructions.hostEncoded = texten.encode(instructions.hostname); - size += 3 + instructions.hostEncoded.length; - break; - case DATA: - size += instructions.payload.byteLength; - break; - case CONTINUE: - size += 4; - break; - case CLOSE: - size += 1; - break; - case INFO: - size += 2; - if ( instructions.password ) - { - size += 6; - } - if ( instructions.puterAuth ) { - instructions.passwordEncoded = texten.encode(instructions.puterAuth); - size += 8 + instructions.passwordEncoded.length; - } - break; - default: - throw new Error('Not supported'); - } - - let data = new Uint8Array(size); - const view = new DataView(data.buffer); - view.setUint8(0, instructions.packetType); - view.setUint32(1, instructions.streamID, true); - switch ( instructions.packetType ) { // Pass 2: fill out packet - case CONNECT: - view.setUint8(5, instructions.streamType); - view.setUint16(6, instructions.port, true); - data.set(instructions.hostEncoded, 8); - break; - case DATA: - data.set(instructions.payload, 5); - break; - case CONTINUE: - view.setUint32(5, instructions.remainingBuffer, true); - break; - case CLOSE: - view.setUint8(5, instructions.reason); - break; - case INFO: - // WISP 2.0 - view.setUint8(5, 2); - view.setUint8(6, 0); - - if ( instructions.password ) { - // PASSWORD AUTH REQUIRED - view.setUint8(7, 0x02); // Protocol ID (Password) - view.setUint32(8, 1, true); - view.setUint8(12, 0); // Password required? true - } - - if ( instructions.puterAuth ) { - // PASSWORD AUTH REQUIRED - view.setUint8(7, 0x02); // Protocol ID (Password) - view.setUint32(8, 5 + instructions.passwordEncoded.length, true); - view.setUint8(12, 0); - view.setUint16(13, instructions.passwordEncoded.length, true); - data.set(instructions.passwordEncoded, 15); - } - } - return data; -} \ No newline at end of file diff --git a/src/puter-js/src/modules/networking/requests.js b/src/puter-js/src/modules/networking/requests.js index 30acc8a85e..df859a48c0 100644 --- a/src/puter-js/src/modules/networking/requests.js +++ b/src/puter-js/src/modules/networking/requests.js @@ -1,283 +1,84 @@ -// SO: https://stackoverflow.com/a/76332760/ under CC BY-SA 4.0 -function mergeUint8Arrays (...arrays) { - const totalSize = arrays.reduce((acc, e) => acc + e.length, 0); - const merged = new Uint8Array(totalSize); +import { clearEpoxyClientCache, getEpoxyClient } from './index.js'; - arrays.forEach((array, i, arrays) => { - const offset = arrays.slice(0, i).reduce((acc, e) => acc + e.length, 0); - merged.set(array, offset); - }); +function logFetchResult ({ params, result, error }) { + if ( ! globalThis.puter?.apiCallLogger?.isEnabled() ) { + return; + } - return merged; + globalThis.puter.apiCallLogger.logRequest({ + service: 'network', + operation: 'pFetch', + params, + result, + error, + }); } -function parseHTTPHead (head) { - const lines = head.split('\r\n'); - - const firstLine = lines.shift().split(' '); - const status = Number(firstLine[1]); - const statusText = firstLine.slice(2).join(' ') || ''; - - const headersArray = []; - for ( const header of lines ) { - const splitHeaders = header.split(': '); - const key = splitHeaders[0]; - const value = splitHeaders.slice(1).join(': '); - headersArray.push([key, value]); +function normalizeErrorMessage (error) { + if ( error instanceof Error ) { + return error.message; } - return { headers: new Headers(headersArray), statusText, status }; -} - -// Trivial stream based HTTP 1.1 client -// TODO optional redirect handling - -/** - * `puter.net.fetch`: fetches an http/https resource over a raw socket rather - * than the browser's HTTP stack, so it is not subject to CORS. Takes the same - * arguments as `fetch` and resolves to a `Response`. - * - * @type {(input: RequestInfo | URL, init?: RequestInit) => Promise} - */ -export function pFetch (...args) { - return new Promise(async (res, rej) => { - // Declared out here so the catch below can still describe the request - // when `new Request(...)` is what threw. - let reqObj; - try { - reqObj = new Request(...args); - const parsedURL = new URL(reqObj.url); - let headers = new Headers(reqObj.headers); // Make a headers object we can modify - - // Socket creation: regular for HTTP, TLS for https - let socket; - if ( parsedURL.protocol === 'http:' ) { - socket = new puter.net.Socket(parsedURL.hostname, - parsedURL.port || 80); - } else if ( parsedURL.protocol === 'https:' ) { - socket = new puter.net.tls.TLSSocket(parsedURL.hostname, - parsedURL.port || 443); - } else { - const errorMsg = `Failed to fetch. URL scheme "${parsedURL.protocol}" is not supported.`; - - // Log the error - if ( globalThis.puter?.apiCallLogger?.isEnabled() ) { - globalThis.puter.apiCallLogger.logRequest({ - service: 'network', - operation: 'pFetch', - params: { url: reqObj.url, method: reqObj.method }, - error: { message: errorMsg }, - }); - } - - rej(errorMsg); - return; - } - - // Sending default UA. `navigator` is absent in workerd, where - // reading it unguarded would fail every request outright; send - // no User-Agent at all rather than inventing one. - if ( ! headers.get('user-agent') ) { - const runtimeUserAgent = globalThis.navigator?.userAgent; - if ( runtimeUserAgent ) headers.set('user-agent', runtimeUserAgent); - } - - let reqHead = `${reqObj.method} ${parsedURL.pathname}${parsedURL.search} HTTP/1.1\r\nHost: ${parsedURL.host}\r\nConnection: close\r\n`; - for ( const [key, value] of headers ) { - reqHead += `${key}: ${value}\r\n`; - } - let requestBody; - if ( reqObj.body ) { - requestBody = new Uint8Array(await reqObj.arrayBuffer()); - // If we have a body, we need to set the content length - if ( ! headers.has('content-length') ) { - headers.set('content-length', requestBody.length); - } else if ( - headers.get('content-length') !== String(requestBody.length) - ) { - return rej('Content-Length header does not match the body length. Please check your request.'); - } - reqHead += `Content-Length: ${requestBody.length}\r\n`; - } - - reqHead += '\r\n'; - socket.on('open', async () => { - socket.write(reqHead); // Send headers - if ( requestBody ) { - socket.write(requestBody); // Send body if present - } - }); - const decoder = new TextDecoder(); - let responseHead = ''; - let dataOffset = -1; - const fullDataParts = []; - let responseReturned = false; - let contentLength = -1; - let ingestedContent = 0; - let chunkedTransfer = false; - let currentChunkLeft = -1; - let buffer = new Uint8Array(0); - - const outStream = new ReadableStream({ - start (controller) { - // This is annoyingly long - function parseIncomingChunk (data) { - // append new data to our rolling buffer - const tmp = new Uint8Array(buffer.length + data.length); - tmp.set(buffer, 0); - tmp.set(data, buffer.length); - buffer = tmp; - - // pull out as many complete chunks (or headers) as we can - while ( true ) { - if ( currentChunkLeft > 0 ) { - // we’re in the middle of reading a chunk body - // need size + 2 bytes (for trailing \r\n) - if ( buffer.length >= currentChunkLeft + 2 ) { - // full body + CRLF available - const chunk = buffer.slice(0, currentChunkLeft); - controller.enqueue(chunk); - - // strip body + CRLF and reset for next header - buffer = buffer.slice(currentChunkLeft + 2); - currentChunkLeft = 0; - } else { - // only a partial body available - controller.enqueue(buffer); - currentChunkLeft -= buffer.length; - buffer = new Uint8Array(0); - break; // wait for more data - } - } else { - // we need to parse the next size line - // find the first "\r\n" - let idx = -1; - for ( let i = 0; i + 1 < buffer.length; i++ ) { - if ( - buffer[i] === 0x0d && - buffer[i + 1] === 0x0a - ) { - idx = i; - break; - } - } - if ( idx < 0 ) { - // we don’t yet have a full size line - break; - } - - // decode just the size line as ASCII hex - const sizeText = decoder - .decode(buffer.slice(0, idx)) - .trim(); - currentChunkLeft = parseInt(sizeText, 16); - if ( isNaN(currentChunkLeft) ) { - controller.error('Invalid chunk length from server'); - } - // strip off the size line + CRLF - buffer = buffer.slice(idx + 2); - - // zero-length => end of stream - if ( currentChunkLeft === 0 ) { - responseReturned = true; - controller.close(); - return; - } - } - } - } - socket.on('data', (data) => { - // Dataoffset is set to another value once head is returned, its safe to assume all remaining data is body - if ( dataOffset !== -1 && !chunkedTransfer ) { - controller.enqueue(data); - ingestedContent += data.length; - } - - // We dont have the full responseHead yet - if ( dataOffset === -1 ) { - fullDataParts.push(data); - responseHead += decoder.decode(data, { stream: true }); - } - if ( chunkedTransfer ) { - parseIncomingChunk(data); - } + return String(error); +} - // See if we have the HEAD of an HTTP/1.1 yet - if ( responseHead.indexOf('\r\n\r\n') !== -1 ) { - dataOffset = responseHead.indexOf('\r\n\r\n'); - responseHead = responseHead.slice(0, dataOffset); - const parsedHead = parseHTTPHead(responseHead); - contentLength = Number(parsedHead.headers.get('content-length')); - chunkedTransfer = - parsedHead.headers.get('transfer-encoding') === - 'chunked'; +function getFetchLogParams (args) { + const [resource, init] = args; - // Log the response - if ( globalThis.puter?.apiCallLogger?.isEnabled() ) { - globalThis.puter.apiCallLogger.logRequest({ - service: 'network', - operation: 'pFetch', - params: { url: reqObj.url, method: reqObj.method }, - result: { status: parsedHead.status, statusText: parsedHead.statusText }, - }); - } + let url; + if ( typeof resource === 'string' ) { + url = resource; + } else if ( resource instanceof URL ) { + url = resource.toString(); + } else if ( resource && typeof resource.url === 'string' ) { + url = resource.url; + } - // Return initial response object - res(new Response(outStream, parsedHead)); + let method; + if ( init && typeof init.method === 'string' ) { + method = init.method; + } else if ( resource && typeof resource.method === 'string' ) { + method = resource.method; + } else { + method = 'GET'; + } - const residualBody = mergeUint8Arrays(...fullDataParts).slice(dataOffset + 4); - if ( ! chunkedTransfer ) { - // Add any content we have but isn't part of the head into the body stream - ingestedContent += residualBody.length; - controller.enqueue(residualBody); - } else { - parseIncomingChunk(residualBody); - } - } + return { + url, + method, + }; +} - if ( - contentLength !== -1 && - ingestedContent === contentLength && - !chunkedTransfer - ) { - // Work around for the close bug for compliant HTTP/1.1 servers - if ( ! responseReturned ) { - responseReturned = true; - controller.close(); - } - } - }); - socket.on('close', () => { - if ( ! responseReturned ) { - responseReturned = true; - controller.close(); - } - }); - socket.on('error', (reason) => { - // Log the error - if ( globalThis.puter?.apiCallLogger?.isEnabled() ) { - globalThis.puter.apiCallLogger.logRequest({ - service: 'network', - operation: 'pFetch', - params: { url: reqObj.url, method: reqObj.method }, - error: { message: `Socket errored with the following reason: ${ reason}` }, - }); - } - rej(`Socket errored with the following reason: ${ reason}`); - }); - }, - }); - } catch (e) { - // Log unexpected errors - if ( globalThis.puter?.apiCallLogger?.isEnabled() ) { - globalThis.puter.apiCallLogger.logRequest({ - service: 'network', - operation: 'pFetch', - params: { url: reqObj?.url, method: reqObj?.method }, - error: { message: e?.message || String(e), stack: e?.stack }, - }); - } - rej(e); +export async function pFetch (...args) { + const params = getFetchLogParams(args); + let usedEpoxyClient = false; + + try { + const client = await getEpoxyClient(); + usedEpoxyClient = true; + const response = await client.fetch(...args); + + logFetchResult({ + params, + result: { + status: response.status, + statusText: response.statusText, + }, + }); + + return response; + } catch ( error ) { + if ( usedEpoxyClient ) { + clearEpoxyClientCache(); } - }); + + logFetchResult({ + params, + error: { + message: normalizeErrorMessage(error), + stack: error?.stack, + }, + }); + throw error; + } } diff --git a/src/puter-js/src/modules/networking/types.js b/src/puter-js/src/modules/networking/types.js index 34a6a67c05..6c77912306 100644 --- a/src/puter-js/src/modules/networking/types.js +++ b/src/puter-js/src/modules/networking/types.js @@ -24,7 +24,7 @@ * @property {() => Promise} generateWispV1URL Mints a relay URL (server plus single-use * token) for speaking the Wisp v1 protocol directly. * @property {typeof import('./PSocket.js').PSocket} Socket Constructor for a raw TCP `Socket`. - * @property {{ TLSSocket: typeof import('./PTLS.js').PTLSSocket }} tls Constructor for a + * @property {{ TLSSocket: typeof import('./PSocket.js').PTLSSocket }} tls Constructor for a * TLS-protected `TLSSocket`. * @property {(input: RequestInfo | URL, init?: RequestInit) => Promise} fetch * Fetch an http/https resource without being bound by CORS restrictions. From cba67482e964d9d4d6b01e901ecddb4141e3ebc2 Mon Sep 17 00:00:00 2001 From: Toshit Chawda Date: Fri, 27 Mar 2026 16:42:46 -0700 Subject: [PATCH 2/9] update to new epoxy build --- src/puter-js/src/modules/networking/epoxy.js | 17 ++------- src/puter-js/src/modules/networking/index.js | 36 +++++++++++--------- 2 files changed, 23 insertions(+), 30 deletions(-) diff --git a/src/puter-js/src/modules/networking/epoxy.js b/src/puter-js/src/modules/networking/epoxy.js index 097a1f5c35..d8860b1427 100644 --- a/src/puter-js/src/modules/networking/epoxy.js +++ b/src/puter-js/src/modules/networking/epoxy.js @@ -1,22 +1,15 @@ -let EPOXY_BASE = 'https://epoxy.puter.com/0265590'; +let EPOXY_BASE = 'https://puter-net.b-cdn.net/epoxy/7fbb05b'; let epoxyRuntimePromise; const textEncoder = new TextEncoder(); -function getEpoxyBase () { - const overriddenBase = - globalThis.PUTER_EPOXY_BASE || globalThis.PUTER_EPOXY_BASE_ENV; - const base = overriddenBase || EPOXY_BASE; - return base.endsWith('/') ? base.slice(0, -1) : base; -} - async function getEpoxyRuntime () { if ( epoxyRuntimePromise ) { - return epoxyRuntimePromise; + return await epoxyRuntimePromise; } epoxyRuntimePromise = (async () => { - const base = getEpoxyBase(); + const base = EPOXY_BASE; const runtime = await import(/* webpackIgnore: true */ `${base}/full.js`); const wasmResponse = await fetch(`${base}/full.wasm`); if ( ! wasmResponse.ok ) { @@ -82,10 +75,6 @@ function createPuterPasswordBuilder (runtime, wispToken) { } export let initEpoxy = async ({ wispToken, wispServer }) => { - if ( !wispServer || !wispToken ) { - throw new Error('Both wispServer and wispToken are required to initialize networking.'); - } - const runtime = await getEpoxyRuntime(); const provider = new runtime.WispSocketProvider( diff --git a/src/puter-js/src/modules/networking/index.js b/src/puter-js/src/modules/networking/index.js index 8c80ea328b..254d317fa1 100644 --- a/src/puter-js/src/modules/networking/index.js +++ b/src/puter-js/src/modules/networking/index.js @@ -2,8 +2,7 @@ import { initEpoxy } from './epoxy.js'; import { PSocket, PTLSSocket } from './PSocket.js'; import { pFetch } from './requests.js'; -let cachedEpoxyClientPromise; -let cachedEpoxyClientKey; +let cachedEpoxy = undefined; function getPuterInstance () { const puter = globalThis.puter; @@ -59,28 +58,33 @@ export async function generateWispV1URL () { } export async function getEpoxyClient ({ refresh = false } = {}) { + if ( cachedEpoxy && cachedEpoxy.initting ) return await cachedEpoxy.promise; + const nextKey = getClientCacheKey(); - if ( refresh || !cachedEpoxyClientPromise || cachedEpoxyClientKey !== nextKey ) { - cachedEpoxyClientKey = nextKey; - cachedEpoxyClientPromise = (async () => { - const { wispToken, wispServer } = await getWispCredentials(); - return await initEpoxy({ wispToken, wispServer }); + if ( refresh || !(cachedEpoxy && cachedEpoxy.key === nextKey) ) { + let epoxy = { key: nextKey, initting: true }; + let promise = (async () => { + try { + const { wispToken, wispServer } = await getWispCredentials(); + let ret = await initEpoxy({ wispToken, wispServer }); + epoxy.initting = false; + return ret; + } catch { + if ( cachedEpoxy === epoxy ) { + cachedEpoxy = undefined; + } + } })(); + epoxy.promise = promise; - cachedEpoxyClientPromise.catch(() => { - if ( cachedEpoxyClientKey === nextKey ) { - cachedEpoxyClientPromise = undefined; - cachedEpoxyClientKey = undefined; - } - }); + cachedEpoxy = epoxy; } - return await cachedEpoxyClientPromise; + return await cachedEpoxy.promise; } export function clearEpoxyClientCache () { - cachedEpoxyClientPromise = undefined; - cachedEpoxyClientKey = undefined; + cachedEpoxy = undefined; } export let netAPI = { From 7e86b5afa950469841d1df6e4dee0c966fe71125 Mon Sep 17 00:00:00 2001 From: Toshit Chawda Date: Fri, 1 May 2026 16:03:22 -0700 Subject: [PATCH 3/9] force wisp auth --- src/puter-js/src/modules/networking/index.js | 21 +++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/puter-js/src/modules/networking/index.js b/src/puter-js/src/modules/networking/index.js index 254d317fa1..8a0bca94c2 100644 --- a/src/puter-js/src/modules/networking/index.js +++ b/src/puter-js/src/modules/networking/index.js @@ -25,19 +25,38 @@ function getWispRequestHeaders () { return headers; } +async function ensureWispAuthentication () { + const puter = getPuterInstance(); + if ( puter.authToken ) { + return; + } + + await puter.ui.authenticateWithPuter(); +} + function getClientCacheKey () { const puter = getPuterInstance(); return `${puter.APIOrigin}::${puter.authToken || ''}`; } -export async function getWispCredentials () { +export async function getWispCredentials (retryAuth = true) { const puter = getPuterInstance(); + await ensureWispAuthentication(); + const response = await fetch(`${puter.APIOrigin}/wisp/relay-token/create`, { method: 'POST', headers: getWispRequestHeaders(), body: JSON.stringify({}), }); + if ( response.status === 401 && retryAuth ) { + if ( typeof puter.resetAuthToken === 'function' ) { + puter.resetAuthToken(); + } + await ensureWispAuthentication(); + return await getWispCredentials(false); + } + if ( ! response.ok ) { throw new Error( `Failed to create relay token (HTTP ${response.status} ${response.statusText}).`, From 626c70442511ccbe1cc545ec5725a00511c32143 Mon Sep 17 00:00:00 2001 From: Toshit Chawda Date: Wed, 29 Jul 2026 15:49:01 -0700 Subject: [PATCH 4/9] emit Error objects from socket 'error' events main tightened the socket error contract to pass an Error (see types/modules/networking.d.ts, which documents the reason as `error.message`); the epoxy rewrite still emitted a bare string, matching the older `(reason: string)` declaration. Co-Authored-By: Claude Opus 5 (1M context) --- src/puter-js/src/modules/networking/PSocket.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/puter-js/src/modules/networking/PSocket.js b/src/puter-js/src/modules/networking/PSocket.js index da2fb1d485..e607ae2508 100644 --- a/src/puter-js/src/modules/networking/PSocket.js +++ b/src/puter-js/src/modules/networking/PSocket.js @@ -19,12 +19,12 @@ function normalizeWriteData (data) { throw new Error('Invalid data type (not TypedArray, ArrayBuffer or String).'); } -function normalizeErrorReason (reason) { +function normalizeError (reason) { if ( reason instanceof Error ) { - return reason.message; + return reason; } - return String(reason); + return new Error(String(reason)); } /** @typedef {import('./types.js').SocketEvent} SocketEvent */ @@ -278,7 +278,7 @@ export class PSocket extends EventListener { return; } - this.emit('error', normalizeErrorReason(reason)); + this.emit('error', normalizeError(reason)); this.#closing = true; void this.#closeStreams(true); } From 639b4bcb2f56bb825da6b941227878f748ab5db2 Mon Sep 17 00:00:00 2001 From: Toshit Chawda Date: Wed, 29 Jul 2026 16:02:51 -0700 Subject: [PATCH 5/9] test: cover the epoxy-based networking client Replaces the coverage lost when the wisp implementation was removed: PSocket.test.js and PWispHandler.test.js were bound to PWispHandler, parsers.js, and wispInfo, none of which survive the epoxy rewrite. PSocket.test.js - connect/retry, inbound data, write normalisation, close, and the TLS event remapping. Driven through real ReadableStream/WritableStream pairs so reader locking, cancel, and abort behave as they do in the browser. index.test.js - relay-token exchange (auth header, 401 re-auth and retry, malformed responses) and the epoxy client cache (keying, refresh, in-flight sharing). requests.test.js - pFetch delegation, cache invalidation, and the api call logger's request description. epoxy.test.js - the hand-packed wisp password extension payload. The wasm runtime loader is left to integration coverage; it needs a network fetch and a browser. createPuterPasswordBuilder is exported so the byte layout can be tested against an injected fake runtime. One skipped test records a pre-existing defect: on a failed write, #readLoop's normal-termination path races #closeStreams and emits close(false) after an error. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/modules/networking/PSocket.test.js | 445 ++++++++++++++++++ src/puter-js/src/modules/networking/epoxy.js | 4 +- .../src/modules/networking/epoxy.test.js | 79 ++++ .../src/modules/networking/index.test.js | 272 +++++++++++ .../src/modules/networking/requests.test.js | 164 +++++++ 5 files changed, 963 insertions(+), 1 deletion(-) create mode 100644 src/puter-js/src/modules/networking/PSocket.test.js create mode 100644 src/puter-js/src/modules/networking/epoxy.test.js create mode 100644 src/puter-js/src/modules/networking/index.test.js create mode 100644 src/puter-js/src/modules/networking/requests.test.js diff --git a/src/puter-js/src/modules/networking/PSocket.test.js b/src/puter-js/src/modules/networking/PSocket.test.js new file mode 100644 index 0000000000..11ed1c149b --- /dev/null +++ b/src/puter-js/src/modules/networking/PSocket.test.js @@ -0,0 +1,445 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// PSocket's only collaborator is the epoxy client, so that module is stubbed: +// no wasm runtime, no relay, no websocket. Stubbing it also breaks the +// PSocket <-> index.js import cycle for these tests. +const mockGetEpoxyClient = vi.fn(); +const mockClearEpoxyClientCache = vi.fn(); +vi.mock('./index.js', () => ({ + getEpoxyClient: (...args) => mockGetEpoxyClient(...args), + clearEpoxyClientCache: (...args) => mockClearEpoxyClientCache(...args), +})); + +const { PSocket, PTLSSocket } = await import('./PSocket.js'); + +// Socket events land a few microtasks after the call that triggers them, so +// assertions retry rather than guess a tick count. The default 50ms poll would +// dominate the runtime of a suite this size; these waits resolve almost +// immediately. +const until = assertion => vi.waitFor(assertion, { interval: 1, timeout: 1000 }); + +// A duplex pair shaped like what `EpoxyClient.connect` returns: a +// ReadableStream of inbound bytes plus a WritableStream of outbound ones. +// These are the platform's real stream implementations, so reader/writer +// locking, cancel, and abort behave as they do in the browser. +function makeStream ({ failWrite } = {}) { + let readController; + const read = new ReadableStream({ + start (controller) { + readController = controller; + }, + }); + + const written = []; + const write = new WritableStream({ + write (chunk) { + if ( failWrite ) { + throw failWrite; + } + written.push(chunk); + }, + }); + + return { + read, + write, + written, + push: bytes => readController.enqueue(bytes), + endRead: () => readController.close(), + failRead: error => readController.error(error), + }; +} + +function makeClient (stream) { + return { + connect: vi.fn(async () => stream), + connectTls: vi.fn(async () => stream), + }; +} + +// A promise whose settlement the test controls, for pausing mid-connect. +function deferred () { + let resolve; + let reject; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +// Attaches spies for every event a caller can observe. `error` is never +// tls-prefixed, unlike open/data/close. +function listen (socket) { + const spies = { + open: vi.fn(), + data: vi.fn(), + close: vi.fn(), + error: vi.fn(), + }; + for ( const event of Object.keys(spies) ) { + socket.on(event, spies[event]); + } + return spies; +} + +// Opens a socket and waits until it is ready to write. +async function connected (stream = makeStream()) { + const client = makeClient(stream); + mockGetEpoxyClient.mockResolvedValue(client); + + const socket = new PSocket('example.com', 80); + const events = listen(socket); + await until(() => expect(events.open).toHaveBeenCalledTimes(1)); + + return { socket, events, client, stream }; +} + +beforeEach(() => { + mockGetEpoxyClient.mockReset(); + mockClearEpoxyClientCache.mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('PSocket connect', () => { + it('opens a stream through the epoxy client', async () => { + const { client, events } = await connected(); + + expect(client.connect).toHaveBeenCalledWith('example.com', 80); + expect(client.connectTls).not.toHaveBeenCalled(); + expect(mockGetEpoxyClient).toHaveBeenCalledWith({ refresh: false }); + expect(events.error).not.toHaveBeenCalled(); + }); + + it('coerces a string port to a number', async () => { + const client = makeClient(makeStream()); + mockGetEpoxyClient.mockResolvedValue(client); + + const socket = new PSocket('example.com', '443'); + const events = listen(socket); + await until(() => expect(events.open).toHaveBeenCalled()); + + expect(client.connect).toHaveBeenCalledWith('example.com', 443); + }); + + it('retries with a refreshed client when the first attempt fails', async () => { + const client = makeClient(makeStream()); + mockGetEpoxyClient + .mockRejectedValueOnce(new Error('stale client')) + .mockResolvedValueOnce(client); + + const socket = new PSocket('example.com', 80); + const events = listen(socket); + await until(() => expect(events.open).toHaveBeenCalledTimes(1)); + + expect(mockGetEpoxyClient).toHaveBeenNthCalledWith(1, { refresh: false }); + expect(mockGetEpoxyClient).toHaveBeenNthCalledWith(2, { refresh: true }); + expect(events.error).not.toHaveBeenCalled(); + }); + + it('emits an error and closes when both attempts fail', async () => { + mockGetEpoxyClient.mockRejectedValue(new Error('relay down')); + + const socket = new PSocket('example.com', 80); + const events = listen(socket); + await until(() => expect(events.close).toHaveBeenCalled()); + + expect(events.open).not.toHaveBeenCalled(); + expect(events.error).toHaveBeenCalledTimes(1); + expect(events.close).toHaveBeenCalledWith(true); + expect(mockClearEpoxyClientCache).toHaveBeenCalled(); + }); + + // The types declare the handler as `(error: Error) => void` and document the + // reason as `error.message`, so a rejection has to arrive wrapped. + it('reports failures as Error instances', async () => { + mockGetEpoxyClient.mockRejectedValue(new Error('relay down')); + + const socket = new PSocket('example.com', 80); + const events = listen(socket); + await until(() => expect(events.error).toHaveBeenCalled()); + + const [reason] = events.error.mock.calls[0]; + expect(reason).toBeInstanceOf(Error); + expect(reason.message).toBe('relay down'); + }); + + it('wraps a non-Error rejection reason', async () => { + mockGetEpoxyClient.mockRejectedValue('just a string'); + + const socket = new PSocket('example.com', 80); + const events = listen(socket); + await until(() => expect(events.error).toHaveBeenCalled()); + + const [reason] = events.error.mock.calls[0]; + expect(reason).toBeInstanceOf(Error); + expect(reason.message).toBe('just a string'); + }); +}); + +describe('PSocket inbound data', () => { + it('emits each chunk the remote sends', async () => { + const { events, stream } = await connected(); + + stream.push(new Uint8Array([1, 2, 3])); + await until(() => expect(events.data).toHaveBeenCalledTimes(1)); + + expect(Array.from(events.data.mock.calls[0][0])).toEqual([1, 2, 3]); + }); + + it('emits chunks in order', async () => { + const { events, stream } = await connected(); + + stream.push(new Uint8Array([1])); + stream.push(new Uint8Array([2])); + await until(() => expect(events.data).toHaveBeenCalledTimes(2)); + + expect(events.data.mock.calls.map(([chunk]) => Array.from(chunk))) + .toEqual([[1], [2]]); + }); + + it('closes without an error flag when the remote ends the stream', async () => { + const { events, stream } = await connected(); + + stream.endRead(); + await until(() => expect(events.close).toHaveBeenCalled()); + + expect(events.close).toHaveBeenCalledWith(false); + expect(events.error).not.toHaveBeenCalled(); + }); + + it('emits an error and drops the cached client when the read fails', async () => { + const { events, stream } = await connected(); + + stream.failRead(new Error('connection reset')); + await until(() => expect(events.error).toHaveBeenCalled()); + + expect(events.error.mock.calls[0][0].message).toBe('connection reset'); + expect(mockClearEpoxyClientCache).toHaveBeenCalled(); + await until(() => expect(events.close).toHaveBeenCalledWith(true)); + }); +}); + +describe('PSocket write', () => { + it('writes a typed array', async () => { + const { socket, stream } = await connected(); + + socket.write(new Uint8Array([1, 2, 3])); + await until(() => expect(stream.written).toHaveLength(1)); + + expect(Array.from(stream.written[0])).toEqual([1, 2, 3]); + }); + + it('writes an ArrayBuffer', async () => { + const { socket, stream } = await connected(); + + socket.write(new Uint8Array([4, 5, 6]).buffer); + await until(() => expect(stream.written).toHaveLength(1)); + + expect(Array.from(stream.written[0])).toEqual([4, 5, 6]); + }); + + // A view can cover part of a larger buffer; only its own bytes may be sent. + it('writes only the bytes a partial view covers', async () => { + const { socket, stream } = await connected(); + const backing = new Uint8Array([9, 1, 2, 3, 9]).buffer; + + socket.write(new Uint8Array(backing, 1, 3)); + await until(() => expect(stream.written).toHaveLength(1)); + + expect(Array.from(stream.written[0])).toEqual([1, 2, 3]); + }); + + it('encodes a string as utf-8', async () => { + const { socket, stream } = await connected(); + + socket.write('hé'); + await until(() => expect(stream.written).toHaveLength(1)); + + expect(Array.from(stream.written[0])).toEqual([104, 195, 169]); + }); + + it('throws on an unsupported data type', async () => { + const { socket } = await connected(); + + expect(() => socket.write(42)).toThrow(/Invalid data type/); + }); + + it('invokes the callback once the write lands', async () => { + const { socket } = await connected(); + const callback = vi.fn(); + + socket.write('hi', callback); + await until(() => expect(callback).toHaveBeenCalledTimes(1)); + }); + + it('queues writes issued before the socket opens and flushes them in order', async () => { + const stream = makeStream(); + const gate = deferred(); + mockGetEpoxyClient.mockReturnValue(gate.promise); + + const socket = new PSocket('example.com', 80); + const events = listen(socket); + + // Still connecting, so neither write can reach the stream yet. + socket.write('first'); + socket.write('second'); + expect(stream.written).toHaveLength(0); + + gate.resolve(makeClient(stream)); + await until(() => expect(events.open).toHaveBeenCalled()); + await until(() => expect(stream.written).toHaveLength(2)); + + const decoder = new TextDecoder(); + expect(stream.written.map(chunk => decoder.decode(chunk))) + .toEqual(['first', 'second']); + }); + + it('throws when writing to a closed socket', async () => { + const { socket, events } = await connected(); + + socket.close(); + await until(() => expect(events.close).toHaveBeenCalled()); + + expect(() => socket.write('late')).toThrow(/already closed/); + }); + + it('emits an error and drops the cached client when a write fails', async () => { + const stream = makeStream({ failWrite: new Error('write failed') }); + const { socket, events } = await connected(stream); + + socket.write('doomed'); + await until(() => expect(events.error).toHaveBeenCalled()); + + expect(events.error.mock.calls[0][0].message).toBe('write failed'); + expect(mockClearEpoxyClientCache).toHaveBeenCalled(); + }); + + // KNOWN BUG -- unskip once #readLoop stops racing the error path. + // + // A failed write sets #closing and hands the close event to #closeStreams, + // which must await reader.cancel() and writer.close() before emitting + // close(true). That cancel resolves #readLoop's pending read() with + // {done: true}, so the loop breaks and reaches its own #emitClose(false) + // first -- #closed is already set by the time #closeStreams gets there, so + // callers are told the socket shut down cleanly after an error. + // A read failure is unaffected: that path throws into #readLoop's catch, + // which never calls #emitClose(false). + it.skip('closes with the error flag set when a write fails', async () => { + const stream = makeStream({ failWrite: new Error('write failed') }); + const { socket, events } = await connected(stream); + + socket.write('doomed'); + await until(() => expect(events.close).toHaveBeenCalled()); + + expect(events.close).toHaveBeenCalledWith(true); + }); +}); + +describe('PSocket close', () => { + it('emits close exactly once, even when called repeatedly', async () => { + const { socket, events } = await connected(); + + socket.close(); + socket.close(); + await until(() => expect(events.close).toHaveBeenCalled()); + + expect(events.close).toHaveBeenCalledTimes(1); + expect(events.close).toHaveBeenCalledWith(false); + expect(events.error).not.toHaveBeenCalled(); + }); + + // Closing while the relay handshake is still in flight must not leave the + // freshly opened stream dangling, and must not surface as an open socket. + it('tears down a stream that arrives after close, without emitting open', async () => { + const stream = makeStream(); + const cancelSpy = vi.spyOn(stream.read, 'cancel'); + const abortSpy = vi.spyOn(stream.write, 'abort'); + const gate = deferred(); + mockGetEpoxyClient.mockReturnValue(gate.promise); + + const socket = new PSocket('example.com', 80); + const events = listen(socket); + + socket.close(); + gate.resolve(makeClient(stream)); + + await until(() => expect(cancelSpy).toHaveBeenCalled()); + expect(abortSpy).toHaveBeenCalled(); + expect(events.open).not.toHaveBeenCalled(); + }); + + it('stops emitting data after close', async () => { + const { socket, events, stream } = await connected(); + + socket.close(); + await until(() => expect(events.close).toHaveBeenCalled()); + expect(() => stream.push(new Uint8Array([1]))).toThrow(); + + expect(events.data).not.toHaveBeenCalled(); + }); +}); + +describe('PTLSSocket', () => { + it('opens a TLS stream and reports tls-prefixed events through on()', async () => { + const stream = makeStream(); + const client = makeClient(stream); + mockGetEpoxyClient.mockResolvedValue(client); + + const socket = new PTLSSocket('example.com', 443); + const events = listen(socket); + await until(() => expect(events.open).toHaveBeenCalledTimes(1)); + + expect(client.connectTls).toHaveBeenCalledWith('example.com', 443); + expect(client.connect).not.toHaveBeenCalled(); + + stream.push(new Uint8Array([7])); + await until(() => expect(events.data).toHaveBeenCalledTimes(1)); + expect(Array.from(events.data.mock.calls[0][0])).toEqual([7]); + + stream.endRead(); + await until(() => expect(events.close).toHaveBeenCalledWith(false)); + }); + + // `on('open')` is sugar that remaps onto the tls-prefixed name; listening + // for the prefixed name directly has to keep working too. + it('also accepts the tls-prefixed event names directly', async () => { + const stream = makeStream(); + mockGetEpoxyClient.mockResolvedValue(makeClient(stream)); + + const socket = new PTLSSocket('example.com', 443); + const onTlsOpen = vi.fn(); + const onTlsData = vi.fn(); + socket.on('tlsopen', onTlsOpen); + socket.on('tlsdata', onTlsData); + + await until(() => expect(onTlsOpen).toHaveBeenCalledTimes(1)); + + stream.push(new Uint8Array([8])); + await until(() => expect(onTlsData).toHaveBeenCalledTimes(1)); + }); + + it('reports errors on the unprefixed error event', async () => { + mockGetEpoxyClient.mockRejectedValue(new Error('tls handshake failed')); + + const socket = new PTLSSocket('example.com', 443); + const events = listen(socket); + await until(() => expect(events.error).toHaveBeenCalled()); + + expect(events.error.mock.calls[0][0].message).toBe('tls handshake failed'); + }); + + it('routes addListener through the same remapping as on()', async () => { + const stream = makeStream(); + mockGetEpoxyClient.mockResolvedValue(makeClient(stream)); + + const socket = new PTLSSocket('example.com', 443); + const onOpen = vi.fn(); + socket.addListener('open', onOpen); + + await until(() => expect(onOpen).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/src/puter-js/src/modules/networking/epoxy.js b/src/puter-js/src/modules/networking/epoxy.js index d8860b1427..bb9897d81f 100644 --- a/src/puter-js/src/modules/networking/epoxy.js +++ b/src/puter-js/src/modules/networking/epoxy.js @@ -29,7 +29,9 @@ async function getEpoxyRuntime () { } } -function createPuterPasswordBuilder (runtime, wispToken) { +// Exported for tests: the wisp password extension's byte layout is hand-packed, +// and `runtime` is injectable, so it can be exercised without the wasm bundle. +export function createPuterPasswordBuilder (runtime, wispToken) { class PuterPasswordExt extends runtime.JsProtocolExtension { constructor (required, toSend) { super(0x02, [], []); diff --git a/src/puter-js/src/modules/networking/epoxy.test.js b/src/puter-js/src/modules/networking/epoxy.test.js new file mode 100644 index 0000000000..0f46732578 --- /dev/null +++ b/src/puter-js/src/modules/networking/epoxy.test.js @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { createPuterPasswordBuilder } from './epoxy.js'; + +// Stand-ins for the extension base classes the epoxy wasm bundle exports. +// Loading the real runtime needs a network fetch plus a wasm instantiation, so +// only the hand-packed byte layout of our subclasses is exercised here. +function makeRuntime () { + return { + JsProtocolExtension: class { + constructor (id, ...rest) { + this.id = id; + this.rest = rest; + } + }, + JsProtocolExtensionBuilder: class { + constructor (id) { + this.id = id; + } + }, + }; +} + +const PUTER_PASSWORD_EXT_ID = 0x02; + +const build = (token = 'wisp-token') => + createPuterPasswordBuilder(makeRuntime(), token); + +describe('puter password extension', () => { + it('registers under the puter password extension id', () => { + expect(build().id).toBe(PUTER_PASSWORD_EXT_ID); + expect(build().buildToExtension().id).toBe(PUTER_PASSWORD_EXT_ID); + }); + + // Wire format: u8 username length, u16-LE password length, then the + // username and password bytes. Puter sends an empty username and the + // relay token as the password. + it('packs an empty username and the token as the password', () => { + const encoded = build('abc').buildToExtension().encode(); + + expect(Array.from(encoded)).toEqual([ + 0, // username length + 3, 0, // password length, little-endian + 97, 98, 99, // "abc" + ]); + }); + + it('encodes the password length little-endian across the u16 boundary', () => { + const token = 'x'.repeat(300); + + const encoded = build(token).buildToExtension().encode(); + + expect(encoded).toHaveLength(3 + 300); + expect(encoded[0]).toBe(0); + // 300 == 0x012c, so the low byte leads. + expect(encoded[1]).toBe(0x2c); + expect(encoded[2]).toBe(0x01); + }); + + it('encodes a multi-byte token by its utf-8 length, not its character count', () => { + // 'é' is two bytes in utf-8, so a 2-character token is 4 bytes. + const encoded = build('éé').buildToExtension().encode(); + + expect(encoded[1]).toBe(4); + expect(Array.from(encoded.slice(3))).toEqual([195, 169, 195, 169]); + }); + + it('sends nothing for an extension parsed off the wire', () => { + // buildFromBytes has no payload to send; only buildToExtension does. + const parsed = build().buildFromBytes(new Uint8Array([1])); + + expect(parsed.encode()).toHaveLength(0); + }); + + it('marks the extension required when the peer flags it', () => { + expect(build().buildFromBytes(new Uint8Array([1])).required).toBe(true); + expect(build().buildFromBytes(new Uint8Array([2])).required).toBe(true); + expect(build().buildFromBytes(new Uint8Array([0])).required).toBe(false); + }); +}); diff --git a/src/puter-js/src/modules/networking/index.test.js b/src/puter-js/src/modules/networking/index.test.js new file mode 100644 index 0000000000..5a3ae265f1 --- /dev/null +++ b/src/puter-js/src/modules/networking/index.test.js @@ -0,0 +1,272 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Only the relay-token exchange and the client cache are under test, so the +// wasm bundle is stubbed out: initEpoxy just hands back a marker object. +const mockInitEpoxy = vi.fn(); +vi.mock('./epoxy.js', () => ({ + initEpoxy: (...args) => mockInitEpoxy(...args), +})); + +const { + clearEpoxyClientCache, + generateWispV1URL, + getEpoxyClient, + getWispCredentials, + netAPI, +} = await import('./index.js'); + +const CREDENTIALS = { token: 'wisp-token', server: 'wss://relay.test' }; + +function relayResponse ({ status = 200, body = CREDENTIALS } = {}) { + return { + ok: status >= 200 && status < 300, + status, + statusText: String(status), + json: async () => body, + }; +} + +// A promise whose settlement the test controls, for overlapping callers. +function deferred () { + let resolve; + const promise = new Promise(res => { + resolve = res; + }); + return { promise, resolve }; +} + +const origPuter = globalThis.puter; +const origFetch = globalThis.fetch; + +let mockFetch; + +beforeEach(() => { + clearEpoxyClientCache(); + + mockInitEpoxy.mockReset() + .mockImplementation(async () => ({ client: 'epoxy' })); + + mockFetch = vi.fn(async () => relayResponse()); + globalThis.fetch = mockFetch; + + globalThis.puter = { + APIOrigin: 'https://api.test', + authToken: 'tok', + // Production clears the stored token, which is what makes the code + // under test prompt for sign-in again on the retry. + resetAuthToken: vi.fn(() => { + globalThis.puter.authToken = null; + }), + ui: { authenticateWithPuter: vi.fn(async () => {}) }, + }; +}); + +afterEach(() => { + clearEpoxyClientCache(); + globalThis.puter = origPuter; + globalThis.fetch = origFetch; +}); + +describe('getWispCredentials', () => { + it('posts to the relay-token endpoint with the bearer token', async () => { + const credentials = await getWispCredentials(); + + expect(credentials).toEqual({ + wispToken: CREDENTIALS.token, + wispServer: CREDENTIALS.server, + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('https://api.test/wisp/relay-token/create'); + expect(init.method).toBe('POST'); + expect(init.headers.Authorization).toBe('Bearer tok'); + expect(init.headers['Content-Type']).toBe('application/json'); + }); + + it('throws when the puter runtime is not up yet', async () => { + globalThis.puter = undefined; + + await expect(getWispCredentials()).rejects.toThrow(/not initialized/); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('prompts for sign-in before requesting a token when there is none', async () => { + globalThis.puter.authToken = undefined; + + await getWispCredentials(); + + expect(globalThis.puter.ui.authenticateWithPuter).toHaveBeenCalledTimes(1); + }); + + it('sends the token the sign-in prompt just established', async () => { + globalThis.puter.authToken = undefined; + globalThis.puter.ui.authenticateWithPuter.mockImplementation(async () => { + globalThis.puter.authToken = 'fresh-tok'; + }); + + await getWispCredentials(); + + const [, init] = mockFetch.mock.calls[0]; + expect(init.headers.Authorization).toBe('Bearer fresh-tok'); + }); + + it('omits the auth header entirely when no token could be obtained', async () => { + globalThis.puter.authToken = undefined; + + await getWispCredentials(); + + const [, init] = mockFetch.mock.calls[0]; + expect(init.headers).not.toHaveProperty('Authorization'); + }); + + it('discards a rejected token, re-authenticates, and retries once', async () => { + mockFetch + .mockImplementationOnce(async () => relayResponse({ status: 401 })) + .mockImplementationOnce(async () => relayResponse()); + globalThis.puter.ui.authenticateWithPuter.mockImplementation(async () => { + globalThis.puter.authToken = 'fresh-tok'; + }); + + const credentials = await getWispCredentials(); + + expect(credentials.wispToken).toBe(CREDENTIALS.token); + expect(globalThis.puter.resetAuthToken).toHaveBeenCalledTimes(1); + expect(globalThis.puter.ui.authenticateWithPuter).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledTimes(2); + // The retry has to carry the newly minted token, not the rejected one. + const [, retryInit] = mockFetch.mock.calls[1]; + expect(retryInit.headers.Authorization).toBe('Bearer fresh-tok'); + }); + + // The retry passes retryAuth=false, so a second 401 must surface rather + // than recurse into an endless re-auth loop. + it('gives up after a second 401 instead of looping', async () => { + mockFetch.mockImplementation(async () => relayResponse({ status: 401 })); + + await expect(getWispCredentials()).rejects.toThrow(/HTTP 401/); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('reports the status when the endpoint fails outright', async () => { + mockFetch.mockImplementation(async () => relayResponse({ status: 500 })); + + await expect(getWispCredentials()).rejects.toThrow(/HTTP 500/); + // 500 is not a re-auth case, so there is no retry. + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['an empty body', {}], + ['a missing server', { token: 'only-token' }], + ['a missing token', { server: 'wss://relay.test' }], + ])('rejects %s from the relay-token endpoint', async (_label, body) => { + mockFetch.mockImplementation(async () => relayResponse({ body })); + + await expect(getWispCredentials()).rejects.toThrow(/invalid response/); + }); +}); + +describe('generateWispV1URL', () => { + it('joins the relay server and token into a wisp url', async () => { + await expect(generateWispV1URL()).resolves.toBe('wss://relay.test/wisp-token/'); + }); + + it('is reachable through the public net API', async () => { + await expect(netAPI.generateWispV1URL()).resolves.toBe('wss://relay.test/wisp-token/'); + }); +}); + +describe('getEpoxyClient', () => { + it('builds a client from freshly minted credentials', async () => { + const client = await getEpoxyClient(); + + expect(client).toEqual({ client: 'epoxy' }); + expect(mockInitEpoxy).toHaveBeenCalledWith({ + wispToken: CREDENTIALS.token, + wispServer: CREDENTIALS.server, + }); + }); + + it('reuses the cached client for the same origin and token', async () => { + const first = await getEpoxyClient(); + const second = await getEpoxyClient(); + + expect(second).toBe(first); + expect(mockInitEpoxy).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('rebuilds the client when the auth token changes', async () => { + await getEpoxyClient(); + globalThis.puter.authToken = 'different-tok'; + await getEpoxyClient(); + + expect(mockInitEpoxy).toHaveBeenCalledTimes(2); + }); + + it('rebuilds the client when the API origin changes', async () => { + await getEpoxyClient(); + globalThis.puter.APIOrigin = 'https://other.test'; + await getEpoxyClient(); + + expect(mockInitEpoxy).toHaveBeenCalledTimes(2); + }); + + it('rebuilds the client when a refresh is requested', async () => { + await getEpoxyClient(); + await getEpoxyClient({ refresh: true }); + + expect(mockInitEpoxy).toHaveBeenCalledTimes(2); + }); + + it('shares one in-flight init between concurrent callers', async () => { + const gate = deferred(); + mockInitEpoxy.mockImplementation(() => gate.promise); + + const both = Promise.all([getEpoxyClient(), getEpoxyClient()]); + gate.resolve({ client: 'epoxy' }); + const [first, second] = await both; + + expect(first).toBe(second); + expect(mockInitEpoxy).toHaveBeenCalledTimes(1); + }); + + it('drops the cache after clearEpoxyClientCache', async () => { + await getEpoxyClient(); + clearEpoxyClientCache(); + await getEpoxyClient(); + + expect(mockInitEpoxy).toHaveBeenCalledTimes(2); + }); + + // A failed init must not be cached, otherwise every later socket would keep + // resolving the same broken attempt. + it('does not cache a failed init, so the next caller retries', async () => { + mockInitEpoxy.mockRejectedValueOnce(new Error('wasm unavailable')); + + await getEpoxyClient(); + const retried = await getEpoxyClient(); + + expect(mockInitEpoxy).toHaveBeenCalledTimes(2); + expect(retried).toEqual({ client: 'epoxy' }); + }); + + // Credential failures are swallowed the same way an init failure is. + it('does not cache a failed credential fetch', async () => { + mockFetch.mockImplementationOnce(async () => relayResponse({ status: 500 })); + + await getEpoxyClient(); + const retried = await getEpoxyClient(); + + expect(retried).toEqual({ client: 'epoxy' }); + }); +}); + +describe('netAPI surface', () => { + it('exposes the socket constructors and fetch the docs promise', () => { + expect(typeof netAPI.Socket).toBe('function'); + expect(typeof netAPI.tls.TLSSocket).toBe('function'); + expect(typeof netAPI.fetch).toBe('function'); + expect(typeof netAPI.generateWispV1URL).toBe('function'); + }); +}); diff --git a/src/puter-js/src/modules/networking/requests.test.js b/src/puter-js/src/modules/networking/requests.test.js new file mode 100644 index 0000000000..111ea9ccbe --- /dev/null +++ b/src/puter-js/src/modules/networking/requests.test.js @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// pFetch is a thin wrapper over the epoxy client, so the client module is +// stubbed: the tests care about delegation, cache invalidation, and logging. +const mockGetEpoxyClient = vi.fn(); +const mockClearEpoxyClientCache = vi.fn(); +vi.mock('./index.js', () => ({ + getEpoxyClient: (...args) => mockGetEpoxyClient(...args), + clearEpoxyClientCache: (...args) => mockClearEpoxyClientCache(...args), +})); + +const { pFetch } = await import('./requests.js'); + +const origPuter = globalThis.puter; + +let mockClientFetch; +let logRequest; + +// Matches what the epoxy client resolves to: a Response-like object. +const RESPONSE = { status: 204, statusText: 'No Content' }; + +beforeEach(() => { + mockClearEpoxyClientCache.mockReset(); + mockClientFetch = vi.fn(async () => RESPONSE); + mockGetEpoxyClient.mockReset() + .mockResolvedValue({ fetch: (...args) => mockClientFetch(...args) }); + + logRequest = vi.fn(); + globalThis.puter = { + apiCallLogger: { isEnabled: () => true, logRequest }, + }; +}); + +afterEach(() => { + globalThis.puter = origPuter; +}); + +describe('pFetch delegation', () => { + it('passes its arguments straight through and returns the response', async () => { + const init = { method: 'POST', body: 'payload' }; + + const response = await pFetch('https://example.com/api', init); + + expect(response).toBe(RESPONSE); + expect(mockClientFetch).toHaveBeenCalledWith('https://example.com/api', init); + }); + + it('works with no init argument', async () => { + await pFetch('https://example.com/api'); + + expect(mockClientFetch).toHaveBeenCalledWith('https://example.com/api'); + }); + + it('rethrows a failed request', async () => { + mockClientFetch.mockRejectedValue(new Error('socket closed')); + + await expect(pFetch('https://example.com')).rejects.toThrow('socket closed'); + }); + + it('drops the cached client when the request fails', async () => { + mockClientFetch.mockRejectedValue(new Error('socket closed')); + + await expect(pFetch('https://example.com')).rejects.toThrow(); + + expect(mockClearEpoxyClientCache).toHaveBeenCalledTimes(1); + }); + + // Nothing was cached if the client itself never came up, so there is + // nothing to invalidate -- clearing here would just mask the real failure. + it('leaves the cache alone when the client could not be created', async () => { + mockGetEpoxyClient.mockRejectedValue(new Error('wasm unavailable')); + + await expect(pFetch('https://example.com')).rejects.toThrow('wasm unavailable'); + + expect(mockClearEpoxyClientCache).not.toHaveBeenCalled(); + }); +}); + +describe('pFetch api call logging', () => { + it('logs the response status on success', async () => { + await pFetch('https://example.com/api', { method: 'PUT' }); + + expect(logRequest).toHaveBeenCalledTimes(1); + expect(logRequest).toHaveBeenCalledWith(expect.objectContaining({ + service: 'network', + operation: 'pFetch', + params: { url: 'https://example.com/api', method: 'PUT' }, + result: { status: 204, statusText: 'No Content' }, + })); + }); + + it('logs the message and stack on failure', async () => { + const failure = new Error('socket closed'); + mockClientFetch.mockRejectedValue(failure); + + await expect(pFetch('https://example.com/api')).rejects.toThrow(); + + const [entry] = logRequest.mock.calls[0]; + expect(entry.error.message).toBe('socket closed'); + expect(entry.error.stack).toBe(failure.stack); + }); + + it('stringifies a non-Error rejection reason', async () => { + mockClientFetch.mockRejectedValue('just a string'); + + await expect(pFetch('https://example.com/api')).rejects.toBe('just a string'); + + const [entry] = logRequest.mock.calls[0]; + expect(entry.error.message).toBe('just a string'); + expect(entry.error.stack).toBeUndefined(); + }); + + it('stays quiet while logging is disabled', async () => { + globalThis.puter.apiCallLogger.isEnabled = () => false; + + await pFetch('https://example.com/api'); + + expect(logRequest).not.toHaveBeenCalled(); + }); + + it('does not require a puter runtime to be present', async () => { + globalThis.puter = undefined; + + await expect(pFetch('https://example.com/api')).resolves.toBe(RESPONSE); + }); + + describe('request description', () => { + it('reads a string url and defaults the method to GET', async () => { + await pFetch('https://example.com/plain'); + + const [entry] = logRequest.mock.calls[0]; + expect(entry.params).toEqual({ url: 'https://example.com/plain', method: 'GET' }); + }); + + it('serialises a URL instance', async () => { + await pFetch(new URL('https://example.com/from-url')); + + const [entry] = logRequest.mock.calls[0]; + expect(entry.params.url).toBe('https://example.com/from-url'); + }); + + it('reads url and method off a Request-like object', async () => { + await pFetch({ url: 'https://example.com/req', method: 'DELETE' }); + + const [entry] = logRequest.mock.calls[0]; + expect(entry.params).toEqual({ url: 'https://example.com/req', method: 'DELETE' }); + }); + + // An explicit init overrides the method carried by the request object. + it('prefers the init method over the request object method', async () => { + await pFetch({ url: 'https://example.com/req', method: 'DELETE' }, { method: 'PATCH' }); + + const [entry] = logRequest.mock.calls[0]; + expect(entry.params.method).toBe('PATCH'); + }); + + it('records an undefined url for an unrecognised resource', async () => { + await pFetch(42); + + const [entry] = logRequest.mock.calls[0]; + expect(entry.params).toEqual({ url: undefined, method: 'GET' }); + }); + }); +}); From 572cdf6437505407193ef2e827dc90be4eb8af8c Mon Sep 17 00:00:00 2001 From: Toshit Chawda Date: Wed, 29 Jul 2026 16:27:25 -0700 Subject: [PATCH 6/9] fix: report socket close flags and epoxy init failures accurately PSocket #readLoop raced the teardown it was being torn down by. A failed write sets #closing and hands the close event to #closeStreams, which has to await reader.cancel() and writer.close() before emitting close(true). That cancel resolves the loop's pending read with {done: true}, so the loop broke, reached its own #emitClose(false), and set #closed first -- callers were told the socket shut down cleanly right after an error. The loop now defers the close event whenever a teardown is under way, in both its normal and its throwing path, and #closeStreams always emits. getEpoxyClient swallowed every init failure and resolved undefined, so a dead relay reached callers as "cannot read properties of undefined (reading 'connect')" instead of its cause. It now re-throws, still without caching the failed attempt. A socket reports "wasm unavailable" or "Failed to create relay token (HTTP 503 ...)" and closes with the error flag set. Two related cache faults fell out of that rewrite: the in-flight early-return ignored `refresh`, handing a caller the very attempt it asked to replace, and it ignored the cache key, so an attempt started before a token change satisfied a request made after it. Reuse is now conditional on both. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/modules/networking/PSocket.js | 17 ++- .../src/modules/networking/PSocket.test.js | 98 +++++++++++++++-- src/puter-js/src/modules/networking/index.js | 44 ++++---- .../src/modules/networking/index.test.js | 101 +++++++++++++++++- 4 files changed, 220 insertions(+), 40 deletions(-) diff --git a/src/puter-js/src/modules/networking/PSocket.js b/src/puter-js/src/modules/networking/PSocket.js index e607ae2508..a015765264 100644 --- a/src/puter-js/src/modules/networking/PSocket.js +++ b/src/puter-js/src/modules/networking/PSocket.js @@ -220,14 +220,21 @@ export class PSocket extends EventListener { } } - this.#emitClose(false); + // A teardown already under way owns the close event. Cancelling the + // reader resolves the pending read with `done`, which lands here + // first; emitting now would report a clean shutdown and let + // #closeStreams' hadError flag lose the race. + if ( ! this.#closing ) { + this.#emitClose(false); + } } catch ( error ) { if ( this.#closing ) { - this.#emitClose(false); - } else { - clearEpoxyClientCache(); - this.#emitErrorAndClose(error); + // As above: #closeStreams is mid-flight and will emit close. + return; } + + clearEpoxyClientCache(); + this.#emitErrorAndClose(error); } finally { try { this.#reader.releaseLock(); diff --git a/src/puter-js/src/modules/networking/PSocket.test.js b/src/puter-js/src/modules/networking/PSocket.test.js index 11ed1c149b..cf4bc86cbb 100644 --- a/src/puter-js/src/modules/networking/PSocket.test.js +++ b/src/puter-js/src/modules/networking/PSocket.test.js @@ -50,6 +50,41 @@ function makeStream ({ failWrite } = {}) { }; } +// The epoxy client's streams come from wasm rather than being the platform's +// ReadableStream, so a cancel that rejects the pending read -- instead of +// resolving it `done` the way the spec requires -- is possible. This models +// that, which a real ReadableStream cannot be made to do. +function makeRejectingStream ({ failWrite } = {}) { + let rejectRead; + const reader = { + read: () => new Promise((resolve, reject) => { + rejectRead = reject; + }), + cancel: async () => { + rejectRead?.(new Error('stream torn down')); + }, + releaseLock: () => {}, + }; + + const written = []; + const writer = { + write: async chunk => { + if ( failWrite ) { + throw failWrite; + } + written.push(chunk); + }, + close: async () => {}, + releaseLock: () => {}, + }; + + return { + read: { getReader: () => reader }, + write: { getWriter: () => writer }, + written, + }; +} + function makeClient (stream) { return { connect: vi.fn(async () => stream), @@ -318,17 +353,11 @@ describe('PSocket write', () => { expect(mockClearEpoxyClientCache).toHaveBeenCalled(); }); - // KNOWN BUG -- unskip once #readLoop stops racing the error path. - // - // A failed write sets #closing and hands the close event to #closeStreams, - // which must await reader.cancel() and writer.close() before emitting - // close(true). That cancel resolves #readLoop's pending read() with - // {done: true}, so the loop breaks and reaches its own #emitClose(false) - // first -- #closed is already set by the time #closeStreams gets there, so - // callers are told the socket shut down cleanly after an error. - // A read failure is unaffected: that path throws into #readLoop's catch, - // which never calls #emitClose(false). - it.skip('closes with the error flag set when a write fails', async () => { + // Regression guard: a failed write hands the close event to #closeStreams, + // whose reader.cancel() resolves #readLoop's pending read with {done: true}. + // The loop must not treat that as a clean shutdown and emit close(false) + // before #closeStreams reports the error. + it('closes with the error flag set when a write fails', async () => { const stream = makeStream({ failWrite: new Error('write failed') }); const { socket, events } = await connected(stream); @@ -337,6 +366,25 @@ describe('PSocket write', () => { expect(events.close).toHaveBeenCalledWith(true); }); + + // Same guarantee when the teardown's cancel rejects the in-flight read + // rather than ending it cleanly: the flag must still come from the path + // that knows an error happened. + it('keeps the error flag when cancelling rejects the pending read', async () => { + const stream = makeRejectingStream({ failWrite: new Error('write failed') }); + mockGetEpoxyClient.mockResolvedValue(makeClient(stream)); + + const socket = new PSocket('example.com', 80); + const events = listen(socket); + await until(() => expect(events.open).toHaveBeenCalled()); + + socket.write('doomed'); + await until(() => expect(events.close).toHaveBeenCalled()); + + expect(events.error.mock.calls[0][0].message).toBe('write failed'); + expect(events.close).toHaveBeenCalledTimes(1); + expect(events.close).toHaveBeenCalledWith(true); + }); }); describe('PSocket close', () => { @@ -372,6 +420,34 @@ describe('PSocket close', () => { expect(events.open).not.toHaveBeenCalled(); }); + // #readLoop now defers the close event to #closeStreams whenever a teardown + // is under way, so #closeStreams has to emit it even when cancelling the + // reader fails -- otherwise close would go missing entirely. + it('still emits close when cancelling the reader fails', async () => { + let readController; + const read = new ReadableStream({ + start (controller) { + readController = controller; + }, + cancel () { + throw new Error('cancel failed'); + }, + }); + void readController; + const write = new WritableStream({ write () {} }); + mockGetEpoxyClient.mockResolvedValue(makeClient({ read, write })); + + const socket = new PSocket('example.com', 80); + const events = listen(socket); + await until(() => expect(events.open).toHaveBeenCalled()); + + socket.close(); + await until(() => expect(events.close).toHaveBeenCalled()); + + expect(events.close).toHaveBeenCalledTimes(1); + expect(events.close).toHaveBeenCalledWith(false); + }); + it('stops emitting data after close', async () => { const { socket, events, stream } = await connected(); diff --git a/src/puter-js/src/modules/networking/index.js b/src/puter-js/src/modules/networking/index.js index 8a0bca94c2..b369c414f9 100644 --- a/src/puter-js/src/modules/networking/index.js +++ b/src/puter-js/src/modules/networking/index.js @@ -77,29 +77,35 @@ export async function generateWispV1URL () { } export async function getEpoxyClient ({ refresh = false } = {}) { - if ( cachedEpoxy && cachedEpoxy.initting ) return await cachedEpoxy.promise; - const nextKey = getClientCacheKey(); - if ( refresh || !(cachedEpoxy && cachedEpoxy.key === nextKey) ) { - let epoxy = { key: nextKey, initting: true }; - let promise = (async () => { - try { - const { wispToken, wispServer } = await getWispCredentials(); - let ret = await initEpoxy({ wispToken, wispServer }); - epoxy.initting = false; - return ret; - } catch { - if ( cachedEpoxy === epoxy ) { - cachedEpoxy = undefined; - } - } - })(); - epoxy.promise = promise; - cachedEpoxy = epoxy; + // Concurrent callers share one attempt, but only while it is still the + // attempt they asked for: a `refresh` exists to replace the cached entry, + // and a changed origin or token needs a client of its own. + if ( ! refresh && cachedEpoxy && cachedEpoxy.key === nextKey ) { + return await cachedEpoxy.promise; } - return await cachedEpoxy.promise; + const epoxy = { key: nextKey }; + epoxy.promise = (async () => { + try { + const { wispToken, wispServer } = await getWispCredentials(); + return await initEpoxy({ wispToken, wispServer }); + } catch ( error ) { + // Never cache a failed attempt, or every later caller would keep + // resolving the same broken client. The reason is re-thrown so + // callers report why the relay is unreachable instead of tripping + // over an undefined client. + if ( cachedEpoxy === epoxy ) { + cachedEpoxy = undefined; + } + throw error; + } + })(); + + cachedEpoxy = epoxy; + + return await epoxy.promise; } export function clearEpoxyClientCache () { diff --git a/src/puter-js/src/modules/networking/index.test.js b/src/puter-js/src/modules/networking/index.test.js index 5a3ae265f1..c0d275ee75 100644 --- a/src/puter-js/src/modules/networking/index.test.js +++ b/src/puter-js/src/modules/networking/index.test.js @@ -239,27 +239,78 @@ describe('getEpoxyClient', () => { expect(mockInitEpoxy).toHaveBeenCalledTimes(2); }); - // A failed init must not be cached, otherwise every later socket would keep - // resolving the same broken attempt. + // Swallowing the reason here used to hand callers an undefined client, so + // a dead relay surfaced as "cannot read properties of undefined" instead. + it('surfaces why the client could not be built', async () => { + mockInitEpoxy.mockRejectedValue(new Error('wasm unavailable')); + + await expect(getEpoxyClient()).rejects.toThrow('wasm unavailable'); + }); + + it('surfaces a credential failure the same way', async () => { + mockFetch.mockImplementation(async () => relayResponse({ status: 500 })); + + await expect(getEpoxyClient()).rejects.toThrow(/HTTP 500/); + }); + + it('rejects every caller waiting on a failed attempt', async () => { + mockInitEpoxy.mockRejectedValue(new Error('wasm unavailable')); + + const results = await Promise.allSettled([getEpoxyClient(), getEpoxyClient()]); + + expect(results.map(r => r.status)).toEqual(['rejected', 'rejected']); + expect(mockInitEpoxy).toHaveBeenCalledTimes(1); + }); + + // A failed attempt must not be cached, otherwise every later socket would + // keep resolving the same broken client. it('does not cache a failed init, so the next caller retries', async () => { mockInitEpoxy.mockRejectedValueOnce(new Error('wasm unavailable')); - await getEpoxyClient(); + await expect(getEpoxyClient()).rejects.toThrow('wasm unavailable'); const retried = await getEpoxyClient(); expect(mockInitEpoxy).toHaveBeenCalledTimes(2); expect(retried).toEqual({ client: 'epoxy' }); }); - // Credential failures are swallowed the same way an init failure is. it('does not cache a failed credential fetch', async () => { mockFetch.mockImplementationOnce(async () => relayResponse({ status: 500 })); - await getEpoxyClient(); + await expect(getEpoxyClient()).rejects.toThrow(/HTTP 500/); const retried = await getEpoxyClient(); expect(retried).toEqual({ client: 'epoxy' }); }); + + // A refresh exists to replace what is cached, so it must not be answered + // with the very attempt the caller is trying to supersede. + it('honours a refresh requested while an init is still in flight', async () => { + const gate = deferred(); + mockInitEpoxy.mockImplementationOnce(() => gate.promise); + + const stale = getEpoxyClient(); + const fresh = getEpoxyClient({ refresh: true }); + gate.resolve({ client: 'stale' }); + + expect(await stale).toEqual({ client: 'stale' }); + expect(await fresh).toEqual({ client: 'epoxy' }); + expect(mockInitEpoxy).toHaveBeenCalledTimes(2); + }); + + it('starts a separate attempt when the token changes mid-init', async () => { + const gate = deferred(); + mockInitEpoxy.mockImplementationOnce(() => gate.promise); + + const first = getEpoxyClient(); + globalThis.puter.authToken = 'different-tok'; + const second = getEpoxyClient(); + gate.resolve({ client: 'first' }); + + expect(await first).toEqual({ client: 'first' }); + expect(await second).toEqual({ client: 'epoxy' }); + expect(mockInitEpoxy).toHaveBeenCalledTimes(2); + }); }); describe('netAPI surface', () => { @@ -270,3 +321,43 @@ describe('netAPI surface', () => { expect(typeof netAPI.generateWispV1URL).toBe('function'); }); }); + +// The PSocket unit tests stub this module out, so nothing there would notice if +// the two drifted apart. These drive the real socket against the real client +// cache -- only the wasm bundle and the token endpoint are stubbed -- and pin +// the property that matters: a caller learns why the relay is unreachable. +describe('failure reporting through a real socket', () => { + const listen = () => { + const socket = new netAPI.Socket('example.com', 80); + const events = { error: vi.fn(), close: vi.fn() }; + socket.on('error', events.error); + socket.on('close', events.close); + return events; + }; + + const closed = events => + vi.waitFor(() => expect(events.close).toHaveBeenCalled(), { interval: 1 }); + + it('reports why the epoxy client could not be built', async () => { + mockInitEpoxy.mockRejectedValue(new Error('wasm unavailable')); + + const events = listen(); + await closed(events); + + expect(events.error).toHaveBeenCalledTimes(1); + const [reason] = events.error.mock.calls[0]; + expect(reason).toBeInstanceOf(Error); + expect(reason.message).toBe('wasm unavailable'); + expect(events.close).toHaveBeenCalledWith(true); + }); + + it('reports why the relay refused to mint a token', async () => { + mockFetch.mockImplementation(async () => relayResponse({ status: 503 })); + + const events = listen(); + await closed(events); + + expect(events.error.mock.calls[0][0].message).toMatch(/HTTP 503/); + expect(events.close).toHaveBeenCalledWith(true); + }); +}); From cd26ba30c059df104742e2d61fb2c5f8bc7392d2 Mon Sep 17 00:00:00 2001 From: Toshit Chawda Date: Mon, 10 Aug 2026 16:13:25 -0700 Subject: [PATCH 7/9] restore the public API surface dropped in the epoxy revamp The epoxy rewrite kept the shape of the old networking client but lost three things callers depend on: - JSDoc on the exposed methods. Re-added on PSocket (constructor, on, addListener, write, close), PTLSSocket and pFetch, along with the SocketEvent typedef and a Networking type on netAPI. `on` carries the TLS event aliasing docs that used to live on PTLSSocket, since that behaviour moved here. - socket.write's error message, which lost its trailing '!!'. Apps match on the message, so it is restored verbatim. - pFetch's client-side validation. Epoxy reports a bad request as an opaque wasm value from deep inside the transport, so the scheme check, the Content-Length/body check and the malformed-request TypeError are made before a relay is dialled, rejecting with exactly what they rejected with before. Validation builds its Request from a clone, or it would consume the body epoxy is about to send. The latter two are what the net API suite asserts on node, browser and workerd, and it has been failing on all three since the revamp. addListener takes named parameters so it can be typed, and three unit tests that pinned pFetch's habit of forwarding unvalidatable input were updated to the documented behaviour. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/modules/networking/PSocket.js | 54 +++++++++++-- src/puter-js/src/modules/networking/index.js | 11 +++ .../src/modules/networking/requests.js | 45 +++++++++++ .../src/modules/networking/requests.test.js | 81 ++++++++++++++++++- 4 files changed, 179 insertions(+), 12 deletions(-) diff --git a/src/puter-js/src/modules/networking/PSocket.js b/src/puter-js/src/modules/networking/PSocket.js index a015765264..ce29bec43b 100644 --- a/src/puter-js/src/modules/networking/PSocket.js +++ b/src/puter-js/src/modules/networking/PSocket.js @@ -16,7 +16,9 @@ function normalizeWriteData (data) { return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); } - throw new Error('Invalid data type (not TypedArray, ArrayBuffer or String).'); + // Verbatim from the pre-epoxy socket: apps match on this message, so the + // trailing '!!' stays. + throw new Error('Invalid data type (not TypedArray, ArrayBuffer or String!!)'); } function normalizeError (reason) { @@ -47,7 +49,8 @@ function normalizeError (reason) { /** * A raw TCP socket in the browser, tunnelled over the Wisp relay. Construct it * with `puter.net.Socket(hostname, port)`; the connection is established - * asynchronously, so write once `'open'` has fired. + * asynchronously, so writes issued before `'open'` fires are queued and sent + * once the stream is up. * * @extends {EventListener} */ @@ -64,6 +67,12 @@ export class PSocket extends EventListener { #closed = false; #pendingWrites = []; + /** + * @param {string} host hostname or IP address of the server to connect to + * @param {number} port port to connect to on that server + * @param {{ tls?: boolean }} [options] `tls: true` wraps the connection in + * TLS and switches to the `tls`-prefixed events; {@link PTLSSocket} sets it + */ constructor (host, port, options = {}) { super(['data', 'drain', 'open', 'error', 'close', 'tlsdata', 'tlsopen', 'tlsclose']); @@ -94,16 +103,27 @@ export class PSocket extends EventListener { } /** - * Registers a handler for a socket event, the same as `on`. + * Registers a handler for a socket event, the same as {@link PSocket#on}. * * @template {SocketEvent} K - * @param {[event: K, handler: (data: PSocketEventMap[K]) => void]} args - * @returns {void} + * @param {K} event + * @param {(data: PSocketEventMap[K]) => void} callback + * @returns {this | undefined} */ - addListener (...args) { - return this.on(...args); + addListener (event, callback) { + return this.on(event, callback); } + /** + * Writes data to the socket, invoking `callback` once it has been handed to + * the relay. Data written before the socket is open is queued. Throws if + * `data` is not a string, `ArrayBuffer`, or typed array, or if the socket + * has already closed. + * + * @param {ArrayBuffer | ArrayBufferView | string} data + * @param {() => void} [callback] + * @returns {void} + */ write (data, callback) { const payload = normalizeWriteData(data); @@ -119,6 +139,11 @@ export class PSocket extends EventListener { void this.#writePayload(payload, callback); } + /** + * Closes the TCP connection. + * + * @returns {void} + */ close () { if ( this.#closing || this.#closed ) { return; @@ -195,7 +220,11 @@ export class PSocket extends EventListener { try { await this.#writer.write(payload); if ( callback ) { - callback(); + try { + callback(); + } catch ( callbackError ) { + setTimeout(() => { throw callbackError; }, 0); + } } } catch ( error ) { clearEpoxyClientCache(); @@ -308,7 +337,16 @@ export class PSocket extends EventListener { } } +/** + * A TLS-protected TCP socket in the browser. Same interface as {@link PSocket}, + * but the connection is encrypted and its events are `'tls'`-prefixed. + * Construct it with `puter.net.tls.TLSSocket(hostname, port)`. + */ export class PTLSSocket extends PSocket { + /** + * @param {string} host hostname or IP address of the server to connect to + * @param {number} port port to connect to on that server + */ constructor (host, port) { super(host, port, { tls: true }); } diff --git a/src/puter-js/src/modules/networking/index.js b/src/puter-js/src/modules/networking/index.js index b369c414f9..05327e0a40 100644 --- a/src/puter-js/src/modules/networking/index.js +++ b/src/puter-js/src/modules/networking/index.js @@ -112,7 +112,18 @@ export function clearEpoxyClientCache () { cachedEpoxy = undefined; } +/** + * The `puter.net` module. + * + * @type {import('./types.js').Networking} + */ export let netAPI = { + /** + * Mints a relay URL (server + single-use token) for speaking the Wisp v1 + * protocol directly, which is what the sockets below do for you. + * + * @returns {Promise} + */ async generateWispV1URL () { return await generateWispV1URL(); }, diff --git a/src/puter-js/src/modules/networking/requests.js b/src/puter-js/src/modules/networking/requests.js index df859a48c0..8542d84d68 100644 --- a/src/puter-js/src/modules/networking/requests.js +++ b/src/puter-js/src/modules/networking/requests.js @@ -49,11 +49,56 @@ function getFetchLogParams (args) { }; } +/** + * Rejects a request that cannot be made, before a relay connection is dialled. + * Epoxy reports a bad request as an opaque wasm value from deep inside the + * transport, so the checks the pre-epoxy client made client-side are still made + * here, and reject with what they always rejected with: a `TypeError` from + * `new Request` for a malformed request, and these two strings otherwise. + * + * @param {[input: RequestInfo | URL, init?: RequestInit]} args + * @returns {Promise} + */ +async function assertRequestIsFetchable (args) { + const [input, init] = args; + + // Building a Request from a Request disturbs the original's body, and the + // original is what gets handed to epoxy -- so validate against a clone. + const request = new Request( + input instanceof Request ? input.clone() : input, + init, + ); + + const { protocol } = new URL(request.url); + if ( protocol !== 'http:' && protocol !== 'https:' ) { + throw `Failed to fetch. URL scheme "${protocol}" is not supported.`; + } + + const declaredLength = request.headers.get('content-length'); + if ( declaredLength === null || ! request.body ) { + return; + } + + const { byteLength } = await request.clone().arrayBuffer(); + if ( declaredLength !== String(byteLength) ) { + throw 'Content-Length header does not match the body length. Please check your request.'; + } +} + +/** + * `puter.net.fetch`: fetches an http/https resource over a raw socket rather + * than the browser's HTTP stack, so it is not subject to CORS. Takes the same + * arguments as `fetch` and resolves to a `Response`. + * + * @type {(input: RequestInfo | URL, init?: RequestInit) => Promise} + */ export async function pFetch (...args) { const params = getFetchLogParams(args); let usedEpoxyClient = false; try { + await assertRequestIsFetchable(args); + const client = await getEpoxyClient(); usedEpoxyClient = true; const response = await client.fetch(...args); diff --git a/src/puter-js/src/modules/networking/requests.test.js b/src/puter-js/src/modules/networking/requests.test.js index 111ea9ccbe..e2fc60598c 100644 --- a/src/puter-js/src/modules/networking/requests.test.js +++ b/src/puter-js/src/modules/networking/requests.test.js @@ -139,8 +139,8 @@ describe('pFetch api call logging', () => { expect(entry.params.url).toBe('https://example.com/from-url'); }); - it('reads url and method off a Request-like object', async () => { - await pFetch({ url: 'https://example.com/req', method: 'DELETE' }); + it('reads url and method off a Request object', async () => { + await pFetch(new Request('https://example.com/req', { method: 'DELETE' })); const [entry] = logRequest.mock.calls[0]; expect(entry.params).toEqual({ url: 'https://example.com/req', method: 'DELETE' }); @@ -148,17 +148,90 @@ describe('pFetch api call logging', () => { // An explicit init overrides the method carried by the request object. it('prefers the init method over the request object method', async () => { - await pFetch({ url: 'https://example.com/req', method: 'DELETE' }, { method: 'PATCH' }); + await pFetch( + new Request('https://example.com/req', { method: 'DELETE' }), + { method: 'PATCH' }, + ); const [entry] = logRequest.mock.calls[0]; expect(entry.params.method).toBe('PATCH'); }); it('records an undefined url for an unrecognised resource', async () => { - await pFetch(42); + await expect(pFetch(42)).rejects.toThrow(TypeError); const [entry] = logRequest.mock.calls[0]; expect(entry.params).toEqual({ url: undefined, method: 'GET' }); }); }); }); + +// The pre-epoxy client made these checks itself; epoxy reports the same +// failures as opaque wasm values, so they stay client-side and keep rejecting +// with exactly what they used to. +describe('pFetch request validation', () => { + it('refuses a URL scheme it cannot tunnel without dialing', async () => { + await expect(pFetch('ftp://example.com/file.txt')).rejects.toBe( + 'Failed to fetch. URL scheme "ftp:" is not supported.', + ); + + expect(mockGetEpoxyClient).not.toHaveBeenCalled(); + expect(mockClientFetch).not.toHaveBeenCalled(); + }); + + it('surfaces a malformed request as a TypeError without dialing', async () => { + await expect(pFetch('http://example.com/', { + method: 'GET', + body: 'not allowed on a GET', + })).rejects.toThrow(TypeError); + + expect(mockGetEpoxyClient).not.toHaveBeenCalled(); + }); + + it('rejects a Content-Length that disagrees with the body', async () => { + await expect(pFetch('http://example.com/', { + method: 'POST', + headers: { 'content-length': '999' }, + body: 'short', + })).rejects.toBe( + 'Content-Length header does not match the body length. Please check your request.', + ); + + expect(mockGetEpoxyClient).not.toHaveBeenCalled(); + }); + + it('accepts a Content-Length that matches the body', async () => { + await pFetch('http://example.com/', { + method: 'POST', + headers: { 'content-length': '5' }, + body: 'short', + }); + + expect(mockClientFetch).toHaveBeenCalledTimes(1); + }); + + // Validation builds a Request of its own; doing that from the caller's + // Request would consume the body epoxy is about to send. + it('leaves the body of a caller-supplied Request intact', async () => { + const request = new Request('https://example.com/api', { + method: 'POST', + body: 'payload', + }); + + await pFetch(request); + + expect(mockClientFetch).toHaveBeenCalledWith(request); + expect(request.bodyUsed).toBe(false); + await expect(request.text()).resolves.toBe('payload'); + }); + + it('logs a validation failure without clearing the client cache', async () => { + await expect(pFetch('ftp://example.com/file.txt')).rejects.toBeTruthy(); + + const [entry] = logRequest.mock.calls[0]; + expect(entry.error.message).toBe( + 'Failed to fetch. URL scheme "ftp:" is not supported.', + ); + expect(mockClearEpoxyClientCache).not.toHaveBeenCalled(); + }); +}); From f5898cd7a3f2186874a3377f14a426f5b2f42556 Mon Sep 17 00:00:00 2001 From: Toshit Chawda Date: Mon, 10 Aug 2026 16:13:40 -0700 Subject: [PATCH 8/9] update epoxy client to 04e4930 connectionPrefs changed shape: the handshake and its required extension list are one object now rather than a tuple, and handing the new build the old form throws part way through the handshake. The bump is not optional. The previous build cannot reach the current relay at all: it enforces the required puter password extension even when a relay answers with a v1 CONTINUE, and fails with "Protocol extensions not supported" -- which is what puter.cafe answers with today. Verified against a local relay: fetch, POST bodies, https, raw sockets and TLS sockets, the last exercising connectTls, whose third parameter became an options object in this build. Co-Authored-By: Claude Opus 5 (1M context) --- src/puter-js/src/modules/networking/epoxy.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/puter-js/src/modules/networking/epoxy.js b/src/puter-js/src/modules/networking/epoxy.js index bb9897d81f..17b8461e00 100644 --- a/src/puter-js/src/modules/networking/epoxy.js +++ b/src/puter-js/src/modules/networking/epoxy.js @@ -1,4 +1,4 @@ -let EPOXY_BASE = 'https://puter-net.b-cdn.net/epoxy/7fbb05b'; +let EPOXY_BASE = 'https://puter-net.b-cdn.net/epoxy/04e4930'; let epoxyRuntimePromise; const textEncoder = new TextEncoder(); @@ -82,10 +82,10 @@ export let initEpoxy = async ({ wispToken, wispServer }) => { const provider = new runtime.WispSocketProvider( new runtime.WebSocketJsProvider(), wispServer, - () => [ - { builders: [createPuterPasswordBuilder(runtime, wispToken)] }, - [0x02], - ], + () => ({ + builders: [createPuterPasswordBuilder(runtime, wispToken)], + requiredExts: [0x02], + }), ); return new runtime.EpoxyClient(provider); From ff002976d836ab413b81a9e73273904884a21a99 Mon Sep 17 00:00:00 2001 From: Toshit Chawda Date: Wed, 26 Aug 2026 16:36:28 -0700 Subject: [PATCH 9/9] bump epoxy --- src/puter-js/src/modules/networking/epoxy.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/puter-js/src/modules/networking/epoxy.js b/src/puter-js/src/modules/networking/epoxy.js index 17b8461e00..01d73cd8de 100644 --- a/src/puter-js/src/modules/networking/epoxy.js +++ b/src/puter-js/src/modules/networking/epoxy.js @@ -1,4 +1,4 @@ -let EPOXY_BASE = 'https://puter-net.b-cdn.net/epoxy/04e4930'; +let EPOXY_BASE = 'https://puter-net.b-cdn.net/epoxy/43ed248'; let epoxyRuntimePromise; const textEncoder = new TextEncoder();