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({}),