diff --git a/src/wampy.js b/src/wampy.js index 9e3fd0b..7d29e2f 100644 --- a/src/wampy.js +++ b/src/wampy.js @@ -838,10 +838,11 @@ class Wampy { } const messageOptions = { - ...helloCustomDetails, + ...this._extractCustomOptions(helloCustomDetails), ...this._wamp_features, ...(authid ? { authid, authmethods, authextra } : {}), }; + // WAMP SPEC: [HELLO, Realm|string, Details|dict] const encodedMessage = this._encode([WAMP_MSG_SPEC.HELLO, realm, messageOptions]); if (encodedMessage) { @@ -1927,12 +1928,12 @@ class Wampy { acknowledge: true, ...messageOptions, ...(ppt_scheme ? { ppt_scheme } : {}), - ...(ppt_scheme ? { ppt_scheme } : {}), ...(ppt_serializer ? { ppt_serializer } : {}), ...(ppt_cipher ? { ppt_cipher } : {}), ...(ppt_keyid ? { ppt_keyid } : {}), ...(exclude_me ? { exclude_me } : {}), ...(disclose_me ? { disclose_me } : {}), + ...(retain ? { retain } : {}), ...this._extractCustomOptions(advancedOptions) }; diff --git a/test/custom-attributes-data.js b/test/custom-attributes-data.js new file mode 100644 index 0000000..cb851e2 --- /dev/null +++ b/test/custom-attributes-data.js @@ -0,0 +1,188 @@ +import { WAMP_MSG_SPEC } from "../src/constants.js"; + +const customAttrsData = [ + // Test custom attributes in outgoing messages by checking what client sends + { + trigger: { + messageType: WAMP_MSG_SPEC.SUBSCRIBE, + condition: (msg) => { + const options = msg[2]; + // Verify custom attributes are present in SUBSCRIBE options + return ( + options._tracking_id === "sub_12345" && + options._priority === "high" && + options._custom_auth === "bearer_token" + ); + }, + }, + response: [WAMP_MSG_SPEC.SUBSCRIBED, "REQUEST_ID", 1001], + }, + + { + trigger: { + messageType: WAMP_MSG_SPEC.PUBLISH, + condition: (msg) => { + const options = msg[2]; + // Verify custom attributes are present in PUBLISH options + return ( + options._tracking_id === "pub_67890" && + options._priority === "urgent" && + options._routing_hint === "datacenter_west" + ); + }, + }, + response: [WAMP_MSG_SPEC.PUBLISHED, "REQUEST_ID", 2001], + }, + + { + trigger: { + messageType: WAMP_MSG_SPEC.CALL, + condition: (msg) => { + const options = msg[2]; + // Verify custom attributes are present in CALL options + return ( + options._request_id === "call_abcdef" && + options._timeout_override === 30000 && + options._auth_context === "admin_user" + ); + }, + }, + response: [ + WAMP_MSG_SPEC.RESULT, + "REQUEST_ID", + {}, + [{ argsList: ["custom", "call", "result"] }], + ], + }, + + { + trigger: { + messageType: WAMP_MSG_SPEC.CANCEL, + condition: (msg) => { + const options = msg[2]; + // Verify custom attributes are present in CANCEL options + return ( + options._cancel_reason === "user_requested" && + options._priority === "immediate" && + options.mode === "kill" + ); + }, + }, + // CANCEL doesn't get a direct response, but we can verify it was received + response: null, + }, + + { + trigger: { + messageType: WAMP_MSG_SPEC.REGISTER, + condition: (msg) => { + const options = msg[2]; + // Verify custom attributes are present in REGISTER options + return ( + options._handler_version === "2.1.0" && + options._load_balancing === "round_robin" && + options._timeout_hint === 5000 + ); + }, + }, + response: [WAMP_MSG_SPEC.REGISTERED, "REQUEST_ID", 3001], + }, + + // Test mixed standard + custom options + { + trigger: { + messageType: WAMP_MSG_SPEC.CALL, + condition: (msg) => { + const options = msg[2]; + // Verify both standard and custom attributes + return ( + options.timeout === 5000 && + options.disclose_me === true && + options._tracking_id === "mixed_123" && + options._priority === "high" + ); + }, + }, + response: [ + WAMP_MSG_SPEC.RESULT, + "REQUEST_ID", + {}, + [{ argsList: ["mixed", "options", "result"] }], + ], + }, + + { + trigger: { + messageType: WAMP_MSG_SPEC.PUBLISH, + condition: (msg) => { + const options = msg[2]; + // Verify both standard and custom attributes + return ( + options.exclude_me === false && + options.disclose_me === true && + options._event_type === "notification" && + options._source_system === "billing" + ); + }, + }, + response: [WAMP_MSG_SPEC.PUBLISHED, "REQUEST_ID", 4001], + }, + + { + trigger: { + messageType: WAMP_MSG_SPEC.SUBSCRIBE, + condition: (msg) => { + const options = msg[2]; + // Verify both standard and custom attributes for prefix subscription + return ( + options.match === "prefix" && + options._subscription_type === "live" && + options._filter_level === "debug" + ); + }, + }, + response: [WAMP_MSG_SPEC.SUBSCRIBED, "REQUEST_ID", 5002], + }, + + { + trigger: { + messageType: WAMP_MSG_SPEC.REGISTER, + condition: (msg) => { + const options = msg[2]; + // Verify both standard and custom attributes + return ( + options.invoke === "roundrobin" && + options._handler_type === "async" && + options._max_concurrency === 10 + ); + }, + }, + response: [WAMP_MSG_SPEC.REGISTERED, "REQUEST_ID", 6002], + }, + + // Test invalid custom attributes (should be filtered out) + { + trigger: { + messageType: WAMP_MSG_SPEC.CALL, + condition: (msg) => { + const options = msg[2]; + // Should only have valid custom attributes, invalid ones filtered + return ( + options._valid_attr === "valid" && + !options._x && // Too short - should be filtered + !options._X && // Uppercase - should be filtered + !options["_invalid-dash"] && // Contains dash - should be filtered + !options.no_underscore + ); // No underscore - should be filtered + }, + }, + response: [ + WAMP_MSG_SPEC.RESULT, + "REQUEST_ID", + {}, + [{ argsList: ["filtered", "result"] }], + ], + }, +]; + +export default customAttrsData; diff --git a/test/custom-attributes-test.js b/test/custom-attributes-test.js new file mode 100644 index 0000000..bf54e47 --- /dev/null +++ b/test/custom-attributes-test.js @@ -0,0 +1,273 @@ +import { expect } from "chai"; +import { Wampy } from "../src/wampy.js"; +import WebSocket from "./fake-ws-custom-attrs.js"; + +describe("Wampy.js Custom Attributes (WAMP spec 3.1)", function () { + this.timeout(1000); + + let wampy; + + before(function () { + globalThis.WebSocket = WebSocket; + }); + + beforeEach(function () { + wampy = new Wampy("ws://fake.server.url/ws/", { + realm: "AppRealm", + ws: WebSocket, + }); + }); + + afterEach(async function () { + if (wampy.getOpStatus().code === 0) { + await wampy.disconnect(); + } + }); + + describe("Client-to-Router Messages (Options/Details)", function () { + // WAMP SPEC: [HELLO, Realm|string, Details|dict] + it("HELLO.Details - supports custom attributes via helloCustomDetails", async function () { + const helloCustomDetails = { + _client_version: "7.2.0", + _tracking_session: "session_12345", + _environment: "production", + }; + + const wampyWithCustomHello = new Wampy("ws://fake.server.url/ws/", { + realm: "AppRealm", + ws: WebSocket, + helloCustomDetails, + }); + + // This should work - HELLO uses helloCustomDetails, not _extractCustomOptions + await wampyWithCustomHello.connect(); + expect(wampyWithCustomHello.getOpStatus().code).to.equal(0); + await wampyWithCustomHello.disconnect(); + }); + + // WAMP SPEC: [SUBSCRIBE, Request|id, Options|dict, Topic|uri] + it("SUBSCRIBE.Options - supports custom attributes", async function () { + await wampy.connect(); + + const customOptions = { + _tracking_id: "sub_12345", + _priority: "high", + _custom_auth: "bearer_token", + }; + + const result = await wampy.subscribe( + "test.topic", + function () {}, + customOptions + ); + expect(result).to.have.property("subscriptionId"); + }); + + // WAMP SPEC: [PUBLISH, Request|id, Options|dict, Topic|uri, Arguments|list, ArgumentsKw|dict] + it("PUBLISH.Options - supports custom attributes", async function () { + await wampy.connect(); + + const customOptions = { + _tracking_id: "pub_67890", + _priority: "urgent", + _routing_hint: "datacenter_west", + }; + + const result = await wampy.publish( + "test.topic", + "test payload", + customOptions + ); + expect(result).to.have.property("publicationId"); + }); + + // WAMP SPEC: [CALL, Request|id, Options|dict, Procedure|uri, Arguments|list, ArgumentsKw|dict] + it("CALL.Options - supports custom attributes", async function () { + await wampy.connect(); + + const customOptions = { + _request_id: "call_abcdef", + _timeout_override: 30000, + _auth_context: "admin_user", + }; + + const result = await wampy.call( + "test.procedure", + ["arg1"], + customOptions + ); + expect(result).to.have.property("argsList"); + }); + + // WAMP SPEC: [CANCEL, CALL.Request|id, Options|dict] + it("CANCEL.Options - supports custom attributes", async function () { + await wampy.connect(); + + // Start a call first + wampy.call("slow.procedure", []); + const reqId = wampy.getOpStatus().reqId; + + const customOptions = { + mode: "kill", + _cancel_reason: "user_requested", + _priority: "immediate", + }; + + const result = wampy.cancel(reqId, customOptions); + expect(result).to.be.true; + }); + + // WAMP SPEC: [REGISTER, Request|id, Options|dict, Procedure|uri] + it("REGISTER.Options - supports custom attributes", async function () { + await wampy.connect(); + + const customOptions = { + _handler_version: "2.1.0", + _load_balancing: "round_robin", + _timeout_hint: 5000, + }; + + const result = await wampy.register( + "test.rpc", + function () { + return { result: "ok" }; + }, + customOptions + ); + expect(result).to.have.property("registrationId"); + }); + + // WAMP SPEC: [YIELD, INVOCATION.Request|id, Options|dict, Arguments|list, ArgumentsKw|dict] + it("YIELD.Options - supports custom attributes in RPC handler", async function () { + await wampy.connect(); + + await wampy.register( + "custom.yield.rpc", + function ({ result_handler }) { + const customYieldOptions = { + _processing_time: 150, + _cache_hint: "no_cache", + _result_version: "1.0", + }; + + result_handler({ + argsList: ["custom result"], + options: customYieldOptions, + }); + } + ); + + // This will be tested when an INVOCATION comes in + expect(wampy.getOpStatus().code).to.equal(0); + }); + }); + + describe("Invalid Custom Attributes", function () { + it("ignores attributes that do not match _[a-z0-9_]{3,} pattern", async function () { + await wampy.connect(); + + const invalidOptions = { + _x: "too_short", // Too short (< 3 chars after _) + _X: "uppercase", // Uppercase letter + "_invalid-dash": "dash", // Contains dash + no_underscore: "none", // No leading underscore + _valid_attr: "valid", // This should work + }; + + // Should not throw error, just ignore invalid ones + const result = await wampy.call( + "test.procedure", + [], + invalidOptions + ); + expect(result).to.have.property("argsList"); + }); + + it("handles empty or null advanced options gracefully", async function () { + await wampy.connect(); + + // Should work with null/undefined options + const result1 = await wampy.call("test.procedure", [], null); + const result2 = await wampy.call("test.procedure", []); + const result3 = await wampy.call("test.procedure", []); + + expect(result1).to.have.property("argsList"); + expect(result2).to.have.property("argsList"); + expect(result3).to.have.property("argsList"); + }); + }); + + describe("Integration with Standard Options", function () { + it("combines custom attributes with standard options in CALL", async function () { + await wampy.connect(); + + const mixedOptions = { + timeout: 5000, // Standard option + disclose_me: true, // Standard option + _tracking_id: "mixed_123", // Custom attribute + _priority: "high", // Custom attribute + }; + + const result = await wampy.call( + "test.procedure", + ["data"], + mixedOptions + ); + expect(result).to.have.property("argsList"); + }); + + it("combines custom attributes with standard options in PUBLISH", async function () { + await wampy.connect(); + + const mixedOptions = { + exclude_me: false, // Standard option + disclose_me: true, // Standard option + _event_type: "notification", // Custom attribute + _source_system: "billing", // Custom attribute + }; + + const result = await wampy.publish( + "test.topic", + { data: "test" }, + mixedOptions + ); + expect(result).to.have.property("publicationId"); + }); + + it("combines custom attributes with standard options in SUBSCRIBE", async function () { + await wampy.connect(); + + const mixedOptions = { + match: "prefix", // Standard option + _subscription_type: "live", // Custom attribute + _filter_level: "debug", // Custom attribute + }; + + const result = await wampy.subscribe( + "test.prefix", + function () {}, + mixedOptions + ); + expect(result).to.have.property("subscriptionId"); + }); + + it("combines custom attributes with standard options in REGISTER", async function () { + await wampy.connect(); + + const mixedOptions = { + invoke: "roundrobin", // Standard option + _handler_type: "async", // Custom attribute + _max_concurrency: 10, // Custom attribute + }; + + const result = await wampy.register( + "test.rpc", + function () { + return { ok: true }; + }, + mixedOptions + ); + expect(result).to.have.property("registrationId"); + }); + }); +}); diff --git a/test/fake-ws-custom-attrs.js b/test/fake-ws-custom-attrs.js new file mode 100644 index 0000000..036e641 --- /dev/null +++ b/test/fake-ws-custom-attrs.js @@ -0,0 +1,150 @@ +import { WAMP_MSG_SPEC } from "../src/constants.js"; +import customAttrsData from "./custom-attributes-data.js"; + +let messageQueue = []; +const isDebugMode = false; + +const WebSocket = function (url, protocols) { + this.url = url; + this.protocol = protocols && protocols.length > 0 ? protocols[0] : undefined; + this.readyState = WebSocket.CONNECTING; + this.extensions = ""; + this.bufferedAmount = 0; + this.binaryType = "arraybuffer"; + + messageQueue = [...customAttrsData]; + + setTimeout(() => { + this.readyState = WebSocket.OPEN; + if (this.onopen) { + this.onopen(); + } + }, 1); +}; + +WebSocket.prototype.encode = function (data) { + return JSON.stringify(data); +}; + +WebSocket.prototype.decode = function (data) { + return JSON.parse(data); +}; + +WebSocket.prototype.close = function () { + this.readyState = WebSocket.CLOSED; + if (this.onclose) { + this.onclose(); + } +}; + +WebSocket.prototype.abort = function () { + this.readyState = WebSocket.CLOSED; + if (this.onerror) { + this.onerror(); + } +}; + +WebSocket.prototype.send = function (data) { + const receivedMessage = this.decode(data); + + if (isDebugMode) { + console.log("Mock received:", receivedMessage); + } + + const response = this.findResponse(receivedMessage); + + if (response) { + const responseData = this.processResponse(response, receivedMessage); + + if (responseData) { + setTimeout(() => { + if (isDebugMode) { + console.log("Mock sending:", responseData); + } + if (this.onmessage) { + this.onmessage({ data: this.encode(responseData) }); + } + }, response.delay || 1); + } + } +}; + +WebSocket.prototype.findResponse = function (receivedMessage) { + const [messageType, requestId] = receivedMessage; + + for (const item of messageQueue) { + if (item.trigger && item.trigger.messageType === messageType) { + if ( !item.trigger.condition || item.trigger.condition(receivedMessage) ) { + return item; + } else { + continue; + } + } + } + + const defaultResponses = { + [WAMP_MSG_SPEC.HELLO]: { + response: [ + WAMP_MSG_SPEC.WELCOME, + 12345, + { + agent: "Custom Attrs Test Router", + roles: { + broker: { features: {} }, + dealer: { features: { call_canceling: true } }, + }, + }, + ], + }, + [WAMP_MSG_SPEC.SUBSCRIBE]: { + response: [ + WAMP_MSG_SPEC.SUBSCRIBED, + requestId, + Math.floor(Math.random() * 10000), + ], + }, + [WAMP_MSG_SPEC.PUBLISH]: { + response: [ + WAMP_MSG_SPEC.PUBLISHED, + requestId, + Math.floor(Math.random() * 10000), + ], + }, + [WAMP_MSG_SPEC.CALL]: { + response: [WAMP_MSG_SPEC.RESULT, requestId, {}, ["result"]], + }, + [WAMP_MSG_SPEC.REGISTER]: { + response: [ + WAMP_MSG_SPEC.REGISTERED, + requestId, + Math.floor(Math.random() * 10000), + ], + }, + [WAMP_MSG_SPEC.GOODBYE]: { + response: [WAMP_MSG_SPEC.GOODBYE, {}, "wamp.close.normal"], + }, + }; + + return defaultResponses[messageType]; +}; + +WebSocket.prototype.processResponse = function (responseItem, receivedMessage) { + if (responseItem.response) { + const response = [...responseItem.response]; + + if (response[1] === "REQUEST_ID") { + response[1] = receivedMessage[1]; + } + + return response; + } + + return null; +}; + +WebSocket.CONNECTING = 0; +WebSocket.OPEN = 1; +WebSocket.CLOSING = 2; +WebSocket.CLOSED = 3; + +export default WebSocket;