Add OpenScript.fetch cross-origin network API and docs

This commit is contained in:
2026-09-13 13:39:46 -07:00
parent 1cd7b693ab
commit da135d506a
11 changed files with 332 additions and 11 deletions

View File

@@ -72,6 +72,20 @@ await OpenScript.storage.delete('repo_cache');
Stored keys are scoped to the current script and survive reloads and browser restarts. Orphaned values are removed when the popup opens after their script has been deleted. Stored keys are scoped to the current script and survive reloads and browser restarts. Orphaned values are removed when the popup opens after their script has been deleted.
### Cross-origin network requests
Make cross-origin requests that bypass page CORS and CSP restrictions using the extension's privileged background worker:
```javascript
const response = await OpenScript.fetch('https://api.example.com/data', {
headers: { Accept: 'application/json' },
credentials: 'include', // includes browser cookies for the target domain
});
const data = await response.json();
```
`OpenScript.fetch(url, options)` returns a native `Response` instance supporting `.json()`, `.text()`, `.arrayBuffer()`, `.blob()`, `.status`, `.ok`, and `.headers`. See the [Cross-origin fetch guide](docs/fetch.md) for full options and examples.
### External libraries ### External libraries
Use `@require` to cache libraries when a script is saved: Use `@require` to cache libraries when a script is saved:

132
docs/fetch.md Normal file
View File

@@ -0,0 +1,132 @@
# Cross-Origin Fetch (`OpenScript.fetch`)
`OpenScript.fetch(url, options)` allows user scripts to perform cross-origin HTTP(S) requests that bypass webpage CORS (Cross-Origin Resource Sharing) and page CSP (Content Security Policy) restrictions.
Requests are routed through OpenScript's privileged background service worker using extension host permissions (`*://*/*`), and return a standard, native browser `Response` instance.
---
## Why use `OpenScript.fetch`?
When a user script runs on a website (such as `youtube.com` or `github.com`), standard `window.fetch()` calls are subject to the page's security context:
- External endpoints that lack CORS headers (`Access-Control-Allow-Origin`) cannot be read.
- Webpage CSP (`connect-src`) can block outbound network calls.
- Third-party cookies cannot be attached to cross-origin requests.
`OpenScript.fetch` solves all of these limitations without requiring legacy, callback-heavy APIs like `GM_xmlhttpRequest`.
---
## Syntax
```javascript
const response = await OpenScript.fetch(resource, options);
```
### Parameters
- **`resource`** *(string | URL)*: The target URL to fetch.
- **`options`** *(object, optional)*: Standard fetch options:
- `method` *(string)*: HTTP method, e.g. `'GET'`, `'POST'`, `'PUT'`, `'DELETE'`. Defaults to `'GET'`.
- `headers` *(object | Headers)*: Request headers as key-value pairs or a `Headers` instance.
- `body` *(string | ArrayBuffer | Uint8Array)*: Body payload for `POST` / `PUT` requests.
- `credentials` *(string)*: Set to `'include'` to attach browser session cookies for the target domain. Defaults to `'same-origin'`.
- `cache`, `redirect`, and other standard fetch options.
### Return Value
Returns a `Promise` resolving to a native browser **[`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response)** instance.
Supported methods and properties:
- `res.ok` *(boolean)*: `true` if status code is in the 200299 range.
- `res.status` *(number)*: HTTP status code (e.g. `200`, `404`).
- `res.statusText` *(string)*: Status message (e.g. `'OK'`).
- `res.headers` *(Headers)*: Map of response headers (`res.headers.get('content-type')`).
- `res.url` *(string)*: Final URL after redirects.
- `await res.json()`: Parses response body as JSON.
- `await res.text()`: Reads response body as text string.
- `await res.arrayBuffer()`: Reads raw binary data.
- `await res.blob()`: Reads response as a `Blob`.
---
## Examples
### 1. Basic JSON Request (Bypassing CORS)
```javascript
// ==UserScript==
// @name Reddit Search on YouTube
// @match https://www.youtube.com/*
// ==/UserScript==
const res = await OpenScript.fetch('https://www.reddit.com/search.json?q=OpenScript');
if (!res.ok) {
console.error(`HTTP error: ${res.status}`);
return;
}
const data = await res.json();
console.log('Reddit posts:', data.data.children);
```
### 2. Authenticated Request using Session Cookies
Passing `credentials: 'include'` forwards the browser's existing cookies for the destination host, enabling interactions with services where the user is already logged in:
```javascript
// Check current Reddit user session without OAuth
const res = await OpenScript.fetch('https://www.reddit.com/api/me.json', {
credentials: 'include',
});
if (res.ok) {
const me = await res.json();
console.log('Logged in as:', me.data.name);
}
```
### 3. POST Request with JSON Body
```javascript
const res = await OpenScript.fetch('https://api.example.com/items', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${OpenScript.env.API_KEY}`,
},
body: JSON.stringify({ name: 'New Item' }),
});
const result = await res.json();
```
### 4. Downloading Binary Data / Blobs
```javascript
const res = await OpenScript.fetch('https://example.com/image.png');
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = objectUrl;
document.body.append(img);
```
### 5. Error Handling
Like native `fetch()`, network failures (offline, connection refused, DNS errors) reject with a `TypeError`. HTTP errors (like 404 or 500) resolve normally and should be checked with `res.ok`:
```javascript
try {
const res = await OpenScript.fetch('https://api.example.com/data');
if (!res.ok) throw new Error(`Server returned status ${res.status}`);
const data = await res.json();
} catch (err) {
if (err instanceof TypeError) {
console.error('Network failure:', err.message);
} else {
console.error('API error:', err.message);
}
}
```

View File

@@ -2,7 +2,7 @@
"manifest_version": 3, "manifest_version": 3,
"minimum_chrome_version": "138", "minimum_chrome_version": "138",
"name": "OpenScript", "name": "OpenScript",
"version": "1.0.3", "version": "1.0.4",
"description": "A lightweight user script manager for modern browsers", "description": "A lightweight user script manager for modern browsers",
"action": { "action": {
"default_popup": "src/popup.html", "default_popup": "src/popup.html",

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "openscript", "name": "openscript",
"version": "1.0.3", "version": "1.0.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "openscript", "name": "openscript",
"version": "1.0.3", "version": "1.0.4",
"dependencies": { "dependencies": {
"lucide": "^0.475.0" "lucide": "^0.475.0"
}, },

View File

@@ -1,6 +1,6 @@
{ {
"name": "openscript", "name": "openscript",
"version": "1.0.3", "version": "1.0.4",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -1,5 +1,6 @@
import { syncUserScripts } from './utils/userScripts.js'; import { syncUserScripts } from './utils/userScripts.js';
import { runScriptStorageOperation } from './utils/storage.js'; import { runScriptStorageOperation } from './utils/storage.js';
import { runScriptFetch } from './utils/fetch.js';
let syncQueue = Promise.resolve(); let syncQueue = Promise.resolve();
const safelySync = async options => { const safelySync = async options => {
@@ -30,9 +31,16 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
}); });
chrome.runtime.onUserScriptMessage.addListener((msg, sender, sendResponse) => { chrome.runtime.onUserScriptMessage.addListener((msg, sender, sendResponse) => {
if (msg?.type !== 'OPEN_SCRIPT_STORAGE') return; if (msg?.type === 'OPEN_SCRIPT_STORAGE') {
runScriptStorageOperation(msg.token, msg.operation, msg.key, msg.value) runScriptStorageOperation(msg.token, msg.operation, msg.key, msg.value)
.then(result => sendResponse({ ok: true, ...result })) .then(result => sendResponse({ ok: true, ...result }))
.catch(error => sendResponse({ ok: false, error: error.message })); .catch(error => sendResponse({ ok: false, error: error.message }));
return true; return true;
}
if (msg?.type === 'OPEN_SCRIPT_FETCH') {
runScriptFetch(msg.url, msg.options)
.then(result => sendResponse({ ok: true, ...result }))
.catch(error => sendResponse({ ok: false, error: error.message }));
return true;
}
}); });

9
src/utils/fetch.js Normal file
View File

@@ -0,0 +1,9 @@
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
export const runScriptFetch = async (url, options = {}) => {
const res = await fetch(url, options);
const { status, statusText } = res;
const headers = [...res.headers.entries()];
const body = NULL_BODY_STATUSES.has(status) ? null : await res.arrayBuffer();
return { status, statusText, headers, url: res.url, body };
};

View File

@@ -3,6 +3,7 @@ import { normalizeMatch, parseMeta, getMetaRunAt } from './parser.js';
import { VERSION } from '../version.js'; import { VERSION } from '../version.js';
const STORAGE_MESSAGE = 'OPEN_SCRIPT_STORAGE'; const STORAGE_MESSAGE = 'OPEN_SCRIPT_STORAGE';
const FETCH_MESSAGE = 'OPEN_SCRIPT_FETCH';
export const isUserScriptsAvailable = async () => { export const isUserScriptsAvailable = async () => {
if (!chrome.userScripts) return false; if (!chrome.userScripts) return false;
@@ -34,7 +35,21 @@ ${code}
delete: key => call('delete', key).then(() => undefined), delete: key => call('delete', key).then(() => undefined),
list: () => call('list').then(result => result.keys), list: () => call('list').then(result => result.keys),
}); });
const OpenScript = Object.freeze({ version: '${VERSION}', env, storage }); const fetch = async (url, options = {}) => {
let { headers, body, ...rest } = options;
if (headers instanceof Headers) headers = Object.fromEntries(headers.entries());
const response = await chrome.runtime.sendMessage({
type: '${FETCH_MESSAGE}', url: url.toString(), options: { ...rest, headers, body },
});
if (!response?.ok) throw new TypeError(response?.error || 'OpenScript fetch failed');
const resBody = [101, 204, 205, 304].includes(response.status) ? null : response.body;
const res = new Response(resBody, {
status: response.status, statusText: response.statusText, headers: response.headers,
});
Object.defineProperty(res, 'url', { value: response.url || url.toString() });
return res;
};
const OpenScript = Object.freeze({ version: '${VERSION}', env, storage, fetch });
globalThis.OpenScript = OpenScript; globalThis.OpenScript = OpenScript;
globalThis.env = env; globalThis.env = env;
return [OpenScript, env]; return [OpenScript, env];

View File

@@ -1 +1 @@
export const VERSION = '1.0.3'; export const VERSION = '1.0.4';

142
tests/fetch.test.js Normal file
View File

@@ -0,0 +1,142 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { runScriptFetch } from '../src/utils/fetch.js';
import { wrapScriptCode } from '../src/utils/userScripts.js';
test('runScriptFetch performs background fetch and serializes response', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async (url, options) => new Response(JSON.stringify({ hello: 'world' }), {
status: 200,
statusText: 'OK',
headers: { 'content-type': 'application/json', 'x-custom': 'val' },
});
try {
const result = await runScriptFetch('https://api.example.com/test', { method: 'GET' });
assert.equal(result.status, 200);
assert.equal(result.statusText, 'OK');
assert.ok(result.headers.some(([k, v]) => k === 'content-type' && v === 'application/json'));
assert.ok(result.body instanceof ArrayBuffer);
const decoded = JSON.parse(new TextDecoder().decode(result.body));
assert.deepEqual(decoded, { hello: 'world' });
} finally {
globalThis.fetch = originalFetch;
}
});
test('runScriptFetch handles null-body statuses', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response(null, { status: 204, statusText: 'No Content' });
try {
const result = await runScriptFetch('https://api.example.com/empty');
assert.equal(result.status, 204);
assert.equal(result.body, null);
} finally {
globalThis.fetch = originalFetch;
}
});
test('runScriptFetch forwards errors on network failure', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => { throw new Error('Failed to fetch'); };
try {
await assert.rejects(() => runScriptFetch('https://broken.example.com'), /Failed to fetch/);
} finally {
globalThis.fetch = originalFetch;
}
});
test('OpenScript.fetch runtime wrapper reconstructs a native Response', async () => {
const originalChrome = globalThis.chrome;
const mockPayload = {
status: 200,
statusText: 'OK',
headers: [['content-type', 'application/json'], ['x-powered-by', 'openscript']],
url: 'https://api.example.com/redirected',
body: new TextEncoder().encode(JSON.stringify({ success: true })).buffer,
};
globalThis.chrome = {
runtime: {
sendMessage: async msg => {
if (msg.type === 'OPEN_SCRIPT_FETCH') {
assert.equal(msg.url, 'https://api.example.com/data');
assert.equal(msg.options.headers['authorization'], 'Bearer 123');
return { ok: true, ...mockPayload };
}
return { ok: false, error: 'unknown' };
},
},
};
let resolveDone;
const donePromise = new Promise(resolve => { resolveDone = resolve; });
globalThis.__resolve_done = resolveDone;
const scriptCode = `
const res = await OpenScript.fetch('https://api.example.com/data', {
headers: new Headers({ authorization: 'Bearer 123' }),
});
globalThis.__resolve_done({
ok: res.ok,
status: res.status,
statusText: res.statusText,
url: res.url,
header: res.headers.get('x-powered-by'),
data: await res.json(),
});
`;
try {
const wrapped = wrapScriptCode(scriptCode);
const fn = new Function(wrapped);
fn();
const result = await donePromise;
assert.deepEqual(result, {
ok: true,
status: 200,
statusText: 'OK',
url: 'https://api.example.com/redirected',
header: 'openscript',
data: { success: true },
});
} finally {
delete globalThis.__resolve_done;
globalThis.chrome = originalChrome;
}
});
test('OpenScript.fetch throws TypeError when request fails', async () => {
const originalChrome = globalThis.chrome;
globalThis.chrome = {
runtime: {
sendMessage: async () => ({ ok: false, error: 'Network error' }),
},
};
let resolveDone;
const donePromise = new Promise(resolve => { resolveDone = resolve; });
globalThis.__resolve_done = resolveDone;
const scriptCode = `
try {
await OpenScript.fetch('https://broken.example.com');
} catch (err) {
globalThis.__resolve_done({ name: err.name, message: err.message });
}
`;
try {
const wrapped = wrapScriptCode(scriptCode);
const fn = new Function(wrapped);
fn();
const err = await donePromise;
assert.equal(err.name, 'TypeError');
assert.equal(err.message, 'Network error');
} finally {
delete globalThis.__resolve_done;
globalThis.chrome = originalChrome;
}
});

View File

@@ -79,6 +79,7 @@ test('wrapScriptCode provides async OpenScript APIs without GM polyfills', () =>
assert.match(wrapped, /async function\(OpenScript, env\)/); assert.match(wrapped, /async function\(OpenScript, env\)/);
assert.ok(wrapped.includes('"API_KEY":"secret123"')); assert.ok(wrapped.includes('"API_KEY":"secret123"'));
assert.ok(wrapped.includes("call('list')")); assert.ok(wrapped.includes("call('list')"));
assert.ok(wrapped.includes('OPEN_SCRIPT_FETCH'));
assert.ok(wrapped.includes('storage-token')); assert.ok(wrapped.includes('storage-token'));
assert.ok(wrapped.includes(code)); assert.ok(wrapped.includes(code));
assert.ok(!wrapped.includes('GM_getValue')); assert.ok(!wrapped.includes('GM_getValue'));