Skip to content
Draft
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
1 change: 1 addition & 0 deletions packages/base-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
},
"dependencies": {
"@metamask/messenger": "^2.0.0",
"@metamask/superstruct": "^3.4.1",
"@metamask/utils": "^11.11.0",
"immer": "^9.0.6"
},
Expand Down
39 changes: 38 additions & 1 deletion packages/base-controller/src/BaseController.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable jest/no-export */
import type { MockAnyNamespace } from '@metamask/messenger';
import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger';
import { number, object, string, Struct } from '@metamask/superstruct';
import type { Json } from '@metamask/utils';
import type { Draft, Patch } from 'immer';

Expand All @@ -12,7 +13,11 @@
ControllerStateChangedEvent,
StatePropertyMetadata,
} from './BaseController.js';
import { BaseController, deriveStateFromMetadata } from './BaseController.js';
import {
BaseController,
deriveStateFromMetadata,
validateControllerState,
} from './BaseController.js';

export const countControllerName = 'CountController';

Expand Down Expand Up @@ -1191,3 +1196,35 @@
});
});
});

describe('validateControllerState', () => {
type FooControllerState = {
foo: string;
bar: number;
};

const FooControllerStateStruct = object({
foo: string(),
bar: number(),
});

class FooController extends BaseController<
'FooController',
FooControllerState,
any

Check failure on line 1214 in packages/base-controller/src/BaseController.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (lint:eslint) (24.x)

Unexpected any. Specify a different type
> {
static readonly struct: Struct<FooControllerState> =
FooControllerStateStruct;
}

it('throws for invalid state in strict mode', () => {
expect(() =>
validateControllerState(
'FooController',
FooController,
{ foo: 'foo', bar: 'bar' },
'strict',
),
).toThrow('At path: bar -- Expected a number, but received: \"bar\"');

Check failure on line 1228 in packages/base-controller/src/BaseController.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (lint:eslint) (24.x)

Unnecessary escape character: \"

Check failure on line 1228 in packages/base-controller/src/BaseController.test.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (lint:eslint) (24.x)

Unnecessary escape character: \"
});
});
47 changes: 47 additions & 0 deletions packages/base-controller/src/BaseController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
MessengerActions,
MessengerEvents,
} from '@metamask/messenger';
import { Struct, validate } from '@metamask/superstruct';
import type { Json, PublicInterface } from '@metamask/utils';
import { enablePatches, produceWithPatches, applyPatches, freeze } from 'immer';
import type { Draft, Patch } from 'immer';
Expand Down Expand Up @@ -229,10 +230,10 @@

public readonly metadata: StateMetadata<ControllerState>;

/**

Check failure on line 233 in packages/base-controller/src/BaseController.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (lint:eslint) (24.x)

Missing JSDoc @PARAM "options.struct" declaration
* Creates a BaseController instance.
*
* @param options - Controller options.

Check failure on line 236 in packages/base-controller/src/BaseController.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (lint:eslint) (24.x)

Missing @PARAM "options.struct"
* @param options.messenger - The controller messenger.
* @param options.metadata - ControllerState metadata, describing how to "anonymize" the state, and which
* parts should be persisted.
Expand All @@ -244,6 +245,7 @@
metadata,
name,
state,
struct,

Check failure on line 248 in packages/base-controller/src/BaseController.ts

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (lint:eslint) (24.x)

'struct' is defined but never used. Allowed unused args must match /[_]+/u
}: {
messenger: ControllerActions<
ControllerName,
Expand All @@ -264,6 +266,7 @@
metadata: StateMetadata<ControllerState>;
name: ControllerName;
state: ControllerState;
struct?: Struct<ControllerState>;
}) {
// The parameter type validates that the expected actions/events are present
// We don't have a way to validate the type property because the type is invariant
Expand Down Expand Up @@ -446,3 +449,47 @@
}
}, {} as never);
}

type ValidatableController<ControllerState extends StateConstraint> = {
struct: Struct<ControllerState>;
};

/**
* Validate the state of a controller against its struct. Returning the optionally coerced state if valid and otherwise throwing.
*
* Note that if the `mode` is lenient, validation errors are logged and not thrown.
*
* @param name - The name of the controller.
* @param controller - The static controller.
* @param state - The state of the controller.
* @param mode - The validation mode.
* @param captureException - A utility function for reporting an error to Sentry.
* @returns The validated controller state.
*/
export function validateControllerState<
ControllerState extends StateConstraint,
>(
name: string,
controller: ValidatableController<ControllerState>,
state: unknown,
mode: 'strict' | 'lenient',
captureException?: (error: Error) => void,
): ControllerState {
const [validationError, result] = validate(state, controller.struct);

if (mode === 'strict' && validationError) {
throw validationError;
} else if (mode === 'lenient' && validationError) {
const error = new Error(
`Validation of "${name}" state failed, but did not block: ${validationError.message}`,
);
// @ts-expect-error Current target does not support causes.
error.cause = validationError;

captureException?.(error);
console.warn(error);
return state as ControllerState;
}

return result as ControllerState;
}
1 change: 1 addition & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6193,6 +6193,7 @@ __metadata:
dependencies:
"@metamask/auto-changelog": "npm:^6.1.0"
"@metamask/messenger": "npm:^2.0.0"
"@metamask/superstruct": "npm:^3.4.1"
"@metamask/utils": "npm:^11.11.0"
"@ts-bridge/cli": "npm:^0.6.4"
"@types/jest": "npm:^30.0.0"
Expand Down
Loading