diff --git a/src/blocks/errors.ts b/src/blocks/errors.ts
index 90f985b095..2936f29435 100644
--- a/src/blocks/errors.ts
+++ b/src/blocks/errors.ts
@@ -23,6 +23,7 @@ import { BusinessError, CancelError } from "@/errors/businessErrors";
import { type MessageContext } from "@/types/loggerTypes";
import { type RegistryId } from "@/types/registryTypes";
import { type Schema } from "@/types/schemaTypes";
+import { type BrickArgs, type BrickArgsContext } from "@/types/runtimeTypes";
export class PipelineConfigurationError extends BusinessError {
override name = "PipelineConfigurationError";
@@ -45,16 +46,16 @@ export class HeadlessModeError extends Error {
public readonly blockId: RegistryId;
- public readonly args: unknown;
+ public readonly args: BrickArgs;
- public readonly ctxt: unknown;
+ public readonly ctxt: BrickArgsContext;
public readonly loggerContext: MessageContext;
constructor(
blockId: RegistryId,
- args: unknown, // BlockArg
- ctxt: unknown, // BrickArgsContext
+ args: BrickArgs,
+ ctxt: BrickArgsContext,
loggerContext: MessageContext
) {
super(`${blockId} is a renderer`);
diff --git a/src/blocks/transformers/brickFactory.test.ts b/src/blocks/transformers/brickFactory.test.ts
index a11a91070b..83c06c4160 100644
--- a/src/blocks/transformers/brickFactory.test.ts
+++ b/src/blocks/transformers/brickFactory.test.ts
@@ -33,6 +33,9 @@ import Run from "@/blocks/transformers/controlFlow/Run";
import { GetPageState } from "@/blocks/effects/pageState";
import { cloneDeep } from "lodash";
import { extraEmptyModStateContext } from "@/runtime/extendModVariableContext";
+import { setContext } from "@/testUtils/detectPageMock";
+
+setContext("contentScript");
beforeEach(() => {
blockRegistry.clear();
diff --git a/src/blocks/transformers/brickFactory.ts b/src/blocks/transformers/brickFactory.ts
index 27fd016c29..36270b70c0 100644
--- a/src/blocks/transformers/brickFactory.ts
+++ b/src/blocks/transformers/brickFactory.ts
@@ -15,7 +15,7 @@
* along with this program. If not, see .
*/
-import { BrickABC, type Brick } from "@/types/brickTypes";
+import { type Brick, BrickABC } from "@/types/brickTypes";
import { readerFactory } from "@/blocks/readers/factory";
import {
type Schema as ValidatorSchema,
@@ -35,25 +35,47 @@ import {
type ApiVersion,
type BrickArgs,
type BrickOptions,
+ validateBrickArgsContext,
} from "@/types/runtimeTypes";
import { type Schema, type UiSchema } from "@/types/schemaTypes";
-import {
- type Metadata,
- type RegistryId,
- type SemVerString,
-} from "@/types/registryTypes";
+import { type Metadata, type SemVerString } from "@/types/registryTypes";
import { type UnknownObject } from "@/types/objectTypes";
import { inputProperties } from "@/helpers";
-
import { isPipelineExpression } from "@/utils/expressionUtils";
+import { isContentScript } from "webext-detect-page";
+import { getTopLevelFrame } from "webext-messenger";
+import { uuidv4 } from "@/types/helpers";
+import { isSpecificError } from "@/errors/errorHelpers";
+import { HeadlessModeError } from "@/blocks/errors";
+import BackgroundLogger from "@/telemetry/BackgroundLogger";
+import { runHeadlessPipeline } from "@/contentScript/messenger/api";
type BrickDefinition = {
+ /**
+ * The runtime version to use when running the Brick.
+ */
apiVersion?: ApiVersion;
+
+ /**
+ * User-defined brick kind.
+ */
kind: "component";
+
+ /**
+ * Registry package metadata.
+ */
metadata: Metadata;
- defaultOptions: Record;
+
+ /**
+ * The wrapped bricks.
+ */
pipeline: BrickConfig | BrickPipeline;
+
+ /**
+ * JSON Schema for brick inputs.
+ */
inputSchema: Schema;
+
/**
* An optional RJSF uiSchema for inputs.
*
@@ -63,6 +85,9 @@ type BrickDefinition = {
*/
uiSchema?: UiSchema;
+ /**
+ * An optional JSON Output Schema for the brick.
+ */
outputSchema?: Schema;
/**
@@ -70,11 +95,11 @@ type BrickDefinition = {
* @since 1.7.34
*/
defaultOutputKey?: string;
-
- // Mapping from `key` -> `serviceId`
- services?: Record;
};
+/**
+ * Throw an error if the brick definition is invalid with respect to the brick meta-schema.
+ */
function validateBrickDefinition(
component: unknown
): asserts component is BrickDefinition {
@@ -97,9 +122,7 @@ function validateBrickDefinition(
/**
* A non-native (i.e., non-JS) Brick. Typically defined in YAML/JSON.
*/
-class ExternalBlock extends BrickABC {
- public readonly component: BrickDefinition;
-
+class UserDefinedBrick extends BrickABC {
readonly apiVersion: ApiVersion;
readonly inputSchema: Schema;
@@ -108,9 +131,10 @@ class ExternalBlock extends BrickABC {
readonly version: SemVerString;
- constructor(component: BrickDefinition) {
+ constructor(public readonly component: BrickDefinition) {
const { id, name, description, icon, version } = component.metadata;
super(id, name, description, icon);
+ // Fall back to v1 for backward compatability
this.apiVersion = component.apiVersion ?? "v1";
this.component = component;
this.inputSchema = this.component.inputSchema;
@@ -240,21 +264,62 @@ class ExternalBlock extends BrickABC {
// Blocks only have inputs, they can't pick up free variables from the environment
const initialValues: InitialValues = {
input: argsWithClosures,
- // OptionsArgs are set at the blueprint level. For composite bricks, the options should be passed in
- // as part of the brick inputs
+ // OptionsArgs are set at the blueprint level. For user-defined bricks, are passed via brick inputs
optionsArgs: undefined,
// Services are passed as inputs to the brick
serviceContext: undefined,
root: options.root,
};
- return reducePipeline(this.component.pipeline, initialValues, {
- logger: options.logger,
- headless: options.headless,
- // The component uses its declared version of the runtime API, regardless of what version of the runtime
- // is used to call the component
- ...apiVersionOptions(this.component.apiVersion),
- });
+ if (isContentScript()) {
+ // Already in the contentScript, run the pipeline directly.
+ return reducePipeline(this.component.pipeline, initialValues, {
+ logger: options.logger,
+ headless: options.headless,
+ // The component uses its declared version of the runtime API, regardless of what version of the runtime
+ // is used to call the component
+ ...apiVersionOptions(this.component.apiVersion),
+ });
+ }
+
+ // The brick is being run as a renderer from a modal or a sidebar. Run the logic in the contentScript and return the
+ // renderer. The caller can't run the whole brick in the contentScript because renderers can return React
+ // Components which can't be serialized across messenger boundaries.
+
+ // TODO: call top-level contentScript directly after https://github.com/pixiebrix/webext-messenger/issues/72
+ const topLevelFrame = await getTopLevelFrame();
+
+ try {
+ return await runHeadlessPipeline(topLevelFrame, {
+ nonce: uuidv4(),
+ // OptionsArgs are set at the blueprint level. For user-defined bricks, are passed via brick inputs
+ context: validateBrickArgsContext({
+ "@input": argsWithClosures,
+ "@options": {},
+ }),
+ pipeline: castArray(this.component.pipeline),
+ options: apiVersionOptions(this.apiVersion),
+ messageContext: options.logger.context,
+ meta: {
+ // Don't trace within user-defined bricks
+ extensionId: options.logger.context.extensionId,
+ branches: [],
+ runId: null,
+ },
+ });
+ } catch (error) {
+ if (isSpecificError(error, HeadlessModeError)) {
+ const continuation = error;
+ const renderer = await blockRegistry.lookup(continuation.blockId);
+ return renderer.run(continuation.args, {
+ ...options,
+ ctxt: continuation.ctxt,
+ logger: new BackgroundLogger(continuation.loggerContext),
+ });
+ }
+
+ throw error;
+ }
}
}
@@ -271,5 +336,5 @@ export function fromJS(component: UnknownObject): Brick {
}
validateBrickDefinition(component);
- return new ExternalBlock(component);
+ return new UserDefinedBrick(component);
}
diff --git a/src/components/documentBuilder/render/ButtonElement.tsx b/src/components/documentBuilder/render/ButtonElement.tsx
index 9fdac745c6..896e341105 100644
--- a/src/components/documentBuilder/render/ButtonElement.tsx
+++ b/src/components/documentBuilder/render/ButtonElement.tsx
@@ -18,7 +18,7 @@
import React, { useContext, useState } from "react";
import { type BrickPipeline } from "@/blocks/types";
import AsyncButton, { type AsyncButtonProps } from "@/components/AsyncButton";
-import { runEffectPipeline } from "@/contentScript/messenger/api";
+import { runHeadlessPipeline } from "@/contentScript/messenger/api";
import { uuidv4 } from "@/types/helpers";
import DocumentContext from "@/components/documentBuilder/render/DocumentContext";
import { type Except } from "type-fest";
@@ -68,7 +68,7 @@ const ButtonElement: React.FC = ({
const topLevelFrame = await getTopLevelFrame();
try {
- await runEffectPipeline(topLevelFrame, {
+ await runHeadlessPipeline(topLevelFrame, {
nonce: uuidv4(),
context: ctxt,
pipeline: onClick,
diff --git a/src/contentScript/messenger/api.ts b/src/contentScript/messenger/api.ts
index 8f70f58da9..f3574f1198 100644
--- a/src/contentScript/messenger/api.ts
+++ b/src/contentScript/messenger/api.ts
@@ -73,7 +73,7 @@ export const cancelSelect = getMethod("CANCEL_SELECT_ELEMENT");
export const selectElement = getMethod("SELECT_ELEMENT");
export const runRendererPipeline = getMethod("RUN_RENDERER_PIPELINE");
-export const runEffectPipeline = getMethod("RUN_EFFECT_PIPELINE");
+export const runHeadlessPipeline = getMethod("RUN_HEADLESS_PIPELINE");
export const runMapArgs = getMethod("RUN_MAP_ARGS");
export const getPageState = getMethod("GET_PAGE_STATE");
diff --git a/src/contentScript/messenger/registration.ts b/src/contentScript/messenger/registration.ts
index 42df6806f2..e41d78ddab 100644
--- a/src/contentScript/messenger/registration.ts
+++ b/src/contentScript/messenger/registration.ts
@@ -64,7 +64,7 @@ import {
selectElement,
} from "@/contentScript/pageEditor/elementPicker";
import {
- runEffectPipeline,
+ runHeadlessPipeline,
runMapArgs,
runRendererPipeline,
} from "@/contentScript/pipelineProtocol";
@@ -130,7 +130,7 @@ declare global {
SELECT_ELEMENT: typeof selectElement;
RUN_RENDERER_PIPELINE: typeof runRendererPipeline;
- RUN_EFFECT_PIPELINE: typeof runEffectPipeline;
+ RUN_HEADLESS_PIPELINE: typeof runHeadlessPipeline;
RUN_MAP_ARGS: typeof runMapArgs;
NOTIFY_INFO: typeof notify.info;
@@ -195,7 +195,7 @@ export default function registerMessenger(): void {
SELECT_ELEMENT: selectElement,
RUN_RENDERER_PIPELINE: runRendererPipeline,
- RUN_EFFECT_PIPELINE: runEffectPipeline,
+ RUN_HEADLESS_PIPELINE: runHeadlessPipeline,
RUN_MAP_ARGS: runMapArgs,
NOTIFY_INFO: notify.info,
diff --git a/src/contentScript/pipelineProtocol.ts b/src/contentScript/pipelineProtocol.ts
index c094b33a3b..dc2230d29e 100644
--- a/src/contentScript/pipelineProtocol.ts
+++ b/src/contentScript/pipelineProtocol.ts
@@ -102,20 +102,23 @@ export async function runRendererPipeline({
throw new BusinessError("Pipeline does not include a renderer");
}
-export async function runEffectPipeline({
+/**
+ * Run a pipeline in headless mode.
+ */
+export async function runHeadlessPipeline({
pipeline,
context,
options,
meta,
messageContext,
-}: RunPipelineParams): Promise {
+}: RunPipelineParams): Promise {
expectContext("contentScript");
if (meta.extensionId == null) {
- throw new Error("runEffectPipeline requires meta.extensionId");
+ throw new Error("runHeadlessPipeline requires meta.extensionId");
}
- await reducePipeline(
+ return reducePipeline(
pipeline,
{
input: context["@input"] ?? {},
diff --git a/src/runtime/pipelineTests/component.test.ts b/src/runtime/pipelineTests/component.test.ts
index f14e0ae90c..0b829ac8fe 100644
--- a/src/runtime/pipelineTests/component.test.ts
+++ b/src/runtime/pipelineTests/component.test.ts
@@ -27,6 +27,9 @@ import {
import { fromJS } from "@/blocks/transformers/brickFactory";
import { validateSemVerString } from "@/types/helpers";
+import { setContext } from "@/testUtils/detectPageMock";
+
+setContext("contentScript");
beforeEach(() => {
blockRegistry.clear();
diff --git a/src/runtime/reducePipeline.ts b/src/runtime/reducePipeline.ts
index 642603edcd..cbc25aa5e0 100644
--- a/src/runtime/reducePipeline.ts
+++ b/src/runtime/reducePipeline.ts
@@ -44,7 +44,10 @@ import {
throwIfInvalidInput,
} from "@/runtime/runtimeUtils";
import ConsoleLogger from "@/utils/ConsoleLogger";
-import { type ResolvedBrickConfig } from "@/runtime/runtimeTypes";
+import {
+ type ResolvedBrickConfig,
+ unsafeAssumeValidArg,
+} from "@/runtime/runtimeTypes";
import { type RunBlock } from "@/contentScript/runBlockTypes";
import { resolveBlockConfig } from "@/blocks/registry";
import { isObject } from "@/utils";
@@ -570,7 +573,8 @@ export async function runBlock(
throw new HeadlessModeError(
block.id,
- props.args,
+ // Call to throwIfInvalidInput above ensures args are valid for the brick
+ unsafeAssumeValidArg(props.args),
props.context,
logger.context
);
diff --git a/src/sidebar/PanelBody.tsx b/src/sidebar/PanelBody.tsx
index 40c6971632..b9400cb4f4 100644
--- a/src/sidebar/PanelBody.tsx
+++ b/src/sidebar/PanelBody.tsx
@@ -160,8 +160,7 @@ const PanelBody: React.FunctionComponent<{
console.debug("Running panel body for panel payload", payload);
const block = await blockRegistry.lookup(blockId);
- // In the future, the renderer brick should run in the contentScript, not the panel frame
- // TODO: https://github.com/pixiebrix/pixiebrix-extension/issues/1939
+
const body = await block.run(unsafeAssumeValidArg(args), {
ctxt,
root: null,
diff --git a/src/sidebar/RendererComponent.test.tsx b/src/sidebar/RendererComponent.test.tsx
index e62748fbfb..27240ec5cd 100644
--- a/src/sidebar/RendererComponent.test.tsx
+++ b/src/sidebar/RendererComponent.test.tsx
@@ -25,19 +25,21 @@ import { screen } from "shadow-dom-testing-library";
import { act } from "@testing-library/react";
import { SubmitPanelAction } from "@/blocks/errors";
import ConsoleLogger from "@/utils/ConsoleLogger";
-import { runEffectPipeline } from "@/contentScript/messenger/api";
+import { runHeadlessPipeline } from "@/contentScript/messenger/api";
jest.mock("@/contentScript/messenger/api", () => ({
- runEffectPipeline: jest.fn().mockRejectedValue(new Error("not implemented")),
+ runHeadlessPipeline: jest
+ .fn()
+ .mockRejectedValue(new Error("not implemented")),
}));
-const runEffectPipelineMock = runEffectPipeline as jest.MockedFunction<
- typeof runEffectPipeline
+const runHeadlessPipelineMock = runHeadlessPipeline as jest.MockedFunction<
+ typeof runHeadlessPipeline
>;
describe("RendererComponent", () => {
beforeEach(() => {
- runEffectPipelineMock.mockReset();
+ runHeadlessPipelineMock.mockReset();
});
test("provide onAction to document renderer", async () => {
@@ -45,7 +47,7 @@ describe("RendererComponent", () => {
const extensionId = uuidv4();
const onAction = jest.fn();
- runEffectPipelineMock.mockRejectedValue(
+ runHeadlessPipelineMock.mockRejectedValue(
new SubmitPanelAction("submit", { foo: "bar" })
);
diff --git a/src/testUtils/detectPageMock.ts b/src/testUtils/detectPageMock.ts
index 1d8cd0f967..505344afa4 100644
--- a/src/testUtils/detectPageMock.ts
+++ b/src/testUtils/detectPageMock.ts
@@ -53,6 +53,10 @@ export function isWeb() {
return _context === "web";
}
+export function isContentScript() {
+ return _context === "contentScript";
+}
+
export function getContextName(): ContextName {
return _context;
}
diff --git a/src/types/runtimeTypes.ts b/src/types/runtimeTypes.ts
index d46dc2b06b..35a18e0802 100644
--- a/src/types/runtimeTypes.ts
+++ b/src/types/runtimeTypes.ts
@@ -208,7 +208,18 @@ export type BrickArgsContext = UnknownObject & {
};
/**
- * The JSON Schema validated arguments to pass into the `run` method of an Brick.
+ * Returns an object as a BrickArgsContext, or throw a TypeError if it's not a valid context.
+ */
+export function validateBrickArgsContext(obj: UnknownObject): BrickArgsContext {
+ if (!("@input" in obj)) {
+ throw new TypeError("BrickArgsContext must have @input property");
+ }
+
+ return obj as BrickArgsContext;
+}
+
+/**
+ * The JSON Schema validated arguments to pass into the `run` method of a Brick.
*
* Uses `any` for values so that bricks don't have to assert/cast all their argument types. The input values
* are validated using JSON Schema in `reducePipeline`.
@@ -225,7 +236,7 @@ export type BrickArgs<
};
/**
- * The non-validated arguments to pass into the `run` method of an Brick.
+ * The non-validated arguments to pass into the `run` method of a Brick.
* @see BrickArgs
*/
export type RenderedArgs = UnknownObject & {