Skip to content

[WIP sketch] feat: Have injectScript return the result value from the script #1765

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/guide/essentials/content-scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,29 @@ For MV3, `injectScript` is synchronous and the injected script will be evaluated
However for MV2, `injectScript` has to `fetch` the script's text content and create an inline `<script>` block. This means for MV2, your script is injected asynchronously and it will not be evaluated at the same time as your content script's `run_at`.
:::

The `script` element can be manipulated just before it is added to the DOM by using the `manipulateScript` option. This can be used to e.g. pass data to the script as shown in the example:

```ts
// entrypoints/example.content.ts
export default defineContentScript({
matches: ['*://*/*'],
async main() {
await injectScript('/example-main-world.js', {
manipulateScript(script) {
script.dataset['greeting'] = 'Hello there';
},
});
},
});
```

```ts
// entrypoints/example-main-world.ts
export default defineUnlistedScript(() => {
console.log(document.currentScript?.dataset['greeting']);
});
```

## Mounting UI to dynamic element

In many cases, you may need to mount a UI to a DOM element that does not exist at the time the web page is initially loaded. To handle this, use the `autoMount` API to automatically mount the UI when the target element appears dynamically and unmount it when the element disappears. In WXT, the `anchor` option is used to target the element, enabling automatic mounting and unmounting based on its appearance and removal.
Expand Down
49 changes: 46 additions & 3 deletions packages/wxt/src/utils/inject-script.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** @module wxt/utils/inject-script */
import { browser } from 'wxt/browser';
import { waitForScriptResultEvent } from './internal/script-result';

export type ScriptPublicPath = Extract<
// @ts-expect-error: PublicPath is generated per-project
Expand All @@ -15,11 +16,13 @@ export type ScriptPublicPath = Extract<
*
* Make sure to add the injected script to your manifest's
* `web_accessible_resources`.
*
* @returns The return value of the script.
*/
export async function injectScript(
path: ScriptPublicPath,
options?: InjectScriptOptions,
): Promise<void> {
): Promise<unknown> {
// @ts-expect-error: getURL is defined per-project, but not inside the package
const url = browser.runtime.getURL(path);
const script = document.createElement('script');
Expand All @@ -32,11 +35,43 @@ export async function injectScript(
script.src = url;
}

const loadedPromise = makeLoadedPromise(script);
const resultPromise = waitForScriptResultEvent(script);

await options?.manipulateScript?.(script);

(document.head ?? document.documentElement).append(script);

if (!options?.keepInDom) {
script.onload = () => script.remove();
script.remove();
}

(document.head ?? document.documentElement).append(script);
await loadedPromise;
const result = await resultPromise;

return result;
}

function makeLoadedPromise(script: HTMLScriptElement): Promise<void> {
return new Promise((resolve, reject) => {
const onload = () => {
resolve();
cleanup();
};

const onerror = () => {
reject(new Error(`Failed to load script: ${script.src}`));
cleanup();
};

const cleanup = () => {
script.removeEventListener('load', onload);
script.removeEventListener('error', onerror);
};

script.addEventListener('load', onload);
script.addEventListener('error', onerror);
});
}

export interface InjectScriptOptions {
Expand All @@ -45,4 +80,12 @@ export interface InjectScriptOptions {
* injected. To disable this behavior, set this flag to true.
*/
keepInDom?: boolean;
/**
* Manipulate the script element just before it is added to the DOM.
*
* It can be useful for e.g. passing data to the script via the `dataset`
* property (which can be accessed by the script via
* `document.currentScript`).
*/
manipulateScript?: (script: HTMLScriptElement) => Promise<void> | void;
}
161 changes: 161 additions & 0 deletions packages/wxt/src/utils/internal/script-result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// The `MAIN` world script cannot use `browser.runtime.id`, but the uniqueness
// of the event name is not an issue as the event is only dispatched on a
// specific `script` element which is detached from the document.
const SCRIPT_RESULT_EVENT_NAME = 'wxt:script-result';

/**
* Dispatch a `CustomEvent` for a successful script result which carries a
* result value.
*/
export function dispatchScriptSuccessEvent(
target: EventTarget,
value: unknown,
): boolean {
return dispatchScriptResultEvent(target, scriptSuccess(value));
}

/**
* Dispatch a `CustomEvent` for a failed script result which carries an `Error`
* value.
*/
export function dispatchScriptErrorEvent(
target: EventTarget,
error: Error,
): boolean {
return dispatchScriptResultEvent(target, scriptError(error));
}

function dispatchScriptResultEvent(
target: EventTarget,
result: ScriptResult,
): boolean {
return target.dispatchEvent(
new CustomEvent(SCRIPT_RESULT_EVENT_NAME, { detail: result }),
);
}

/**
* Add a one-time event listener for a script result `CustomEvent`, and return a
* `Promise` which either resolves with the result value or rejects with an
* error depending on whether the result is successful or not.
*/
export function waitForScriptResultEvent(
target: EventTarget,
): Promise<unknown> {
return new Promise((resolve, reject) => {
target.addEventListener(
SCRIPT_RESULT_EVENT_NAME,
(event) => {
try {
resolve(parseScriptResultEvent(event));
} catch (err) {
reject(err);
}
},
{ once: true },
);
});
}

function parseScriptResultEvent(event: Event): unknown {
if (!(event instanceof CustomEvent)) {
throw new Error(
`Expected a \`CustomEvent\`, got: \`${event.constructor.name}\``,
);
}

return parseScriptResult(event.detail as unknown);
}

type ScriptResult =
| { type: 'success'; value: unknown }
| { type: 'error'; error: Error };

function scriptSuccess(value: unknown): ScriptResult {
return { type: 'success', value };
}

function scriptError(error: Error): ScriptResult {
return { type: 'error', error: deconstructError(error) };
}

function parseScriptResult(result: unknown): unknown {
if (!isScriptResult(result)) {
throw new Error(
`Expected a \`ScriptResult\`, got: ${JSON.stringify(result)}`,
);
}

switch (result.type) {
case 'success':
return result.value;
case 'error':
throw reconstructError(result.error);
default:
throw new Error(
`Impossible \`ScriptResult\`: ${JSON.stringify(result satisfies never)}`,
);
}
}

function isScriptResult(result: unknown): result is ScriptResult {
return (
result != null &&
typeof result === 'object' &&
'type' in result &&
typeof result.type === 'string' &&
((result.type === 'success' && 'value' in result) ||
(result.type === 'error' &&
'error' in result &&
isDeconstructedError(result.error)))
);
}

/**
* A representation of an `Error` which can be transmitted within a
* `CustomEvent`.
*/
type DeconstructedError = {
name: string;
message: string;
stack?: string;
};

function deconstructError(error: Error): DeconstructedError {
const result: DeconstructedError = {
name: error.name,
message: error.message,
};

if ('stack' in error) result.stack = error.stack;

return result;
}

function reconstructError(deconstructedError: DeconstructedError): Error {
const error = new Error(deconstructedError.message);
error.name = deconstructedError.name;

if ('stack' in deconstructedError) {
error.stack = deconstructedError.stack;
} else {
delete error.stack;
}

return error;
}

function isDeconstructedError(value: unknown): value is DeconstructedError {
return (
value != null &&
typeof value === 'object' &&
'name' in value &&
value.name != null &&
typeof value.name === 'string' &&
'message' in value &&
value.message != null &&
typeof value.message === 'string' &&
(!('stack' in value) ||
(value.stack != null && typeof value.stack === 'string'))
);
}
40 changes: 29 additions & 11 deletions packages/wxt/src/virtual/unlisted-script-entrypoint.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,40 @@
import definition from 'virtual:user-unlisted-script-entrypoint';
import { logger } from '../utils/internal/logger';
import {
dispatchScriptSuccessEvent,
dispatchScriptErrorEvent,
} from '../utils/internal/script-result';
import { initPlugins } from 'virtual:wxt-plugins';

const result = (async () => {
// TODO: This only works with injectScript, not the scripting API.

(async () => {
try {
initPlugins();
return await definition.main();
} catch (err) {
const script = document.currentScript;
if (script === null) {
// This should never happen.
throw new Error(`\`document.currentScript\` is null!`);
}

try {
initPlugins();
const result = await definition.main();
dispatchScriptSuccessEvent(script, result);
} catch (error) {
dispatchScriptErrorEvent(
script,
error instanceof Error ? error : new Error(`${error}`),
);
}
} catch (outerError) {
// This should never happen.
logger.error(
`The unlisted script "${import.meta.env.ENTRYPOINT}" crashed on startup!`,
err,
outerError,
);
throw err;
throw outerError;
}
})();

// Return the main function's result to the background when executed via the
// scripting API. Default export causes the IIFE to return a value.
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/executeScript#return_value
// Tested on both Chrome and Firefox
export default result;
// TODO: The build result fails without this.
export default undefined;