feat: construct RpcPromise from a Promise - #242

Merged
ndisidore merged 6 commits into
mainfrom
feat/from-promise
Aug 20, 2026
Merged

feat: construct RpcPromise from a Promise#242
ndisidore merged 6 commits into
mainfrom
feat/from-promise

Conversation

@ndisidore

@ndisidorendisidore commented Aug 12, 2026

Copy link
Copy Markdown
Member

You can now write new RpcPromise(promise) (resolves the TODO). Calls made before the promise settles are queued and delivered in order once it does, and awaiting it yields the resolution. The promise can resolve to an RpcTarget, a stub, or a plain value.

@kentonv mentioned this re: reconnection in cloudflare-os#172

Passing an existing RpcPromise adopts its hook directly rather than awaiting it. Two caveats are documented in the README: ownership of the resolution transfers to the RpcPromise (resolve with a dup() if you also keep the stub), and pending calls queue unboundedly (reject the promise when giving up).

Stacked because the new tests exercise rejection and dispose-before-resolution paths that only behave correctly with the PromiseStubHook fixes in #241. Landing that first keeps this diff feature-only.

@changeset-bot

changeset-botBot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bf3c7be

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
NameType
capnwebMinor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-newBot commented Aug 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/capnweb@242

commit: bf3c7be

@ask-bonk

This comment was marked as outdated.

@ndisidore
ndisidoreforce-pushed the feat/from-promise branch 3 times, most recently from 0535521 to 1ecfa82CompareAugust 12, 2026 21:23
@ndisidore
ndisidore marked this pull request as ready for review August 12, 2026 21:24
Comment threadsrc/index.ts Outdated
Comment threadsrc/core.ts Outdated
Comment threadsrc/core.ts Outdated
Comment threadsrc/core.ts Outdated
Comment threadREADME.md Outdated
@ndisidore
ndisidore requested a review from kentonvAugust 12, 2026 23:32
@ndisidore

Copy link
Copy Markdown
MemberAuthor

@ask-bonk review this

@ask-bonkask-bonkBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — LGTM

Re-reviewed at 33c0122, which addresses all four of @kentonv's review comments. Verified locally: build succeeds, test:types passes, and all 178 tests in index.test.ts pass on node, chromium, and workerd.

Review feedback resolution

  1. Type overloads (src/index.ts) — the two overloads are collapsed into a single new <T extends RpcCompatible<T>>(value: Promise<T | Stub<T>>): RpcPromise<T>, and PromiseLike was dropped (non-Promise thenables no longer accepted, as agreed). Inference works without an explicit <T> — the type tests confirm new RpcPromise(Promise.resolve(pointStub)) infers RpcPromise<PointTarget> (__type-tests__/rpc-base-cases.test.ts:160-162).
  2. hookForResolution one-liner — inlined.
  3. hookForPromiseArg — inlined into the constructor.
  4. The bare-stub bug — the real catch. The branch is now gated on typeForRpc(hook) === "rpc-promise" only (src/core.ts:555), so a bare RpcStub no longer has its non-promise hook adopted directly (which couldn't answer pull()); it falls through to the payload path instead. Covered by the regression test "resolves when awaited after construction from a bare local stub" (__tests__/index.test.ts).

Correctness

  • Rejection is adopted as an ErrorStubHook and the backing PromiseStubHook promise never rejects; combined with ignoreUnhandledRejections(), discarded pipelined calls, awaits, and map() results can't leak unhandled rejections. All three "does not report an unhandled rejection…" tests pass.
  • Disposal is correctly ordered behind queued calls via PromiseStubHook.dispose() — the "delivers a call initiated before disposal" and "disposes the eventual target when disposed before resolution" tests exercise exactly the ordering paths that rely on the #241 fixes, which justifies the stacking.
  • Adopting an existing RpcPromise directly preserves laziness (only one ["pull"] is sent) and brokenness — verified by the "keeps an adopted RpcPromise lazy" and "preserves brokenness" tests.
  • No wire-protocol change (client-side construction reusing existing representations), so no protocol.md update needed. Changeset present (minor). README documents the ownership-transfer and unbounded-queue caveats.

No logic bugs, capability leaks, or backward-compat concerns found. Ship it.

@ask-bonk

This comment was marked as off-topic.

@ask-bonk

This comment was marked as off-topic.

Comment threadsrc/index.ts
@ndisidore
ndisidore changed the base branch from fix/promise-stubhook-disposal to mainAugust 17, 2026 20:18
@ndisidore
ndisidoreforce-pushed the feat/from-promise branch 2 times, most recently from 11d58ca to 8c51593CompareAugust 17, 2026 21:02
Comment threadsrc/core.ts
Comment threadsrc/core.ts Outdated
Comment threadsrc/index.ts Outdated
Comment threadsrc/index.ts Outdated
Comment threadREADME.md Outdated
ndisidore added a commit that referenced this pull request Aug 18, 2026
Per review feedback on #242: the ownership-transfer note (nobody wraps an
RpcPromise they already hold on purpose) and the thenable-assimilation
note (not specific to this constructor) don't belong in the public docs.
The behaviors themselves are unchanged and remain pinned by tests.
Resolves the long-standing TODO on the RpcPromise constructor: the
application may now pass a Promise (or any other thenable) for the
eventual resolution. Calls made before the promise settles are queued
and delivered in order once it does, so an RpcPromise can stand in for
a capability that doesn't exist yet -- for example, one that will only
become available after a broken session has been re-established.
The promise may resolve to an RpcTarget, a stub, or a plain value.
Promise.resolve() performs thenable assimilation natively, so no
hand-rolled hardening against misbehaving thenables is needed. The
resolution is adopted with return semantics (the same representation
used for resolutions of local async calls), so awaiting delivers the
value, pipelined calls forward through it without forcing a pull, and
brokenness of a stub resolution is preserved. Passing an existing
RpcPromise adopts its hook directly, keeping it lazy.
A rejection is adopted as an ErrorStubHook rather than left to reject
the backing promise, so the promise chains behind queued calls never
reject: calls land on the ErrorStubHook (which disposes their
arguments) and the error surfaces only through pull() or onBroken().
Without this, a discarded pipelined call on a promise-backed stub
would raise an unhandled rejection event when the promise rejects
(crashing Node under its default handling), even though
fire-and-forget calls on the session-backed stub it stands in for
reject only on pull.
- Only adopt the hook of an existing RpcPromise; a bare stub's hook may
not implement pull(), so bare stubs now take the generic path, whose
resolution payload handles them correctly (await previously rejected
with "Tried to resolve a non-promise stub."). Regression test added.
- Inline hookForPromiseArg and hookForResolution into the constructor.
- Collapse the constructor's type overloads into a single signature,
narrowing the accepted type to Promise (runtime still assimilates
arbitrary thenables).
- Reframe the README section around the local-loopback RPC equivalence,
and align the jsdoc and changeset with it.
- Adopting an existing RpcPromise now consumes the source: its hook is
neutered to DISPOSED_HOOK, so disposing the source can no longer
silently kill the wrapper. Using the source after wrapping reports
the standard disposed error.
- Restore the invariant that every RpcPromise has a defined path by
defaulting pathIfPromise to [] on the internal StubHook path.
- Wrap workerd-native RpcPromise/RpcProperty values (rpc-thenable) in
a TargetStubHook so pipelined calls aren't eagerly assimilated.
- Document ownership transfer on adoption and the dup() workaround for
keeping a deferred capability lazy when resolving a native Promise
with an RpcPromise.
Per review feedback on #242: the ownership-transfer note (nobody wraps an
RpcPromise they already hold on purpose) and the thenable-assimilation
note (not specific to this constructor) don't belong in the public docs.
The behaviors themselves are unchanged and remain pinned by tests.
…pped native stubs
- get([]) on a thenable-backed TargetStubHook now returns dup() instead of
throwing, fixing dup() and argument-passing of wrapped native promises.
- onBroken() now subscribes to a thenable target's rejection, so onRpcBroken
fires when a wrapped native promise rejects instead of silently no-oping.
- Property promises share the source hook and path so the get() happens
lazily on first use, avoiding eager wire pushes / getter side effects.
…orStubHook
Per review: PromiseStubHook already handles a rejected backing promise --
it disposes the arguments of queued calls (since #241) and surfaces the
error through pull() and onBroken() -- so the constructor no longer maps
rejection to an ErrorStubHook resolution.
Observable change: a pipelined call whose result is neither awaited nor
disposed now fires an unhandled rejection event, matching the existing
behavior of local async calls. The unhandled-rejection tests now dispose
the discarded results, which both silences the event and models correct
usage.
@ndisidore
ndisidore merged commit 9751a4e into mainAug 20, 2026
8 checks passed
@github-actionsgithub-actionsBot mentioned this pull request Aug 20, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@ndisidore@kentonv@dimitropoulos
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat: construct RpcPromise from a Promise - #242

Merged
ndisidore merged 6 commits into
mainfrom
feat/from-promise
Aug 20, 2026
Merged

feat: construct RpcPromise from a Promise#242
ndisidore merged 6 commits into
mainfrom
feat/from-promise

Conversation

@ndisidore

@ndisidorendisidore commented Aug 12, 2026

Copy link
Copy Markdown
Member

You can now write new RpcPromise(promise) (resolves the TODO). Calls made before the promise settles are queued and delivered in order once it does, and awaiting it yields the resolution. The promise can resolve to an RpcTarget, a stub, or a plain value.

@kentonv mentioned this re: reconnection in cloudflare-os#172

Passing an existing RpcPromise adopts its hook directly rather than awaiting it. Two caveats are documented in the README: ownership of the resolution transfers to the RpcPromise (resolve with a dup() if you also keep the stub), and pending calls queue unboundedly (reject the promise when giving up).

Stacked because the new tests exercise rejection and dispose-before-resolution paths that only behave correctly with the PromiseStubHook fixes in #241. Landing that first keeps this diff feature-only.

@changeset-bot

changeset-botBot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bf3c7be

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
NameType
capnwebMinor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-newBot commented Aug 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/capnweb@242

commit: bf3c7be

@ask-bonk

This comment was marked as outdated.

@ndisidore
ndisidoreforce-pushed the feat/from-promise branch 3 times, most recently from 0535521 to 1ecfa82CompareAugust 12, 2026 21:23
@ndisidore
ndisidore marked this pull request as ready for review August 12, 2026 21:24
Comment threadsrc/index.ts Outdated
Comment threadsrc/core.ts Outdated
Comment threadsrc/core.ts Outdated
Comment threadsrc/core.ts Outdated
Comment threadREADME.md Outdated
@ndisidore
ndisidore requested a review from kentonvAugust 12, 2026 23:32
@ndisidore

Copy link
Copy Markdown
MemberAuthor

@ask-bonk review this

@ask-bonkask-bonkBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — LGTM

Re-reviewed at 33c0122, which addresses all four of @kentonv's review comments. Verified locally: build succeeds, test:types passes, and all 178 tests in index.test.ts pass on node, chromium, and workerd.

Review feedback resolution

  1. Type overloads (src/index.ts) — the two overloads are collapsed into a single new <T extends RpcCompatible<T>>(value: Promise<T | Stub<T>>): RpcPromise<T>, and PromiseLike was dropped (non-Promise thenables no longer accepted, as agreed). Inference works without an explicit <T> — the type tests confirm new RpcPromise(Promise.resolve(pointStub)) infers RpcPromise<PointTarget> (__type-tests__/rpc-base-cases.test.ts:160-162).
  2. hookForResolution one-liner — inlined.
  3. hookForPromiseArg — inlined into the constructor.
  4. The bare-stub bug — the real catch. The branch is now gated on typeForRpc(hook) === "rpc-promise" only (src/core.ts:555), so a bare RpcStub no longer has its non-promise hook adopted directly (which couldn't answer pull()); it falls through to the payload path instead. Covered by the regression test "resolves when awaited after construction from a bare local stub" (__tests__/index.test.ts).

Correctness

  • Rejection is adopted as an ErrorStubHook and the backing PromiseStubHook promise never rejects; combined with ignoreUnhandledRejections(), discarded pipelined calls, awaits, and map() results can't leak unhandled rejections. All three "does not report an unhandled rejection…" tests pass.
  • Disposal is correctly ordered behind queued calls via PromiseStubHook.dispose() — the "delivers a call initiated before disposal" and "disposes the eventual target when disposed before resolution" tests exercise exactly the ordering paths that rely on the #241 fixes, which justifies the stacking.
  • Adopting an existing RpcPromise directly preserves laziness (only one ["pull"] is sent) and brokenness — verified by the "keeps an adopted RpcPromise lazy" and "preserves brokenness" tests.
  • No wire-protocol change (client-side construction reusing existing representations), so no protocol.md update needed. Changeset present (minor). README documents the ownership-transfer and unbounded-queue caveats.

No logic bugs, capability leaks, or backward-compat concerns found. Ship it.

@ask-bonk

This comment was marked as off-topic.

@ask-bonk

This comment was marked as off-topic.

Comment threadsrc/index.ts
@ndisidore
ndisidore changed the base branch from fix/promise-stubhook-disposal to mainAugust 17, 2026 20:18
@ndisidore
ndisidoreforce-pushed the feat/from-promise branch 2 times, most recently from 11d58ca to 8c51593CompareAugust 17, 2026 21:02
Comment threadsrc/core.ts
Comment threadsrc/core.ts Outdated
Comment threadsrc/index.ts Outdated
Comment threadsrc/index.ts Outdated
Comment threadREADME.md Outdated
ndisidore added a commit that referenced this pull request Aug 18, 2026
Per review feedback on #242: the ownership-transfer note (nobody wraps an
RpcPromise they already hold on purpose) and the thenable-assimilation
note (not specific to this constructor) don't belong in the public docs.
The behaviors themselves are unchanged and remain pinned by tests.
Resolves the long-standing TODO on the RpcPromise constructor: the
application may now pass a Promise (or any other thenable) for the
eventual resolution. Calls made before the promise settles are queued
and delivered in order once it does, so an RpcPromise can stand in for
a capability that doesn't exist yet -- for example, one that will only
become available after a broken session has been re-established.
The promise may resolve to an RpcTarget, a stub, or a plain value.
Promise.resolve() performs thenable assimilation natively, so no
hand-rolled hardening against misbehaving thenables is needed. The
resolution is adopted with return semantics (the same representation
used for resolutions of local async calls), so awaiting delivers the
value, pipelined calls forward through it without forcing a pull, and
brokenness of a stub resolution is preserved. Passing an existing
RpcPromise adopts its hook directly, keeping it lazy.
A rejection is adopted as an ErrorStubHook rather than left to reject
the backing promise, so the promise chains behind queued calls never
reject: calls land on the ErrorStubHook (which disposes their
arguments) and the error surfaces only through pull() or onBroken().
Without this, a discarded pipelined call on a promise-backed stub
would raise an unhandled rejection event when the promise rejects
(crashing Node under its default handling), even though
fire-and-forget calls on the session-backed stub it stands in for
reject only on pull.
- Only adopt the hook of an existing RpcPromise; a bare stub's hook may
not implement pull(), so bare stubs now take the generic path, whose
resolution payload handles them correctly (await previously rejected
with "Tried to resolve a non-promise stub."). Regression test added.
- Inline hookForPromiseArg and hookForResolution into the constructor.
- Collapse the constructor's type overloads into a single signature,
narrowing the accepted type to Promise (runtime still assimilates
arbitrary thenables).
- Reframe the README section around the local-loopback RPC equivalence,
and align the jsdoc and changeset with it.
- Adopting an existing RpcPromise now consumes the source: its hook is
neutered to DISPOSED_HOOK, so disposing the source can no longer
silently kill the wrapper. Using the source after wrapping reports
the standard disposed error.
- Restore the invariant that every RpcPromise has a defined path by
defaulting pathIfPromise to [] on the internal StubHook path.
- Wrap workerd-native RpcPromise/RpcProperty values (rpc-thenable) in
a TargetStubHook so pipelined calls aren't eagerly assimilated.
- Document ownership transfer on adoption and the dup() workaround for
keeping a deferred capability lazy when resolving a native Promise
with an RpcPromise.
Per review feedback on #242: the ownership-transfer note (nobody wraps an
RpcPromise they already hold on purpose) and the thenable-assimilation
note (not specific to this constructor) don't belong in the public docs.
The behaviors themselves are unchanged and remain pinned by tests.
…pped native stubs
- get([]) on a thenable-backed TargetStubHook now returns dup() instead of
throwing, fixing dup() and argument-passing of wrapped native promises.
- onBroken() now subscribes to a thenable target's rejection, so onRpcBroken
fires when a wrapped native promise rejects instead of silently no-oping.
- Property promises share the source hook and path so the get() happens
lazily on first use, avoiding eager wire pushes / getter side effects.
…orStubHook
Per review: PromiseStubHook already handles a rejected backing promise --
it disposes the arguments of queued calls (since #241) and surfaces the
error through pull() and onBroken() -- so the constructor no longer maps
rejection to an ErrorStubHook resolution.
Observable change: a pipelined call whose result is neither awaited nor
disposed now fires an unhandled rejection event, matching the existing
behavior of local async calls. The unhandled-rejection tests now dispose
the discarded results, which both silences the event and models correct
usage.
@ndisidore
ndisidore merged commit 9751a4e into mainAug 20, 2026
8 checks passed
@github-actionsgithub-actionsBot mentioned this pull request Aug 20, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@ndisidore@kentonv@dimitropoulos
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: construct RpcPromise from a Promise - #242

Merged
ndisidore merged 6 commits into
mainfrom
feat/from-promise
Aug 20, 2026
Merged

feat: construct RpcPromise from a Promise#242
ndisidore merged 6 commits into
mainfrom
feat/from-promise

Conversation

@ndisidore

@ndisidorendisidore commented Aug 12, 2026

Copy link
Copy Markdown
Member

You can now write new RpcPromise(promise) (resolves the TODO). Calls made before the promise settles are queued and delivered in order once it does, and awaiting it yields the resolution. The promise can resolve to an RpcTarget, a stub, or a plain value.

@kentonv mentioned this re: reconnection in cloudflare-os#172

Passing an existing RpcPromise adopts its hook directly rather than awaiting it. Two caveats are documented in the README: ownership of the resolution transfers to the RpcPromise (resolve with a dup() if you also keep the stub), and pending calls queue unboundedly (reject the promise when giving up).

Stacked because the new tests exercise rejection and dispose-before-resolution paths that only behave correctly with the PromiseStubHook fixes in #241. Landing that first keeps this diff feature-only.

@changeset-bot

changeset-botBot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bf3c7be

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
NameType
capnwebMinor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-newBot commented Aug 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/capnweb@242

commit: bf3c7be

@ask-bonk

This comment was marked as outdated.

@ndisidore
ndisidoreforce-pushed the feat/from-promise branch 3 times, most recently from 0535521 to 1ecfa82CompareAugust 12, 2026 21:23
@ndisidore
ndisidore marked this pull request as ready for review August 12, 2026 21:24
Comment threadsrc/index.ts Outdated
Comment threadsrc/core.ts Outdated
Comment threadsrc/core.ts Outdated
Comment threadsrc/core.ts Outdated
Comment threadREADME.md Outdated
@ndisidore
ndisidore requested a review from kentonvAugust 12, 2026 23:32
@ndisidore

Copy link
Copy Markdown
MemberAuthor

@ask-bonk review this

@ask-bonkask-bonkBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — LGTM

Re-reviewed at 33c0122, which addresses all four of @kentonv's review comments. Verified locally: build succeeds, test:types passes, and all 178 tests in index.test.ts pass on node, chromium, and workerd.

Review feedback resolution

  1. Type overloads (src/index.ts) — the two overloads are collapsed into a single new <T extends RpcCompatible<T>>(value: Promise<T | Stub<T>>): RpcPromise<T>, and PromiseLike was dropped (non-Promise thenables no longer accepted, as agreed). Inference works without an explicit <T> — the type tests confirm new RpcPromise(Promise.resolve(pointStub)) infers RpcPromise<PointTarget> (__type-tests__/rpc-base-cases.test.ts:160-162).
  2. hookForResolution one-liner — inlined.
  3. hookForPromiseArg — inlined into the constructor.
  4. The bare-stub bug — the real catch. The branch is now gated on typeForRpc(hook) === "rpc-promise" only (src/core.ts:555), so a bare RpcStub no longer has its non-promise hook adopted directly (which couldn't answer pull()); it falls through to the payload path instead. Covered by the regression test "resolves when awaited after construction from a bare local stub" (__tests__/index.test.ts).

Correctness

  • Rejection is adopted as an ErrorStubHook and the backing PromiseStubHook promise never rejects; combined with ignoreUnhandledRejections(), discarded pipelined calls, awaits, and map() results can't leak unhandled rejections. All three "does not report an unhandled rejection…" tests pass.
  • Disposal is correctly ordered behind queued calls via PromiseStubHook.dispose() — the "delivers a call initiated before disposal" and "disposes the eventual target when disposed before resolution" tests exercise exactly the ordering paths that rely on the #241 fixes, which justifies the stacking.
  • Adopting an existing RpcPromise directly preserves laziness (only one ["pull"] is sent) and brokenness — verified by the "keeps an adopted RpcPromise lazy" and "preserves brokenness" tests.
  • No wire-protocol change (client-side construction reusing existing representations), so no protocol.md update needed. Changeset present (minor). README documents the ownership-transfer and unbounded-queue caveats.

No logic bugs, capability leaks, or backward-compat concerns found. Ship it.

@ask-bonk

This comment was marked as off-topic.

@ask-bonk

This comment was marked as off-topic.

Comment threadsrc/index.ts
@ndisidore
ndisidore changed the base branch from fix/promise-stubhook-disposal to mainAugust 17, 2026 20:18
@ndisidore
ndisidoreforce-pushed the feat/from-promise branch 2 times, most recently from 11d58ca to 8c51593CompareAugust 17, 2026 21:02
Comment threadsrc/core.ts
Comment threadsrc/core.ts Outdated
Comment threadsrc/index.ts Outdated
Comment threadsrc/index.ts Outdated
Comment threadREADME.md Outdated
ndisidore added a commit that referenced this pull request Aug 18, 2026
Per review feedback on #242: the ownership-transfer note (nobody wraps an
RpcPromise they already hold on purpose) and the thenable-assimilation
note (not specific to this constructor) don't belong in the public docs.
The behaviors themselves are unchanged and remain pinned by tests.
Resolves the long-standing TODO on the RpcPromise constructor: the
application may now pass a Promise (or any other thenable) for the
eventual resolution. Calls made before the promise settles are queued
and delivered in order once it does, so an RpcPromise can stand in for
a capability that doesn't exist yet -- for example, one that will only
become available after a broken session has been re-established.
The promise may resolve to an RpcTarget, a stub, or a plain value.
Promise.resolve() performs thenable assimilation natively, so no
hand-rolled hardening against misbehaving thenables is needed. The
resolution is adopted with return semantics (the same representation
used for resolutions of local async calls), so awaiting delivers the
value, pipelined calls forward through it without forcing a pull, and
brokenness of a stub resolution is preserved. Passing an existing
RpcPromise adopts its hook directly, keeping it lazy.
A rejection is adopted as an ErrorStubHook rather than left to reject
the backing promise, so the promise chains behind queued calls never
reject: calls land on the ErrorStubHook (which disposes their
arguments) and the error surfaces only through pull() or onBroken().
Without this, a discarded pipelined call on a promise-backed stub
would raise an unhandled rejection event when the promise rejects
(crashing Node under its default handling), even though
fire-and-forget calls on the session-backed stub it stands in for
reject only on pull.
- Only adopt the hook of an existing RpcPromise; a bare stub's hook may
not implement pull(), so bare stubs now take the generic path, whose
resolution payload handles them correctly (await previously rejected
with "Tried to resolve a non-promise stub."). Regression test added.
- Inline hookForPromiseArg and hookForResolution into the constructor.
- Collapse the constructor's type overloads into a single signature,
narrowing the accepted type to Promise (runtime still assimilates
arbitrary thenables).
- Reframe the README section around the local-loopback RPC equivalence,
and align the jsdoc and changeset with it.
- Adopting an existing RpcPromise now consumes the source: its hook is
neutered to DISPOSED_HOOK, so disposing the source can no longer
silently kill the wrapper. Using the source after wrapping reports
the standard disposed error.
- Restore the invariant that every RpcPromise has a defined path by
defaulting pathIfPromise to [] on the internal StubHook path.
- Wrap workerd-native RpcPromise/RpcProperty values (rpc-thenable) in
a TargetStubHook so pipelined calls aren't eagerly assimilated.
- Document ownership transfer on adoption and the dup() workaround for
keeping a deferred capability lazy when resolving a native Promise
with an RpcPromise.
Per review feedback on #242: the ownership-transfer note (nobody wraps an
RpcPromise they already hold on purpose) and the thenable-assimilation
note (not specific to this constructor) don't belong in the public docs.
The behaviors themselves are unchanged and remain pinned by tests.
…pped native stubs
- get([]) on a thenable-backed TargetStubHook now returns dup() instead of
throwing, fixing dup() and argument-passing of wrapped native promises.
- onBroken() now subscribes to a thenable target's rejection, so onRpcBroken
fires when a wrapped native promise rejects instead of silently no-oping.
- Property promises share the source hook and path so the get() happens
lazily on first use, avoiding eager wire pushes / getter side effects.
…orStubHook
Per review: PromiseStubHook already handles a rejected backing promise --
it disposes the arguments of queued calls (since #241) and surfaces the
error through pull() and onBroken() -- so the constructor no longer maps
rejection to an ErrorStubHook resolution.
Observable change: a pipelined call whose result is neither awaited nor
disposed now fires an unhandled rejection event, matching the existing
behavior of local async calls. The unhandled-rejection tests now dispose
the discarded results, which both silences the event and models correct
usage.
@ndisidore
ndisidore merged commit 9751a4e into mainAug 20, 2026
8 checks passed
@github-actionsgithub-actionsBot mentioned this pull request Aug 20, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@ndisidore@kentonv@dimitropoulos
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: construct RpcPromise from a Promise - #242

Merged
ndisidore merged 6 commits into
mainfrom
feat/from-promise
Aug 20, 2026
Merged

feat: construct RpcPromise from a Promise#242
ndisidore merged 6 commits into
mainfrom
feat/from-promise

Conversation

@ndisidore

@ndisidorendisidore commented Aug 12, 2026

Copy link
Copy Markdown
Member

You can now write new RpcPromise(promise) (resolves the TODO). Calls made before the promise settles are queued and delivered in order once it does, and awaiting it yields the resolution. The promise can resolve to an RpcTarget, a stub, or a plain value.

@kentonv mentioned this re: reconnection in cloudflare-os#172

Passing an existing RpcPromise adopts its hook directly rather than awaiting it. Two caveats are documented in the README: ownership of the resolution transfers to the RpcPromise (resolve with a dup() if you also keep the stub), and pending calls queue unboundedly (reject the promise when giving up).

Stacked because the new tests exercise rejection and dispose-before-resolution paths that only behave correctly with the PromiseStubHook fixes in #241. Landing that first keeps this diff feature-only.

@changeset-bot

changeset-botBot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bf3c7be

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
NameType
capnwebMinor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-newBot commented Aug 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/capnweb@242

commit: bf3c7be

@ask-bonk

This comment was marked as outdated.

@ndisidore
ndisidoreforce-pushed the feat/from-promise branch 3 times, most recently from 0535521 to 1ecfa82CompareAugust 12, 2026 21:23
@ndisidore
ndisidore marked this pull request as ready for review August 12, 2026 21:24
Comment threadsrc/index.ts Outdated
Comment threadsrc/core.ts Outdated
Comment threadsrc/core.ts Outdated
Comment threadsrc/core.ts Outdated
Comment threadREADME.md Outdated
@ndisidore
ndisidore requested a review from kentonvAugust 12, 2026 23:32
@ndisidore

Copy link
Copy Markdown
MemberAuthor

@ask-bonk review this

@ask-bonkask-bonkBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — LGTM

Re-reviewed at 33c0122, which addresses all four of @kentonv's review comments. Verified locally: build succeeds, test:types passes, and all 178 tests in index.test.ts pass on node, chromium, and workerd.

Review feedback resolution

  1. Type overloads (src/index.ts) — the two overloads are collapsed into a single new <T extends RpcCompatible<T>>(value: Promise<T | Stub<T>>): RpcPromise<T>, and PromiseLike was dropped (non-Promise thenables no longer accepted, as agreed). Inference works without an explicit <T> — the type tests confirm new RpcPromise(Promise.resolve(pointStub)) infers RpcPromise<PointTarget> (__type-tests__/rpc-base-cases.test.ts:160-162).
  2. hookForResolution one-liner — inlined.
  3. hookForPromiseArg — inlined into the constructor.
  4. The bare-stub bug — the real catch. The branch is now gated on typeForRpc(hook) === "rpc-promise" only (src/core.ts:555), so a bare RpcStub no longer has its non-promise hook adopted directly (which couldn't answer pull()); it falls through to the payload path instead. Covered by the regression test "resolves when awaited after construction from a bare local stub" (__tests__/index.test.ts).

Correctness

  • Rejection is adopted as an ErrorStubHook and the backing PromiseStubHook promise never rejects; combined with ignoreUnhandledRejections(), discarded pipelined calls, awaits, and map() results can't leak unhandled rejections. All three "does not report an unhandled rejection…" tests pass.
  • Disposal is correctly ordered behind queued calls via PromiseStubHook.dispose() — the "delivers a call initiated before disposal" and "disposes the eventual target when disposed before resolution" tests exercise exactly the ordering paths that rely on the #241 fixes, which justifies the stacking.
  • Adopting an existing RpcPromise directly preserves laziness (only one ["pull"] is sent) and brokenness — verified by the "keeps an adopted RpcPromise lazy" and "preserves brokenness" tests.
  • No wire-protocol change (client-side construction reusing existing representations), so no protocol.md update needed. Changeset present (minor). README documents the ownership-transfer and unbounded-queue caveats.

No logic bugs, capability leaks, or backward-compat concerns found. Ship it.

@ask-bonk

This comment was marked as off-topic.

@ask-bonk

This comment was marked as off-topic.

Comment threadsrc/index.ts
@ndisidore
ndisidore changed the base branch from fix/promise-stubhook-disposal to mainAugust 17, 2026 20:18
@ndisidore
ndisidoreforce-pushed the feat/from-promise branch 2 times, most recently from 11d58ca to 8c51593CompareAugust 17, 2026 21:02
Comment threadsrc/core.ts
Comment threadsrc/core.ts Outdated
Comment threadsrc/index.ts Outdated
Comment threadsrc/index.ts Outdated
Comment threadREADME.md Outdated
ndisidore added a commit that referenced this pull request Aug 18, 2026
Per review feedback on #242: the ownership-transfer note (nobody wraps an
RpcPromise they already hold on purpose) and the thenable-assimilation
note (not specific to this constructor) don't belong in the public docs.
The behaviors themselves are unchanged and remain pinned by tests.
Resolves the long-standing TODO on the RpcPromise constructor: the
application may now pass a Promise (or any other thenable) for the
eventual resolution. Calls made before the promise settles are queued
and delivered in order once it does, so an RpcPromise can stand in for
a capability that doesn't exist yet -- for example, one that will only
become available after a broken session has been re-established.
The promise may resolve to an RpcTarget, a stub, or a plain value.
Promise.resolve() performs thenable assimilation natively, so no
hand-rolled hardening against misbehaving thenables is needed. The
resolution is adopted with return semantics (the same representation
used for resolutions of local async calls), so awaiting delivers the
value, pipelined calls forward through it without forcing a pull, and
brokenness of a stub resolution is preserved. Passing an existing
RpcPromise adopts its hook directly, keeping it lazy.
A rejection is adopted as an ErrorStubHook rather than left to reject
the backing promise, so the promise chains behind queued calls never
reject: calls land on the ErrorStubHook (which disposes their
arguments) and the error surfaces only through pull() or onBroken().
Without this, a discarded pipelined call on a promise-backed stub
would raise an unhandled rejection event when the promise rejects
(crashing Node under its default handling), even though
fire-and-forget calls on the session-backed stub it stands in for
reject only on pull.
- Only adopt the hook of an existing RpcPromise; a bare stub's hook may
not implement pull(), so bare stubs now take the generic path, whose
resolution payload handles them correctly (await previously rejected
with "Tried to resolve a non-promise stub."). Regression test added.
- Inline hookForPromiseArg and hookForResolution into the constructor.
- Collapse the constructor's type overloads into a single signature,
narrowing the accepted type to Promise (runtime still assimilates
arbitrary thenables).
- Reframe the README section around the local-loopback RPC equivalence,
and align the jsdoc and changeset with it.
- Adopting an existing RpcPromise now consumes the source: its hook is
neutered to DISPOSED_HOOK, so disposing the source can no longer
silently kill the wrapper. Using the source after wrapping reports
the standard disposed error.
- Restore the invariant that every RpcPromise has a defined path by
defaulting pathIfPromise to [] on the internal StubHook path.
- Wrap workerd-native RpcPromise/RpcProperty values (rpc-thenable) in
a TargetStubHook so pipelined calls aren't eagerly assimilated.
- Document ownership transfer on adoption and the dup() workaround for
keeping a deferred capability lazy when resolving a native Promise
with an RpcPromise.
Per review feedback on #242: the ownership-transfer note (nobody wraps an
RpcPromise they already hold on purpose) and the thenable-assimilation
note (not specific to this constructor) don't belong in the public docs.
The behaviors themselves are unchanged and remain pinned by tests.
…pped native stubs
- get([]) on a thenable-backed TargetStubHook now returns dup() instead of
throwing, fixing dup() and argument-passing of wrapped native promises.
- onBroken() now subscribes to a thenable target's rejection, so onRpcBroken
fires when a wrapped native promise rejects instead of silently no-oping.
- Property promises share the source hook and path so the get() happens
lazily on first use, avoiding eager wire pushes / getter side effects.
…orStubHook
Per review: PromiseStubHook already handles a rejected backing promise --
it disposes the arguments of queued calls (since #241) and surfaces the
error through pull() and onBroken() -- so the constructor no longer maps
rejection to an ErrorStubHook resolution.
Observable change: a pipelined call whose result is neither awaited nor
disposed now fires an unhandled rejection event, matching the existing
behavior of local async calls. The unhandled-rejection tests now dispose
the discarded results, which both silences the event and models correct
usage.
@ndisidore
ndisidore merged commit 9751a4e into mainAug 20, 2026
8 checks passed
@github-actionsgithub-actionsBot mentioned this pull request Aug 20, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@ndisidore@kentonv@dimitropoulos
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat: construct RpcPromise from a Promise - #242

Merged
ndisidore merged 6 commits into
mainfrom
feat/from-promise
Aug 20, 2026
Merged

feat: construct RpcPromise from a Promise#242
ndisidore merged 6 commits into
mainfrom
feat/from-promise

Conversation

@ndisidore

@ndisidorendisidore commented Aug 12, 2026

Copy link
Copy Markdown
Member

You can now write new RpcPromise(promise) (resolves the TODO). Calls made before the promise settles are queued and delivered in order once it does, and awaiting it yields the resolution. The promise can resolve to an RpcTarget, a stub, or a plain value.

@kentonv mentioned this re: reconnection in cloudflare-os#172

Passing an existing RpcPromise adopts its hook directly rather than awaiting it. Two caveats are documented in the README: ownership of the resolution transfers to the RpcPromise (resolve with a dup() if you also keep the stub), and pending calls queue unboundedly (reject the promise when giving up).

Stacked because the new tests exercise rejection and dispose-before-resolution paths that only behave correctly with the PromiseStubHook fixes in #241. Landing that first keeps this diff feature-only.

@changeset-bot

changeset-botBot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bf3c7be

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
NameType
capnwebMinor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-newBot commented Aug 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/capnweb@242

commit: bf3c7be

@ask-bonk

This comment was marked as outdated.

@ndisidore
ndisidoreforce-pushed the feat/from-promise branch 3 times, most recently from 0535521 to 1ecfa82CompareAugust 12, 2026 21:23
@ndisidore
ndisidore marked this pull request as ready for review August 12, 2026 21:24
Comment threadsrc/index.ts Outdated
Comment threadsrc/core.ts Outdated
Comment threadsrc/core.ts Outdated
Comment threadsrc/core.ts Outdated
Comment threadREADME.md Outdated
@ndisidore
ndisidore requested a review from kentonvAugust 12, 2026 23:32
@ndisidore

Copy link
Copy Markdown
MemberAuthor

@ask-bonk review this

@ask-bonkask-bonkBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — LGTM

Re-reviewed at 33c0122, which addresses all four of @kentonv's review comments. Verified locally: build succeeds, test:types passes, and all 178 tests in index.test.ts pass on node, chromium, and workerd.

Review feedback resolution

  1. Type overloads (src/index.ts) — the two overloads are collapsed into a single new <T extends RpcCompatible<T>>(value: Promise<T | Stub<T>>): RpcPromise<T>, and PromiseLike was dropped (non-Promise thenables no longer accepted, as agreed). Inference works without an explicit <T> — the type tests confirm new RpcPromise(Promise.resolve(pointStub)) infers RpcPromise<PointTarget> (__type-tests__/rpc-base-cases.test.ts:160-162).
  2. hookForResolution one-liner — inlined.
  3. hookForPromiseArg — inlined into the constructor.
  4. The bare-stub bug — the real catch. The branch is now gated on typeForRpc(hook) === "rpc-promise" only (src/core.ts:555), so a bare RpcStub no longer has its non-promise hook adopted directly (which couldn't answer pull()); it falls through to the payload path instead. Covered by the regression test "resolves when awaited after construction from a bare local stub" (__tests__/index.test.ts).

Correctness

  • Rejection is adopted as an ErrorStubHook and the backing PromiseStubHook promise never rejects; combined with ignoreUnhandledRejections(), discarded pipelined calls, awaits, and map() results can't leak unhandled rejections. All three "does not report an unhandled rejection…" tests pass.
  • Disposal is correctly ordered behind queued calls via PromiseStubHook.dispose() — the "delivers a call initiated before disposal" and "disposes the eventual target when disposed before resolution" tests exercise exactly the ordering paths that rely on the #241 fixes, which justifies the stacking.
  • Adopting an existing RpcPromise directly preserves laziness (only one ["pull"] is sent) and brokenness — verified by the "keeps an adopted RpcPromise lazy" and "preserves brokenness" tests.
  • No wire-protocol change (client-side construction reusing existing representations), so no protocol.md update needed. Changeset present (minor). README documents the ownership-transfer and unbounded-queue caveats.

No logic bugs, capability leaks, or backward-compat concerns found. Ship it.

@ask-bonk

This comment was marked as off-topic.

@ask-bonk

This comment was marked as off-topic.

Comment threadsrc/index.ts
@ndisidore
ndisidore changed the base branch from fix/promise-stubhook-disposal to mainAugust 17, 2026 20:18
@ndisidore
ndisidoreforce-pushed the feat/from-promise branch 2 times, most recently from 11d58ca to 8c51593CompareAugust 17, 2026 21:02
Comment threadsrc/core.ts
Comment threadsrc/core.ts Outdated
Comment threadsrc/index.ts Outdated
Comment threadsrc/index.ts Outdated
Comment threadREADME.md Outdated
ndisidore added a commit that referenced this pull request Aug 18, 2026
Per review feedback on #242: the ownership-transfer note (nobody wraps an
RpcPromise they already hold on purpose) and the thenable-assimilation
note (not specific to this constructor) don't belong in the public docs.
The behaviors themselves are unchanged and remain pinned by tests.
Resolves the long-standing TODO on the RpcPromise constructor: the
application may now pass a Promise (or any other thenable) for the
eventual resolution. Calls made before the promise settles are queued
and delivered in order once it does, so an RpcPromise can stand in for
a capability that doesn't exist yet -- for example, one that will only
become available after a broken session has been re-established.
The promise may resolve to an RpcTarget, a stub, or a plain value.
Promise.resolve() performs thenable assimilation natively, so no
hand-rolled hardening against misbehaving thenables is needed. The
resolution is adopted with return semantics (the same representation
used for resolutions of local async calls), so awaiting delivers the
value, pipelined calls forward through it without forcing a pull, and
brokenness of a stub resolution is preserved. Passing an existing
RpcPromise adopts its hook directly, keeping it lazy.
A rejection is adopted as an ErrorStubHook rather than left to reject
the backing promise, so the promise chains behind queued calls never
reject: calls land on the ErrorStubHook (which disposes their
arguments) and the error surfaces only through pull() or onBroken().
Without this, a discarded pipelined call on a promise-backed stub
would raise an unhandled rejection event when the promise rejects
(crashing Node under its default handling), even though
fire-and-forget calls on the session-backed stub it stands in for
reject only on pull.
- Only adopt the hook of an existing RpcPromise; a bare stub's hook may
not implement pull(), so bare stubs now take the generic path, whose
resolution payload handles them correctly (await previously rejected
with "Tried to resolve a non-promise stub."). Regression test added.
- Inline hookForPromiseArg and hookForResolution into the constructor.
- Collapse the constructor's type overloads into a single signature,
narrowing the accepted type to Promise (runtime still assimilates
arbitrary thenables).
- Reframe the README section around the local-loopback RPC equivalence,
and align the jsdoc and changeset with it.
- Adopting an existing RpcPromise now consumes the source: its hook is
neutered to DISPOSED_HOOK, so disposing the source can no longer
silently kill the wrapper. Using the source after wrapping reports
the standard disposed error.
- Restore the invariant that every RpcPromise has a defined path by
defaulting pathIfPromise to [] on the internal StubHook path.
- Wrap workerd-native RpcPromise/RpcProperty values (rpc-thenable) in
a TargetStubHook so pipelined calls aren't eagerly assimilated.
- Document ownership transfer on adoption and the dup() workaround for
keeping a deferred capability lazy when resolving a native Promise
with an RpcPromise.
Per review feedback on #242: the ownership-transfer note (nobody wraps an
RpcPromise they already hold on purpose) and the thenable-assimilation
note (not specific to this constructor) don't belong in the public docs.
The behaviors themselves are unchanged and remain pinned by tests.
…pped native stubs
- get([]) on a thenable-backed TargetStubHook now returns dup() instead of
throwing, fixing dup() and argument-passing of wrapped native promises.
- onBroken() now subscribes to a thenable target's rejection, so onRpcBroken
fires when a wrapped native promise rejects instead of silently no-oping.
- Property promises share the source hook and path so the get() happens
lazily on first use, avoiding eager wire pushes / getter side effects.
…orStubHook
Per review: PromiseStubHook already handles a rejected backing promise --
it disposes the arguments of queued calls (since #241) and surfaces the
error through pull() and onBroken() -- so the constructor no longer maps
rejection to an ErrorStubHook resolution.
Observable change: a pipelined call whose result is neither awaited nor
disposed now fires an unhandled rejection event, matching the existing
behavior of local async calls. The unhandled-rejection tests now dispose
the discarded results, which both silences the event and models correct
usage.
@ndisidore
ndisidore merged commit 9751a4e into mainAug 20, 2026
8 checks passed
@github-actionsgithub-actionsBot mentioned this pull request Aug 20, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@ndisidore@kentonv@dimitropoulos
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: construct RpcPromise from a Promise - #242

Merged
ndisidore merged 6 commits into
mainfrom
feat/from-promise
Aug 20, 2026
Merged

feat: construct RpcPromise from a Promise#242
ndisidore merged 6 commits into
mainfrom
feat/from-promise

Conversation

@ndisidore

@ndisidorendisidore commented Aug 12, 2026

Copy link
Copy Markdown
Member

You can now write new RpcPromise(promise) (resolves the TODO). Calls made before the promise settles are queued and delivered in order once it does, and awaiting it yields the resolution. The promise can resolve to an RpcTarget, a stub, or a plain value.

@kentonv mentioned this re: reconnection in cloudflare-os#172

Passing an existing RpcPromise adopts its hook directly rather than awaiting it. Two caveats are documented in the README: ownership of the resolution transfers to the RpcPromise (resolve with a dup() if you also keep the stub), and pending calls queue unboundedly (reject the promise when giving up).

Stacked because the new tests exercise rejection and dispose-before-resolution paths that only behave correctly with the PromiseStubHook fixes in #241. Landing that first keeps this diff feature-only.

@changeset-bot

changeset-botBot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bf3c7be

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
NameType
capnwebMinor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-newBot commented Aug 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/capnweb@242

commit: bf3c7be

@ask-bonk

This comment was marked as outdated.

@ndisidore
ndisidoreforce-pushed the feat/from-promise branch 3 times, most recently from 0535521 to 1ecfa82CompareAugust 12, 2026 21:23
@ndisidore
ndisidore marked this pull request as ready for review August 12, 2026 21:24
Comment threadsrc/index.ts Outdated
Comment threadsrc/core.ts Outdated
Comment threadsrc/core.ts Outdated
Comment threadsrc/core.ts Outdated
Comment threadREADME.md Outdated
@ndisidore
ndisidore requested a review from kentonvAugust 12, 2026 23:32
@ndisidore

Copy link
Copy Markdown
MemberAuthor

@ask-bonk review this

@ask-bonkask-bonkBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — LGTM

Re-reviewed at 33c0122, which addresses all four of @kentonv's review comments. Verified locally: build succeeds, test:types passes, and all 178 tests in index.test.ts pass on node, chromium, and workerd.

Review feedback resolution

  1. Type overloads (src/index.ts) — the two overloads are collapsed into a single new <T extends RpcCompatible<T>>(value: Promise<T | Stub<T>>): RpcPromise<T>, and PromiseLike was dropped (non-Promise thenables no longer accepted, as agreed). Inference works without an explicit <T> — the type tests confirm new RpcPromise(Promise.resolve(pointStub)) infers RpcPromise<PointTarget> (__type-tests__/rpc-base-cases.test.ts:160-162).
  2. hookForResolution one-liner — inlined.
  3. hookForPromiseArg — inlined into the constructor.
  4. The bare-stub bug — the real catch. The branch is now gated on typeForRpc(hook) === "rpc-promise" only (src/core.ts:555), so a bare RpcStub no longer has its non-promise hook adopted directly (which couldn't answer pull()); it falls through to the payload path instead. Covered by the regression test "resolves when awaited after construction from a bare local stub" (__tests__/index.test.ts).

Correctness

  • Rejection is adopted as an ErrorStubHook and the backing PromiseStubHook promise never rejects; combined with ignoreUnhandledRejections(), discarded pipelined calls, awaits, and map() results can't leak unhandled rejections. All three "does not report an unhandled rejection…" tests pass.
  • Disposal is correctly ordered behind queued calls via PromiseStubHook.dispose() — the "delivers a call initiated before disposal" and "disposes the eventual target when disposed before resolution" tests exercise exactly the ordering paths that rely on the #241 fixes, which justifies the stacking.
  • Adopting an existing RpcPromise directly preserves laziness (only one ["pull"] is sent) and brokenness — verified by the "keeps an adopted RpcPromise lazy" and "preserves brokenness" tests.
  • No wire-protocol change (client-side construction reusing existing representations), so no protocol.md update needed. Changeset present (minor). README documents the ownership-transfer and unbounded-queue caveats.

No logic bugs, capability leaks, or backward-compat concerns found. Ship it.

@ask-bonk

This comment was marked as off-topic.

@ask-bonk

This comment was marked as off-topic.

Comment threadsrc/index.ts
@ndisidore
ndisidore changed the base branch from fix/promise-stubhook-disposal to mainAugust 17, 2026 20:18
@ndisidore
ndisidoreforce-pushed the feat/from-promise branch 2 times, most recently from 11d58ca to 8c51593CompareAugust 17, 2026 21:02
Comment threadsrc/core.ts
Comment threadsrc/core.ts Outdated
Comment threadsrc/index.ts Outdated
Comment threadsrc/index.ts Outdated
Comment threadREADME.md Outdated
ndisidore added a commit that referenced this pull request Aug 18, 2026
Per review feedback on #242: the ownership-transfer note (nobody wraps an
RpcPromise they already hold on purpose) and the thenable-assimilation
note (not specific to this constructor) don't belong in the public docs.
The behaviors themselves are unchanged and remain pinned by tests.
Resolves the long-standing TODO on the RpcPromise constructor: the
application may now pass a Promise (or any other thenable) for the
eventual resolution. Calls made before the promise settles are queued
and delivered in order once it does, so an RpcPromise can stand in for
a capability that doesn't exist yet -- for example, one that will only
become available after a broken session has been re-established.
The promise may resolve to an RpcTarget, a stub, or a plain value.
Promise.resolve() performs thenable assimilation natively, so no
hand-rolled hardening against misbehaving thenables is needed. The
resolution is adopted with return semantics (the same representation
used for resolutions of local async calls), so awaiting delivers the
value, pipelined calls forward through it without forcing a pull, and
brokenness of a stub resolution is preserved. Passing an existing
RpcPromise adopts its hook directly, keeping it lazy.
A rejection is adopted as an ErrorStubHook rather than left to reject
the backing promise, so the promise chains behind queued calls never
reject: calls land on the ErrorStubHook (which disposes their
arguments) and the error surfaces only through pull() or onBroken().
Without this, a discarded pipelined call on a promise-backed stub
would raise an unhandled rejection event when the promise rejects
(crashing Node under its default handling), even though
fire-and-forget calls on the session-backed stub it stands in for
reject only on pull.
- Only adopt the hook of an existing RpcPromise; a bare stub's hook may
not implement pull(), so bare stubs now take the generic path, whose
resolution payload handles them correctly (await previously rejected
with "Tried to resolve a non-promise stub."). Regression test added.
- Inline hookForPromiseArg and hookForResolution into the constructor.
- Collapse the constructor's type overloads into a single signature,
narrowing the accepted type to Promise (runtime still assimilates
arbitrary thenables).
- Reframe the README section around the local-loopback RPC equivalence,
and align the jsdoc and changeset with it.
- Adopting an existing RpcPromise now consumes the source: its hook is
neutered to DISPOSED_HOOK, so disposing the source can no longer
silently kill the wrapper. Using the source after wrapping reports
the standard disposed error.
- Restore the invariant that every RpcPromise has a defined path by
defaulting pathIfPromise to [] on the internal StubHook path.
- Wrap workerd-native RpcPromise/RpcProperty values (rpc-thenable) in
a TargetStubHook so pipelined calls aren't eagerly assimilated.
- Document ownership transfer on adoption and the dup() workaround for
keeping a deferred capability lazy when resolving a native Promise
with an RpcPromise.
Per review feedback on #242: the ownership-transfer note (nobody wraps an
RpcPromise they already hold on purpose) and the thenable-assimilation
note (not specific to this constructor) don't belong in the public docs.
The behaviors themselves are unchanged and remain pinned by tests.
…pped native stubs
- get([]) on a thenable-backed TargetStubHook now returns dup() instead of
throwing, fixing dup() and argument-passing of wrapped native promises.
- onBroken() now subscribes to a thenable target's rejection, so onRpcBroken
fires when a wrapped native promise rejects instead of silently no-oping.
- Property promises share the source hook and path so the get() happens
lazily on first use, avoiding eager wire pushes / getter side effects.
…orStubHook
Per review: PromiseStubHook already handles a rejected backing promise --
it disposes the arguments of queued calls (since #241) and surfaces the
error through pull() and onBroken() -- so the constructor no longer maps
rejection to an ErrorStubHook resolution.
Observable change: a pipelined call whose result is neither awaited nor
disposed now fires an unhandled rejection event, matching the existing
behavior of local async calls. The unhandled-rejection tests now dispose
the discarded results, which both silences the event and models correct
usage.
@ndisidore
ndisidore merged commit 9751a4e into mainAug 20, 2026
8 checks passed
@github-actionsgithub-actionsBot mentioned this pull request Aug 20, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@ndisidore@kentonv@dimitropoulos
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: construct RpcPromise from a Promise - #242

Merged
ndisidore merged 6 commits into
mainfrom
feat/from-promise
Aug 20, 2026
Merged

feat: construct RpcPromise from a Promise#242
ndisidore merged 6 commits into
mainfrom
feat/from-promise

Conversation

@ndisidore

@ndisidorendisidore commented Aug 12, 2026

Copy link
Copy Markdown
Member

You can now write new RpcPromise(promise) (resolves the TODO). Calls made before the promise settles are queued and delivered in order once it does, and awaiting it yields the resolution. The promise can resolve to an RpcTarget, a stub, or a plain value.

@kentonv mentioned this re: reconnection in cloudflare-os#172

Passing an existing RpcPromise adopts its hook directly rather than awaiting it. Two caveats are documented in the README: ownership of the resolution transfers to the RpcPromise (resolve with a dup() if you also keep the stub), and pending calls queue unboundedly (reject the promise when giving up).

Stacked because the new tests exercise rejection and dispose-before-resolution paths that only behave correctly with the PromiseStubHook fixes in #241. Landing that first keeps this diff feature-only.

@changeset-bot

changeset-botBot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bf3c7be

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
NameType
capnwebMinor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-newBot commented Aug 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/capnweb@242

commit: bf3c7be

@ask-bonk

This comment was marked as outdated.

@ndisidore
ndisidoreforce-pushed the feat/from-promise branch 3 times, most recently from 0535521 to 1ecfa82CompareAugust 12, 2026 21:23
@ndisidore
ndisidore marked this pull request as ready for review August 12, 2026 21:24
Comment threadsrc/index.ts Outdated
Comment threadsrc/core.ts Outdated
Comment threadsrc/core.ts Outdated
Comment threadsrc/core.ts Outdated
Comment threadREADME.md Outdated
@ndisidore
ndisidore requested a review from kentonvAugust 12, 2026 23:32
@ndisidore

Copy link
Copy Markdown
MemberAuthor

@ask-bonk review this

@ask-bonkask-bonkBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — LGTM

Re-reviewed at 33c0122, which addresses all four of @kentonv's review comments. Verified locally: build succeeds, test:types passes, and all 178 tests in index.test.ts pass on node, chromium, and workerd.

Review feedback resolution

  1. Type overloads (src/index.ts) — the two overloads are collapsed into a single new <T extends RpcCompatible<T>>(value: Promise<T | Stub<T>>): RpcPromise<T>, and PromiseLike was dropped (non-Promise thenables no longer accepted, as agreed). Inference works without an explicit <T> — the type tests confirm new RpcPromise(Promise.resolve(pointStub)) infers RpcPromise<PointTarget> (__type-tests__/rpc-base-cases.test.ts:160-162).
  2. hookForResolution one-liner — inlined.
  3. hookForPromiseArg — inlined into the constructor.
  4. The bare-stub bug — the real catch. The branch is now gated on typeForRpc(hook) === "rpc-promise" only (src/core.ts:555), so a bare RpcStub no longer has its non-promise hook adopted directly (which couldn't answer pull()); it falls through to the payload path instead. Covered by the regression test "resolves when awaited after construction from a bare local stub" (__tests__/index.test.ts).

Correctness

  • Rejection is adopted as an ErrorStubHook and the backing PromiseStubHook promise never rejects; combined with ignoreUnhandledRejections(), discarded pipelined calls, awaits, and map() results can't leak unhandled rejections. All three "does not report an unhandled rejection…" tests pass.
  • Disposal is correctly ordered behind queued calls via PromiseStubHook.dispose() — the "delivers a call initiated before disposal" and "disposes the eventual target when disposed before resolution" tests exercise exactly the ordering paths that rely on the #241 fixes, which justifies the stacking.
  • Adopting an existing RpcPromise directly preserves laziness (only one ["pull"] is sent) and brokenness — verified by the "keeps an adopted RpcPromise lazy" and "preserves brokenness" tests.
  • No wire-protocol change (client-side construction reusing existing representations), so no protocol.md update needed. Changeset present (minor). README documents the ownership-transfer and unbounded-queue caveats.

No logic bugs, capability leaks, or backward-compat concerns found. Ship it.

@ask-bonk

This comment was marked as off-topic.

@ask-bonk

This comment was marked as off-topic.

Comment threadsrc/index.ts
@ndisidore
ndisidore changed the base branch from fix/promise-stubhook-disposal to mainAugust 17, 2026 20:18
@ndisidore
ndisidoreforce-pushed the feat/from-promise branch 2 times, most recently from 11d58ca to 8c51593CompareAugust 17, 2026 21:02
Comment threadsrc/core.ts
Comment threadsrc/core.ts Outdated
Comment threadsrc/index.ts Outdated
Comment threadsrc/index.ts Outdated
Comment threadREADME.md Outdated
ndisidore added a commit that referenced this pull request Aug 18, 2026
Per review feedback on #242: the ownership-transfer note (nobody wraps an
RpcPromise they already hold on purpose) and the thenable-assimilation
note (not specific to this constructor) don't belong in the public docs.
The behaviors themselves are unchanged and remain pinned by tests.
Resolves the long-standing TODO on the RpcPromise constructor: the
application may now pass a Promise (or any other thenable) for the
eventual resolution. Calls made before the promise settles are queued
and delivered in order once it does, so an RpcPromise can stand in for
a capability that doesn't exist yet -- for example, one that will only
become available after a broken session has been re-established.
The promise may resolve to an RpcTarget, a stub, or a plain value.
Promise.resolve() performs thenable assimilation natively, so no
hand-rolled hardening against misbehaving thenables is needed. The
resolution is adopted with return semantics (the same representation
used for resolutions of local async calls), so awaiting delivers the
value, pipelined calls forward through it without forcing a pull, and
brokenness of a stub resolution is preserved. Passing an existing
RpcPromise adopts its hook directly, keeping it lazy.
A rejection is adopted as an ErrorStubHook rather than left to reject
the backing promise, so the promise chains behind queued calls never
reject: calls land on the ErrorStubHook (which disposes their
arguments) and the error surfaces only through pull() or onBroken().
Without this, a discarded pipelined call on a promise-backed stub
would raise an unhandled rejection event when the promise rejects
(crashing Node under its default handling), even though
fire-and-forget calls on the session-backed stub it stands in for
reject only on pull.
- Only adopt the hook of an existing RpcPromise; a bare stub's hook may
not implement pull(), so bare stubs now take the generic path, whose
resolution payload handles them correctly (await previously rejected
with "Tried to resolve a non-promise stub."). Regression test added.
- Inline hookForPromiseArg and hookForResolution into the constructor.
- Collapse the constructor's type overloads into a single signature,
narrowing the accepted type to Promise (runtime still assimilates
arbitrary thenables).
- Reframe the README section around the local-loopback RPC equivalence,
and align the jsdoc and changeset with it.
- Adopting an existing RpcPromise now consumes the source: its hook is
neutered to DISPOSED_HOOK, so disposing the source can no longer
silently kill the wrapper. Using the source after wrapping reports
the standard disposed error.
- Restore the invariant that every RpcPromise has a defined path by
defaulting pathIfPromise to [] on the internal StubHook path.
- Wrap workerd-native RpcPromise/RpcProperty values (rpc-thenable) in
a TargetStubHook so pipelined calls aren't eagerly assimilated.
- Document ownership transfer on adoption and the dup() workaround for
keeping a deferred capability lazy when resolving a native Promise
with an RpcPromise.
Per review feedback on #242: the ownership-transfer note (nobody wraps an
RpcPromise they already hold on purpose) and the thenable-assimilation
note (not specific to this constructor) don't belong in the public docs.
The behaviors themselves are unchanged and remain pinned by tests.
…pped native stubs
- get([]) on a thenable-backed TargetStubHook now returns dup() instead of
throwing, fixing dup() and argument-passing of wrapped native promises.
- onBroken() now subscribes to a thenable target's rejection, so onRpcBroken
fires when a wrapped native promise rejects instead of silently no-oping.
- Property promises share the source hook and path so the get() happens
lazily on first use, avoiding eager wire pushes / getter side effects.
…orStubHook
Per review: PromiseStubHook already handles a rejected backing promise --
it disposes the arguments of queued calls (since #241) and surfaces the
error through pull() and onBroken() -- so the constructor no longer maps
rejection to an ErrorStubHook resolution.
Observable change: a pipelined call whose result is neither awaited nor
disposed now fires an unhandled rejection event, matching the existing
behavior of local async calls. The unhandled-rejection tests now dispose
the discarded results, which both silences the event and models correct
usage.
@ndisidore
ndisidore merged commit 9751a4e into mainAug 20, 2026
8 checks passed
@github-actionsgithub-actionsBot mentioned this pull request Aug 20, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@ndisidore@kentonv@dimitropoulos
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat: construct RpcPromise from a Promise - #242

Merged
ndisidore merged 6 commits into
mainfrom
feat/from-promise
Aug 20, 2026
Merged

feat: construct RpcPromise from a Promise#242
ndisidore merged 6 commits into
mainfrom
feat/from-promise

Conversation

@ndisidore

@ndisidorendisidore commented Aug 12, 2026

Copy link
Copy Markdown
Member

You can now write new RpcPromise(promise) (resolves the TODO). Calls made before the promise settles are queued and delivered in order once it does, and awaiting it yields the resolution. The promise can resolve to an RpcTarget, a stub, or a plain value.

@kentonv mentioned this re: reconnection in cloudflare-os#172

Passing an existing RpcPromise adopts its hook directly rather than awaiting it. Two caveats are documented in the README: ownership of the resolution transfers to the RpcPromise (resolve with a dup() if you also keep the stub), and pending calls queue unboundedly (reject the promise when giving up).

Stacked because the new tests exercise rejection and dispose-before-resolution paths that only behave correctly with the PromiseStubHook fixes in #241. Landing that first keeps this diff feature-only.

@changeset-bot

changeset-botBot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bf3c7be

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
NameType
capnwebMinor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-newBot commented Aug 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/cloudflare/capnweb@242

commit: bf3c7be

@ask-bonk

This comment was marked as outdated.

@ndisidore
ndisidoreforce-pushed the feat/from-promise branch 3 times, most recently from 0535521 to 1ecfa82CompareAugust 12, 2026 21:23
@ndisidore
ndisidore marked this pull request as ready for review August 12, 2026 21:24
Comment threadsrc/index.ts Outdated
Comment threadsrc/core.ts Outdated
Comment threadsrc/core.ts Outdated
Comment threadsrc/core.ts Outdated
Comment threadREADME.md Outdated
@ndisidore
ndisidore requested a review from kentonvAugust 12, 2026 23:32
@ndisidore

Copy link
Copy Markdown
MemberAuthor

@ask-bonk review this

@ask-bonkask-bonkBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — LGTM

Re-reviewed at 33c0122, which addresses all four of @kentonv's review comments. Verified locally: build succeeds, test:types passes, and all 178 tests in index.test.ts pass on node, chromium, and workerd.

Review feedback resolution

  1. Type overloads (src/index.ts) — the two overloads are collapsed into a single new <T extends RpcCompatible<T>>(value: Promise<T | Stub<T>>): RpcPromise<T>, and PromiseLike was dropped (non-Promise thenables no longer accepted, as agreed). Inference works without an explicit <T> — the type tests confirm new RpcPromise(Promise.resolve(pointStub)) infers RpcPromise<PointTarget> (__type-tests__/rpc-base-cases.test.ts:160-162).
  2. hookForResolution one-liner — inlined.
  3. hookForPromiseArg — inlined into the constructor.
  4. The bare-stub bug — the real catch. The branch is now gated on typeForRpc(hook) === "rpc-promise" only (src/core.ts:555), so a bare RpcStub no longer has its non-promise hook adopted directly (which couldn't answer pull()); it falls through to the payload path instead. Covered by the regression test "resolves when awaited after construction from a bare local stub" (__tests__/index.test.ts).

Correctness

  • Rejection is adopted as an ErrorStubHook and the backing PromiseStubHook promise never rejects; combined with ignoreUnhandledRejections(), discarded pipelined calls, awaits, and map() results can't leak unhandled rejections. All three "does not report an unhandled rejection…" tests pass.
  • Disposal is correctly ordered behind queued calls via PromiseStubHook.dispose() — the "delivers a call initiated before disposal" and "disposes the eventual target when disposed before resolution" tests exercise exactly the ordering paths that rely on the #241 fixes, which justifies the stacking.
  • Adopting an existing RpcPromise directly preserves laziness (only one ["pull"] is sent) and brokenness — verified by the "keeps an adopted RpcPromise lazy" and "preserves brokenness" tests.
  • No wire-protocol change (client-side construction reusing existing representations), so no protocol.md update needed. Changeset present (minor). README documents the ownership-transfer and unbounded-queue caveats.

No logic bugs, capability leaks, or backward-compat concerns found. Ship it.

@ask-bonk

This comment was marked as off-topic.

@ask-bonk

This comment was marked as off-topic.

Comment threadsrc/index.ts
@ndisidore
ndisidore changed the base branch from fix/promise-stubhook-disposal to mainAugust 17, 2026 20:18
@ndisidore
ndisidoreforce-pushed the feat/from-promise branch 2 times, most recently from 11d58ca to 8c51593CompareAugust 17, 2026 21:02
Comment threadsrc/core.ts
Comment threadsrc/core.ts Outdated
Comment threadsrc/index.ts Outdated
Comment threadsrc/index.ts Outdated
Comment threadREADME.md Outdated
ndisidore added a commit that referenced this pull request Aug 18, 2026
Per review feedback on #242: the ownership-transfer note (nobody wraps an
RpcPromise they already hold on purpose) and the thenable-assimilation
note (not specific to this constructor) don't belong in the public docs.
The behaviors themselves are unchanged and remain pinned by tests.
Resolves the long-standing TODO on the RpcPromise constructor: the
application may now pass a Promise (or any other thenable) for the
eventual resolution. Calls made before the promise settles are queued
and delivered in order once it does, so an RpcPromise can stand in for
a capability that doesn't exist yet -- for example, one that will only
become available after a broken session has been re-established.
The promise may resolve to an RpcTarget, a stub, or a plain value.
Promise.resolve() performs thenable assimilation natively, so no
hand-rolled hardening against misbehaving thenables is needed. The
resolution is adopted with return semantics (the same representation
used for resolutions of local async calls), so awaiting delivers the
value, pipelined calls forward through it without forcing a pull, and
brokenness of a stub resolution is preserved. Passing an existing
RpcPromise adopts its hook directly, keeping it lazy.
A rejection is adopted as an ErrorStubHook rather than left to reject
the backing promise, so the promise chains behind queued calls never
reject: calls land on the ErrorStubHook (which disposes their
arguments) and the error surfaces only through pull() or onBroken().
Without this, a discarded pipelined call on a promise-backed stub
would raise an unhandled rejection event when the promise rejects
(crashing Node under its default handling), even though
fire-and-forget calls on the session-backed stub it stands in for
reject only on pull.
- Only adopt the hook of an existing RpcPromise; a bare stub's hook may
not implement pull(), so bare stubs now take the generic path, whose
resolution payload handles them correctly (await previously rejected
with "Tried to resolve a non-promise stub."). Regression test added.
- Inline hookForPromiseArg and hookForResolution into the constructor.
- Collapse the constructor's type overloads into a single signature,
narrowing the accepted type to Promise (runtime still assimilates
arbitrary thenables).
- Reframe the README section around the local-loopback RPC equivalence,
and align the jsdoc and changeset with it.
- Adopting an existing RpcPromise now consumes the source: its hook is
neutered to DISPOSED_HOOK, so disposing the source can no longer
silently kill the wrapper. Using the source after wrapping reports
the standard disposed error.
- Restore the invariant that every RpcPromise has a defined path by
defaulting pathIfPromise to [] on the internal StubHook path.
- Wrap workerd-native RpcPromise/RpcProperty values (rpc-thenable) in
a TargetStubHook so pipelined calls aren't eagerly assimilated.
- Document ownership transfer on adoption and the dup() workaround for
keeping a deferred capability lazy when resolving a native Promise
with an RpcPromise.
Per review feedback on #242: the ownership-transfer note (nobody wraps an
RpcPromise they already hold on purpose) and the thenable-assimilation
note (not specific to this constructor) don't belong in the public docs.
The behaviors themselves are unchanged and remain pinned by tests.
…pped native stubs
- get([]) on a thenable-backed TargetStubHook now returns dup() instead of
throwing, fixing dup() and argument-passing of wrapped native promises.
- onBroken() now subscribes to a thenable target's rejection, so onRpcBroken
fires when a wrapped native promise rejects instead of silently no-oping.
- Property promises share the source hook and path so the get() happens
lazily on first use, avoiding eager wire pushes / getter side effects.
…orStubHook
Per review: PromiseStubHook already handles a rejected backing promise --
it disposes the arguments of queued calls (since #241) and surfaces the
error through pull() and onBroken() -- so the constructor no longer maps
rejection to an ErrorStubHook resolution.
Observable change: a pipelined call whose result is neither awaited nor
disposed now fires an unhandled rejection event, matching the existing
behavior of local async calls. The unhandled-rejection tests now dispose
the discarded results, which both silences the event and models correct
usage.
@ndisidore
ndisidore merged commit 9751a4e into mainAug 20, 2026
8 checks passed
@github-actionsgithub-actionsBot mentioned this pull request Aug 20, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@ndisidore@kentonv@dimitropoulos