Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
16 changes: 16 additions & 0 deletions docs/runtime/workers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -310,3 +310,19 @@ if (Bun.isMainThread) {
console.log("I'm in a worker");
}
```

## `Worker.data`

Pass a `data` option to `new Worker()` to send a value to the worker at startup. Inside the worker it is available as `Worker.data`, and also as `require("node:worker_threads").workerData`.

```ts
// main.ts
const worker = new Worker("./worker.ts", {
data: { greeting: "hello" },
});

// worker.ts
console.log(Worker.data); // => { greeting: "hello" }
```

The value is cloned with the HTML structured clone algorithm. Use `transferList` to transfer `MessagePort`, `ArrayBuffer`, and similar values instead of copying them.
23 changes: 23 additions & 0 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,29 @@ declare module "bun" {
* Equivalent to passing the `--preload` CLI argument, but only for this Worker.
*/
preload?: string[] | string | undefined;

/**
* Any JavaScript value that is cloned and made available inside the worker
* as `Worker.data` and `require("node:worker_threads").workerData`.
*
* The cloning uses the HTML structured clone algorithm; use
* `transferList` to transfer values such as `MessagePort` instead of
* copying them.
*/
data?: any;

/**
* Alias for {@link data}. If both are provided, `workerData` takes
* precedence.
*/
workerData?: any;

/**
* Transferable values (such as `MessagePort` or `ArrayBuffer`) referenced
* from `data`/`workerData` that should be moved to the worker instead of
* cloned.
*/
transferList?: readonly import("node:worker_threads").TransferListItem[] | undefined;
}

interface Worker extends EventTarget, AbstractWorker {
Expand Down
5 changes: 5 additions & 0 deletions src/js/node/worker_threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ const {
8: _markAsUncloneable,
9: _setEntryEvaluatedHook,
10: _isNodeWorker,
11: _setWorkerData,
} = $cpp("Worker.cpp", "createNodeWorkerThreadsBinding") as [
unknown,
number,
Expand All @@ -93,6 +94,7 @@ const {
(value: unknown) => void,
(hook: () => void) => void,
boolean,
(value: unknown) => void,
];

type NodeWorkerOptions = import("node:worker_threads").WorkerOptions;
Expand Down Expand Up @@ -744,6 +746,9 @@ if (
if (stdioPorts) setupWorkerStdio(stdioPorts);
if (controlPort) messaging.setupMainThreadPort(controlPort, _setEntryEvaluatedHook);
}
// The native cache behind `Worker.data` was seeded from the raw deserialized
// value; write back the unpacked/unwrapped result so both surfaces agree.
Comment thread
robobun marked this conversation as resolved.
Outdated
if (workerData !== _workerData) _setWorkerData(workerData);
function receiveMessageOnPort(port: MessagePort) {
let res = _receiveMessageOnPort(port);
if (!res) return undefined;
Expand Down
1 change: 1 addition & 0 deletions src/jsc/bindings/ZigGlobalObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4140,6 +4140,7 @@ void GlobalObject::adoptNapiEnvsForTestIsolation(GlobalObject* oldGlobal)
}

void GlobalObject::setNodeWorkerEnvironmentData(JSMap* data) { m_nodeWorkerEnvironmentData.set(vm(), this, data); }
void GlobalObject::setNodeWorkerData(JSValue data) { m_nodeWorkerData.set(vm(), this, data); }
void GlobalObject::setNodeWorkerEntryEvaluatedHook(JSObject* hook)
{
if (hook)
Expand Down
5 changes: 5 additions & 0 deletions src/jsc/bindings/ZigGlobalObject.h
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,9 @@ class GlobalObject : public Bun::GlobalScope {
/* Supports getEnvironmentData() and setEnvironmentData(), and is cloned into newly-created */ \
/* Workers. Initialized in createNodeWorkerThreadsBinding. */ \
V(private, WriteBarrier<JSMap>, m_nodeWorkerEnvironmentData) \
/* The deserialized `workerData`/`data` value passed to `new Worker()`. */ \
/* Initialized in createNodeWorkerThreadsBinding; read by the Worker.data getter. */ \
Comment thread
robobun marked this conversation as resolved.
Outdated
V(private, WriteBarrier<JSC::Unknown>, m_nodeWorkerData) \
/* setupMainThreadPort's drain callback; run once by WebWorker__dispatchOnline */ \
/* after entry-module evaluation. Stored here (not on globalThis) so user code can't clobber it. */ \
V(private, WriteBarrier<JSObject>, m_nodeWorkerEntryEvaluatedHook) \
Expand Down Expand Up @@ -748,6 +751,8 @@ class GlobalObject : public Bun::GlobalScope {

JSMap* nodeWorkerEnvironmentData() { return m_nodeWorkerEnvironmentData.get(); }
void setNodeWorkerEnvironmentData(JSMap* data);
JSValue nodeWorkerData() { return m_nodeWorkerData.get(); }
void setNodeWorkerData(JSValue data);
JSObject* nodeWorkerEntryEvaluatedHook() { return m_nodeWorkerEntryEvaluatedHook.get(); }
void setNodeWorkerEntryEvaluatedHook(JSObject* hook);

Expand Down
17 changes: 17 additions & 0 deletions src/jsc/bindings/webcore/JSWorker.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -406,13 +406,30 @@ template<> JSValue JSWorkerDOMConstructor::prototypeForStructure(JSC::VM& vm, co
return JSEventTarget::getConstructor(vm, &globalObject);
}

JSC_DEFINE_CUSTOM_GETTER(jsWorkerConstructor_data, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue, PropertyName))
{
auto& vm = JSC::getVM(lexicalGlobalObject);
auto throwScope = DECLARE_THROW_SCOPE(vm);
auto* globalObject = defaultGlobalObject(lexicalGlobalObject);
// The serialized workerData lives on WorkerOptions and is deserialized once by
// createNodeWorkerThreadsBinding (which also caches it on the global). If
// `Worker.data` is read before `node:worker_threads` is loaded, run that
// deserialization now so both surfaces see the same value.
Comment thread
robobun marked this conversation as resolved.
Outdated
if (!globalObject->nodeWorkerEnvironmentData()) {
createNodeWorkerThreadsBinding(globalObject);
RETURN_IF_EXCEPTION(throwScope, {});
}
RELEASE_AND_RETURN(throwScope, JSValue::encode(globalObject->nodeWorkerData()));
}

template<> void JSWorkerDOMConstructor::initializeProperties(VM& vm, JSDOMGlobalObject& globalObject)
{
putDirect(vm, vm.propertyNames->length, jsNumber(1), JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontEnum);
JSString* nameString = jsNontrivialString(vm, "Worker"_s);
m_originalName.set(vm, this, nameString);
putDirect(vm, vm.propertyNames->name, nameString, JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontEnum);
putDirect(vm, vm.propertyNames->prototype, JSWorker::prototype(vm, globalObject), JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::DontDelete);
putDirectCustomAccessor(vm, Identifier::fromString(vm, "data"_s), JSC::CustomGetterSetter::create(vm, jsWorkerConstructor_data, nullptr), JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontEnum | JSC::PropertyAttribute::CustomAccessor);
}

JSC_DEFINE_CUSTOM_GETTER(jsWorker_threadIdGetter, (JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, PropertyName))
Expand Down
19 changes: 17 additions & 2 deletions src/jsc/bindings/webcore/Worker.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -825,6 +825,15 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionSetEntryEvaluatedHook, (JSC::JSGlobalObject *
return JSC::JSValue::encode(jsUndefined());
}

JSC_DEFINE_HOST_FUNCTION(jsFunctionSetWorkerData, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame))
{
// node:worker_threads unwraps/transforms the raw deserialized value
// (stdio/messaging wrapper, unpackJSTransferables); push that back into the
// global cache so Worker.data stays identical to the exported workerData.
Comment thread
robobun marked this conversation as resolved.
Outdated
defaultGlobalObject(lexicalGlobalObject)->setNodeWorkerData(callFrame->argument(0));
return JSC::JSValue::encode(jsUndefined());
}

JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject)
{
VM& vm = globalObject->vm();
Expand All @@ -833,7 +842,11 @@ JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject)
JSValue workerData = jsNull();
JSValue threadId = jsNumber(0);
JSValue threadName = jsEmptyString(vm);
JSMap* environmentData = nullptr;
// Both the `Worker.data` getter and `$cpp("Worker.cpp", ...)` in
// node:worker_threads call this; re-entry must not clobber or re-deserialize.
Comment thread
robobun marked this conversation as resolved.
Outdated
JSMap* environmentData = globalObject->nodeWorkerEnvironmentData();
if (JSValue cached = globalObject->nodeWorkerData())
workerData = cached;

if (auto* worker = WebWorker__getParentWorker(globalObject->bunVM())) {
auto& options = worker->options();
Expand Down Expand Up @@ -877,12 +890,13 @@ JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject)
}
ASSERT(environmentData);
globalObject->setNodeWorkerEnvironmentData(environmentData);
globalObject->setNodeWorkerData(workerData);
Comment thread
claude[bot] marked this conversation as resolved.

bool isNodeWorker = false;
if (auto* worker = WebWorker__getParentWorker(globalObject->bunVM()))
isNodeWorker = worker->options().kind == WorkerOptions::Kind::Node;

JSObject* array = constructEmptyArray(globalObject, nullptr, 11);
JSObject* array = constructEmptyArray(globalObject, nullptr, 12);
RETURN_IF_EXCEPTION(scope, {});
array->putDirectIndex(globalObject, 0, workerData);
array->putDirectIndex(globalObject, 1, threadId);
Expand All @@ -895,6 +909,7 @@ JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject)
array->putDirectIndex(globalObject, 8, JSFunction::create(vm, globalObject, 1, "markAsUncloneable"_s, jsFunctionMarkAsUncloneable, ImplementationVisibility::Public, NoIntrinsic));
array->putDirectIndex(globalObject, 9, JSFunction::create(vm, globalObject, 1, "setEntryEvaluatedHook"_s, jsFunctionSetEntryEvaluatedHook, ImplementationVisibility::Public, NoIntrinsic));
array->putDirectIndex(globalObject, 10, jsBoolean(isNodeWorker));
array->putDirectIndex(globalObject, 11, JSFunction::create(vm, globalObject, 1, "setWorkerData"_s, jsFunctionSetWorkerData, ImplementationVisibility::Public, NoIntrinsic));
return array;
}

Expand Down
106 changes: 106 additions & 0 deletions test/js/web/workers/worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,4 +458,110 @@ describe("worker_threads", () => {
await p;
expect(message).toEqual("hello");
});

// https://github.com/oven-sh/bun/issues/9330
// Spawned so that the worker body can observe `Worker.data` before the
// `node:worker_threads` module has been imported (the deserialization is
// shared between the two, so ordering matters).
describe("Worker.data", () => {
test.concurrent("is the cloned value of the `data` option", async () => {
const body = `
const before = Worker.data;
const { workerData } = require("node:worker_threads");
postMessage({ before, after: Worker.data, workerData,
same: before === workerData && Worker.data === workerData });`;
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const w = new Worker(${JSON.stringify("data:text/javascript," + encodeURIComponent(body))}, {
data: { greeting: "hello" },
});
w.onerror = e => { console.error(e.message); process.exit(1); };
w.onmessage = e => { console.log(JSON.stringify(e.data)); w.terminate(); };`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(JSON.parse(stdout)).toEqual({
before: { greeting: "hello" },
after: { greeting: "hello" },
workerData: { greeting: "hello" },
same: true,
});
Comment thread
claude[bot] marked this conversation as resolved.
expect(exitCode).toBe(0);
});

test.concurrent("agrees with workerData when node:worker_threads is imported first", async () => {
const body = `
const { workerData } = require("node:worker_threads");
postMessage({ workerData, data: Worker.data, same: Worker.data === workerData });`;
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const w = new Worker(${JSON.stringify("data:text/javascript," + encodeURIComponent(body))}, {
data: { v: 1 },
});
w.onerror = e => { console.error(e.message); process.exit(1); };
w.onmessage = e => { console.log(JSON.stringify(e.data)); w.terminate(); };`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(JSON.parse(stdout)).toEqual({ workerData: { v: 1 }, data: { v: 1 }, same: true });
expect(exitCode).toBe(0);
});

test.concurrent("is the unwrapped workerData inside a node:worker_threads Worker", async () => {
// The node wt.Worker constructor wraps workerData to carry internal
// stdio/messaging ports; Worker.data must be the unwrapped user value,
// identical to `workerData`, not the transport wrapper.
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const { Worker } = require("node:worker_threads");
const src = 'const wt = require("node:worker_threads");' +
'console.log(JSON.stringify({ data: Worker.data, same: Worker.data === wt.workerData,' +
' keys: typeof Worker.data === "object" ? Object.keys(Worker.data) : null }));';
const w = new Worker(src, { eval: true, workerData: { greeting: "hello" } });
w.on("error", e => { console.error(e.message); process.exit(1); });
w.on("exit", code => process.exit(code));`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(JSON.parse(stdout)).toEqual({ data: { greeting: "hello" }, same: true, keys: ["greeting"] });
expect(exitCode).toBe(0);
});

test.concurrent("mirrors workerData when no data is passed", async () => {
// node:worker_threads' workerData is null on the main thread but undefined
// inside a worker that received no workerData; Worker.data should match both.
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`console.log(Worker.data === require("node:worker_threads").workerData ? "main ok" : "main mismatch");
const body = 'postMessage(Worker.data === require("node:worker_threads").workerData ? "worker ok" : "worker mismatch")';
const w = new Worker("data:text/javascript," + encodeURIComponent(body));
w.onerror = e => { console.error(e.message); process.exit(1); };
w.onmessage = e => { console.log(e.data); w.terminate(); };`,
],
env: bunEnv,
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout).toBe("main ok\nworker ok\n");
expect(exitCode).toBe(0);
});
});
});
Loading