From 41cf9283c8f3e55fa90d819308405765fc3f678f Mon Sep 17 00:00:00 2001 From: Alberto Ricart Date: Thu, 13 Feb 2025 17:18:02 -0600 Subject: [PATCH 1/3] opts string --- opts/deno.json | 18 +++++ opts/mod.ts | 176 +++++++++++++++++++++++++++++++++++++++++++++++ opts/mod_test.ts | 38 ++++++++++ 3 files changed, 232 insertions(+) create mode 100644 opts/deno.json create mode 100644 opts/mod.ts create mode 100644 opts/mod_test.ts diff --git a/opts/deno.json b/opts/deno.json new file mode 100644 index 0000000..3ad83d4 --- /dev/null +++ b/opts/deno.json @@ -0,0 +1,18 @@ +{ + "name": "@synadiaorbit/opts", + "version": "1.0.0-1", + "exports": { + ".": "./mod.ts" + }, + "publish": { + "exclude": ["./examples"] + }, + "tasks": { + "clean": "rm -Rf ./coverage", + "test": "deno task clean & deno test --allow-all --parallel --reload --quiet --coverage=coverage", + "cover": "deno coverage ./coverage --lcov > ./coverage/out.lcov && genhtml -o ./coverage/html ./coverage/out.lcov && open ./coverage/html/index.html" + }, + "imports": { + "@nats-io/nats-core": "jsr:@nats-io/nats-core@^3.0.0-50" + } +} diff --git a/opts/mod.ts b/opts/mod.ts new file mode 100644 index 0000000..33d7117 --- /dev/null +++ b/opts/mod.ts @@ -0,0 +1,176 @@ +/* + * Copyright 2025 Synadia Communications, Inc + * 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. + */ + +import type { ConnectionOptions } from "@nats-io/nats-core"; +import { wsconnect } from "@nats-io/nats-core"; + +type ConnectionProperty = Omit< + keyof ConnectionOptions & { creds: string }, + "authenticator" | "reconnectDelayHandler" +>; + +const booleanProps = [ + "debug", + "ignoreAuthErrorAbort", + "ignoreClusterUpdates", + "noAsyncTraces", + "noEcho", + "noRandomize", + "pedantic", + "reconnect", + "resolve", + "tls", + "verbose", + "waitOnFirstConnect", +] as const; + +const numberProps = [ + "maxPingOut", + "maxReconnectAttempts", + "pingInterval", + "reconnectJitter", + "reconnectJitterTLS", + "reconnectTimeWait", + "port", + "timeout", +] as const; + +const stringProps = [ + "name", + "pass", + "token", + "user", + "inboxPrefix", +] as const; +const arrayProps = ["servers"] as const; + +export function encode(opts: Partial): string { + const u = new URL("http://nothing:80"); + arrayProps.forEach((n) => { + let v = opts[n] as string[] | string; + if (!Array.isArray(v)) { + v = [v]; + } + if (v) { + v.forEach((s) => { + u.searchParams.append(n, encodeURIComponent(s)); + }); + } + }); + + stringProps.forEach((n) => { + const v = opts[n] as string | undefined; + if (v) { + u.searchParams.append(n, encodeURIComponent(v)); + } + }); + + numberProps.forEach((n) => { + const v = opts[n] as number | undefined; + if (typeof v === "number") { + u.searchParams.append(n, v.toString()); + } + }); + + booleanProps.forEach((n) => { + const v = opts[n] as boolean | undefined; + if (typeof v === "boolean") { + u.searchParams.append(n, v ? "true" : "false"); + } + }); + + return `nats-opts:${u.searchParams.toString()}`; +} + +export function parse( + str = "", +): Promise> { + if (str === "") { + return Promise.resolve({ servers: "127.0.0.1:4222" }); + } + + // some implementations of URL.parse() only handle "http/s" rip the protocol + // url parsing will inject host/port defaults based on the protocol... + // maybe we just ignore that and explicitly look for + // server= + if (!str.startsWith("nats-opts:")) { + return Promise.reject( + new Error( + "Invalid connection string. Must start with encoded-opts:", + ), + ); + } + + const u = URL.parse(`http://nothing:80?${str.substring(10)}`); + if (u === null) { + return Promise.reject(new Error(`failed to parse '${str}'`)); + } + + const opts: Record = {}; + + function configBoolean( + n: string, + ) { + const v = u?.searchParams.get(n) || null; + if (v !== null) { + opts[n] = v === "true"; + } + } + + function configNumber( + n: string, + ) { + const v = u?.searchParams.get(n) || null; + if (v !== null) { + opts[n] = parseInt(v); + } + } + + function configStringArray(n: string, defaultValue = "") { + let a = u?.searchParams.getAll(n); + a = a?.map((s) => decodeURIComponent(s)); + if (defaultValue && a === null) { + a = [defaultValue]; + } + if (a) { + opts[n] = a; + } + } + + function configString(n: string) { + const v = u?.searchParams.get(n); + if (v) { + opts[n] = decodeURIComponent(v); + } + } + + booleanProps.forEach((n) => { + configBoolean(n); + }); + + stringProps.forEach((n) => { + configString(n); + }); + + numberProps.forEach((n) => { + configNumber(n); + }); + + arrayProps.forEach((n) => { + configStringArray(n); + }); + + return Promise.resolve(opts); +} diff --git a/opts/mod_test.ts b/opts/mod_test.ts new file mode 100644 index 0000000..07852eb --- /dev/null +++ b/opts/mod_test.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2025 Synadia Communications, Inc + * 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. + */ +import type { ConnectionOptions } from "@nats-io/nats-core"; +import { wsconnect } from "@nats-io/nats-core"; +import { encode, parse } from "./mod.ts"; + +Deno.test("basics", async () => { + const opts: Partial = {}; + opts.debug = true; + opts.servers = ["wss://demo.nats.io:8443"]; + opts.name = "me"; + opts.noEcho = true; + opts.reconnect = false; + opts.timeout = 10_000; + + const u = encode(opts); + console.log(u); + + const opts2 = await parse(u); + console.log(opts2); + + const nc = await wsconnect(opts2); + console.log(nc.getServer()); + await nc.flush(); + await nc.close(); +}); From 7c79930c53501a8cf92ab104a1fa8b55bcec4236 Mon Sep 17 00:00:00 2001 From: Alberto Ricart Date: Tue, 18 Feb 2025 15:42:36 -0600 Subject: [PATCH 2/3] Add TLS and URL parsing support to connection options Improved encode and parse functions to handle TLS options and extended URL parsing capabilities. Refactored property encoding and decoding logic for better maintainability. Introduced test cases to ensure correct handling of TLS and URL parsing features. --- opts/mod.ts | 241 +++++++++++++++++++++++++++++++---------------- opts/mod_test.ts | 23 +++++ 2 files changed, 184 insertions(+), 80 deletions(-) diff --git a/opts/mod.ts b/opts/mod.ts index 33d7117..c4302b5 100644 --- a/opts/mod.ts +++ b/opts/mod.ts @@ -14,7 +14,6 @@ */ import type { ConnectionOptions } from "@nats-io/nats-core"; -import { wsconnect } from "@nats-io/nats-core"; type ConnectionProperty = Omit< keyof ConnectionOptions & { creds: string }, @@ -31,9 +30,9 @@ const booleanProps = [ "pedantic", "reconnect", "resolve", - "tls", "verbose", "waitOnFirstConnect", + "handshakeFirst", ] as const; const numberProps = [ @@ -43,21 +42,88 @@ const numberProps = [ "reconnectJitter", "reconnectJitterTLS", "reconnectTimeWait", - "port", "timeout", ] as const; const stringProps = [ "name", - "pass", - "token", - "user", "inboxPrefix", ] as const; const arrayProps = ["servers"] as const; +const tlsBooleanProps = [ + "handshakeFirst", +]; + +const tlsStringProps = [ + "cert", + "certFile", + "ca", + "caFile", + "key", + "keyFile", +]; + +function encodeStringPropsFn(src: Record, target: URL) { + return (n: string) => { + const v = src[n] as string | undefined; + if (v) { + target.searchParams.append(n, encodeURIComponent(v)); + } + }; +} + +function encodeBooleanPropsFn(src: Record, target: URL) { + return (n: string) => { + const v = src[n] as boolean | undefined; + if (typeof v === "boolean") { + target.searchParams.append(n, v ? "true" : "false"); + } + }; +} + +function encodeNumberPropsFn(src: Record, target: URL) { + return (n: string) => { + const v = src[n] as number | undefined; + if (typeof v === "number") { + target.searchParams.append(n, v.toString()); + } + }; +} + export function encode(opts: Partial): string { - const u = new URL("http://nothing:80"); + if (typeof opts?.servers === "string") { + opts.servers = [opts.servers]; + } + let u: URL; + if (opts?.servers?.length) { + if ( + opts.servers[0].startsWith("wss://") || + opts.servers[0].startsWith("ws://") + ) { + u = new URL(opts.servers[0]); + } else { + u = new URL(`nats://${opts.servers[0]}`); + } + // remove this server from the list as it is part of the URL + opts.servers = opts.servers.slice(1); + } else { + u = new URL("nats://127.0.0.1:4222"); + } + if (opts.port) { + u.port = `${opts.port}`; + } + + if (opts.user) { + u.username = opts.user; + } + if (opts.pass) { + u.password = opts.pass; + } + if (opts.token) { + u.username = opts.token; + } + arrayProps.forEach((n) => { let v = opts[n] as string[] | string; if (!Array.isArray(v)) { @@ -70,28 +136,69 @@ export function encode(opts: Partial): string { } }); - stringProps.forEach((n) => { - const v = opts[n] as string | undefined; - if (v) { - u.searchParams.append(n, encodeURIComponent(v)); + stringProps.forEach(encodeStringPropsFn(opts, u)); + numberProps.forEach(encodeNumberPropsFn(opts, u)); + booleanProps.forEach(encodeBooleanPropsFn(opts, u)); + + if (opts.tls) { + console.log(u); + if (u.protocol !== "nats:") { + throw new Error("tls options can only be used with nats:// urls"); } - }); + u.protocol = opts.tls.handshakeFirst ? "natss:" : "tls:"; + tlsStringProps.forEach( + encodeStringPropsFn(opts.tls as Record, u), + ); + } - numberProps.forEach((n) => { - const v = opts[n] as number | undefined; - if (typeof v === "number") { - u.searchParams.append(n, v.toString()); + return u.toString(); +} + +type Values = boolean | number | string | string[]; +type Obj = Record; +type Config = Record; + +function configBooleanFn(u: URL, target: Config) { + return (n: string) => { + const v = u?.searchParams.get(n) || null; + if (v !== null) { + target[n] = v === "true"; } - }); + }; +} - booleanProps.forEach((n) => { - const v = opts[n] as boolean | undefined; - if (typeof v === "boolean") { - u.searchParams.append(n, v ? "true" : "false"); +function configNumberFn(u: URL, target: Config) { + return ( + n: string, + ) => { + const v = u?.searchParams.get(n) || null; + if (v !== null) { + target[n] = parseInt(v); } - }); + }; +} - return `nats-opts:${u.searchParams.toString()}`; +function configStringFn(u: URL, target: Config) { + return (n: string) => { + const v = u?.searchParams.get(n); + if (v) { + target[n] = decodeURIComponent(v); + } + }; +} + +function configStringArrayFn(u: URL, target: Config) { + return (n: string) => { + let a = u?.searchParams.getAll(n); + a = a?.map((s) => decodeURIComponent(s)); + if (!target[n]) { + target[n] = a; + } else { + const aa = target[n] as string[]; + aa.push(...a); + target[n] = aa; + } + }; } export function parse( @@ -101,76 +208,50 @@ export function parse( return Promise.resolve({ servers: "127.0.0.1:4222" }); } - // some implementations of URL.parse() only handle "http/s" rip the protocol - // url parsing will inject host/port defaults based on the protocol... - // maybe we just ignore that and explicitly look for - // server= - if (!str.startsWith("nats-opts:")) { - return Promise.reject( - new Error( - "Invalid connection string. Must start with encoded-opts:", - ), - ); - } - - const u = URL.parse(`http://nothing:80?${str.substring(10)}`); + const u = URL.parse(str); if (u === null) { return Promise.reject(new Error(`failed to parse '${str}'`)); } - const opts: Record = {}; - - function configBoolean( - n: string, - ) { - const v = u?.searchParams.get(n) || null; - if (v !== null) { - opts[n] = v === "true"; + const opts: ConnectionOptions = {}; + const r = opts as Record; + if (u.protocol !== "nats") { + const protocol = u.protocol; + const host = u.host; + let s = `${protocol}//${host}`; + if (u.pathname && u.pathname !== "/") { + s += u.pathname; } + r.servers = [s]; + } else { + r.servers = [u.host]; } - function configNumber( - n: string, - ) { - const v = u?.searchParams.get(n) || null; - if (v !== null) { - opts[n] = parseInt(v); + if (u.username) { + if (u.password === undefined || u.password === "") { + opts.token = u.username; + } else { + opts.user = u.username; } } - - function configStringArray(n: string, defaultValue = "") { - let a = u?.searchParams.getAll(n); - a = a?.map((s) => decodeURIComponent(s)); - if (defaultValue && a === null) { - a = [defaultValue]; - } - if (a) { - opts[n] = a; - } + if (u.password) { + opts.pass = u.password; } - - function configString(n: string) { - const v = u?.searchParams.get(n); - if (v) { - opts[n] = decodeURIComponent(v); - } + if (u.protocol === "natss") { + opts.tls = { handshakeFirst: true }; } - booleanProps.forEach((n) => { - configBoolean(n); - }); + booleanProps.forEach(configBooleanFn(u, r)); + stringProps.forEach(configStringFn(u, r)); + numberProps.forEach(configNumberFn(u, r)); + arrayProps.forEach(configStringArrayFn(u, r)); - stringProps.forEach((n) => { - configString(n); - }); - - numberProps.forEach((n) => { - configNumber(n); - }); - - arrayProps.forEach((n) => { - configStringArray(n); - }); + const tls: Obj = opts.tls as Obj || {}; + tlsBooleanProps.forEach(configBooleanFn(u, tls)); + tlsStringProps.forEach(configStringFn(u, tls)); + if (Object.keys(tls).length > 0) { + opts.tls = tls; + } return Promise.resolve(opts); } diff --git a/opts/mod_test.ts b/opts/mod_test.ts index 07852eb..cf09d7f 100644 --- a/opts/mod_test.ts +++ b/opts/mod_test.ts @@ -36,3 +36,26 @@ Deno.test("basics", async () => { await nc.flush(); await nc.close(); }); + +Deno.test("tls", () => { + const opts: Partial = {}; + opts.debug = true; + opts.servers = ["demo.nats.io"]; + opts.name = "me"; + opts.noEcho = true; + opts.reconnect = false; + opts.timeout = 10_000; + opts.tls = { + handshakeFirst: true, + }; + + const u = encode(opts); + console.log(u); + + console.log(parse(u)); +}); + +Deno.test("url", async () => { + const u = new URL("nats://hello:world@localhost"); + console.log(u); +}); From 49b69a03c9ac1a8c387e7b6cca25838538eaf37e Mon Sep 17 00:00:00 2001 From: Alberto Ricart Date: Thu, 1 May 2025 09:31:25 -0500 Subject: [PATCH 3/3] fixed a double insertion of a `nats:` scheme on urls that contain them. --- opts/deno.json | 9 +++++++-- opts/mod.ts | 10 ++++++++-- opts/mod_test.ts | 41 ++++++++++++++++++++++++++++++++++------- 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/opts/deno.json b/opts/deno.json index 3ad83d4..a8a7490 100644 --- a/opts/deno.json +++ b/opts/deno.json @@ -9,10 +9,15 @@ }, "tasks": { "clean": "rm -Rf ./coverage", - "test": "deno task clean & deno test --allow-all --parallel --reload --quiet --coverage=coverage", + + "test": { + "command": "deno test --allow-all --parallel --reload --quiet --coverage=coverage", + "dependencies": ["clean"] + }, "cover": "deno coverage ./coverage --lcov > ./coverage/out.lcov && genhtml -o ./coverage/html ./coverage/out.lcov && open ./coverage/html/index.html" }, "imports": { - "@nats-io/nats-core": "jsr:@nats-io/nats-core@^3.0.0-50" + "@nats-io/nats-core": "jsr:@nats-io/nats-core@^3.0.0-50", + "@std/assert": "jsr:@std/assert@^1.0.13" } } diff --git a/opts/mod.ts b/opts/mod.ts index c4302b5..15d582e 100644 --- a/opts/mod.ts +++ b/opts/mod.ts @@ -92,12 +92,16 @@ function encodeNumberPropsFn(src: Record, target: URL) { } export function encode(opts: Partial): string { + opts = opts || {}; + opts = Object.assign({}, opts); + if (typeof opts?.servers === "string") { opts.servers = [opts.servers]; } let u: URL; if (opts?.servers?.length) { if ( + opts.servers[0].startsWith("nats://") || opts.servers[0].startsWith("wss://") || opts.servers[0].startsWith("ws://") ) { @@ -141,7 +145,6 @@ export function encode(opts: Partial): string { booleanProps.forEach(encodeBooleanPropsFn(opts, u)); if (opts.tls) { - console.log(u); if (u.protocol !== "nats:") { throw new Error("tls options can only be used with nats:// urls"); } @@ -215,7 +218,10 @@ export function parse( const opts: ConnectionOptions = {}; const r = opts as Record; - if (u.protocol !== "nats") { + if (u.protocol === "natss:") { + opts.tls = { handshakeFirst: true }; + r.servers = [u.host]; + } else if (u.protocol !== "nats:") { const protocol = u.protocol; const host = u.host; let s = `${protocol}//${host}`; diff --git a/opts/mod_test.ts b/opts/mod_test.ts index cf09d7f..7141df8 100644 --- a/opts/mod_test.ts +++ b/opts/mod_test.ts @@ -15,6 +15,11 @@ import type { ConnectionOptions } from "@nats-io/nats-core"; import { wsconnect } from "@nats-io/nats-core"; import { encode, parse } from "./mod.ts"; +import { + assert, + assertEquals, + assertExists, +} from "https://deno.land/std@0.221.0/assert/mod.ts"; Deno.test("basics", async () => { const opts: Partial = {}; @@ -31,16 +36,18 @@ Deno.test("basics", async () => { const opts2 = await parse(u); console.log(opts2); + assertEquals(opts, opts2); + const nc = await wsconnect(opts2); console.log(nc.getServer()); await nc.flush(); await nc.close(); }); -Deno.test("tls", () => { +Deno.test("tls", async () => { const opts: Partial = {}; opts.debug = true; - opts.servers = ["demo.nats.io"]; + opts.servers = ["demo.nats.io:2224"]; opts.name = "me"; opts.noEcho = true; opts.reconnect = false; @@ -50,12 +57,32 @@ Deno.test("tls", () => { }; const u = encode(opts); - console.log(u); + const opts2 = await parse(u); - console.log(parse(u)); + assertEquals(opts, opts2); }); -Deno.test("url", async () => { - const u = new URL("nats://hello:world@localhost"); - console.log(u); +Deno.test("schemes", async () => { + const opts: Partial = {}; + opts.servers = [ + "nats://localhost:1234", + "localhost:4222", + "wss://localhost", + ]; + + const s = encode(opts); + const opts2 = await parse(s) as ConnectionOptions; + + assertExists(opts2.servers); + assert(Array.isArray(opts2.servers)); + const n = + (opts2.servers as string[]).find((s: string) => s.startsWith("nats://")) || + ""; + assertEquals(n, ""); + const n2 = + (opts2.servers as string[]).find((s: string) => + s.startsWith("localhost:1234") + ) || + ""; + assertEquals(n2, "localhost:1234"); });