@foir/sdk 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.
- package/README.md +110 -0
- package/dist/auth.d.ts +305 -0
- package/dist/auth.js +309 -0
- package/dist/chunk-ADO5FJNM.js +30 -0
- package/dist/client.d.ts +83 -0
- package/dist/client.js +81 -0
- package/dist/codegen/block-registry.d.ts +27 -0
- package/dist/codegen/block-registry.js +35 -0
- package/dist/codegen/block-scaffold.d.ts +28 -0
- package/dist/codegen/block-scaffold.js +45 -0
- package/dist/codegen/index.d.ts +65 -0
- package/dist/codegen/index.js +80 -0
- package/dist/content.d.ts +96 -0
- package/dist/content.js +161 -0
- package/dist/index.d.ts +394 -0
- package/dist/index.js +476 -0
- package/dist/media.d.ts +234 -0
- package/dist/media.js +399 -0
- package/dist/realtime.d.ts +111 -0
- package/dist/realtime.js +168 -0
- package/dist/server.d.ts +457 -0
- package/dist/server.js +296 -0
- package/dist/shared-DkWlEd2Z.d.ts +18 -0
- package/package.json +136 -0
package/dist/media.js
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
// src/media/ProgressiveMedia.tsx
|
|
2
|
+
import { useRef as useRef2, useState as useState2, useCallback as useCallback2, useEffect as useEffect3 } from "react";
|
|
3
|
+
|
|
4
|
+
// src/graphql/foir-request.ts
|
|
5
|
+
import { print } from "graphql";
|
|
6
|
+
var FOIR_CDN_BASE = "https://cdn.foir.dev/";
|
|
7
|
+
function absolutiseFoirMediaUrl(url, cdnBase = FOIR_CDN_BASE) {
|
|
8
|
+
if (!url) return void 0;
|
|
9
|
+
if (/^https?:\/\//i.test(url)) return url;
|
|
10
|
+
return cdnBase + url.replace(/^\/+/, "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// src/media/media.ts
|
|
14
|
+
var DEFAULT_VARIANT_TIERS = [
|
|
15
|
+
"thumbnail",
|
|
16
|
+
"small",
|
|
17
|
+
"medium",
|
|
18
|
+
"large",
|
|
19
|
+
"xlarge",
|
|
20
|
+
"_2xl",
|
|
21
|
+
"_3xl",
|
|
22
|
+
"_4xl"
|
|
23
|
+
];
|
|
24
|
+
function toProgressiveImage(img) {
|
|
25
|
+
if (!img?.url) return void 0;
|
|
26
|
+
const rawCrop = img.crop;
|
|
27
|
+
const crop = rawCrop && typeof rawCrop === "object" ? {
|
|
28
|
+
x: typeof rawCrop.x === "number" ? rawCrop.x : void 0,
|
|
29
|
+
y: typeof rawCrop.y === "number" ? rawCrop.y : void 0,
|
|
30
|
+
width: typeof rawCrop.width === "number" ? rawCrop.width : void 0,
|
|
31
|
+
height: typeof rawCrop.height === "number" ? rawCrop.height : void 0
|
|
32
|
+
} : void 0;
|
|
33
|
+
return {
|
|
34
|
+
url: img.url,
|
|
35
|
+
alt: img.alt ?? void 0,
|
|
36
|
+
width: img.width ?? void 0,
|
|
37
|
+
height: img.height ?? void 0,
|
|
38
|
+
blurhash: img.blurhash ?? void 0,
|
|
39
|
+
dominantColor: img.dominantColor ?? void 0,
|
|
40
|
+
thumbnailUrl: img.webp?.thumbnail?.url ?? void 0,
|
|
41
|
+
webp: img.webp ?? void 0,
|
|
42
|
+
avif: img.avif ?? void 0,
|
|
43
|
+
crop
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function toProgressiveVideo(v) {
|
|
47
|
+
if (!v?.url) return void 0;
|
|
48
|
+
return {
|
|
49
|
+
url: v.url,
|
|
50
|
+
width: v.width ?? void 0,
|
|
51
|
+
height: v.height ?? void 0,
|
|
52
|
+
hlsUrl: absolutiseFoirMediaUrl(v.hlsManifestUrl),
|
|
53
|
+
blurhash: v.blurhash ?? void 0,
|
|
54
|
+
dominantColor: v.dominantColor ?? void 0,
|
|
55
|
+
thumbnailUrl: v.thumbnailUrl ?? void 0,
|
|
56
|
+
previewUrl: v.previewUrl ?? void 0
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function effectiveImageDimensions(image) {
|
|
60
|
+
if (!image?.width || !image?.height) return null;
|
|
61
|
+
const crop = image.crop;
|
|
62
|
+
const cw = typeof crop?.width === "number" ? crop.width : null;
|
|
63
|
+
const ch = typeof crop?.height === "number" ? crop.height : null;
|
|
64
|
+
if (cw != null && ch != null && cw > 0 && ch > 0) {
|
|
65
|
+
return {
|
|
66
|
+
width: image.width * cw / 100,
|
|
67
|
+
height: image.height * ch / 100
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return { width: image.width, height: image.height };
|
|
71
|
+
}
|
|
72
|
+
function cropAspectRatio(image) {
|
|
73
|
+
const dims = effectiveImageDimensions(image);
|
|
74
|
+
if (!dims) return null;
|
|
75
|
+
return `${dims.width} / ${dims.height}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// src/media/useProgressiveLoad.ts
|
|
79
|
+
import { useState, useEffect, useCallback } from "react";
|
|
80
|
+
var STAGE_ORDER = ["color", "blurhash", "thumbnail", "full"];
|
|
81
|
+
function stageIndex(s) {
|
|
82
|
+
return STAGE_ORDER.indexOf(s);
|
|
83
|
+
}
|
|
84
|
+
function useProgressiveLoad({
|
|
85
|
+
blurhash,
|
|
86
|
+
thumbnailUrl,
|
|
87
|
+
fullUrl,
|
|
88
|
+
fullSrcSet,
|
|
89
|
+
fullSizes,
|
|
90
|
+
isVideo
|
|
91
|
+
}) {
|
|
92
|
+
const [stage, setStage] = useState(() => {
|
|
93
|
+
if (blurhash) return "blurhash";
|
|
94
|
+
if (thumbnailUrl) return "thumbnail";
|
|
95
|
+
return "color";
|
|
96
|
+
});
|
|
97
|
+
const advance = useCallback((target) => {
|
|
98
|
+
setStage(
|
|
99
|
+
(current) => stageIndex(target) > stageIndex(current) ? target : current
|
|
100
|
+
);
|
|
101
|
+
}, []);
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (!thumbnailUrl) return;
|
|
104
|
+
const img = new Image();
|
|
105
|
+
img.onload = () => advance("thumbnail");
|
|
106
|
+
img.src = thumbnailUrl;
|
|
107
|
+
}, [thumbnailUrl, advance]);
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!fullUrl || isVideo) return;
|
|
110
|
+
const img = new Image();
|
|
111
|
+
img.onload = () => advance("full");
|
|
112
|
+
if (fullSizes) img.sizes = fullSizes;
|
|
113
|
+
if (fullSrcSet) img.srcset = fullSrcSet;
|
|
114
|
+
img.src = fullUrl;
|
|
115
|
+
}, [fullUrl, fullSrcSet, fullSizes, isVideo, advance]);
|
|
116
|
+
const onFullReady = useCallback(() => advance("full"), [advance]);
|
|
117
|
+
return { stage, onFullReady };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// src/media/BlurhashCanvas.tsx
|
|
121
|
+
import { useEffect as useEffect2, useRef } from "react";
|
|
122
|
+
import { jsx } from "react/jsx-runtime";
|
|
123
|
+
var SIZE = 32;
|
|
124
|
+
function BlurhashCanvas({ hash, className }) {
|
|
125
|
+
const canvasRef = useRef(null);
|
|
126
|
+
useEffect2(() => {
|
|
127
|
+
const canvas = canvasRef.current;
|
|
128
|
+
if (!canvas) return;
|
|
129
|
+
import("blurhash").then(({ decode }) => {
|
|
130
|
+
const pixels = decode(hash, SIZE, SIZE);
|
|
131
|
+
canvas.width = SIZE;
|
|
132
|
+
canvas.height = SIZE;
|
|
133
|
+
const ctx = canvas.getContext("2d");
|
|
134
|
+
if (!ctx) return;
|
|
135
|
+
const imageData = ctx.createImageData(SIZE, SIZE);
|
|
136
|
+
imageData.data.set(pixels);
|
|
137
|
+
ctx.putImageData(imageData, 0, 0);
|
|
138
|
+
}).catch(() => {
|
|
139
|
+
});
|
|
140
|
+
}, [hash]);
|
|
141
|
+
return /* @__PURE__ */ jsx(
|
|
142
|
+
"canvas",
|
|
143
|
+
{
|
|
144
|
+
ref: canvasRef,
|
|
145
|
+
className: `h-full w-full object-cover${className ? ` ${className}` : ""}`
|
|
146
|
+
}
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/media/ProgressiveMedia.tsx
|
|
151
|
+
import { jsx as jsx2, jsxs } from "react/jsx-runtime";
|
|
152
|
+
function useInViewport(rootMargin = "300px", skip = false) {
|
|
153
|
+
const [inView, setInView] = useState2(skip);
|
|
154
|
+
const elRef = useRef2(null);
|
|
155
|
+
const obsRef = useRef2(null);
|
|
156
|
+
const setRef = useCallback2(
|
|
157
|
+
(el) => {
|
|
158
|
+
elRef.current = el;
|
|
159
|
+
if (skip || !el) return;
|
|
160
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
161
|
+
setInView(true);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
obsRef.current?.disconnect();
|
|
165
|
+
obsRef.current = new IntersectionObserver(
|
|
166
|
+
(entries) => {
|
|
167
|
+
for (const e of entries) {
|
|
168
|
+
if (e.isIntersecting) {
|
|
169
|
+
setInView(true);
|
|
170
|
+
obsRef.current?.disconnect();
|
|
171
|
+
obsRef.current = null;
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
{ rootMargin }
|
|
177
|
+
);
|
|
178
|
+
obsRef.current.observe(el);
|
|
179
|
+
},
|
|
180
|
+
[rootMargin, skip]
|
|
181
|
+
);
|
|
182
|
+
useEffect3(() => {
|
|
183
|
+
return () => obsRef.current?.disconnect();
|
|
184
|
+
}, []);
|
|
185
|
+
return [setRef, inView];
|
|
186
|
+
}
|
|
187
|
+
function buildSrcset(set, tiers) {
|
|
188
|
+
if (!set) return void 0;
|
|
189
|
+
const entries = [];
|
|
190
|
+
for (const key of tiers) {
|
|
191
|
+
const tier = set[key];
|
|
192
|
+
if (tier?.url && tier.width) entries.push(`${tier.url} ${tier.width}w`);
|
|
193
|
+
}
|
|
194
|
+
return entries.length > 0 ? entries.join(", ") : void 0;
|
|
195
|
+
}
|
|
196
|
+
function pickFallbackSrc(set) {
|
|
197
|
+
if (!set) return void 0;
|
|
198
|
+
return set.large?.url ?? set.medium?.url ?? set.xlarge?.url ?? set.small?.url ?? set.thumbnail?.url ?? void 0;
|
|
199
|
+
}
|
|
200
|
+
function ProgressiveMedia({
|
|
201
|
+
image,
|
|
202
|
+
video,
|
|
203
|
+
alt,
|
|
204
|
+
className,
|
|
205
|
+
fill,
|
|
206
|
+
fit = "cover",
|
|
207
|
+
placeholder,
|
|
208
|
+
autoplay = true,
|
|
209
|
+
muted = true,
|
|
210
|
+
loop = true,
|
|
211
|
+
active = true,
|
|
212
|
+
sizes = "100vw",
|
|
213
|
+
priority = false,
|
|
214
|
+
variantTiers = DEFAULT_VARIANT_TIERS
|
|
215
|
+
}) {
|
|
216
|
+
const fitClass = fit === "contain" ? "object-contain" : "object-cover";
|
|
217
|
+
const videoRef = useRef2(null);
|
|
218
|
+
const hlsRef = useRef2(null);
|
|
219
|
+
const cleanupRef = useRef2(null);
|
|
220
|
+
const source = video ?? image;
|
|
221
|
+
const isVideo = !!video;
|
|
222
|
+
const [wrapperRef, inViewport] = useInViewport("300px", priority);
|
|
223
|
+
const videoActive = isVideo && active && (priority || inViewport);
|
|
224
|
+
const imageActive = !isVideo && active;
|
|
225
|
+
const hlsUrl = video?.hlsUrl;
|
|
226
|
+
const useHls = isVideo && !!hlsUrl;
|
|
227
|
+
const fullUrl = isVideo ? video?.url : pickFallbackSrc(image?.webp) ?? image?.url;
|
|
228
|
+
const avifSrcSet = !isVideo ? buildSrcset(image?.avif, variantTiers) : void 0;
|
|
229
|
+
const webpSrcSet = !isVideo ? buildSrcset(image?.webp, variantTiers) : void 0;
|
|
230
|
+
const srcSet = webpSrcSet;
|
|
231
|
+
const thumbnailUrl = source?.thumbnailUrl;
|
|
232
|
+
const blurhash = source?.blurhash;
|
|
233
|
+
const dominantColor = source?.dominantColor;
|
|
234
|
+
const { stage, onFullReady } = useProgressiveLoad({
|
|
235
|
+
blurhash,
|
|
236
|
+
thumbnailUrl,
|
|
237
|
+
// Skip the full-image preloader when this branch is inactive — the
|
|
238
|
+
// dual-render responsive pattern mounts both variants but only one should
|
|
239
|
+
// download its full asset.
|
|
240
|
+
fullUrl: imageActive ? fullUrl : void 0,
|
|
241
|
+
fullSrcSet: imageActive ? srcSet : void 0,
|
|
242
|
+
fullSizes: imageActive && srcSet ? sizes : void 0,
|
|
243
|
+
isVideo
|
|
244
|
+
});
|
|
245
|
+
useEffect3(() => {
|
|
246
|
+
if (!videoActive || !useHls || !videoRef.current) return;
|
|
247
|
+
const el = videoRef.current;
|
|
248
|
+
if (el.canPlayType("application/vnd.apple.mpegurl")) {
|
|
249
|
+
el.src = hlsUrl;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
let hls;
|
|
253
|
+
import("hls.js").then((mod) => {
|
|
254
|
+
const Hls = mod.default;
|
|
255
|
+
if (!Hls.isSupported()) {
|
|
256
|
+
if (video?.url) el.src = video.url;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
hls = new Hls({ capLevelToPlayerSize: true });
|
|
260
|
+
hlsRef.current = hls;
|
|
261
|
+
hls.loadSource(hlsUrl);
|
|
262
|
+
hls.attachMedia(el);
|
|
263
|
+
}).catch(() => {
|
|
264
|
+
if (video?.url) el.src = video.url;
|
|
265
|
+
});
|
|
266
|
+
return () => {
|
|
267
|
+
if (hls) {
|
|
268
|
+
hls.destroy();
|
|
269
|
+
hlsRef.current = null;
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
}, [videoActive, useHls, hlsUrl, video?.url]);
|
|
273
|
+
const videoCallbackRef = useCallback2(
|
|
274
|
+
(el) => {
|
|
275
|
+
if (cleanupRef.current) {
|
|
276
|
+
cleanupRef.current();
|
|
277
|
+
cleanupRef.current = null;
|
|
278
|
+
}
|
|
279
|
+
videoRef.current = el;
|
|
280
|
+
if (!el) return;
|
|
281
|
+
if (el.readyState >= 3) {
|
|
282
|
+
onFullReady();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const handler = () => {
|
|
286
|
+
onFullReady();
|
|
287
|
+
el.removeEventListener("canplay", handler);
|
|
288
|
+
el.removeEventListener("playing", handler);
|
|
289
|
+
};
|
|
290
|
+
el.addEventListener("canplay", handler);
|
|
291
|
+
el.addEventListener("playing", handler);
|
|
292
|
+
cleanupRef.current = () => {
|
|
293
|
+
el.removeEventListener("canplay", handler);
|
|
294
|
+
el.removeEventListener("playing", handler);
|
|
295
|
+
};
|
|
296
|
+
},
|
|
297
|
+
[onFullReady]
|
|
298
|
+
);
|
|
299
|
+
if (!source) {
|
|
300
|
+
if (!placeholder) return null;
|
|
301
|
+
const aspect2 = placeholder.aspectRatio ?? 16 / 10;
|
|
302
|
+
return /* @__PURE__ */ jsx2(
|
|
303
|
+
"div",
|
|
304
|
+
{
|
|
305
|
+
className: `relative overflow-hidden ${fill ? "h-full w-full" : ""} ${className ?? ""}`,
|
|
306
|
+
style: fill ? { backgroundColor: placeholder.color } : { aspectRatio: `${aspect2}`, backgroundColor: placeholder.color },
|
|
307
|
+
"aria-label": alt
|
|
308
|
+
}
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
const effective = !isVideo ? effectiveImageDimensions(image) : null;
|
|
312
|
+
const w = effective?.width ?? source.width;
|
|
313
|
+
const h = effective?.height ?? source.height;
|
|
314
|
+
const aspect = w && h ? w / h : 16 / 10;
|
|
315
|
+
return /* @__PURE__ */ jsxs(
|
|
316
|
+
"div",
|
|
317
|
+
{
|
|
318
|
+
ref: wrapperRef,
|
|
319
|
+
className: `relative overflow-hidden ${fill ? "h-full w-full" : ""} ${className ?? ""}`,
|
|
320
|
+
style: fill ? void 0 : { aspectRatio: `${aspect}` },
|
|
321
|
+
children: [
|
|
322
|
+
dominantColor && /* @__PURE__ */ jsx2(
|
|
323
|
+
"div",
|
|
324
|
+
{
|
|
325
|
+
className: "absolute inset-0",
|
|
326
|
+
style: { backgroundColor: dominantColor }
|
|
327
|
+
}
|
|
328
|
+
),
|
|
329
|
+
blurhash && /* @__PURE__ */ jsx2(
|
|
330
|
+
"div",
|
|
331
|
+
{
|
|
332
|
+
className: `absolute inset-0 transition-opacity duration-300 ease-in-out ${stage === "color" ? "opacity-0" : "opacity-100"}`,
|
|
333
|
+
children: /* @__PURE__ */ jsx2(BlurhashCanvas, { hash: blurhash })
|
|
334
|
+
}
|
|
335
|
+
),
|
|
336
|
+
thumbnailUrl && /* @__PURE__ */ jsx2(
|
|
337
|
+
"img",
|
|
338
|
+
{
|
|
339
|
+
src: thumbnailUrl,
|
|
340
|
+
alt: "",
|
|
341
|
+
"aria-hidden": true,
|
|
342
|
+
width: w,
|
|
343
|
+
height: h,
|
|
344
|
+
loading: priority ? "eager" : "lazy",
|
|
345
|
+
fetchPriority: priority ? "high" : void 0,
|
|
346
|
+
className: `absolute inset-0 h-full w-full ${fitClass} transition-opacity duration-400 ease-in-out ${stage === "thumbnail" || stage === "full" ? "opacity-100" : "opacity-0"}`
|
|
347
|
+
}
|
|
348
|
+
),
|
|
349
|
+
videoActive ? /* @__PURE__ */ jsx2(
|
|
350
|
+
"video",
|
|
351
|
+
{
|
|
352
|
+
ref: videoCallbackRef,
|
|
353
|
+
src: useHls ? void 0 : fullUrl,
|
|
354
|
+
poster: thumbnailUrl,
|
|
355
|
+
width: w,
|
|
356
|
+
height: h,
|
|
357
|
+
autoPlay: autoplay,
|
|
358
|
+
muted,
|
|
359
|
+
loop,
|
|
360
|
+
playsInline: true,
|
|
361
|
+
controls: !autoplay,
|
|
362
|
+
className: `absolute inset-0 h-full w-full ${fitClass} transition-opacity duration-500 ease-in-out ${stage === "full" ? "opacity-100" : "opacity-0"}`
|
|
363
|
+
}
|
|
364
|
+
) : imageActive ? (
|
|
365
|
+
// <picture>: browser picks AVIF when it can decode it, else WebP. The
|
|
366
|
+
// <img> (WebP) is the universal fallback and carries the layout attrs.
|
|
367
|
+
/* @__PURE__ */ jsxs("picture", { children: [
|
|
368
|
+
avifSrcSet ? /* @__PURE__ */ jsx2("source", { type: "image/avif", srcSet: avifSrcSet, sizes }) : null,
|
|
369
|
+
webpSrcSet ? /* @__PURE__ */ jsx2("source", { type: "image/webp", srcSet: webpSrcSet, sizes }) : null,
|
|
370
|
+
/* @__PURE__ */ jsx2(
|
|
371
|
+
"img",
|
|
372
|
+
{
|
|
373
|
+
src: fullUrl,
|
|
374
|
+
srcSet,
|
|
375
|
+
sizes: srcSet ? sizes : void 0,
|
|
376
|
+
alt: alt ?? image?.alt ?? "",
|
|
377
|
+
width: w,
|
|
378
|
+
height: h,
|
|
379
|
+
loading: priority ? "eager" : "lazy",
|
|
380
|
+
fetchPriority: priority ? "high" : void 0,
|
|
381
|
+
className: `absolute inset-0 h-full w-full ${fitClass} transition-opacity duration-500 ease-in-out ${stage === "full" ? "opacity-100" : "opacity-0"}`
|
|
382
|
+
}
|
|
383
|
+
)
|
|
384
|
+
] })
|
|
385
|
+
) : null
|
|
386
|
+
]
|
|
387
|
+
}
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
export {
|
|
391
|
+
BlurhashCanvas,
|
|
392
|
+
DEFAULT_VARIANT_TIERS,
|
|
393
|
+
ProgressiveMedia,
|
|
394
|
+
cropAspectRatio,
|
|
395
|
+
effectiveImageDimensions,
|
|
396
|
+
toProgressiveImage,
|
|
397
|
+
toProgressiveVideo,
|
|
398
|
+
useProgressiveLoad
|
|
399
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import * as Y from 'yjs';
|
|
2
|
+
import { WebsocketProvider } from 'y-websocket';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @foir/sdk/realtime — optional thin web helper over y-websocket (EID-103).
|
|
6
|
+
*
|
|
7
|
+
* The apollo-link analogue for Foir realtime collaboration: sugar over the
|
|
8
|
+
* off-the-shelf `y-websocket` provider that adds exactly the wire-protocol
|
|
9
|
+
* contract conventions — explicit URL, canonical `{namespace}/{roomId}` path,
|
|
10
|
+
* presence payload shape — plus the one thing `y-websocket` lacks out of the
|
|
11
|
+
* box: token refresh that the next reconnect picks up. Tokens are ~5-min-lived
|
|
12
|
+
* and the gateway re-validates on every upgrade (a live socket survives past
|
|
13
|
+
* TTL; the token only matters at (re)connect), so "refresh" means keeping the
|
|
14
|
+
* provider's url params current for whenever it next reconnects.
|
|
15
|
+
*
|
|
16
|
+
* Agnostic by design: token-agnostic (caller supplies `getToken` — an internal
|
|
17
|
+
* mint for admin, a customer access token for an app), persistence-agnostic
|
|
18
|
+
* (never persists), doc-schema-agnostic (caller owns the `Y.Doc` + shared types).
|
|
19
|
+
*
|
|
20
|
+
* `yjs` + `y-websocket` are OPTIONAL peer deps gated to this subpath, so a
|
|
21
|
+
* consumer importing only other `@foir/sdk/*` exports pulls no Yjs.
|
|
22
|
+
*
|
|
23
|
+
* Wire-protocol contract: `docs/realtime-collab-contract.md` (EID-117).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
type Awareness = WebsocketProvider['awareness'];
|
|
27
|
+
interface RoomAddress {
|
|
28
|
+
/** Room-type selector — e.g. `record`, `ephemeral`, `tilly_note`. */
|
|
29
|
+
namespace: string;
|
|
30
|
+
/** Namespace-specific id. For `record`: `{modelKey}:{recordId}:{variantId}`. */
|
|
31
|
+
id: string;
|
|
32
|
+
}
|
|
33
|
+
interface CollabPresence {
|
|
34
|
+
id: string;
|
|
35
|
+
name: string;
|
|
36
|
+
/** Stable per-user colour; derived deterministically from `id` when omitted. */
|
|
37
|
+
color?: string;
|
|
38
|
+
[key: string]: unknown;
|
|
39
|
+
}
|
|
40
|
+
interface CollabPeer {
|
|
41
|
+
/** The remote peer's y-awareness client id. */
|
|
42
|
+
clientId: number;
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
color?: string;
|
|
46
|
+
[key: string]: unknown;
|
|
47
|
+
}
|
|
48
|
+
interface CreateCollabSessionOptions {
|
|
49
|
+
/** Explicit realtime origin, e.g. `wss://realtime.foir.io`. Never derived from the API host. */
|
|
50
|
+
url: string;
|
|
51
|
+
/** Room address; built into the canonical bare `{namespace}/{id}` path. A raw string path is also accepted. */
|
|
52
|
+
room: RoomAddress | string;
|
|
53
|
+
/** Async token source. Invoked before the first connect and on each refresh tick. */
|
|
54
|
+
getToken: () => string | Promise<string>;
|
|
55
|
+
/** Caller-owned `Y.Doc` carrying the app-defined shared types. */
|
|
56
|
+
doc: Y.Doc;
|
|
57
|
+
/** Local presence. Sets awareness under the `user` field (contract shape). Omit for no presence. */
|
|
58
|
+
presence?: CollabPresence;
|
|
59
|
+
/** Token refresh cadence (ms). Default `240_000` (4 min; tokens are ~5-min TTL). `0` disables. */
|
|
60
|
+
refreshMs?: number;
|
|
61
|
+
/** Connect immediately once the first token resolves (default `true`). */
|
|
62
|
+
connect?: boolean;
|
|
63
|
+
/** Extra y-websocket url params, merged with `token`. */
|
|
64
|
+
params?: Record<string, string>;
|
|
65
|
+
/** WebSocket implementation for non-browser runtimes. */
|
|
66
|
+
WebSocketPolyfill?: typeof WebSocket;
|
|
67
|
+
}
|
|
68
|
+
type CollabEventMap = {
|
|
69
|
+
/** Connection status flipped. */
|
|
70
|
+
status: {
|
|
71
|
+
connected: boolean;
|
|
72
|
+
};
|
|
73
|
+
/** Initial (or post-reconnect) Yjs sync completed (`true`) or lost (`false`). */
|
|
74
|
+
synced: boolean;
|
|
75
|
+
/** Remote peer set changed (always excludes the local client). */
|
|
76
|
+
'presence-changed': Array<CollabPeer>;
|
|
77
|
+
/** A `getToken()` (or other internal) error surfaced. */
|
|
78
|
+
error: unknown;
|
|
79
|
+
};
|
|
80
|
+
interface CollabSession {
|
|
81
|
+
/** The underlying y-websocket provider (escape hatch). */
|
|
82
|
+
readonly provider: WebsocketProvider;
|
|
83
|
+
/** The y-awareness instance (escape hatch). */
|
|
84
|
+
readonly awareness: Awareness;
|
|
85
|
+
/** The caller's `Y.Doc`. */
|
|
86
|
+
readonly doc: Y.Doc;
|
|
87
|
+
/** Canonical room path actually used (`{namespace}/{id}`). */
|
|
88
|
+
readonly room: string;
|
|
89
|
+
readonly connected: boolean;
|
|
90
|
+
readonly synced: boolean;
|
|
91
|
+
/** Current remote peers (excludes self). */
|
|
92
|
+
readonly peers: Array<CollabPeer>;
|
|
93
|
+
on<E extends keyof CollabEventMap>(event: E, handler: (data: CollabEventMap[E]) => void): () => void;
|
|
94
|
+
/** Manually push a fresh token, used by the next reconnect. Refresh is automatic; rarely needed. */
|
|
95
|
+
updateToken(token: string): void;
|
|
96
|
+
/** Update or clear local presence. */
|
|
97
|
+
setPresence(presence: CollabPresence | null): void;
|
|
98
|
+
/** Disconnect, stop refresh, tear down awareness. Does NOT destroy the caller's doc. */
|
|
99
|
+
destroy(): void;
|
|
100
|
+
}
|
|
101
|
+
/** Deterministic per-id presence colour (stable across sessions and clients). */
|
|
102
|
+
declare function colorForId(id: string): string;
|
|
103
|
+
/** Build the canonical bare `{namespace}/{id}` room path (contract §2). */
|
|
104
|
+
declare function roomPath(room: RoomAddress | string): string;
|
|
105
|
+
/**
|
|
106
|
+
* Create a collaboration session: a wrapped `y-websocket` provider that conforms
|
|
107
|
+
* to the Foir realtime contract and keeps its auth token fresh for reconnects.
|
|
108
|
+
*/
|
|
109
|
+
declare function createCollabSession(options: CreateCollabSessionOptions): CollabSession;
|
|
110
|
+
|
|
111
|
+
export { type CollabEventMap, type CollabPeer, type CollabPresence, type CollabSession, type CreateCollabSessionOptions, type RoomAddress, colorForId, createCollabSession, roomPath };
|
package/dist/realtime.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// src/realtime/index.ts
|
|
2
|
+
import { WebsocketProvider } from "y-websocket";
|
|
3
|
+
var PRESENCE_COLORS = [
|
|
4
|
+
"#ef4444",
|
|
5
|
+
"#f97316",
|
|
6
|
+
"#eab308",
|
|
7
|
+
"#22c55e",
|
|
8
|
+
"#14b8a6",
|
|
9
|
+
"#3b82f6",
|
|
10
|
+
"#8b5cf6",
|
|
11
|
+
"#ec4899"
|
|
12
|
+
];
|
|
13
|
+
function colorForId(id) {
|
|
14
|
+
let hash = 0;
|
|
15
|
+
for (let i = 0; i < id.length; i++) {
|
|
16
|
+
hash = id.charCodeAt(i) + ((hash << 5) - hash);
|
|
17
|
+
}
|
|
18
|
+
return PRESENCE_COLORS[Math.abs(hash) % PRESENCE_COLORS.length];
|
|
19
|
+
}
|
|
20
|
+
function roomPath(room) {
|
|
21
|
+
if (typeof room === "string") {
|
|
22
|
+
const p = room.replace(/^\/+/, "");
|
|
23
|
+
if (!p) throw new Error("createCollabSession: room path is empty");
|
|
24
|
+
return p;
|
|
25
|
+
}
|
|
26
|
+
const namespace = room.namespace?.trim();
|
|
27
|
+
const id = room.id?.trim();
|
|
28
|
+
if (!namespace || !id) {
|
|
29
|
+
throw new Error("createCollabSession: room.namespace and room.id are required");
|
|
30
|
+
}
|
|
31
|
+
return `${namespace}/${id}`;
|
|
32
|
+
}
|
|
33
|
+
function createCollabSession(options) {
|
|
34
|
+
const {
|
|
35
|
+
url,
|
|
36
|
+
doc,
|
|
37
|
+
getToken,
|
|
38
|
+
presence,
|
|
39
|
+
refreshMs = 4 * 60 * 1e3,
|
|
40
|
+
connect = true,
|
|
41
|
+
params: extraParams,
|
|
42
|
+
WebSocketPolyfill
|
|
43
|
+
} = options;
|
|
44
|
+
const room = roomPath(options.room);
|
|
45
|
+
const provider = new WebsocketProvider(url, room, doc, {
|
|
46
|
+
connect: false,
|
|
47
|
+
params: { ...extraParams },
|
|
48
|
+
...WebSocketPolyfill ? { WebSocketPolyfill } : {}
|
|
49
|
+
});
|
|
50
|
+
const awareness = provider.awareness;
|
|
51
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
52
|
+
function on(event, handler) {
|
|
53
|
+
let set = listeners.get(event);
|
|
54
|
+
if (!set) {
|
|
55
|
+
set = /* @__PURE__ */ new Set();
|
|
56
|
+
listeners.set(event, set);
|
|
57
|
+
}
|
|
58
|
+
set.add(handler);
|
|
59
|
+
return () => {
|
|
60
|
+
listeners.get(event)?.delete(handler);
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function emit(event, data) {
|
|
64
|
+
for (const handler of listeners.get(event) ?? []) {
|
|
65
|
+
try {
|
|
66
|
+
handler(data);
|
|
67
|
+
} catch {
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
let connected = false;
|
|
72
|
+
let synced = false;
|
|
73
|
+
let peers = [];
|
|
74
|
+
let destroyed = false;
|
|
75
|
+
let refreshTimer = null;
|
|
76
|
+
const onStatus = (event) => {
|
|
77
|
+
const now = event.status === "connected";
|
|
78
|
+
if (now === connected) return;
|
|
79
|
+
connected = now;
|
|
80
|
+
emit("status", { connected });
|
|
81
|
+
};
|
|
82
|
+
const onSync = (state) => {
|
|
83
|
+
synced = state;
|
|
84
|
+
emit("synced", state);
|
|
85
|
+
};
|
|
86
|
+
provider.on("status", onStatus);
|
|
87
|
+
provider.on("sync", onSync);
|
|
88
|
+
function readPeers() {
|
|
89
|
+
const next = [];
|
|
90
|
+
awareness.getStates().forEach((state, clientId) => {
|
|
91
|
+
if (clientId === awareness.clientID) return;
|
|
92
|
+
const user = state["user"];
|
|
93
|
+
if (user && typeof user.id === "string") {
|
|
94
|
+
next.push({ clientId, id: user.id, name: user.name, color: user.color });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
peers = next;
|
|
98
|
+
emit("presence-changed", peers);
|
|
99
|
+
}
|
|
100
|
+
const onAwarenessChange = () => readPeers();
|
|
101
|
+
awareness.on("change", onAwarenessChange);
|
|
102
|
+
function setPresence(next) {
|
|
103
|
+
if (!next) {
|
|
104
|
+
awareness.setLocalState(null);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
awareness.setLocalStateField("user", {
|
|
108
|
+
...next,
|
|
109
|
+
color: next.color ?? colorForId(next.id)
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
if (presence) setPresence(presence);
|
|
113
|
+
function updateToken(token) {
|
|
114
|
+
provider.params = { ...provider.params, token };
|
|
115
|
+
}
|
|
116
|
+
async function refreshToken() {
|
|
117
|
+
try {
|
|
118
|
+
updateToken(await getToken());
|
|
119
|
+
} catch (err) {
|
|
120
|
+
emit("error", err);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
void (async () => {
|
|
124
|
+
await refreshToken();
|
|
125
|
+
if (destroyed) return;
|
|
126
|
+
if (connect) provider.connect();
|
|
127
|
+
if (refreshMs > 0) {
|
|
128
|
+
refreshTimer = setInterval(() => {
|
|
129
|
+
void refreshToken();
|
|
130
|
+
}, refreshMs);
|
|
131
|
+
}
|
|
132
|
+
})();
|
|
133
|
+
function destroy() {
|
|
134
|
+
if (destroyed) return;
|
|
135
|
+
destroyed = true;
|
|
136
|
+
if (refreshTimer) clearInterval(refreshTimer);
|
|
137
|
+
refreshTimer = null;
|
|
138
|
+
provider.off("status", onStatus);
|
|
139
|
+
provider.off("sync", onSync);
|
|
140
|
+
awareness.off("change", onAwarenessChange);
|
|
141
|
+
listeners.clear();
|
|
142
|
+
provider.destroy();
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
provider,
|
|
146
|
+
awareness,
|
|
147
|
+
doc,
|
|
148
|
+
room,
|
|
149
|
+
get connected() {
|
|
150
|
+
return connected;
|
|
151
|
+
},
|
|
152
|
+
get synced() {
|
|
153
|
+
return synced;
|
|
154
|
+
},
|
|
155
|
+
get peers() {
|
|
156
|
+
return peers;
|
|
157
|
+
},
|
|
158
|
+
on,
|
|
159
|
+
updateToken,
|
|
160
|
+
setPresence,
|
|
161
|
+
destroy
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
export {
|
|
165
|
+
colorForId,
|
|
166
|
+
createCollabSession,
|
|
167
|
+
roomPath
|
|
168
|
+
};
|