Compare commits

...

6 Commits

Author SHA1 Message Date
Hendrik Eeckhaut
650553e793 Removed bundled plugins and bump version to 1201
* Removed bundled plugins

* bump version to 1201
2025-08-20 14:32:20 +02:00
dylan1951
de676eb498 fix: cookie parsing logic (again) (#194)
Co-authored-by: dylan <dylanbradshaw107@hotmail.com>
2025-08-19 16:48:14 +02:00
Tanner
d334286cbd Sidepanel supports <input> fields for user input through plugins (#195)
Co-authored-by: Hendrik Eeckhaut <hendrik@eeckhaut.org>
2025-08-18 19:23:23 +02:00
baptistapedro
538331c847 remove defaultPluginsInstalled as it blocks testing with tlsn-plugin-boilerplate (#198) 2025-08-18 10:43:15 +02:00
Hendrik Eeckhaut
df1af592b6 Interactive verifier support in browser extension (#196)
* refactor: turn p2p prover into interactive verifier plugin prover
2025-08-18 10:01:59 +02:00
Tanner
7db5b12629 Network Tab moved to Dev Mode (#197) 2025-08-14 10:45:26 +02:00
17 changed files with 673 additions and 517 deletions

692
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "tlsn-extension",
"version": "0.1.0.1200",
"version": "0.1.0.1201",
"license": "MIT",
"repository": {
"type": "git",
@@ -94,4 +94,4 @@
"webpack-ext-reloader": "^1.1.12",
"zip-webpack-plugin": "^4.0.1"
}
}
}

Binary file not shown.

View File

@@ -72,16 +72,6 @@ export default function Menu(props: {
>
Verify
</MenuRow>
<MenuRow
fa="fa-solid fa-network-wired"
className="border-b border-slate-300"
onClick={() => {
props.setOpen(false);
navigate('/p2p');
}}
>
P2P
</MenuRow>
<MenuRow
className="lg:hidden"
fa="fa-solid fa-up-right-and-down-left-from-center"

View File

@@ -24,6 +24,7 @@ import {
MAX_RECEIVED_LS_KEY,
getMaxRecv,
getMaxSent,
getDeveloperMode,
} from '../../utils/storage';
import { MAX_RECV, MAX_SENT } from '../../utils/constants';

View File

@@ -42,10 +42,6 @@ const requestDb = db.sublevel<string, any>('requests', {
valueEncoding: 'json',
});
enum AppDatabaseKey {
DefaultPluginsInstalled = 'DefaultPluginsInstalled',
}
export async function upsertRequestLog(request: UpsertRequestLog) {
const existing = await getRequestLog(request.requestId);
@@ -146,6 +142,17 @@ export async function addNotaryRequestProofs(
return newReq;
}
export async function setNotaryRequestSessionId(
id: string,
sessionId: string,
): Promise<RequestHistory | null> {
const existing = await historyDb.get(id);
if (!existing) return null;
const newReq: RequestHistory = { ...existing, sessionId };
await historyDb.put(id, newReq);
return newReq;
}
export async function setNotaryRequestStatus(
id: string,
status: '' | 'pending' | 'success' | 'error',
@@ -441,8 +448,11 @@ export async function getCookiesByHost(linkOrHost: string) {
for (const header of filteredRequest.requestHeaders) {
if (header.name.toLowerCase() === 'cookie') {
header.value?.split(';').forEach((cookie) => {
const [name, value] = cookie.split('=');
ret[name.trim()] = value.trim();
const i = cookie.indexOf('=');
if (i !== -1) {
const name = cookie.slice(0, i).trim();
ret[name] = cookie.slice(i + 1).trim();
}
});
}
}
@@ -555,24 +565,6 @@ export async function getSessionStorageByHost(host: string) {
return ret;
}
async function getDefaultPluginsInstalled(): Promise<string | boolean> {
return appDb.get(AppDatabaseKey.DefaultPluginsInstalled).catch(() => false);
}
export async function setDefaultPluginsInstalled(
installed: string | boolean = false,
) {
return mutex.runExclusive(async () => {
await appDb.put(AppDatabaseKey.DefaultPluginsInstalled, installed);
});
}
export async function getAppState() {
return {
defaultPluginsInstalled: await getDefaultPluginsInstalled(),
};
}
export async function resetDB() {
return mutex.runExclusive(async () => {
return Promise.all([

View File

@@ -1,11 +1,6 @@
import { onBeforeRequest, onResponseStarted, onSendHeaders } from './handlers';
import browser from 'webextension-polyfill';
import {
getAppState,
removePlugin,
removeRequestLogsByTabId,
setDefaultPluginsInstalled,
} from './db';
import { removePlugin, removeRequestLogsByTabId } from './db';
import { installPlugin } from './plugins/utils';
(async () => {
@@ -37,40 +32,6 @@ import { installPlugin } from './plugins/utils';
removeRequestLogsByTabId(tabId);
});
const { defaultPluginsInstalled } = await getAppState();
switch (defaultPluginsInstalled) {
case false: {
try {
const twitterProfileUrl = browser.runtime.getURL(
'twitter_profile.wasm',
);
const discordDmUrl = browser.runtime.getURL('discord_dm.wasm');
await installPlugin(twitterProfileUrl);
await installPlugin(discordDmUrl);
} finally {
await setDefaultPluginsInstalled('0.1.0.703');
}
break;
}
case true: {
try {
await removePlugin(
'6931d2ad63340d3a1fb1a5c1e3f4454c5a518164d6de5ad272e744832355ee02',
);
const twitterProfileUrl = browser.runtime.getURL(
'twitter_profile.wasm',
);
await installPlugin(twitterProfileUrl);
} finally {
await setDefaultPluginsInstalled('0.1.0.703');
}
break;
}
case '0.1.0.703':
break;
}
const { initRPC } = await import('./rpc');
await createOffscreenDocument();
initRPC();

View File

@@ -18,13 +18,12 @@ import {
removePluginConfig,
getCookiesByHost,
getHeadersByHost,
getAppState,
setDefaultPluginsInstalled,
setLocalStorage,
setSessionStorage,
setNotaryRequestProgress,
getRequestLogsByTabId,
clearAllRequestLogs,
setNotaryRequestSessionId,
} from './db';
import { addOnePlugin, removeOnePlugin } from '../../reducers/plugins';
import {
@@ -222,6 +221,7 @@ export type RequestHistory = {
metadata?: {
[k: string]: string;
};
sessionId?: string;
};
export const initRPC = () => {
@@ -279,12 +279,6 @@ export const initRPC = () => {
case BackgroundActiontype.get_logging_level:
getLoggingFilter().then(sendResponse);
return true;
case BackgroundActiontype.get_app_state:
getAppState().then(sendResponse);
return true;
case BackgroundActiontype.set_default_plugins_installed:
setDefaultPluginsInstalled(request.data).then(sendResponse);
return true;
case BackgroundActiontype.set_local_storage:
return handleSetLocalStorage(request, sender, sendResponse);
case BackgroundActiontype.set_session_storage:
@@ -397,8 +391,9 @@ async function handleFinishProveRequest(
request: BackgroundAction,
sendResponse: (data?: any) => void,
) {
const { id, proof, error, verification } = request.data;
const { id, proof, error, verification, sessionId } = request.data;
console.log('handleFinishProveRequest', request.data);
if (proof) {
const newReq = await addNotaryRequestProofs(id, proof);
if (!newReq) return;
@@ -420,6 +415,12 @@ async function handleFinishProveRequest(
await pushToRedux(addRequestHistory(await getNotaryRequest(id)));
}
if (sessionId) {
const newReq = await setNotaryRequestSessionId(id, sessionId);
if (!newReq) return;
await pushToRedux(addRequestHistory(await getNotaryRequest(id)));
}
return sendResponse();
}
@@ -528,6 +529,7 @@ async function runPluginProver(request: BackgroundAction, now = Date.now()) {
websocketProxyUrl: _websocketProxyUrl,
maxSentData: _maxSentData,
maxRecvData: _maxRecvData,
metadata,
} = request.data;
const notaryUrl = _notaryUrl || (await getNotaryApi());
const websocketProxyUrl = _websocketProxyUrl || (await getProxyApi());
@@ -547,6 +549,7 @@ async function runPluginProver(request: BackgroundAction, now = Date.now()) {
maxSentData,
secretHeaders,
secretResps,
metadata,
});
await setNotaryRequestStatus(id, 'pending');
@@ -713,24 +716,38 @@ async function runP2PPluginProver(request: BackgroundAction, now = Date.now()) {
websocketProxyUrl: _websocketProxyUrl,
maxSentData: _maxSentData,
maxRecvData: _maxRecvData,
clientId,
verifierPlugin,
notaryUrl,
} = request.data;
const rendezvousApi = await getRendezvousApi();
const proverUrl = `${rendezvousApi}?clientId=${clientId}:proof`;
const websocketProxyUrl = _websocketProxyUrl || (await getProxyApi());
const maxSentData = _maxSentData || (await getMaxSent());
const maxRecvData = _maxRecvData || (await getMaxRecv());
const { id } = await addNotaryRequest(now, {
url,
method,
headers,
body,
notaryUrl,
websocketProxyUrl,
maxRecvData,
maxSentData,
secretHeaders,
secretResps: [],
});
await browser.runtime.sendMessage({
type: OffscreenActionTypes.start_p2p_prover,
data: {
id,
pluginUrl,
pluginHex,
url,
method,
headers,
body,
proverUrl,
proverUrl: notaryUrl,
verifierPlugin,
websocketProxyUrl,
maxRecvData,
maxSentData,
@@ -1068,10 +1085,11 @@ async function handleRunPluginByURLRequest(request: BackgroundAction) {
const onPluginRequest = async (req: any) => {
if (req.type !== SidePanelActionTypes.execute_plugin_response) return;
console.log('onPluginRequest', req.data);
if (req.data.url !== url) return;
if (req.data.error) defer.reject(req.data.error);
if (req.data.proof) defer.resolve(req.data.proof);
if (req.data.sessionId) defer.resolve(req.data.sessionId);
browser.runtime.onMessage.removeListener(onPluginRequest);
};

View File

@@ -245,6 +245,7 @@ export const startP2PVerifier = async (request: any) => {
export const startP2PProver = async (request: any) => {
const {
id,
pluginUrl,
pluginHex,
url,
@@ -257,53 +258,56 @@ export const startP2PProver = async (request: any) => {
maxSentData,
secretHeaders,
getSecretResponse,
verifierPlugin,
} = request.data;
const hostname = urlify(url)?.hostname || '';
updateRequestProgress(id, RequestProgress.CreatingProver);
const prover: TProver = await new Prover({
id: pluginUrl,
id,
serverDns: hostname,
maxSentData,
maxRecvData,
serverIdentity: true,
});
browser.runtime.sendMessage({
type: BackgroundActiontype.prover_instantiated,
data: {
pluginUrl,
updateRequestProgress(id, RequestProgress.GettingSession);
const resp = await fetch(`${proverUrl}/session`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
clientType: 'Websocket',
maxRecvData,
maxSentData,
plugin: 'plugin-js',
}),
});
const { sessionId } = await resp.json();
const _url = new URL(proverUrl);
const protocol = _url.protocol === 'https:' ? 'wss' : 'ws';
const pathname = _url.pathname;
const sessionUrl = `${protocol}://${_url.host}${pathname === '/' ? '' : pathname}/notarize?sessionId=${sessionId!}`;
const proofRequestStart = waitForEvent(
OffscreenActionTypes.start_p2p_proof_request,
updateRequestProgress(id, RequestProgress.SettingUpProver);
await prover.setup(sessionUrl);
await handleProgress(
id,
RequestProgress.SendingRequest,
() =>
prover.sendRequest(websocketProxyUrl + `?token=${hostname}`, {
url,
method,
headers,
body,
}),
`Error connecting to websocket proxy: ${websocketProxyUrl}. Please check the proxy URL and ensure it's accessible.`,
);
const proverSetup = prover.setup(proverUrl);
await new Promise((r) => setTimeout(r, 5000));
browser.runtime.sendMessage({
type: BackgroundActiontype.prover_setup,
data: {
pluginUrl,
},
});
await proverSetup;
browser.runtime.sendMessage({
type: BackgroundActiontype.prover_started,
data: {
pluginUrl,
},
});
await proofRequestStart;
await prover.sendRequest(websocketProxyUrl + `?token=${hostname}`, {
url,
method,
headers,
body,
});
updateRequestProgress(id, RequestProgress.ReadingTranscript);
const transcript = await prover.transcript();
let secretResps: string[] = [];
@@ -344,9 +348,15 @@ export const startP2PProver = async (request: any) => {
),
};
const endRequest = waitForEvent(OffscreenActionTypes.end_p2p_proof_request);
await prover.reveal({ ...commit, server_identity: false });
await endRequest;
await prover.reveal({ ...commit, server_identity: true });
updateRequestProgress(id, RequestProgress.FinalizingOutputs);
browser.runtime.sendMessage({
type: BackgroundActiontype.finish_prove_request,
data: {
id,
sessionId: sessionId,
},
});
};
async function createProof(options: {

View File

@@ -7,6 +7,7 @@ import {
makePlugin,
PluginConfig,
StepConfig,
InputFieldConfig,
} from '../../utils/misc';
import DefaultPluginIcon from '../../assets/img/default-plugin-icon.png';
import logo from '../../assets/img/icon-128.png';
@@ -149,6 +150,14 @@ function PluginBody({
proof: notaryRequest.proof,
},
});
} else if (notaryRequest?.sessionId) {
browser.runtime.sendMessage({
type: SidePanelActionTypes.execute_plugin_response,
data: {
url,
sessionId: notaryRequest.sessionId,
},
});
} else if (notaryRequest?.status === 'error') {
browser.runtime.sendMessage({
type: SidePanelActionTypes.execute_plugin_response,
@@ -161,7 +170,7 @@ function PluginBody({
},
});
}
}, [url, notaryRequest?.status]);
}, [url, notaryRequest?.status, notaryRequest?.sessionId]);
return (
<div className="flex flex-col p-4">
@@ -225,13 +234,27 @@ function StepContent(
p2p = false,
clientId = '',
parameterValues,
inputs,
} = props;
const [completed, setCompleted] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState('');
const [notarizationId, setNotarizationId] = useState('');
const [inputValues, setInputValues] = useState<Record<string, string>>({});
const notaryRequest = useRequestHistory(notarizationId);
useEffect(() => {
if (inputs) {
const initialValues: Record<string, string> = {};
inputs.forEach((input) => {
if (input.defaultValue) {
initialValues[input.name] = input.defaultValue;
}
});
setInputValues(initialValues);
}
}, [inputs]);
const getPlugin = useCallback(async () => {
const hex = (await getPluginByUrl(url)) || _hex;
const arrayBuffer = hexToArrayBuffer(hex!);
@@ -243,16 +266,31 @@ function StepContent(
if (!plugin) return;
if (index > 0 && !lastResponse) return;
// Validate required input fields
if (inputs) {
for (const input of inputs) {
if (
input.required &&
(!inputValues[input.name] || inputValues[input.name].trim() === '')
) {
setError(`${input.label} is required`);
return;
}
}
}
setPending(true);
setError('');
try {
const out = await plugin.call(
action,
index > 0
? JSON.stringify(lastResponse)
: JSON.stringify(parameterValues),
);
let stepData: any;
if (index > 0) {
stepData = lastResponse;
} else {
stepData = { ...parameterValues, ...inputValues };
}
const out = await plugin.call(action, JSON.stringify(stepData));
const val = JSON.parse(out!.string());
if (val && prover) {
setNotarizationId(val);
@@ -266,7 +304,16 @@ function StepContent(
} finally {
setPending(false);
}
}, [action, index, lastResponse, prover, getPlugin]);
}, [
action,
index,
lastResponse,
prover,
getPlugin,
inputs,
inputValues,
parameterValues,
]);
const onClick = useCallback(() => {
if (
@@ -311,11 +358,16 @@ function StepContent(
}, []);
useEffect(() => {
processStep();
}, [processStep]);
// only auto-progress if this step does need inputs
if (!inputs || inputs.length === 0) {
processStep();
}
}, [processStep, inputs]);
let btnContent = null;
console.log('notaryRequest', notaryRequest);
console.log('notarizationId', notarizationId);
if (prover && p2p) {
btnContent = (
<button
@@ -327,7 +379,7 @@ function StepContent(
<span className="text-sm">View in P2P</span>
</button>
);
} else if (completed) {
} else if (completed || notaryRequest?.sessionId) {
btnContent = (
<button
className={classNames(
@@ -420,8 +472,105 @@ function StepContent(
</div>
</div>
)}
{inputs && inputs.length > 0 && !completed && (
<div className="flex flex-col gap-3 mt-3">
{inputs.map((input) => (
<InputField
key={input.name}
config={input}
value={inputValues[input.name] || ''}
onChange={(value) =>
setInputValues((prev) => ({ ...prev, [input.name]: value }))
}
/>
))}
</div>
)}
{btnContent}
</div>
</div>
);
}
interface InputFieldProps {
config: InputFieldConfig;
value: string;
onChange: (value: string) => void;
disabled?: boolean;
}
function InputField({
config,
value,
onChange,
disabled = false,
}: InputFieldProps): ReactElement {
const { name, label, type, placeholder, required, options } = config;
const baseClasses =
'w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent';
const renderInput = () => {
switch (type) {
case 'textarea':
return (
<textarea
id={name}
name={name}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
required={required}
disabled={disabled}
className={classNames(baseClasses, 'resize-y min-h-[80px]')}
rows={3}
/>
);
case 'select':
return (
<select
id={name}
name={name}
value={value}
onChange={(e) => onChange(e.target.value)}
required={required}
disabled={disabled}
className={baseClasses}
>
<option value="">{placeholder || 'Select an option'}</option>
{options?.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
default:
return (
<input
type={type}
id={name}
name={name}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
required={required}
disabled={disabled}
className={baseClasses}
/>
);
}
};
return (
<div className="flex flex-col gap-1">
<label htmlFor={name} className="text-sm font-medium text-gray-700">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
{renderInput()}
</div>
);
}

View File

@@ -28,7 +28,7 @@
],
"web_accessible_resources": [
{
"resources": ["content.styles.css", "icon-128.png", "icon-34.png", "content.bundle.js", "discord_dm.wasm", "twitter_profile.wasm"],
"resources": ["content.styles.css", "icon-128.png", "icon-34.png", "content.bundle.js"],
"matches": ["http://*/*", "https://*/*", "<all_urls>"]
}
],

View File

@@ -30,6 +30,12 @@ export default function Home(props: {
getDeveloperMode().then(setDeveloperMode);
}, []);
useEffect(() => {
if (props.tab === 'network' && !developerMode) {
setTab('history');
}
}, [props.tab, developerMode]);
useEffect(() => {
const element = scrollableContent.current;
if (!element) return;
@@ -65,12 +71,14 @@ export default function Home(props: {
},
)}
>
<TabSelector
onClick={() => setTab('network')}
selected={tab === 'network'}
>
Network
</TabSelector>
{developerMode && (
<TabSelector
onClick={() => setTab('network')}
selected={tab === 'network'}
>
Network
</TabSelector>
)}
<TabSelector
onClick={() => setTab('history')}
selected={tab === 'history'}
@@ -88,7 +96,9 @@ export default function Home(props: {
</div>
<div className="flex-grow">
{tab === 'history' && <History />}
{tab === 'network' && <Requests shouldFix={shouldFix} />}
{tab === 'network' && developerMode && (
<Requests shouldFix={shouldFix} />
)}
{tab === 'plugins' && (
<PluginList
className="p-2 overflow-y-auto"

View File

@@ -384,15 +384,6 @@ function AdvancedOptions(props: {
setDirty(true);
}}
/>
<InputField
label="Rendezvous API (for P2P)"
value={rendezvous}
type="text"
onChange={(e) => {
setRendezvous(e.target.value);
setDirty(true);
}}
/>
<div className="flex flex-col flex-nowrap py-1 px-2 gap-2">
<div className="font-semibold">Logging Level</div>
<select

View File

@@ -151,6 +151,15 @@ export default function ProofViewer(props?: {
label="Notary Key"
value={props?.notaryKey || request?.verification?.notaryKey}
/>
{request?.metadata &&
Object.entries(request.metadata).map(([key, value]) => (
<MetadataRow
key={`req-${key}`}
label={`Custom: ${key}`}
value={String(value)}
/>
))}
</div>
)}
</div>

View File

@@ -188,11 +188,13 @@ export const makePlugin = async (
} = {
redirect: function (context: CallContext, off: bigint) {
const r = context.read(off);
if (!r) throw new Error('Failed to read context');
const url = r.text();
browser.tabs.update(tab.id, { url });
},
notarize: function (context: CallContext, off: bigint) {
const r = context.read(off);
if (!r) throw new Error('Failed to read context');
const params = JSON.parse(r.text());
const now = Date.now();
const id = charwise.encode(now).toString('hex');
@@ -221,11 +223,20 @@ export const makePlugin = async (
}
(async () => {
const { getSecretResponse, body: reqBody } = params;
const {
getSecretResponse,
body: reqBody,
interactive,
verifierPlugin,
} = params;
if (meta?.p2p) {
console.log('interactive', interactive);
console.log('verifierPlugin', verifierPlugin);
console.log('params', params);
if (interactive) {
const pluginHex = Buffer.from(arrayBuffer).toString('hex');
const pluginUrl = await sha256(pluginHex);
handleExecP2PPluginProver({
type: BackgroundActiontype.execute_p2p_plugin_prover,
data: {
@@ -234,7 +245,7 @@ export const makePlugin = async (
pluginHex,
body: reqBody,
now,
clientId: meta.clientId,
verifierPlugin,
},
});
} else {
@@ -338,7 +349,10 @@ export const makePlugin = async (
const pluginConfig: ExtismPluginOptions = {
useWasi: true,
config: injectedConfig,
config: {
...injectedConfig,
tabId: tab.id?.toString() || '',
},
// allowedHosts: approvedRequests.map((r) => urlify(r.url)?.origin),
functions: {
'extism:host/user': funcs,
@@ -349,12 +363,23 @@ export const makePlugin = async (
return plugin;
};
export type InputFieldConfig = {
name: string; // Unique identifier for the input field
label: string; // Display label for the input
type: 'text' | 'password' | 'email' | 'number' | 'textarea' | 'select'; // Input field type
placeholder?: string; // Optional placeholder text
required?: boolean; // Whether the field is required
defaultValue?: string; // Default value for the field
options?: { value: string; label: string }[]; // Options for select type
};
export type StepConfig = {
title: string; // Text for the step's title
description?: string; // Text for the step's description (optional)
cta: string; // Text for the step's call-to-action button
action: string; // The function name that this step will execute
prover?: boolean; // Boolean indicating if this step outputs a notarization (optional)
inputs?: InputFieldConfig[]; // Input fields for user data collection (optional)
};
export type PluginConfig = {
@@ -382,6 +407,7 @@ export const getPluginConfig = async (
): Promise<PluginConfig> => {
const plugin = data instanceof ArrayBuffer ? await makePlugin(data) : data;
const out = await plugin.call('config');
if (!out) throw new Error('Plugin config call returned null');
const config: PluginConfig = JSON.parse(out.string());
assert(typeof config.title === 'string' && config.title.length);
@@ -439,6 +465,23 @@ export const getPluginConfig = async (
assert(typeof step.cta === 'string' && step.cta.length);
assert(typeof step.action === 'string' && step.action.length);
assert(!step.prover || typeof step.prover === 'boolean');
if (step.inputs) {
for (const input of step.inputs) {
assert(typeof input.name === 'string' && input.name.length);
assert(typeof input.label === 'string' && input.label.length);
assert(!input.placeholder || typeof input.placeholder === 'string');
assert(!input.required || typeof input.required === 'boolean');
assert(!input.defaultValue || typeof input.defaultValue === 'string');
if (input.type === 'select') {
assert(Array.isArray(input.options) && input.options.length > 0);
for (const option of input.options!) {
assert(typeof option.value === 'string');
assert(typeof option.label === 'string');
}
}
}
}
}
}

View File

@@ -205,16 +205,6 @@ var options = {
to: path.join(__dirname, "build"),
force: true,
},
{
from: "src/assets/plugins/discord_dm.wasm",
to: path.join(__dirname, "build"),
force: true,
},
{
from: "src/assets/plugins/twitter_profile.wasm",
to: path.join(__dirname, "build"),
force: true,
},
],
}),
new HtmlWebpackPlugin({