diff --git a/package-lock.json b/package-lock.json index 92085273..c36db6d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27706,12 +27706,6 @@ "node": ">= 8" } }, - "node_modules/web-worker": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", - "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", - "license": "Apache-2.0" - }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -30219,8 +30213,7 @@ "license": "Apache-2.0", "dependencies": { "acorn": "^8.18.0", - "javascript-stringify": "^2.1.0", - "web-worker": "^1.5.0" + "javascript-stringify": "^2.1.0" }, "devDependencies": { "@mongodb-js/eslint-config-devtools": "^0.11.14", diff --git a/packages/shell-bson-parser/README.md b/packages/shell-bson-parser/README.md index 14e25f70..6992b982 100644 --- a/packages/shell-bson-parser/README.md +++ b/packages/shell-bson-parser/README.md @@ -87,7 +87,7 @@ This options object will disallow method calls, but will allow comments ## Worker -Every call to `parse` is sent to a shared worker (using [`web-worker`](https://github.com/developit/web-worker), so it works in both Node.js and browsers). The worker is created lazily on the first call and reused for later calls. BSON values are serialized across the worker boundary and rebuilt as `bson` instances on the calling side. +Every call to `parse` is sent to a shared worker: a Web Worker in browsers, or a `worker_threads` worker in Node.js. The worker code ships inside the package and is loaded lazily on the first `parse` call, so it works the same when bundled and adds nothing for consumers that only use `toJSString`. The worker is created lazily on the first call and reused for later calls. BSON values are serialized across the worker boundary and rebuilt as `bson` instances on the calling side. If parsing throws inside the worker, the returned promise rejects with the error message. diff --git a/packages/shell-bson-parser/package.json b/packages/shell-bson-parser/package.json index 265e1b1f..848d20fa 100644 --- a/packages/shell-bson-parser/package.json +++ b/packages/shell-bson-parser/package.json @@ -53,8 +53,7 @@ }, "dependencies": { "acorn": "^8.18.0", - "javascript-stringify": "^2.1.0", - "web-worker": "^1.5.0" + "javascript-stringify": "^2.1.0" }, "peerDependencies": { "bson": "^4.6.3 || ^5 || ^6.10.3 || ^7.3.2" diff --git a/packages/shell-bson-parser/src/browser-worker.ts b/packages/shell-bson-parser/src/browser-worker.ts new file mode 100644 index 00000000..c24c3e80 --- /dev/null +++ b/packages/shell-bson-parser/src/browser-worker.ts @@ -0,0 +1,35 @@ +import type { WorkerHandle, WorkerCallbacks } from './worker-types.js'; + +export function createBrowserWorker( + source: string, + { onMessage, onError }: WorkerCallbacks, +): WorkerHandle { + // Compass Web injects whatwg-url for bare URL references; it lacks blob APIs. + // Use the native constructor for blob creation and revocation. + const blobUrl = globalThis.URL.createObjectURL( + new Blob([source], { type: 'text/javascript' }), + ); + try { + const worker = new globalThis.Worker(blobUrl); + worker.addEventListener('message', (event: MessageEvent) => + onMessage(event.data), + ); + worker.addEventListener('error', (event: ErrorEvent) => + onError(new Error(event.message || 'Worker error')), + ); + worker.addEventListener('messageerror', () => + onError(new Error('Worker message could not be deserialized')), + ); + + return { + postMessage: (message) => worker.postMessage(message), + terminate: () => { + worker.terminate(); + globalThis.URL.revokeObjectURL(blobUrl); + }, + }; + } catch (err) { + globalThis.URL.revokeObjectURL(blobUrl); + throw err; + } +} diff --git a/packages/shell-bson-parser/src/index.spec.ts b/packages/shell-bson-parser/src/index.spec.ts index ce11ccf2..34a3d650 100644 --- a/packages/shell-bson-parser/src/index.spec.ts +++ b/packages/shell-bson-parser/src/index.spec.ts @@ -3,15 +3,15 @@ import sinon from 'sinon'; import vm from 'vm'; import fs from 'fs/promises'; import path from 'path'; -import { fileURLToPath } from 'url'; -import * as WebWorkerModule from 'web-worker'; +import { fileURLToPath, pathToFileURL } from 'url'; +import { Worker as NodeWorker } from 'worker_threads'; import * as api from './index.js'; import { terminateWorker, callWorker, getExecutionTimeoutMs, - getWorkerScriptUrl, + setWorkerSource, } from './worker-client.js'; import { restrictGlobalScope, @@ -21,39 +21,42 @@ import { } from './worker.js'; import { PARSE_TEST_CASES } from './../test/parse-test-cases.js'; -const WebWorker = (WebWorkerModule as unknown as { default: typeof Worker }) - .default; +const specDir = path.dirname(fileURLToPath(import.meta.url)); describe('shell-bson-parser with webworker processing', function () { - const initialWorkerScriptUrl = process.env.TEST_WORKER_SCRIPT_URL; + let workerBundle: string; - before(function () { - process.env.TEST_WORKER_SCRIPT_URL = '../dist/worker.js'; + before(async function () { + // Specs run from src, where worker-source.ts is an empty placeholder. + workerBundle = await fs.readFile( + path.join(specDir, '..', 'dist', 'worker.js'), + 'utf8', + ); + setWorkerSource(workerBundle); }); after(function () { - if (initialWorkerScriptUrl) { - process.env.TEST_WORKER_SCRIPT_URL = initialWorkerScriptUrl; - } else { - delete process.env.TEST_WORKER_SCRIPT_URL; - } + setWorkerSource(null); terminateWorker(); }); - it('returns the file URL for the worker script', async function () { - const initialWorkerScriptUrl = process.env.TEST_WORKER_SCRIPT_URL; - delete process.env.TEST_WORKER_SCRIPT_URL; + it('inlines the bundled worker into dist/worker-source.js', async function () { + const { default: inlined } = await import( + pathToFileURL(path.join(specDir, '..', 'dist', 'worker-source.js')).href + ); + expect(inlined).to.equal(workerBundle); + }); + it('rejects when the worker source is missing', async function () { + terminateWorker(); + setWorkerSource(''); try { - expect(await getWorkerScriptUrl()).to.equal( - new URL('./worker.js', import.meta.url).toString(), - ); + await api.parse('{ a: 1 }'); + expect.fail('Expected parse to throw'); + } catch (err) { + expect((err as Error).message).to.match(/worker source is missing/); } finally { - if (initialWorkerScriptUrl) { - process.env.TEST_WORKER_SCRIPT_URL = initialWorkerScriptUrl; - } else { - delete process.env.TEST_WORKER_SCRIPT_URL; - } + setWorkerSource(workerBundle); } }); @@ -176,7 +179,7 @@ describe('shell-bson-parser with webworker processing', function () { let terminateSpy: sinon.SinonSpy; beforeEach(function () { - terminateSpy = sinon.spy(WebWorker.prototype, 'terminate'); + terminateSpy = sinon.spy(NodeWorker.prototype, 'terminate'); }); afterEach(function () { @@ -199,14 +202,118 @@ describe('shell-bson-parser with webworker processing', function () { }); }); - it('strips dangerous globals and locks down Object.prototype when the real worker starts', async function () { - const workerBundlePath = path.join( - path.dirname(fileURLToPath(import.meta.url)), - '..', - 'dist', - 'worker.js', - ); - const code = await fs.readFile(workerBundlePath, 'utf8'); + describe('worker lifecycle', function () { + afterEach(function () { + terminateWorker(); + setWorkerSource(workerBundle); + }); + + it('rejects pending requests right away when the worker thread exits', async function () { + terminateWorker(); + setWorkerSource('process.exit(3)'); + try { + await callWorker([1]); + expect.fail('Expected callWorker to throw'); + } catch (err) { + expect((err as Error).message).to.equal('Worker exited with code 3'); + } + }); + + it('does not let the exit of a terminated worker kill its replacement', async function () { + await api.parse('{ a: 1 }'); + terminateWorker(); + // The old thread's 'exit' event lands while the new request is pending + const res = await api.parse('{ b: 2 }'); + expect(res).to.deep.equal({ b: 2 }); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(await api.parse('{ c: 3 }')).to.deep.equal({ c: 3 }); + }); + }); + + describe('browser worker', function () { + type Listener = (event: unknown) => void; + + class FakeWorker { + static instances: FakeWorker[] = []; + listeners = new Map(); + terminated = false; + constructor(public url: string) { + FakeWorker.instances.push(this); + } + addEventListener(type: string, fn: Listener) { + this.listeners.set(type, fn); + } + postMessage(message: { id: number }) { + const respond = this.listeners.get('message'); + setTimeout(() => + respond?.({ + data: { + id: message.id, + ok: true, + result: { data: 'from-browser', bsonTypes: new Map() }, + }, + }), + ); + } + terminate() { + this.terminated = true; + } + } + + let createObjectURL: sinon.SinonStub; + let revokeObjectURL: sinon.SinonStub; + + beforeEach(function () { + terminateWorker(); + FakeWorker.instances = []; + (globalThis as { Worker?: unknown }).Worker = FakeWorker; + createObjectURL = sinon + .stub(globalThis.URL, 'createObjectURL') + .returns('blob:fake'); + revokeObjectURL = sinon.stub(globalThis.URL, 'revokeObjectURL'); + }); + + afterEach(function () { + terminateWorker(); + delete (globalThis as { Worker?: unknown }).Worker; + createObjectURL.restore(); + revokeObjectURL.restore(); + }); + + it('starts the worker from a blob URL and revokes it on terminate', async function () { + expect(await callWorker(['x'])).to.equal('from-browser'); + expect(FakeWorker.instances).to.have.length(1); + expect(FakeWorker.instances[0].url).to.equal('blob:fake'); + const blob = createObjectURL.firstCall.args[0] as Blob; + expect(await blob.text()).to.equal(workerBundle); + expect(revokeObjectURL.called).to.equal(false); + + terminateWorker(); + expect(FakeWorker.instances[0].terminated).to.equal(true); + expect(revokeObjectURL.calledOnceWith('blob:fake')).to.equal(true); + }); + + it('rejects pending requests on a worker error event', async function () { + sinon.stub(FakeWorker.prototype, 'postMessage').callsFake(function ( + this: FakeWorker, + ) { + // Never responds; fails with an error event instead + setTimeout(() => this.listeners.get('error')?.({ message: 'boom' })); + }); + try { + await callWorker(['x']); + expect.fail('Expected callWorker to throw'); + } catch (err) { + expect((err as Error).message).to.equal('boom'); + } finally { + (FakeWorker.prototype.postMessage as sinon.SinonStub).restore(); + } + expect(FakeWorker.instances[0].terminated).to.equal(true); + }); + }); + + it('strips dangerous globals and locks down Object.prototype when the real worker starts', function () { + const code = workerBundle; const sandbox: Record = Object.create(null); let messageHandler: ((event: MessageEvent) => void) | undefined; @@ -265,20 +372,19 @@ describe('shell-bson-parser with webworker processing', function () { }); describe('execution timeout', function () { - const initialWorkerScriptUrl = process.env.TEST_WORKER_SCRIPT_URL; - - beforeEach(function () { + beforeEach(async function () { terminateWorker(); - process.env.TEST_WORKER_SCRIPT_URL = '../test/fixtures/slow-worker.mjs'; + setWorkerSource( + await fs.readFile( + path.join(specDir, '..', 'test', 'fixtures', 'slow-worker.js'), + 'utf8', + ), + ); }); afterEach(function () { terminateWorker(); - if (initialWorkerScriptUrl) { - process.env.TEST_WORKER_SCRIPT_URL = initialWorkerScriptUrl; - } else { - delete process.env.TEST_WORKER_SCRIPT_URL; - } + setWorkerSource(workerBundle); }); it('rejects a request whose worker thread is wedged past the timeout', async function () { diff --git a/packages/shell-bson-parser/src/node-worker.ts b/packages/shell-bson-parser/src/node-worker.ts new file mode 100644 index 00000000..c8f9ff34 --- /dev/null +++ b/packages/shell-bson-parser/src/node-worker.ts @@ -0,0 +1,41 @@ +import type { Worker as NodeWorker } from 'worker_threads'; +import type { WorkerHandle, WorkerCallbacks } from './worker-types.js'; + +export function createNodeWorker( + source: string, + { onMessage, onError }: WorkerCallbacks, +): WorkerHandle { + // getBuiltinModule keeps bundlers from rewriting or polyfilling the import. + const threads = + typeof process !== 'undefined' && process.getBuiltinModule + ? process.getBuiltinModule('worker_threads') + : undefined; + if (!threads) { + throw new Error( + '@mongodb-js/shell-bson-parser needs a global Worker or process.getBuiltinModule', + ); + } + + // The worker bundle talks to worker_threads' parentPort itself. + const worker: NodeWorker = new threads.Worker(source, { eval: true }); + // Fails pending requests right away if the thread dies, instead of waiting + // for their timeout. Also fires after terminate(), ignored by the client. + worker.on('exit', (code: number) => + onError(new Error(`Worker exited with code ${code}`)), + ); + worker.on('message', onMessage); + worker.on('error', (err: Error) => + onError(new Error(err?.message || 'Worker error')), + ); + worker.on('messageerror', () => + onError(new Error('Worker message could not be deserialized')), + ); + // An idle worker should not keep the host process alive. Must come after + // the listeners, since adding a 'message' listener re-refs the port. + worker.unref(); + + return { + postMessage: (message) => worker.postMessage(message), + terminate: () => void worker.terminate(), + }; +} diff --git a/packages/shell-bson-parser/src/worker-client.ts b/packages/shell-bson-parser/src/worker-client.ts index 006bb83d..02618821 100644 --- a/packages/shell-bson-parser/src/worker-client.ts +++ b/packages/shell-bson-parser/src/worker-client.ts @@ -1,8 +1,11 @@ -import * as WebWorkerModule from 'web-worker'; -const WebWorker = (WebWorkerModule as unknown as { default: typeof Worker }) - .default; +import { createBrowserWorker } from './browser-worker.js'; +import { createNodeWorker } from './node-worker.js'; import { trackBSON, untrackBSON } from './structured-clone-bson.js'; -import type { WorkerResponse } from './worker-types.js'; +import type { + WorkerHandle, + WorkerCallbacks, + WorkerResponse, +} from './worker-types.js'; /** Default execution timeout for worker requests */ const DEFAULT_EXECUTION_TIMEOUT_MS = 5_000; @@ -20,9 +23,9 @@ export function getExecutionTimeoutMs(executionTimeoutMs?: number): number { return executionTimeoutMs; } -let worker: Worker | null = null; -let workerPromise: Promise | null = null; -let blobUrl: string | null = null; +let worker: WorkerHandle | null = null; +let workerSourceOverride: string | null = null; +let workerPromise: Promise | null = null; let nextId = 0; const pending = new Map< number, @@ -34,93 +37,78 @@ const pending = new Map< >(); /** Exported for tests */ -export async function getWorkerScriptUrl(): Promise { - const override = - typeof process !== 'undefined' - ? process.env.TEST_WORKER_SCRIPT_URL - : undefined; - if (override) { - return new URL(override, import.meta.url).toString(); - } +export function setWorkerSource(source: string | null): void { + workerSourceOverride = source; +} - const scriptUrl = new URL('./worker.js', import.meta.url); - if (scriptUrl.protocol === 'file:') { - return scriptUrl.toString(); - } - const response = await fetch(scriptUrl); - if (!response.ok) { - throw new Error( - `Failed to fetch shell-bson-parser worker script: ${response.status} ${response.statusText}`, - ); +async function getWorkerSource(): Promise { + if (workerSourceOverride !== null) { + return workerSourceOverride; } - const code = await response.text(); - // Compass Web injects whatwg-url for bare URL references; it lacks blob APIs. - // Use the native constructor for blob creation and revocation. - blobUrl = globalThis.URL.createObjectURL( - new Blob([code], { type: 'text/javascript' }), - ); - return blobUrl; + const source = await import('./worker-source.js'); + return source.default; } -function createWorker(): Promise { +function createWorker(): Promise { if (worker) { return Promise.resolve(worker); } if (workerPromise) { return workerPromise; } - - workerPromise = (async () => { - const scriptUrl = await getWorkerScriptUrl(); - const newWorker = new WebWorker( - scriptUrl, - typeof globalThis.Worker === 'function' ? undefined : { type: 'module' }, - ); - const onMessageHandler = (event: MessageEvent) => { - const response = event.data; - const entry = pending.get(response.id); - if (!entry) { - return; - } - clearTimeout(entry.executionTimer); - pending.delete(response.id); - if (!response.ok) { - const { error, errorProps } = response; - entry.reject( - typeof error === 'object' && error !== null - ? Object.assign(error, errorProps) - : error, - ); - return; - } - try { - entry.resolve(untrackBSON(response.result)); - } catch (err) { - entry.reject(err as Error); - } - }; - - const onErrorHandler = (event: ErrorEvent) => { - terminateWorker(new Error(event.message || 'Worker error')); - }; - - const onMessageErrorHandler = () => { - terminateWorker(new Error('Worker message could not be deserialized')); + const onMessage = (response: WorkerResponse) => { + const entry = pending.get(response.id); + if (!entry) { + return; + } + clearTimeout(entry.executionTimer); + pending.delete(response.id); + if (!response.ok) { + const { error, errorProps } = response; + entry.reject( + typeof error === 'object' && error !== null + ? Object.assign(error, errorProps) + : error, + ); + return; + } + try { + entry.resolve(untrackBSON(response.result)); + } catch (err) { + entry.reject(err as Error); + } + }; + + const startup = (async () => { + const source = await getWorkerSource(); + if (!source) { + throw new Error( + '@mongodb-js/shell-bson-parser worker source is missing from the build', + ); + } + // Late errors/exits from an already terminated worker must not kill its + // replacement, so only react when this handle is still the active one. + let newWorker: WorkerHandle | null = null; + const handlers: WorkerCallbacks = { + onMessage, + onError: (err) => { + if (newWorker && newWorker === worker) terminateWorker(err); + }, }; - - newWorker.addEventListener('message', onMessageHandler); - newWorker.addEventListener('error', onErrorHandler); - newWorker.addEventListener('messageerror', onMessageErrorHandler); + newWorker = + typeof globalThis.Worker === 'function' + ? createBrowserWorker(source, handlers) + : createNodeWorker(source, handlers); worker = newWorker; return newWorker; })(); - - workerPromise.catch(() => { - workerPromise = null; + startup.catch(() => { + if (workerPromise === startup) workerPromise = null; }); + workerPromise = startup; - return workerPromise; + return startup; } export async function callWorker( @@ -149,9 +137,11 @@ export async function callWorker( }); } catch (err) { const entry = pending.get(id); - if (entry) clearTimeout(entry.executionTimer); - pending.get(id)?.reject(err as Error); - pending.delete(id); + if (entry) { + clearTimeout(entry.executionTimer); + pending.delete(id); + entry.reject(err as Error); + } } return promise; } @@ -160,11 +150,9 @@ export function terminateWorker( reason: Error = new Error('Worker terminated'), ): void { if (worker) worker.terminate(); - if (blobUrl) globalThis.URL.revokeObjectURL(blobUrl); worker = null; workerPromise = null; - blobUrl = null; for (const [id, entry] of pending) { clearTimeout(entry.executionTimer); diff --git a/packages/shell-bson-parser/src/worker-source.ts b/packages/shell-bson-parser/src/worker-source.ts new file mode 100644 index 00000000..043c0634 --- /dev/null +++ b/packages/shell-bson-parser/src/worker-source.ts @@ -0,0 +1,4 @@ +// Placeholder, overwritten in dist with the bundled worker code by +// webpack.worker.config.cjs so the worker never has to be resolved by path. +const workerSource = ''; +export default workerSource; diff --git a/packages/shell-bson-parser/src/worker-types.ts b/packages/shell-bson-parser/src/worker-types.ts index 41ff331c..7bda8417 100644 --- a/packages/shell-bson-parser/src/worker-types.ts +++ b/packages/shell-bson-parser/src/worker-types.ts @@ -9,3 +9,13 @@ export type WorkerResponse = { id: number } & ( | { ok: true; result: TrackedPayload } | { ok: false; error: Error; errorProps: Record } ); + +export type WorkerCallbacks = { + onMessage: (response: WorkerResponse) => void; + onError: (err: Error) => void; +}; + +export type WorkerHandle = { + postMessage: (message: unknown) => void; + terminate: () => void; +}; diff --git a/packages/shell-bson-parser/src/worker.ts b/packages/shell-bson-parser/src/worker.ts index 095cbb1a..137ac597 100644 --- a/packages/shell-bson-parser/src/worker.ts +++ b/packages/shell-bson-parser/src/worker.ts @@ -4,6 +4,10 @@ import { trackBSON, untrackBSON } from './structured-clone-bson.js'; import type { WorkerRequest, WorkerResponse } from './worker-types.js'; const { self, postMessage } = globalThis; +const parentPort = + typeof process !== 'undefined' && process.getBuiltinModule + ? process.getBuiltinModule('worker_threads')?.parentPort + : null; // Exported for tests export const ALLOWED_GLOBALS = new Set([ @@ -82,7 +86,14 @@ export function handleRequest(request: WorkerRequest): WorkerResponse { } } -if (typeof self !== 'undefined') { +if (parentPort) { + const postMessage = parentPort.postMessage.bind(parentPort); + parentPort.on('message', (request: WorkerRequest) => { + postMessage(handleRequest(request)); + }); + restrictObjectPrototype(); + restrictGlobalScope(globalThis); +} else if (typeof self !== 'undefined') { const sendResponse = postMessage.bind(self); self.addEventListener('message', (event: MessageEvent) => { sendResponse(handleRequest(event.data)); diff --git a/packages/shell-bson-parser/test/fixtures/slow-worker.mjs b/packages/shell-bson-parser/test/fixtures/slow-worker.js similarity index 76% rename from packages/shell-bson-parser/test/fixtures/slow-worker.mjs rename to packages/shell-bson-parser/test/fixtures/slow-worker.js index c1c47b89..a6ac6a93 100644 --- a/packages/shell-bson-parser/test/fixtures/slow-worker.mjs +++ b/packages/shell-bson-parser/test/fixtures/slow-worker.js @@ -1,21 +1,22 @@ // Avoiding the import of these two functions, // adding them here to keep this file self-contained +const { parentPort } = require('worker_threads'); + function untrackBSON(value) { return value.data; } function trackBSON(value) { return { data: value, bsonTypes: new Map() }; } -self.onmessage = (event) => { - const { id, args } = event.data; +parentPort.on('message', ({ id, args }) => { const [delayMs] = untrackBSON(args); const start = Date.now(); while (Date.now() - start < delayMs) { // noop } - self.postMessage({ + parentPort.postMessage({ id, ok: true, result: trackBSON('done'), }); -}; +}); diff --git a/packages/shell-bson-parser/webpack.cjs.config.cjs b/packages/shell-bson-parser/webpack.cjs.config.cjs index 821719c2..e1de7828 100644 --- a/packages/shell-bson-parser/webpack.cjs.config.cjs +++ b/packages/shell-bson-parser/webpack.cjs.config.cjs @@ -1,6 +1,5 @@ 'use strict'; const path = require('path'); -const webpack = require('webpack'); const distDir = path.resolve(__dirname, 'dist'); const cjsDir = path.join(distDir, 'cjs'); @@ -10,12 +9,7 @@ module.exports = { target: 'node', entry: { index: path.join(distDir, 'index.js'), - 'web-worker': { - import: 'web-worker', - library: { type: 'commonjs2', export: 'default' }, - }, }, - resolve: { conditionNames: ['import', 'node', 'default'] }, output: { path: cjsDir, clean: true, @@ -27,31 +21,16 @@ module.exports = { /^acorn/, /^javascript-stringify/, /^bson/, - ({ request, contextInfo }, callback) => { - if (request === 'web-worker' && contextInfo.issuer) { - return callback(null, 'commonjs ./web-worker.js'); - } - callback(); - }, + ({ request }, callback) => + request === './worker-source.js' + ? callback(null, 'import ../worker-source.js') + : callback(), ], module: { parser: { javascript: { - url: false, - import: false, importExportsPresence: 'warn', }, }, }, - plugins: [ - new webpack.DefinePlugin({ - 'import.meta.url': webpack.DefinePlugin.runtimeValue( - ({ module }) => - module.resource === path.join(distDir, 'worker-client.js') - ? 'require("node:url").pathToFileURL(require("node:path").join(__dirname, "..", "worker-client.js")).href' - : 'require("node:url").pathToFileURL(__filename).href', - true, - ), - }), - ], }; diff --git a/packages/shell-bson-parser/webpack.worker.config.cjs b/packages/shell-bson-parser/webpack.worker.config.cjs index 0f736a65..66ba8d37 100644 --- a/packages/shell-bson-parser/webpack.worker.config.cjs +++ b/packages/shell-bson-parser/webpack.worker.config.cjs @@ -1,12 +1,30 @@ 'use strict'; +const fs = require('fs'); const path = require('path'); +const distDir = path.resolve(__dirname, 'dist'); + +// Inlines the bundled worker as a string module, so consumers never have to +// resolve worker.js by path (which breaks once they bundle this package). +class InlineWorkerSourcePlugin { + apply(compiler) { + compiler.hooks.afterEmit.tap('InlineWorkerSourcePlugin', () => { + const code = fs.readFileSync(path.join(distDir, 'worker.js'), 'utf8'); + fs.writeFileSync( + path.join(distDir, 'worker-source.js'), + `const workerSource = ${JSON.stringify(code)};\nexport default workerSource;\n`, + ); + fs.rmSync(path.join(distDir, 'worker-source.js.map'), { force: true }); + }); + } +} + module.exports = { mode: 'production', target: 'webworker', - entry: path.resolve(__dirname, 'dist', 'worker.js'), + entry: path.join(distDir, 'worker.js'), output: { - path: path.resolve(__dirname, 'dist'), + path: distDir, filename: 'worker.js', }, module: { @@ -18,4 +36,5 @@ module.exports = { }, }, }, + plugins: [new InlineWorkerSourcePlugin()], };