mirror of
https://github.com/GetOpenScript/OpenScript.git
synced 2026-09-18 09:45:43 +00:00
Add OpenScript.fetch cross-origin network API and docs
This commit is contained in:
14
README.md
14
README.md
@@ -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
132
docs/fetch.md
Normal 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 200–299 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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
4
package-lock.json
generated
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
9
src/utils/fetch.js
Normal 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 };
|
||||||
|
};
|
||||||
@@ -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];
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const VERSION = '1.0.3';
|
export const VERSION = '1.0.4';
|
||||||
|
|||||||
142
tests/fetch.test.js
Normal file
142
tests/fetch.test.js
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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'));
|
||||||
|
|||||||
Reference in New Issue
Block a user