initial pjs helper stuff

This commit is contained in:
Neal Shah
2026-07-22 18:18:52 -04:00
parent 43b9200205
commit d03363f2f1
15 changed files with 649 additions and 279 deletions
+7 -6
View File
@@ -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({}),
},
);
+280
View File
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<any>} json
* @property {() => Promise<string>} text
* @property {() => Promise<Blob>} blob
* @property {() => Promise<ArrayBuffer>} arrayBuffer
* @property {() => AsyncGenerator<any>} 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 <puter.authToken>`.
* @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<PuterResponse>}
*/
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 };
+222
View File
@@ -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 });
});
});
});
+69 -44
View File
@@ -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,
};
+4 -3
View File
@@ -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 : [];
+3 -6
View File
@@ -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 ) {
+22 -141
View File
@@ -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();
}
}
+17 -61
View File
@@ -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) {
+6 -8
View File
@@ -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,
});
@@ -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);
+2 -1
View File
@@ -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',
+5 -3
View File
@@ -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.');
}
+4 -2
View File
@@ -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) } : {}),
+3
View File
@@ -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);
}
@@ -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({}),