diff --git a/.github/workflows/test-all-packages.yml b/.github/workflows/test-all-packages.yml index d9c49d72884..2645314df45 100644 --- a/.github/workflows/test-all-packages.yml +++ b/.github/workflows/test-all-packages.yml @@ -162,6 +162,8 @@ jobs: run: cd packages/ERTP && yarn test - name: yarn test (eventual-send) run: cd packages/eventual-send && yarn test + - name: yarn test (far) + run: cd packages/far && yarn test - name: yarn test (governance) run: cd packages/governance && yarn test - name: yarn test (import-bundle) diff --git a/package.json b/package.json index 1bc6b257349..5b5d94603e7 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "packages/transform-metering", "packages/install-metering-and-ses", "packages/marshal", + "packages/far", "packages/same-structure", "packages/captp", "packages/stat-logger", diff --git a/packages/far/README.md b/packages/far/README.md new file mode 100644 index 00000000000..9af73856343 --- /dev/null +++ b/packages/far/README.md @@ -0,0 +1,17 @@ +# Agoric Far Object helpers + +The `@agoric/far` package provides a convenient way to use the Agoric +[distributed objects system](https://agoric.com/documentation/js-programming/far.html) without relying on the underlying messaging +implementation. + +It exists to reduce the boilerplate in Hardened JavaScript vats that are running +in Agoric's SwingSet kernel, +[`@agoric/swingset-vat`](https://github.com/Agoric/agoric-sdk/tree/master/packages/SwingSet), +or arbitrary JS programs using Hardened JavaScript and communicating via +[`@agoric/captp`](https://github.com/Agoric/agoric-sdk/tree/master/packages/captp). + +You can import any of the following from `@agoric/far`: + +```js +import { E, Far, getInterfaceOf, passStyleOf } from '@agoric/far'; +``` diff --git a/packages/far/jsconfig.json b/packages/far/jsconfig.json new file mode 100644 index 00000000000..619986a30fb --- /dev/null +++ b/packages/far/jsconfig.json @@ -0,0 +1,19 @@ +// This file can contain .js-specific Typescript compiler config. +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + + "noEmit": true, +/* + // The following flags are for creating .d.ts files: + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, +*/ + "downlevelIteration": true, + "strictNullChecks": true, + "moduleResolution": "node", + }, + "include": ["src/**/*.js", "exported.js"], +} diff --git a/packages/far/package.json b/packages/far/package.json new file mode 100644 index 00000000000..fa55cd50dce --- /dev/null +++ b/packages/far/package.json @@ -0,0 +1,66 @@ +{ + "name": "@agoric/far", + "version": "0.1.0", + "description": "Helpers for Agoric distributed objects.", + "type": "module", + "main": "src/index.js", + "scripts": { + "test": "ava", + "test:c8": "c8 $C8_OPTIONS ava --config=ava-nesm.config.js", + "test:xs": "exit 0", + "build": "exit 0", + "lint-fix": "yarn lint:eslint --fix && yarn lint:types", + "lint-check": "yarn lint", + "lint": "yarn lint:types && yarn lint:eslint", + "lint:types": "tsc -p jsconfig.json", + "lint:eslint": "eslint '**/*.js'" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Agoric/agoric-sdk.git" + }, + "author": "Agoric", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/Agoric/agoric-sdk/issues" + }, + "homepage": "https://github.com/Agoric/agoric-sdk#readme", + "dependencies": { + "@agoric/eventual-send": "^0.13.30", + "@agoric/marshal": "^0.4.28" + }, + "devDependencies": { + "@agoric/install-ses": "^0.5.28", + "@endo/ses-ava": "^0.2.8", + "ava": "^3.12.1", + "c8": "^7.7.2" + }, + "keywords": [ + "eventual send", + "wavy dot", + "remote objects", + "tildot", + "far" + ], + "files": [ + "src" + ], + "eslintConfig": { + "extends": [ + "@agoric" + ] + }, + "prettier": { + "trailingComma": "all", + "singleQuote": true + }, + "publishConfig": { + "access": "public" + }, + "ava": { + "files": [ + "test/**/test-*.js" + ], + "timeout": "2m" + } +} diff --git a/packages/far/src/index.js b/packages/far/src/index.js new file mode 100644 index 00000000000..44a2d494d37 --- /dev/null +++ b/packages/far/src/index.js @@ -0,0 +1,7 @@ +export { E } from '@agoric/eventual-send'; +export { Far, getInterfaceOf, passStyleOf } from '@agoric/marshal'; + +/** + * @template T + * @typedef {import('@agoric/eventual-send').EOnly} EOnly + */ diff --git a/packages/far/test/prepare-test-env-ava.js b/packages/far/test/prepare-test-env-ava.js new file mode 100644 index 00000000000..13fd5a5930b --- /dev/null +++ b/packages/far/test/prepare-test-env-ava.js @@ -0,0 +1,7 @@ +import '@agoric/install-ses/debug.js'; + +import { wrapTest } from '@endo/ses-ava'; +import rawTest from 'ava'; + +/** @type {typeof rawTest} */ +export const test = wrapTest(rawTest); diff --git a/packages/far/test/test-e.js b/packages/far/test/test-e.js new file mode 100644 index 00000000000..1e880c042c7 --- /dev/null +++ b/packages/far/test/test-e.js @@ -0,0 +1,164 @@ +/* global HandledPromise */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { test } from './prepare-test-env-ava.js'; + +import { E } from '../src/index.js'; + +test('E reexports', async t => { + t.is(E.resolve, HandledPromise.resolve, 'E reexports resolve'); +}); + +test('E.when', async t => { + let stash; + await E.when(123, val => (stash = val)); + t.is(stash, 123, `onfulfilled handler fires`); + let raised; + // eslint-disable-next-line prefer-promise-reject-errors + await E.when(Promise.reject('foo'), undefined, val => (raised = val)); + t.assert(raised, 'foo', 'onrejected handler fires'); + + let ret; + let exc; + await E.when( + Promise.resolve('foo'), + val => (ret = val), + val => (exc = val), + ); + t.is(ret, 'foo', 'onfulfilled option fires'); + t.is(exc, undefined, 'onrejected option does not fire'); + + let ret2; + let exc2; + await E.when( + // eslint-disable-next-line prefer-promise-reject-errors + Promise.reject('foo'), + val => (ret2 = val), + val => (exc2 = val), + ); + t.is(ret2, undefined, 'onfulfilled option does not fire'); + t.is(exc2, 'foo', 'onrejected option fires'); +}); + +test('E method calls', async t => { + const x = { + double(n) { + return 2 * n; + }, + }; + const d = E(x).double(6); + t.is(typeof d.then, 'function', 'return is a thenable'); + t.is(await d, 12, 'method call works'); +}); + +test('E sendOnly method calls', async t => { + let testIncrDoneResolve; + const testIncrDone = new Promise(resolve => { + testIncrDoneResolve = resolve; + }); + + let count = 0; + const counter = { + incr(n) { + count += n; + testIncrDoneResolve(); // only here for the test. + return count; + }, + }; + const result = E.sendOnly(counter).incr(42); + t.is(typeof result, 'undefined', 'return is undefined as expected'); + await testIncrDone; + t.is(count, 42, 'sendOnly method call variant works'); +}); + +test('E call missing method', async t => { + const x = { + double(n) { + return 2 * n; + }, + }; + await t.throwsAsync(() => E(x).triple(6), { + message: 'target has no method "triple", has ["double"]', + }); +}); + +test('E sendOnly call missing method', async t => { + let count = 279; + const counter = { + incr(n) { + count += n; + return count; + }, + }; + + const result = E.sendOnly(counter).decr(210); + t.is(result, undefined, 'return is undefined as expected'); + await null; + t.is(count, 279, `sendOnly method call doesn't change count`); +}); + +test('E call undefined method', async t => { + const x = { + double(n) { + return 2 * n; + }, + }; + await t.throwsAsync(() => E(x)(6), { + message: 'Cannot invoke target as a function; typeof target is "object"', + }); +}); + +test('E invoke a non-method', async t => { + const x = { double: 24 }; + await t.throwsAsync(() => E(x).double(6), { + message: 'invoked method "double" is not a function; it is a "number"', + }); +}); + +test('E method call undefined receiver', async t => { + await t.throwsAsync(() => E(undefined).double(6), { + message: 'Cannot deliver "double" to target; typeof target is "undefined"', + }); +}); + +test('E shortcuts', async t => { + const x = { + name: 'buddy', + val: 123, + y: Object.freeze({ + val2: 456, + name2: 'holly', + fn: n => 2 * n, + }), + hello(greeting) { + return `${greeting}, ${this.name}!`; + }, + }; + t.is(await E(x).hello('Hello'), 'Hello, buddy!', 'method call works'); + t.is( + await E(await E.get(await E.get(x).y).fn)(4), + 8, + 'anonymous method works', + ); + t.is(await E.get(x).val, 123, 'property get'); +}); + +test('E.get', async t => { + const x = { + name: 'buddy', + val: 123, + y: Object.freeze({ + val2: 456, + name2: 'holly', + fn: n => 2 * n, + }), + hello(greeting) { + return `${greeting}, ${this.name}!`; + }, + }; + t.is( + await E(await E.get(await E.get(x).y).fn)(4), + 8, + 'anonymous method works', + ); + t.is(await E.get(x).val, 123, 'property get'); +}); diff --git a/packages/far/test/test-marshal-far-function.js b/packages/far/test/test-marshal-far-function.js new file mode 100644 index 00000000000..b924a651875 --- /dev/null +++ b/packages/far/test/test-marshal-far-function.js @@ -0,0 +1,68 @@ +// @ts-check + +import { test } from './prepare-test-env-ava.js'; + +import { Far, getInterfaceOf, passStyleOf } from '../src/index.js'; + +const { freeze, setPrototypeOf } = Object; + +test('Far functions', t => { + t.notThrows(() => Far('arrow', a => a + 1), 'Far function'); + const arrow = Far('arrow', a => a + 1); + t.is(passStyleOf(arrow), 'remotable'); + t.is(getInterfaceOf(arrow), 'Alleged: arrow'); +}); + +test('Acceptable far functions', t => { + t.is(passStyleOf(Far('asyncArrow', async a => a + 1)), 'remotable'); + // Even though concise methods start as methods, they can be + // made into far functions *instead*. + const concise = { doFoo() {} }.doFoo; + t.is(passStyleOf(Far('concise', concise)), 'remotable'); +}); + +test('Unacceptable far functions', t => { + t.throws( + () => + Far( + 'alreadyFrozen', + freeze(a => a + 1), + ), + { + message: /is already frozen/, + }, + ); + t.throws(() => Far('keywordFunc', function keyword() {}), { + message: /unexpected properties besides \.name and \.length/, + }); +}); + +test('Far functions cannot be methods', t => { + const doFoo = Far('doFoo', a => a + 1); + t.throws( + () => + Far('badMethod', { + doFoo, + }), + { + message: /Remotables with non-methods/, + }, + ); +}); + +test('Data can contain far functions', t => { + const arrow = Far('arrow', a => a + 1); + t.is(passStyleOf(harden({ x: 8, foo: arrow })), 'copyRecord'); + const mightBeMethod = a => a + 1; + t.throws(() => passStyleOf(freeze({ x: 8, foo: mightBeMethod })), { + message: /Remotables with non-methods like "x" /, + }); +}); + +test('function without prototype', t => { + const arrow = a => a; + setPrototypeOf(arrow, null); + t.throws(() => Far('arrow', arrow), { + message: /must not inherit from null/, + }); +}); diff --git a/packages/far/test/test-marshal-far-obj.js b/packages/far/test/test-marshal-far-obj.js new file mode 100644 index 00000000000..49e975e0cd5 --- /dev/null +++ b/packages/far/test/test-marshal-far-obj.js @@ -0,0 +1,142 @@ +// @ts-check + +import { test } from './prepare-test-env-ava.js'; + +import { Far, passStyleOf, getInterfaceOf } from '../src/index.js'; + +const { quote: q } = assert; +const { create, getPrototypeOf } = Object; + +// this only includes the tests that do not use liveSlots + +test('Remotable/getInterfaceOf', t => { + t.throws( + () => Far('MyHandle', { foo: 123 }), + { message: /cannot serialize/ }, + 'non-function props are not implemented', + ); + + t.is(getInterfaceOf('foo'), undefined, 'string, no interface'); + t.is(getInterfaceOf(null), undefined, 'null, no interface'); + t.is( + getInterfaceOf(a => a + 1), + undefined, + 'function, no interface', + ); + t.is(getInterfaceOf(123), undefined, 'number, no interface'); + + // Check that a handle can be created. + const p = Far('MyHandle'); + harden(p); + // console.log(p); + t.is(getInterfaceOf(p), 'Alleged: MyHandle', `interface MyHandle`); + t.is(`${p}`, '[object Alleged: MyHandle]', 'stringify [MyHandle]'); + t.is(`${q(p)}`, '"[Alleged: MyHandle]"', 'quotify [MyHandle]'); + + const p2 = Far('Thing', { + name() { + return 'cretin'; + }, + birthYear(now) { + return now - 64; + }, + }); + t.is(getInterfaceOf(p2), 'Alleged: Thing', `interface is Thing`); + t.is(p2.name(), 'cretin', `name() method is presence`); + t.is(p2.birthYear(2020), 1956, `birthYear() works`); +}); + +const GOOD_PASS_STYLE = Symbol.for('passStyle'); +const BAD_PASS_STYLE = Symbol('passStyle'); + +const testRecord = ({ + styleSymbol = GOOD_PASS_STYLE, + styleString = 'remotable', + styleEnumerable = false, + tagSymbol = Symbol.toStringTag, + tagString = 'Alleged: Good remotable proto', + tagEnumerable = false, + extras = {}, +} = {}) => + harden( + create(Object.prototype, { + [styleSymbol]: { value: styleString, enumerable: styleEnumerable }, + [tagSymbol]: { value: tagString, enumerable: tagEnumerable }, + ...extras, + }), + ); + +const goodRemotableProto = testRecord(); + +// @ts-ignore We're testing bad things anyway +const badRemotableProto1 = testRecord({ styleSymbol: BAD_PASS_STYLE }); + +const badRemotableProto2 = testRecord({ styleString: 'string' }); + +const badRemotableProto3 = testRecord({ + extras: { + toString: { + value: Object, // Any function will do + enumerable: true, + }, + }, +}); + +const badRemotableProto4 = testRecord({ tagString: 'Bad remotable proto' }); + +const sub = sup => harden({ __proto__: sup }); + +test('getInterfaceOf validation', t => { + t.is(getInterfaceOf(goodRemotableProto), undefined); + t.is(getInterfaceOf(badRemotableProto1), undefined); + t.is(getInterfaceOf(badRemotableProto2), undefined); + t.is(getInterfaceOf(badRemotableProto3), undefined); + t.is(getInterfaceOf(badRemotableProto4), undefined); + + t.is( + getInterfaceOf(sub(goodRemotableProto)), + 'Alleged: Good remotable proto', + ); + t.is(getInterfaceOf(sub(badRemotableProto1)), undefined); + t.is(getInterfaceOf(sub(badRemotableProto2)), undefined); + t.is(getInterfaceOf(sub(badRemotableProto3)), undefined); + t.is(getInterfaceOf(sub(badRemotableProto4)), undefined); +}); + +const NON_METHOD = { + message: /cannot serialize Remotables with non-methods like .* in .*/, +}; +const IFACE_ALLEGED = { + message: /For now, iface "Bad remotable proto" must be "Remotable" or begin with "Alleged: "; unimplemented/, +}; +const UNEXPECTED_PROPS = { + message: /Unexpected properties on Remotable Proto .*/, +}; +const UNEXPECTED_PASS_STYLE = { + message: /Unrecognized PassStyle/, +}; +const EXPECTED_PASS_STYLE = { + message: /\[Symbol\(passStyle\)\]" property expected/, +}; + +// Parallels the getInterfaceOf validation cases, explaining why +// each failure failed. +test('passStyleOf validation of remotables', t => { + t.throws(() => passStyleOf(goodRemotableProto), NON_METHOD); + t.throws(() => passStyleOf(badRemotableProto1), NON_METHOD); + t.throws(() => passStyleOf(badRemotableProto2), UNEXPECTED_PASS_STYLE); + t.throws(() => passStyleOf(badRemotableProto3), NON_METHOD); + t.throws(() => passStyleOf(badRemotableProto4), NON_METHOD); + + t.is(passStyleOf(sub(goodRemotableProto)), 'remotable'); + + t.throws(() => passStyleOf(sub(badRemotableProto1)), EXPECTED_PASS_STYLE); + t.throws(() => passStyleOf(sub(badRemotableProto2)), UNEXPECTED_PASS_STYLE); + t.throws(() => passStyleOf(sub(badRemotableProto3)), UNEXPECTED_PROPS); + t.throws(() => passStyleOf(sub(badRemotableProto4)), IFACE_ALLEGED); +}); + +test('object without prototype', t => { + const base = Far('base', { __proto__: null }); + t.is(getPrototypeOf(getPrototypeOf(base)), Object.prototype); +});