tuil
ReferencePackages@mwillbanks/tuil-devtoolsAPI

DevtoolsExtensionRegistry

class exported by @mwillbanks/tuil-devtools.

View rawEdit

class

Public class exported by @mwillbanks/tuil-devtools.

export class DevtoolsExtensionRegistry {
  readonly #extensions = new Map<string, DevtoolsExtension>();
  readonly #capabilities: ReadonlySet<DevtoolsCapability>;
  readonly #history: DevtoolsActionRecord[] = [];
  readonly #historyLimit: number;
  #historyDropped = 0;
  readonly #development: boolean;
  readonly #transport?: ProtocolTransport;
  readonly #redact: (value: unknown) => unknown;

  constructor(
    options: {
      readonly capabilities?: ReadonlySet<DevtoolsCapability>;
      readonly development?: boolean;
      readonly transport?: ProtocolTransport;
      readonly redact?: (value: unknown) => unknown;
      readonly actionHistoryLimit?: number;
    } = {},
  ) {
    this.#capabilities =
      options.capabilities ??
      new Set<DevtoolsCapability>([
        "runtime",
        "services",
        "commands",
        "focus",
        "semantics",
        "layout",
        "pointer",
        "frames",
        "scroll",
        "operations",
        "workflows",
        "routes",
        "plugins",
        "themes",
        "editors",
        "logs",
        "performance",
        "errors",
      ]);
    this.#development = options.development ?? false;
    this.#historyLimit = options.actionHistoryLimit ?? 200;
    if (!Number.isSafeInteger(this.#historyLimit) || this.#historyLimit <= 0) {
      throw new Error(
        "Devtools actionHistoryLimit must be a positive safe integer",
      );
    }
    this.#transport = options.transport;
    this.#redact = options.redact
      ? (value) =>
          redactDevtoolsValue(options.redact?.(redactDevtoolsValue(value)))
      : redactDevtoolsValue;
  }

  register(extension: DevtoolsExtension): () => void {
    validateDevtoolsExtension(extension, this.#extensions, this.#capabilities);
    if (extension.activation && !extension.activation()) {
      return () => {};
    }
    this.#extensions.set(extension.id, extension);
    void Promise.resolve(
      this.#transport?.send(
        createProtocolMessage("contribution", {
          id: extension.id,
          kind: extension.kind,
        }),
      ),
    ).catch(() => undefined);
    return () => {
      if (this.#extensions.get(extension.id) !== extension) return;
      this.#extensions.delete(extension.id);
      extension.dispose?.();
    };
  }

  list<TKind extends DevtoolsExtension["kind"]>(
    kind?: TKind,
  ): readonly Extract<DevtoolsExtension, { readonly kind: TKind }>[] {
    return Object.freeze(
      [...this.#extensions.values()].filter(
        (
          extension,
        ): extension is Extract<DevtoolsExtension, { readonly kind: TKind }> =>
          !kind || extension.kind === kind,
      ),
    );
  }

  inspect(id: string): unknown {
    const panel = this.#extensions.get(id);
    if (panel?.kind !== "panel") {
      throw new Error(`Devtools panel "${id}" is unavailable`);
    }
    return this.#redact(panel.inspect());
  }

  search(query: string): readonly DevtoolsPanelContribution[] {
    const needle = query.toLowerCase();
    return Object.freeze(
      this.list("panel").filter((panel) =>
        `${panel.title} ${panel.searchText?.() ?? ""}`
          .toLowerCase()
          .includes(needle),
      ),
    );
  }

  async execute(id: string, input?: unknown): Promise<unknown> {
    const extension = this.#extensions.get(id);
    if (extension?.kind !== "action") {
      throw new Error(`Devtools action "${id}" is unavailable`);
    }
    if (!extension.permissions.has("write")) {
      throw new Error(`Devtools action "${id}" lacks write permission`);
    }
    if (!this.#development) {
      throw new Error(`Devtools action "${id}" requires development mode`);
    }
    let result: unknown;
    try {
      result = await extension.run(input, {
        development: true,
        record: (entry) => {
          this.#record(entry);
        },
      });
    } catch (error) {
      const message = redactDevtoolsValue(
        error instanceof Error ? error.message : String(error),
      ) as string;
      this.#record({
        id,
        timestamp: Date.now(),
        input,
        ok: false,
        error: message,
      });
      throw new Error(message);
    }
    const entry = this.#record({
      id,
      timestamp: Date.now(),
      input,
      ok: true,
    });
    try {
      await this.#transport?.send(createProtocolMessage("command", entry));
    } catch {
      // Delivery is observational. The completed action must not become a
      // retryable failure when a devtools transport is unavailable.
    }
    return result;
  }

  #record(entry: DevtoolsActionRecord): DevtoolsActionRecord {
    const redacted = Object.freeze({
      ...entry,
      input: this.#redact(entry.input),
      error:
        entry.error === undefined
          ? undefined
          : (this.#redact(entry.error) as string),
    });
    this.#history.push(redacted);
    const overflow = this.#history.length - this.#historyLimit;
    if (overflow > 0) {
      this.#history.splice(0, overflow);
      this.#historyDropped += overflow;
    }
    return redacted;
  }

  actionHistory(): readonly DevtoolsActionRecord[] {
    return Object.freeze([...this.#history]);
  }

  actionHistorySnapshot(): DevtoolsActionHistorySnapshot {
    return Object.freeze({
      records: this.actionHistory(),
      limit: this.#historyLimit,
      dropped: this.#historyDropped,
      truncated: this.#historyDropped > 0,
    });
  }

  async query(id: string, input: string): Promise<unknown> {
    const extension = this.#extensions.get(id);
    if (extension?.kind !== "query") {
      throw new Error(`Devtools query "${id}" is unavailable`);
    }
    return extension.query(input);
  }

  async observe(id: string, input?: unknown): Promise<unknown> {
    const extension = this.#extensions.get(id);
    if (
      !extension ||
      extension.kind === "panel" ||
      extension.kind === "action" ||
      extension.kind === "query"
    ) {
      throw new Error(`Devtools observer "${id}" is unavailable`);
    }
    return extension.observe(input);
  }

  diagnosticsBundle(): string {
    const panels = Object.fromEntries(
      this.list("panel").map((panel) => [panel.id, panel.inspect()]),
    );
    return JSON.stringify(
      this.#redact({
        version: 1,
        generatedAt: new Date(0).toISOString(),
        panels,
        actions: this.#history,
        actionHistory: {
          limit: this.#historyLimit,
          dropped: this.#historyDropped,
          truncated: this.#historyDropped > 0,
        },
      }),
      null,
      2,
    );
  }

  dispose(): void {
    for (const extension of this.#extensions.values()) {
      extension.dispose?.();
    }
    this.#extensions.clear();
  }
}

Members

MemberTypeRequiredDescriptionRelated types
#extensionsMap<string, DevtoolsExtension>YesThe #extensions member uses the Map<string, DevtoolsExtension> contract.DevtoolsExtension
#capabilitiesReadonlySet<DevtoolsCapability>YesThe #capabilities member uses the ReadonlySet<DevtoolsCapability> contract.DevtoolsCapability
#historyDevtoolsActionRecord[]YesThe #history member uses the DevtoolsActionRecord[] contract.DevtoolsActionRecord
#historyLimitnumberYesThe #historyLimit member uses the number contract.
#historyDroppednumberYesThe #historyDropped member uses the number contract.
#developmentbooleanYesThe #development member uses the boolean contract.
#transportProtocolTransport | undefinedNoThe #transport member uses the ProtocolTransport | undefined contract.
#redact(value: unknown) => unknownYesThe #redact member uses the (value: unknown) => unknown contract.
__constructoranyYesThe __constructor member uses the any contract.
register(extension: DevtoolsExtension) => () => voidYesThe register member uses the (extension: DevtoolsExtension) => () => void contract.DevtoolsExtension
list<TKind extends DevtoolsExtension["kind"]>(kind?: TKind) => readonly Extract<DevtoolsExtension, &#123; readonly kind: TKind; &#125;>[]YesThe list member uses the <TKind extends DevtoolsExtension["kind"]>(kind?: TKind) => readonly Extract<DevtoolsExtension, &#123; readonly kind: TKind; &#125;>[] contract.DevtoolsExtension
inspect(id: string) => unknownYesThe inspect member uses the (id: string) => unknown contract.
search(query: string) => readonly DevtoolsPanelContribution[]YesThe search member uses the (query: string) => readonly DevtoolsPanelContribution[] contract.DevtoolsPanelContribution
execute(id: string, input?: unknown) => Promise<unknown>YesThe execute member uses the (id: string, input?: unknown) => Promise<unknown> contract.
#record(entry: DevtoolsActionRecord) => DevtoolsActionRecordYesThe #record member uses the (entry: DevtoolsActionRecord) => DevtoolsActionRecord contract.DevtoolsActionRecord
actionHistory() => readonly DevtoolsActionRecord[]YesThe actionHistory member uses the () => readonly DevtoolsActionRecord[] contract.DevtoolsActionRecord
actionHistorySnapshot() => DevtoolsActionHistorySnapshotYesThe actionHistorySnapshot member uses the () => DevtoolsActionHistorySnapshot contract.DevtoolsActionHistorySnapshot
query(id: string, input: string) => Promise<unknown>YesThe query member uses the (id: string, input: string) => Promise<unknown> contract.
observe(id: string, input?: unknown) => Promise<unknown>YesThe observe member uses the (id: string, input?: unknown) => Promise<unknown> contract.
diagnosticsBundle() => stringYesThe diagnosticsBundle member uses the () => string contract.
dispose() => voidYesThe dispose member uses the () => void contract.

Parameters

This declaration has no public members.

Returns

This declaration does not return a value.

Throws

No thrown errors are documented for this declaration.

Source

View the secondary source reference

Package

@mwillbanks/tuil-devtools

On this page