# jsdom-qjs
⚠️ Security notice (early release)
-
This project embeds QuickJS and emulates a limited browser environment using JSDOM.
The sandbox design and escape surface are still under active security review.
Until this review is complete, treat all untrusted input as potentially unsafe
and avoid production use.

------
JSDOM wrapper running inside QuickJS interpreter that allows pages to be evaluated inside a WebAssembly sandbox. It bundles a custom JSDOM build, and provides multiple resource loading options: local filesystem loader, external network fetcher, or a hybrid mode combining both. 

For the safest execution, you can supply your own HTML and disable the resource loader altogether, the resource loader is disabled by default as well. You can choose a resource loader based on your security requirements: no loader (safest), local mode (restricts access to the specified archive directory), external mode (allows network access for both resource loading and fetch API in general), and hybrid mode enables both (relatively unsafe). The package [`quickjs-emscripten`](https://www.npmjs.com/package/quickjs-emscripten) is used for the QuickJS runtime. 

## Features
- Runs the JSDOM bundle inside QuickJS to avoid untrusted host-side JS execution.
- Provides a similar API to the JSDOM module.
- Flexible resource loading with security controls: local filesystem (restricted to archive directory), external network fetcher, or hybrid mode with fallback. Sequential loading applies to `<script>` elements. Archive structure matches HTTrack's default format.
- Exposes QuickJS console output through a log buffer (`qjsLogs`).

## Installation

```sh
npm i jsdom-qjs
```


## Core API

```js
const {createJSDOM, setUrlWhitelist, setUrlBlacklist, clearUrlFilters} = require("jsdom-qjs");
```

### `await createJSDOM(options = {})`
Initialises QuickJS and returns an object with:

- `JSDOM` – constructor for sandboxed DOM instances.
- `context` – the active QuickJS context (reused across JSDOM instances).
- `scope` – shared `Scope` used to dispose handles.
- `qjsLogs` – array capturing console output from QuickJS.

Supported options:

- `qjsInstance` – reuse an existing QuickJS module instance (optional).
- `context` – reuse an existing QuickJS context (optional).
- `bundleCode` – pass a custom JSDOM bundle string instead of `jsdom.js`.
- `additionalPolyfills` – string injected before the bundle loads (used for analysis helpers).
- `dangerousExternalFetcher` – set to `true` to enable external fetching of resources instead of local archive lookups. If enabled, `localResourceLoader` must be `false` unless `allowLocalAndExternal` is `true`.
- `allowLocalAndExternal` – when `true`, allows both local archive lookups and external fetching. Falls back to external fetch if local file is not found.
- `customFetch` – custom fetch function to use for external resource fetching (defaults to global `fetch`). The fetch function gets wrapped to handle URL whitelist/blacklist settings and to block access to localhost.
- `fetchOptions` – options object passed to the fetch function when fetching external resources.
- `createArchive` – when `true` with external fetcher, automatically archives fetched resources to the local filesystem.
- `overwriteOnArchive` – when `true` with `createArchive`, overwrites existing archived files with newly fetched content.

### `new JSDOM(html, jsdomOptions)`
Constructs a sandboxed DOM. The following `jsdomOptions` are either specific to this package or vital for it to run as intended. All regular JSDOM options are also passed to the instance inside QuickJS.
- `timeout` – interrupt deadline for `dom.eval` and script execution.
- `onloadTimeout` – extra guard for delayed `onload` handlers.
- `memoryLimit` – forwarded to QuickJS `setMemoryLimit`.
- `archiveRoot` – absolute path to the directory containing the downloaded site; required unless `localResourceLoader: false`.
- `url` – origin for relative resource resolution
- `localResourceLoader` – set to `false` to disable local archive lookups.
- `runScripts` – must be `"dangerously"` to enable executing scripts inside the page, `"outside-only"` by default.  


JSDOM instances expose:

- `resourcePromise` – resolves once scripts, timeouts, and pending jobs finish. If regular `window.onload` fails to run due to an error during JSDOM creation, it is manually triggered after this.
- `eval(code, { inWindow, timeout })` – runs the specified code within a sandboxed environment (it executes in the window context if inWindow: true). Alternatively, you can use `dom.window.eval`, which is a shorthand for calling eval with `{ inWindow: true }`.
- `window` – lightweight [proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) that fetch properties via QuickJS.
- `logs` – alias for `qjsLogs` from the factory.
- `dispose()` – releases QuickJS handles, clears timeouts, and optionally tears down the context.

### URL Filtering (Whitelist/Blacklist)

Control which URLs can be fetched when using external or hybrid resource loading modes. Only one filter type can be active at a time.

#### `setUrlWhitelist(patterns)`
Enable whitelist mode. Only URLs matching at least one pattern will be allowed. Non-matching URLs receive a 404 response.

**Parameters:**
- `patterns` - A single pattern, array of patterns, or a custom function
  - String patterns support wildcards (`*` matches any characters)
  - RegExp objects for complex matching
  - Function receives URL string and should return `true` to allow

```js
// Pattern-based whitelist
setUrlWhitelist(['https://example.com/*', 'https://cdn.example.com/*']);

// RegExp patterns
setUrlWhitelist([/^https:\/\/.*\.example\.com\/.*/]);

// Or use a custom  function
setUrlWhitelist((url) => {
  const trustedDomains = ['example.com', 'api.example.com'];
  try {
    const urlObj = new URL(url);
    return urlObj.protocol === 'https:' && trustedDomains.includes(urlObj.hostname);
  } catch {
    return false;
  }
});
```

#### `setUrlBlacklist(patterns)`
Create blacklist for URLs. URLs matching any pattern will be blocked with a 404 response.

**Parameters:**
- `patterns` - A single pattern, array of patterns, or a custom function
  - String patterns support wildcards (`*` matches any characters)
  - RegExp objects for complex matching
  - Function receives URL string and should return `true` to block

```js
// Block trackers
setUrlBlacklist([
  'https://ads.example.com/*',
  'https://tracker.example.com/*',
  /.*analytics.*/
]);

// Or use a custom function to block based on URL characteristics
setUrlBlacklist((url) => {
  const blockKeywords = ['ads', 'tracking', 'analytics', 'pixel'];
  return blockKeywords.some(keyword => url.toLowerCase().includes(keyword));
});
```

#### `clearUrlFilters()`
Disable all URL filtering. All URLs except the ones that resolve to localhost will be allowed.

```js
clearUrlFilters();
```

**Notes:**
- URL filters only affect external fetching (requires `dangerousExternalFetcher: true`)
- Blocked URLs return 404 responses to minimize breaking websites
- Only whitelist OR blacklist can be active (throws error if both attempted)
- Call `clearUrlFilters()` before switching between whitelist and blacklist

## Usage Example

```js
const polyfills = "/* YOUR POLYFILL SCRIPT */"
const analysisContent = "/* YOUR ANALYSIS SCRIPT */"
// Resource loader is limited to the directory specified
const archivePath = path.join(ARCHIVE_DIR, folderName);

const { JSDOM, context, scope, qjsLogs } = await createJSDOM({
// These are ran before the JSDOM is created, you can use it to patch behvaiors of functions or polyfill missing functions
  additionalPolyfills: polyfills,
});

const dom = new JSDOM(codeContent, {
  url: "https://example.com", // Resources are fetched relative to this url, see example archive tree below
  archiveRoot: archivePath,
  runScripts: "dangerously",
  pretendToBeVisual: true,
  timeout: MAX_EXECUTION_TIME_MS,
  memoryLimit: MAX_MEMORY_BYTES,
});
const sharedObjHandle = scope.manage(context.newObject());
context.setProp(context.global, "sharedObj", sharedObjHandle);
await new Promise((res,rej)=>{
    dom.window.onload = async () => {
        // set sharedObj.result and sharedObj.error inside your script
        const analysisResult = await dom.eval(analysisContent);
        context.unwrapResult(analysisResult);
        analysisResult.dispose();   
        const sharedObjHandle = scope.manage(context.getProp(context.global, "sharedObj"));
        // Read `sharedObj.result` or `sharedObj.error` back
        const resultHandle = context.getProp(context.global, "sharedObj");
        const valueHandle = context.getProp(resultHandle, "result");
        const value = context.getString(valueHandle);
        valueHandle.dispose();
        resultHandle.dispose();
        res()
    };
})
await dom.dispose();
```
### Example archive tree
```sh
$ tree
.
├── example.com
│   └── index.html
└── someexternalsourceofexample_com.com
    ├── script.js
    └── style.css


```

### Resource Loading Modes

Choose a loading mode based on your security requirements. For the safest execution, disable the resource loader entirely. Local mode restricts access to the filesystem (archive directory only), external mode allows network access, and hybrid mode enables both.

The loader deduplicates requests and queues script evaluation to mimic browser execution order. Note that promises execute synchronously inside the QuickJS sandbox, but filesystem access and network requests happen asynchronously from the host environment.

#### No Resource Loader (No filesystem or network access)
Disable resource loading entirely when analyzing. This is the default and the safest option as it prevents all filesystem and network access.

```js
const { JSDOM } = await createJSDOM({
  localResourceLoader: false, // This is disabled by default as well
});
const dom = new JSDOM(html, {
  url: "https://example.com",
  runScripts: "dangerously", 
});
```

#### Local Mode (Filesystem access)
Resources are loaded from the local archive directory. Safest option for offline analysis of archived sites.

```js
const { JSDOM } = await createJSDOM();
const dom = new JSDOM(html, {
  url: "https://example.com",
  archiveRoot: "/path/to/archive",
});
```

#### External Mode (Network Access)
Fetches resources from the network. Use when you need live content or want to create archives. Requires `localResourceLoader: false`.

```js
const { JSDOM } = await createJSDOM({
  dangerousExternalFetcher: true,
  localResourceLoader: false,
  customFetch: myCustomFetchFunction, // optional
  createArchive: true, // optionally save to filesystem
});

const dom = new JSDOM(html, {
  url: "https://example.com",
  archiveRoot: "/path/to/archive",
});
```

#### Hybrid Mode (Filesystem + Network Access)
Attempts to fetch from local archive first, falls back to network if not found. Useful for incomplete archives, API's and archives generated with HTTrack.

```js
const { JSDOM } = await createJSDOM({
  dangerousExternalFetcher: true,
  allowLocalAndExternal: true,
  createArchive: true,
});

const dom = new JSDOM(html, {
  url: "https://example.com",
  archiveRoot: "/path/to/archive",
});
```


You can use `scope` instance exposed from createJSDOM with `scope.manage(handle)` for `dom.dispose()` method to take care of disposing the handle.

## Tips
- Always call `await dom.dispose()` to clear timers, pending jobs, and dispose the QuickJS context. All handles must be disposed for proper cleanup.
- For the safest execution, supply your own HTML and set `localResourceLoader: false` to prevent all filesystem and network access.
- Choose your resource loading mode carefully based on security needs: no loader (safest, no access), local mode (filesystem only), external mode (network only), or hybrid mode (both).
- Use `dangerousExternalFetcher: true` only when necessary, as it grants network access to sandboxed code. Consider enabling `createArchive` to persist fetched resources.
- Console output is sent to `qjsLogs` by default. Customize with `consoleLog`, `consoleInfo`, and `consoleError` options in `createJSDOM`.
- Promises are resolved synchronously inside the sandbox, while filesystem and network access (when enabled) happen asynchronously from the host environment.

## Bundling JSDOM
Browserify was used for generating the `jsdom.js` file. If you need to regenerate it due to an update or to patch JSDOM in a different way, apply the patch in `normalized.patch` after bundling and hope for the best.

```sh
browserify api.js -t [ babelify --presets [ @babel/preset-env ] --plugins [ @babel/plugin-proposal-nullish-coalescing-operator ] ] -s jsdom -o jsdom.js
```

## Contributing
Contributions are welcome. Feel free to open issues or submit pull requests.

## License
[Licensed under the MIT License.](./LICENSE.md)

This project includes portions of code derived from other open-source projects.
Details and license information for these components are provided in the [NOTICE](./NOTICE.md) file.