Skip to content
Closed
Show file tree
Hide file tree
Changes from 39 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
231e63d
feat(capn-web): new package implementing Cap'n Web RPC
claude Apr 27, 2026
d5a8f91
feat(capn-web): interop with cloudflare/capnweb, fetch codecs, lint c…
claude Apr 27, 2026
3486e84
fix(capn-web): address PR review + Node 18 / lint CI failures
claude Apr 27, 2026
7ccdecb
fix(capn-web): drop capnweb devDep so docs/tests don't trip on its br…
claude Apr 27, 2026
d019e29
fix(capn-web): Headers iteration on Node 18, deferred capnweb import
claude Apr 27, 2026
396600b
fix(capn-web): revert new Function() trick — SES forbids the Function…
claude Apr 27, 2026
c69175e
fix(capn-web): declare process global in fetch-codec.test.js
claude Apr 27, 2026
915f408
fix(capn-web): address Copilot review (sync-throw guards, validation,…
claude Apr 27, 2026
322471f
fix(capn-web): skip standalone Headers round-trip test on Node 18
claude Apr 27, 2026
f0c8e0a
fix(capn-web): address 6 more Copilot review comments
claude Apr 27, 2026
6831080
refactor(capn-web): pass-style classification, remove RpcTarget, retu…
claude Apr 27, 2026
7f13c54
fix(capn-web): cast caught error to Error before reading .message
claude Apr 27, 2026
482a38e
feat(capn-web): server-side HTTP batch + WHATWG Streams bridge
claude Apr 28, 2026
e52cb66
test(capn-web): add 18 integration tests inspired by cloudflare/capnweb
claude Apr 28, 2026
de4f965
feat(capn-web): three-party capability forwarding via passStyleOf + R…
claude Apr 29, 2026
9668a45
ci(capn-web): run cloudflare/capnweb interop tests in CI
claude Apr 30, 2026
759f39b
test(capn-web): add stream backpressure + map() parity tests
claude Apr 30, 2026
a733ad1
feat(capn-web): align map() wire format with cloudflare/capnweb + int…
claude Apr 30, 2026
46505e5
refactor(capn-web): apply review fixes + cover gaps (T1, T2, T4, T5)
claude Apr 30, 2026
790e36e
test(capn-web): cover gaps T6-T8 + T10-T12
claude Apr 30, 2026
06ae532
fix(capn-web): Node 18 T12 skip + reflect actual wire shape in docs
claude Apr 30, 2026
1f94dfc
docs(capn-web): drop stale RpcTarget reference from localMain option
claude May 1, 2026
f3f6eb2
refactor(capn-web): use makeExo instead of Far in streams.js
claude May 1, 2026
4fba8aa
fix(capn-web): non-finite numbers in remap, drain() incoming work, tr…
claude May 1, 2026
45a26fb
docs(capn-web): add comparison with @endo/ocapn
claude May 6, 2026
730190c
docs(capn-web): cite specs + source for each row of the ocapn comparison
claude May 6, 2026
04df266
feat(capn-web): foreign stub as method receiver in .map() mappers
claude May 6, 2026
bd77f35
feat(capn-web): support capnweb's ["pipe"] symmetric stream channel
claude May 6, 2026
b1a35ce
feat(capn-web): align value-rejection coverage with cloudflare/capnweb
claude May 6, 2026
40ea7d9
test(capn-web): expand cross-client capnweb interop coverage
claude May 6, 2026
c1a06ea
docs(capn-web): clarify capnweb's call-scoped disposal is client policy
claude May 6, 2026
00b6b1a
docs(capn-web): document harden-vs-WHATWG-streams workaround + link i…
claude May 6, 2026
d6cb7e7
test(capn-web): structural tests for streams within structures
claude May 6, 2026
5c7f5ba
fix(capn-web): apply patchStreamForHarden at the boundaries that need it
claude May 6, 2026
90c0f97
refactor(capn-web): apply patchStreamForHarden at stream creation sites
claude May 6, 2026
69ae4ea
docs(capn-web): document user-side patchStreamForHarden + throw on no-op
claude May 6, 2026
a584721
fix(capn-web): callRemap intermediate push id no longer leaks
claude May 6, 2026
1e1dddf
test(capn-web): cover promise-stub identity round-trip via pipeline r…
claude May 15, 2026
ec25dfa
chore: Update yarn.lock
kriscendobot Jul 16, 2026
eeae838
fixup! test(capn-web): add 18 integration tests inspired by cloudflar…
kriscendobot Jul 16, 2026
639a900
fixup! feat(capn-web): interop with cloudflare/capnweb, fetch codecs,…
kriscendobot Jul 16, 2026
6740638
fixup! feat(capn-web): new package implementing Cap'n Web RPC
kriscendobot Jul 16, 2026
0bcfe57
fixup! feat(capn-web): new package implementing Cap'n Web RPC
kriscendobot Jul 17, 2026
913331c
fixup! feat(capn-web): new package implementing Cap'n Web RPC
kriscendobot Jul 17, 2026
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
317 changes: 317 additions & 0 deletions packages/capn-web/README.md

Large diffs are not rendered by default.

91 changes: 91 additions & 0 deletions packages/capn-web/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
{
"name": "@endo/capn-web",
"version": "0.1.0",
"description": "Cap'n Web RPC for the browser, on top of @endo/eventual-send",
"type": "module",
"keywords": [
"endo",
"capnweb",
"rpc",
"eventual-send",
"object-capabilities"
],
"author": "Endo contributors",
"homepage": "https://github.com/endojs/endo#readme",
"license": "Apache-2.0",
"main": "src/index.js",
"exports": {
".": "./src/index.js",
"./loopback.js": "./src/transports/loopback.js",
"./websocket.js": "./src/transports/websocket.js",
"./http-batch.js": "./src/transports/http-batch.js",
"./message-port.js": "./src/transports/message-port.js",
"./package.json": "./package.json"
},
"module": "src/index.js",
"directories": {
"src": "src",
"test": "test"
},
"files": [
"./*.d.ts",
"./*.js",
"./*.map",
"LICENSE*",
"SECURITY*",
"src"
],
"repository": {
"type": "git",
"url": "git+https://github.com/endojs/endo.git",
"directory": "packages/capn-web"
},
"scripts": {
"build": "exit 0",
"prepack": "git clean -fX -e node_modules/ && tsc --build tsconfig.build.json",
"postpack": "git clean -fX -e node_modules/",
"test": "ses-ava",
"test:xs": "exit 0",
"lint-fix": "yarn lint:eslint --fix && yarn lint:types",
"lint": "yarn lint:eslint && yarn lint:types",
"lint:eslint": "eslint '**/*.js'",
"lint:types": "tsc"
},
"devDependencies": {
"@endo/init": "workspace:^",
"@endo/ses-ava": "workspace:^",
"ava": "catalog:dev",
"c8": "catalog:dev",
"capnweb": "^0.6.1",
"eslint": "catalog:dev",
"ses": "workspace:^",
"typescript": "catalog:dev"
},
"dependencies": {
"@endo/errors": "workspace:^",
"@endo/eventual-send": "workspace:^",
"@endo/exo": "workspace:^",
"@endo/harden": "workspace:^",
"@endo/pass-style": "workspace:^",
"@endo/promise-kit": "workspace:^"
},
"bugs": {
"url": "https://github.com/endojs/endo/issues"
},
"publishConfig": {
"access": "public"
},
"sesAvaConfigs": {
"lockdown": "../../ava-endo-lockdown.config.mjs",
"unsafe": "../../ava-endo-lockdown-unsafe.config.mjs",
"endo": "../../ava-endo-shims-only.config.mjs"
},
"eslintConfig": {
"extends": [
"plugin:@endo/internal"
]
},
"typeCoverage": {
"atLeast": 0
}
}
226 changes: 226 additions & 0 deletions packages/capn-web/src/devaluate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
/* global globalThis */
// Devaluator: turn a live JS value into a Cap'n Web wire expression.
//
// The output is a JSON-serialisable structure whose semantics are described
// in https://github.com/cloudflare/capnweb/blob/main/protocol.md
//
// Notably:
// - Arrays are wrapped in another array as [[...]] (escape) to disambiguate
// them from instruction arrays.
// - Capabilities are encoded as ["export", -id] / ["promise", -id] (when
// freshly introduced) or ["import", id] (when round-tripping a remote
// reference).
// - Special atomic values (undefined, BigInt, Date, etc.) get tagged forms.
//
// Classification dispatches in this order:
// 1. atomic specials (undefined / nan / inf / bigint / Date / Uint8Array
// / Error) via tryEncodeSpecial
// 2. JSON-safe primitives (null, string, boolean, finite number)
// 3. host objects with explicit wire forms (Headers / Request / Response,
// WritableStream / ReadableStream)
// 4. round-trip identity for stubs we already imported
// 5. `passStyleOf` leaf classification — used to recognise remotables
// (Far / makeExo) and bare promises in a way that respects endo's
// pass-style discipline. We do NOT pass the whole value tree through
// passStyleOf, because that's recursive and would reject any nested
// non-passable host (Date, Uint8Array, …) that we already handle
// ourselves.
// 6. plain arrays and records — recursed locally
// 7. anything else → "cannot serialize" error.

import harden from '@endo/harden';
import { passStyleOf } from '@endo/pass-style';

import { tryEncodeSpecial } from './special-values.js';
import {
isHeaders,
isRequest,
isResponse,
encodeHeaders,
encodeRequest,
encodeResponse,
} from './fetch-codec.js';
import { exportWritableStream, exportReadableStream } from './streams.js';
import { isForbiddenKey } from './path-keys.js';

const G = /** @type {any} */ (globalThis);

const isPlainObject = v => {
if (v === null || typeof v !== 'object') return false;
const proto = Object.getPrototypeOf(v);
return proto === Object.prototype || proto === null;
};

/**
* Try `passStyleOf` on a single value but don't let it raise — we only
* use it for leaf classification (remotable / promise / error). Returns
* undefined if the value isn't passable on its own terms.
*
* @param {unknown} value
*/
const passStyleOfOrUndefined = value => {
try {
return passStyleOf(/** @type {any} */ (value));
} catch (_e) {
return undefined;
}
};

/**
* @typedef {object} DevaluatorContext
* @property {(value: object) => number | undefined} importIdOf
* If `value` is a remote stub (presence or promise) from this session's
* imports, returns its allocator-side id; otherwise undefined.
* @property {(value: unknown, isPromise: boolean) => number} exportValue
* Allocate (or reuse) a negative export id and register `value` as our
* export. Returns the id.
* @property {(readable: ReadableStream) => number} [sendPipe]
* Open a capnweb-compatible `["pipe"]` channel for `readable` and
* return the new positive import id. When omitted (e.g. devaluating
* outside a session context), `ReadableStream` values fall back to
* the legacy Far'd readable stub form.
*/

/**
* @param {DevaluatorContext} ctx
*/
export const makeDevaluator = ctx => {
/**
* @param {unknown} value
* @param {WeakSet<object>} seen Tracks containers currently on the
* recursion stack to detect cycles.
* @returns {unknown}
*/
const devaluateInner = (value, seen) => {
// 1. Atomic specials.
const special = tryEncodeSpecial(value);
if (special !== undefined) return special;

// 2. JSON-safe primitives.
if (value === null) return null;
if (typeof value === 'string') return value;
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value;

// 3. Host objects with explicit wire forms.
if (isHeaders(value)) return encodeHeaders(/** @type {Headers} */ (value));
if (isRequest(value)) {
return encodeRequest(/** @type {Request} */ (value), v =>
devaluateInner(v, seen),
);
}
if (isResponse(value)) {
return encodeResponse(/** @type {Response} */ (value), v =>
devaluateInner(v, seen),
);
}
if (G.WritableStream && value instanceof G.WritableStream) {
const writer = exportWritableStream(
/** @type {WritableStream} */ (value),
);
const id = ctx.exportValue(writer, false);
return ['writable', id];
}
if (G.ReadableStream && value instanceof G.ReadableStream) {
// Capnweb-compatible: send `["pipe"]` and reference the new pipe
// import id via `["readable", +id]`. The framework pumps the
// user's readable into a synthetic writable that issues per-chunk
// method calls on the import. Falls back to the legacy Far'd
// readable stub form (`["readable", -id]`) if `sendPipe` isn't
// configured (e.g. no `TransformStream` available, or the
// devaluator was constructed in a non-session context).
if (ctx.sendPipe) {
const id = ctx.sendPipe(/** @type {ReadableStream} */ (value));
return ['readable', id];
}
const reader = exportReadableStream(
/** @type {ReadableStream} */ (value),
);
const id = ctx.exportValue(reader, false);
return ['readable', id];
}

// 4. Round-trip identity.
if (
value !== null &&
(typeof value === 'object' || typeof value === 'function')
) {
const importId = ctx.importIdOf(/** @type {object} */ (value));
if (importId !== undefined) {
const isPromiseStub =
typeof (/** @type {any} */ (value).then) === 'function';
return [isPromiseStub ? 'pipeline' : 'import', importId];
}
}

// 5. Pass-style leaf classification — remotable / promise / error.
const style = passStyleOfOrUndefined(value);
if (style === 'remotable') {
const id = ctx.exportValue(value, false);
return ['export', id];
}
if (style === 'promise') {
const id = ctx.exportValue(value, true);
return ['promise', id];
}
if (style === 'error') {
return [
'error',
/** @type {any} */ (value).name || 'Error',
/** @type {any} */ (value).message || '',
];
}

// 6. Plain shapes — locally recursed. Detect cycles up-front: a
// structure that contains itself can't be serialised, and a naive
// recursion would never return.
if (Array.isArray(value)) {
if (seen.has(/** @type {object} */ (value))) {
throw new TypeError('Cannot serialize circular reference');
}
seen.add(/** @type {object} */ (value));
try {
return [value.map(v => devaluateInner(v, seen))];
} finally {
seen.delete(/** @type {object} */ (value));
}
}
if (isPlainObject(value)) {
if (seen.has(/** @type {object} */ (value))) {
throw new TypeError('Cannot serialize circular reference');
}
seen.add(/** @type {object} */ (value));
try {
/** @type {Record<string, unknown>} */
const out = {};
for (const [k, v] of Object.entries(/** @type {object} */ (value))) {
if (!isForbiddenKey(k)) {
out[k] = devaluateInner(v, seen);
}
}
return out;
} finally {
seen.delete(/** @type {object} */ (value));
}
}

// 7. Functions that aren't marked as remotables, exotic class
// instances, etc. Reject with a clear message.
if (typeof value === 'function') {
throw new TypeError(
`Cannot serialize bare function; wrap with Far / makeExo to expose it as a remotable.`,
);
}
throw new TypeError(
`Cannot serialize value of type ${Object.prototype.toString.call(value)}`,
);
};

/**
* @param {unknown} value
* @returns {unknown} a JSON-serialisable expression
*/
const devaluate = value => devaluateInner(value, new WeakSet());

return harden({ devaluate });
};
Loading
Loading