From d03363f2f1e89761998f901929dd1d49e301e224 Mon Sep 17 00:00:00 2001 From: Neal Shah Date: Tue, 21 Jul 2026 18:02:38 -0400 Subject: [PATCH] initial pjs helper stuff --- src/puter-js/src/index.js | 13 +- src/puter-js/src/lib/PuterClient.js | 280 ++++++++++++++++++ src/puter-js/src/lib/PuterClient.test.js | 222 ++++++++++++++ src/puter-js/src/lib/utils.js | 113 ++++--- src/puter-js/src/modules/AI.js | 7 +- src/puter-js/src/modules/Apps.js | 9 +- src/puter-js/src/modules/Auth.js | 163 ++-------- src/puter-js/src/modules/Drivers.js | 78 ++--- src/puter-js/src/modules/FileSystem/Batch.js | 14 +- .../modules/FileSystem/operations/upload.js | 4 +- src/puter-js/src/modules/KV.js | 3 +- src/puter-js/src/modules/Peer.js | 8 +- src/puter-js/src/modules/Perms.js | 6 +- src/puter-js/src/modules/Workers.js | 3 + .../src/modules/networking/PSocket.js | 5 +- 15 files changed, 649 insertions(+), 279 deletions(-) create mode 100644 src/puter-js/src/lib/PuterClient.js create mode 100644 src/puter-js/src/lib/PuterClient.test.js diff --git a/src/puter-js/src/index.js b/src/puter-js/src/index.js index 921048ae6..b3d05f5e5 100644 --- a/src/puter-js/src/index.js +++ b/src/puter-js/src/index.js @@ -1,5 +1,6 @@ import kvjs from '@heyputer/kv.js'; import APICallLogger from './lib/APICallLogger.js'; +import { fetchUrl } from './lib/PuterClient.js'; import { isStoredTokenUsableForOrigin } from './lib/authTokenOrigin.js'; import path from './lib/path.js'; import localStorageMemory from './lib/polyfills/localStorage.js'; @@ -666,12 +667,12 @@ const puterInit = function () { this.net = { generateWispV1URL: async () => { const { token: wispToken, server: wispServer } = await ( - await fetch( + await fetchUrl( `${this.APIOrigin}/wisp/relay-token/create`, { method: 'POST', + includePuterAuth: true, headers: { - Authorization: `Bearer ${this.authToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({}), @@ -717,10 +718,10 @@ const puterInit = function () { let had_error = false; try { - const resp = await fetch(`${this.APIOrigin}/rao`, { + const resp = await fetchUrl(`${this.APIOrigin}/rao`, { method: 'POST', + includePuterAuth: true, headers: { - Authorization: `Bearer ${this.authToken}`, Origin: location.origin, // This is ignored in the browser but needed for workers and nodejs }, }); @@ -1064,7 +1065,7 @@ const puterInit = function () { // everywhere else the JSON token response is all we need. const sameOrigin = globalThis.location?.origin === this.defaultGUIOrigin; - const resp = await fetch( + const resp = await fetchUrl( `${this.defaultGUIOrigin}/auth/migrate-token`, { method: 'POST', @@ -1072,7 +1073,7 @@ const puterInit = function () { Authorization: `Bearer ${v1Token}`, 'Content-Type': 'application/json', }, - credentials: sameOrigin ? 'include' : 'omit', + withCredentials: sameOrigin, body: JSON.stringify({}), }, ); diff --git a/src/puter-js/src/lib/PuterClient.js b/src/puter-js/src/lib/PuterClient.js new file mode 100644 index 000000000..5367ad494 --- /dev/null +++ b/src/puter-js/src/lib/PuterClient.js @@ -0,0 +1,280 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { createDeferred, resolveReauth } from './utils.js'; + +/** + * The single HTTP core for puter.js. `fetchUrl` is an XHR-based replacement for + * `fetch()` — every request that used to call `fetch()` directly routes through + * here so auth headers, streaming, API-call logging, and 401 reauth-replay live + * in one place. XHR (not `fetch`) because `fetch` is inconsistent across the + * platforms puter.js supports (browser / web-worker / service-worker / node) + * and we ship a strong in-house XHR polyfill (`lib/polyfills/xhrshim.js`), so + * `new XMLHttpRequest()` resolves to native XHR or the shim transparently. + * + * The interface is frozen: additive changes only. Retry, dedup, and pagination + * options are reserved below and land in later sprint steps. + */ + +/** + * @typedef {Object} PuterResponse + * A `fetch`-Response-like view over a completed (or streaming) XHR. + * @property {boolean} ok - status in the 200-299 range. + * @property {number} status + * @property {string} statusText + * @property {string} url - final response URL. + * @property {{ get(name: string): (string|null) }} headers + * @property {() => Promise} json + * @property {() => Promise} text + * @property {() => Promise} blob + * @property {() => Promise} arrayBuffer + * @property {() => AsyncGenerator} stream - parsed NDJSON lines; only + * meaningful for `application/x-ndjson` responses. + */ + +const isNdjson = contentType => (contentType || '').includes('application/x-ndjson'); + +/** Read the XHR body as text regardless of the responseType it was sent with. */ +async function bodyText (xhr) { + switch ( xhr.responseType ) { + case 'blob': return await xhr.response.text(); + case 'arraybuffer': return new TextDecoder().decode(xhr.response); + case 'json': return JSON.stringify(xhr.response); + default: return xhr.responseText; // '' | 'text' + } +} + +async function bodyBlob (xhr) { + if ( xhr.responseType === 'blob' ) return xhr.response; + const type = xhr.getResponseHeader('content-type') || 'application/octet-stream'; + if ( xhr.responseType === 'arraybuffer' ) return new Blob([xhr.response], { type }); + return new Blob([await bodyText(xhr)], { type }); +} + +async function bodyArrayBuffer (xhr) { + if ( xhr.responseType === 'arraybuffer' ) return xhr.response; + return await (await bodyBlob(xhr)).arrayBuffer(); +} + +async function bodyJson (xhr) { + if ( xhr.responseType === 'json' ) return xhr.response; + return JSON.parse(await bodyText(xhr)); +} + +function makeResponse (xhr, stream) { + const status = xhr.status; + return { + ok: status >= 200 && status < 300, + status, + statusText: xhr.statusText, + url: xhr.responseURL || '', + headers: { get: name => xhr.getResponseHeader(name) }, + text: () => bodyText(xhr), + json: () => bodyJson(xhr), + blob: () => bodyBlob(xhr), + arrayBuffer: () => bodyArrayBuffer(xhr), + stream: () => { + if ( ! stream ) { + throw new Error('stream() is only available for application/x-ndjson responses'); + } + return stream; + }, + }; +} + +function logRequest (logId, { result = null, error = null } = {}) { + if ( ! logId || ! globalThis.puter?.apiCallLogger?.isEnabled() ) return; + globalThis.puter.apiCallLogger.logRequest({ ...logId, result, error }); +} + +/** Best-effort body for logging — parsed JSON where sensible, else a placeholder. */ +async function bodyForLog (xhr) { + const contentType = xhr.getResponseHeader('content-type') || ''; + if ( xhr.responseType === '' || xhr.responseType === 'text' || contentType.includes('json') ) { + try { return await bodyJson(xhr); } catch ( e ) { + try { return await bodyText(xhr); } catch ( e2 ) { return null; } + } + } + return `[${contentType || 'binary'}]`; +} + +/** + * XHR-based `fetch()` replacement. Returns a `fetch`-Response-like object. + * + * fetch semantics: the promise resolves for any HTTP status (`ok` reflects + * 2xx); it rejects only on network/abort errors. The one exception is a 401 + * carrying a reauth signal — the reauth flow is driven first and, on success, + * the request is replayed once with the fresh token (transparent recovery). A + * non-recoverable 401 resolves as an `ok: false` response like any other. + * + * @param {string} url - Full request URL (callers own origin composition). + * @param {Object} [opts] + * @param {boolean} [opts.includePuterAuth=false] - Add `Authorization: Bearer `. + * @param {string} [opts.method='GET'] + * @param {Object} [opts.headers] - Extra request headers (undefined/null values skipped). + * @param {string|Blob|ArrayBuffer|FormData|null} [opts.body] + * @param {''|'text'|'json'|'blob'|'arraybuffer'} [opts.responseType=''] + * @param {boolean} [opts.withCredentials=true] + * @param {AbortSignal} [opts.signal] + * @param {{service: string, operation: string, params?: Object}} [opts.logContext] + * Semantic context for the centralized API-call log. Omit to log generically. + * @param {Object} [opts.retry] - Reserved for a later sprint step (ignored). + * @param {Object} [opts.dedupe] - Reserved for a later sprint step (ignored). + * @param {Object} [opts.paginate] - Reserved for a later sprint step (ignored). + * @returns {Promise} + */ +function fetchUrl (url, opts = {}) { + const { + includePuterAuth = false, + method = 'GET', + headers = {}, + body = null, + responseType = '', + withCredentials = true, + signal, + logContext, + _reauthReplayed = false, // internal one-shot guard for reauth replay + } = opts; + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open(method, url, true); + xhr.withCredentials = withCredentials; + // Text mode keeps NDJSON progress deltas available for stream(); callers + // that need binary/parsed bodies pass an explicit responseType. + xhr.responseType = responseType; + + if ( includePuterAuth && globalThis.puter?.authToken ) { + xhr.setRequestHeader('Authorization', `Bearer ${globalThis.puter.authToken}`); + } + for ( const [ name, value ] of Object.entries(headers) ) { + if ( value !== undefined && value !== null ) { + xhr.setRequestHeader(name, value); + } + } + + if ( signal ) { + if ( signal.aborted ) return reject(signal.reason ?? new DOMException('Aborted', 'AbortError')); + signal.addEventListener('abort', () => xhr.abort(), { once: true }); + } + + let logId = null; + if ( globalThis.puter?.apiCallLogger?.isEnabled() ) { + logId = logContext ?? { service: 'fetchUrl', operation: `${method} ${url}`, params: { url, method } }; + } + + // -- NDJSON streaming -- + // Detect a stream at HEADERS_RECEIVED and resolve early with a response + // whose stream() yields parsed lines as onprogress deltas arrive. This + // mirrors the streaming in driverCall_ (utils.js) and the xhrshim. + let resolvedAsStream = false; + let responseComplete = false; + let signalStreamUpdate = null; + const linesReceived = []; + let carry = ''; + let consumedLength = 0; + + const pushLines = text => { + carry += text; + let nl; + while ( (nl = carry.indexOf('\n')) !== -1 ) { + linesReceived.push(carry.slice(0, nl)); + carry = carry.slice(nl + 1); + } + }; + + xhr.onreadystatechange = () => { + if ( xhr.readyState === 2 && ! isNdjson(xhr.getResponseHeader('Content-Type')) ) return; + if ( xhr.readyState === 2 ) { + resolvedAsStream = true; + const stream = (async function* () { + while ( true ) { + while ( linesReceived.length > 0 ) { + const line = linesReceived.shift(); + if ( line.trim() === '' ) continue; + yield JSON.parse(line); + } + if ( responseComplete ) break; + const sig = createDeferred(); + signalStreamUpdate = sig.resolve; + await sig.promise; + } + })(); + logRequest(logId, { result: '[stream]' }); + resolve(makeResponse(xhr, stream)); + } + if ( xhr.readyState === 4 && resolvedAsStream ) { + if ( carry.length > 0 ) { linesReceived.push(carry); carry = ''; } + responseComplete = true; + signalStreamUpdate?.(); + } + }; + + xhr.onprogress = () => { + if ( ! resolvedAsStream ) return; + const fresh = xhr.responseText.slice(consumedLength); + consumedLength = xhr.responseText.length; + if ( ! fresh ) return; + pushLines(fresh); + signalStreamUpdate?.(); + }; + + // -- Buffered (non-stream) response -- + xhr.addEventListener('load', async function () { + if ( resolvedAsStream ) return; + + if ( this.status === 401 && ! _reauthReplayed ) { + let parsed = null; + try { parsed = await bodyJson(this); } catch ( e ) { parsed = null; } + const reauth = await resolveReauth(parsed); + if ( reauth?.action === 'replay' ) { + try { + return resolve(await fetchUrl(url, { ...opts, _reauthReplayed: true })); + } catch ( e ) { + return reject(e); + } + } + // reauth 'reject'/null (incl. token_auth_failed handled inside + // resolveReauth): surface the 401 as an ok:false response, the + // same way fetch does for any error status. + } + + const resp = makeResponse(this); + if ( logId ) { + const logged = await bodyForLog(this); + logRequest(logId, this.status >= 400 + ? { error: logged ?? { message: this.statusText, status: this.status } } + : { result: logged }); + } + resolve(resp); + }); + + xhr.addEventListener('error', function (e) { + logRequest(logId, { error: { message: 'Network error occurred', event: e.type } }); + reject(new TypeError(`Network request to ${url} failed`)); + }); + xhr.addEventListener('abort', function () { + reject(signal?.reason ?? new DOMException('Aborted', 'AbortError')); + }); + + xhr.send(body); + }); +} + +export { fetchUrl }; diff --git a/src/puter-js/src/lib/PuterClient.test.js b/src/puter-js/src/lib/PuterClient.test.js new file mode 100644 index 000000000..a69dab055 --- /dev/null +++ b/src/puter-js/src/lib/PuterClient.test.js @@ -0,0 +1,222 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fetchUrl } from './PuterClient.js'; + +// -- Controllable fake XMLHttpRequest -- +// Drives fetchUrl's event handlers deterministically. Each instance plays the +// `program` set on the class, which the test uses to script the response. +function installFakeXHR (program) { + const instances = []; + class FakeXHR extends EventTarget { + constructor () { + super(); + this.readyState = 0; + this.status = 0; + this.statusText = ''; + this.responseText = ''; + this.response = null; + this.responseType = ''; + this.responseURL = ''; + this.withCredentials = false; + this._reqHeaders = {}; + this._respHeaders = {}; + this.onreadystatechange = null; + this.onprogress = null; + instances.push(this); + } + open (method, url) { this.method = method; this.url = url; } + setRequestHeader (name, value) { this._reqHeaders[name.toLowerCase()] = String(value); } + getResponseHeader (name) { return this._respHeaders[name.toLowerCase()] ?? null; } + abort () { this.dispatchEvent(new Event('abort')); } + send (body) { + this.reqBody = body; + queueMicrotask(() => program(this)); + } + // Test helpers the program uses to emit response phases. + _setHeaders (status, headers = {}) { + this.status = status; + this.statusText = String(status); + for ( const [ k, v ] of Object.entries(headers) ) this._respHeaders[k.toLowerCase()] = v; + } + _headersReceived () { this.readyState = 2; this.onreadystatechange?.(); } + _progress (chunk) { this.responseText += chunk; this.readyState = 3; this.onprogress?.(); } + _done () { this.readyState = 4; this.onreadystatechange?.(); this.dispatchEvent(new Event('load')); } + _networkError () { this.dispatchEvent(new Event('error')); } + } + globalThis.XMLHttpRequest = FakeXHR; + return instances; +} + +// A simple buffered JSON/text response. +const respond = ({ status = 200, contentType = 'application/json', body = '' }) => xhr => { + xhr._setHeaders(status, { 'content-type': contentType }); + xhr._headersReceived(); + xhr.responseText = typeof body === 'string' ? body : JSON.stringify(body); + xhr._done(); +}; + +let savedXHR; +beforeEach(() => { savedXHR = globalThis.XMLHttpRequest; }); +afterEach(() => { + globalThis.XMLHttpRequest = savedXHR; + delete globalThis.puter; + vi.restoreAllMocks(); +}); + +describe('fetchUrl', () => { + it('adds a Bearer header from the live puter.authToken when includePuterAuth', async () => { + globalThis.puter = { authToken: 'tok-123' }; + const xhrs = installFakeXHR(respond({ body: { ok: true } })); + await fetchUrl('https://api.example/whoami', { includePuterAuth: true }); + expect(xhrs[0]._reqHeaders['authorization']).toBe('Bearer tok-123'); + }); + + it('omits the Bearer header when includePuterAuth is false', async () => { + globalThis.puter = { authToken: 'tok-123' }; + const xhrs = installFakeXHR(respond({ body: {} })); + await fetchUrl('https://api.example/public'); + expect(xhrs[0]._reqHeaders['authorization']).toBeUndefined(); + }); + + it('passes through custom headers and body, skipping nullish header values', async () => { + const xhrs = installFakeXHR(respond({ body: {} })); + await fetchUrl('https://api.example/x', { + method: 'POST', + headers: { 'puter-auth': 'w-tok', 'x-skip': undefined }, + body: 'payload', + }); + expect(xhrs[0].method).toBe('POST'); + expect(xhrs[0]._reqHeaders['puter-auth']).toBe('w-tok'); + expect('x-skip' in xhrs[0]._reqHeaders).toBe(false); + expect(xhrs[0].reqBody).toBe('payload'); + }); + + it('resolves ok:true on 200', async () => { + installFakeXHR(respond({ status: 200, body: { a: 1 } })); + const resp = await fetchUrl('https://api.example/x'); + expect(resp.ok).toBe(true); + expect(resp.status).toBe(200); + expect(await resp.json()).toEqual({ a: 1 }); + }); + + it('resolves (not rejects) with ok:false on 404 and 500', async () => { + for ( const status of [ 404, 500 ] ) { + installFakeXHR(respond({ status, body: { error: 'nope' } })); + const resp = await fetchUrl('https://api.example/x'); + expect(resp.ok).toBe(false); + expect(resp.status).toBe(status); + expect(await resp.json()).toEqual({ error: 'nope' }); + } + }); + + it('rejects on a network error', async () => { + installFakeXHR(xhr => xhr._networkError()); + await expect(fetchUrl('https://api.example/x')).rejects.toThrow(/failed/); + }); + + it('exposes text(), json(), and blob() accessors', async () => { + installFakeXHR(respond({ contentType: 'application/json', body: { hello: 'world' } })); + const resp = await fetchUrl('https://api.example/x'); + expect(await resp.text()).toBe('{"hello":"world"}'); + expect(await resp.json()).toEqual({ hello: 'world' }); + const blob = await resp.blob(); + expect(blob).toBeInstanceOf(Blob); + expect(blob.type).toBe('application/json'); + expect(blob.size).toBe('{"hello":"world"}'.length); + }); + + it('streams parsed NDJSON objects across chunk boundaries', async () => { + installFakeXHR(xhr => { + xhr._setHeaders(200, { 'content-type': 'application/x-ndjson' }); + xhr._headersReceived(); + // A JSON object split across two progress deltas, plus a full line. + xhr._progress('{"n":1}\n{"n":'); + xhr._progress('2}\n{"n":3}\n'); + xhr._done(); + }); + const resp = await fetchUrl('https://api.example/stream'); + const got = []; + for await ( const obj of resp.stream() ) got.push(obj); + expect(got).toEqual([ { n: 1 }, { n: 2 }, { n: 3 } ]); + }); + + describe('401 reauth', () => { + it('triggers reauth once and replays with the fresh token', async () => { + const triggerReauth = vi.fn(async () => { globalThis.puter.authToken = 'fresh'; }); + globalThis.puter = { authToken: 'stale', env: 'web', triggerReauth }; + + let call = 0; + installFakeXHR(xhr => { + call++; + if ( call === 1 ) { + // first attempt: 401 reauth_required + return respond({ status: 401, body: { code: 'reauth_required', reason: 'x', auth_id: 'a' } })(xhr); + } + // replay carries the fresh token and succeeds + expect(xhr._reqHeaders['authorization']).toBe('Bearer fresh'); + return respond({ status: 200, body: { ok: true } })(xhr); + }); + + const resp = await fetchUrl('https://api.example/x', { includePuterAuth: true }); + expect(triggerReauth).toHaveBeenCalledTimes(1); + expect(resp.ok).toBe(true); + expect(await resp.json()).toEqual({ ok: true }); + }); + + it('does not loop: a second 401 after replay surfaces as ok:false', async () => { + const triggerReauth = vi.fn(async () => {}); + globalThis.puter = { authToken: 'stale', env: 'web', triggerReauth }; + + installFakeXHR(respond({ status: 401, body: { code: 'reauth_required' } })); + const resp = await fetchUrl('https://api.example/x', { includePuterAuth: true }); + // reauth attempted exactly once; replayed request's 401 is returned. + expect(triggerReauth).toHaveBeenCalledTimes(1); + expect(resp.ok).toBe(false); + expect(resp.status).toBe(401); + }); + + it('plain 401 (no reauth code) resolves ok:false without triggering reauth', async () => { + const triggerReauth = vi.fn(); + globalThis.puter = { authToken: 't', env: 'web', triggerReauth }; + installFakeXHR(respond({ status: 401, body: { message: 'Unauthorized' } })); + const resp = await fetchUrl('https://api.example/x', { includePuterAuth: true }); + expect(triggerReauth).not.toHaveBeenCalled(); + expect(resp.ok).toBe(false); + }); + }); + + describe('API call logging', () => { + const makeLogger = () => ({ isEnabled: () => true, logRequest: vi.fn() }); + + it('logs on success when the logger is enabled', async () => { + const apiCallLogger = makeLogger(); + globalThis.puter = { apiCallLogger }; + installFakeXHR(respond({ status: 200, body: {} })); + await fetchUrl('https://api.example/x'); + expect(apiCallLogger.logRequest).toHaveBeenCalledTimes(1); + expect(apiCallLogger.logRequest.mock.calls[0][0].error).toBeNull(); + }); + + it('logs an error entry on a 4xx', async () => { + const apiCallLogger = makeLogger(); + globalThis.puter = { apiCallLogger }; + installFakeXHR(respond({ status: 404, body: { code: 'not_found' } })); + await fetchUrl('https://api.example/x'); + expect(apiCallLogger.logRequest).toHaveBeenCalledTimes(1); + const entry = apiCallLogger.logRequest.mock.calls[0][0]; + expect(entry.error).toMatchObject({ code: 'not_found' }); + expect(entry.result).toBeNull(); + }); + + it('uses logContext for semantic service/operation when provided', async () => { + const apiCallLogger = makeLogger(); + globalThis.puter = { apiCallLogger }; + installFakeXHR(respond({ status: 200, body: { u: 1 } })); + await fetchUrl('https://api.example/whoami', { + logContext: { service: 'auth', operation: 'whoami', params: {} }, + }); + const entry = apiCallLogger.logRequest.mock.calls[0][0]; + expect(entry).toMatchObject({ service: 'auth', operation: 'whoami' }); + expect(entry.result).toEqual({ u: 1 }); + }); + }); +}); diff --git a/src/puter-js/src/lib/utils.js b/src/puter-js/src/lib/utils.js index eccb56d09..766345090 100644 --- a/src/puter-js/src/lib/utils.js +++ b/src/puter-js/src/lib/utils.js @@ -155,6 +155,64 @@ function replayXhrAfterReauth (response, success_cb, error_cb, resolve_func, rej return true; } +/** + * Shared 401 reauth policy for a parsed response body. Drives the env-specific + * reauth flow on the Puter class and tells the caller what to do next, so the + * generic XHR path (`handle_resp`) and the fetch replacement (`fetchUrl`) apply + * the exact same policy. The driver-call handler (`driverCall_`) keeps its own + * replay because it must preserve streaming/transform semantics on retry. + * + * Recognised backend signals: + * - `reauth_required` (v2 `authProbe`): legacy v1 tokens, revoked sessions, + * and expired sessions beyond the silent re-mint window. + * - `token_auth_failed` (legacy `APIError.create('token_auth_failed')`): + * token no longer valid, prompt re-login (web env only). + * + * @param {Object} resp - The parsed response body. + * @returns {Promise<{action: 'replay'}|{action: 'reject', error: Object}|null>} + * `replay` when the caller should re-issue the request once with the fresh + * token, `reject` with the error to surface, or `null` when this is not a + * reauth-recoverable 401 and the caller should handle it normally. + */ +async function resolveReauth (resp) { + if ( resp?.code === 'reauth_required' ) { + try { + await puter.triggerReauth({ + reason: resp.reason, + auth_id: resp.auth_id, + }); + return { action: 'replay' }; + } catch ( e ) { + return { + action: 'reject', + error: { + status: 401, + code: 'reauth_required', + reason: resp.reason, + auth_id: resp.auth_id, + message: e?.message || 'Reauthentication required', + }, + }; + } + } + if ( resp?.code === 'token_auth_failed' && puter.env === 'web' ) { + try { + puter.resetAuthToken(); + await puter.ui.authenticateWithPuter(); + } catch (e) { + return { + action: 'reject', + error: { + error: { + code: 'auth_canceled', message: 'Authentication canceled', + }, + }, + }; + } + } + return null; +} + /** * Handles an HTTP response by invoking appropriate callback functions and resolving or rejecting a promise. * @@ -176,50 +234,17 @@ async function handle_resp (success_cb, error_cb, resolve_func, reject_func, res const resp = await parseResponse(response); // error - unauthorized if ( response.status === 401 ) { - // v2 reauth signal. The backend `authProbe` middleware returns - // `401 { code: 'reauth_required', reason, auth_id }` for legacy - // v1 tokens, revoked sessions, and expired sessions beyond the - // silent re-mint window. Drive the env-specific reauth flow on - // the Puter class, then replay the original request with the - // new token. - if ( resp?.code === 'reauth_required' ) { - try { - await puter.triggerReauth({ - reason: resp.reason, - auth_id: resp.auth_id, - }); - if ( replayXhrAfterReauth(response, success_cb, error_cb, resolve_func, reject_func) ) { - return; - } - } catch ( e ) { - const err = { - status: 401, - code: 'reauth_required', - reason: resp.reason, - auth_id: resp.auth_id, - message: e?.message || 'Reauthentication required', - }; - if ( error_cb && typeof error_cb === 'function' ) error_cb(err); - return reject_func(err); - } - } - // Backend signals "token is no longer valid, prompt re-login" via - // the legacy `token_auth_failed` code (matches v1 backend's - // APIError.create('token_auth_failed') shape). Trigger the same - // reset + re-auth flow the driver-call handler uses, so stale or - // legacy-shaped tokens auto-recover instead of bubbling a raw - // "Unauthorized" up to every caller. - if ( resp?.code === 'token_auth_failed' && puter.env === 'web' ) { - try { - puter.resetAuthToken(); - await puter.ui.authenticateWithPuter(); - } catch (e) { - return reject_func({ - error: { - code: 'auth_canceled', message: 'Authentication canceled', - }, - }); + const reauth = await resolveReauth(resp); + if ( reauth?.action === 'replay' ) { + // Replay the original request with the fresh token. If the replay + // can't be scheduled (no captured request, or already retried), + // fall through to the generic Unauthorized rejection below. + if ( replayXhrAfterReauth(response, success_cb, error_cb, resolve_func, reject_func) ) { + return; } + } else if ( reauth?.action === 'reject' ) { + if ( error_cb && typeof error_cb === 'function' ) error_cb(reauth.error); + return reject_func(reauth.error); } // if error callback is provided, call it if ( error_cb && typeof error_cb === 'function' ) @@ -786,5 +811,5 @@ const isVideoInput = (url) => { }; export { - arrayBufferToDataUri, blob_to_url, blobToDataUri, driverCall, handle_error, handle_resp, initXhr, isVideoInput, make_driver_method, parseResponse, setupXhrEventHandlers, uuidv4, + arrayBufferToDataUri, blob_to_url, blobToDataUri, createDeferred, driverCall, handle_error, handle_resp, initXhr, isVideoInput, make_driver_method, parseResponse, resolveReauth, setupXhrEventHandlers, uuidv4, }; diff --git a/src/puter-js/src/modules/AI.js b/src/puter-js/src/modules/AI.js index 4dc2bf096..23ad8fe93 100644 --- a/src/puter-js/src/modules/AI.js +++ b/src/puter-js/src/modules/AI.js @@ -1,4 +1,5 @@ import * as utils from '../lib/utils.js'; +import { fetchUrl } from '../lib/PuterClient.js'; import getAbsolutePathForApp from './FileSystem/utils/getAbsolutePathForApp.js'; const normalizeTTSProvider = (value) => { @@ -59,10 +60,10 @@ class AI { */ async listModels (provider) { // Prefer the public API endpoint and fall back to the legacy driver call if needed. - const headers = this.authToken ? { Authorization: `Bearer ${this.authToken}` } : {}; - const tryFetchModels = async () => { - const resp = await fetch(`${this.APIOrigin }/puterai/chat/models/details`, { headers }); + const resp = await fetchUrl(`${this.APIOrigin }/puterai/chat/models/details`, { + includePuterAuth: !! this.authToken, + }); if ( ! resp.ok ) return null; const data = await resp.json(); const models = Array.isArray(data?.models) ? data.models : []; diff --git a/src/puter-js/src/modules/Apps.js b/src/puter-js/src/modules/Apps.js index 8e4672b85..798581966 100644 --- a/src/puter-js/src/modules/Apps.js +++ b/src/puter-js/src/modules/Apps.js @@ -1,4 +1,5 @@ import * as utils from '../lib/utils.js'; +import { fetchUrl } from '../lib/PuterClient.js'; class Apps { /** @@ -228,13 +229,9 @@ class Apps { }; } - const resp = await fetch( + const resp = await fetchUrl( `${puter.APIOrigin}/apps/nameAvailable?name=${encodeURIComponent(name)}`, - { - headers: { - Authorization: `Bearer ${puter.authToken}`, - }, - }, + { includePuterAuth: true }, ); const result = await resp.json(); if ( ! resp.ok ) { diff --git a/src/puter-js/src/modules/Auth.js b/src/puter-js/src/modules/Auth.js index 2640cff76..f73d928ee 100644 --- a/src/puter-js/src/modules/Auth.js +++ b/src/puter-js/src/modules/Auth.js @@ -1,4 +1,5 @@ import * as utils from '../lib/utils.js'; +import { fetchUrl } from '../lib/PuterClient.js'; import PuterDialog from './PuterDialog.js'; import { hasUserActivation, openAuthPopup } from '../lib/auth-popup.js'; @@ -73,7 +74,7 @@ class Auth { (async () => { while (true) { try { - const result = await fetch(`${this.APIOrigin}/login/wait`, { + const result = await fetchUrl(`${this.APIOrigin}/login/wait`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -249,79 +250,19 @@ class Auth { }; } - try { - const resp = await fetch(`${this.APIOrigin}/whoami`, { - headers: { - Authorization: `Bearer ${this.authToken}`, - }, - }); - - const result = await resp.json(); - - // Log the response - if ( globalThis.puter?.apiCallLogger?.isEnabled() ) { - globalThis.puter.apiCallLogger.logRequest({ - service: 'auth', - operation: 'whoami', - params: {}, - result: result, - }); - } - - return result; - } catch ( error ) { - // Log the error - if ( globalThis.puter?.apiCallLogger?.isEnabled() ) { - globalThis.puter.apiCallLogger.logRequest({ - service: 'auth', - operation: 'whoami', - params: {}, - error: { - message: error.message || error.toString(), - stack: error.stack, - }, - }); - } - throw error; - } + const resp = await fetchUrl(`${this.APIOrigin}/whoami`, { + includePuterAuth: true, + logContext: { service: 'auth', operation: 'whoami', params: {} }, + }); + return await resp.json(); } async getMonthlyUsage () { - try { - const resp = await fetch(`${this.APIOrigin}/metering/usage`, { - headers: { - Authorization: `Bearer ${this.authToken}`, - }, - }); - - const result = await resp.json(); - - // Log the response - if ( globalThis.puter?.apiCallLogger?.isEnabled() ) { - globalThis.puter.apiCallLogger.logRequest({ - service: 'auth', - operation: 'usage', - params: {}, - result: result, - }); - } - - return result; - } catch ( error ) { - // Log the error - if ( globalThis.puter?.apiCallLogger?.isEnabled() ) { - globalThis.puter.apiCallLogger.logRequest({ - service: 'auth', - operation: 'usage', - params: {}, - error: { - message: error.message || error.toString(), - stack: error.stack, - }, - }); - } - throw error; - } + const resp = await fetchUrl(`${this.APIOrigin}/metering/usage`, { + includePuterAuth: true, + logContext: { service: 'auth', operation: 'usage', params: {} }, + }); + return await resp.json(); } async getDetailedAppUsage (appId) { @@ -329,79 +270,19 @@ class Auth { throw new Error('appId is required'); } - try { - const resp = await fetch(`${this.APIOrigin}/metering/usage/${appId}`, { - headers: { - Authorization: `Bearer ${this.authToken}`, - }, - }); - - const result = await resp.json(); - - // Log the response - if ( globalThis.puter?.apiCallLogger?.isEnabled() ) { - globalThis.puter.apiCallLogger.logRequest({ - service: 'auth', - operation: 'detailed_app_usage', - params: { appId }, - result: result, - }); - } - - return result; - } catch ( error ) { - // Log the error - if ( globalThis.puter?.apiCallLogger?.isEnabled() ) { - globalThis.puter.apiCallLogger.logRequest({ - service: 'auth', - operation: 'detailed_app_usage', - params: { appId }, - error: { - message: error.message || error.toString(), - stack: error.stack, - }, - }); - } - throw error; - } + const resp = await fetchUrl(`${this.APIOrigin}/metering/usage/${appId}`, { + includePuterAuth: true, + logContext: { service: 'auth', operation: 'detailed_app_usage', params: { appId } }, + }); + return await resp.json(); } async getGlobalUsage () { - try { - const resp = await fetch(`${this.APIOrigin}/metering/globalUsage`, { - headers: { - Authorization: `Bearer ${this.authToken}`, - }, - }); - - const result = await resp.json(); - - // Log the response - if ( globalThis.puter?.apiCallLogger?.isEnabled() ) { - globalThis.puter.apiCallLogger.logRequest({ - service: 'auth', - operation: 'global_usage', - params: {}, - result: result, - }); - } - - return result; - } catch ( error ) { - // Log the error - if ( globalThis.puter?.apiCallLogger?.isEnabled() ) { - globalThis.puter.apiCallLogger.logRequest({ - service: 'auth', - operation: 'global_usage', - params: {}, - error: { - message: error.message || error.toString(), - stack: error.stack, - }, - }); - } - throw error; - } + const resp = await fetchUrl(`${this.APIOrigin}/metering/globalUsage`, { + includePuterAuth: true, + logContext: { service: 'auth', operation: 'global_usage', params: {} }, + }); + return await resp.json(); } } diff --git a/src/puter-js/src/modules/Drivers.js b/src/puter-js/src/modules/Drivers.js index 6199ad6ec..024bfb817 100644 --- a/src/puter-js/src/modules/Drivers.js +++ b/src/puter-js/src/modules/Drivers.js @@ -1,3 +1,5 @@ +import { fetchUrl } from '../lib/PuterClient.js'; + class FetchDriverCallBackend { constructor ({ getAPIOrigin, getAuthToken }) { this.getAPIOrigin = getAPIOrigin; @@ -5,39 +7,22 @@ class FetchDriverCallBackend { this.response_handlers = this.constructor.response_handlers; } + // Dispatched by response content type. The handlers consume the + // fetchUrl PuterResponse: stream() yields parsed NDJSON lines, json()/blob() + // read the buffered body. static response_handlers = { - 'application/x-ndjson': async resp => { - const Stream = async function* Stream (readableStream) { - const reader = readableStream.getReader(); - let value, done; - while ( !done ) { - ({ value, done } = await reader.read()); - if ( done ) break; - const parts = (new TextDecoder().decode(value).split('\n')); - for ( const part of parts ) { - if ( part.trim() === '' ) continue; - yield JSON.parse(part); - } - } - }; - - return Stream(resp.body); - }, - 'application/json': async resp => { - return await resp.json(); - }, - 'application/octet-stream': async resp => { - return await resp.blob(); - }, + 'application/x-ndjson': resp => resp.stream(), + 'application/json': resp => resp.json(), + 'application/octet-stream': resp => resp.blob(), }; async call ({ driver, method_name, parameters }) { try { - const resp = await fetch(`${this.getAPIOrigin()}/drivers/call`, { + const resp = await fetchUrl(`${this.getAPIOrigin()}/drivers/call`, { + method: 'POST', headers: { 'Content-Type': 'text/plain;actually=json', }, - method: 'POST', body: JSON.stringify({ 'interface': driver.iface_name, ...(driver.service_name @@ -170,42 +155,13 @@ class Drivers { } async list () { - try { - const resp = await fetch(`${this.APIOrigin}/lsmod`, { - headers: { - Authorization: `Bearer ${ this.authToken}`, - }, - method: 'POST', - }); - - const list = await resp.json(); - - // Log the response - if ( globalThis.puter?.apiCallLogger?.isEnabled() ) { - globalThis.puter.apiCallLogger.logRequest({ - service: 'drivers', - operation: 'list', - params: {}, - result: list.interfaces, - }); - } - - return list.interfaces; - } catch ( error ) { - // Log the error - if ( globalThis.puter?.apiCallLogger?.isEnabled() ) { - globalThis.puter.apiCallLogger.logRequest({ - service: 'drivers', - operation: 'list', - params: {}, - error: { - message: error.message || error.toString(), - stack: error.stack, - }, - }); - } - throw error; - } + const resp = await fetchUrl(`${this.APIOrigin}/lsmod`, { + method: 'POST', + includePuterAuth: true, + logContext: { service: 'drivers', operation: 'list', params: {} }, + }); + const list = await resp.json(); + return list.interfaces; } async get (iface_name, service_name) { diff --git a/src/puter-js/src/modules/FileSystem/Batch.js b/src/puter-js/src/modules/FileSystem/Batch.js index e303f215a..575e2c0e0 100644 --- a/src/puter-js/src/modules/FileSystem/Batch.js +++ b/src/puter-js/src/modules/FileSystem/Batch.js @@ -1,3 +1,5 @@ +import { fetchUrl } from '../../lib/PuterClient.js'; + export default puter => class Batch { constructor () { this.form = new FormData(); @@ -34,15 +36,11 @@ export default puter => class Batch { this.form.append('operation', JSON.stringify(operation)); } - // Send Request - const res = await fetch(`${puter.APIOrigin}/batch`, { - headers: { - Authorization: `Bearer ${puter.authToken}`, - ...(['web', 'app'].includes(puter.env) ? { - Origin: 'https://puter.work', - } : {}), - }, + // Send Request. The Content-Type (multipart boundary) is set by the + // transport from the FormData body, so it is not passed explicitly. + const res = await fetchUrl(`${puter.APIOrigin}/batch`, { method: 'POST', + includePuterAuth: true, body: this.form, }); diff --git a/src/puter-js/src/modules/FileSystem/operations/upload.js b/src/puter-js/src/modules/FileSystem/operations/upload.js index c99a48494..c91ecda7e 100644 --- a/src/puter-js/src/modules/FileSystem/operations/upload.js +++ b/src/puter-js/src/modules/FileSystem/operations/upload.js @@ -1,4 +1,5 @@ import path from '../../../lib/path.js'; +import { fetchUrl } from '../../../lib/PuterClient.js'; import * as utils from '../../../lib/utils.js'; import { showUsageLimitDialog } from '../../../modules/UsageLimitDialog.js'; import getAbsolutePathForApp from '../utils/getAbsolutePathForApp.js'; @@ -178,10 +179,9 @@ const toRequestError = (response, body, fallbackMessage) => { }; const postJson = async (apiOrigin, authToken, endpoint, payload) => { - const response = await fetch(`${apiOrigin}${endpoint}`, { + const response = await fetchUrl(`${apiOrigin}${endpoint}`, { method: 'POST', headers: createApiHeaders(authToken), - credentials: 'include', body: JSON.stringify(payload), }); const body = await parseFetchResponseBody(response); diff --git a/src/puter-js/src/modules/KV.js b/src/puter-js/src/modules/KV.js index 8ed2b9ce9..df63eb495 100644 --- a/src/puter-js/src/modules/KV.js +++ b/src/puter-js/src/modules/KV.js @@ -1,4 +1,5 @@ import * as utils from '../lib/utils.js'; +import { fetchUrl } from '../lib/PuterClient.js'; const createDeferred = () => { let resolve; @@ -54,7 +55,7 @@ class KV { (async () => { await this.gui_cache_init.promise; this.gui_cache_init = null; - const resp = await fetch(`${this.APIOrigin}/drivers/call`, { + const resp = await fetchUrl(`${this.APIOrigin}/drivers/call`, { method: 'POST', headers: { 'Content-Type': 'text/plain;actually=json', diff --git a/src/puter-js/src/modules/Peer.js b/src/puter-js/src/modules/Peer.js index 2e00fecb4..2e84fae98 100644 --- a/src/puter-js/src/modules/Peer.js +++ b/src/puter-js/src/modules/Peer.js @@ -1,3 +1,5 @@ +import { fetchUrl } from '../lib/PuterClient.js'; + class PuterPeerServerConnectionEvent extends Event { conn; user; @@ -389,11 +391,11 @@ class Peer { if ( this.#turnFailed ) return; if ( this.#turnServers && Date.now() - this.#turnStartedAt < this.#turnTTL * 1000 ) return; - const response = await fetch(`${this.APIOrigin}/peer/generate-turn`, { + const response = await fetchUrl(`${this.APIOrigin}/peer/generate-turn`, { method: 'POST', + includePuterAuth: true, headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.authToken}`, }, }); @@ -411,7 +413,7 @@ class Peer { async #loadMetadata () { if ( this.#signallerUrl ) return; - const response = await fetch(`${this.APIOrigin}/peer/signaller-info`); + const response = await fetchUrl(`${this.APIOrigin}/peer/signaller-info`); if ( ! response.ok ) { throw new Error('Failed to get signaller info from Puter.'); } diff --git a/src/puter-js/src/modules/Perms.js b/src/puter-js/src/modules/Perms.js index f2f4eb0df..2ba65baf4 100644 --- a/src/puter-js/src/modules/Perms.js +++ b/src/puter-js/src/modules/Perms.js @@ -1,3 +1,5 @@ +import { fetchUrl } from '../lib/PuterClient.js'; + export default class Perms { constructor (puter) { this.puter = puter; @@ -12,10 +14,10 @@ export default class Perms { } async req_ (route, body) { try { - const resp = await fetch(this.APIOrigin + route, { + const resp = await fetchUrl(this.APIOrigin + route, { method: body ? 'POST' : 'GET', + includePuterAuth: true, headers: { - Authorization: `Bearer ${this.authToken}`, 'Content-Type': 'application/json', }, ...(body ? { body: JSON.stringify(body) } : {}), diff --git a/src/puter-js/src/modules/Workers.js b/src/puter-js/src/modules/Workers.js index 5f609826b..39ec3b2ec 100644 --- a/src/puter-js/src/modules/Workers.js +++ b/src/puter-js/src/modules/Workers.js @@ -71,6 +71,9 @@ export class WorkersHandler { req.headers.set('puter-auth', puter.authToken); } req.headers.delete('x-puter-no-auth'); + // Passthrough to a user worker URL: takes the fetch Request interface and + // returns the raw fetch Response as public API, so it stays on fetch + // rather than the XHR-based fetchUrl (whose response is a narrower shape). return fetch(req); } diff --git a/src/puter-js/src/modules/networking/PSocket.js b/src/puter-js/src/modules/networking/PSocket.js index 636a3ebf7..752b3436a 100644 --- a/src/puter-js/src/modules/networking/PSocket.js +++ b/src/puter-js/src/modules/networking/PSocket.js @@ -1,4 +1,5 @@ import EventListener from '../../lib/EventListener.js'; +import { fetchUrl } from '../../lib/PuterClient.js'; import { errors } from './parsers.js'; import { PWispHandler } from './PWispHandler.js'; const texten = new TextEncoder(); @@ -27,10 +28,10 @@ export class PSocket extends EventListener { } if ( ! wispInfo.handler ) { // first launch -- lets init the socket - const { token: wispToken, server: wispServer } = (await (await fetch(`${puter.APIOrigin }/wisp/relay-token/create`, { + const { token: wispToken, server: wispServer } = (await (await fetchUrl(`${puter.APIOrigin }/wisp/relay-token/create`, { method: 'POST', + includePuterAuth: !! puter.authToken, headers: { - Authorization: puter.authToken ? `Bearer ${puter.authToken}` : '', 'Content-Type': 'application/json', }, body: JSON.stringify({}),