Converted go-roots-ws to typescript.

This commit is contained in:
Jay
2025-11-02 16:16:33 -05:00
commit e84066cae4
18 changed files with 3606 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 nostr:npub10mtatsat7ph6rsq0w8u8npt8d86x4jfr2nqjnvld2439q6f8ugqq0x27hf
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

344
README.md Normal file
View File

@@ -0,0 +1,344 @@
# TS-Roots-WS - Nostr WebSocket Transport for TypeScript
Source: https://git.wisehodl.dev/jay/ts-roots-ws
Mirror: https://github.com/wisehodl/ts-roots-ws
## What this library does
`ts-roots-ws` is a consensus-layer Nostr protocol websocket transport library for TypeScript. It only provides primitives for working with Nostr protocol websocket connection states and messages:
- WebSocket Connection States
- Envelope Structure
- Message Validation
- Protocol Message Creation
- Protocol Message Parsing
- Standard Label Handling
## What this library does not do
`ts-roots-ws` serves as a foundation for other libraries and applications to implement higher level transport abstractions on top of it, including:
- Connection Management
- Event Loops
- Subscription Handling
- State Management
- Reconnection Logic
## Installation
1. Add `ts-roots-ws` to your project:
```bash
npm install @wisehodl/roots-ws
```
2. Import the packages:
```typescript
import {
encloseEvent,
encloseSubscriptionEvent,
findEvent,
findSubscriptionEvent,
getLabel,
isStandardLabel
} from "@wisehodl/roots-ws/envelope";
import { ConnectionStatus } from "@wisehodl/roots-ws";
import { InvalidJSONError, WrongEnvelopeLabelError } from "@wisehodl/roots-ws/errors";
```
3. Access functions with appropriate namespaces.
## Usage Examples
### Envelope Creation
#### Create EVENT envelope
```typescript
// Create an event using ts-roots
import { Event } from "@wisehodl/roots/events";
const event: Event = {
id: "abc123",
pubkey: "def456",
kind: 1,
content: "Hello Nostr!",
created_at: Math.floor(Date.now() / 1000),
tags: [],
sig: ""
};
// Convert to JSON
const eventJSON = JSON.stringify(event);
// Create envelope
const env = encloseEvent(eventJSON);
// Result: ["EVENT",{"id":"abc123","pubkey":"def456","kind":1,"content":"Hello Nostr!","created_at":1636394097}]
```
#### Create subscription EVENT envelope
```typescript
// Create an event using ts-roots
import { Event } from "@wisehodl/roots/events";
const event: Event = {
id: "abc123",
pubkey: "def456",
kind: 1,
content: "Hello Nostr!",
created_at: Math.floor(Date.now() / 1000),
tags: [],
sig: ""
};
// Convert to JSON
const eventJSON = JSON.stringify(event);
// Create envelope with subscription ID
const subID = "sub1";
const env = encloseSubscriptionEvent(subID, eventJSON);
// Result: ["EVENT","sub1",{"id":"abc123","pubkey":"def456","kind":1,"content":"Hello Nostr!","created_at":1636394097}]
```
#### Create REQ envelope
```typescript
// Create filters using ts-roots
import { Filter } from "@wisehodl/roots/filters";
const since = Math.floor(Date.now() / 1000) - (24 * 60 * 60);
const limit = 50;
const filter1: Filter = {
kinds: [1],
limit: limit,
since: since
};
const filter2: Filter = {
authors: ["def456"]
};
// Convert to JSON
const filter1JSON = JSON.stringify(filter1);
const filter2JSON = JSON.stringify(filter2);
// Create envelope
const subID = "sub1";
const filtersJSON = [filter1JSON, filter2JSON];
const env = encloseReq(subID, filtersJSON);
// Result: ["REQ","sub1",{"kinds":[1],"limit":50,"since":1636307697},{"authors":["def456"]}]
```
#### Create other envelope types
```typescript
// Create CLOSE envelope
const env1 = encloseClose("sub1");
// Result: ["CLOSE","sub1"]
// Create EOSE envelope
const env2 = encloseEOSE("sub1");
// Result: ["EOSE","sub1"]
// Create NOTICE envelope
const env3 = encloseNotice("This is a notice");
// Result: ["NOTICE","This is a notice"]
// Create OK envelope
const env4 = encloseOK("abc123", true, "Event accepted");
// Result: ["OK","abc123",true,"Event accepted"]
// Create AUTH challenge
const env5 = encloseAuthChallenge("random-challenge-string");
// Result: ["AUTH","random-challenge-string"]
// Create AUTH response
import { Event } from "@wisehodl/roots/events";
const authEvent: Event = {
id: "abc123",
pubkey: "def456",
kind: 22242,
content: "",
created_at: Math.floor(Date.now() / 1000),
tags: [],
sig: ""
};
// Convert to JSON
const authEventJSON = JSON.stringify(authEvent);
// Create envelope
const env6 = encloseAuthResponse(authEventJSON);
// Result: ["AUTH",{"id":"abc123","pubkey":"def456","kind":22242,"content":"","created_at":1636394097}]
```
---
### Envelope Parsing
#### Extract label from envelope
```typescript
const env = `["EVENT",{"id":"abc123","pubkey":"def456","kind":1,"content":"Hello Nostr!"}]`;
try {
const label = getLabel(env);
// label: "EVENT"
// Check if label is standard
const isStandard = isStandardLabel(label);
// isStandard: true
} catch (err) {
console.error(err);
}
```
#### Extract event from EVENT envelope
```typescript
const env = `["EVENT",{"id":"abc123","pubkey":"def456","kind":1,"content":"Hello Nostr!"}]`;
try {
const eventObj = findEvent(env);
// Parse into ts-roots Event if needed
import { Event, validate } from "@wisehodl/roots/events";
// Validate the event
try {
validate(eventObj as Event);
} catch (err) {
console.error(`Invalid event: ${err.message}`);
}
// Now you can access event properties
console.log(eventObj.id, eventObj.kind, eventObj.content);
} catch (err) {
console.error(err);
}
```
#### Extract subscription event
```typescript
const env = `["EVENT","sub1",{"id":"abc123","pubkey":"def456","kind":1,"content":"Hello Nostr!"}]`;
try {
const [subID, eventObj] = findSubscriptionEvent(env);
// Parse into ts-roots Event if needed
import { Event } from "@wisehodl/roots/events";
console.log(`Subscription: ${subID}, Event ID: ${eventObj.id}`);
} catch (err) {
console.error(err);
}
```
#### Extract subscription request
```typescript
const env = `["REQ","sub1",{"kinds":[1],"limit":50},{"authors":["def456"]}]`;
try {
const [subID, filtersObj] = findReq(env);
// Parse each filter
import { Filter } from "@wisehodl/roots/filters";
// Now you can use the filter objects
filtersObj.forEach((filter, i) => {
console.log(`Filter ${i}: `, filter);
});
} catch (err) {
console.error(err);
}
```
#### Extract other envelope types
```typescript
// Extract OK response
const env1 = `["OK","abc123",true,"Event accepted"]`;
try {
const [eventID, status, message] = findOK(env1);
// eventID: "abc123"
// status: true
// message: "Event accepted"
} catch (err) {
console.error(err);
}
// Extract EOSE message
const env2 = `["EOSE","sub1"]`;
try {
const subID = findEOSE(env2);
// subID: "sub1"
} catch (err) {
console.error(err);
}
// Extract CLOSE message
const env3 = `["CLOSE","sub1"]`;
try {
const subID = findClose(env3);
// subID: "sub1"
} catch (err) {
console.error(err);
}
// Extract CLOSED message
const env4 = `["CLOSED","sub1","Subscription complete"]`;
try {
const [subID, message] = findClosed(env4);
// subID: "sub1"
// message: "Subscription complete"
} catch (err) {
console.error(err);
}
// Extract NOTICE message
const env5 = `["NOTICE","This is a notice"]`;
try {
const message = findNotice(env5);
// message: "This is a notice"
} catch (err) {
console.error(err);
}
// Extract AUTH challenge
const env6 = `["AUTH","random-challenge-string"]`;
try {
const challenge = findAuthChallenge(env6);
// challenge: "random-challenge-string"
} catch (err) {
console.error(err);
}
// Extract AUTH response
const env7 = `["AUTH",{"id":"abc123","pubkey":"def456","kind":22242,"content":""}]`;
try {
const authEvent = findAuthResponse(env7);
// Parse into ts-roots Event if needed
import { Event } from "@wisehodl/roots/events";
} catch (err) {
console.error(err);
}
```
## Testing
This library contains a comprehensive suite of unit tests. Run them with:
```bash
npm test
```
Or for a single run:
```bash
npm run test:run
```

1
c2p Executable file
View File

@@ -0,0 +1 @@
code2prompt -e "c2p" -e "package-lock.json" .

1711
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

50
package.json Normal file
View File

@@ -0,0 +1,50 @@
{
"name": "@wisehodl/roots-ws",
"version": "0.1.0",
"description": "Nostr WebSocket transport primitives for TypeScript",
"license": "MIT",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./envelope": {
"import": "./dist/envelope/index.js",
"types": "./dist/envelope/index.d.ts"
},
"./errors": {
"import": "./dist/errors/index.js",
"types": "./dist/errors/index.d.ts"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"lint": "tsc --noEmit",
"format": "prettier -w src",
"test": "vitest",
"test:run": "vitest run"
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/node": "^24.9.1",
"prettier": "^3.5.3",
"typescript": "^5.9.3",
"vite": "^7.1.12",
"vitest": "^4.0.2"
},
"prettier": {
"importOrder": [
"^@(/)(.*)",
"^[./]"
],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"plugins": [
"@trivago/prettier-plugin-sort-imports"
]
}
}

View File

@@ -0,0 +1,262 @@
import { describe, expect, it } from "vitest";
import {
encloseAuthChallenge,
encloseAuthResponse,
encloseClose,
encloseClosed,
encloseEOSE,
encloseEvent,
encloseNotice,
encloseOK,
encloseReq,
encloseSubscriptionEvent,
} from "./enclose";
describe("encloseEvent", () => {
const cases = [
{
name: "empty event",
event: "{}",
want: '["EVENT",{}]',
},
{
name: "invalid json",
event: "in[valid,]",
want: '["EVENT",in[valid,]]',
},
{
name: "populated event",
event: '{"id":"abc123","kind":1,"sig":"abc123"}',
want: '["EVENT",{"id":"abc123","kind":1,"sig":"abc123"}]',
},
];
cases.forEach((tc) => {
it(tc.name, () => {
const got = encloseEvent(tc.event);
expect(got).toEqual(tc.want);
});
});
});
describe("encloseOK", () => {
const cases = [
{
name: "successful event",
eventID: "abc123",
status: true,
message: "Event accepted",
want: '["OK","abc123",true,"Event accepted"]',
},
{
name: "rejected event",
eventID: "xyz789",
status: false,
message: "Invalid signature",
want: '["OK","xyz789",false,"Invalid signature"]',
},
{
name: "empty message",
eventID: "def456",
status: true,
message: "",
want: '["OK","def456",true,""]',
},
];
cases.forEach((tc) => {
it(tc.name, () => {
const got = encloseOK(tc.eventID, tc.status, tc.message);
expect(got).toEqual(tc.want);
});
});
});
describe("encloseReq", () => {
const cases = [
{
name: "single filter",
subID: "sub1",
filters: ['{"kinds":[1],"limit":10}'],
want: '["REQ","sub1",{"kinds":[1],"limit":10}]',
},
{
name: "multiple filters",
subID: "sub2",
filters: ['{"kinds":[1]}', '{"authors":["abc"]}'],
want: '["REQ","sub2",{"kinds":[1]},{"authors":["abc"]}]',
},
{
name: "no filters",
subID: "sub3",
filters: [],
want: '["REQ","sub3"]',
},
];
cases.forEach((tc) => {
it(tc.name, () => {
const got = encloseReq(tc.subID, tc.filters);
expect(got).toEqual(tc.want);
});
});
});
describe("encloseSubscriptionEvent", () => {
const cases = [
{
name: "basic event",
subID: "sub1",
event: '{"id":"abc123","kind":1}',
want: '["EVENT","sub1",{"id":"abc123","kind":1}]',
},
{
name: "empty event",
subID: "sub2",
event: "{}",
want: '["EVENT","sub2",{}]',
},
];
cases.forEach((tc) => {
it(tc.name, () => {
const got = encloseSubscriptionEvent(tc.subID, tc.event);
expect(got).toEqual(tc.want);
});
});
});
describe("encloseEOSE", () => {
const cases = [
{
name: "valid subscription ID",
subID: "sub1",
want: '["EOSE","sub1"]',
},
{
name: "empty subscription ID",
subID: "",
want: '["EOSE",""]',
},
];
cases.forEach((tc) => {
it(tc.name, () => {
const got = encloseEOSE(tc.subID);
expect(got).toEqual(tc.want);
});
});
});
describe("encloseClose", () => {
const cases = [
{
name: "valid subscription ID",
subID: "sub1",
want: '["CLOSE","sub1"]',
},
{
name: "empty subscription ID",
subID: "",
want: '["CLOSE",""]',
},
];
cases.forEach((tc) => {
it(tc.name, () => {
const got = encloseClose(tc.subID);
expect(got).toEqual(tc.want);
});
});
});
describe("encloseClosed", () => {
const cases = [
{
name: "with message",
subID: "sub1",
message: "Subscription complete",
want: '["CLOSED","sub1","Subscription complete"]',
},
{
name: "empty message",
subID: "sub2",
message: "",
want: '["CLOSED","sub2",""]',
},
];
cases.forEach((tc) => {
it(tc.name, () => {
const got = encloseClosed(tc.subID, tc.message);
expect(got).toEqual(tc.want);
});
});
});
describe("encloseNotice", () => {
const cases = [
{
name: "valid message",
message: "This is a notice",
want: '["NOTICE","This is a notice"]',
},
{
name: "empty message",
message: "",
want: '["NOTICE",""]',
},
];
cases.forEach((tc) => {
it(tc.name, () => {
const got = encloseNotice(tc.message);
expect(got).toEqual(tc.want);
});
});
});
describe("encloseAuthChallenge", () => {
const cases = [
{
name: "valid challenge",
challenge: "random-challenge-string",
want: '["AUTH","random-challenge-string"]',
},
{
name: "empty challenge",
challenge: "",
want: '["AUTH",""]',
},
];
cases.forEach((tc) => {
it(tc.name, () => {
const got = encloseAuthChallenge(tc.challenge);
expect(got).toEqual(tc.want);
});
});
});
describe("encloseAuthResponse", () => {
const cases = [
{
name: "valid event",
event: '{"id":"abc123","kind":22242}',
want: '["AUTH",{"id":"abc123","kind":22242}]',
},
{
name: "empty event",
event: "{}",
want: '["AUTH",{}]',
},
];
cases.forEach((tc) => {
it(tc.name, () => {
const got = encloseAuthResponse(tc.event);
expect(got).toEqual(tc.want);
});
});
});

95
src/envelope/enclose.ts Normal file
View File

@@ -0,0 +1,95 @@
import { Envelope } from "../types";
/**
* Creates an EVENT envelope for publishing events.
* It wraps the provided event JSON in the format ["EVENT", event].
*/
export function encloseEvent(event: string): Envelope {
return `["EVENT",${event}]`;
}
/**
* Creates an OK envelope acknowledging receipt of an event.
* Format: ["OK", eventID, status, message]
*/
export function encloseOK(
eventID: string,
status: boolean,
message: string,
): Envelope {
return `["OK","${eventID}",${status},"${message}"]`;
}
/**
* Creates a REQ envelope for subscription requests.
* Format: ["REQ", subID, filter1, filter2, ...]
*/
export function encloseReq(subID: string, filters: string[]): Envelope {
let envelope = `["REQ","${subID}"`;
for (const filter of filters) {
envelope += `,${filter}`;
}
envelope += "]";
return envelope;
}
/**
* Creates an EVENT envelope for delivering subscription events.
* Format: ["EVENT", subID, event]
*/
export function encloseSubscriptionEvent(
subID: string,
event: string,
): Envelope {
return `["EVENT","${subID}",${event}]`;
}
/**
* Creates an EOSE (End of Stored Events) envelope.
* Format: ["EOSE", subID]
*/
export function encloseEOSE(subID: string): Envelope {
return `["EOSE","${subID}"]`;
}
/**
* Creates a CLOSE envelope for ending a subscription.
* Format: ["CLOSE", subID]
*/
export function encloseClose(subID: string): Envelope {
return `["CLOSE","${subID}"]`;
}
/**
* Creates a CLOSED envelope for indicating a terminated subscription.
* Format: ["CLOSED", subID, message]
*/
export function encloseClosed(subID: string, message: string): Envelope {
return `["CLOSED","${subID}","${message}"]`;
}
/**
* Creates a NOTICE envelope for responder messages.
* Format: ["NOTICE", message]
*/
export function encloseNotice(message: string): Envelope {
return `["NOTICE","${message}"]`;
}
/**
* Creates an AUTH challenge envelope.
* Format: ["AUTH", challenge]
*/
export function encloseAuthChallenge(challenge: string): Envelope {
return `["AUTH","${challenge}"]`;
}
/**
* Creates an AUTH response envelope.
* Format: ["AUTH", event]
*/
export function encloseAuthResponse(event: string): Envelope {
return `["AUTH",${event}]`;
}

View File

@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import { getLabel, getStandardLabels, isStandardLabel } from "./envelope";
describe("getLabel", () => {
const cases = [
{
name: "valid envelope with EVENT label",
env: '["EVENT",{"id":"abc123"}]',
wantLabel: "EVENT",
},
{
name: "valid envelope with custom label",
env: '["TEST",{"data":"value"}]',
wantLabel: "TEST",
},
{
name: "invalid json",
env: "invalid",
wantErrText: "Unexpected token",
},
{
name: "empty array",
env: "[]",
wantErrText: "empty envelope",
},
{
name: "label not a string",
env: '[123,{"id":"abc123"}]',
wantErrText: "label is not a string",
},
];
cases.forEach((tc) => {
it(tc.name, () => {
if (tc.wantErrText) {
try {
getLabel(tc.env);
expect.fail("Expected function to throw an error");
} catch (err) {
expect((err as Error).message).toContain(tc.wantErrText);
}
} else {
const got = getLabel(tc.env);
expect(got).toEqual(tc.wantLabel);
}
});
});
});
describe("getStandardLabels", () => {
it("returns the correct standard labels", () => {
const expected = new Set([
"EVENT",
"REQ",
"CLOSE",
"CLOSED",
"EOSE",
"NOTICE",
"OK",
"AUTH",
]);
const labels = getStandardLabels();
// Check that we have the exact same number of labels
expect(labels.size).toBe(expected.size);
// Check that all expected labels are present
expected.forEach((label) => {
expect(labels.has(label)).toBe(true);
});
});
});
describe("isStandardLabel", () => {
const standardCases = [
"EVENT",
"REQ",
"CLOSE",
"CLOSED",
"EOSE",
"NOTICE",
"OK",
"AUTH",
];
const nonStandardCases = ["TEST", "CUSTOM", "event", "REQ1", ""];
standardCases.forEach((label) => {
it(`${label} should be standard`, () => {
expect(isStandardLabel(label)).toBe(true);
});
});
nonStandardCases.forEach((label) => {
it(`${label} should not be standard`, () => {
expect(isStandardLabel(label)).toBe(false);
});
});
});

52
src/envelope/envelope.ts Normal file
View File

@@ -0,0 +1,52 @@
import {
InvalidEnvelopeError,
InvalidJSONError,
WrongFieldTypeError,
} from "../errors";
import { Envelope } from "../types";
/**
* Gets the label from an envelope.
*/
export function getLabel(env: Envelope): string {
try {
const arr = JSON.parse(env);
if (!Array.isArray(arr) || arr.length < 1) {
throw new InvalidEnvelopeError("empty envelope");
}
if (typeof arr[0] !== "string") {
throw new WrongFieldTypeError("label is not a string");
}
return arr[0];
} catch (err) {
if (err instanceof Error && !(err instanceof InvalidJSONError)) {
throw err;
}
throw new InvalidJSONError(`${err}`);
}
}
/**
* Returns a set of standard Nostr WebSocket message labels.
*/
export function getStandardLabels(): Set<string> {
return new Set([
"EVENT",
"REQ",
"CLOSE",
"CLOSED",
"EOSE",
"NOTICE",
"OK",
"AUTH",
]);
}
/**
* Checks if the given label is a standard Nostr WebSocket message label.
*/
export function isStandardLabel(label: string): boolean {
return getStandardLabels().has(label);
}

509
src/envelope/find.test.ts Normal file
View File

@@ -0,0 +1,509 @@
import { describe, expect, it } from "vitest";
import {
findAuthChallenge,
findAuthResponse,
findClose,
findClosed,
findEOSE,
findEvent,
findNotice,
findOK,
findReq,
findSubscriptionEvent,
} from "./find";
describe("findEvent", () => {
const cases = [
{
name: "valid event",
env: '["EVENT",{"id":"abc123","kind":1}]',
wantEvent: '{"id":"abc123","kind":1}',
},
{
name: "wrong label",
env: '["REQ",{"id":"abc123","kind":1}]',
wantErrText: "expected EVENT, got REQ",
},
{
name: "invalid json",
env: "invalid",
wantErrText: "Unexpected token",
},
{
name: "missing elements",
env: '["EVENT"]',
wantErrText: "expected 2 elements, got 1",
},
{
name: "extraneous elements",
env: '["EVENT",{"id":"abc123"},"extra"]',
wantEvent: '{"id":"abc123"}',
},
];
cases.forEach((tc) => {
it(tc.name, () => {
if (tc.wantErrText) {
try {
findEvent(tc.env);
expect.fail("Expected function to throw an error");
} catch (err) {
expect((err as Error).message).toContain(tc.wantErrText);
}
} else {
const got = findEvent(tc.env);
expect(JSON.stringify(got)).toEqual(tc.wantEvent);
}
});
});
});
describe("findSubscriptionEvent", () => {
const cases = [
{
name: "valid event",
env: '["EVENT","sub1",{"id":"abc123","kind":1}]',
wantSubID: "sub1",
wantEvent: '{"id":"abc123","kind":1}',
},
{
name: "wrong label",
env: '["REQ","sub1",{"id":"abc123","kind":1}]',
wantErrText: "expected EVENT, got REQ",
},
{
name: "invalid json",
env: "invalid",
wantErrText: "Unexpected token",
},
{
name: "missing elements",
env: '["EVENT","sub1"]',
wantErrText: "expected 3 elements, got 2",
},
{
name: "extraneous elements",
env: '["EVENT","sub1",{"id":"abc123"},"extra"]',
wantSubID: "sub1",
wantEvent: '{"id":"abc123"}',
},
];
cases.forEach((tc) => {
it(tc.name, () => {
if (tc.wantErrText) {
try {
findSubscriptionEvent(tc.env);
expect.fail("Expected function to throw an error");
} catch (err) {
expect((err as Error).message).toContain(tc.wantErrText);
}
} else {
const [gotSubID, gotEvent] = findSubscriptionEvent(tc.env);
expect(gotSubID).toEqual(tc.wantSubID);
expect(JSON.stringify(gotEvent)).toEqual(tc.wantEvent);
}
});
});
});
describe("findOK", () => {
const cases = [
{
name: "accepted event",
env: '["OK","abc123",true,"Event accepted"]',
wantEventID: "abc123",
wantStatus: true,
wantMessage: "Event accepted",
},
{
name: "rejected event",
env: '["OK","xyz789",false,"Invalid signature"]',
wantEventID: "xyz789",
wantStatus: false,
wantMessage: "Invalid signature",
},
{
name: "wrong status type",
env: '["OK","abc123","ok","Event accepted"]',
wantErrText: "status is not the expected type",
},
{
name: "wrong label",
env: '["EVENT","abc123",true,"Event accepted"]',
wantErrText: "expected OK, got EVENT",
},
{
name: "invalid json",
env: "invalid",
wantErrText: "Unexpected token",
},
{
name: "missing elements",
env: '["OK","abc123",true]',
wantErrText: "expected 4 elements, got 3",
},
{
name: "extraneous elements",
env: '["OK","abc123",true,"Event accepted","extra"]',
wantEventID: "abc123",
wantStatus: true,
wantMessage: "Event accepted",
},
];
cases.forEach((tc) => {
it(tc.name, () => {
if (tc.wantErrText) {
try {
findOK(tc.env);
expect.fail("Expected function to throw an error");
} catch (err) {
expect((err as Error).message).toContain(tc.wantErrText);
}
} else {
const [gotEventID, gotStatus, gotMessage] = findOK(tc.env);
expect(gotEventID).toEqual(tc.wantEventID);
expect(gotStatus).toEqual(tc.wantStatus);
expect(gotMessage).toEqual(tc.wantMessage);
}
});
});
});
describe("findReq", () => {
const cases = [
{
name: "single filter",
env: '["REQ","sub1",{"kinds":[1],"limit":10}]',
wantSubID: "sub1",
wantFilters: ['{"kinds":[1],"limit":10}'],
},
{
name: "multiple filters",
env: '["REQ","sub2",{"kinds":[1]},{"authors":["abc"]}]',
wantSubID: "sub2",
wantFilters: ['{"kinds":[1]}', '{"authors":["abc"]}'],
},
{
name: "no filters",
env: '["REQ","sub3"]',
wantSubID: "sub3",
wantFilters: [],
},
{
name: "wrong label",
env: '["EVENT","sub1",{"kinds":[1],"limit":10}]',
wantErrText: "expected REQ, got EVENT",
},
{
name: "invalid json",
env: "invalid",
wantErrText: "Unexpected token",
},
{
name: "missing elements",
env: '["REQ"]',
wantErrText: "expected 2 elements, got 1",
},
];
cases.forEach((tc) => {
it(tc.name, () => {
if (tc.wantErrText) {
try {
findReq(tc.env);
expect.fail("Expected function to throw an error");
} catch (err) {
expect((err as Error).message).toContain(tc.wantErrText);
}
} else {
const [gotSubID, gotFilters] = findReq(tc.env);
expect(gotSubID).toEqual(tc.wantSubID);
expect(gotFilters.map((f) => JSON.stringify(f))).toEqual(
tc.wantFilters,
);
}
});
});
});
describe("findEOSE", () => {
const cases = [
{
name: "valid EOSE",
env: '["EOSE","sub1"]',
wantSubID: "sub1",
},
{
name: "wrong label",
env: '["EVENT","sub1"]',
wantErrText: "expected EOSE, got EVENT",
},
{
name: "invalid json",
env: "invalid",
wantErrText: "Unexpected token",
},
{
name: "missing elements",
env: '["EOSE"]',
wantErrText: "expected 2 elements, got 1",
},
{
name: "extraneous elements",
env: '["EOSE","sub1","extra"]',
wantSubID: "sub1",
},
];
cases.forEach((tc) => {
it(tc.name, () => {
if (tc.wantErrText) {
try {
findEOSE(tc.env);
expect.fail("Expected function to throw an error");
} catch (err) {
expect((err as Error).message).toContain(tc.wantErrText);
}
} else {
const gotSubID = findEOSE(tc.env);
expect(gotSubID).toEqual(tc.wantSubID);
}
});
});
});
describe("findClose", () => {
const cases = [
{
name: "valid CLOSE",
env: '["CLOSE","sub1"]',
wantSubID: "sub1",
},
{
name: "wrong label",
env: '["EVENT","sub1"]',
wantErrText: "expected CLOSE, got EVENT",
},
{
name: "invalid json",
env: "invalid",
wantErrText: "Unexpected token",
},
{
name: "missing elements",
env: '["CLOSE"]',
wantErrText: "expected 2 elements, got 1",
},
{
name: "extraneous elements",
env: '["CLOSE","sub1","extra"]',
wantSubID: "sub1",
},
];
cases.forEach((tc) => {
it(tc.name, () => {
if (tc.wantErrText) {
try {
findClose(tc.env);
expect.fail("Expected function to throw an error");
} catch (err) {
expect((err as Error).message).toContain(tc.wantErrText);
}
} else {
const gotSubID = findClose(tc.env);
expect(gotSubID).toEqual(tc.wantSubID);
}
});
});
});
describe("findClosed", () => {
const cases = [
{
name: "valid CLOSED",
env: '["CLOSED","sub1","Subscription complete"]',
wantSubID: "sub1",
wantMessage: "Subscription complete",
},
{
name: "wrong label",
env: '["EVENT","sub1","Subscription complete"]',
wantErrText: "expected CLOSED, got EVENT",
},
{
name: "invalid json",
env: "invalid",
wantErrText: "Unexpected token",
},
{
name: "missing elements",
env: '["CLOSED","sub1"]',
wantErrText: "expected 3 elements, got 2",
},
{
name: "extraneous elements",
env: '["CLOSED","sub1","Subscription complete","extra"]',
wantSubID: "sub1",
wantMessage: "Subscription complete",
},
];
cases.forEach((tc) => {
it(tc.name, () => {
if (tc.wantErrText) {
try {
findClosed(tc.env);
expect.fail("Expected function to throw an error");
} catch (err) {
expect((err as Error).message).toContain(tc.wantErrText);
}
} else {
const [gotSubID, gotMessage] = findClosed(tc.env);
expect(gotSubID).toEqual(tc.wantSubID);
expect(gotMessage).toEqual(tc.wantMessage);
}
});
});
});
describe("findNotice", () => {
const cases = [
{
name: "valid NOTICE",
env: '["NOTICE","This is a notice"]',
wantMessage: "This is a notice",
},
{
name: "wrong label",
env: '["EVENT","This is a notice"]',
wantErrText: "expected NOTICE, got EVENT",
},
{
name: "invalid json",
env: "invalid",
wantErrText: "Unexpected token",
},
{
name: "missing elements",
env: '["NOTICE"]',
wantErrText: "expected 2 elements, got 1",
},
{
name: "extraneous elements",
env: '["NOTICE","This is a notice","extra"]',
wantMessage: "This is a notice",
},
];
cases.forEach((tc) => {
it(tc.name, () => {
if (tc.wantErrText) {
try {
findNotice(tc.env);
expect.fail("Expected function to throw an error");
} catch (err) {
expect((err as Error).message).toContain(tc.wantErrText);
}
} else {
const gotMessage = findNotice(tc.env);
expect(gotMessage).toEqual(tc.wantMessage);
}
});
});
});
describe("findAuthChallenge", () => {
const cases = [
{
name: "valid AUTH challenge",
env: '["AUTH","random-challenge-string"]',
wantChallenge: "random-challenge-string",
},
{
name: "wrong label",
env: '["EVENT","random-challenge-string"]',
wantErrText: "expected AUTH, got EVENT",
},
{
name: "invalid json",
env: "invalid",
wantErrText: "Unexpected token",
},
{
name: "missing elements",
env: '["AUTH"]',
wantErrText: "expected 2 elements, got 1",
},
{
name: "extraneous elements",
env: '["AUTH","random-challenge-string","extra"]',
wantChallenge: "random-challenge-string",
},
];
cases.forEach((tc) => {
it(tc.name, () => {
if (tc.wantErrText) {
try {
findAuthChallenge(tc.env);
expect.fail("Expected function to throw an error");
} catch (err) {
expect((err as Error).message).toContain(tc.wantErrText);
}
} else {
const gotChallenge = findAuthChallenge(tc.env);
expect(gotChallenge).toEqual(tc.wantChallenge);
}
});
});
});
describe("findAuthResponse", () => {
const cases = [
{
name: "valid AUTH response",
env: '["AUTH",{"id":"abc123","kind":22242}]',
wantEvent: '{"id":"abc123","kind":22242}',
},
{
name: "wrong label",
env: '["EVENT",{"id":"abc123","kind":22242}]',
wantErrText: "expected AUTH, got EVENT",
},
{
name: "invalid json",
env: "invalid",
wantErrText: "Unexpected token",
},
{
name: "missing elements",
env: '["AUTH"]',
wantErrText: "expected 2 elements, got 1",
},
{
name: "extraneous elements",
env: '["AUTH",{"id":"abc123","kind":22242},"extra"]',
wantEvent: '{"id":"abc123","kind":22242}',
},
];
cases.forEach((tc) => {
it(tc.name, () => {
if (tc.wantErrText) {
try {
findAuthResponse(tc.env);
expect.fail("Expected function to throw an error");
} catch (err) {
expect((err as Error).message).toContain(tc.wantErrText);
}
} else {
const gotEvent = findAuthResponse(tc.env);
expect(JSON.stringify(gotEvent)).toEqual(tc.wantEvent);
}
});
});
});

332
src/envelope/find.ts Normal file
View File

@@ -0,0 +1,332 @@
import {
InvalidEnvelopeError,
InvalidJSONError,
WrongEnvelopeLabelError,
WrongFieldTypeError,
} from "../errors";
import { Envelope } from "../types";
/**
* Helper function that ensures the JSON array has at least the minimum length required.
*/
export function checkArrayLength(arr: any[], minLen: number): void {
if (arr.length < minLen) {
throw new InvalidEnvelopeError(
`expected ${minLen} elements, got ${arr.length}`,
);
}
}
/**
* Helper function that verifies that the envelope label matches the expected one.
*/
export function checkLabel(got: string, want: string): void {
if (got !== want) {
throw new WrongEnvelopeLabelError(`expected ${want}, got ${got}`);
}
}
/**
* Extracts an event from an EVENT envelope with no subscription ID.
* Expected Format: ["EVENT", event]
*/
export function findEvent(env: Envelope): any {
try {
const arr = JSON.parse(env);
checkArrayLength(arr, 2);
const label = arr[0];
if (typeof label !== "string") {
throw new WrongFieldTypeError("envelope label is not a string");
}
checkLabel(label, "EVENT");
return arr[1];
} catch (err) {
if (err instanceof Error) {
throw err;
}
throw new InvalidJSONError(`${err}`);
}
}
/**
* Extracts an event and subscription ID from an EVENT envelope.
* Expected Format: ["EVENT", subID, event]
*/
export function findSubscriptionEvent(
env: Envelope,
): [subID: string, event: any] {
try {
const arr = JSON.parse(env);
checkArrayLength(arr, 3);
const label = arr[0];
if (typeof label !== "string") {
throw new WrongFieldTypeError("envelope label is not a string");
}
checkLabel(label, "EVENT");
const subID = arr[1];
if (typeof subID !== "string") {
throw new WrongFieldTypeError("subscription ID is not a string");
}
return [subID, arr[2]];
} catch (err) {
if (err instanceof Error) {
throw err;
}
throw new InvalidJSONError(`${err}`);
}
}
/**
* Extracts eventID, status, and message from an OK envelope.
* Expected Format: ["OK", eventID, status, message]
*/
export function findOK(
env: Envelope,
): [eventID: string, status: boolean, message: string] {
try {
const arr = JSON.parse(env);
checkArrayLength(arr, 4);
const label = arr[0];
if (typeof label !== "string") {
throw new WrongFieldTypeError("envelope label is not a string");
}
checkLabel(label, "OK");
const eventID = arr[1];
if (typeof eventID !== "string") {
throw new WrongFieldTypeError("event ID is not a string");
}
if (typeof arr[2] !== "boolean") {
throw new WrongFieldTypeError("status is not the expected type");
}
const status = arr[2];
const message = arr[3];
if (typeof message !== "string") {
throw new WrongFieldTypeError("message is not a string");
}
return [eventID, status, message];
} catch (err) {
if (err instanceof Error) {
throw err;
}
throw new InvalidJSONError(`${err}`);
}
}
/**
* Extracts subscription ID and filters from a REQ envelope.
* Expected Format: ["REQ", subID, filter1, filter2, ...]
*/
export function findReq(env: Envelope): [subID: string, filters: any[]] {
try {
const arr = JSON.parse(env);
checkArrayLength(arr, 2);
const label = arr[0];
if (typeof label !== "string") {
throw new WrongFieldTypeError("envelope label is not a string");
}
checkLabel(label, "REQ");
const subID = arr[1];
if (typeof subID !== "string") {
throw new WrongFieldTypeError("subscription ID is not a string");
}
const filters: any[] = [];
for (let i = 2; i < arr.length; i++) {
filters.push(arr[i]);
}
return [subID, filters];
} catch (err) {
if (err instanceof Error) {
throw err;
}
throw new InvalidJSONError(`${err}`);
}
}
/**
* Extracts subscription ID from an EOSE envelope.
* Expected Format: ["EOSE", subID]
*/
export function findEOSE(env: Envelope): string {
try {
const arr = JSON.parse(env);
checkArrayLength(arr, 2);
const label = arr[0];
if (typeof label !== "string") {
throw new WrongFieldTypeError("envelope label is not a string");
}
checkLabel(label, "EOSE");
const subID = arr[1];
if (typeof subID !== "string") {
throw new WrongFieldTypeError("subscription ID is not a string");
}
return subID;
} catch (err) {
if (err instanceof Error) {
throw err;
}
throw new InvalidJSONError(`${err}`);
}
}
/**
* Extracts subscription ID from a CLOSE envelope.
* Expected Format: ["CLOSE", subID]
*/
export function findClose(env: Envelope): string {
try {
const arr = JSON.parse(env);
checkArrayLength(arr, 2);
const label = arr[0];
if (typeof label !== "string") {
throw new WrongFieldTypeError("envelope label is not a string");
}
checkLabel(label, "CLOSE");
const subID = arr[1];
if (typeof subID !== "string") {
throw new WrongFieldTypeError("subscription ID is not a string");
}
return subID;
} catch (err) {
if (err instanceof Error) {
throw err;
}
throw new InvalidJSONError(`${err}`);
}
}
/**
* Extracts subscription ID and message from a CLOSED envelope.
* Expected Format: ["CLOSED", subID, message]
*/
export function findClosed(env: Envelope): [subID: string, message: string] {
try {
const arr = JSON.parse(env);
checkArrayLength(arr, 3);
const label = arr[0];
if (typeof label !== "string") {
throw new WrongFieldTypeError("envelope label is not a string");
}
checkLabel(label, "CLOSED");
const subID = arr[1];
if (typeof subID !== "string") {
throw new WrongFieldTypeError("subscription ID is not a string");
}
const message = arr[2];
if (typeof message !== "string") {
throw new WrongFieldTypeError("message is not a string");
}
return [subID, message];
} catch (err) {
if (err instanceof Error) {
throw err;
}
throw new InvalidJSONError(`${err}`);
}
}
/**
* Extracts message from a NOTICE envelope.
* Expected Format: ["NOTICE", message]
*/
export function findNotice(env: Envelope): string {
try {
const arr = JSON.parse(env);
checkArrayLength(arr, 2);
const label = arr[0];
if (typeof label !== "string") {
throw new WrongFieldTypeError("envelope label is not a string");
}
checkLabel(label, "NOTICE");
const message = arr[1];
if (typeof message !== "string") {
throw new WrongFieldTypeError("message is not a string");
}
return message;
} catch (err) {
if (err instanceof Error) {
throw err;
}
throw new InvalidJSONError(`${err}`);
}
}
/**
* Extracts challenge from an AUTH challenge envelope.
* Expected Format: ["AUTH", challenge]
*/
export function findAuthChallenge(env: Envelope): string {
try {
const arr = JSON.parse(env);
checkArrayLength(arr, 2);
const label = arr[0];
if (typeof label !== "string") {
throw new WrongFieldTypeError("envelope label is not a string");
}
checkLabel(label, "AUTH");
const challenge = arr[1];
if (typeof challenge !== "string") {
throw new WrongFieldTypeError("challenge is not a string");
}
return challenge;
} catch (err) {
if (err instanceof Error) {
throw err;
}
throw new InvalidJSONError(`${err}`);
}
}
/**
* Extracts event from an AUTH response envelope.
* Expected Format: ["AUTH", event]
*/
export function findAuthResponse(env: Envelope): any {
try {
const arr = JSON.parse(env);
checkArrayLength(arr, 2);
const label = arr[0];
if (typeof label !== "string") {
throw new WrongFieldTypeError("envelope label is not a string");
}
checkLabel(label, "AUTH");
// The second element should be an object (the event)
return arr[1];
} catch (err) {
if (err instanceof Error) {
throw err;
}
throw new InvalidJSONError(`${err}`);
}
}

3
src/envelope/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from "./envelope";
export * from "./enclose";
export * from "./find";

70
src/errors/index.ts Normal file
View File

@@ -0,0 +1,70 @@
/**
* Base error class for all roots-ws errors.
*/
export class RootsWSError extends Error {
constructor(message: string) {
super(message);
this.name = this.constructor.name;
// This sets the prototype correctly for instanceof checks
Object.setPrototypeOf(this, new.target.prototype);
}
}
/**
* Data Structure Errors
*/
/**
* Indicates that a byte sequence could not be parsed as valid JSON.
* This is typically returned when unmarshaling fails during envelope processing.
*/
export class InvalidJSONError extends RootsWSError {
constructor(message: string = "invalid JSON") {
super(message);
}
}
/**
* Indicates that a required field is absent from a data structure.
* This is returned when validating that all mandatory components are present.
*/
export class MissingFieldError extends RootsWSError {
constructor(message: string = "missing required field") {
super(message);
}
}
/**
* Indicates that a field's type does not match the expected type.
* This is returned when unmarshaling a specific value fails due to type mismatch.
*/
export class WrongFieldTypeError extends RootsWSError {
constructor(message: string = "wrong field type") {
super(message);
}
}
/**
* Envelope Errors
*/
/**
* Indicates that a message does not conform to the Nostr envelope structure.
* This typically occurs when an array has incorrect number of elements for its message type.
*/
export class InvalidEnvelopeError extends RootsWSError {
constructor(message: string = "invalid envelope format") {
super(message);
}
}
/**
* Indicates that an envelope's label does not match the expected type.
* This is returned when attempting to parse an envelope using a Find function that
* expects a different label than what was provided.
*/
export class WrongEnvelopeLabelError extends RootsWSError {
constructor(message: string = "wrong envelope label") {
super(message);
}
}

1
src/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from "./types";

29
src/types.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* Represents a Nostr websocket message.
*/
export type Envelope = string;
/**
* Represents the current state of a WebSocket connection.
*/
export enum ConnectionStatus {
/**
* Indicates the connection is not active and no connection attempt is in progress.
*/
Disconnected = 0,
/**
* Indicates a connection attempt is currently in progress but not yet established.
*/
Connecting = 1,
/**
* Indicates the connection is active and ready for message exchange.
*/
Connected = 2,
/**
* Indicates the connection is in the process of shutting down gracefully.
*/
Closing = 3,
}

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020"],
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "bundler"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

7
vitest.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
},
});