Skip to content

Commit d27a5e8

Browse files
committed
[WIP] feat: Have injectScript return the result value from the script
1 parent de58942 commit d27a5e8

File tree

3 files changed

+198
-12
lines changed

3 files changed

+198
-12
lines changed

packages/wxt/src/utils/inject-script.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/** @module wxt/utils/inject-script */
22
import { browser } from 'wxt/browser';
3+
import { waitForScriptResultEvent } from './internal/script-result';
34

45
export type ScriptPublicPath = Extract<
56
// @ts-expect-error: PublicPath is generated per-project
@@ -15,11 +16,13 @@ export type ScriptPublicPath = Extract<
1516
*
1617
* Make sure to add the injected script to your manifest's
1718
* `web_accessible_resources`.
19+
*
20+
* @returns The return value of the script.
1821
*/
1922
export async function injectScript(
2023
path: ScriptPublicPath,
2124
options?: InjectScriptOptions,
22-
): Promise<void> {
25+
): Promise<unknown> {
2326
// @ts-expect-error: getURL is defined per-project, but not inside the package
2427
const url = browser.runtime.getURL(path);
2528
const script = document.createElement('script');
@@ -33,6 +36,7 @@ export async function injectScript(
3336
}
3437

3538
const loadedPromise = makeLoadedPromise(script);
39+
const resultPromise = waitForScriptResultEvent(script);
3640

3741
await options?.manipulateScript?.(script);
3842

@@ -43,6 +47,9 @@ export async function injectScript(
4347
}
4448

4549
await loadedPromise;
50+
const result = await resultPromise;
51+
52+
return result;
4653
}
4754

4855
function makeLoadedPromise(script: HTMLScriptElement): Promise<void> {
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// The `MAIN` world script cannot use `browser.runtime.id`, but the uniqueness
2+
// of the event name is not an issue as the event is only dispatched on a
3+
// specific `script` element which is detached from the document.
4+
const SCRIPT_RESULT_EVENT_NAME = 'wxt:script-result';
5+
6+
/**
7+
* Dispatch a `CustomEvent` for a successful script result which carries a
8+
* result value.
9+
*/
10+
export function dispatchScriptSuccessEvent(
11+
target: EventTarget,
12+
value: unknown,
13+
): boolean {
14+
return dispatchScriptResultEvent(target, scriptSuccess(value));
15+
}
16+
17+
/**
18+
* Dispatch a `CustomEvent` for a failed script result which carries an `Error`
19+
* value.
20+
*/
21+
export function dispatchScriptErrorEvent(
22+
target: EventTarget,
23+
error: Error,
24+
): boolean {
25+
return dispatchScriptResultEvent(target, scriptError(error));
26+
}
27+
28+
function dispatchScriptResultEvent(
29+
target: EventTarget,
30+
result: ScriptResult,
31+
): boolean {
32+
return target.dispatchEvent(
33+
new CustomEvent(SCRIPT_RESULT_EVENT_NAME, { detail: result }),
34+
);
35+
}
36+
37+
/**
38+
* Add a one-time event listener for a script result `CustomEvent`, and return a
39+
* `Promise` which either resolves with the result value or rejects with an
40+
* error depending on whether the result is successful or not.
41+
*/
42+
export function waitForScriptResultEvent(
43+
target: EventTarget,
44+
): Promise<unknown> {
45+
return new Promise((resolve, reject) => {
46+
target.addEventListener(
47+
SCRIPT_RESULT_EVENT_NAME,
48+
(event) => {
49+
try {
50+
resolve(parseScriptResultEvent(event));
51+
} catch (err) {
52+
reject(err);
53+
}
54+
},
55+
{ once: true },
56+
);
57+
});
58+
}
59+
60+
function parseScriptResultEvent(event: Event): unknown {
61+
if (!(event instanceof CustomEvent)) {
62+
throw new Error(
63+
`Expected a \`CustomEvent\`, got: ${JSON.stringify(event)}`,
64+
);
65+
}
66+
67+
return parseScriptResult(event.detail as unknown);
68+
}
69+
70+
type ScriptResult =
71+
| { type: 'success'; value: unknown }
72+
| { type: 'error'; error: Error };
73+
74+
function scriptSuccess(value: unknown): ScriptResult {
75+
return { type: 'success', value };
76+
}
77+
78+
function scriptError(error: Error): ScriptResult {
79+
return { type: 'error', error: deconstructError(error) };
80+
}
81+
82+
function parseScriptResult(result: unknown): unknown {
83+
if (!isScriptResult(result)) {
84+
throw new Error(
85+
`Expected a \`ScriptResult\`, got: ${JSON.stringify(result)}`,
86+
);
87+
}
88+
89+
switch (result.type) {
90+
case 'success':
91+
return result.value;
92+
case 'error':
93+
throw reconstructError(result.error);
94+
default:
95+
throw new Error(
96+
`Impossible \`ScriptResult\`: ${JSON.stringify(result satisfies never)}`,
97+
);
98+
}
99+
}
100+
101+
function isScriptResult(result: unknown): result is ScriptResult {
102+
return (
103+
result != null &&
104+
typeof result === 'object' &&
105+
'type' in result &&
106+
typeof result.type === 'string' &&
107+
((result.type === 'success' && 'value' in result) ||
108+
(result.type === 'error' &&
109+
'error' in result &&
110+
isDeconstructedError(result.error)))
111+
);
112+
}
113+
114+
/**
115+
* A representation of an `Error` which can be transmitted within a
116+
* `CustomEvent`.
117+
*/
118+
type DeconstructedError = {
119+
name: string;
120+
message: string;
121+
stack?: string;
122+
};
123+
124+
function deconstructError(error: Error): DeconstructedError {
125+
const result: DeconstructedError = {
126+
name: error.name,
127+
message: error.message,
128+
};
129+
130+
if ('stack' in error) result.stack = error.stack;
131+
132+
return result;
133+
}
134+
135+
function reconstructError(deconstructedError: DeconstructedError): Error {
136+
const error = new Error(deconstructedError.message);
137+
error.name = deconstructedError.name;
138+
139+
if ('stack' in deconstructedError) {
140+
error.stack = deconstructedError.stack;
141+
} else {
142+
delete error.stack;
143+
}
144+
145+
return error;
146+
}
147+
148+
function isDeconstructedError(value: unknown): value is DeconstructedError {
149+
return (
150+
value != null &&
151+
typeof value === 'object' &&
152+
'name' in value &&
153+
value.name != null &&
154+
typeof value.name === 'string' &&
155+
'message' in value &&
156+
value.message != null &&
157+
typeof value.message === 'string' &&
158+
(!('stack' in value) ||
159+
(value.stack != null && typeof value.stack === 'string'))
160+
);
161+
}
Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,40 @@
11
import definition from 'virtual:user-unlisted-script-entrypoint';
22
import { logger } from '../utils/internal/logger';
3+
import {
4+
dispatchScriptSuccessEvent,
5+
dispatchScriptErrorEvent,
6+
} from '../utils/internal/script-result';
37
import { initPlugins } from 'virtual:wxt-plugins';
48

5-
const result = (async () => {
9+
// TODO: This only works with injectScript, not the scripting API.
10+
11+
(async () => {
612
try {
7-
initPlugins();
8-
return await definition.main();
9-
} catch (err) {
13+
const script = document.currentScript;
14+
if (script === null) {
15+
// This should never happen.
16+
throw new Error(`\`document.currentScript\` is null!`);
17+
}
18+
19+
try {
20+
initPlugins();
21+
const result = await definition.main();
22+
dispatchScriptSuccessEvent(script, result);
23+
} catch (error) {
24+
dispatchScriptErrorEvent(
25+
script,
26+
error instanceof Error ? error : new Error(`${error}`),
27+
);
28+
}
29+
} catch (outerError) {
30+
// This should never happen.
1031
logger.error(
1132
`The unlisted script "${import.meta.env.ENTRYPOINT}" crashed on startup!`,
12-
err,
33+
outerError,
1334
);
14-
throw err;
35+
throw outerError;
1536
}
1637
})();
1738

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

0 commit comments

Comments
 (0)