diff --git a/.gitignore b/.gitignore index 30f71a3..7cb22aa 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,10 @@ .env .env.* .idea +.agents .codex .claude +skills-lock.json .output addon *.log diff --git a/src/cli/plugins/typescript/declaration/transport/TransportDeclaration.test.ts b/src/cli/plugins/typescript/declaration/transport/TransportDeclaration.test.ts new file mode 100644 index 0000000..addbc4a --- /dev/null +++ b/src/cli/plugins/typescript/declaration/transport/TransportDeclaration.test.ts @@ -0,0 +1,87 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; + +import TransportDeclaration, {TransportDeclarationLayer} from "./TransportDeclaration"; + +import type {ReadonlyConfig} from "@typing/config"; + +describe("TransportDeclaration", () => { + const rootDirs: string[] = []; + + const makeRootDir = (): string => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-transport-declaration-")); + + rootDirs.push(rootDir); + + return rootDir; + }; + + afterEach(() => { + for (const rootDir of rootDirs.splice(0)) { + fs.rmSync(rootDir, {recursive: true, force: true}); + } + }); + + const cases = [ + { + dictionary: { + alpha: "{ call(value: string): Promise; }", + beta: "{ nested: { ping(): boolean; }; }", + }, + filename: "offscreen.d.ts", + layer: TransportDeclarationLayer.Offscreen, + registry: "OffscreenRegistry", + }, + { + dictionary: { + alpha: "{ call(value: string): Promise; }", + beta: "{ nested: { ping(): boolean; }; }", + }, + filename: "service.d.ts", + layer: TransportDeclarationLayer.Service, + registry: "ServiceRegistry", + }, + { + dictionary: { + alpha: "{ call(value: string): Promise; }", + beta: "{ nested: { ping(): boolean; }; }", + }, + filename: "relay.d.ts", + layer: TransportDeclarationLayer.Relay, + registry: "RelayRegistry", + }, + ]; + + test.each(cases)("writes a strict $layer registry", ({dictionary, filename, layer, registry}) => { + const rootDir = makeRootDir(); + + new TransportDeclaration({rootDir} as ReadonlyConfig, layer).dictionary(dictionary).build(); + + const declaration = fs.readFileSync(path.join(rootDir, ".adnbn", filename), "utf-8"); + + expect(declaration).toContain('import "adnbn";'); + expect(declaration).toContain(`import "adnbn/${layer}";`); + expect(declaration).not.toContain('import "adnbn/transport";'); + expect(declaration).toContain(`declare module "adnbn/${layer}"`); + expect(declaration).not.toContain('declare module "adnbn/transport"'); + expect(declaration).toContain(`export interface ${registry}`); + expect(declaration).toContain("'alpha': { call(value: string): Promise; };"); + expect(declaration).toContain("'beta': { nested: { ping(): boolean; }; };"); + expect(declaration).not.toContain("[name: string]: any"); + expect(declaration).not.toContain("export interface TransportDictionary"); + expect(declaration).not.toContain("export function get"); + }); + + test("keeps empty layer registries strict", () => { + const rootDir = makeRootDir(); + + new TransportDeclaration({rootDir} as ReadonlyConfig, TransportDeclarationLayer.Relay).dictionary({}).build(); + + const declaration = fs.readFileSync(path.join(rootDir, ".adnbn", "relay.d.ts"), "utf-8"); + + expect(declaration).toContain("export interface RelayRegistry"); + expect(declaration).not.toContain("[name: string]: any"); + expect(declaration).not.toContain("__TRANSPORT_DICTIONARY__"); + }); +}); diff --git a/src/cli/plugins/typescript/declaration/transport/TransportDeclaration.ts b/src/cli/plugins/typescript/declaration/transport/TransportDeclaration.ts index a4a1c03..634fbbc 100644 --- a/src/cli/plugins/typescript/declaration/transport/TransportDeclaration.ts +++ b/src/cli/plugins/typescript/declaration/transport/TransportDeclaration.ts @@ -48,7 +48,7 @@ export default class = Record> return this.readFile() .replaceAll(":layer", this.layer) .replaceAll("Layer", _.upperFirst(this.layer)) - .replace(`{ [name: string]: any }`, `{\n\t\t${type}\n\t}`); + .replaceAll("__TRANSPORT_DICTIONARY__", type); } public dictionary(dictionary: T): this { diff --git a/src/cli/plugins/typescript/declaration/transport/transport.d.ts b/src/cli/plugins/typescript/declaration/transport/transport.d.ts index 80d9fae..959e7bf 100644 --- a/src/cli/plugins/typescript/declaration/transport/transport.d.ts +++ b/src/cli/plugins/typescript/declaration/transport/transport.d.ts @@ -1,15 +1,9 @@ import ":package"; -import type {LayerProxyTarget, LayerTarget} from ":package/:layer"; - -declare module ":package" { - // prettier-ignore - export interface LayerRegistry { [name: string]: any } - - export function getLayer(name: N): LayerProxyTarget; -} +import ":package/:layer"; declare module ":package/:layer" { - import type {LayerRegistry} from ":package"; - - export function getLayer(name: N): LayerTarget; + // prettier-ignore + export interface LayerRegistry { + __TRANSPORT_DICTIONARY__ + } } diff --git a/src/main/offscreen.ts b/src/main/offscreen.ts index a43e5a0..c8b7994 100644 --- a/src/main/offscreen.ts +++ b/src/main/offscreen.ts @@ -1,8 +1,8 @@ import ProxyOffscreen from "@offscreen/providers/ProxyOffscreen"; import {type OffscreenDefinition, OffscreenReason, type OffscreenUnresolvedDefinition} from "@typing/offscreen"; -import {TransportDictionary, TransportType} from "@typing/transport"; -import {DeepAsyncProxy} from "@typing/helpers"; +import type {OffscreenName, OffscreenProxyTarget} from "@offscreen/index"; +import type {TransportType} from "@typing/transport"; type OffscreenParameters = chrome.offscreen.CreateParameters; @@ -31,9 +31,7 @@ export const getOffscreens = (): OffscreenMap => { return offscreens; }; -export const getOffscreen = >( - name: N -): DeepAsyncProxy => { +export const getOffscreen = (name: N): OffscreenProxyTarget => { const parameters = getOffscreens().get(name); if (!parameters) { diff --git a/src/main/relay.ts b/src/main/relay.ts index 41bc725..446c234 100644 --- a/src/main/relay.ts +++ b/src/main/relay.ts @@ -1,8 +1,8 @@ import RelayPermission from "@relay/RelayPermission"; import {ProxyRelay, type ProxyRelayParams} from "@relay/providers"; -import {DeepAsyncProxy} from "@typing/helpers"; -import {TransportDictionary, TransportType} from "@typing/transport"; +import type {RelayName, RelayProxyTarget} from "@relay/index"; +import type {TransportType} from "@typing/transport"; import {RelayDefinition, RelayMethod, RelayOptions, RelayOptionsMap, RelayUnresolvedDefinition} from "@typing/relay"; export {RelayMethod}; @@ -25,10 +25,7 @@ const getRelayOptionsMap = (): RelayOptionsMap => { return relays; }; -export const getRelay = >( - name: N, - params: ProxyRelayParams -): DeepAsyncProxy => { +export const getRelay = (name: N, params: ProxyRelayParams): RelayProxyTarget => { const relays = getRelayOptionsMap(); RelayPermission.init(relays); diff --git a/src/main/service.ts b/src/main/service.ts index 03f7496..7d58ec4 100644 --- a/src/main/service.ts +++ b/src/main/service.ts @@ -1,7 +1,7 @@ import {ProxyService} from "@service/providers"; -import {DeepAsyncProxy} from "@typing/helpers"; -import {TransportDictionary, TransportType} from "@typing/transport"; +import type {ServiceName, ServiceProxyTarget} from "@service/index"; +import type {TransportType} from "@typing/transport"; import {ServiceDefinition} from "@typing/service"; export type {ServiceDefinition}; @@ -10,8 +10,6 @@ export const defineService = (options: ServiceDefinitio return options; }; -export const getService = >( - name: N -): DeepAsyncProxy => { +export const getService = (name: N): ServiceProxyTarget => { return new ProxyService(name).get(); }; diff --git a/src/message/MessageManager.ts b/src/message/MessageManager.ts index 2afa214..0c4f775 100644 --- a/src/message/MessageManager.ts +++ b/src/message/MessageManager.ts @@ -3,8 +3,11 @@ import {onMessage} from "@addon-core/browser"; import { MessageBody, MessageDictionary, + MessageError, MessageGlobalKey, MessageHandler, + MessageResult, + MessageResultEnvelopeProperty, MessageSender, MessageType, } from "@typing/message"; @@ -48,7 +51,7 @@ export default class MessageManager { private listener>( message: MessageBody, sender: MessageSender, - sendResponse: (response?: any) => void + sendResponse: (response?: MessageResult) => void ): boolean | void { if (!message || typeof message !== "object" || !message.type) { return; @@ -64,20 +67,66 @@ export default class MessageManager { results.push(Promise.resolve(result)); } } catch (err) { - console.error("Message handler error:", err); + results.push(Promise.reject(err)); } } if (results.length > 1) { - throw new Error( - `Message type "${message.type}" has multiple handlers returning a response. Only one response is allowed.` + sendResponse( + this.failure( + new Error( + `Message type "${message.type}" has multiple handlers returning a response. Only one response is allowed.` + ) + ) ); + + return true; } if (results.length === 1) { - results[0].then(sendResponse); + results[0].then( + result => sendResponse(this.success(result)), + error => sendResponse(this.failure(error)) + ); return true; } } + + private success(payload: TData): MessageResult { + return {[MessageResultEnvelopeProperty]: true, ok: true, payload}; + } + + private failure(error: unknown): MessageResult { + return {[MessageResultEnvelopeProperty]: true, ok: false, error: this.serializeError(error)}; + } + + private serializeError(error: unknown): MessageError { + if (error instanceof Error) { + return this.error(error.name, error.message, error.stack); + } + + if (typeof error === "object" && error !== null) { + const record = error as Record; + const name = typeof record.name === "string" ? record.name : "Error"; + const message = typeof record.message === "string" ? record.message : this.stringifyError(error); + const stack = typeof record.stack === "string" ? record.stack : undefined; + + return this.error(name, message, stack); + } + + return this.error("Error", String(error)); + } + + private stringifyError(error: object): string { + try { + return JSON.stringify(error); + } catch { + return String(error); + } + } + + private error(name: string, message: string, stack?: string): MessageError { + return stack ? {name, message, stack} : {name, message}; + } } diff --git a/src/message/providers/Message.test.ts b/src/message/providers/Message.test.ts index a1b87dc..984141c 100644 --- a/src/message/providers/Message.test.ts +++ b/src/message/providers/Message.test.ts @@ -6,6 +6,13 @@ type MessageMap = { toUpperCase: (str: string) => string; sayHello: (data?: string) => string; fetchUser: (name: string) => Promise<{name: string}>; + throwSync: (message: string) => never; + throwAsync: (message: string) => Promise; + throwPrimitive: (message: string) => never; + throwPlainObject: (message: string) => never; + envelopeLikePayload: (data?: undefined) => {ok: false; error: string}; + rawEnvelopeLikePayload: (data?: undefined) => {ok: false; error: string}; + rawSuccessEnvelopeLikePayload: (data?: undefined) => {ok: true; payload: string}; }; let message: Message; @@ -171,6 +178,83 @@ describe("send method", () => { ); expect(result).toBe(4); }); + + test("rejects when a sync handler throws", async () => { + message.watch("throwSync", data => { + throw new TypeError(data); + }); + + await expect(message.send("throwSync", "sync boom")).rejects.toMatchObject({ + name: "TypeError", + message: "sync boom", + }); + await expect(message.send("throwSync", "sync boom")).rejects.toBeInstanceOf(TypeError); + }); + + test("rejects when an async handler rejects", async () => { + message.watch("throwAsync", async data => { + throw new RangeError(data); + }); + + await expect(message.send("throwAsync", "async boom")).rejects.toMatchObject({ + name: "RangeError", + message: "async boom", + }); + await expect(message.send("throwAsync", "async boom")).rejects.toBeInstanceOf(RangeError); + }); + + test("rejects when a handler throws a primitive value", async () => { + message.watch("throwPrimitive", data => { + throw data; + }); + + await expect(message.send("throwPrimitive", "primitive boom")).rejects.toMatchObject({ + name: "Error", + message: "primitive boom", + }); + }); + + test("rejects when a handler throws a plain object", async () => { + message.watch("throwPlainObject", data => { + throw {name: "CustomError", message: data}; + }); + + await expect(message.send("throwPlainObject", "plain object boom")).rejects.toMatchObject({ + name: "CustomError", + message: "plain object boom", + }); + }); + + test("returns envelope-like user data as payload", async () => { + message.watch("envelopeLikePayload", () => ({ok: false, error: "user payload"})); + + await expect(message.send("envelopeLikePayload", undefined)).resolves.toEqual({ + ok: false, + error: "user payload", + }); + }); + + test("returns raw invalid failure envelope as payload", async () => { + (chrome.runtime.sendMessage as jest.Mock).mockImplementationOnce((msg, callback) => { + callback?.({ok: false, error: "raw payload"}); + }); + + await expect(message.send("rawEnvelopeLikePayload", undefined)).resolves.toEqual({ + ok: false, + error: "raw payload", + }); + }); + + test("returns raw success envelope-like response as payload", async () => { + (chrome.runtime.sendMessage as jest.Mock).mockImplementationOnce((msg, callback) => { + callback?.({ok: true, payload: "raw payload"}); + }); + + await expect(message.send("rawSuccessEnvelopeLikePayload", undefined)).resolves.toEqual({ + ok: true, + payload: "raw payload", + }); + }); }); describe("multiple handlers error for same message type", () => { diff --git a/src/message/providers/Message.ts b/src/message/providers/Message.ts index 1cfb19c..347cb9c 100644 --- a/src/message/providers/Message.ts +++ b/src/message/providers/Message.ts @@ -3,11 +3,15 @@ import {sendMessage, sendTabMessage} from "@addon-core/browser"; import {isBrowser} from "@main/env"; import { + MessageBody, MessageData, MessageDictionary, + MessageError, MessageGeneralHandler, MessageHandler, MessageMapHandler, + MessageResult, + MessageResultEnvelopeProperty, MessageResponse, MessageSendOptions, MessageTargetHandler, @@ -31,28 +35,111 @@ export default class Message extends AbstractMessag return MessageManager.getInstance(); } - public send>( + public async send>( type: K, data: MessageData, options?: MessageSendOptions ): Promise> { const message = this.buildMessage(type, data); + const response = await this.dispatch(message, options); - if (options) { - if (typeof options === "number") { - return sendTabMessage(options, message); - } + return this.unwrap(response); + } + + private dispatch>( + message: MessageBody, + options?: MessageSendOptions + ): Promise> | MessageResponse | undefined> { + if (options === undefined) { + return sendMessage(message); + } + + if (typeof options === "number") { + return sendTabMessage(options, message); + } + + const {tabId, ...other} = options; + + if (isBrowser(Browser.Firefox)) { + delete other.documentId; + } + + return sendTabMessage(tabId, message, other); + } + + private unwrap>( + response: MessageResult> | MessageResponse | undefined + ): MessageResponse { + if (!this.isMessageResult(response)) { + return response as MessageResponse; + } + + if (response.ok) { + return response.payload; + } - const {tabId, ...other} = options; + throw this.restoreError(response.error); + } - if (isBrowser(Browser.Firefox)) { - delete other.documentId; - } + private isMessageResult(response: unknown): response is MessageResult { + if ( + !this.isRecord(response) || + response[MessageResultEnvelopeProperty] !== true || + typeof response.ok !== "boolean" + ) { + return false; + } - return sendTabMessage(tabId, message, other); + if (response.ok) { + return "payload" in response; } - return sendMessage(message); + return this.isSerializedError(response.error); + } + + private isSerializedError(error: unknown): error is MessageError { + return ( + this.isRecord(error) && + typeof error.name === "string" && + typeof error.message === "string" && + (error.stack === undefined || typeof error.stack === "string") + ); + } + + private isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; + } + + private restoreError(error: MessageError): Error { + const ErrorConstructor = this.getErrorConstructor(error.name); + const restored = new ErrorConstructor(error.message); + + restored.name = error.name || "Error"; + + if (error.stack) { + restored.stack = error.stack; + } + + return restored; + } + + private getErrorConstructor(name: string): new (message?: string) => Error { + switch (name) { + case "EvalError": + return EvalError; + case "RangeError": + return RangeError; + case "ReferenceError": + return ReferenceError; + case "SyntaxError": + return SyntaxError; + case "TypeError": + return TypeError; + case "URIError": + return URIError; + default: + return Error; + } } public watch>( diff --git a/src/offscreen/index.ts b/src/offscreen/index.ts index 89a0fbd..a8a6219 100644 --- a/src/offscreen/index.ts +++ b/src/offscreen/index.ts @@ -1,15 +1,18 @@ import {Offscreen, ProxyOffscreen, RegisterOffscreen} from "./providers"; import OffscreenBackground from "./OffscreenBackground"; -import { - TransportDictionary, - TransportName, - TransportProxyTarget as OffscreenProxyTarget, - TransportTarget as OffscreenTarget, -} from "@typing/transport"; +import type {TransportProxyTarget, TransportTarget} from "@transport/index"; -export {type OffscreenTarget, type OffscreenProxyTarget, ProxyOffscreen, RegisterOffscreen, OffscreenBackground}; +export {ProxyOffscreen, RegisterOffscreen, OffscreenBackground}; -export const getOffscreen = (name: N): TransportDictionary[N] => { +export interface OffscreenRegistry {} + +export type OffscreenName = Extract; + +export type OffscreenTarget = TransportTarget; + +export type OffscreenProxyTarget = TransportProxyTarget; + +export const getOffscreen = (name: N): OffscreenTarget => { return new Offscreen(name).get(); }; diff --git a/src/relay/index.ts b/src/relay/index.ts index b4e8727..6b13f9d 100644 --- a/src/relay/index.ts +++ b/src/relay/index.ts @@ -1,14 +1,17 @@ import {ProxyRelay, RegisterRelay, Relay, type ProxyRelayParams} from "./providers"; -import type { - TransportDictionary, - TransportName, - TransportProxyTarget as RelayProxyTarget, - TransportTarget as RelayTarget, -} from "@typing/transport"; +import type {TransportProxyTarget, TransportTarget} from "@transport/index"; -export {type RelayTarget, type RelayProxyTarget, type ProxyRelayParams, ProxyRelay, RegisterRelay}; +export {type ProxyRelayParams, ProxyRelay, RegisterRelay}; -export const getRelay = (name: N): TransportDictionary[N] => { +export interface RelayRegistry {} + +export type RelayName = Extract; + +export type RelayTarget = TransportTarget; + +export type RelayProxyTarget = TransportProxyTarget; + +export const getRelay = (name: N): RelayTarget => { return new Relay(name).get(); }; diff --git a/src/service/index.ts b/src/service/index.ts index 8f0a363..4de1059 100644 --- a/src/service/index.ts +++ b/src/service/index.ts @@ -1,14 +1,17 @@ import {ProxyService, RegisterService, Service} from "./providers"; -import type { - TransportDictionary, - TransportName, - TransportProxyTarget as ServiceProxyTarget, - TransportTarget as ServiceTarget, -} from "@typing/transport"; +import type {TransportProxyTarget, TransportTarget} from "@transport/index"; -export {type ServiceTarget, type ServiceProxyTarget, ProxyService, RegisterService}; +export {ProxyService, RegisterService}; -export const getService = (name: N): TransportDictionary[N] => { +export interface ServiceRegistry {} + +export type ServiceName = Extract; + +export type ServiceTarget = TransportTarget; + +export type ServiceProxyTarget = TransportProxyTarget; + +export const getService = (name: N): ServiceTarget => { return new Service(name).get(); }; diff --git a/src/transport/index.ts b/src/transport/index.ts index f7cb5ec..66d4682 100644 --- a/src/transport/index.ts +++ b/src/transport/index.ts @@ -1,8 +1,13 @@ +import type {DeepAsyncProxy} from "@typing/helpers"; + +export type TransportTarget = T[K]; + +export type TransportProxyTarget = DeepAsyncProxy; + export type { TransportDefinition, TransportResolvedDefinition, TransportUnresolvedDefinition, - TransportName, TransportType, TransportOptions, } from "@typing/transport"; diff --git a/src/types/message.ts b/src/types/message.ts index 4aa79b1..1088470 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -4,6 +4,8 @@ export const MessageTypeSeparator = ":"; export const MessageSenderProperty = "$sender"; +export const MessageResultEnvelopeProperty = "__adnbnEnvelope"; + export type MessageSender = chrome.runtime.MessageSender; export type MessageSendOptions = number | {tabId: number; frameId?: number; documentId?: string}; @@ -16,6 +18,16 @@ export interface MessageSenderAware { readonly [MessageSenderProperty]?: MessageSender; } +export interface MessageError { + name: string; + message: string; + stack?: string; +} + +export type MessageResult = + | {readonly [MessageResultEnvelopeProperty]: true; ok: true; payload: T} + | {readonly [MessageResultEnvelopeProperty]: true; ok: false; error: MessageError}; + export type MessageType = Extract; export type MessageData> = Parameters[0]; export type MessageResponse> = ReturnType;