diff --git a/package-lock.json b/package-lock.json index 28ae1a2cd..def984df6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,10 @@ "dependencies": { "@xmldom/xmldom": "^0.9.8", "bson": "^7.0.0", + "buffer": "^6.0.3", "cbor-js": "^0.1.0", "eventemitter3": "^5.0.1", - "pngparse": "^2.0.0", + "fast-png": "^7.0.1", "uuid": "^13.0.0", "ws": "^8.0.0" }, @@ -1965,6 +1966,12 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, "node_modules/@types/ssh2": { "version": "1.15.5", "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", @@ -2724,7 +2731,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -2773,6 +2779,31 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -2806,10 +2837,9 @@ } }, "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", "funding": [ { "type": "github", @@ -2827,7 +2857,7 @@ "license": "MIT", "dependencies": { "base64-js": "^1.3.1", - "ieee754": "^1.1.13" + "ieee754": "^1.2.1" } }, "node_modules/buildcheck": { @@ -3599,6 +3629,17 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-png": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-7.0.1.tgz", + "integrity": "sha512-aD5BELuxRrAPlRhb9V/z1PVMFJy3cUXqIvoxM3IQ+7Rku+T4cbXxWclZ47f1XwhViEl4n30TAN8JmvTJKKc2Dw==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^6.0.0", + "pako": "^2.1.0" + } + }, "node_modules/fast-uri": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.5.tgz", @@ -3906,7 +3947,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -3975,6 +4015,12 @@ "dev": true, "license": "ISC" }, + "node_modules/iobuffer": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-6.0.1.tgz", + "integrity": "sha512-SZWYkWNfjIXIBYSDpXDYIgshqtbOPsi4lviawAEceR1Kqk+sHDlcQjWrzNQsii80AyBY0q5c8HCTNjqo74ul+Q==", + "license": "MIT" + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -4516,6 +4562,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4640,11 +4692,6 @@ "pathe": "^1.1.2" } }, - "node_modules/pngparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pngparse/-/pngparse-2.0.1.tgz", - "integrity": "sha1-hoUt5N40n077HoUudSVlXlrF37g=" - }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", diff --git a/package.json b/package.json index efe22c77f..f49e21667 100644 --- a/package.json +++ b/package.json @@ -41,9 +41,10 @@ "dependencies": { "@xmldom/xmldom": "^0.9.8", "bson": "^7.0.0", + "buffer": "^6.0.3", "cbor-js": "^0.1.0", "eventemitter3": "^5.0.1", - "pngparse": "^2.0.0", + "fast-png": "^7.0.1", "uuid": "^13.0.0", "ws": "^8.0.0" }, diff --git a/src/core/SocketAdapter.ts b/src/core/SocketAdapter.ts index 6b22d6c14..54335153b 100644 --- a/src/core/SocketAdapter.ts +++ b/src/core/SocketAdapter.ts @@ -11,6 +11,7 @@ import { } from "../types/protocol.js"; import { deserialize } from "bson"; import type { WebSocket as WsWebSocket } from "ws"; +import decompressPng from "../util/decompressPng.js"; export type RequiredSocketInterface = Pick< WebSocket | RTCDataChannel | WsWebSocket, @@ -157,27 +158,11 @@ export default class SocketAdapter { callback: (message: RosbridgeMessage) => void, ) { if (isRosbridgePngMessage(message)) { - const pngCallback = (data: unknown) => { - if (isRosbridgeMessage(data)) { - callback(data); - } else { - throw new Error("Decompressed PNG data was invalid!"); - } - }; - // If in Node.js.. - if (typeof window === "undefined") { - import("../util/decompressPng.js") - .then(({ default: decompressPng }) => { - decompressPng(message.data, pngCallback); - }) - .catch(console.error); + const decoded = decompressPng(message.data); + if (isRosbridgeMessage(decoded)) { + callback(decoded); } else { - // if in browser.. - import("../util/shim/decompressPng.js") - .then(({ default: decompressPng }) => { - decompressPng(message.data, pngCallback); - }) - .catch(console.error); + throw new Error("Received invalid message in PNG data!"); } } else { callback(message); diff --git a/src/types/pngparse.d.ts b/src/types/pngparse.d.ts deleted file mode 100644 index 8820ad2dd..000000000 --- a/src/types/pngparse.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare module "pngparse" { - function parse(data: Buffer, cb: (err: string, data: unknown) => void); -} diff --git a/src/util/decompressPng.ts b/src/util/decompressPng.ts index 8a6efec25..80ea5d267 100644 --- a/src/util/decompressPng.ts +++ b/src/util/decompressPng.ts @@ -3,7 +3,10 @@ * @author Ramon Wijnands - rayman747@hotmail.com */ -import pngparse from "pngparse"; +import { decode } from "fast-png"; +import { Buffer } from "buffer"; + +const textDecoder = new TextDecoder(); /** * If a message was compressed as a PNG image (a compression hack since @@ -11,20 +14,23 @@ import pngparse from "pngparse"; * the "image" as a Base64 string. * * @param data - An object containing the PNG data. - * @param callback - Function with the following params: */ -export default function decompressPng( - data: string, - callback: (data: unknown) => void, -) { +export default function decompressPng(data: string): unknown { const buffer = Buffer.from(data, "base64"); - pngparse.parse(buffer, function (err, data) { - if (err || !(data instanceof Object) || !("data" in data)) { - throw new Error("Cannot process PNG encoded message "); - } else { - const jsonData = String(data.data); - callback(JSON.parse(jsonData)); - } - }); + const decoded = tryDecodeBuffer(buffer); + + try { + return JSON.parse(textDecoder.decode(decoded.data)); + } catch (error) { + throw new Error("Error parsing PNG JSON contents", { cause: error }); + } +} + +function tryDecodeBuffer(buffer: Buffer) { + try { + return decode(buffer); + } catch (error) { + throw new Error("Error decoding buffer", { cause: error }); + } } diff --git a/src/util/shim/decompressPng.ts b/src/util/shim/decompressPng.ts deleted file mode 100644 index 271c60091..000000000 --- a/src/util/shim/decompressPng.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * @fileOverview - * @author Graeme Yeates - github.com/megawac - */ - -/** - * If a message was compressed as a PNG image (a compression hack since - * gzipping over WebSockets * is not supported yet), this function places the - * "image" in a canvas element then decodes the * "image" as a Base64 string. - * - * @param data - A string containing the PNG data. - * @param callback - Function with the following params: - */ -export default function decompressPng( - data: string, - callback: (data: unknown) => void, -) { - // Uncompresses the data before sending it through (use image/canvas to do so). - const image = new Image(); - // When the image loads, extracts the raw data (JSON message). - image.onload = function () { - // Creates a local canvas to draw on. - const canvas = document.createElement("canvas"); - const context = canvas.getContext("2d"); - - if (!context) { - throw new Error("Failed to create Canvas context!"); - } - - // Sets width and height. - canvas.width = image.width; - canvas.height = image.height; - - // Prevents anti-aliasing and loosing data - context.imageSmoothingEnabled = false; - - // Puts the data into the image. - context.drawImage(image, 0, 0); - // Grabs the raw, uncompressed data. - const imageData = context.getImageData( - 0, - 0, - image.width, - image.height, - ).data; - - // Constructs the JSON. - let jsonData = ""; - for (let i = 0; i < imageData.length; i += 4) { - // RGB - jsonData += String.fromCharCode( - imageData[i], - imageData[i + 1], - imageData[i + 2], - ); - } - callback(JSON.parse(jsonData)); - }; - // Sends the image data to load. - image.src = `data:image/png;base64,${data}`; -} diff --git a/test/examples/topic-listener.example.ts b/test/examples/topic-listener.example.ts index 3ae5e20ed..7defe06e6 100644 --- a/test/examples/topic-listener.example.ts +++ b/test/examples/topic-listener.example.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import * as ROSLIB from "../../src/RosLib.js"; const ros = new ROSLIB.Ros({ @@ -45,4 +45,22 @@ describe("Topics Example", function () { topic.on("unsubscribe", done); })); -}, 1000); + + // TODO: reenable when rosbridge is fixed in ROS 2 + it.skip("Listening to a PNG-compressed topic", async () => { + const topic = ros.Topic<{ data: string }>({ + name: "/png_test", + messageType: "std_msgs/String", + compression: "png", + }); + const callback = vi.fn(); + topic.subscribe(callback); + + topic.publish({ data: "some message that will be PNG-compressed" }); + await vi.waitFor(() => { + expect(callback).toHaveBeenCalledWith({ + data: "some message that will be PNG-compressed", + }); + }); + }); +});