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

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,
}