From ffe80eed96bab523228c3cfc17fa96c59ebecb1e Mon Sep 17 00:00:00 2001 From: unknown Date: Sun, 18 Oct 2026 20:07:29 +0530 Subject: [PATCH] fix: match the server's NATS subject encoding for segments with dots The server now maps each URI segment to one subject token and percent-encodes every byte outside [A-Za-z0-9_-] (priompt/priompt#33), so priompt://acme.corp/x/a.b publishes on priompt.acme%2Ecorp.x.a%2Eb. subject() still joined segments with "." unencoded, so subscribe() on such a URI listened on a subject the server no longer uses. Dot-free URIs keep their subject. subject() is exported for the tests, which reuse the server's cases. Adds an npm test suite (node:test) and runs it in CI. Fixes #12 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 1 + client.js | 13 +++++++++++-- package.json | 3 ++- test/subject.test.js | 26 ++++++++++++++++++++++++++ 4 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 test/subject.test.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 72e9309..ac09b02 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,5 +15,6 @@ jobs: node-version: 24 - run: npm install - run: node --check client.js + - run: npm test # construct the client so the vendored proto is parsed at load time - run: node -e "new (require('./client.js').PromptClient)({host:'localhost:1'})" diff --git a/client.js b/client.js index 159d35a..79f4583 100644 --- a/client.js +++ b/client.js @@ -17,8 +17,17 @@ function loadService() { return grpc.loadPackageDefinition(def).priompt.v1; } +// subject maps a prompt URI to its NATS subject exactly as the server's +// pubsub.Subject does: each URI segment is one subject token, with every byte +// outside [A-Za-z0-9_-] percent-encoded in uppercase hex ("." is the NATS token +// separator), so priompt://acme.corp/x/a.b -> priompt.acme%2Ecorp.x.a%2Eb. function subject(uri) { - return "priompt." + uri.replace("priompt://", "").replace(/\//g, "."); + const path = uri.startsWith("priompt://") ? uri.slice("priompt://".length) : uri; + const token = (seg) => + Array.from(Buffer.from(seg, "utf8"), (c) => + /[A-Za-z0-9_-]/.test(String.fromCharCode(c)) ? String.fromCharCode(c) : "%" + c.toString(16).toUpperCase().padStart(2, "0") + ).join(""); + return "priompt." + path.split("/").map(token).join("."); } // parseUrl splits a priompt://@host:port connection URL into { host, @@ -152,4 +161,4 @@ class PromptClient { } } -module.exports = { PromptClient }; +module.exports = { PromptClient, subject }; diff --git a/package.json b/package.json index bb7e867..dd8326b 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "access": "public" }, "scripts": { - "sync-proto": "node -e \"const fs=require('fs');fs.mkdirSync('proto/priompt/v1',{recursive:true});fs.copyFileSync('../proto/proto/priompt/v1/prompt.proto','proto/priompt/v1/prompt.proto')\"" + "sync-proto": "node -e \"const fs=require('fs');fs.mkdirSync('proto/priompt/v1',{recursive:true});fs.copyFileSync('../proto/proto/priompt/v1/prompt.proto','proto/priompt/v1/prompt.proto')\"", + "test": "node --test \"test/*.test.js\"" }, "dependencies": { "@grpc/grpc-js": "^1.10.0", diff --git a/test/subject.test.js b/test/subject.test.js new file mode 100644 index 0000000..9a6ca11 --- /dev/null +++ b/test/subject.test.js @@ -0,0 +1,26 @@ +const test = require("node:test"); +const assert = require("node:assert"); +const { subject } = require("../client"); + +// Cases mirror the server's internal/pubsub TestSubjectIsInjective and +// TestSubjectMapping (priompt/priompt#33). +test("distinct URIs map to distinct subjects", () => { + for (const [a, b] of [ + ["priompt://acme/a.b", "priompt://acme/a/b"], + ["priompt://acme/corp/x", "priompt://acme.corp/x"], + ["priompt://acme/a.b/c", "priompt://acme/a/b.c"], + ]) { + assert.notStrictEqual(subject(a), subject(b), `${a} and ${b} share a subject`); + } +}); + +test("dot-free URIs keep their subject", () => { + assert.strictEqual(subject("priompt://acme/support/agent"), "priompt.acme.support.agent"); + assert.strictEqual(subject("priompt://acme/support/agent-v2_x"), "priompt.acme.support.agent-v2_x"); +}); + +test("bytes outside [A-Za-z0-9_-] are percent-encoded with uppercase hex", () => { + assert.strictEqual(subject("priompt://acme.corp/x/a.b"), "priompt.acme%2Ecorp.x.a%2Eb"); + // Encoded per UTF-8 byte, as the server's Go loop does. + assert.strictEqual(subject("priompt://acme/café"), "priompt.acme.caf%C3%A9"); +});