@sansavision/yousaid 0.0.0-stage → 0.2.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.
- package/CHANGELOG.md +13 -0
- package/LICENSE +6 -0
- package/README.md +152 -2
- package/dist/index.d.ts +532 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +383 -0
- package/dist/proof-strings.d.ts +39 -0
- package/dist/proof-strings.d.ts.map +1 -0
- package/dist/proof-strings.js +56 -0
- package/dist/proof-upload.d.ts +96 -0
- package/dist/proof-upload.d.ts.map +1 -0
- package/dist/proof-upload.js +174 -0
- package/dist/proof.d.ts +356 -0
- package/dist/proof.d.ts.map +1 -0
- package/dist/proof.js +278 -0
- package/dist/realtime.d.ts +113 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +246 -0
- package/package.json +37 -4
package/dist/proof.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { uploadProofMedia } from "./proof-upload.js";
|
|
2
|
+
function renderQuery(options) {
|
|
3
|
+
const params = new URLSearchParams();
|
|
4
|
+
if (options.bucket !== undefined)
|
|
5
|
+
params.set("b", String(options.bucket === "random" ? Math.floor(Math.random() * 100) : Math.max(0, Math.min(99, Math.floor(options.bucket)))));
|
|
6
|
+
const context = options.context ?? {};
|
|
7
|
+
const pairs = [["slot", "ctx_slot"], ["audience", "ctx_segment"], ["product", "ctx_product"], ["locale", "ctx_locale"], ["page", "ctx_page"]];
|
|
8
|
+
for (const [key, param] of pairs)
|
|
9
|
+
if (context[key])
|
|
10
|
+
params.set(param, String(context[key]).slice(0, key === "page" ? 200 : 48));
|
|
11
|
+
const query = params.toString();
|
|
12
|
+
return query ? `?${query}` : "";
|
|
13
|
+
}
|
|
14
|
+
function origin(endpoint) {
|
|
15
|
+
return new URL(endpoint ?? "https://yousaid.dev", "https://yousaid.dev").origin;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Fetches the eligibility-gated render manifest for a placement. No cookies are sent.
|
|
19
|
+
* @experimental
|
|
20
|
+
*/
|
|
21
|
+
export async function fetchProofRender(placementId, options = {}) {
|
|
22
|
+
if (!/^ppl_[0-9a-f]{32}$/.test(placementId))
|
|
23
|
+
throw new Error("Invalid placement id.");
|
|
24
|
+
const response = await (options.fetch ?? globalThis.fetch)(`${origin(options.endpoint)}/api/public/proof/render/${placementId}${renderQuery(options)}`, { credentials: "omit", signal: options.signal });
|
|
25
|
+
if (!response.ok)
|
|
26
|
+
throw new Error(`Proof render failed (${response.status}).`);
|
|
27
|
+
const data = await response.json();
|
|
28
|
+
if (data.schema !== "yousaid-proof-render-v1")
|
|
29
|
+
throw new Error("Unsupported proof render schema.");
|
|
30
|
+
return data;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A signed snapshot may be shown as a fallback only until it expires (the revocation SLA window).
|
|
34
|
+
* @experimental
|
|
35
|
+
*/
|
|
36
|
+
export function isRenderFresh(render, now = Date.now()) {
|
|
37
|
+
return Date.parse(render.expiresAt) > now;
|
|
38
|
+
}
|
|
39
|
+
/** Must match RENDER_PUBLIC_SIGNATURE_PREFIX in lib/proof-publish/render.ts. */
|
|
40
|
+
const RENDER_SIGNATURE_PREFIX = "yousaid-proof-render:";
|
|
41
|
+
const keySets = new Map();
|
|
42
|
+
/**
|
|
43
|
+
* Published proof keys (current and previous Ed25519 keys) from the same
|
|
44
|
+
* first-party origin as the render API, never a third party. Cached per origin
|
|
45
|
+
* for the page lifetime; a failed fetch is not cached.
|
|
46
|
+
* @experimental
|
|
47
|
+
*/
|
|
48
|
+
export function fetchProofKeys(options = {}) {
|
|
49
|
+
const base = origin(options.endpoint);
|
|
50
|
+
let cached = keySets.get(base);
|
|
51
|
+
if (!cached) {
|
|
52
|
+
cached = (async () => {
|
|
53
|
+
const response = await (options.fetch ?? globalThis.fetch)(`${base}/api/public/proof/receipts/keys`, { credentials: "omit" });
|
|
54
|
+
if (!response.ok)
|
|
55
|
+
throw new Error(`Proof keys failed (${response.status}).`);
|
|
56
|
+
const data = await response.json();
|
|
57
|
+
return { keys: Array.isArray(data.keys) ? data.keys.filter((key) => Boolean(key) && typeof key === "object") : [] };
|
|
58
|
+
})();
|
|
59
|
+
cached.catch(() => keySets.delete(base));
|
|
60
|
+
keySets.set(base, cached);
|
|
61
|
+
}
|
|
62
|
+
return cached;
|
|
63
|
+
}
|
|
64
|
+
/** Same output as canonicalJson in lib/proof/core.ts (sorted keys, undefined dropped in objects, null in arrays). */
|
|
65
|
+
function canonical(value) {
|
|
66
|
+
if (value === undefined)
|
|
67
|
+
return "null";
|
|
68
|
+
if (value === null || typeof value !== "object")
|
|
69
|
+
return JSON.stringify(value);
|
|
70
|
+
if (Array.isArray(value))
|
|
71
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
72
|
+
return `{${Object.entries(value).filter(([, item]) => item !== undefined)
|
|
73
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
74
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(",")}}`;
|
|
75
|
+
}
|
|
76
|
+
function base64UrlBytes(value) {
|
|
77
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value))
|
|
78
|
+
return null;
|
|
79
|
+
const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (value.length % 4)) % 4));
|
|
80
|
+
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* True only when `render.publicSignature` is a valid Ed25519 signature from a
|
|
84
|
+
* key in `keys` (matched by kid). Any failure, including a runtime without
|
|
85
|
+
* Ed25519 in WebCrypto, returns false: an unverifiable snapshot is never shown.
|
|
86
|
+
* @experimental
|
|
87
|
+
*/
|
|
88
|
+
export async function verifyProofRenderSignature(render, keys, subtle = globalThis.crypto?.subtle) {
|
|
89
|
+
const proof = render?.publicSignature;
|
|
90
|
+
if (!subtle || !proof || proof.alg !== "EdDSA" || typeof proof.kid !== "string" || typeof proof.signature !== "string")
|
|
91
|
+
return false;
|
|
92
|
+
const jwk = keys.keys.find((key) => key.kid === proof.kid && key.kty === "OKP" && key.crv === "Ed25519" && typeof key.x === "string");
|
|
93
|
+
const signature = base64UrlBytes(proof.signature);
|
|
94
|
+
if (!jwk || !signature)
|
|
95
|
+
return false;
|
|
96
|
+
const { signature: _hmac, publicSignature: _public, ...body } = render;
|
|
97
|
+
try {
|
|
98
|
+
const key = await subtle.importKey("jwk", { kty: "OKP", crv: "Ed25519", x: jwk.x }, { name: "Ed25519" }, false, ["verify"]);
|
|
99
|
+
return await subtle.verify({ name: "Ed25519" }, key, signature, new TextEncoder().encode(RENDER_SIGNATURE_PREFIX + canonical(body)));
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The stored static fallback to show when the live render failed, or null.
|
|
107
|
+
* Shown only when it is a render for this placement, unexpired, and carries a
|
|
108
|
+
* valid Ed25519 signature from a key the first-party JWKS publishes (previous
|
|
109
|
+
* keys stay published during a rotation). Fails closed: no keys, unknown kid,
|
|
110
|
+
* tampered payload or no Ed25519 support all yield null.
|
|
111
|
+
* @experimental
|
|
112
|
+
*/
|
|
113
|
+
export async function verifiedProofFallback(fallback, options = {}) {
|
|
114
|
+
const render = fallback;
|
|
115
|
+
const now = options.now ?? Date.now;
|
|
116
|
+
if (!render || typeof render !== "object" || render.schema !== "yousaid-proof-render-v1" || typeof render.expiresAt !== "string" || !isRenderFresh(render, now()))
|
|
117
|
+
return null;
|
|
118
|
+
if (options.placementId && render.placement?.id !== options.placementId)
|
|
119
|
+
return null;
|
|
120
|
+
try {
|
|
121
|
+
const keys = await fetchProofKeys({ endpoint: options.endpoint, fetch: options.fetch });
|
|
122
|
+
if (!await verifyProofRenderSignature(render, keys, options.subtle ?? globalThis.crypto?.subtle))
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
return isRenderFresh(render, now()) ? render : null;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Must match CONVERSION_NAME_PATTERN in lib/proof-publish/beacon.ts.
|
|
132
|
+
* @experimental
|
|
133
|
+
*/
|
|
134
|
+
export const PROOF_CONVERSION_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
135
|
+
/** Live beacons on this page, so one call can report a conversion to every placement shown. */
|
|
136
|
+
const activeBeacons = new Set();
|
|
137
|
+
/**
|
|
138
|
+
* Reports a site conversion to every proof placement rendered on this page.
|
|
139
|
+
* Returns how many placements received it. Call it where the conversion
|
|
140
|
+
* happens on the same page (for example on the signup form's success). A page
|
|
141
|
+
* with no proof placement sends nothing.
|
|
142
|
+
* @experimental
|
|
143
|
+
*/
|
|
144
|
+
export function sendProofConversion(name) {
|
|
145
|
+
if (!PROOF_CONVERSION_NAME.test(name))
|
|
146
|
+
return 0;
|
|
147
|
+
let sent = 0;
|
|
148
|
+
for (const beacon of activeBeacons)
|
|
149
|
+
if (beacon.conversion(name))
|
|
150
|
+
sent += 1;
|
|
151
|
+
// <yousaid-proof> Web Components on the same page report it too (each keeps its own page-memory list).
|
|
152
|
+
if (typeof document !== "undefined") {
|
|
153
|
+
document.querySelectorAll?.("yousaid-proof").forEach((element) => { if (element.conversion?.(name))
|
|
154
|
+
sent += 1; });
|
|
155
|
+
}
|
|
156
|
+
return sent;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Batched, cookie-free beacon. Flushes every 5 s, at 40 queued events and when
|
|
160
|
+
* the page is hidden; a conversion flushes at once. Returns a no-op beacon when
|
|
161
|
+
* there is no event token (previews, paused placements).
|
|
162
|
+
* @experimental
|
|
163
|
+
*/
|
|
164
|
+
export function createProofBeacon(token, options = {}) {
|
|
165
|
+
if (!token || typeof window === "undefined")
|
|
166
|
+
return { push() { }, conversion() { return false; }, flush() { }, close() { } };
|
|
167
|
+
const url = `${origin(options.endpoint)}/api/public/proof/events`;
|
|
168
|
+
const queue = [];
|
|
169
|
+
const seen = new Set();
|
|
170
|
+
/** Items with a qualified impression in this page load. Memory only. */
|
|
171
|
+
const impressed = new Set();
|
|
172
|
+
const flush = () => {
|
|
173
|
+
if (!queue.length)
|
|
174
|
+
return;
|
|
175
|
+
// `account`: sha256 hex of the site's own account id, for CRM influence reporting. Anything else is dropped.
|
|
176
|
+
const account = options.account && /^[a-f0-9]{64}$/.test(options.account) ? options.account : undefined;
|
|
177
|
+
const body = JSON.stringify({ token, events: queue.splice(0, 50), ...(account ? { acct: account } : {}) });
|
|
178
|
+
try {
|
|
179
|
+
if (navigator.sendBeacon?.(url, new Blob([body], { type: "text/plain" })))
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
catch { /* fall through */ }
|
|
183
|
+
void (options.fetch ?? fetch)(url, { method: "POST", body, keepalive: true, credentials: "omit", headers: { "content-type": "text/plain" } }).catch(() => undefined);
|
|
184
|
+
};
|
|
185
|
+
const timer = window.setInterval(flush, 5000);
|
|
186
|
+
const onHide = () => { if (document.visibilityState === "hidden")
|
|
187
|
+
flush(); };
|
|
188
|
+
document.addEventListener("visibilitychange", onHide);
|
|
189
|
+
const beacon = {
|
|
190
|
+
push(event, onceKey) {
|
|
191
|
+
if (onceKey) {
|
|
192
|
+
if (seen.has(onceKey))
|
|
193
|
+
return;
|
|
194
|
+
seen.add(onceKey);
|
|
195
|
+
}
|
|
196
|
+
if (event.t === "impression")
|
|
197
|
+
impressed.add(event.i);
|
|
198
|
+
queue.push(event);
|
|
199
|
+
if (queue.length >= 40)
|
|
200
|
+
flush();
|
|
201
|
+
},
|
|
202
|
+
conversion(name) {
|
|
203
|
+
if (!PROOF_CONVERSION_NAME.test(name) || seen.has(`conv:${name}`))
|
|
204
|
+
return false;
|
|
205
|
+
seen.add(`conv:${name}`);
|
|
206
|
+
queue.push({ t: "conversion", n: name, a: [...impressed].slice(0, 50) });
|
|
207
|
+
flush();
|
|
208
|
+
return true;
|
|
209
|
+
},
|
|
210
|
+
flush,
|
|
211
|
+
close() { flush(); window.clearInterval(timer); document.removeEventListener("visibilitychange", onHide); activeBeacons.delete(beacon); },
|
|
212
|
+
};
|
|
213
|
+
activeBeacons.add(beacon);
|
|
214
|
+
return beacon;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* YouSaid Proof collection client for the native in-app prompt. Uses the same
|
|
218
|
+
* signed end-user identity token as the rest of the SDK: identity is verified
|
|
219
|
+
* server-side and prefilled, and the customer still controls every field
|
|
220
|
+
* proposed for publication.
|
|
221
|
+
* @experimental
|
|
222
|
+
*/
|
|
223
|
+
export class YouSaidProofClient {
|
|
224
|
+
#options;
|
|
225
|
+
constructor(options) {
|
|
226
|
+
this.#options = options;
|
|
227
|
+
}
|
|
228
|
+
get origin() {
|
|
229
|
+
return new URL(this.#options.endpoint ?? "https://yousaid.dev", "https://yousaid.dev").origin;
|
|
230
|
+
}
|
|
231
|
+
/** Server-side frequency caps, "not now" and "never ask" across devices. */
|
|
232
|
+
async eligibility(formId) {
|
|
233
|
+
return this.#request(`/api/proof/collect/prompt?formId=${encodeURIComponent(formId)}`);
|
|
234
|
+
}
|
|
235
|
+
/** Starts (or resumes on this device) the known customer's draft. Consumes one token nonce. */
|
|
236
|
+
async startSession(formId, presentation = "drawer") {
|
|
237
|
+
const session = await this.#request("/api/proof/collect/sessions", { method: "POST", body: JSON.stringify({ formId, presentation }) });
|
|
238
|
+
return { draftId: session.draftId, sessionToken: session.sessionToken, formId: session.formId };
|
|
239
|
+
}
|
|
240
|
+
async recordPreference(formId, action, contactPreference) {
|
|
241
|
+
await this.#request("/api/proof/collect/prompt", { method: "POST", body: JSON.stringify({ formId, action, ...(contactPreference ? { contactPreference } : {}) }) });
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Resumable upload of a recording or image into the session's draft (R2
|
|
245
|
+
* multipart, up to 500 MB for video). Attach the returned `media` to an
|
|
246
|
+
* answer when you submit through your own UI.
|
|
247
|
+
*/
|
|
248
|
+
async uploadMedia(session, blob, mode, options = {}) {
|
|
249
|
+
return uploadProofMedia(session.draftId, session.sessionToken, blob, mode, { ...options, endpoint: this.origin, ...(this.#options.fetch ? { fetch: this.#options.fetch } : {}) });
|
|
250
|
+
}
|
|
251
|
+
/** The hosted, embeddable page. The session secret travels in the fragment, never in a request line. */
|
|
252
|
+
frameUrl(formId, session) {
|
|
253
|
+
const base = `${this.origin}/p/${encodeURIComponent(formId)}?embed=1`;
|
|
254
|
+
return session ? `${base}#d=${encodeURIComponent(session.draftId)}.${encodeURIComponent(session.sessionToken)}` : base;
|
|
255
|
+
}
|
|
256
|
+
/** Narrows a window message to a genuine event from this origin's collection frame. */
|
|
257
|
+
parseEvent(event, frame) {
|
|
258
|
+
if (event.origin !== this.origin)
|
|
259
|
+
return null;
|
|
260
|
+
if (frame && event.source !== frame.contentWindow)
|
|
261
|
+
return null;
|
|
262
|
+
const data = event.data;
|
|
263
|
+
return data && data.source === "yousaid-proof" && typeof data.type === "string" ? data : null;
|
|
264
|
+
}
|
|
265
|
+
async #request(path, init = {}) {
|
|
266
|
+
const token = typeof this.#options.token === "function" ? await this.#options.token() : this.#options.token;
|
|
267
|
+
const request = this.#options.fetch ?? globalThis.fetch;
|
|
268
|
+
const headers = new Headers(init.headers);
|
|
269
|
+
headers.set("authorization", `Bearer ${token}`);
|
|
270
|
+
if (init.body)
|
|
271
|
+
headers.set("content-type", "application/json");
|
|
272
|
+
const response = await request(`${this.origin}${path}`, { ...init, headers, credentials: "omit" });
|
|
273
|
+
const payload = await response.json().catch(() => ({}));
|
|
274
|
+
if (!response.ok)
|
|
275
|
+
throw new Error(payload.error ?? "YouSaid could not complete the Proof request.");
|
|
276
|
+
return payload;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Room realtime client (PRD 12.2). One WebSocket per Room with:
|
|
3
|
+
* - catch-up by event sequence: every (re)connect sends the last processed
|
|
4
|
+
* sequence; the server replays what was missed, or a snapshot plus the
|
|
5
|
+
* events after it when the client is far behind;
|
|
6
|
+
* - strict ordering: events are delivered once, in ascending sequence, even
|
|
7
|
+
* when live events race the replay during a reconnect;
|
|
8
|
+
* - reconnect with exponential backoff and jitter, a fresh short-lived token
|
|
9
|
+
* per attempt, and no reconnect after the server revokes access;
|
|
10
|
+
* - acknowledgements so the server knows the processed sequence.
|
|
11
|
+
*/
|
|
12
|
+
/** @experimental */
|
|
13
|
+
export type RoomRealtimeEvent = {
|
|
14
|
+
id: string;
|
|
15
|
+
sequence: number;
|
|
16
|
+
type: string;
|
|
17
|
+
actorType: string;
|
|
18
|
+
actorId: string;
|
|
19
|
+
visibility: string;
|
|
20
|
+
payload: unknown;
|
|
21
|
+
createdAt: string;
|
|
22
|
+
};
|
|
23
|
+
/** @experimental */
|
|
24
|
+
export type RoomSnapshotFrame = {
|
|
25
|
+
sequence: number;
|
|
26
|
+
events: RoomRealtimeEvent[];
|
|
27
|
+
};
|
|
28
|
+
/** @experimental */
|
|
29
|
+
export type RealtimeStatus = "idle" | "connecting" | "catching_up" | "open" | "reconnecting" | "closed" | "revoked";
|
|
30
|
+
/** @experimental */
|
|
31
|
+
export type PresenceFrame = {
|
|
32
|
+
type: string;
|
|
33
|
+
principalId?: string;
|
|
34
|
+
displayName?: string;
|
|
35
|
+
members?: Array<{
|
|
36
|
+
principalId: string;
|
|
37
|
+
displayName: string;
|
|
38
|
+
}>;
|
|
39
|
+
};
|
|
40
|
+
type SocketLike = {
|
|
41
|
+
readyState: number;
|
|
42
|
+
send(data: string): void;
|
|
43
|
+
close(code?: number, reason?: string): void;
|
|
44
|
+
onopen: ((event: unknown) => void) | null;
|
|
45
|
+
onmessage: ((event: {
|
|
46
|
+
data: unknown;
|
|
47
|
+
}) => void) | null;
|
|
48
|
+
onclose: ((event: {
|
|
49
|
+
code: number;
|
|
50
|
+
reason?: string;
|
|
51
|
+
}) => void) | null;
|
|
52
|
+
onerror: ((event: unknown) => void) | null;
|
|
53
|
+
};
|
|
54
|
+
/** @experimental */
|
|
55
|
+
export type SocketFactory = (url: string) => SocketLike;
|
|
56
|
+
/** @experimental */
|
|
57
|
+
export type RoomRealtimeOptions = {
|
|
58
|
+
roomId: string;
|
|
59
|
+
/** Mint a fresh connection: a short-lived Room token and the realtime URL (absolute or relative to `origin`). */
|
|
60
|
+
connect: () => Promise<{
|
|
61
|
+
url: string;
|
|
62
|
+
token: string;
|
|
63
|
+
}>;
|
|
64
|
+
origin?: string;
|
|
65
|
+
lastSequence?: number;
|
|
66
|
+
createSocket?: SocketFactory;
|
|
67
|
+
onEvent?: (event: RoomRealtimeEvent) => void;
|
|
68
|
+
onSnapshot?: (snapshot: RoomSnapshotFrame) => void;
|
|
69
|
+
onPresence?: (frame: PresenceFrame) => void;
|
|
70
|
+
onStatus?: (status: RealtimeStatus) => void;
|
|
71
|
+
onError?: (error: Error) => void;
|
|
72
|
+
backoff?: {
|
|
73
|
+
initialMs?: number;
|
|
74
|
+
maxMs?: number;
|
|
75
|
+
};
|
|
76
|
+
random?: () => number;
|
|
77
|
+
schedule?: (callback: () => void, ms: number) => unknown;
|
|
78
|
+
cancel?: (handle: unknown) => void;
|
|
79
|
+
/** Flush buffered replay if the server never sends `catchup.complete` (older servers). */
|
|
80
|
+
catchUpTimeoutMs?: number;
|
|
81
|
+
};
|
|
82
|
+
/** @experimental */
|
|
83
|
+
export declare class RoomRealtimeClient {
|
|
84
|
+
#private;
|
|
85
|
+
readonly roomId: string;
|
|
86
|
+
constructor(options: RoomRealtimeOptions);
|
|
87
|
+
get lastSequence(): number;
|
|
88
|
+
get status(): RealtimeStatus;
|
|
89
|
+
start(): void;
|
|
90
|
+
stop(): void;
|
|
91
|
+
/** Typing indicators are ephemeral and never replayed. */
|
|
92
|
+
sendTyping(active: boolean): void;
|
|
93
|
+
/** Apply events fetched over HTTP (for example /events?after_seq=) through the same ordering rules. */
|
|
94
|
+
ingest(frame: {
|
|
95
|
+
snapshot?: RoomSnapshotFrame | null;
|
|
96
|
+
events: RoomRealtimeEvent[];
|
|
97
|
+
}): void;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Reconnect delay for attempt n (0-based): full exponential cap with 50 to 100 percent jitter.
|
|
101
|
+
* @experimental
|
|
102
|
+
*/
|
|
103
|
+
export declare function reconnectDelay(attempt: number, random: number, initialMs?: number, maxMs?: number): number;
|
|
104
|
+
/**
|
|
105
|
+
* The partial @mention being typed at the caret, or null.
|
|
106
|
+
* @experimental
|
|
107
|
+
*/
|
|
108
|
+
export declare function mentionAtCaret(text: string, caret: number): {
|
|
109
|
+
query: string;
|
|
110
|
+
start: number;
|
|
111
|
+
} | null;
|
|
112
|
+
export {};
|
|
113
|
+
//# sourceMappingURL=realtime.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"realtime.d.ts","sourceRoot":"","sources":["../src/realtime.ts"],"names":[],"mappings":"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"}
|
package/dist/realtime.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Room realtime client (PRD 12.2). One WebSocket per Room with:
|
|
3
|
+
* - catch-up by event sequence: every (re)connect sends the last processed
|
|
4
|
+
* sequence; the server replays what was missed, or a snapshot plus the
|
|
5
|
+
* events after it when the client is far behind;
|
|
6
|
+
* - strict ordering: events are delivered once, in ascending sequence, even
|
|
7
|
+
* when live events race the replay during a reconnect;
|
|
8
|
+
* - reconnect with exponential backoff and jitter, a fresh short-lived token
|
|
9
|
+
* per attempt, and no reconnect after the server revokes access;
|
|
10
|
+
* - acknowledgements so the server knows the processed sequence.
|
|
11
|
+
*/
|
|
12
|
+
const TERMINAL_CLOSE_CODES = new Set([1008, 4401, 4403]);
|
|
13
|
+
/** @experimental */
|
|
14
|
+
export class RoomRealtimeClient {
|
|
15
|
+
roomId;
|
|
16
|
+
#options;
|
|
17
|
+
#socket = null;
|
|
18
|
+
#lastSequence;
|
|
19
|
+
#status = "idle";
|
|
20
|
+
#attempt = 0;
|
|
21
|
+
#stopped = true;
|
|
22
|
+
#timer = null;
|
|
23
|
+
#catchUpTimer = null;
|
|
24
|
+
#buffer = [];
|
|
25
|
+
#catchingUp = false;
|
|
26
|
+
constructor(options) {
|
|
27
|
+
this.roomId = options.roomId;
|
|
28
|
+
this.#options = options;
|
|
29
|
+
this.#lastSequence = Math.max(0, options.lastSequence ?? 0);
|
|
30
|
+
}
|
|
31
|
+
get lastSequence() { return this.#lastSequence; }
|
|
32
|
+
get status() { return this.#status; }
|
|
33
|
+
start() {
|
|
34
|
+
if (!this.#stopped)
|
|
35
|
+
return;
|
|
36
|
+
this.#stopped = false;
|
|
37
|
+
void this.#open();
|
|
38
|
+
}
|
|
39
|
+
stop() {
|
|
40
|
+
this.#stopped = true;
|
|
41
|
+
this.#clearTimers();
|
|
42
|
+
const socket = this.#socket;
|
|
43
|
+
this.#socket = null;
|
|
44
|
+
if (socket) {
|
|
45
|
+
socket.onclose = null;
|
|
46
|
+
try {
|
|
47
|
+
socket.close(1000, "client stopped");
|
|
48
|
+
}
|
|
49
|
+
catch { /* already closed */ }
|
|
50
|
+
}
|
|
51
|
+
this.#setStatus("closed");
|
|
52
|
+
}
|
|
53
|
+
/** Typing indicators are ephemeral and never replayed. */
|
|
54
|
+
sendTyping(active) { this.#send({ type: active ? "typing.start" : "typing.stop" }); }
|
|
55
|
+
/** Apply events fetched over HTTP (for example /events?after_seq=) through the same ordering rules. */
|
|
56
|
+
ingest(frame) {
|
|
57
|
+
if (frame.snapshot)
|
|
58
|
+
this.#applySnapshot(frame.snapshot);
|
|
59
|
+
this.#deliver(frame.events);
|
|
60
|
+
}
|
|
61
|
+
async #open() {
|
|
62
|
+
if (this.#stopped)
|
|
63
|
+
return;
|
|
64
|
+
this.#setStatus(this.#attempt === 0 ? "connecting" : "reconnecting");
|
|
65
|
+
let target;
|
|
66
|
+
try {
|
|
67
|
+
target = await this.#options.connect();
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
this.#options.onError?.(error instanceof Error ? error : new Error("Could not mint a Room connection."));
|
|
71
|
+
this.#scheduleReconnect();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (this.#stopped)
|
|
75
|
+
return;
|
|
76
|
+
const url = new URL(target.url, this.#options.origin ?? "https://yousaid.dev");
|
|
77
|
+
url.protocol = url.protocol === "http:" ? "ws:" : url.protocol === "https:" ? "wss:" : url.protocol;
|
|
78
|
+
url.searchParams.set("token", target.token);
|
|
79
|
+
url.searchParams.set("after_seq", String(this.#lastSequence));
|
|
80
|
+
const factory = this.#options.createSocket ?? ((address) => new WebSocket(address));
|
|
81
|
+
let socket;
|
|
82
|
+
try {
|
|
83
|
+
socket = factory(url.toString());
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
this.#options.onError?.(error instanceof Error ? error : new Error("WebSocket unavailable."));
|
|
87
|
+
this.#scheduleReconnect();
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
this.#socket = socket;
|
|
91
|
+
this.#catchingUp = true;
|
|
92
|
+
this.#buffer = [];
|
|
93
|
+
socket.onopen = () => { this.#attempt = 0; this.#setStatus("catching_up"); this.#armCatchUpTimeout(); };
|
|
94
|
+
socket.onmessage = (message) => this.#handle(message.data);
|
|
95
|
+
socket.onerror = () => { };
|
|
96
|
+
socket.onclose = (event) => {
|
|
97
|
+
if (this.#socket !== socket)
|
|
98
|
+
return;
|
|
99
|
+
this.#socket = null;
|
|
100
|
+
this.#flushCatchUp();
|
|
101
|
+
if (TERMINAL_CLOSE_CODES.has(event.code)) {
|
|
102
|
+
this.#stopped = true;
|
|
103
|
+
this.#setStatus("revoked");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
this.#scheduleReconnect();
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
#handle(data) {
|
|
110
|
+
let frame;
|
|
111
|
+
try {
|
|
112
|
+
frame = JSON.parse(typeof data === "string" ? data : new TextDecoder().decode(data));
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const type = String(frame.type ?? "");
|
|
118
|
+
if (type === "room.event" && frame.event) {
|
|
119
|
+
const event = frame.event;
|
|
120
|
+
if (this.#catchingUp)
|
|
121
|
+
this.#buffer.push(event);
|
|
122
|
+
else
|
|
123
|
+
this.#deliver([event]);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (type === "room.snapshot" && frame.snapshot) {
|
|
127
|
+
this.#applySnapshot(frame.snapshot);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (type === "catchup.complete") {
|
|
131
|
+
this.#flushCatchUp();
|
|
132
|
+
this.#setStatus("open");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (type === "access.revoked") {
|
|
136
|
+
this.#stopped = true;
|
|
137
|
+
this.#setStatus("revoked");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (type === "connected" || type === "pong" || type === "error")
|
|
141
|
+
return;
|
|
142
|
+
this.#options.onPresence?.(frame);
|
|
143
|
+
}
|
|
144
|
+
#applySnapshot(snapshot) {
|
|
145
|
+
if (snapshot.sequence <= this.#lastSequence)
|
|
146
|
+
return;
|
|
147
|
+
// Buffered events at or below the snapshot are already folded into it.
|
|
148
|
+
this.#buffer = this.#buffer.filter((event) => event.sequence > snapshot.sequence);
|
|
149
|
+
this.#lastSequence = snapshot.sequence;
|
|
150
|
+
this.#options.onSnapshot?.({ sequence: snapshot.sequence, events: [...snapshot.events].sort((left, right) => left.sequence - right.sequence) });
|
|
151
|
+
this.#ack();
|
|
152
|
+
}
|
|
153
|
+
#flushCatchUp() {
|
|
154
|
+
if (this.#catchUpTimer !== null) {
|
|
155
|
+
(this.#options.cancel ?? clearTimeout)(this.#catchUpTimer);
|
|
156
|
+
this.#catchUpTimer = null;
|
|
157
|
+
}
|
|
158
|
+
if (!this.#catchingUp)
|
|
159
|
+
return;
|
|
160
|
+
this.#catchingUp = false;
|
|
161
|
+
const buffered = this.#buffer;
|
|
162
|
+
this.#buffer = [];
|
|
163
|
+
this.#deliver(buffered);
|
|
164
|
+
}
|
|
165
|
+
/** Deliver in ascending sequence, dropping anything already processed. */
|
|
166
|
+
#deliver(events) {
|
|
167
|
+
const ordered = [...events].filter((event) => Number.isInteger(event.sequence)).sort((left, right) => left.sequence - right.sequence);
|
|
168
|
+
let delivered = false;
|
|
169
|
+
for (const event of ordered) {
|
|
170
|
+
if (event.sequence <= this.#lastSequence)
|
|
171
|
+
continue;
|
|
172
|
+
this.#lastSequence = event.sequence;
|
|
173
|
+
delivered = true;
|
|
174
|
+
try {
|
|
175
|
+
this.#options.onEvent?.(event);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
this.#options.onError?.(error instanceof Error ? error : new Error("Room event handler failed."));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (delivered)
|
|
182
|
+
this.#ack();
|
|
183
|
+
}
|
|
184
|
+
#ack() { this.#send({ type: "ack", sequence: this.#lastSequence }); }
|
|
185
|
+
#send(payload) {
|
|
186
|
+
const socket = this.#socket;
|
|
187
|
+
if (!socket || socket.readyState !== 1)
|
|
188
|
+
return;
|
|
189
|
+
try {
|
|
190
|
+
socket.send(JSON.stringify(payload));
|
|
191
|
+
}
|
|
192
|
+
catch { /* the close handler reconnects */ }
|
|
193
|
+
}
|
|
194
|
+
#armCatchUpTimeout() {
|
|
195
|
+
const schedule = this.#options.schedule ?? ((callback, ms) => setTimeout(callback, ms));
|
|
196
|
+
this.#catchUpTimer = schedule(() => { this.#catchUpTimer = null; this.#flushCatchUp(); if (this.#socket)
|
|
197
|
+
this.#setStatus("open"); }, this.#options.catchUpTimeoutMs ?? 3_000);
|
|
198
|
+
}
|
|
199
|
+
#scheduleReconnect() {
|
|
200
|
+
if (this.#stopped)
|
|
201
|
+
return;
|
|
202
|
+
this.#setStatus("reconnecting");
|
|
203
|
+
const initial = this.#options.backoff?.initialMs ?? 500;
|
|
204
|
+
const maximum = this.#options.backoff?.maxMs ?? 30_000;
|
|
205
|
+
const delay = reconnectDelay(this.#attempt, (this.#options.random ?? Math.random)(), initial, maximum);
|
|
206
|
+
this.#attempt += 1;
|
|
207
|
+
const schedule = this.#options.schedule ?? ((callback, ms) => setTimeout(callback, ms));
|
|
208
|
+
this.#timer = schedule(() => { this.#timer = null; void this.#open(); }, delay);
|
|
209
|
+
}
|
|
210
|
+
#clearTimers() {
|
|
211
|
+
const cancel = this.#options.cancel ?? ((handle) => clearTimeout(handle));
|
|
212
|
+
if (this.#timer !== null) {
|
|
213
|
+
cancel(this.#timer);
|
|
214
|
+
this.#timer = null;
|
|
215
|
+
}
|
|
216
|
+
if (this.#catchUpTimer !== null) {
|
|
217
|
+
cancel(this.#catchUpTimer);
|
|
218
|
+
this.#catchUpTimer = null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
#setStatus(status) {
|
|
222
|
+
if (this.#status === status)
|
|
223
|
+
return;
|
|
224
|
+
this.#status = status;
|
|
225
|
+
this.#options.onStatus?.(status);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Reconnect delay for attempt n (0-based): full exponential cap with 50 to 100 percent jitter.
|
|
230
|
+
* @experimental
|
|
231
|
+
*/
|
|
232
|
+
export function reconnectDelay(attempt, random, initialMs = 500, maxMs = 30_000) {
|
|
233
|
+
const exponential = Math.min(maxMs, initialMs * 2 ** attempt);
|
|
234
|
+
return Math.round(exponential / 2 + random * exponential / 2);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* The partial @mention being typed at the caret, or null.
|
|
238
|
+
* @experimental
|
|
239
|
+
*/
|
|
240
|
+
export function mentionAtCaret(text, caret) {
|
|
241
|
+
const before = text.slice(0, Math.max(0, caret));
|
|
242
|
+
const match = before.match(/(^|\s)@([a-z]{0,20})$/i);
|
|
243
|
+
if (!match)
|
|
244
|
+
return null;
|
|
245
|
+
return { query: (match[2] ?? "").toLowerCase(), start: before.length - (match[2] ?? "").length - 1 };
|
|
246
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sansavision/yousaid",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Framework-free YouSaid client: signals, inbox, release receipts and diagnostics.",
|
|
5
|
+
"private": false,
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"module": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE",
|
|
21
|
+
"CHANGELOG.md"
|
|
22
|
+
],
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/Sansa-Organisation/yousaid.git",
|
|
30
|
+
"directory": "packages/core"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://yousaid.dev/docs/sdk",
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20.0.0"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
}
|
|
39
|
+
}
|