diff --git a/packages/vite-node/src/client.ts b/packages/vite-node/src/client.ts index 50c058328a5c..ddf08103a009 100644 --- a/packages/vite-node/src/client.ts +++ b/packages/vite-node/src/client.ts @@ -180,10 +180,6 @@ export class ViteNodeRunner { const promise = this.directRequest(id, fsPath, callstack) Object.assign(mod, { promise, evaluated: false }) - promise.finally(() => { - mod.evaluated = true - }) - return await promise } @@ -263,9 +259,14 @@ export class ViteNodeRunner { if (externalize) { debugNative(externalize) - const exports = await this.interopedImport(externalize) - mod.exports = exports - return exports + try { + const exports = await this.interopedImport(externalize) + mod.exports = exports + return exports + } + finally { + mod.evaluated = true + } } if (transformed == null) @@ -363,7 +364,12 @@ export class ViteNodeRunner { columnOffset: -codeDefinition.length, }) - await fn(...Object.values(context)) + try { + await fn(...Object.values(context)) + } + finally { + mod.evaluated = true + } return exports } diff --git a/packages/web-worker/README.md b/packages/web-worker/README.md index aa61e8a75721..7ab25887c67b 100644 --- a/packages/web-worker/README.md +++ b/packages/web-worker/README.md @@ -2,7 +2,14 @@ > Web Worker support for Vitest testing. Doesn't require JSDom. -Simulates Web Worker, but in the same thread. Supports both `new Worker(url)` and `import from './worker?worker`. +Simulates Web Worker, but in the same thread. + +Supported: + +- `new Worker(path)` +- `new SharedWorker(path)` +- `import MyWorker from './worker?worker'` +- `import MySharedWorker from './worker?sharedworker'` ## Installing @@ -33,6 +40,22 @@ export default defineConfig({ }) ``` +You can also import `defineWebWorkers` from `@vitest/web-worker/pure` to defined workers, whenever you need: + +```js +import { defineWebWorkers } from '@vitest/web-worker/pure' + +if (process.env.SUPPORT_WORKERS) + defineWebWorkers({ clone: 'none' }) +``` + +It accepts options: + +- `clone`: `'native' | 'ponyfill' | 'none'`. Defines how should `Worker` clone message, when transferring data. Applies only to `Worker` communication. `SharedWorker` uses `MessageChannel` from Node's `worker_threads` module, and is not configurable. + +> **Note** +> Requires Node 17, if you want to use native `structuredClone`. Otherwise, it fallbacks to [polyfill](https://github.com/ungap/structured-clone), if not specified as `none`. You can also configure this option with `VITEST_WEB_WORKER_CLONE` environmental variable. + ## Examples ```ts @@ -59,8 +82,8 @@ worker.onmessage = (e) => { ## Notes -- Does not support `onmessage = () => {}`. Please, use `self.onmessage = () => {}`. +- Worker does not support `onmessage = () => {}`. Please, use `self.onmessage = () => {}`. +- Shared worker does not support `onconnect = () => {}`. Please, use `self.onconnect = () => {}`. - Transferring Buffer will not change its `byteLength`. - You have access to shared global space as your tests. -- Requires Node 17, if you want to use native `structuredClone`. Otherwise, it fallbacks to [polyfill](https://github.com/ungap/structured-clone). You can configure this behavior by passing down `clone` option (`'native' | 'ponyfill' | 'none'`) to `defineWebWorker` or using `VITEST_WEB_WORKER_CLONE` environmental variable. -- If something is wrong, you can debug your worker, using `DEBUG=vitest:web-worker` environmental variable. +- You can debug your worker, using `DEBUG=vitest:web-worker` environmental variable. diff --git a/packages/web-worker/pure.d.ts b/packages/web-worker/pure.d.ts index 59a76669ca65..1bc49ea553d3 100644 --- a/packages/web-worker/pure.d.ts +++ b/packages/web-worker/pure.d.ts @@ -2,6 +2,7 @@ declare type CloneOption = 'native' | 'ponyfill' | 'none'; interface DefineWorkerOptions { clone: CloneOption; } -declare function defineWebWorker(options?: DefineWorkerOptions): void; -export { defineWebWorker }; +declare function defineWebWorkers(options?: DefineWorkerOptions): void; + +export { defineWebWorkers }; diff --git a/packages/web-worker/src/index.ts b/packages/web-worker/src/index.ts index 0b401ea47e3f..6f7f32506ab5 100644 --- a/packages/web-worker/src/index.ts +++ b/packages/web-worker/src/index.ts @@ -1,3 +1,3 @@ -import { defineWebWorker } from './pure' +import { defineWebWorkers } from './pure' -defineWebWorker() +defineWebWorkers() diff --git a/packages/web-worker/src/pure.ts b/packages/web-worker/src/pure.ts index cd752cb34ec5..5c47233ab2aa 100644 --- a/packages/web-worker/src/pure.ts +++ b/packages/web-worker/src/pure.ts @@ -1,253 +1,19 @@ -/* eslint-disable no-restricted-imports */ -import { VitestRunner } from 'vitest/node' -import type { WorkerGlobalState } from 'vitest' -import ponyfillStructuredClone from '@ungap/structured-clone' -import { toFilePath } from 'vite-node/utils' -import createDebug from 'debug' +import { createWorkerConstructor } from './worker' +import type { DefineWorkerOptions } from './types' +import { assertGlobalExists } from './utils' +import { createSharedWorkerConstructor } from './shared-worker' -const debug = createDebug('vitest:web-worker') +export function defineWebWorkers(options?: DefineWorkerOptions) { + if (typeof Worker === 'undefined' || !('__VITEST_WEB_WORKER__' in globalThis.Worker)) { + assertGlobalExists('EventTarget') + assertGlobalExists('MessageEvent') -function getWorkerState(): WorkerGlobalState { - // @ts-expect-error untyped global - return globalThis.__vitest_worker__ -} - -type Procedure = (...args: any[]) => void -type CloneOption = 'native' | 'ponyfill' | 'none' - -interface DefineWorkerOptions { - clone: CloneOption -} - -interface InlineWorkerContext { - onmessage: Procedure | null - name?: string - close: () => void - dispatchEvent: (e: Event) => void - addEventListener: (e: string, fn: Procedure) => void - removeEventListener: (e: string, fn: Procedure) => void - postMessage: (data: any, transfer?: Transferable[] | StructuredSerializeOptions) => void - self: InlineWorkerContext - global: InlineWorkerContext - importScripts?: any -} - -class InlineWorkerRunner extends VitestRunner { - constructor(options: any, private context: InlineWorkerContext) { - super(options) - } - - prepareContext(context: Record) { - const ctx = super.prepareContext(context) - // not supported for now, we can't synchronously load modules - const importScripts = () => { - throw new Error('[vitest] `importScripts` is not supported in Vite workers. Please, consider using `import` instead.') - } - return Object.assign(ctx, this.context, { - importScripts, - }) - } -} - -function assertGlobalExists(name: string) { - if (!(name in globalThis)) - throw new Error(`[@vitest/web-worker] Cannot initiate a custom Web Worker. "${name}" is not supported in this environment. Please, consider using jsdom or happy-dom environment.`) -} - -function createClonedMessageEvent(data: any, transferOrOptions: StructuredSerializeOptions | Transferable[] | undefined, clone: CloneOption) { - const transfer = Array.isArray(transferOrOptions) ? transferOrOptions : transferOrOptions?.transfer - - debug('clone worker message %o', data) - const origin = typeof location === 'undefined' ? undefined : location.origin - - if (typeof structuredClone === 'function' && clone === 'native') { - debug('create message event, using native structured clone') - return new MessageEvent('message', { - data: structuredClone(data, { transfer }), - origin, - }) - } - if (clone !== 'none') { - debug('create message event, using polifylled structured clone') - transfer?.length && console.warn( - '[@vitest/web-worker] `structuredClone` is not supported in this environment. ' - + 'Falling back to polyfill, your transferable options will be lost. ' - + 'Set `VITEST_WEB_WORKER_CLONE` environmental variable to "none", if you don\'t want to loose it,' - + 'or update to Node 17+.', - ) - return new MessageEvent('message', { - data: ponyfillStructuredClone(data, { lossy: true }), - origin, - }) - } - debug('create message event without cloning an object') - return new MessageEvent('message', { - data, - origin, - }) -} - -function createMessageEvent(data: any, transferOrOptions: StructuredSerializeOptions | Transferable[] | undefined, clone: CloneOption) { - try { - return createClonedMessageEvent(data, transferOrOptions, clone) - } - catch (error) { - debug('failed to clone message, dispatch "messageerror" event: %o', error) - return new MessageEvent('messageerror', { - data: error, - }) + globalThis.Worker = createWorkerConstructor(options) } -} - -export function defineWebWorker(options?: DefineWorkerOptions) { - if (typeof Worker !== 'undefined' && '__VITEST_WEB_WORKER__' in globalThis.Worker) - return - - assertGlobalExists('EventTarget') - assertGlobalExists('MessageEvent') - - const { config, rpc, mockMap, moduleCache } = getWorkerState() - - const runnerOptions = { - fetchModule(id: string) { - return rpc.fetch(id) - }, - resolveId(id: string, importer?: string) { - return rpc.resolveId(id, importer) - }, - moduleCache, - mockMap, - interopDefault: config.deps.interopDefault ?? true, - root: config.root, - base: config.base, - } - - const cloneType = () => (options?.clone ?? process.env.VITEST_WEB_WORKER_CLONE ?? 'native') as CloneOption - - globalThis.Worker = class Worker extends EventTarget { - static __VITEST_WEB_WORKER__ = true - - private _vw_workerTarget = new EventTarget() - private _vw_insideListeners = new Map() - private _vw_outsideListeners = new Map() - private _vw_name: string - private _vw_messageQueue: any[] | null = [] - - public onmessage: null | Procedure = null - public onmessageerror: null | Procedure = null - public onerror: null | Procedure = null - - constructor(url: URL | string, options?: WorkerOptions) { - super() - - // should be equal to DedicatedWorkerGlobalScope - const context: InlineWorkerContext = { - onmessage: null, - name: options?.name, - close: () => this.terminate(), - dispatchEvent: (event: Event) => { - return this._vw_workerTarget.dispatchEvent(event) - }, - addEventListener: (...args) => { - if (args[1]) - this._vw_insideListeners.set(args[0], args[1]) - return this._vw_workerTarget.addEventListener(...args) - }, - removeEventListener: this._vw_workerTarget.removeEventListener, - postMessage: (...args) => { - if (!args.length) - throw new SyntaxError('"postMessage" requires at least one argument.') - - debug('posting message %o from the worker %s to the main thread', args[0], this._vw_name) - const event = createMessageEvent(args[0], args[1], cloneType()) - this.dispatchEvent(event) - }, - get self() { - return context - }, - get global() { - return context - }, - } - - this._vw_workerTarget.addEventListener('message', (e) => { - context.onmessage?.(e) - }) - - this.addEventListener('message', (e) => { - this.onmessage?.(e) - }) - - this.addEventListener('messageerror', (e) => { - this.onmessageerror?.(e) - }) - - const runner = new InlineWorkerRunner(runnerOptions, context) - - const id = (url instanceof URL ? url.toString() : url).replace(/^file:\/+/, '/') - - const fsPath = toFilePath(id, config.root) - - this._vw_name = options?.name ?? fsPath - - debug('initialize worker %s', this._vw_name) - - runner.executeFile(fsPath) - .then(() => { - // worker should be new every time, invalidate its sub dependency - moduleCache.invalidateSubDepTree([fsPath, `mock:${fsPath}`]) - const q = this._vw_messageQueue - this._vw_messageQueue = null - if (q) - q.forEach(([data, transfer]) => this.postMessage(data, transfer), this) - debug('worker %s successfully initialized', this._vw_name) - }).catch((e) => { - debug('worker %s failed to initialize: %o', this._vw_name, e) - const EventConstructor = globalThis.ErrorEvent || globalThis.Event - const error = new EventConstructor('error', { - error: e, - message: e.message, - }) - this.dispatchEvent(error) - this.onerror?.(e) - console.error(e) - }) - } - - addEventListener(type: string, callback: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions): void { - if (callback) - this._vw_outsideListeners.set(type, callback) - return super.addEventListener(type, callback, options) - } - - postMessage(...args: [any, StructuredSerializeOptions | Transferable[] | undefined]): void { - if (!args.length) - throw new SyntaxError('"postMessage" requires at least one argument.') - - const [data, transferOrOptions] = args - if (this._vw_messageQueue != null) { - debug('worker %s is not yet initialized, queue message %s', this._vw_name, data) - this._vw_messageQueue.push([data, transferOrOptions]) - return - } - - debug('posting message %o from the main thread to the worker %s', data, this._vw_name) - const event = createMessageEvent(data, transferOrOptions, cloneType()) - if (event.type === 'messageerror') - this.dispatchEvent(event) - else - this._vw_workerTarget.dispatchEvent(event) - } + if (typeof SharedWorker === 'undefined' || !('__VITEST_WEB_WORKER__' in globalThis.SharedWorker)) { + assertGlobalExists('EventTarget') - terminate() { - debug('terminating worker %s', this._vw_name) - this._vw_outsideListeners.forEach((fn, type) => { - this.removeEventListener(type, fn) - }) - this._vw_insideListeners.forEach((fn, type) => { - this._vw_workerTarget.removeEventListener(type, fn) - }) - } + globalThis.SharedWorker = createSharedWorkerConstructor() } } diff --git a/packages/web-worker/src/runner.ts b/packages/web-worker/src/runner.ts new file mode 100644 index 000000000000..d7e7be3373a9 --- /dev/null +++ b/packages/web-worker/src/runner.ts @@ -0,0 +1,18 @@ +import { VitestRunner } from 'vitest/node' + +export class InlineWorkerRunner extends VitestRunner { + constructor(options: any, private context: any) { + super(options) + } + + prepareContext(context: Record) { + const ctx = super.prepareContext(context) + // not supported for now, we can't synchronously load modules + const importScripts = () => { + throw new Error('[vitest] `importScripts` is not supported in Vite workers. Please, consider using `import` instead.') + } + return Object.assign(ctx, this.context, { + importScripts, + }) + } +} diff --git a/packages/web-worker/src/shared-worker.ts b/packages/web-worker/src/shared-worker.ts new file mode 100644 index 000000000000..49028bbb5e17 --- /dev/null +++ b/packages/web-worker/src/shared-worker.ts @@ -0,0 +1,136 @@ +import { MessageChannel, type MessagePort as NodeMessagePort } from 'worker_threads' +import { toFilePath } from 'vite-node/utils' +import type { InlineWorkerContext, Procedure } from './types' +import { InlineWorkerRunner } from './runner' +import { debug, getRunnerOptions } from './utils' + +interface SharedInlineWorkerContext extends Omit { + onconnect: Procedure | null + self: SharedInlineWorkerContext + global: SharedInlineWorkerContext +} + +const convertNodePortToWebPort = (port: NodeMessagePort): MessagePort => { + if (!('addEventListener' in port)) { + Object.defineProperty(port, 'addEventListener', { + value(...args: any[]) { + return this.addListener(...args) + }, + configurable: true, + enumerable: true, + }) + } + if (!('removeEventListener' in port)) { + Object.defineProperty(port, 'removeEventListener', { + value(...args: any[]) { + return this.removeListener(...args) + }, + configurable: true, + enumerable: true, + }) + } + if (!('dispatchEvent' in port)) { + const emit = port.emit.bind(port) + Object.defineProperty(port, 'emit', { + value(event: any) { + if (event.name === 'message') + (port as any).onmessage?.(event) + if (event.name === 'messageerror') + (port as any).onmessageerror?.(event) + return emit(event) + }, + configurable: true, + enumerable: true, + }) + Object.defineProperty(port, 'dispatchEvent', { + value(event: any) { + return this.emit(event) + }, + configurable: true, + enumerable: true, + }) + } + return port as any as MessagePort +} + +export function createSharedWorkerConstructor(): typeof SharedWorker { + const runnerOptions = getRunnerOptions() + + return class SharedWorker extends EventTarget { + static __VITEST_WEB_WORKER__ = true + + private _vw_workerTarget = new EventTarget() + private _vw_name: string + private _vw_workerPort: MessagePort + + public onerror: null | Procedure = null + + public port: MessagePort + + constructor(url: URL | string, options?: WorkerOptions | string) { + super() + + const name = typeof options === 'string' ? options : options?.name + + // should be equal to SharedWorkerGlobalScope + const context: SharedInlineWorkerContext = { + onconnect: null, + name, + close: () => this.port.close(), + dispatchEvent: (event: Event) => { + return this._vw_workerTarget.dispatchEvent(event) + }, + addEventListener: (...args) => { + return this._vw_workerTarget.addEventListener(...args) + }, + removeEventListener: this._vw_workerTarget.removeEventListener, + get self() { + return context + }, + get global() { + return context + }, + } + + const channel = new MessageChannel() + this.port = convertNodePortToWebPort(channel.port1) + this._vw_workerPort = convertNodePortToWebPort(channel.port2) + + this._vw_workerTarget.addEventListener('connect', (e) => { + context.onconnect?.(e) + }) + + const runner = new InlineWorkerRunner(runnerOptions, context) + + const id = (url instanceof URL ? url.toString() : url).replace(/^file:\/+/, '/') + + const fsPath = toFilePath(id, runnerOptions.root) + + this._vw_name = name ?? fsPath + + debug('initialize shared worker %s', this._vw_name) + + runner.executeFile(fsPath) + .then(() => { + // worker should be new every time, invalidate its sub dependency + runnerOptions.moduleCache.invalidateSubDepTree([fsPath, `mock:${fsPath}`]) + this._vw_workerTarget.dispatchEvent( + new MessageEvent('connect', { + ports: [this._vw_workerPort], + }), + ) + debug('shared worker %s successfully initialized', this._vw_name) + }).catch((e) => { + debug('shared worker %s failed to initialize: %o', this._vw_name, e) + const EventConstructor = globalThis.ErrorEvent || globalThis.Event + const error = new EventConstructor('error', { + error: e, + message: e.message, + }) + this.dispatchEvent(error) + this.onerror?.(error) + console.error(e) + }) + } + } +} diff --git a/packages/web-worker/src/types.ts b/packages/web-worker/src/types.ts new file mode 100644 index 000000000000..7c665f5e8222 --- /dev/null +++ b/packages/web-worker/src/types.ts @@ -0,0 +1,19 @@ +export type Procedure = (...args: any[]) => void +export type CloneOption = 'native' | 'ponyfill' | 'none' + +export interface DefineWorkerOptions { + clone: CloneOption +} + +export interface InlineWorkerContext { + onmessage: Procedure | null + name?: string + close: () => void + dispatchEvent: (e: Event) => void + addEventListener: (e: string, fn: Procedure) => void + removeEventListener: (e: string, fn: Procedure) => void + postMessage: (data: any, transfer?: Transferable[] | StructuredSerializeOptions) => void + self: InlineWorkerContext + global: InlineWorkerContext + importScripts?: any +} diff --git a/packages/web-worker/src/utils.ts b/packages/web-worker/src/utils.ts new file mode 100644 index 000000000000..5209412aed48 --- /dev/null +++ b/packages/web-worker/src/utils.ts @@ -0,0 +1,80 @@ +/* eslint-disable no-restricted-imports */ +import type { WorkerGlobalState } from 'vitest' +import ponyfillStructuredClone from '@ungap/structured-clone' +import createDebug from 'debug' +import type { CloneOption } from './types' + +export const debug = createDebug('vitest:web-worker') + +export function getWorkerState(): WorkerGlobalState { + // @ts-expect-error untyped global + return globalThis.__vitest_worker__ +} + +export function assertGlobalExists(name: string) { + if (!(name in globalThis)) + throw new Error(`[@vitest/web-worker] Cannot initiate a custom Web Worker. "${name}" is not supported in this environment. Please, consider using jsdom or happy-dom environment.`) +} + +function createClonedMessageEvent(data: any, transferOrOptions: StructuredSerializeOptions | Transferable[] | undefined, clone: CloneOption) { + const transfer = Array.isArray(transferOrOptions) ? transferOrOptions : transferOrOptions?.transfer + + debug('clone worker message %o', data) + const origin = typeof location === 'undefined' ? undefined : location.origin + + if (typeof structuredClone === 'function' && clone === 'native') { + debug('create message event, using native structured clone') + return new MessageEvent('message', { + data: structuredClone(data, { transfer }), + origin, + }) + } + if (clone !== 'none') { + debug('create message event, using polifylled structured clone') + transfer?.length && console.warn( + '[@vitest/web-worker] `structuredClone` is not supported in this environment. ' + + 'Falling back to polyfill, your transferable options will be lost. ' + + 'Set `VITEST_WEB_WORKER_CLONE` environmental variable to "none", if you don\'t want to loose it,' + + 'or update to Node 17+.', + ) + return new MessageEvent('message', { + data: ponyfillStructuredClone(data, { lossy: true }), + origin, + }) + } + debug('create message event without cloning an object') + return new MessageEvent('message', { + data, + origin, + }) +} + +export function createMessageEvent(data: any, transferOrOptions: StructuredSerializeOptions | Transferable[] | undefined, clone: CloneOption) { + try { + return createClonedMessageEvent(data, transferOrOptions, clone) + } + catch (error) { + debug('failed to clone message, dispatch "messageerror" event: %o', error) + return new MessageEvent('messageerror', { + data: error, + }) + } +} + +export function getRunnerOptions() { + const { config, rpc, mockMap, moduleCache } = getWorkerState() + + return { + fetchModule(id: string) { + return rpc.fetch(id) + }, + resolveId(id: string, importer?: string) { + return rpc.resolveId(id, importer) + }, + moduleCache, + mockMap, + interopDefault: config.deps.interopDefault ?? true, + root: config.root, + base: config.base, + } +} diff --git a/packages/web-worker/src/worker.ts b/packages/web-worker/src/worker.ts new file mode 100644 index 000000000000..d77c9bf8f9be --- /dev/null +++ b/packages/web-worker/src/worker.ts @@ -0,0 +1,136 @@ +import { toFilePath } from 'vite-node/utils' +import type { CloneOption, DefineWorkerOptions, InlineWorkerContext, Procedure } from './types' +import { InlineWorkerRunner } from './runner' +import { createMessageEvent, debug, getRunnerOptions } from './utils' + +export function createWorkerConstructor(options?: DefineWorkerOptions): typeof Worker { + const runnerOptions = getRunnerOptions() + const cloneType = () => (options?.clone ?? process.env.VITEST_WEB_WORKER_CLONE ?? 'native') as CloneOption + + return class Worker extends EventTarget { + static __VITEST_WEB_WORKER__ = true + + private _vw_workerTarget = new EventTarget() + private _vw_insideListeners = new Map() + private _vw_outsideListeners = new Map() + private _vw_name: string + private _vw_messageQueue: any[] | null = [] + + public onmessage: null | Procedure = null + public onmessageerror: null | Procedure = null + public onerror: null | Procedure = null + + constructor(url: URL | string, options?: WorkerOptions) { + super() + + // should be equal to DedicatedWorkerGlobalScope + const context: InlineWorkerContext = { + onmessage: null, + name: options?.name, + close: () => this.terminate(), + dispatchEvent: (event: Event) => { + return this._vw_workerTarget.dispatchEvent(event) + }, + addEventListener: (...args) => { + if (args[1]) + this._vw_insideListeners.set(args[0], args[1]) + return this._vw_workerTarget.addEventListener(...args) + }, + removeEventListener: this._vw_workerTarget.removeEventListener, + postMessage: (...args) => { + if (!args.length) + throw new SyntaxError('"postMessage" requires at least one argument.') + + debug('posting message %o from the worker %s to the main thread', args[0], this._vw_name) + const event = createMessageEvent(args[0], args[1], cloneType()) + this.dispatchEvent(event) + }, + get self() { + return context + }, + get global() { + return context + }, + } + + this._vw_workerTarget.addEventListener('message', (e) => { + context.onmessage?.(e) + }) + + this.addEventListener('message', (e) => { + this.onmessage?.(e) + }) + + this.addEventListener('messageerror', (e) => { + this.onmessageerror?.(e) + }) + + const runner = new InlineWorkerRunner(runnerOptions, context) + + const id = (url instanceof URL ? url.toString() : url).replace(/^file:\/+/, '/') + + const fsPath = toFilePath(id, runnerOptions.root) + + this._vw_name = options?.name ?? fsPath + + debug('initialize worker %s', this._vw_name) + + runner.executeFile(fsPath) + .then(() => { + // worker should be new every time, invalidate its sub dependency + runnerOptions.moduleCache.invalidateSubDepTree([fsPath, `mock:${fsPath}`]) + const q = this._vw_messageQueue + this._vw_messageQueue = null + if (q) + q.forEach(([data, transfer]) => this.postMessage(data, transfer), this) + debug('worker %s successfully initialized', this._vw_name) + }).catch((e) => { + debug('worker %s failed to initialize: %o', this._vw_name, e) + const EventConstructor = globalThis.ErrorEvent || globalThis.Event + const error = new EventConstructor('error', { + error: e, + message: e.message, + }) + this.dispatchEvent(error) + this.onerror?.(error) + console.error(e) + }) + } + + addEventListener(type: string, callback: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions): void { + if (callback) + this._vw_outsideListeners.set(type, callback) + return super.addEventListener(type, callback, options) + } + + postMessage(...args: [any, StructuredSerializeOptions | Transferable[] | undefined]): void { + if (!args.length) + throw new SyntaxError('"postMessage" requires at least one argument.') + + const [data, transferOrOptions] = args + if (this._vw_messageQueue != null) { + debug('worker %s is not yet initialized, queue message %s', this._vw_name, data) + this._vw_messageQueue.push([data, transferOrOptions]) + return + } + + debug('posting message %o from the main thread to the worker %s', data, this._vw_name) + + const event = createMessageEvent(data, transferOrOptions, cloneType()) + if (event.type === 'messageerror') + this.dispatchEvent(event) + else + this._vw_workerTarget.dispatchEvent(event) + } + + terminate() { + debug('terminating worker %s', this._vw_name) + this._vw_outsideListeners.forEach((fn, type) => { + this.removeEventListener(type, fn) + }) + this._vw_insideListeners.forEach((fn, type) => { + this._vw_workerTarget.removeEventListener(type, fn) + }) + } + } +} diff --git a/test/web-worker/src/sharedWorker.ts b/test/web-worker/src/sharedWorker.ts new file mode 100644 index 000000000000..cbe67ecf637c --- /dev/null +++ b/test/web-worker/src/sharedWorker.ts @@ -0,0 +1,10 @@ +self.addEventListener('connect', (event) => { + const e = event as MessageEvent + const port = e.ports[0] + + port.onmessage = (e) => { + port.postMessage(e.data) + } + + port.start() +}) diff --git a/test/web-worker/test/init.test.ts b/test/web-worker/test/init.test.ts index a22872f0c9c7..e6c1bbfd88e8 100644 --- a/test/web-worker/test/init.test.ts +++ b/test/web-worker/test/init.test.ts @@ -54,6 +54,19 @@ it('worker with url', async () => { await testWorker(new Worker(new URL('../src/worker.ts', url))) }) +it('worker with invalid url throws an error', async () => { + const url = import.meta.url + const worker = new Worker(new URL('../src/workerInvalid-path.ts', url)) + const event = await new Promise((resolve) => { + worker.onerror = (e) => { + resolve(e) + } + }) + expect(event).toBeInstanceOf(ErrorEvent) + expect(event.error).toBeInstanceOf(Error) + expect(event.error.message).toContain('Failed to load') +}) + it('self injected into worker and its deps should be equal', async () => { expect.assertions(4) expect(await testSelfWorker(new MySelfWorker())).toBeTruthy() diff --git a/test/web-worker/test/sharedWorker.spec.ts b/test/web-worker/test/sharedWorker.spec.ts new file mode 100644 index 000000000000..993afbf50d8f --- /dev/null +++ b/test/web-worker/test/sharedWorker.spec.ts @@ -0,0 +1,66 @@ +import { expect, it } from 'vitest' +import MySharedWorker from './src/sharedWorker?sharedworker' + +const sendEventMessage = (worker: SharedWorker, msg: any) => { + worker.port.postMessage(msg) + return new Promise((resolve) => { + worker.port.addEventListener('message', function onmessage(e) { + worker.port.removeEventListener('message', onmessage) + resolve(e.data as string) + }) + }) +} + +const sendOnMessage = (worker: SharedWorker, msg: any) => { + worker.port.postMessage(msg) + return new Promise((resolve) => { + worker.port.onmessage = function onmessage(e) { + worker.port.onmessage = null + resolve(e.data as string) + } + }) +} + +it('vite shared worker works', async () => { + expect(MySharedWorker).toBeDefined() + expect(SharedWorker).toBeDefined() + const worker = new MySharedWorker() + expect(worker).toBeInstanceOf(SharedWorker) + + await expect(sendEventMessage(worker, 'event')).resolves.toBe('event') + await expect(sendOnMessage(worker, 'event')).resolves.toBe('event') +}) + +it('shared worker with path works', async () => { + expect(SharedWorker).toBeDefined() + const worker = new SharedWorker(new URL('../src/sharedWorker.ts', import.meta.url)) + expect(worker).toBeTruthy() + + await expect(sendEventMessage(worker, 'event')).resolves.toBe('event') + await expect(sendOnMessage(worker, 'event')).resolves.toBe('event') +}) + +it('throws an error on invalid path', async () => { + expect(SharedWorker).toBeDefined() + const worker = new SharedWorker('./some-invalid-path') + const event = await new Promise((resolve) => { + worker.onerror = (e) => { + resolve(e) + } + }) + expect(event).toBeInstanceOf(ErrorEvent) + expect(event.error).toBeInstanceOf(Error) + expect(event.error.message).toContain('Failed to load') +}) + +it('doesn\'t trigger events, if closed', async () => { + const worker = new MySharedWorker() + worker.port.close() + await new Promise((resolve) => { + worker.port.addEventListener('message', () => { + expect.fail('should not trigger message') + }) + worker.port.postMessage('event') + setTimeout(resolve, 100) + }) +}) diff --git a/test/web-worker/vitest.config.ts b/test/web-worker/vitest.config.ts index d5daed349443..69f70c696d4d 100644 --- a/test/web-worker/vitest.config.ts +++ b/test/web-worker/vitest.config.ts @@ -11,5 +11,9 @@ export default defineConfig({ /packages\/web-worker/, ], }, + onConsoleLog(log) { + if (log.includes('Failed to load')) + return false + }, }, })