Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
.env
.env.*
.idea
.agents
.codex
.claude
skills-lock.json
.output
addon
*.log
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>; }",
beta: "{ nested: { ping(): boolean; }; }",
},
filename: "offscreen.d.ts",
layer: TransportDeclarationLayer.Offscreen,
registry: "OffscreenRegistry",
},
{
dictionary: {
alpha: "{ call(value: string): Promise<string>; }",
beta: "{ nested: { ping(): boolean; }; }",
},
filename: "service.d.ts",
layer: TransportDeclarationLayer.Service,
registry: "ServiceRegistry",
},
{
dictionary: {
alpha: "{ call(value: string): Promise<string>; }",
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<string>; };");
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__");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export default class<T extends Record<string, string> = Record<string, string>>
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 {
Expand Down
16 changes: 5 additions & 11 deletions src/cli/plugins/typescript/declaration/transport/transport.d.ts
Original file line number Diff line number Diff line change
@@ -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<N extends keyof LayerRegistry>(name: N): LayerProxyTarget<LayerRegistry, N>;
}
import ":package/:layer";

declare module ":package/:layer" {
import type {LayerRegistry} from ":package";

export function getLayer<N extends keyof LayerRegistry>(name: N): LayerTarget<LayerRegistry, N>;
// prettier-ignore
export interface LayerRegistry {
__TRANSPORT_DICTIONARY__
}
}
8 changes: 3 additions & 5 deletions src/main/offscreen.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -31,9 +31,7 @@ export const getOffscreens = (): OffscreenMap => {
return offscreens;
};

export const getOffscreen = <N extends Extract<keyof TransportDictionary, string>>(
name: N
): DeepAsyncProxy<TransportDictionary[N]> => {
export const getOffscreen = <N extends OffscreenName>(name: N): OffscreenProxyTarget<N> => {
const parameters = getOffscreens().get(name);

if (!parameters) {
Expand Down
9 changes: 3 additions & 6 deletions src/main/relay.ts
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -25,10 +25,7 @@ const getRelayOptionsMap = (): RelayOptionsMap => {
return relays;
};

export const getRelay = <N extends Extract<keyof TransportDictionary, string>>(
name: N,
params: ProxyRelayParams
): DeepAsyncProxy<TransportDictionary[N]> => {
export const getRelay = <N extends RelayName>(name: N, params: ProxyRelayParams): RelayProxyTarget<N> => {
const relays = getRelayOptionsMap();

RelayPermission.init(relays);
Expand Down
8 changes: 3 additions & 5 deletions src/main/service.ts
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -10,8 +10,6 @@ export const defineService = <T extends TransportType>(options: ServiceDefinitio
return options;
};

export const getService = <N extends Extract<keyof TransportDictionary, string>>(
name: N
): DeepAsyncProxy<TransportDictionary[N]> => {
export const getService = <N extends ServiceName>(name: N): ServiceProxyTarget<N> => {
return new ProxyService(name).get();
};
59 changes: 54 additions & 5 deletions src/message/MessageManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import {onMessage} from "@addon-core/browser";
import {
MessageBody,
MessageDictionary,
MessageError,
MessageGlobalKey,
MessageHandler,
MessageResult,
MessageResultEnvelopeProperty,
MessageSender,
MessageType,
} from "@typing/message";
Expand Down Expand Up @@ -48,7 +51,7 @@ export default class MessageManager<T extends MessageDictionary> {
private listener<K extends MessageType<T>>(
message: MessageBody<T, K>,
sender: MessageSender,
sendResponse: (response?: any) => void
sendResponse: (response?: MessageResult) => void
): boolean | void {
if (!message || typeof message !== "object" || !message.type) {
return;
Expand All @@ -64,20 +67,66 @@ export default class MessageManager<T extends MessageDictionary> {
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<TData>(payload: TData): MessageResult<TData> {
return {[MessageResultEnvelopeProperty]: true, ok: true, payload};
}

private failure(error: unknown): MessageResult<never> {
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<string, unknown>;
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};
}
}
84 changes: 84 additions & 0 deletions src/message/providers/Message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
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<MessageMap>;
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading
Loading