diff --git a/karma.conf.js b/karma.conf.js index c7840f40..fdea0649 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -24,6 +24,10 @@ module.exports = function (config) { pattern: "dist/**/*.@(mjs|js)", included: false, }, + { + pattern: "node_modules/comlink/dist/esm/**/*.@(mjs|js)", + type: "module", + }, { pattern: "tests/*.test.js", type: "module", diff --git a/package.json b/package.json index 689d80b8..9b0b1167 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@rollup/plugin-typescript": "11.0.0", "chai": "^4.3.7", "conditional-type-checks": "1.0.6", + "comlink": "4.3.0", "husky": "8.0.3", "karma": "6.4.1", "karma-chai": "0.1.0", diff --git a/src/comlink.ts b/src/comlink.ts index 8896b71d..cd811d4a 100644 --- a/src/comlink.ts +++ b/src/comlink.ts @@ -8,10 +8,13 @@ import { Endpoint, EventSource, Message, + LegacyMessageType, MessageType, PostMessageWithOrigin, WireValue, + LegacyWireValueType, WireValueType, + MessageTypeMap, } from "./protocol"; export type { Endpoint }; @@ -305,36 +308,45 @@ export function expose( } const { id, type, path } = { path: [] as string[], - ...(ev.data as Message), + ...(ev.data as Message | WireValue), }; + + const isLegacy = typeof type === "number"; const argumentList = (ev.data.argumentList || []).map(fromWireValue); + let returnValue; try { const parent = path.slice(0, -1).reduce((obj, prop) => obj[prop], obj); const rawValue = path.reduce((obj, prop) => obj[prop], obj); switch (type) { + case LegacyMessageType.GET: case MessageType.GET: { returnValue = rawValue; } break; + case LegacyMessageType.SET: case MessageType.SET: { - parent[path.slice(-1)[0]] = fromWireValue(ev.data.value); + const value = fromWireValue(ev.data.value); + parent[path.slice(-1)[0]] = value; returnValue = true; } break; + case LegacyMessageType.APPLY: case MessageType.APPLY: { returnValue = rawValue.apply(parent, argumentList); } break; + case LegacyMessageType.CONSTRUCT: case MessageType.CONSTRUCT: { const value = new rawValue(...argumentList); returnValue = proxy(value); } break; + case LegacyMessageType.ENDPOINT: case MessageType.ENDPOINT: { const { port1, port2 } = new MessageChannel(); @@ -342,6 +354,7 @@ export function expose( returnValue = transfer(port1, [port1]); } break; + case LegacyMessageType.RELEASE: case MessageType.RELEASE: { returnValue = undefined; @@ -358,7 +371,7 @@ export function expose( return { value, [throwMarker]: 0 }; }) .then((returnValue) => { - const [wireValue, transferables] = toWireValue(returnValue); + const [wireValue, transferables] = toWireValue(returnValue, isLegacy); ep.postMessage({ ...wireValue, id }, transferables); if (type === MessageType.RELEASE) { // detach and deactive after sending release response above. @@ -371,10 +384,13 @@ export function expose( }) .catch((error) => { // Send Serialization Error To Caller - const [wireValue, transferables] = toWireValue({ - value: new TypeError("Unserializable return value"), - [throwMarker]: 0, - }); + const [wireValue, transferables] = toWireValue( + { + value: new TypeError("Unserializable return value"), + [throwMarker]: 0, + }, + isLegacy + ); ep.postMessage({ ...wireValue, id }, transferables); }); } as any); @@ -387,11 +403,38 @@ function isMessagePort(endpoint: Endpoint): endpoint is MessagePort { return endpoint.constructor.name === "MessagePort"; } +function isEndpoint(endpoint: object): endpoint is Endpoint { + return ( + "postMessage" in endpoint && + "addEventListener" in endpoint && + "removeEventListener" in endpoint + ); +} + +function markLegacyPort(maybePort: unknown) { + if (isObject(maybePort)) { + if (isEndpoint(maybePort) && isMessagePort(maybePort)) { + legacyEndpoints.add(maybePort); + } else { + for (const prop in maybePort) { + markLegacyPort(maybePort[prop as keyof typeof maybePort]); + } + } + } +} + function closeEndPoint(endpoint: Endpoint) { if (isMessagePort(endpoint)) endpoint.close(); } -export function wrap(ep: Endpoint, target?: any): Remote { +export function wrap( + ep: Endpoint, + target?: any, + legacy: boolean = false +): Remote { + if (legacy) { + legacyEndpoints.add(ep); + } return createProxy(ep, [], target) as any; } @@ -401,9 +444,9 @@ function throwIfProxyReleased(isReleased: boolean) { } } -function releaseEndpoint(ep: Endpoint) { +function releaseEndpoint(ep: Endpoint, legacy: boolean) { return requestResponseMessage(ep, { - type: MessageType.RELEASE, + type: msgType(MessageType.RELEASE, legacy), }).then(() => { closeEndPoint(ep); }); @@ -420,6 +463,7 @@ interface FinalizationRegistry { } declare var FinalizationRegistry: FinalizationRegistry; +const legacyEndpoints = new WeakSet(); const proxyCounter = new WeakMap(); const proxyFinalizers = "FinalizationRegistry" in globalThis && @@ -427,7 +471,8 @@ const proxyFinalizers = const newCount = (proxyCounter.get(ep) || 0) - 1; proxyCounter.set(ep, newCount); if (newCount === 0) { - releaseEndpoint(ep); + releaseEndpoint(ep, legacyEndpoints.has(ep)); + legacyEndpoints.delete(ep); } }); @@ -451,13 +496,14 @@ function createProxy( target: object = function () {} ): Remote { let isProxyReleased = false; + const isLegacy = legacyEndpoints.has(ep); const proxy = new Proxy(target, { get(_target, prop) { throwIfProxyReleased(isProxyReleased); if (prop === releaseProxy) { return () => { unregisterProxy(proxy); - releaseEndpoint(ep); + releaseEndpoint(ep, isLegacy); isProxyReleased = true; }; } @@ -466,7 +512,7 @@ function createProxy( return { then: () => proxy }; } const r = requestResponseMessage(ep, { - type: MessageType.GET, + type: msgType(MessageType.GET, isLegacy), path: path.map((p) => p.toString()), }).then(fromWireValue); return r.then.bind(r); @@ -477,11 +523,11 @@ function createProxy( throwIfProxyReleased(isProxyReleased); // FIXME: ES6 Proxy Handler `set` methods are supposed to return a // boolean. To show good will, we return true asynchronously ¯\_(ツ)_/¯ - const [value, transferables] = toWireValue(rawValue); + const [value, transferables] = toWireValue(rawValue, isLegacy); return requestResponseMessage( ep, { - type: MessageType.SET, + type: msgType(MessageType.SET, isLegacy), path: [...path, prop].map((p) => p.toString()), value, }, @@ -493,18 +539,21 @@ function createProxy( const last = path[path.length - 1]; if ((last as any) === createEndpoint) { return requestResponseMessage(ep, { - type: MessageType.ENDPOINT, + type: msgType(MessageType.ENDPOINT, isLegacy), }).then(fromWireValue); } // We just pretend that `bind()` didn’t happen. if (last === "bind") { return createProxy(ep, path.slice(0, -1)); } - const [argumentList, transferables] = processArguments(rawArgumentList); + const [argumentList, transferables] = processArguments( + rawArgumentList, + isLegacy + ); return requestResponseMessage( ep, { - type: MessageType.APPLY, + type: msgType(MessageType.APPLY, isLegacy), path: path.map((p) => p.toString()), argumentList, }, @@ -513,11 +562,14 @@ function createProxy( }, construct(_target, rawArgumentList) { throwIfProxyReleased(isProxyReleased); - const [argumentList, transferables] = processArguments(rawArgumentList); + const [argumentList, transferables] = processArguments( + rawArgumentList, + isLegacy + ); return requestResponseMessage( ep, { - type: MessageType.CONSTRUCT, + type: msgType(MessageType.CONSTRUCT, isLegacy), path: path.map((p) => p.toString()), argumentList, }, @@ -533,8 +585,11 @@ function myFlat(arr: (T | T[])[]): T[] { return Array.prototype.concat.apply([], arr); } -function processArguments(argumentList: any[]): [WireValue[], Transferable[]] { - const processed = argumentList.map(toWireValue); +function processArguments( + argumentList: any[], + isLegacy: boolean +): [WireValue[], Transferable[]] { + const processed = argumentList.map((arg) => toWireValue(arg, isLegacy)); return [processed.map((v) => v[0]), myFlat(processed.map((v) => v[1]))]; } @@ -561,13 +616,16 @@ export function windowEndpoint( }; } -function toWireValue(value: any): [WireValue, Transferable[]] { +function toWireValue( + value: any, + isLegacy: boolean +): [WireValue, Transferable[]] { for (const [name, handler] of transferHandlers) { if (handler.canHandle(value)) { const [serializedValue, transferables] = handler.serialize(value); return [ { - type: WireValueType.HANDLER, + type: isLegacy ? LegacyWireValueType.HANDLER : WireValueType.HANDLER, name, value: serializedValue, }, @@ -577,7 +635,7 @@ function toWireValue(value: any): [WireValue, Transferable[]] { } return [ { - type: WireValueType.RAW, + type: isLegacy ? LegacyWireValueType.RAW : WireValueType.RAW, value, }, transferCache.get(value) || [], @@ -586,8 +644,12 @@ function toWireValue(value: any): [WireValue, Transferable[]] { function fromWireValue(value: WireValue): any { switch (value.type) { + case LegacyWireValueType.HANDLER: + markLegacyPort(value.value); case WireValueType.HANDLER: return transferHandlers.get(value.name)!.deserialize(value.value); + case LegacyWireValueType.RAW: + markLegacyPort(value.value); case WireValueType.RAW: return value.value; } @@ -614,6 +676,34 @@ function requestResponseMessage( }); } +function msgType( + type: T, + isLegacy: boolean +): MessageTypeMap[T] | T { + if (!isLegacy) { + return type; + } + + function mapMessageTypeToLegacyType(type: MessageType): LegacyMessageType { + switch (type) { + case MessageType.GET: + return LegacyMessageType.GET; + case MessageType.SET: + return LegacyMessageType.SET; + case MessageType.APPLY: + return LegacyMessageType.APPLY; + case MessageType.CONSTRUCT: + return LegacyMessageType.CONSTRUCT; + case MessageType.ENDPOINT: + return LegacyMessageType.ENDPOINT; + case MessageType.RELEASE: + return LegacyMessageType.RELEASE; + } + } + + return mapMessageTypeToLegacyType(type) as MessageTypeMap[T]; +} + function generateUUID(): string { return new Array(4) .fill(0) diff --git a/src/protocol.ts b/src/protocol.ts index d3eb2692..a32bcd71 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -32,6 +32,11 @@ export interface Endpoint extends EventSource { start?: () => void; } +export const enum LegacyWireValueType { + RAW, + HANDLER = 3, +} + export const enum WireValueType { RAW = "RAW", PROXY = "PROXY", @@ -41,13 +46,13 @@ export const enum WireValueType { export interface RawWireValue { id?: string; - type: WireValueType.RAW; + type: WireValueType.RAW | LegacyWireValueType.RAW; value: {}; } export interface HandlerWireValue { id?: string; - type: WireValueType.HANDLER; + type: WireValueType.HANDLER | LegacyWireValueType.HANDLER; name: string; value: unknown; } @@ -56,6 +61,24 @@ export type WireValue = RawWireValue | HandlerWireValue; export type MessageID = string; +export const enum LegacyMessageType { + GET, + SET, + APPLY, + CONSTRUCT, + ENDPOINT, + RELEASE, +} + +export interface MessageTypeMap { + [MessageType.GET]: LegacyMessageType.GET; + [MessageType.SET]: LegacyMessageType.SET; + [MessageType.APPLY]: LegacyMessageType.APPLY; + [MessageType.CONSTRUCT]: LegacyMessageType.CONSTRUCT; + [MessageType.ENDPOINT]: LegacyMessageType.ENDPOINT; + [MessageType.RELEASE]: LegacyMessageType.RELEASE; +} + export const enum MessageType { GET = "GET", SET = "SET", @@ -67,39 +90,39 @@ export const enum MessageType { export interface GetMessage { id?: MessageID; - type: MessageType.GET; + type: MessageType.GET | LegacyMessageType.GET; path: string[]; } export interface SetMessage { id?: MessageID; - type: MessageType.SET; + type: MessageType.SET | LegacyMessageType.SET; path: string[]; value: WireValue; } export interface ApplyMessage { id?: MessageID; - type: MessageType.APPLY; + type: MessageType.APPLY | LegacyMessageType.APPLY; path: string[]; argumentList: WireValue[]; } export interface ConstructMessage { id?: MessageID; - type: MessageType.CONSTRUCT; + type: MessageType.CONSTRUCT | LegacyMessageType.CONSTRUCT; path: string[]; argumentList: WireValue[]; } export interface EndpointMessage { id?: MessageID; - type: MessageType.ENDPOINT; + type: MessageType.ENDPOINT | LegacyMessageType.ENDPOINT; } export interface ReleaseMessage { id?: MessageID; - type: MessageType.RELEASE; + type: MessageType.RELEASE | LegacyMessageType.RELEASE; } export type Message = diff --git a/tests/fixtures/v430comlink.html b/tests/fixtures/v430comlink.html new file mode 100644 index 00000000..2614f58c --- /dev/null +++ b/tests/fixtures/v430comlink.html @@ -0,0 +1,46 @@ + diff --git a/tests/v430comlink.test.js b/tests/v430comlink.test.js new file mode 100644 index 00000000..06cc398f --- /dev/null +++ b/tests/v430comlink.test.js @@ -0,0 +1,129 @@ +/** + * Copyright 2017 Google Inc. All Rights Reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// comlink 4.3.0 +import * as Comlink from "/base/node_modules/comlink/dist/esm/comlink.mjs"; + +describe("Comlink across versions (4.3.0 to latest main)", function () { + let oldTruncateThreshold; + let notcalled; + let error; + let iframePort; + + function unhandledrejectionCallback(ev) { + notcalled = false; + error = ev.reason; + } + + function assertNoUnhandledRejection() { + expect(error).to.equal(""); + expect(notcalled).to.equal(true); + } + + beforeEach(function () { + notcalled = true; + error = ""; + window.addEventListener("unhandledrejection", unhandledrejectionCallback); + + oldTruncateThreshold = chai.config.truncateThreshold; + chai.config.truncateThreshold = 0; + this.ifr = document.createElement("iframe"); + this.ifr.sandbox.add("allow-scripts", "allow-same-origin"); + this.ifr.src = "/base/tests/fixtures/v430comlink.html"; + document.body.appendChild(this.ifr); + return new Promise((resolve) => (this.ifr.onload = resolve)).then(() => { + const iframeChannel = new MessageChannel(); + iframePort = iframeChannel.port1; + this.ifr.contentWindow.postMessage(iframeChannel.port2, "*", [ + iframeChannel.port2, + ]); + }); + }); + + afterEach(function () { + window.removeEventListener( + "unhandledrejection", + unhandledrejectionCallback + ); + this.ifr.remove(); + chai.config.truncateThreshold = oldTruncateThreshold; + }); + + it("can send a proxy and call a function", async function () { + const iframe = iframePort; + const latest = Comlink.wrap(iframe); + + expect(await latest.acceptProxy(Comlink.proxy(() => 3))).to.equal(4); + + assertNoUnhandledRejection(); + }); + + it("can send port that get wrapped and transfer by argument", async function () { + const iframe = iframePort; + const latest = Comlink.wrap(iframe); + let maybecalled = false; + class Test { + maybe() { + maybecalled = true; + } + } + const channel = new MessageChannel(); + Comlink.expose(new Test(), channel.port1); + + await latest.callme( + Comlink.transfer({ foo: channel.port2 }, [channel.port2]) + ); + + expect(maybecalled).to.equal(true); + + assertNoUnhandledRejection(); + }); + + it("can send port that get wrapped and transfer by a setter", async function () { + // Verify that it also works with a setter + const iframe = iframePort; + const latest = Comlink.wrap(iframe); + const channel2 = new MessageChannel(); + let risecalled = false; + Comlink.expose( + { + rise() { + risecalled = true; + }, + }, + channel2.port1 + ); + + latest.iwannabeaport = Comlink.transfer(channel2.port2, [channel2.port2]); + await latest.wrapWannaBeAndCall(); + + expect(risecalled).to.equal(true); + + assertNoUnhandledRejection(); + }); + + it("works with custom handlers", async function () { + const iframe = iframePort; + const proxy = Comlink.wrap(iframe); + + Comlink.transferHandlers.set("pingpong", { + canHandle: (obj) => obj === "ping" || obj === "pong", + serialize: (obj) => [obj, []], + deserialize: (obj) => obj, + }); + + expect(await proxy.pong("ping")).to.equal("pong"); + + assertNoUnhandledRejection(); + }); +});