Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/snaps-execution-environments/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Export endowment factories via `@metamask/snaps-execution-environments/endowments` sub-path ([#3957](https://github.com/MetaMask/snaps/pull/3957))
- Exports endowment factory modules: `timeout`, `interval`, `date`, `textEncoder`, `textDecoder`, `crypto`, `math`, `consoleEndowment`, `network`
- Exports `buildCommonEndowments` and types: `EndowmentFactory`, `EndowmentFactoryOptions`, `EndowmentFactoryResult`, `NotifyFunction`

## [11.0.2]

### Changed
Expand Down
2 changes: 1 addition & 1 deletion packages/snaps-execution-environments/coverage.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"branches": 90.09,
"branches": 92.03,
"functions": 95.34,
"lines": 92.74,
"statements": 91.69
Expand Down
1 change: 1 addition & 0 deletions packages/snaps-execution-environments/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ module.exports = deepmerge(baseConfig, {
coverageReporters: ['html', 'json-summary', 'json'],
coveragePathIgnorePatterns: [
'./src/index.ts',
'./src/endowments.ts',
'./src/iframe/index.ts',
'./src/offscreen/index.ts',
'./src/webworker/executor/index.ts',
Expand Down
10 changes: 10 additions & 0 deletions packages/snaps-execution-environments/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@
"default": "./dist/index.cjs"
}
},
"./endowments": {
"import": {
"types": "./dist/endowments.d.mts",
"default": "./dist/endowments.mjs"
},
"require": {
"types": "./dist/endowments.d.cts",
"default": "./dist/endowments.cjs"
}
},
"./node-process": "./dist/webpack/node-process/bundle.js",
"./node-thread": "./dist/webpack/node-thread/bundle.js",
"./package.json": "./package.json"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,7 @@
args: InvokeSnapArgs | undefined,
) => Promise<Json>;

export type NotifyFunction = (
notification: Omit<JsonRpcNotification, 'jsonrpc'>,
) => Promise<void>;
export type { NotifyFunction } from './endowments/commonEndowmentFactory';

export class BaseSnapExecutor {
readonly #snapData: Map<string, SnapData>;
Expand Down Expand Up @@ -127,7 +125,7 @@

const errorData = getErrorData(serializedError);

this.#notify({

Check warning on line 128 in packages/snaps-execution-environments/src/common/BaseSnapExecutor.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint

Avoid using promises inside of callbacks
method: 'UnhandledError',
params: {
error: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { JsonRpcNotification } from '@metamask/utils';

import consoleEndowment from './console';
import crypto from './crypto';
import date from './date';
Expand All @@ -7,19 +9,64 @@ import network from './network';
import textDecoder from './textDecoder';
import textEncoder from './textEncoder';
import timeout from './timeout';
import type { NotifyFunction } from '../BaseSnapExecutor';
import { rootRealmGlobal } from '../globalObject';

/**
* A function for sending JSON-RPC notifications from an endowment.
* Used by endowments that perform outbound operations (e.g., network `fetch`)
* to signal request lifecycle events.
*/
export type NotifyFunction = (
notification: Omit<JsonRpcNotification, 'jsonrpc'>,
) => Promise<void>;

/**
* Options passed to endowment factory functions.
*/
export type EndowmentFactoryOptions = {
snapId?: string;
/**
* A label identifying the source of endowment interactions, used as a
* prefix in console output. For example, passing `"MyApp"` causes console
* messages to be prefixed with `[MyApp]`.
*/
sourceLabel?: string;

/**
* A notification callback used by endowments that perform outbound
* operations (e.g., network `fetch`).
*/
notify?: NotifyFunction;
};

/**
* The object returned by an endowment factory. Contains the endowment values
* keyed by their global name (e.g., `setTimeout`, `Date`) and an optional
* teardown function for lifecycle management.
*/
export type EndowmentFactoryResult = {
/**
* An optional function that performs cleanup when active resources (e.g.,
* pending timers or open network connections) should be released. Must not
* render endowments unusable — only restore them to their initial state,
* since they may be reused without reconstruction.
*/
teardownFunction?: () => Promise<void> | void;
[key: string]: unknown;
};

/**
* Describes an endowment factory module. Each module exposes the names of
* the endowments it provides and a factory function that produces them.
*/
export type EndowmentFactory = {
names: readonly string[];
factory: (options?: EndowmentFactoryOptions) => { [key: string]: unknown };
factory: (options?: EndowmentFactoryOptions) => EndowmentFactoryResult;
};

/**
* Describes a simple global value that should be hardened and exposed as an
* endowment without additional attenuation.
*/
export type CommonEndowmentSpecification = {
endowment: unknown;
name: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ describe('Console endowment', () => {

it('returns console properties from rootRealmGlobal', () => {
const { console }: { console: Partial<typeof rootRealmGlobal.console> } =
consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
consoleEndowment.factory({ sourceLabel: `Snap: ${MOCK_SNAP_ID}` });
const consoleProperties = Object.getOwnPropertyNames(
rootRealmGlobal.console,
);
Expand All @@ -33,12 +33,16 @@ describe('Console endowment', () => {

describe('log', () => {
it('does not return the original console.log', () => {
const { console } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
const { console } = consoleEndowment.factory({
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
});
expect(console.log).not.toStrictEqual(rootRealmGlobal.console.log);
});

it('will log a message identifying the source of the call (snap id)', () => {
const { console } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
it('prefixes output with the source label', () => {
const { console } = consoleEndowment.factory({
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
});
const logSpy = jest.spyOn(rootRealmGlobal.console, 'log');
console.log('This is a log message.');
expect(logSpy).toHaveBeenCalledTimes(1);
Expand All @@ -48,7 +52,9 @@ describe('Console endowment', () => {
});

it('can handle non-string message types', () => {
const { console } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
const { console } = consoleEndowment.factory({
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
});
const logSpy = jest.spyOn(rootRealmGlobal.console, 'log');
console.log(12345);
console.log({ foo: 'bar' });
Expand All @@ -66,12 +72,16 @@ describe('Console endowment', () => {

describe('error', () => {
it('does not return the original console.error', () => {
const { console } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
const { console } = consoleEndowment.factory({
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
});
expect(console.error).not.toStrictEqual(rootRealmGlobal.console.error);
});

it('will log a message identifying the source of the call (snap id)', () => {
const { console } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
it('prefixes output with the source label', () => {
const { console } = consoleEndowment.factory({
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
});
const errorSpy = jest.spyOn(rootRealmGlobal.console, 'error');
console.error('This is an error message.');
expect(errorSpy).toHaveBeenCalledTimes(1);
Expand All @@ -83,12 +93,16 @@ describe('Console endowment', () => {

describe('assert', () => {
it('does not return the original console.assert', () => {
const { console } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
const { console } = consoleEndowment.factory({
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
});
expect(console.assert).not.toStrictEqual(rootRealmGlobal.console.assert);
});

it('will log a message identifying the source of the call (snap id)', () => {
const { console } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
it('prefixes output with the source label', () => {
const { console } = consoleEndowment.factory({
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
});
const assertSpy = jest.spyOn(rootRealmGlobal.console, 'assert');
console.assert(1 > 2, 'This is an assert message.');
expect(assertSpy).toHaveBeenCalledTimes(1);
Expand All @@ -101,12 +115,16 @@ describe('Console endowment', () => {

describe('debug', () => {
it('does not return the original console.debug', () => {
const { console } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
const { console } = consoleEndowment.factory({
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
});
expect(console.debug).not.toStrictEqual(rootRealmGlobal.console.debug);
});

it('will log a message identifying the source of the call (snap id)', () => {
const { console } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
it('prefixes output with the source label', () => {
const { console } = consoleEndowment.factory({
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
});
const debugSpy = jest.spyOn(rootRealmGlobal.console, 'debug');
console.debug('This is a debug message.');
expect(debugSpy).toHaveBeenCalledTimes(1);
Expand All @@ -118,12 +136,16 @@ describe('Console endowment', () => {

describe('info', () => {
it('does not return the original console.info', () => {
const { console } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
const { console } = consoleEndowment.factory({
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
});
expect(console.info).not.toStrictEqual(rootRealmGlobal.console.info);
});

it('will log a message identifying the source of the call (snap id)', () => {
const { console } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
it('prefixes output with the source label', () => {
const { console } = consoleEndowment.factory({
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
});
const infoSpy = jest.spyOn(rootRealmGlobal.console, 'info');
console.info('This is an info message.');
expect(infoSpy).toHaveBeenCalledTimes(1);
Expand All @@ -135,12 +157,16 @@ describe('Console endowment', () => {

describe('warn', () => {
it('does not return the original console.warn', () => {
const { console } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
const { console } = consoleEndowment.factory({
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
});
expect(console.warn).not.toStrictEqual(rootRealmGlobal.console.warn);
});

it('will log a message identifying the source of the call (snap id)', () => {
const { console } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
it('prefixes output with the source label', () => {
const { console } = consoleEndowment.factory({
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
});
const warnSpy = jest.spyOn(rootRealmGlobal.console, 'warn');
console.warn('This is a warn message.');
expect(warnSpy).toHaveBeenCalledTimes(1);
Expand All @@ -149,4 +175,23 @@ describe('Console endowment', () => {
);
});
});

it('throws when sourceLabel is not provided', () => {
expect(() => consoleEndowment.factory()).toThrow(
'The "sourceLabel" option is required by the console endowment factory.',
);

expect(() => consoleEndowment.factory({})).toThrow(
'The "sourceLabel" option is required by the console endowment factory.',
);
});

it('supports arbitrary source labels for non-Snap consumers', () => {
const { console } = consoleEndowment.factory({
sourceLabel: 'ocap-kernel: vat-42',
});
const logSpy = jest.spyOn(rootRealmGlobal.console, 'log');
console.log('test message');
expect(logSpy).toHaveBeenCalledWith('[ocap-kernel: vat-42] test message');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ export const consoleAttenuatedMethods = new Set([
]);

/**
* A set of all the `console` values that will be passed to the snap. This has
* all the values that are available in both the browser and Node.js.
* A set of all the `console` method names that will be included in the
* attenuated console object. Covers values available in both browser and
* Node.js.
*/
export const consoleMethods = new Set([
'debug',
Expand Down Expand Up @@ -52,13 +53,13 @@ type ConsoleFunctions = {
* Gets the appropriate (prepended) message to pass to one of the attenuated
* method calls.
*
* @param snapId - Id of the snap that we're getting a message for.
* @param message - The id of the snap that will interact with the endowment.
* @param sourceLabel - Label identifying the source of the console call.
* @param message - The first argument passed to the console method.
* @param args - The array of additional arguments.
* @returns An array of arguments to be passed into an attenuated console method call.
*/
function getMessage(snapId: string, message: unknown, ...args: unknown[]) {
const prefix = `[Snap: ${snapId}]`;
function getMessage(sourceLabel: string, message: unknown, ...args: unknown[]) {
const prefix = `[${sourceLabel}]`;

// If the first argument is a string, prepend the prefix to the message, and keep the
// rest of the arguments as-is.
Expand All @@ -72,15 +73,18 @@ function getMessage(snapId: string, message: unknown, ...args: unknown[]) {
}

/**
* Create a a {@link console} object, with the same properties as the global
* Create a {@link console} object, with the same properties as the global
* {@link console} object, but with some methods replaced.
*
* @param options - Factory options used in construction of the endowment.
* @param options.snapId - The id of the snap that will interact with the endowment.
* @param options.sourceLabel - Label identifying the source of the console call.
* @returns The {@link console} object with the replaced methods.
*/
function createConsole({ snapId }: EndowmentFactoryOptions = {}) {
assert(snapId !== undefined);
function createConsole({ sourceLabel }: EndowmentFactoryOptions = {}) {
assert(
sourceLabel !== undefined,
'The "sourceLabel" option is required by the console endowment factory.',
);
Comment thread
cursor[bot] marked this conversation as resolved.
const keys = Object.getOwnPropertyNames(
rootRealmGlobal.console,
) as (keyof typeof console)[];
Expand All @@ -103,15 +107,15 @@ function createConsole({ snapId }: EndowmentFactoryOptions = {}) {
) => {
rootRealmGlobal.console.assert(
value,
...getMessage(snapId, message, ...optionalParams),
...getMessage(sourceLabel, message, ...optionalParams),
);
},
...consoleFunctions.reduce<ConsoleFunctions>((target, key) => {
return {
...target,
[key]: (message?: unknown, ...optionalParams: any[]) => {
rootRealmGlobal.console[key](
...getMessage(snapId, message, ...optionalParams),
...getMessage(sourceLabel, message, ...optionalParams),
);
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ describe('endowments', () => {
const modules = buildCommonEndowments();
modules.forEach((endowment) =>
// @ts-expect-error: Partial mock.
endowment.factory({ snapId: MOCK_SNAP_ID, notify: mockNotify }),
endowment.factory({
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
notify: mockNotify,
}),
);

// Specially attenuated endowments or endowments that require
Expand All @@ -70,7 +73,7 @@ describe('endowments', () => {
});
const { Date: DateAttenuated } = date.factory();
const { console: consoleAttenuated } = consoleEndowment.factory({
snapId: MOCK_SNAP_ID,
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
});

const TEST_ENDOWMENTS = {
Expand Down
Loading
Loading