@yappr/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ import { type ConnStatus, type SocketFactory } from "./connection.js";
2
+ import { type Message } from "./log.js";
3
+ import { type YapprStorage } from "./storage.js";
4
+ export interface ChannelState {
5
+ messages: Message[];
6
+ status: ConnStatus;
7
+ unreadCount: number;
8
+ hasOlder: boolean;
9
+ }
10
+ export interface ChannelConfig {
11
+ channelId: string;
12
+ buildUrl: (since: number) => string;
13
+ selfUserId: string;
14
+ storage: YapprStorage;
15
+ createSocket?: SocketFactory;
16
+ genId?: () => string;
17
+ now?: () => number;
18
+ setTimer?: (fn: () => void, ms: number) => unknown;
19
+ clearTimer?: (h: unknown) => void;
20
+ random?: () => number;
21
+ }
22
+ export declare class Channel {
23
+ #private;
24
+ constructor(cfg: ChannelConfig);
25
+ init(): Promise<void>;
26
+ subscribe(listener: () => void): () => void;
27
+ getSnapshot(): ChannelState;
28
+ send(content: string): void;
29
+ loadOlder(): Promise<void>;
30
+ markRead(seq: number): void;
31
+ close(): void;
32
+ }
@@ -0,0 +1,191 @@
1
+ import { Connection } from "./connection.js";
2
+ import { MessageLog } from "./log.js";
3
+ import { Outbox } from "./outbox.js";
4
+ import { UnreadTracker } from "./unread.js";
5
+ import { storageKey } from "./storage.js";
6
+ const PAGE_SIZE = 50;
7
+ export class Channel {
8
+ #cfg;
9
+ #genId;
10
+ #now;
11
+ #log = new MessageLog();
12
+ #outbox;
13
+ #unread;
14
+ #conn;
15
+ #listeners = new Set();
16
+ #status = "disconnected";
17
+ #hasOlder = false;
18
+ #snapshot = null;
19
+ #dirty = true;
20
+ #loadingOlder = null;
21
+ constructor(cfg) {
22
+ this.#cfg = cfg;
23
+ this.#genId = cfg.genId ?? (() => crypto.randomUUID());
24
+ this.#now = cfg.now ?? (() => Date.now());
25
+ this.#outbox = new Outbox(cfg.storage, storageKey(cfg.channelId, "outbox"));
26
+ this.#unread = new UnreadTracker(cfg.storage, storageKey(cfg.channelId, "lastRead"));
27
+ this.#conn = new Connection({
28
+ getUrl: () => cfg.buildUrl(this.#log.newestSeq() ?? 0),
29
+ onOpen: () => this.#onOpen(),
30
+ onMessage: (m) => this.#onMessage(m),
31
+ onStatus: (s) => this.#onStatus(s),
32
+ createSocket: cfg.createSocket,
33
+ setTimer: cfg.setTimer,
34
+ clearTimer: cfg.clearTimer,
35
+ random: cfg.random,
36
+ });
37
+ }
38
+ async init() {
39
+ try {
40
+ await this.#outbox.load();
41
+ await this.#unread.load();
42
+ for (const e of this.#outbox.entries()) {
43
+ this.#log.addPending({
44
+ status: "sending",
45
+ id: e.clientMsgId,
46
+ senderId: this.#cfg.selfUserId,
47
+ content: e.content,
48
+ createdAt: e.createdAt,
49
+ kind: "user",
50
+ });
51
+ }
52
+ }
53
+ catch {
54
+ // storage failure is non-fatal — degrade to empty state but still connect
55
+ }
56
+ this.#bump();
57
+ this.#conn.connect();
58
+ }
59
+ subscribe(listener) {
60
+ this.#listeners.add(listener);
61
+ return () => this.#listeners.delete(listener);
62
+ }
63
+ getSnapshot() {
64
+ if (this.#dirty || this.#snapshot === null) {
65
+ this.#snapshot = {
66
+ messages: this.#log.messages(),
67
+ status: this.#status,
68
+ unreadCount: this.#unread.unreadCount(),
69
+ hasOlder: this.#hasOlder,
70
+ };
71
+ this.#dirty = false;
72
+ }
73
+ return this.#snapshot;
74
+ }
75
+ send(content) {
76
+ const id = this.#genId();
77
+ const createdAt = this.#now();
78
+ this.#log.addPending({ status: "sending", id, senderId: this.#cfg.selfUserId, content, createdAt, kind: "user" });
79
+ void this.#outbox.add({ clientMsgId: id, content, createdAt }).catch(() => { });
80
+ this.#conn.send(JSON.stringify({ type: "send", clientMsgId: id, content }));
81
+ this.#bump();
82
+ }
83
+ loadOlder() {
84
+ if (!this.#hasOlder)
85
+ return Promise.resolve();
86
+ if (this.#loadingOlder)
87
+ return this.#loadingOlder.promise;
88
+ const before = this.#log.oldestSeq();
89
+ if (before === undefined)
90
+ return Promise.resolve();
91
+ let resolve;
92
+ const promise = new Promise((r) => { resolve = r; });
93
+ this.#loadingOlder = { promise, resolve };
94
+ const sent = this.#conn.send(JSON.stringify({ type: "loadOlder", before, limit: PAGE_SIZE }));
95
+ if (!sent) {
96
+ this.#loadingOlder = null;
97
+ resolve();
98
+ }
99
+ return promise;
100
+ }
101
+ markRead(seq) {
102
+ const prev = this.#unread.lastRead();
103
+ if (seq <= prev)
104
+ return;
105
+ // #advance sets #lastRead synchronously before its internal await, so
106
+ // unreadCount() reflects the new value immediately after this call.
107
+ void this.#unread.markRead(seq).catch(() => { });
108
+ this.#conn.send(JSON.stringify({ type: "read", seq }));
109
+ this.#bump();
110
+ }
111
+ close() {
112
+ this.#conn.close();
113
+ }
114
+ #onStatus(s) {
115
+ this.#status = s;
116
+ if (s === "disconnected" && this.#loadingOlder) {
117
+ this.#loadingOlder.resolve();
118
+ this.#loadingOlder = null;
119
+ }
120
+ this.#bump();
121
+ }
122
+ #onOpen() {
123
+ for (const e of this.#outbox.entries()) {
124
+ this.#conn.send(JSON.stringify({ type: "send", clientMsgId: e.clientMsgId, content: e.content }));
125
+ }
126
+ const lastRead = this.#unread.lastRead();
127
+ if (lastRead > 0) {
128
+ this.#conn.send(JSON.stringify({ type: "read", seq: lastRead }));
129
+ }
130
+ }
131
+ #onMessage(msg) {
132
+ switch (msg.type) {
133
+ case "sync": {
134
+ this.#log.applyServer(msg.messages);
135
+ for (const m of msg.messages) {
136
+ if (this.#outbox.has(m.id))
137
+ void this.#outbox.remove(m.id).catch(() => { });
138
+ }
139
+ const newest = this.#log.newestSeq();
140
+ if (newest !== undefined)
141
+ this.#unread.observe(newest);
142
+ void this.#unread.reconcileServer(msg.lastReadSeq).then((c) => { if (c)
143
+ this.#bump(); }).catch(() => { });
144
+ this.#hasOlder = this.#computeHasOlder();
145
+ this.#bump();
146
+ break;
147
+ }
148
+ case "add": {
149
+ const isOwn = this.#outbox.has(msg.message.id);
150
+ this.#log.confirm(msg.message);
151
+ if (isOwn) {
152
+ void this.#outbox.remove(msg.message.id).catch(() => { });
153
+ void this.#unread.markRead(msg.message.seq).catch(() => { });
154
+ }
155
+ this.#unread.observe(msg.message.seq);
156
+ this.#bump();
157
+ break;
158
+ }
159
+ case "page": {
160
+ this.#log.prepend(msg.messages);
161
+ this.#hasOlder = msg.messages.length === 0 ? false : this.#computeHasOlder();
162
+ this.#loadingOlder?.resolve();
163
+ this.#loadingOlder = null;
164
+ this.#bump();
165
+ break;
166
+ }
167
+ case "read": {
168
+ void this.#unread.reconcileServer(msg.seq).then((c) => { if (c)
169
+ this.#bump(); }).catch(() => { });
170
+ break;
171
+ }
172
+ case "error": {
173
+ if (msg.clientMsgId) {
174
+ this.#log.markFailed(msg.clientMsgId);
175
+ void this.#outbox.remove(msg.clientMsgId).catch(() => { });
176
+ this.#bump();
177
+ }
178
+ break;
179
+ }
180
+ }
181
+ }
182
+ // Older history exists when the oldest message we hold isn't seq 1.
183
+ #computeHasOlder() {
184
+ return (this.#log.oldestSeq() ?? 1) > 1;
185
+ }
186
+ #bump() {
187
+ this.#dirty = true;
188
+ for (const l of this.#listeners)
189
+ l();
190
+ }
191
+ }
@@ -0,0 +1,31 @@
1
+ import { type ChannelState } from "./channel.js";
2
+ import { type YapprStorage } from "./storage.js";
3
+ import type { SocketFactory } from "./connection.js";
4
+ export interface YapprClientConfig {
5
+ url?: string;
6
+ tenantId: string;
7
+ key: string;
8
+ userId: string;
9
+ displayName?: string;
10
+ token?: string;
11
+ storage?: YapprStorage;
12
+ createSocket?: SocketFactory;
13
+ genId?: () => string;
14
+ now?: () => number;
15
+ setTimer?: (fn: () => void, ms: number) => unknown;
16
+ clearTimer?: (h: unknown) => void;
17
+ random?: () => number;
18
+ }
19
+ export interface ChannelHandle {
20
+ subscribe(listener: () => void): () => void;
21
+ getSnapshot(): ChannelState;
22
+ send(content: string): void;
23
+ loadOlder(): Promise<void>;
24
+ markRead(seq: number): void;
25
+ close(): void;
26
+ }
27
+ export interface YapprClient {
28
+ channel(channelId: string): ChannelHandle;
29
+ close(): void;
30
+ }
31
+ export declare function createClient(config: YapprClientConfig): YapprClient;
package/dist/client.js ADDED
@@ -0,0 +1,47 @@
1
+ import { Channel } from "./channel.js";
2
+ import { MemoryStorage } from "./storage.js";
3
+ const DEFAULT_URL = "wss://api.yappr.sh";
4
+ export function createClient(config) {
5
+ const storage = config.storage ?? new MemoryStorage();
6
+ const channels = new Map();
7
+ const buildUrl = (channelId, since) => {
8
+ const params = new URLSearchParams();
9
+ params.set("key", config.key);
10
+ if (config.userId)
11
+ params.set("userId", config.userId);
12
+ if (config.displayName)
13
+ params.set("displayName", config.displayName);
14
+ if (config.token)
15
+ params.set("token", config.token);
16
+ if (since > 0)
17
+ params.set("since", String(since));
18
+ return `${config.url ?? DEFAULT_URL}/parties/chat/${config.tenantId}:${channelId}?${params.toString()}`;
19
+ };
20
+ return {
21
+ channel(channelId) {
22
+ const existing = channels.get(channelId);
23
+ if (existing)
24
+ return existing;
25
+ const ch = new Channel({
26
+ channelId,
27
+ buildUrl: (since) => buildUrl(channelId, since),
28
+ selfUserId: config.userId,
29
+ storage,
30
+ createSocket: config.createSocket,
31
+ genId: config.genId,
32
+ now: config.now,
33
+ setTimer: config.setTimer,
34
+ clearTimer: config.clearTimer,
35
+ random: config.random,
36
+ });
37
+ channels.set(channelId, ch);
38
+ void ch.init();
39
+ return ch;
40
+ },
41
+ close() {
42
+ for (const ch of channels.values())
43
+ ch.close();
44
+ channels.clear();
45
+ },
46
+ };
47
+ }
@@ -0,0 +1,32 @@
1
+ import { type ServerMessage } from "./protocol.js";
2
+ export type ConnStatus = "connecting" | "connected" | "disconnected";
3
+ export interface SocketLike {
4
+ send(d: string): void;
5
+ close(code?: number, reason?: string): void;
6
+ readyState: number;
7
+ onopen: (() => void) | null;
8
+ onclose: (() => void) | null;
9
+ onerror: (() => void) | null;
10
+ onmessage: ((ev: {
11
+ data: unknown;
12
+ }) => void) | null;
13
+ }
14
+ export type SocketFactory = (url: string) => SocketLike;
15
+ export interface ConnectionOptions {
16
+ getUrl: () => string;
17
+ onOpen: () => void;
18
+ onMessage: (m: ServerMessage) => void;
19
+ onStatus: (s: ConnStatus) => void;
20
+ createSocket?: SocketFactory;
21
+ setTimer?: (fn: () => void, ms: number) => unknown;
22
+ clearTimer?: (h: unknown) => void;
23
+ random?: () => number;
24
+ }
25
+ export declare class Connection {
26
+ #private;
27
+ constructor(o: ConnectionOptions);
28
+ status(): ConnStatus;
29
+ connect(): void;
30
+ send(d: string): boolean;
31
+ close(): void;
32
+ }
@@ -0,0 +1,85 @@
1
+ import { parseServerMessage } from "./protocol.js";
2
+ const OPEN = 1;
3
+ export class Connection {
4
+ #o;
5
+ #socket = null;
6
+ #status = "disconnected";
7
+ #attempt = 0;
8
+ #closing = false;
9
+ #timer = null;
10
+ constructor(o) {
11
+ this.#o = {
12
+ getUrl: o.getUrl,
13
+ onOpen: o.onOpen,
14
+ onMessage: o.onMessage,
15
+ onStatus: o.onStatus,
16
+ createSocket: o.createSocket ?? ((url) => new WebSocket(url)),
17
+ setTimer: o.setTimer ?? ((fn, ms) => setTimeout(fn, ms)),
18
+ clearTimer: o.clearTimer ?? ((h) => clearTimeout(h)),
19
+ random: o.random ?? Math.random,
20
+ };
21
+ }
22
+ status() {
23
+ return this.#status;
24
+ }
25
+ connect() {
26
+ if (this.#timer !== null) {
27
+ this.#o.clearTimer(this.#timer);
28
+ this.#timer = null;
29
+ }
30
+ this.#closing = false;
31
+ this.#setStatus("connecting");
32
+ const socket = this.#o.createSocket(this.#o.getUrl());
33
+ this.#socket = socket;
34
+ socket.onopen = () => {
35
+ this.#attempt = 0;
36
+ this.#setStatus("connected");
37
+ this.#o.onOpen();
38
+ };
39
+ socket.onmessage = (ev) => {
40
+ if (typeof ev.data !== "string")
41
+ return;
42
+ const msg = parseServerMessage(ev.data);
43
+ if (msg)
44
+ this.#o.onMessage(msg);
45
+ };
46
+ socket.onclose = () => this.#onDrop();
47
+ socket.onerror = () => this.#onDrop();
48
+ }
49
+ send(d) {
50
+ if (this.#socket && this.#socket.readyState === OPEN) {
51
+ this.#socket.send(d);
52
+ return true;
53
+ }
54
+ return false;
55
+ }
56
+ close() {
57
+ this.#closing = true;
58
+ if (this.#timer !== null) {
59
+ this.#o.clearTimer(this.#timer);
60
+ this.#timer = null;
61
+ }
62
+ this.#socket?.close();
63
+ this.#setStatus("disconnected");
64
+ }
65
+ #onDrop() {
66
+ if (this.#closing || this.#timer !== null)
67
+ return;
68
+ this.#setStatus("disconnected");
69
+ const ms = this.#delay(this.#attempt++);
70
+ this.#timer = this.#o.setTimer(() => {
71
+ this.#timer = null;
72
+ this.connect();
73
+ }, ms);
74
+ }
75
+ #delay(attempt) {
76
+ const base = Math.min(15000, 500 * 2 ** attempt);
77
+ return base / 2 + this.#o.random() * (base / 2);
78
+ }
79
+ #setStatus(s) {
80
+ if (this.#status === s)
81
+ return;
82
+ this.#status = s;
83
+ this.#o.onStatus(s);
84
+ }
85
+ }
@@ -0,0 +1,8 @@
1
+ export * from "./protocol.js";
2
+ export * from "./storage.js";
3
+ export { Outbox, type OutboxEntry } from "./outbox.js";
4
+ export { MessageLog, type Message, type PendingMessage } from "./log.js";
5
+ export { UnreadTracker } from "./unread.js";
6
+ export { Connection, type ConnStatus, type SocketLike, type SocketFactory } from "./connection.js";
7
+ export { Channel, type ChannelState, type ChannelConfig } from "./channel.js";
8
+ export { createClient, type YapprClient, type YapprClientConfig, type ChannelHandle, } from "./client.js";
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export * from "./protocol.js";
2
+ export * from "./storage.js";
3
+ export { Outbox } from "./outbox.js";
4
+ export { MessageLog } from "./log.js";
5
+ export { UnreadTracker } from "./unread.js";
6
+ export { Connection } from "./connection.js";
7
+ export { Channel } from "./channel.js";
8
+ export { createClient, } from "./client.js";
package/dist/log.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ import type { Envelope } from "./protocol.js";
2
+ export interface PendingMessage {
3
+ status: "sending" | "failed";
4
+ id: string;
5
+ senderId: string;
6
+ content: string;
7
+ createdAt: number;
8
+ kind: "user";
9
+ }
10
+ export type Message = (Envelope & {
11
+ status: "sent";
12
+ }) | PendingMessage;
13
+ export declare class MessageLog {
14
+ #private;
15
+ addPending(p: PendingMessage): void;
16
+ confirm(env: Envelope): void;
17
+ applyServer(envs: Envelope[]): void;
18
+ prepend(envs: Envelope[]): void;
19
+ markFailed(id: string): void;
20
+ oldestSeq(): number | undefined;
21
+ newestSeq(): number | undefined;
22
+ messages(): Message[];
23
+ }
package/dist/log.js ADDED
@@ -0,0 +1,59 @@
1
+ export class MessageLog {
2
+ #confirmed = new Map(); // seq -> envelope
3
+ #confirmedIds = new Set();
4
+ #pending = [];
5
+ // Seq bounds tracked incrementally; confirmed rows are never removed, so
6
+ // these stay valid without rescanning the map on every read.
7
+ #minSeq;
8
+ #maxSeq;
9
+ addPending(p) {
10
+ if (this.#confirmedIds.has(p.id))
11
+ return;
12
+ if (this.#pending.some((x) => x.id === p.id))
13
+ return;
14
+ this.#pending.push(p);
15
+ }
16
+ confirm(env) {
17
+ this.#removePending(env.id);
18
+ this.#upsert(env);
19
+ }
20
+ applyServer(envs) {
21
+ for (const e of envs) {
22
+ this.#removePending(e.id);
23
+ this.#upsert(e);
24
+ }
25
+ }
26
+ prepend(envs) {
27
+ for (const e of envs)
28
+ if (!this.#confirmedIds.has(e.id))
29
+ this.#upsert(e);
30
+ }
31
+ markFailed(id) {
32
+ const p = this.#pending.find((x) => x.id === id);
33
+ if (p)
34
+ p.status = "failed";
35
+ }
36
+ oldestSeq() {
37
+ return this.#minSeq;
38
+ }
39
+ newestSeq() {
40
+ return this.#maxSeq;
41
+ }
42
+ messages() {
43
+ const confirmed = [...this.#confirmed.values()]
44
+ .sort((a, b) => a.seq - b.seq)
45
+ .map((e) => ({ ...e, status: "sent" }));
46
+ return [...confirmed, ...this.#pending];
47
+ }
48
+ #upsert(env) {
49
+ this.#confirmed.set(env.seq, env);
50
+ this.#confirmedIds.add(env.id);
51
+ if (this.#minSeq === undefined || env.seq < this.#minSeq)
52
+ this.#minSeq = env.seq;
53
+ if (this.#maxSeq === undefined || env.seq > this.#maxSeq)
54
+ this.#maxSeq = env.seq;
55
+ }
56
+ #removePending(id) {
57
+ this.#pending = this.#pending.filter((x) => x.id !== id);
58
+ }
59
+ }
@@ -0,0 +1,15 @@
1
+ import type { YapprStorage } from "./storage.js";
2
+ export interface OutboxEntry {
3
+ clientMsgId: string;
4
+ content: string;
5
+ createdAt: number;
6
+ }
7
+ export declare class Outbox {
8
+ #private;
9
+ constructor(storage: YapprStorage, key: string);
10
+ load(): Promise<void>;
11
+ entries(): OutboxEntry[];
12
+ has(id: string): boolean;
13
+ add(entry: OutboxEntry): Promise<void>;
14
+ remove(id: string): Promise<void>;
15
+ }
package/dist/outbox.js ADDED
@@ -0,0 +1,46 @@
1
+ function isEntry(x) {
2
+ return (typeof x === "object" && x !== null &&
3
+ typeof x.clientMsgId === "string" &&
4
+ typeof x.content === "string" &&
5
+ typeof x.createdAt === "number");
6
+ }
7
+ export class Outbox {
8
+ #storage;
9
+ #key;
10
+ #entries = [];
11
+ constructor(storage, key) {
12
+ this.#storage = storage;
13
+ this.#key = key;
14
+ }
15
+ async load() {
16
+ const raw = await this.#storage.get(this.#key);
17
+ if (!raw)
18
+ return;
19
+ try {
20
+ const parsed = JSON.parse(raw);
21
+ if (Array.isArray(parsed))
22
+ this.#entries = parsed.filter(isEntry);
23
+ }
24
+ catch {
25
+ this.#entries = [];
26
+ }
27
+ }
28
+ entries() {
29
+ return this.#entries;
30
+ }
31
+ has(id) {
32
+ return this.#entries.some((e) => e.clientMsgId === id);
33
+ }
34
+ async add(entry) {
35
+ if (!this.has(entry.clientMsgId))
36
+ this.#entries.push(entry);
37
+ await this.#persist();
38
+ }
39
+ async remove(id) {
40
+ this.#entries = this.#entries.filter((e) => e.clientMsgId !== id);
41
+ await this.#persist();
42
+ }
43
+ async #persist() {
44
+ await this.#storage.set(this.#key, JSON.stringify(this.#entries));
45
+ }
46
+ }
@@ -0,0 +1,46 @@
1
+ export type MessageKind = "user";
2
+ export interface Envelope {
3
+ seq: number;
4
+ id: string;
5
+ senderId: string;
6
+ content: string;
7
+ createdAt: number;
8
+ kind: MessageKind;
9
+ }
10
+ export type ClientMessage = {
11
+ type: "send";
12
+ clientMsgId: string;
13
+ content: string;
14
+ } | {
15
+ type: "read";
16
+ seq: number;
17
+ } | {
18
+ type: "loadOlder";
19
+ before: number;
20
+ limit: number;
21
+ };
22
+ export type ParsedClient = ClientMessage | {
23
+ type: "__unknown__";
24
+ };
25
+ export type ServerMessage = {
26
+ type: "sync";
27
+ messages: Envelope[];
28
+ hasGap: boolean;
29
+ lastReadSeq: number;
30
+ } | {
31
+ type: "add";
32
+ message: Envelope;
33
+ } | {
34
+ type: "page";
35
+ messages: Envelope[];
36
+ } | {
37
+ type: "read";
38
+ seq: number;
39
+ } | {
40
+ type: "error";
41
+ code: string;
42
+ message: string;
43
+ clientMsgId?: string;
44
+ };
45
+ export declare function parseClientMessage(raw: string): ParsedClient;
46
+ export declare function parseServerMessage(raw: string): ServerMessage | null;
@@ -0,0 +1,77 @@
1
+ function isEnvelope(x) {
2
+ if (typeof x !== "object" || x === null)
3
+ return false;
4
+ const e = x;
5
+ return (typeof e.seq === "number" && typeof e.id === "string" &&
6
+ typeof e.senderId === "string" && typeof e.content === "string" &&
7
+ typeof e.createdAt === "number" && typeof e.kind === "string");
8
+ }
9
+ function asObject(raw) {
10
+ const data = JSON.parse(raw); // may throw — caller decides
11
+ if (typeof data !== "object" || data === null)
12
+ throw new Error("not_an_object");
13
+ return data;
14
+ }
15
+ export function parseClientMessage(raw) {
16
+ let data;
17
+ try {
18
+ data = asObject(raw);
19
+ }
20
+ catch {
21
+ throw new Error("invalid_json");
22
+ }
23
+ switch (data.type) {
24
+ case "send":
25
+ if (typeof data.clientMsgId !== "string" || typeof data.content !== "string")
26
+ throw new Error("invalid_client_message");
27
+ return { type: "send", clientMsgId: data.clientMsgId, content: data.content };
28
+ case "read":
29
+ if (typeof data.seq !== "number")
30
+ throw new Error("invalid_client_message");
31
+ return { type: "read", seq: data.seq };
32
+ case "loadOlder":
33
+ if (typeof data.before !== "number" || typeof data.limit !== "number")
34
+ throw new Error("invalid_client_message");
35
+ return { type: "loadOlder", before: data.before, limit: data.limit };
36
+ default:
37
+ return { type: "__unknown__" }; // forward-compat: ignore unknown types
38
+ }
39
+ }
40
+ export function parseServerMessage(raw) {
41
+ let data;
42
+ try {
43
+ data = asObject(raw);
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ switch (data.type) {
49
+ case "sync":
50
+ if (Array.isArray(data.messages) && data.messages.every(isEnvelope) && typeof data.hasGap === "boolean" && typeof data.lastReadSeq === "number")
51
+ return { type: "sync", messages: data.messages, hasGap: data.hasGap, lastReadSeq: data.lastReadSeq };
52
+ return null;
53
+ case "add":
54
+ if (isEnvelope(data.message))
55
+ return { type: "add", message: data.message };
56
+ return null;
57
+ case "page":
58
+ if (Array.isArray(data.messages) && data.messages.every(isEnvelope))
59
+ return { type: "page", messages: data.messages };
60
+ return null;
61
+ case "read":
62
+ if (typeof data.seq === "number")
63
+ return { type: "read", seq: data.seq };
64
+ return null;
65
+ case "error":
66
+ if (typeof data.code === "string" && typeof data.message === "string")
67
+ return {
68
+ type: "error",
69
+ code: data.code,
70
+ message: data.message,
71
+ ...(typeof data.clientMsgId === "string" ? { clientMsgId: data.clientMsgId } : {}),
72
+ };
73
+ return null;
74
+ default:
75
+ return null; // forward-compat: ignore unknown types
76
+ }
77
+ }
@@ -0,0 +1,12 @@
1
+ export interface YapprStorage {
2
+ get(key: string): Promise<string | null>;
3
+ set(key: string, value: string): Promise<void>;
4
+ remove(key: string): Promise<void>;
5
+ }
6
+ export declare class MemoryStorage implements YapprStorage {
7
+ #private;
8
+ get(key: string): Promise<string | null>;
9
+ set(key: string, value: string): Promise<void>;
10
+ remove(key: string): Promise<void>;
11
+ }
12
+ export declare function storageKey(channelId: string, name: string): string;
@@ -0,0 +1,15 @@
1
+ export class MemoryStorage {
2
+ #m = new Map();
3
+ async get(key) {
4
+ return this.#m.has(key) ? this.#m.get(key) : null;
5
+ }
6
+ async set(key, value) {
7
+ this.#m.set(key, value);
8
+ }
9
+ async remove(key) {
10
+ this.#m.delete(key);
11
+ }
12
+ }
13
+ export function storageKey(channelId, name) {
14
+ return `yappr:${channelId}:${name}`;
15
+ }
@@ -0,0 +1,11 @@
1
+ import type { YapprStorage } from "./storage.js";
2
+ export declare class UnreadTracker {
3
+ #private;
4
+ constructor(storage: YapprStorage, key: string);
5
+ load(): Promise<void>;
6
+ lastRead(): number;
7
+ observe(seq: number): void;
8
+ unreadCount(): number;
9
+ markRead(seq: number): Promise<boolean>;
10
+ reconcileServer(seq: number): Promise<boolean>;
11
+ }
package/dist/unread.js ADDED
@@ -0,0 +1,39 @@
1
+ export class UnreadTracker {
2
+ #storage;
3
+ #key;
4
+ #lastRead = 0;
5
+ #highest = 0;
6
+ constructor(storage, key) {
7
+ this.#storage = storage;
8
+ this.#key = key;
9
+ }
10
+ async load() {
11
+ const raw = await this.#storage.get(this.#key);
12
+ const n = raw ? parseInt(raw, 10) : NaN;
13
+ if (Number.isFinite(n))
14
+ this.#lastRead = n;
15
+ }
16
+ lastRead() {
17
+ return this.#lastRead;
18
+ }
19
+ observe(seq) {
20
+ if (seq > this.#highest)
21
+ this.#highest = seq;
22
+ }
23
+ unreadCount() {
24
+ return Math.max(0, this.#highest - this.#lastRead);
25
+ }
26
+ async markRead(seq) {
27
+ return this.#advance(seq);
28
+ }
29
+ async reconcileServer(seq) {
30
+ return this.#advance(seq);
31
+ }
32
+ async #advance(seq) {
33
+ if (seq <= this.#lastRead)
34
+ return false;
35
+ this.#lastRead = seq;
36
+ await this.#storage.set(this.#key, String(this.#lastRead));
37
+ return true;
38
+ }
39
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@yappr/core",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "files": [
8
+ "dist"
9
+ ],
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "devDependencies": {
14
+ "typescript": "5.9.3",
15
+ "vitest": "^3.0.0"
16
+ },
17
+ "scripts": {
18
+ "check": "tsc --noEmit -p tsconfig.json",
19
+ "build": "tsc -p tsconfig.build.json",
20
+ "test": "vitest run"
21
+ },
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js",
26
+ "default": "./dist/index.js"
27
+ }
28
+ }
29
+ }