diff --git a/README.md b/README.md index f9f5925..068b658 100644 --- a/README.md +++ b/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. +### 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 Use `@require` to cache libraries when a script is saved: diff --git a/docs/fetch.md b/docs/fetch.md new file mode 100644 index 0000000..b45a68b --- /dev/null +++ b/docs/fetch.md @@ -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); + } +} +``` diff --git a/manifest.json b/manifest.json index b8e43df..ff1202b 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "minimum_chrome_version": "138", "name": "OpenScript", - "version": "1.0.3", + "version": "1.0.4", "description": "A lightweight user script manager for modern browsers", "action": { "default_popup": "src/popup.html", diff --git a/package-lock.json b/package-lock.json index 5722014..c2a3a17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscript", - "version": "1.0.3", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscript", - "version": "1.0.3", + "version": "1.0.4", "dependencies": { "lucide": "^0.475.0" }, diff --git a/package.json b/package.json index eddace8..7b90447 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openscript", - "version": "1.0.3", + "version": "1.0.4", "private": true, "type": "module", "scripts": { diff --git a/src/background.js b/src/background.js index a17ca1f..61928ab 100644 --- a/src/background.js +++ b/src/background.js @@ -1,5 +1,6 @@ import { syncUserScripts } from './utils/userScripts.js'; import { runScriptStorageOperation } from './utils/storage.js'; +import { runScriptFetch } from './utils/fetch.js'; let syncQueue = Promise.resolve(); const safelySync = async options => { @@ -30,9 +31,16 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { }); chrome.runtime.onUserScriptMessage.addListener((msg, sender, sendResponse) => { - if (msg?.type !== 'OPEN_SCRIPT_STORAGE') return; - runScriptStorageOperation(msg.token, msg.operation, msg.key, msg.value) - .then(result => sendResponse({ ok: true, ...result })) - .catch(error => sendResponse({ ok: false, error: error.message })); - return true; + if (msg?.type === 'OPEN_SCRIPT_STORAGE') { + runScriptStorageOperation(msg.token, msg.operation, msg.key, msg.value) + .then(result => sendResponse({ ok: true, ...result })) + .catch(error => sendResponse({ ok: false, error: error.message })); + 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; + } }); diff --git a/src/utils/fetch.js b/src/utils/fetch.js new file mode 100644 index 0000000..34f754e --- /dev/null +++ b/src/utils/fetch.js @@ -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 }; +}; diff --git a/src/utils/userScripts.js b/src/utils/userScripts.js index 5f2ab3b..c96ff05 100644 --- a/src/utils/userScripts.js +++ b/src/utils/userScripts.js @@ -3,6 +3,7 @@ import { normalizeMatch, parseMeta, getMetaRunAt } from './parser.js'; import { VERSION } from '../version.js'; const STORAGE_MESSAGE = 'OPEN_SCRIPT_STORAGE'; +const FETCH_MESSAGE = 'OPEN_SCRIPT_FETCH'; export const isUserScriptsAvailable = async () => { if (!chrome.userScripts) return false; @@ -34,7 +35,21 @@ ${code} delete: key => call('delete', key).then(() => undefined), 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.env = env; return [OpenScript, env]; diff --git a/src/version.js b/src/version.js index 30cbd1a..be9db68 100644 --- a/src/version.js +++ b/src/version.js @@ -1 +1 @@ -export const VERSION = '1.0.3'; +export const VERSION = '1.0.4'; diff --git a/tests/fetch.test.js b/tests/fetch.test.js new file mode 100644 index 0000000..1706e1a --- /dev/null +++ b/tests/fetch.test.js @@ -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; + } +}); diff --git a/tests/parser.test.js b/tests/parser.test.js index a8e3b14..ab02988 100644 --- a/tests/parser.test.js +++ b/tests/parser.test.js @@ -79,6 +79,7 @@ test('wrapScriptCode provides async OpenScript APIs without GM polyfills', () => assert.match(wrapped, /async function\(OpenScript, env\)/); assert.ok(wrapped.includes('"API_KEY":"secret123"')); assert.ok(wrapped.includes("call('list')")); + assert.ok(wrapped.includes('OPEN_SCRIPT_FETCH')); assert.ok(wrapped.includes('storage-token')); assert.ok(wrapped.includes(code)); assert.ok(!wrapped.includes('GM_getValue'));