SOURCE / PINNED RELEASE
Made of little things.
mini-nostr-saf
- Release
- 72c0506a4a1b…
- Author-recorded commit
- cec3876ccbdc…
- License
- LICENSE
- Author’s source reference
- nostr://npub1whs094x2p7qshzqn77uwm0kx0826f8xdad5ppqet7w9jr6ay5scs99c7wj/wss%3A%2F%2Fgit.napplet.soy%2F/n-599618f6842
Archive hash verified: e3147c802508002b…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
/**
* WebSocket-compatible relay transport for the napplet sandbox.
*
* Hard boundary: napplet code must not use raw WebSocket. The app's relay
* layer speaks Nostr wire frames (REQ / CLOSE / EVENT) and tracks per-relay
* connection state, so this shim keeps that exact surface and routes the
* frames through `@napplet/sdk`'s host-mediated `relay` domain:
*
* - `["REQ", subId, ...filters]` -> `relaySubscribe(filters, onEvent, onEose, { relay: url })`
* - `["CLOSE", subId]` -> subscription `close()`
* - `["EVENT", signed]` -> `relayPublish(template)` (shell signs
* with the viewer's identity), then a
* synthesized `["OK", id, true, ""]`
*
* Incoming shell events are re-emitted as `["EVENT", subId, event]` /
* `["EOSE", subId]` message frames so the app's existing parsers work
* unchanged. `readyState` transitions to OPEN on the next microtask, like a
* real socket whose handlers are assigned right after construction.
*/
import type { NostrEvent, NostrFilter, Subscription } from "@napplet/sdk";
export const NAPPLET_WS_CONNECTING = 0;
export const NAPPLET_WS_OPEN = 1;
export const NAPPLET_WS_CLOSING = 2;
export const NAPPLET_WS_CLOSED = 3;
type MessageHandler = ((ev: { data: string }) => void) | null;
type OpenHandler = ((ev: Event) => void) | null;
type CloseHandler = ((ev: { code: number; reason: string }) => void) | null;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export class NappletRelaySocket {
static readonly CONNECTING = NAPPLET_WS_CONNECTING;
static readonly OPEN = NAPPLET_WS_OPEN;
static readonly CLOSING = NAPPLET_WS_CLOSING;
static readonly CLOSED = NAPPLET_WS_CLOSED;
readonly url: string;
readyState: number = NAPPLET_WS_CONNECTING;
onopen: OpenHandler = null;
onmessage: MessageHandler = null;
onclose: CloseHandler = null;
onerror: OpenHandler = null;
private subs = new Map<string, Subscription>();
private closed = false;
constructor(url: string) {
this.url = url;
// Let the caller assign handlers before we report OPEN, mirroring
// the real WebSocket constructor's asynchronous open.
queueMicrotask(() => {
if (this.closed || this.readyState !== NAPPLET_WS_CONNECTING) return;
this.readyState = NAPPLET_WS_OPEN;
try {
this.onopen?.(new Event("open"));
} catch {
// App handlers must never break the transport.
}
});
}
private emit(frame: unknown[]): void {
if (this.closed) return;
const handler = this.onmessage;
if (!handler) return;
try {
handler({ data: JSON.stringify(frame) });
} catch {
// App handlers must never break the transport.
}
}
private fail(code: number, reason: string): void {
if (this.closed) return;
this.closed = true;
this.readyState = NAPPLET_WS_CLOSED;
for (const sub of this.subs.values()) {
try {
sub.close();
} catch {
// ignore
}
}
this.subs.clear();
try {
this.onclose?.({ code, reason });
} catch {
// ignore
}
}
send(data: string): void {
if (this.readyState !== NAPPLET_WS_OPEN) return;
let frame: unknown;
try {
frame = JSON.parse(data);
} catch {
return;
}
if (!Array.isArray(frame) || typeof frame[0] !== "string") return;
const [verb, arg, ...rest] = frame as [string, unknown, ...unknown[]];
if (verb === "REQ" && typeof arg === "string") {
void this.handleReq(arg, rest);
return;
}
if (verb === "CLOSE" && typeof arg === "string") {
const sub = this.subs.get(arg);
if (sub) {
try {
sub.close();
} catch {
// ignore
}
this.subs.delete(arg);
}
return;
}
if (verb === "EVENT" && isRecord(arg)) {
void this.handlePublish(arg);
}
}
private async handleReq(subId: string, filters: unknown[]): Promise<void> {
// Replace any previous subscription under the same id (NIP-01 semantics).
const previous = this.subs.get(subId);
if (previous) {
try {
previous.close();
} catch {
// ignore
}
this.subs.delete(subId);
}
try {
const { relaySubscribe } = await import("@napplet/sdk");
const typed = filters as NostrFilter | NostrFilter[];
const sub = relaySubscribe(
typed,
(result) => {
if (this.closed || !this.subs.has(subId)) return;
this.emit(["EVENT", subId, result.event]);
},
() => {
if (this.closed || !this.subs.has(subId)) return;
this.emit(["EOSE", subId]);
},
{ relay: this.url },
);
if (this.closed) {
try {
sub.close();
} catch {
// ignore
}
return;
}
this.subs.set(subId, sub);
} catch {
// The shell refused the subscription (unknown relay, policy, ...).
// Report EOSE so the app stops waiting; per-relay status stays usable.
this.emit(["EOSE", subId]);
}
}
private async handlePublish(record: Record<string, unknown>): Promise<void> {
// The shell signs with the viewer's identity; the app must not pre-sign.
// Accept the app's signed-or-unsigned event and forward its template.
const template = {
kind: typeof record.kind === "number" ? record.kind : 1,
created_at:
typeof record.created_at === "number" ? record.created_at : Math.floor(Date.now() / 1000),
tags: Array.isArray(record.tags) ? (record.tags as string[][]) : [],
content: typeof record.content === "string" ? record.content : "",
};
try {
const { relayPublish } = await import("@napplet/sdk");
const published: NostrEvent = await relayPublish(template, { relay: true });
// Synthesize the OK the app's publish tracker waits for. The published
// id differs from any client-side pre-image; report the real one.
this.emit(["OK", published.id, true, ""]);
} catch {
const fallbackId = typeof record.id === "string" ? record.id : "";
this.emit(["OK", fallbackId, false, "publish rejected by host"]);
}
}
close(): void {
if (this.readyState === NAPPLET_WS_CLOSED) return;
this.readyState = NAPPLET_WS_CLOSING;
this.fail(1000, "closed by app");
}
}
/** Drop-in replacement for `new WebSocket(url)` in napplet mode. */
export function createRelaySocket(url: string): NappletRelaySocket {
return new NappletRelaySocket(url);
}
