tempest-react-sdk 0.75.0 → 0.77.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 +4 -2
- package/bin/create-tempest-app.e2e.test.mjs +112 -0
- package/bin/create-tempest-app.mjs +69 -28
- package/bin/lib/component/generate.mjs +191 -0
- package/bin/lib/component/generate.test.mjs +336 -0
- package/bin/lib/openapi/escape.mjs +210 -0
- package/bin/lib/openapi/generate.hostile.test.mjs +592 -0
- package/bin/lib/openapi/generate.mjs +93 -17
- package/bin/lib/openapi/schema-to-zod.mjs +41 -4
- package/bin/tempest.mjs +184 -3
- package/dist/auth/create-tempest-auth.cjs +1 -1
- package/dist/auth/create-tempest-auth.cjs.map +1 -1
- package/dist/auth/create-tempest-auth.js +127 -55
- package/dist/auth/create-tempest-auth.js.map +1 -1
- package/dist/auth/refresh-queue.cjs +1 -1
- package/dist/auth/refresh-queue.cjs.map +1 -1
- package/dist/auth/refresh-queue.js +13 -6
- package/dist/auth/refresh-queue.js.map +1 -1
- package/dist/br/pix.cjs +1 -1
- package/dist/br/pix.cjs.map +1 -1
- package/dist/br/pix.js +72 -62
- package/dist/br/pix.js.map +1 -1
- package/dist/br.d.ts +12 -4
- package/dist/components/Chat/ChatAttachments.cjs +1 -1
- package/dist/components/Chat/ChatAttachments.cjs.map +1 -1
- package/dist/components/Chat/ChatAttachments.js +46 -38
- package/dist/components/Chat/ChatAttachments.js.map +1 -1
- package/dist/components/Markdown/markdown-parse.cjs +7 -5
- package/dist/components/Markdown/markdown-parse.cjs.map +1 -1
- package/dist/components/Markdown/markdown-parse.js +151 -87
- package/dist/components/Markdown/markdown-parse.js.map +1 -1
- package/dist/components/Toast/Toast.module.cjs +1 -1
- package/dist/components/Toast/Toast.module.cjs.map +1 -1
- package/dist/components/Toast/Toast.module.js +7 -3
- package/dist/components/Toast/Toast.module.js.map +1 -1
- package/dist/components/Toast/ToastProvider.cjs +1 -1
- package/dist/components/Toast/ToastProvider.cjs.map +1 -1
- package/dist/components/Toast/ToastProvider.js +77 -51
- package/dist/components/Toast/ToastProvider.js.map +1 -1
- package/dist/data/create-data-provider.cjs +1 -1
- package/dist/data/create-data-provider.cjs.map +1 -1
- package/dist/data/create-data-provider.js +32 -21
- package/dist/data/create-data-provider.js.map +1 -1
- package/dist/http/api-client.cjs +1 -1
- package/dist/http/api-client.cjs.map +1 -1
- package/dist/http/api-client.js +81 -73
- package/dist/http/api-client.js.map +1 -1
- package/dist/http/errors.cjs +1 -1
- package/dist/http/errors.cjs.map +1 -1
- package/dist/http/errors.js +39 -35
- package/dist/http/errors.js.map +1 -1
- package/dist/neural.cjs +2 -0
- package/dist/neural.cjs.map +1 -0
- package/dist/neural.d.ts +110 -0
- package/dist/neural.js +48 -0
- package/dist/neural.js.map +1 -0
- package/dist/oauth/callback.cjs +2 -0
- package/dist/oauth/callback.cjs.map +1 -0
- package/dist/oauth/callback.js +42 -0
- package/dist/oauth/callback.js.map +1 -0
- package/dist/oauth/use-oauth-callback.cjs +1 -1
- package/dist/oauth/use-oauth-callback.cjs.map +1 -1
- package/dist/oauth/use-oauth-callback.js +13 -20
- package/dist/oauth/use-oauth-callback.js.map +1 -1
- package/dist/push/use-push-subscription.cjs +1 -1
- package/dist/push/use-push-subscription.cjs.map +1 -1
- package/dist/push/use-push-subscription.js +42 -42
- package/dist/push/use-push-subscription.js.map +1 -1
- package/dist/push/web-push-client.cjs +1 -1
- package/dist/push/web-push-client.cjs.map +1 -1
- package/dist/push/web-push-client.js +23 -3
- package/dist/push/web-push-client.js.map +1 -1
- package/dist/query/offline-persistence.cjs +1 -1
- package/dist/query/offline-persistence.cjs.map +1 -1
- package/dist/query/offline-persistence.js +68 -23
- package/dist/query/offline-persistence.js.map +1 -1
- package/dist/styles/component/Toast.css +6 -1
- package/dist/styles/feedback.css +6 -1
- package/dist/styles.css +1 -1
- package/dist/sw/background-sync.cjs +1 -1
- package/dist/sw/background-sync.cjs.map +1 -1
- package/dist/sw/background-sync.js +271 -60
- package/dist/sw/background-sync.js.map +1 -1
- package/dist/sw/cache-inspect.cjs.map +1 -1
- package/dist/sw/cache-inspect.js.map +1 -1
- package/dist/sw/cache.cjs +1 -1
- package/dist/sw/cache.cjs.map +1 -1
- package/dist/sw/cache.js +91 -64
- package/dist/sw/cache.js.map +1 -1
- package/dist/sw/credential-headers.cjs +2 -0
- package/dist/sw/credential-headers.cjs.map +1 -0
- package/dist/sw/credential-headers.js +17 -0
- package/dist/sw/credential-headers.js.map +1 -0
- package/dist/sw/session-data.cjs +2 -0
- package/dist/sw/session-data.cjs.map +1 -0
- package/dist/sw/session-data.js +17 -0
- package/dist/sw/session-data.js.map +1 -0
- package/dist/sw.cjs +1 -1
- package/dist/sw.d.ts +271 -23
- package/dist/sw.js +7 -5
- package/dist/telemetry/app-error-owner.cjs +2 -0
- package/dist/telemetry/app-error-owner.cjs.map +1 -0
- package/dist/telemetry/app-error-owner.js +18 -0
- package/dist/telemetry/app-error-owner.js.map +1 -0
- package/dist/telemetry/app-error-queue.cjs +1 -1
- package/dist/telemetry/app-error-queue.cjs.map +1 -1
- package/dist/telemetry/app-error-queue.js +19 -14
- package/dist/telemetry/app-error-queue.js.map +1 -1
- package/dist/telemetry/app-error-reporter.cjs +1 -1
- package/dist/telemetry/app-error-reporter.cjs.map +1 -1
- package/dist/telemetry/app-error-reporter.js +75 -34
- package/dist/telemetry/app-error-reporter.js.map +1 -1
- package/dist/telemetry/app-error-transport.cjs +1 -1
- package/dist/telemetry/app-error-transport.cjs.map +1 -1
- package/dist/telemetry/app-error-transport.js +16 -3
- package/dist/telemetry/app-error-transport.js.map +1 -1
- package/dist/tempest-react-sdk.cjs +1 -1
- package/dist/tempest-react-sdk.d.ts +979 -62
- package/dist/tempest-react-sdk.js +160 -156
- package/dist/utils/csv.cjs +3 -3
- package/dist/utils/csv.cjs.map +1 -1
- package/dist/utils/csv.js +16 -13
- package/dist/utils/csv.js.map +1 -1
- package/dist/utils/safe-url.cjs +2 -0
- package/dist/utils/safe-url.cjs.map +1 -0
- package/dist/utils/safe-url.js +39 -0
- package/dist/utils/safe-url.js.map +1 -0
- package/dist/vite/tempest-csp.cjs +2 -2
- package/dist/vite/tempest-csp.cjs.map +1 -1
- package/dist/vite/tempest-csp.js +118 -63
- package/dist/vite/tempest-csp.js.map +1 -1
- package/dist/vite.d.ts +21 -5
- package/dist/webrtc/peer-link.cjs +1 -1
- package/dist/webrtc/peer-link.cjs.map +1 -1
- package/dist/webrtc/peer-link.js +2 -10
- package/dist/webrtc/peer-link.js.map +1 -1
- package/dist/webrtc/peer-mesh.cjs +1 -1
- package/dist/webrtc/peer-mesh.cjs.map +1 -1
- package/dist/webrtc/peer-mesh.js +110 -72
- package/dist/webrtc/peer-mesh.js.map +1 -1
- package/package.json +11 -1
- package/template-pwa/src/pwa.tsx +22 -0
- package/template-pwa/src/sw.ts +27 -14
- package/dist/components/Markdown/markdown-url.cjs +0 -2
- package/dist/components/Markdown/markdown-url.cjs.map +0 -1
- package/dist/components/Markdown/markdown-url.js +0 -28
- package/dist/components/Markdown/markdown-url.js.map +0 -1
package/dist/webrtc/peer-mesh.js
CHANGED
|
@@ -1,89 +1,124 @@
|
|
|
1
1
|
import { createLinkStatsSampler as e } from "./link-stats.js";
|
|
2
2
|
import { applyQualityToLink as t, resolveDegradation as n, scaleForRoom as r } from "./mesh-quality.js";
|
|
3
|
-
import { acceptCandidate as i, adoptTransceivers as a,
|
|
3
|
+
import { acceptCandidate as i, adoptTransceivers as a, createPeerConnection as o, drainCandidates as s, slotOf as c } from "./peer-link.js";
|
|
4
4
|
//#region src/webrtc/peer-mesh.ts
|
|
5
|
+
var l = Symbol("gone");
|
|
5
6
|
function u(u) {
|
|
6
|
-
let { slots: d, send: f } = u, p = /* @__PURE__ */ new Map(), m = /* @__PURE__ */ new Map(), h = /* @__PURE__ */ new Map(), g = u.iceServers ?? [],
|
|
7
|
+
let { slots: d, send: f } = u, p = /* @__PURE__ */ new Map(), m = /* @__PURE__ */ new Map(), h = /* @__PURE__ */ new Map(), g = /* @__PURE__ */ new Map(), _ = u.iceServers ?? [], v = u.setLocalDescription ?? ((e, t) => e.setLocalDescription(t)), y = u.quality ?? {}, b = !1, x = /* @__PURE__ */ new Map(), S = null, C = () => Object.fromEntries(d.map((e) => [e.name, null])), w = () => {
|
|
7
8
|
u.onPeers?.([...m.values()].map((e) => ({ ...e })));
|
|
8
|
-
},
|
|
9
|
-
|
|
9
|
+
}, T = (e) => !b && p.get(e.peerId) === e, E = async (e, t) => {
|
|
10
|
+
try {
|
|
11
|
+
let n = await t;
|
|
12
|
+
return T(e) ? n : l;
|
|
13
|
+
} catch (t) {
|
|
14
|
+
if (!T(e)) return l;
|
|
15
|
+
throw t;
|
|
16
|
+
}
|
|
17
|
+
}, D = async (t) => {
|
|
18
|
+
if (b) return;
|
|
10
19
|
let n = !1;
|
|
11
20
|
for (let r of [...p.values()]) {
|
|
12
21
|
if (r.pc.connectionState !== "connected") continue;
|
|
13
|
-
let i =
|
|
14
|
-
i === void 0 && (i = e(t.kind === void 0 ? {} : { kind: t.kind }),
|
|
15
|
-
let a
|
|
16
|
-
|
|
22
|
+
let i = x.get(r.peerId);
|
|
23
|
+
i === void 0 && (i = e(t.kind === void 0 ? {} : { kind: t.kind }), x.set(r.peerId, i));
|
|
24
|
+
let a;
|
|
25
|
+
try {
|
|
26
|
+
a = await i.sample(r.pc);
|
|
27
|
+
} catch {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
let o = m.get(r.peerId);
|
|
31
|
+
T(r) && o !== void 0 && (o.stats = a, n = !0, t.onStats?.(r.peerId, a));
|
|
17
32
|
}
|
|
18
|
-
n &&
|
|
19
|
-
},
|
|
33
|
+
n && w();
|
|
34
|
+
}, O = () => {
|
|
20
35
|
let e = u.stats;
|
|
21
36
|
if (e === void 0) return;
|
|
22
|
-
let t = !
|
|
23
|
-
if (t &&
|
|
24
|
-
|
|
37
|
+
let t = !b && p.size > 0;
|
|
38
|
+
if (t && S === null) {
|
|
39
|
+
S = setInterval(() => void D(e), e.intervalMs ?? 2e3);
|
|
25
40
|
return;
|
|
26
41
|
}
|
|
27
|
-
!t &&
|
|
28
|
-
},
|
|
29
|
-
if (
|
|
42
|
+
!t && S !== null && (clearInterval(S), S = null);
|
|
43
|
+
}, k = () => {
|
|
44
|
+
if (b) return;
|
|
30
45
|
let e = [...p.values()].map((e) => e.pc.connectionState);
|
|
31
46
|
e.some((e) => e === "connected") ? u.onState?.("connected") : e.length > 0 ? u.onState?.("connecting", "negotiating") : u.onState?.("connected", "alone");
|
|
32
|
-
},
|
|
33
|
-
|
|
47
|
+
}, A = async (e) => {
|
|
48
|
+
if (b) return;
|
|
49
|
+
y = e;
|
|
34
50
|
let i = r(e, p.size), a = n(e, i);
|
|
35
51
|
for (let e of p.values()) await t(e.transceivers, d, i, a);
|
|
36
|
-
},
|
|
52
|
+
}, j = async (e) => {
|
|
37
53
|
if (!e.makingOffer) {
|
|
38
54
|
e.makingOffer = !0;
|
|
39
55
|
try {
|
|
40
56
|
let t = await e.pc.createOffer();
|
|
57
|
+
if (!T(e)) return;
|
|
41
58
|
if (!t.sdp) {
|
|
42
59
|
u.onNotice?.("empty_local_offer_sdp");
|
|
43
60
|
return;
|
|
44
61
|
}
|
|
45
|
-
await
|
|
62
|
+
if (await v(e.pc, t), !T(e)) return;
|
|
63
|
+
f({
|
|
46
64
|
type: "offer",
|
|
47
65
|
to: e.peerId,
|
|
48
66
|
sdp: e.pc.localDescription?.sdp ?? t.sdp
|
|
49
67
|
});
|
|
50
68
|
} catch {
|
|
51
|
-
u.onNotice?.("offer_failed");
|
|
69
|
+
T(e) && u.onNotice?.("offer_failed");
|
|
52
70
|
} finally {
|
|
53
71
|
e.makingOffer = !1;
|
|
54
72
|
}
|
|
55
73
|
}
|
|
56
|
-
},
|
|
57
|
-
let n =
|
|
74
|
+
}, M = (e) => e.streams?.[0] || (typeof MediaStream > "u" ? null : new MediaStream([e.track])), N = (e, t) => {
|
|
75
|
+
let n = c(t.transceiver, e, d), r = m.get(e.peerId);
|
|
58
76
|
n && r && (r.streams = {
|
|
59
77
|
...r.streams,
|
|
60
|
-
[n.name]:
|
|
78
|
+
[n.name]: M(t)
|
|
61
79
|
}, t.track.onended = () => {
|
|
62
80
|
let t = m.get(e.peerId);
|
|
63
81
|
t && (t.streams = {
|
|
64
82
|
...t.streams,
|
|
65
83
|
[n.name]: null
|
|
66
|
-
},
|
|
67
|
-
},
|
|
68
|
-
},
|
|
84
|
+
}, w());
|
|
85
|
+
}, w());
|
|
86
|
+
}, P = (e, t) => {
|
|
87
|
+
let n = (g.get(e) ?? Promise.resolve()).then(t);
|
|
88
|
+
return g.set(e, n), n;
|
|
89
|
+
}, F = async (e, t, n) => {
|
|
90
|
+
let r = e.transceivers[n];
|
|
91
|
+
if (r && T(e)) try {
|
|
92
|
+
await r.sender.replaceTrack(h.get(t) ?? null);
|
|
93
|
+
} catch {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
}, I = (e) => Promise.all(d.map((t, n) => P(t.name, () => F(e, t.name, n)))), L = (e) => {
|
|
97
|
+
if (b) return;
|
|
69
98
|
let t = p.get(e);
|
|
70
|
-
t && (t.pc.close(), p.delete(e)), m.delete(e),
|
|
71
|
-
},
|
|
72
|
-
if (p.has(e)) return;
|
|
73
|
-
let n =
|
|
99
|
+
t && (t.pc.close(), p.delete(e)), m.delete(e), x.delete(e), A(y), O(), w(), k();
|
|
100
|
+
}, R = async (e, { offerer: t }) => {
|
|
101
|
+
if (b || p.has(e)) return;
|
|
102
|
+
let n = o(_), r = [];
|
|
103
|
+
if (t) try {
|
|
104
|
+
r = d.map((e) => n.addTransceiver(e.kind, { direction: "sendrecv" }));
|
|
105
|
+
} catch (e) {
|
|
106
|
+
throw n.close(), e;
|
|
107
|
+
}
|
|
108
|
+
let i = {
|
|
74
109
|
peerId: e,
|
|
75
110
|
pc: n,
|
|
76
|
-
transceivers:
|
|
111
|
+
transceivers: r,
|
|
77
112
|
pendingCandidates: [],
|
|
78
113
|
remoteReady: !1,
|
|
79
114
|
makingOffer: !1,
|
|
80
115
|
isOfferer: t
|
|
81
116
|
};
|
|
82
|
-
p.set(e,
|
|
117
|
+
p.set(e, i), m.set(e, {
|
|
83
118
|
peerId: e,
|
|
84
119
|
connection: n.connectionState,
|
|
85
|
-
streams: m.get(e)?.streams ??
|
|
86
|
-
}), t && (
|
|
120
|
+
streams: m.get(e)?.streams ?? C()
|
|
121
|
+
}), !(t && (await I(i), !T(i))) && (n.onicecandidate = (t) => {
|
|
87
122
|
f({
|
|
88
123
|
type: "ice",
|
|
89
124
|
to: e,
|
|
@@ -91,50 +126,55 @@ function u(u) {
|
|
|
91
126
|
sdpMid: t.candidate?.sdpMid ?? null,
|
|
92
127
|
sdpMLineIndex: t.candidate?.sdpMLineIndex ?? null
|
|
93
128
|
});
|
|
94
|
-
}, n.ontrack = (e) =>
|
|
129
|
+
}, n.ontrack = (e) => N(i, e), n.onconnectionstatechange = () => {
|
|
130
|
+
if (!T(i)) return;
|
|
95
131
|
let t = m.get(e);
|
|
96
132
|
if (t && (t.connection = n.connectionState), n.connectionState === "failed" || n.connectionState === "closed") {
|
|
97
|
-
|
|
133
|
+
L(e);
|
|
98
134
|
return;
|
|
99
135
|
}
|
|
100
|
-
|
|
136
|
+
w(), k();
|
|
101
137
|
}, n.onnegotiationneeded = () => {
|
|
102
|
-
|
|
103
|
-
},
|
|
104
|
-
},
|
|
105
|
-
p.has(e) || await
|
|
138
|
+
i.isOfferer && j(i);
|
|
139
|
+
}, O(), w(), k(), t && await j(i));
|
|
140
|
+
}, z = async (e, t) => {
|
|
141
|
+
p.has(e) || await R(e, { offerer: !1 });
|
|
106
142
|
let n = p.get(e);
|
|
107
|
-
if (!n)
|
|
108
|
-
await n.pc.setRemoteDescription({
|
|
143
|
+
if (!n || !T(n) || await E(n, n.pc.setRemoteDescription({
|
|
109
144
|
type: "offer",
|
|
110
145
|
sdp: t
|
|
111
|
-
})
|
|
112
|
-
let r = await n.pc.createAnswer();
|
|
113
|
-
if (
|
|
114
|
-
|
|
115
|
-
|
|
146
|
+
})) === l || (n.remoteReady = !0, await s(n), n.isOfferer || (a(n), await I(n)), !T(n))) return;
|
|
147
|
+
let r = await E(n, n.pc.createAnswer());
|
|
148
|
+
if (r !== l) {
|
|
149
|
+
if (!r.sdp) {
|
|
150
|
+
u.onNotice?.("empty_local_answer_sdp");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
await E(n, v(n.pc, r)) !== l && (f({
|
|
154
|
+
type: "answer",
|
|
155
|
+
to: e,
|
|
156
|
+
sdp: n.pc.localDescription?.sdp ?? r.sdp
|
|
157
|
+
}), await A(y));
|
|
116
158
|
}
|
|
117
|
-
await _(n.pc, r), f({
|
|
118
|
-
type: "answer",
|
|
119
|
-
to: e,
|
|
120
|
-
sdp: n.pc.localDescription?.sdp ?? r.sdp
|
|
121
|
-
}), await D(v);
|
|
122
159
|
};
|
|
123
160
|
return u.onState?.("connecting", "signaling"), {
|
|
124
|
-
addPeer:
|
|
125
|
-
removePeer:
|
|
161
|
+
addPeer: R,
|
|
162
|
+
removePeer: L,
|
|
126
163
|
accept: async (e) => {
|
|
164
|
+
if (b) return;
|
|
127
165
|
if (e.type === "offer") {
|
|
128
|
-
await
|
|
166
|
+
await z(e.from, e.sdp);
|
|
129
167
|
return;
|
|
130
168
|
}
|
|
131
169
|
let t = p.get(e.from);
|
|
132
170
|
if (t) {
|
|
133
171
|
if (e.type === "answer") {
|
|
134
|
-
|
|
172
|
+
let n = t.pc.setRemoteDescription({
|
|
135
173
|
type: "answer",
|
|
136
174
|
sdp: e.sdp
|
|
137
|
-
})
|
|
175
|
+
});
|
|
176
|
+
if (await E(t, n) === l) return;
|
|
177
|
+
t.remoteReady = !0, await s(t), await A(y);
|
|
138
178
|
return;
|
|
139
179
|
}
|
|
140
180
|
e.candidate !== null && await i(t, {
|
|
@@ -145,25 +185,23 @@ function u(u) {
|
|
|
145
185
|
}
|
|
146
186
|
},
|
|
147
187
|
setLocalTrack: async (e, t) => {
|
|
188
|
+
if (b) return;
|
|
148
189
|
h.set(e, t);
|
|
149
190
|
let n = d.findIndex((t) => t.name === e);
|
|
150
|
-
|
|
151
|
-
for (let
|
|
152
|
-
|
|
153
|
-
if (r) try {
|
|
154
|
-
await r.sender.replaceTrack(t);
|
|
155
|
-
} catch {}
|
|
156
|
-
}
|
|
157
|
-
await D(v);
|
|
158
|
-
}
|
|
191
|
+
n < 0 || (await P(e, async () => {
|
|
192
|
+
for (let t of [...p.values()]) await F(t, e, n);
|
|
193
|
+
}), await A(y));
|
|
159
194
|
},
|
|
160
|
-
applyQuality:
|
|
195
|
+
applyQuality: A,
|
|
161
196
|
setIceServers: (e) => {
|
|
162
|
-
|
|
197
|
+
_ = e;
|
|
163
198
|
},
|
|
164
199
|
stop: () => {
|
|
165
|
-
|
|
166
|
-
|
|
200
|
+
if (!b) {
|
|
201
|
+
b = !0;
|
|
202
|
+
for (let e of p.values()) e.pc.close();
|
|
203
|
+
p.clear(), m.clear(), x.clear(), h.clear(), O(), w(), u.onState?.("closed");
|
|
204
|
+
}
|
|
167
205
|
},
|
|
168
206
|
getConnection: (e) => p.get(e)?.pc ?? null,
|
|
169
207
|
get peers() {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"peer-mesh.js","names":[],"sources":["../../src/webrtc/peer-mesh.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines — the mesh is one object's lifetime: a link's\n * transceivers, the offer it owes, the tracks published on it and the share of\n * the uplink it costs all change together, and every method here reads or writes\n * the same two maps. The pieces that *could* stand alone already do —\n * `peer-link.ts` for the per-connection mechanics and `mesh-quality.ts` for the\n * division — and what is left is the coordination between them.\n */\nimport { applyQualityToLink, resolveDegradation, scaleForRoom } from \"./mesh-quality\";\nimport {\n acceptCandidate,\n adoptTransceivers,\n attachLocalTracks,\n createPeerConnection,\n drainCandidates,\n slotOf,\n type PeerLink,\n} from \"./peer-link\";\nimport { createLinkStatsSampler, type LinkStatsSampler } from \"./link-stats\";\nimport type {\n MeshMessage,\n MeshPeer,\n MeshQuality,\n MeshStatsOptions,\n PeerMeshOptions,\n} from \"./mesh-types\";\n\n/** A running mesh. */\nexport interface PeerMesh {\n /**\n * Open a link to a peer.\n *\n * `offerer` decides which side sends the offer, and exactly one side of each\n * link must say `true`. Drive it from the arrival order your server already\n * imposes — the peer that joined **later** offers, the one already in the\n * room answers. That avoids glare by construction, without the rollback\n * dance of perfect negotiation and without depending on a browser\n * implementing argument-less `setLocalDescription`.\n */\n addPeer: (peerId: string, options: { offerer: boolean }) => Promise<void>;\n /** Close one link and give its share of the uplink back to the others. */\n removePeer: (peerId: string) => void;\n /** Feed the mesh an `offer`, `answer` or `ice` message from `from`. */\n accept: (message: MeshMessage & { from: string }) => Promise<void>;\n /**\n * Publish or retract one slot across every live link.\n *\n * `null` retracts: the sender keeps its negotiated transceiver and stops\n * producing, which is what mutes a microphone without disturbing the other\n * slots or the connection. Nothing renegotiates — the m-lines were allocated\n * before any track existed, which is what makes a mesh survive toggling: a\n * renegotiation per toggle is N-1 simultaneous offer/answer rounds every time\n * somebody unmutes.\n */\n setLocalTrack: (slot: string, track: MediaStreamTrack | null) => Promise<void>;\n /** Apply encoder limits, divided by the current size of the room. */\n applyQuality: (quality: MeshQuality) => Promise<void>;\n /** Replace the ICE servers used for links opened from now on. */\n setIceServers: (servers: RTCIceServer[]) => void;\n /** Close every link and report `closed`. */\n stop: () => void;\n /** The peers as the mesh currently sees them. */\n readonly peers: MeshPeer[];\n /**\n * The connection carrying one peer's link, or `null`.\n *\n * The escape hatch for everything this mesh does not model. `getStats()`,\n * `getSenders()`, an `RTCDataChannel`, an encoding tweak the `quality`\n * shape has no field for — all of it lives on the connection, and a mesh\n * that keeps it private turns adopting the mesh into losing a feature.\n *\n * {@link MeshPeer.connection} is the *state*, which is what a view needs;\n * this is the object, which is what a measurement needs. For the common\n * case of measuring, {@link PeerMeshOptions.stats} spares you the loop —\n * including the one-sampler-per-connection rule that a hand-rolled version\n * gets wrong.\n *\n * @param peerId - The id the peer was added under.\n * @returns The connection, or `null` when there is no link for that id.\n */\n getConnection: (peerId: string) => RTCPeerConnection | null;\n}\n\n/**\n * One `RTCPeerConnection` per peer, with the slots negotiated up front.\n *\n * The signalling protocol stays yours: this produces and accepts three message\n * shapes (`offer`, `answer`, `ice`) and leaves delivery, room membership and\n * identity to your server. What it owns is the part that is the same in every\n * mesh and wrong in most of them — see {@link PeerMesh} for the traps each\n * method exists to avoid.\n *\n * @param options - See {@link PeerMeshOptions}.\n * @returns The mesh.\n *\n * @example\n * const mesh = createPeerMesh({\n * slots: [\n * { name: \"mic\", kind: \"audio\" },\n * { name: \"cam\", kind: \"video\" },\n * ],\n * send: (message) => socket.send(message),\n * onPeers: setPeers,\n * });\n * socket.onMessage = (message) => void mesh.accept(message);\n * await mesh.addPeer(\"peer-2\", { offerer: true });\n * await mesh.setLocalTrack(\"mic\", micTrack);\n */\nexport function createPeerMesh(options: PeerMeshOptions): PeerMesh {\n const { slots, send } = options;\n const links = new Map<string, PeerLink>();\n const peers = new Map<string, MeshPeer>();\n const localTracks = new Map<string, MediaStreamTrack | null>();\n let iceServers = options.iceServers ?? [];\n const describe =\n options.setLocalDescription ??\n ((connection: RTCPeerConnection, description: RTCSessionDescriptionInit) =>\n connection.setLocalDescription(description));\n let quality: MeshQuality = options.quality ?? {};\n let stopped = false;\n const samplers = new Map<string, LinkStatsSampler>();\n let statsTimer: ReturnType<typeof setInterval> | null = null;\n\n const emptyStreams = (): Record<string, MediaStream | null> =>\n Object.fromEntries(slots.map((slot) => [slot.name, null]));\n\n const emitPeers = (): void => {\n options.onPeers?.([...peers.values()].map((peer) => ({ ...peer })));\n };\n\n /**\n * Sample every connected link once.\n *\n * Only `connected` links are asked. A link still gathering candidates has\n * no traffic to measure, and asking anyway spends a `getStats()` round trip\n * to learn that — per link, on every tick.\n *\n * `settings` arrives as an argument rather than being read from `options`\n * here: the timer is only ever armed past the check that they exist, so\n * reading them again would add a branch that nothing can reach.\n *\n * @param settings - The measuring options, already known to be present.\n */\n const sampleLinks = async (settings: MeshStatsOptions): Promise<void> => {\n if (stopped) return;\n let changed = false;\n\n for (const link of [...links.values()]) {\n if (link.pc.connectionState !== \"connected\") continue;\n let sampler = samplers.get(link.peerId);\n if (sampler === undefined) {\n sampler = createLinkStatsSampler(\n settings.kind === undefined ? {} : { kind: settings.kind },\n );\n samplers.set(link.peerId, sampler);\n }\n const stats = await sampler.sample(link.pc);\n const peer = peers.get(link.peerId);\n /*\n * Belt and braces, and the belt is the state check above: a peer\n * removed from inside `onStats` has its connection closed first, so\n * the next iteration skips it there rather than here. This catches\n * the entry disappearing any other way across the await.\n */\n if (peer === undefined) continue;\n peer.stats = stats;\n changed = true;\n settings.onStats?.(link.peerId, stats);\n }\n\n if (changed) emitPeers();\n };\n\n /**\n * Keep the timer alive exactly while there is something to measure.\n *\n * One timer for the whole mesh, not one per peer — and none at all in an\n * empty room, which is where a call sits for as long as the first person is\n * early.\n */\n const syncStatsTimer = (): void => {\n const settings = options.stats;\n if (settings === undefined) return;\n const wanted = !stopped && links.size > 0;\n if (wanted && statsTimer === null) {\n statsTimer = setInterval(() => void sampleLinks(settings), settings.intervalMs ?? 2000);\n return;\n }\n if (!wanted && statsTimer !== null) {\n clearInterval(statsTimer);\n statsTimer = null;\n }\n };\n\n /**\n * Collapse the per-link states into the one badge a call shows.\n *\n * No links at all is `connected`, not a failure: it means nobody else is in\n * the room yet, which is a legitimate state a call sits in for as long as the\n * first person is early.\n */\n const refreshState = (): void => {\n if (stopped) return;\n const states = [...links.values()].map((link) => link.pc.connectionState);\n if (states.some((state) => state === \"connected\")) options.onState?.(\"connected\");\n else if (states.length > 0) options.onState?.(\"connecting\", \"negotiating\");\n else options.onState?.(\"connected\", \"alone\");\n };\n\n const applyQuality = async (next: MeshQuality): Promise<void> => {\n quality = next;\n const effective = scaleForRoom(next, links.size);\n const degradation = resolveDegradation(next, effective);\n for (const link of links.values()) {\n await applyQualityToLink(link.transceivers, slots, effective, degradation);\n }\n };\n\n const makeOffer = async (link: PeerLink): Promise<void> => {\n if (link.makingOffer) return;\n link.makingOffer = true;\n try {\n const offer = await link.pc.createOffer();\n if (!offer.sdp) {\n options.onNotice?.(\"empty_local_offer_sdp\");\n return;\n }\n await describe(link.pc, offer);\n send({\n type: \"offer\",\n to: link.peerId,\n sdp: link.pc.localDescription?.sdp ?? offer.sdp,\n });\n } catch {\n options.onNotice?.(\"offer_failed\");\n } finally {\n link.makingOffer = false;\n }\n };\n\n /**\n * Wrap an inbound track in the stream the caller renders.\n *\n * The event's own stream is preferred: it is the one the sending side\n * grouped the track into, so a caller that reads `stream.id` sees what the\n * peer published rather than a wrapper invented here. Browsers only fill it\n * when the offer named a stream, hence the fallback — and the guard after\n * that is for a runtime with no `MediaStream` at all, where routing still\n * has to not throw.\n */\n const streamFor = (event: RTCTrackEvent): MediaStream | null => {\n const provided = event.streams?.[0];\n if (provided) return provided;\n return typeof MediaStream === \"undefined\" ? null : new MediaStream([event.track]);\n };\n\n const attachRemoteTrack = (link: PeerLink, event: RTCTrackEvent): void => {\n const slot = slotOf(event.transceiver, link, slots);\n const peer = peers.get(link.peerId);\n if (!slot || !peer) return;\n\n peer.streams = { ...peer.streams, [slot.name]: streamFor(event) };\n event.track.onended = (): void => {\n const current = peers.get(link.peerId);\n if (!current) return;\n current.streams = { ...current.streams, [slot.name]: null };\n emitPeers();\n };\n emitPeers();\n };\n\n const removePeer = (peerId: string): void => {\n const link = links.get(peerId);\n if (link) {\n link.pc.close();\n links.delete(peerId);\n }\n peers.delete(peerId);\n samplers.delete(peerId);\n void applyQuality(quality);\n syncStatsTimer();\n emitPeers();\n refreshState();\n };\n\n const addPeer = async (peerId: string, { offerer }: { offerer: boolean }): Promise<void> => {\n if (links.has(peerId)) return;\n\n const pc = createPeerConnection(iceServers);\n const link: PeerLink = {\n peerId,\n pc,\n transceivers: [],\n pendingCandidates: [],\n remoteReady: false,\n makingOffer: false,\n isOfferer: offerer,\n };\n links.set(peerId, link);\n peers.set(peerId, {\n peerId,\n connection: pc.connectionState,\n streams: peers.get(peerId)?.streams ?? emptyStreams(),\n });\n\n if (offerer) {\n link.transceivers = slots.map((slot) =>\n pc.addTransceiver(slot.kind, { direction: \"sendrecv\" }),\n );\n await attachLocalTracks(link, slots, localTracks);\n }\n\n pc.onicecandidate = (event): void => {\n send({\n type: \"ice\",\n to: peerId,\n candidate: event.candidate?.candidate ?? null,\n sdpMid: event.candidate?.sdpMid ?? null,\n sdpMLineIndex: event.candidate?.sdpMLineIndex ?? null,\n });\n };\n\n pc.ontrack = (event): void => attachRemoteTrack(link, event);\n\n pc.onconnectionstatechange = (): void => {\n const peer = peers.get(peerId);\n if (peer) peer.connection = pc.connectionState;\n if (pc.connectionState === \"failed\" || pc.connectionState === \"closed\") {\n removePeer(peerId);\n return;\n }\n emitPeers();\n refreshState();\n };\n\n pc.onnegotiationneeded = (): void => {\n if (link.isOfferer) void makeOffer(link);\n };\n\n syncStatsTimer();\n emitPeers();\n refreshState();\n if (offerer) await makeOffer(link);\n };\n\n const acceptOffer = async (from: string, sdp: string): Promise<void> => {\n if (!links.has(from)) await addPeer(from, { offerer: false });\n const link = links.get(from);\n if (!link) return;\n\n await link.pc.setRemoteDescription({ type: \"offer\", sdp });\n link.remoteReady = true;\n await drainCandidates(link);\n\n if (!link.isOfferer) {\n adoptTransceivers(link);\n await attachLocalTracks(link, slots, localTracks);\n }\n\n const answer = await link.pc.createAnswer();\n if (!answer.sdp) {\n options.onNotice?.(\"empty_local_answer_sdp\");\n return;\n }\n await describe(link.pc, answer);\n send({ type: \"answer\", to: from, sdp: link.pc.localDescription?.sdp ?? answer.sdp });\n await applyQuality(quality);\n };\n\n const accept = async (message: MeshMessage & { from: string }): Promise<void> => {\n if (message.type === \"offer\") {\n await acceptOffer(message.from, message.sdp);\n return;\n }\n\n const link = links.get(message.from);\n if (!link) return;\n\n if (message.type === \"answer\") {\n await link.pc.setRemoteDescription({ type: \"answer\", sdp: message.sdp });\n link.remoteReady = true;\n await drainCandidates(link);\n await applyQuality(quality);\n return;\n }\n\n if (message.candidate === null) return;\n await acceptCandidate(link, {\n candidate: message.candidate,\n sdpMid: message.sdpMid ?? undefined,\n sdpMLineIndex: message.sdpMLineIndex ?? undefined,\n });\n };\n\n const setLocalTrack = async (slot: string, track: MediaStreamTrack | null): Promise<void> => {\n localTracks.set(slot, track);\n const index = slots.findIndex((entry) => entry.name === slot);\n if (index < 0) return;\n\n for (const link of links.values()) {\n const transceiver = link.transceivers[index];\n if (!transceiver) continue;\n try {\n await transceiver.sender.replaceTrack(track);\n } catch {\n /* the link is tearing down; its state handler will remove it */\n }\n }\n await applyQuality(quality);\n };\n\n const stop = (): void => {\n for (const link of links.values()) link.pc.close();\n links.clear();\n peers.clear();\n samplers.clear();\n stopped = true;\n syncStatsTimer();\n emitPeers();\n options.onState?.(\"closed\");\n };\n\n options.onState?.(\"connecting\", \"signaling\");\n\n return {\n addPeer,\n removePeer,\n accept,\n setLocalTrack,\n applyQuality,\n setIceServers: (servers): void => {\n iceServers = servers;\n },\n stop,\n getConnection: (peerId: string): RTCPeerConnection | null => links.get(peerId)?.pc ?? null,\n get peers(): MeshPeer[] {\n return [...peers.values()].map((peer) => ({ ...peer }));\n },\n };\n}\n"],"mappings":";;;;AA4GA,SAAgB,EAAe,GAAoC;CAC/D,IAAM,EAAE,UAAO,YAAS,GAClB,oBAAQ,IAAI,IAAsB,GAClC,oBAAQ,IAAI,IAAsB,GAClC,oBAAc,IAAI,IAAqC,GACzD,IAAa,EAAQ,cAAc,CAAC,GAClC,IACF,EAAQ,yBACN,GAA+B,MAC7B,EAAW,oBAAoB,CAAW,IAC9C,IAAuB,EAAQ,WAAW,CAAC,GAC3C,IAAU,IACR,oBAAW,IAAI,IAA8B,GAC/C,IAAoD,MAElD,UACF,OAAO,YAAY,EAAM,KAAK,MAAS,CAAC,EAAK,MAAM,IAAI,CAAC,CAAC,GAEvD,UAAwB;EAC1B,EAAQ,UAAU,CAAC,GAAG,EAAM,OAAO,CAAC,CAAC,CAAC,KAAK,OAAU,EAAE,GAAG,EAAK,EAAE,CAAC;CACtE,GAeM,IAAc,OAAO,MAA8C;EACrE,IAAI,GAAS;EACb,IAAI,IAAU;EAEd,KAAK,IAAM,KAAQ,CAAC,GAAG,EAAM,OAAO,CAAC,GAAG;GACpC,IAAI,EAAK,GAAG,oBAAoB,aAAa;GAC7C,IAAI,IAAU,EAAS,IAAI,EAAK,MAAM;GACtC,AAAI,MAAY,KAAA,MACZ,IAAU,EACN,EAAS,SAAS,KAAA,IAAY,CAAC,IAAI,EAAE,MAAM,EAAS,KAAK,CAC7D,GACA,EAAS,IAAI,EAAK,QAAQ,CAAO;GAErC,IAAM,IAAQ,MAAM,EAAQ,OAAO,EAAK,EAAE,GACpC,IAAO,EAAM,IAAI,EAAK,MAAM;GAO9B,MAAS,KAAA,MACb,EAAK,QAAQ,GACb,IAAU,IACV,EAAS,UAAU,EAAK,QAAQ,CAAK;EACzC;EAEA,AAAI,KAAS,EAAU;CAC3B,GASM,UAA6B;EAC/B,IAAM,IAAW,EAAQ;EACzB,IAAI,MAAa,KAAA,GAAW;EAC5B,IAAM,IAAS,CAAC,KAAW,EAAM,OAAO;EACxC,IAAI,KAAU,MAAe,MAAM;GAC/B,IAAa,kBAAkB,KAAK,EAAY,CAAQ,GAAG,EAAS,cAAc,GAAI;GACtF;EACJ;EACA,AAAI,CAAC,KAAU,MAAe,SAC1B,cAAc,CAAU,GACxB,IAAa;CAErB,GASM,UAA2B;EAC7B,IAAI,GAAS;EACb,IAAM,IAAS,CAAC,GAAG,EAAM,OAAO,CAAC,CAAC,CAAC,KAAK,MAAS,EAAK,GAAG,eAAe;EACxE,AAAI,EAAO,MAAM,MAAU,MAAU,WAAW,IAAG,EAAQ,UAAU,WAAW,IACvE,EAAO,SAAS,IAAG,EAAQ,UAAU,cAAc,aAAa,IACpE,EAAQ,UAAU,aAAa,OAAO;CAC/C,GAEM,IAAe,OAAO,MAAqC;EAC7D,IAAU;EACV,IAAM,IAAY,EAAa,GAAM,EAAM,IAAI,GACzC,IAAc,EAAmB,GAAM,CAAS;EACtD,KAAK,IAAM,KAAQ,EAAM,OAAO,GAC5B,MAAM,EAAmB,EAAK,cAAc,GAAO,GAAW,CAAW;CAEjF,GAEM,IAAY,OAAO,MAAkC;EACnD,OAAK,aACT;KAAK,cAAc;GACnB,IAAI;IACA,IAAM,IAAQ,MAAM,EAAK,GAAG,YAAY;IACxC,IAAI,CAAC,EAAM,KAAK;KACZ,EAAQ,WAAW,uBAAuB;KAC1C;IACJ;IAEA,AADA,MAAM,EAAS,EAAK,IAAI,CAAK,GAC7B,EAAK;KACD,MAAM;KACN,IAAI,EAAK;KACT,KAAK,EAAK,GAAG,kBAAkB,OAAO,EAAM;IAChD,CAAC;GACL,QAAQ;IACJ,EAAQ,WAAW,cAAc;GACrC,UAAU;IACN,EAAK,cAAc;GACvB;EAjBmB;CAkBvB,GAYM,KAAa,MACE,EAAM,UAAU,OAE1B,OAAO,cAAgB,MAAc,OAAO,IAAI,YAAY,CAAC,EAAM,KAAK,CAAC,IAG9E,KAAqB,GAAgB,MAA+B;EACtE,IAAM,IAAO,EAAO,EAAM,aAAa,GAAM,CAAK,GAC5C,IAAO,EAAM,IAAI,EAAK,MAAM;EAC9B,AAAC,KAAS,MAEd,EAAK,UAAU;GAAE,GAAG,EAAK;IAAU,EAAK,OAAO,EAAU,CAAK;EAAE,GAChE,EAAM,MAAM,gBAAsB;GAC9B,IAAM,IAAU,EAAM,IAAI,EAAK,MAAM;GAChC,MACL,EAAQ,UAAU;IAAE,GAAG,EAAQ;KAAU,EAAK,OAAO;GAAK,GAC1D,EAAU;EACd,GACA,EAAU;CACd,GAEM,KAAc,MAAyB;EACzC,IAAM,IAAO,EAAM,IAAI,CAAM;EAU7B,AATI,MACA,EAAK,GAAG,MAAM,GACd,EAAM,OAAO,CAAM,IAEvB,EAAM,OAAO,CAAM,GACnB,EAAS,OAAO,CAAM,GACtB,EAAkB,CAAO,GACzB,EAAe,GACf,EAAU,GACV,EAAa;CACjB,GAEM,IAAU,OAAO,GAAgB,EAAE,iBAAmD;EACxF,IAAI,EAAM,IAAI,CAAM,GAAG;EAEvB,IAAM,IAAK,EAAqB,CAAU,GACpC,IAAiB;GACnB;GACA;GACA,cAAc,CAAC;GACf,mBAAmB,CAAC;GACpB,aAAa;GACb,aAAa;GACb,WAAW;EACf;EA6CA,AA5CA,EAAM,IAAI,GAAQ,CAAI,GACtB,EAAM,IAAI,GAAQ;GACd;GACA,YAAY,EAAG;GACf,SAAS,EAAM,IAAI,CAAM,CAAC,EAAE,WAAW,EAAa;EACxD,CAAC,GAEG,MACA,EAAK,eAAe,EAAM,KAAK,MAC3B,EAAG,eAAe,EAAK,MAAM,EAAE,WAAW,WAAW,CAAC,CAC1D,GACA,MAAM,EAAkB,GAAM,GAAO,CAAW,IAGpD,EAAG,kBAAkB,MAAgB;GACjC,EAAK;IACD,MAAM;IACN,IAAI;IACJ,WAAW,EAAM,WAAW,aAAa;IACzC,QAAQ,EAAM,WAAW,UAAU;IACnC,eAAe,EAAM,WAAW,iBAAiB;GACrD,CAAC;EACL,GAEA,EAAG,WAAW,MAAgB,EAAkB,GAAM,CAAK,GAE3D,EAAG,gCAAsC;GACrC,IAAM,IAAO,EAAM,IAAI,CAAM;GAE7B,IADI,MAAM,EAAK,aAAa,EAAG,kBAC3B,EAAG,oBAAoB,YAAY,EAAG,oBAAoB,UAAU;IACpE,EAAW,CAAM;IACjB;GACJ;GAEA,AADA,EAAU,GACV,EAAa;EACjB,GAEA,EAAG,4BAAkC;GACjC,AAAI,EAAK,aAAW,EAAe,CAAI;EAC3C,GAEA,EAAe,GACf,EAAU,GACV,EAAa,GACT,KAAS,MAAM,EAAU,CAAI;CACrC,GAEM,IAAc,OAAO,GAAc,MAA+B;EACpE,AAAK,EAAM,IAAI,CAAI,KAAG,MAAM,EAAQ,GAAM,EAAE,SAAS,GAAM,CAAC;EAC5D,IAAM,IAAO,EAAM,IAAI,CAAI;EAC3B,IAAI,CAAC,GAAM;EAMX,AAJA,MAAM,EAAK,GAAG,qBAAqB;GAAE,MAAM;GAAS;EAAI,CAAC,GACzD,EAAK,cAAc,IACnB,MAAM,EAAgB,CAAI,GAErB,EAAK,cACN,EAAkB,CAAI,GACtB,MAAM,EAAkB,GAAM,GAAO,CAAW;EAGpD,IAAM,IAAS,MAAM,EAAK,GAAG,aAAa;EAC1C,IAAI,CAAC,EAAO,KAAK;GACb,EAAQ,WAAW,wBAAwB;GAC3C;EACJ;EAGA,AAFA,MAAM,EAAS,EAAK,IAAI,CAAM,GAC9B,EAAK;GAAE,MAAM;GAAU,IAAI;GAAM,KAAK,EAAK,GAAG,kBAAkB,OAAO,EAAO;EAAI,CAAC,GACnF,MAAM,EAAa,CAAO;CAC9B;CAyDA,OAFA,EAAQ,UAAU,cAAc,WAAW,GAEpC;EACH;EACA;EACA,eA1DkB,MAA2D;GAC7E,IAAI,EAAQ,SAAS,SAAS;IAC1B,MAAM,EAAY,EAAQ,MAAM,EAAQ,GAAG;IAC3C;GACJ;GAEA,IAAM,IAAO,EAAM,IAAI,EAAQ,IAAI;GAC9B,OAEL;QAAI,EAAQ,SAAS,UAAU;KAI3B,AAHA,MAAM,EAAK,GAAG,qBAAqB;MAAE,MAAM;MAAU,KAAK,EAAQ;KAAI,CAAC,GACvE,EAAK,cAAc,IACnB,MAAM,EAAgB,CAAI,GAC1B,MAAM,EAAa,CAAO;KAC1B;IACJ;IAEI,EAAQ,cAAc,QAC1B,MAAM,EAAgB,GAAM;KACxB,WAAW,EAAQ;KACnB,QAAQ,EAAQ,UAAU,KAAA;KAC1B,eAAe,EAAQ,iBAAiB,KAAA;IAC5C,CAAC;GAPD;EAQJ;EAoCI,sBAlCyB,GAAc,MAAkD;GACzF,EAAY,IAAI,GAAM,CAAK;GAC3B,IAAM,IAAQ,EAAM,WAAW,MAAU,EAAM,SAAS,CAAI;GACxD,UAAQ,IAEZ;SAAK,IAAM,KAAQ,EAAM,OAAO,GAAG;KAC/B,IAAM,IAAc,EAAK,aAAa;KACjC,OACL,IAAI;MACA,MAAM,EAAY,OAAO,aAAa,CAAK;KAC/C,QAAQ,CAER;IACJ;IACA,MAAM,EAAa,CAAO;GAD1B;EAEJ;EAoBI;EACA,gBAAgB,MAAkB;GAC9B,IAAa;EACjB;EACA,YAtBqB;GACrB,KAAK,IAAM,KAAQ,EAAM,OAAO,GAAG,EAAK,GAAG,MAAM;GAOjD,AANA,EAAM,MAAM,GACZ,EAAM,MAAM,GACZ,EAAS,MAAM,GACf,IAAU,IACV,EAAe,GACf,EAAU,GACV,EAAQ,UAAU,QAAQ;EAC9B;EAcI,gBAAgB,MAA6C,EAAM,IAAI,CAAM,CAAC,EAAE,MAAM;EACtF,IAAI,QAAoB;GACpB,OAAO,CAAC,GAAG,EAAM,OAAO,CAAC,CAAC,CAAC,KAAK,OAAU,EAAE,GAAG,EAAK,EAAE;EAC1D;CACJ;AACJ"}
|
|
1
|
+
{"version":3,"file":"peer-mesh.js","names":[],"sources":["../../src/webrtc/peer-mesh.ts"],"sourcesContent":["/**\n * @tempest-limits file-lines — the mesh is one object's lifetime: a link's\n * transceivers, the offer it owes, the tracks published on it and the share of\n * the uplink it costs all change together, and every method here reads or writes\n * the same two maps. The pieces that *could* stand alone already do —\n * `peer-link.ts` for the per-connection mechanics and `mesh-quality.ts` for the\n * division — and what is left is the coordination between them.\n */\nimport { applyQualityToLink, resolveDegradation, scaleForRoom } from \"./mesh-quality\";\nimport {\n acceptCandidate,\n adoptTransceivers,\n createPeerConnection,\n drainCandidates,\n slotOf,\n type PeerLink,\n} from \"./peer-link\";\nimport { createLinkStatsSampler, type LinkStats, type LinkStatsSampler } from \"./link-stats\";\nimport type {\n MeshMessage,\n MeshPeer,\n MeshQuality,\n MeshStatsOptions,\n PeerMeshOptions,\n} from \"./mesh-types\";\n\n/** What {@link createPeerMesh}'s negotiation steps return once their link is gone. */\nconst GONE = Symbol(\"gone\");\n\n/** A running mesh. */\nexport interface PeerMesh {\n /**\n * Open a link to a peer.\n *\n * `offerer` decides which side sends the offer, and exactly one side of each\n * link must say `true`. Drive it from the arrival order your server already\n * imposes — the peer that joined **later** offers, the one already in the\n * room answers. That avoids glare by construction, without the rollback\n * dance of perfect negotiation and without depending on a browser\n * implementing argument-less `setLocalDescription`.\n */\n addPeer: (peerId: string, options: { offerer: boolean }) => Promise<void>;\n /** Close one link and give its share of the uplink back to the others. */\n removePeer: (peerId: string) => void;\n /** Feed the mesh an `offer`, `answer` or `ice` message from `from`. */\n accept: (message: MeshMessage & { from: string }) => Promise<void>;\n /**\n * Publish or retract one slot across every live link.\n *\n * `null` retracts: the sender keeps its negotiated transceiver and stops\n * producing, which is what mutes a microphone without disturbing the other\n * slots or the connection. Nothing renegotiates — the m-lines were allocated\n * before any track existed, which is what makes a mesh survive toggling: a\n * renegotiation per toggle is N-1 simultaneous offer/answer rounds every time\n * somebody unmutes.\n */\n setLocalTrack: (slot: string, track: MediaStreamTrack | null) => Promise<void>;\n /** Apply encoder limits, divided by the current size of the room. */\n applyQuality: (quality: MeshQuality) => Promise<void>;\n /** Replace the ICE servers used for links opened from now on. */\n setIceServers: (servers: RTCIceServer[]) => void;\n /**\n * Close every link, forget the local tracks and report `closed` — for good.\n *\n * Stopping is terminal. The signalling socket usually outlives the call, so\n * an offer still in flight from a former participant, or from anyone the\n * server relays to this user, keeps reaching {@link PeerMesh.accept} after\n * the hang-up. A stopped mesh drops it: no connection is opened, nothing is\n * answered and no track is published, so a call that reports `closed` never\n * sends the microphone or camera again. The same holds for `addPeer`,\n * `setLocalTrack` and `applyQuality`, and for every negotiation that was\n * already awaiting the browser when `stop()` ran. Calling it twice is a\n * no-op.\n *\n * The tracks themselves are not stopped: they belong to the caller, who may\n * still be showing them in a preview or reusing them on the next call. To\n * join again, create a new mesh.\n */\n stop: () => void;\n /** The peers as the mesh currently sees them. */\n readonly peers: MeshPeer[];\n /**\n * The connection carrying one peer's link, or `null`.\n *\n * The escape hatch for everything this mesh does not model. `getStats()`,\n * `getSenders()`, an `RTCDataChannel`, an encoding tweak the `quality`\n * shape has no field for — all of it lives on the connection, and a mesh\n * that keeps it private turns adopting the mesh into losing a feature.\n *\n * {@link MeshPeer.connection} is the *state*, which is what a view needs;\n * this is the object, which is what a measurement needs. For the common\n * case of measuring, {@link PeerMeshOptions.stats} spares you the loop —\n * including the one-sampler-per-connection rule that a hand-rolled version\n * gets wrong.\n *\n * @param peerId - The id the peer was added under.\n * @returns The connection, or `null` when there is no link for that id.\n */\n getConnection: (peerId: string) => RTCPeerConnection | null;\n}\n\n/**\n * One `RTCPeerConnection` per peer, with the slots negotiated up front.\n *\n * The signalling protocol stays yours: this produces and accepts three message\n * shapes (`offer`, `answer`, `ice`) and leaves delivery, room membership and\n * identity to your server. What it owns is the part that is the same in every\n * mesh and wrong in most of them — see {@link PeerMesh} for the traps each\n * method exists to avoid.\n *\n * @param options - See {@link PeerMeshOptions}.\n * @returns The mesh.\n *\n * @example\n * const mesh = createPeerMesh({\n * slots: [\n * { name: \"mic\", kind: \"audio\" },\n * { name: \"cam\", kind: \"video\" },\n * ],\n * send: (message) => socket.send(message),\n * onPeers: setPeers,\n * });\n * socket.onMessage = (message) => void mesh.accept(message);\n * await mesh.addPeer(\"peer-2\", { offerer: true });\n * await mesh.setLocalTrack(\"mic\", micTrack);\n */\nexport function createPeerMesh(options: PeerMeshOptions): PeerMesh {\n const { slots, send } = options;\n const links = new Map<string, PeerLink>();\n const peers = new Map<string, MeshPeer>();\n const localTracks = new Map<string, MediaStreamTrack | null>();\n const slotWrites = new Map<string, Promise<void>>();\n let iceServers = options.iceServers ?? [];\n const describe =\n options.setLocalDescription ??\n ((connection: RTCPeerConnection, description: RTCSessionDescriptionInit) =>\n connection.setLocalDescription(description));\n let quality: MeshQuality = options.quality ?? {};\n let stopped = false;\n const samplers = new Map<string, LinkStatsSampler>();\n let statsTimer: ReturnType<typeof setInterval> | null = null;\n\n const emptyStreams = (): Record<string, MediaStream | null> =>\n Object.fromEntries(slots.map((slot) => [slot.name, null]));\n\n const emitPeers = (): void => {\n options.onPeers?.([...peers.values()].map((peer) => ({ ...peer })));\n };\n\n /**\n * Whether a link is still the one the mesh holds for its peer.\n *\n * Every negotiation step awaits the browser, and `stop()` or `removePeer()`\n * can close the link inside any of those awaits. A continuation that does\n * not re-check keeps negotiating on a closed connection — which a browser\n * answers with `InvalidStateError` — or, worse, signals an answer for a call\n * that already hung up.\n *\n * @param link - The link a continuation is about to touch.\n * @returns `false` once the mesh stopped or the link was removed or replaced.\n */\n const isLive = (link: PeerLink): boolean => !stopped && links.get(link.peerId) === link;\n\n /**\n * Await one negotiation step, and tell the caller whether to go on.\n *\n * A browser settles a call that `close()` ran under in one of two ways:\n * it rejects with `InvalidStateError`, or — what Chromium was measured\n * doing — it never settles at all. The second needs nothing here; the\n * first would surface as a rejected `accept()`, which the documented\n * `void mesh.accept(message)` wiring turns into an unhandled rejection\n * for a call that already hung up. So a rejection on a link that is no\n * longer live is swallowed, and one on a live link is rethrown untouched.\n *\n * @param link - The link the step negotiates on.\n * @param step - The pending browser call.\n * @returns The step's value, or {@link GONE} when the link stopped being live.\n */\n const whileLive = async <T>(link: PeerLink, step: Promise<T>): Promise<T | typeof GONE> => {\n try {\n const value = await step;\n return isLive(link) ? value : GONE;\n } catch (error) {\n if (!isLive(link)) return GONE;\n throw error;\n }\n };\n\n /**\n * Sample every connected link once.\n *\n * Only `connected` links are asked. A link still gathering candidates has\n * no traffic to measure, and asking anyway spends a `getStats()` round trip\n * to learn that — per link, on every tick.\n *\n * `settings` arrives as an argument rather than being read from `options`\n * here: the timer is only ever armed past the check that they exist, so\n * reading them again would add a branch that nothing can reach.\n *\n * Each link is sampled on its own: a `getStats()` that rejects — a link\n * closing under the call does that — skips that link for this tick and\n * leaves the others measured. Across the await the link is re-checked by\n * identity, not by id: a peer removed and re-added under the same id holds\n * a new connection, and the old one's sample must not land on it.\n *\n * @param settings - The measuring options, already known to be present.\n */\n const sampleLinks = async (settings: MeshStatsOptions): Promise<void> => {\n if (stopped) return;\n let changed = false;\n\n for (const link of [...links.values()]) {\n if (link.pc.connectionState !== \"connected\") continue;\n let sampler = samplers.get(link.peerId);\n if (sampler === undefined) {\n sampler = createLinkStatsSampler(\n settings.kind === undefined ? {} : { kind: settings.kind },\n );\n samplers.set(link.peerId, sampler);\n }\n let stats: LinkStats;\n try {\n stats = await sampler.sample(link.pc);\n } catch {\n continue;\n }\n const peer = peers.get(link.peerId);\n if (!isLive(link) || peer === undefined) continue;\n peer.stats = stats;\n changed = true;\n settings.onStats?.(link.peerId, stats);\n }\n\n if (changed) emitPeers();\n };\n\n /**\n * Keep the timer alive exactly while there is something to measure.\n *\n * One timer for the whole mesh, not one per peer — and none at all in an\n * empty room, which is where a call sits for as long as the first person is\n * early.\n */\n const syncStatsTimer = (): void => {\n const settings = options.stats;\n if (settings === undefined) return;\n const wanted = !stopped && links.size > 0;\n if (wanted && statsTimer === null) {\n statsTimer = setInterval(() => void sampleLinks(settings), settings.intervalMs ?? 2000);\n return;\n }\n if (!wanted && statsTimer !== null) {\n clearInterval(statsTimer);\n statsTimer = null;\n }\n };\n\n /**\n * Collapse the per-link states into the one badge a call shows.\n *\n * No links at all is `connected`, not a failure: it means nobody else is in\n * the room yet, which is a legitimate state a call sits in for as long as the\n * first person is early.\n */\n const refreshState = (): void => {\n if (stopped) return;\n const states = [...links.values()].map((link) => link.pc.connectionState);\n if (states.some((state) => state === \"connected\")) options.onState?.(\"connected\");\n else if (states.length > 0) options.onState?.(\"connecting\", \"negotiating\");\n else options.onState?.(\"connected\", \"alone\");\n };\n\n const applyQuality = async (next: MeshQuality): Promise<void> => {\n if (stopped) return;\n quality = next;\n const effective = scaleForRoom(next, links.size);\n const degradation = resolveDegradation(next, effective);\n for (const link of links.values()) {\n await applyQualityToLink(link.transceivers, slots, effective, degradation);\n }\n };\n\n const makeOffer = async (link: PeerLink): Promise<void> => {\n if (link.makingOffer) return;\n link.makingOffer = true;\n try {\n const offer = await link.pc.createOffer();\n if (!isLive(link)) return;\n if (!offer.sdp) {\n options.onNotice?.(\"empty_local_offer_sdp\");\n return;\n }\n await describe(link.pc, offer);\n if (!isLive(link)) return;\n send({\n type: \"offer\",\n to: link.peerId,\n sdp: link.pc.localDescription?.sdp ?? offer.sdp,\n });\n } catch {\n if (isLive(link)) options.onNotice?.(\"offer_failed\");\n } finally {\n link.makingOffer = false;\n }\n };\n\n /**\n * Wrap an inbound track in the stream the caller renders.\n *\n * The event's own stream is preferred: it is the one the sending side\n * grouped the track into, so a caller that reads `stream.id` sees what the\n * peer published rather than a wrapper invented here. Browsers only fill it\n * when the offer named a stream, hence the fallback — and the guard after\n * that is for a runtime with no `MediaStream` at all, where routing still\n * has to not throw.\n */\n const streamFor = (event: RTCTrackEvent): MediaStream | null => {\n const provided = event.streams?.[0];\n if (provided) return provided;\n return typeof MediaStream === \"undefined\" ? null : new MediaStream([event.track]);\n };\n\n const attachRemoteTrack = (link: PeerLink, event: RTCTrackEvent): void => {\n const slot = slotOf(event.transceiver, link, slots);\n const peer = peers.get(link.peerId);\n if (!slot || !peer) return;\n\n peer.streams = { ...peer.streams, [slot.name]: streamFor(event) };\n event.track.onended = (): void => {\n const current = peers.get(link.peerId);\n if (!current) return;\n current.streams = { ...current.streams, [slot.name]: null };\n emitPeers();\n };\n emitPeers();\n };\n\n /**\n * Queue one write behind every earlier write to the same slot.\n *\n * Every write that puts a local track on a sender goes through here — the\n * fan-out of `setLocalTrack` and the first write onto a link that just\n * got its transceivers alike. Two writes to one slot therefore never run\n * at once, and a write queued later always finishes later.\n *\n * @param slot - The slot name.\n * @param write - The write to run once the earlier ones have finished.\n * @returns The queued write.\n */\n const enqueueWrite = (slot: string, write: () => Promise<void>): Promise<void> => {\n const queued = (slotWrites.get(slot) ?? Promise.resolve()).then(write);\n slotWrites.set(slot, queued);\n return queued;\n };\n\n /**\n * Put a slot's current track on one link's sender.\n *\n * The track is read when the write runs, not when it was queued, so the\n * last `setLocalTrack` wins whichever write lands last. A refusal\n * belongs to a link that is tearing down, whose state handler removes it,\n * so it is not an error here.\n *\n * @param link - The link to write onto.\n * @param slot - The slot name.\n * @param index - The slot's position in `slots`, which is its transceiver.\n */\n const writeSlot = async (link: PeerLink, slot: string, index: number): Promise<void> => {\n const transceiver = link.transceivers[index];\n if (!transceiver || !isLive(link)) return;\n try {\n await transceiver.sender.replaceTrack(localTracks.get(slot) ?? null);\n } catch {\n return;\n }\n };\n\n /**\n * Publish every held local track onto a link whose transceivers now exist.\n *\n * Up front for the offerer, after the offer lands for the answerer, so a\n * microphone that was already live when the peer joined starts flowing\n * without waiting for a toggle. Each slot's write joins that slot's queue,\n * so a mute issued while it is in flight still has the last word.\n *\n * @param link - The link to publish onto.\n */\n const attachLocalTracks = (link: PeerLink): Promise<unknown> =>\n Promise.all(\n slots.map((slot, index) =>\n enqueueWrite(slot.name, () => writeSlot(link, slot.name, index)),\n ),\n );\n\n const removePeer = (peerId: string): void => {\n if (stopped) return;\n const link = links.get(peerId);\n if (link) {\n link.pc.close();\n links.delete(peerId);\n }\n peers.delete(peerId);\n samplers.delete(peerId);\n void applyQuality(quality);\n syncStatsTimer();\n emitPeers();\n refreshState();\n };\n\n const addPeer = async (peerId: string, { offerer }: { offerer: boolean }): Promise<void> => {\n if (stopped || links.has(peerId)) return;\n\n const pc = createPeerConnection(iceServers);\n let transceivers: RTCRtpTransceiver[] = [];\n if (offerer) {\n try {\n transceivers = slots.map((slot) =>\n pc.addTransceiver(slot.kind, { direction: \"sendrecv\" }),\n );\n } catch (error) {\n pc.close();\n throw error;\n }\n }\n const link: PeerLink = {\n peerId,\n pc,\n transceivers,\n pendingCandidates: [],\n remoteReady: false,\n makingOffer: false,\n isOfferer: offerer,\n };\n links.set(peerId, link);\n peers.set(peerId, {\n peerId,\n connection: pc.connectionState,\n streams: peers.get(peerId)?.streams ?? emptyStreams(),\n });\n\n if (offerer) {\n await attachLocalTracks(link);\n if (!isLive(link)) return;\n }\n\n pc.onicecandidate = (event): void => {\n send({\n type: \"ice\",\n to: peerId,\n candidate: event.candidate?.candidate ?? null,\n sdpMid: event.candidate?.sdpMid ?? null,\n sdpMLineIndex: event.candidate?.sdpMLineIndex ?? null,\n });\n };\n\n pc.ontrack = (event): void => attachRemoteTrack(link, event);\n\n pc.onconnectionstatechange = (): void => {\n if (!isLive(link)) return;\n const peer = peers.get(peerId);\n if (peer) peer.connection = pc.connectionState;\n if (pc.connectionState === \"failed\" || pc.connectionState === \"closed\") {\n removePeer(peerId);\n return;\n }\n emitPeers();\n refreshState();\n };\n\n pc.onnegotiationneeded = (): void => {\n if (link.isOfferer) void makeOffer(link);\n };\n\n syncStatsTimer();\n emitPeers();\n refreshState();\n if (offerer) await makeOffer(link);\n };\n\n const acceptOffer = async (from: string, sdp: string): Promise<void> => {\n if (!links.has(from)) await addPeer(from, { offerer: false });\n const link = links.get(from);\n if (!link || !isLive(link)) return;\n\n if (\n (await whileLive(link, link.pc.setRemoteDescription({ type: \"offer\", sdp }))) === GONE\n ) {\n return;\n }\n link.remoteReady = true;\n await drainCandidates(link);\n\n if (!link.isOfferer) {\n adoptTransceivers(link);\n await attachLocalTracks(link);\n }\n if (!isLive(link)) return;\n\n const answer = await whileLive(link, link.pc.createAnswer());\n if (answer === GONE) return;\n if (!answer.sdp) {\n options.onNotice?.(\"empty_local_answer_sdp\");\n return;\n }\n if ((await whileLive(link, describe(link.pc, answer))) === GONE) return;\n send({ type: \"answer\", to: from, sdp: link.pc.localDescription?.sdp ?? answer.sdp });\n await applyQuality(quality);\n };\n\n const accept = async (message: MeshMessage & { from: string }): Promise<void> => {\n if (stopped) return;\n if (message.type === \"offer\") {\n await acceptOffer(message.from, message.sdp);\n return;\n }\n\n const link = links.get(message.from);\n if (!link) return;\n\n if (message.type === \"answer\") {\n const applied = link.pc.setRemoteDescription({ type: \"answer\", sdp: message.sdp });\n if ((await whileLive(link, applied)) === GONE) return;\n link.remoteReady = true;\n await drainCandidates(link);\n await applyQuality(quality);\n return;\n }\n\n if (message.candidate === null) return;\n await acceptCandidate(link, {\n candidate: message.candidate,\n sdpMid: message.sdpMid ?? undefined,\n sdpMLineIndex: message.sdpMLineIndex ?? undefined,\n });\n };\n\n /**\n * Publish or retract one slot, one write at a time per slot.\n *\n * Writes to a slot are chained, and each publishes whatever the slot holds\n * when it runs. Without the chain, an unmute whose `replaceTrack` is still\n * in flight could finish after the mute that followed it, leaving the\n * sender on the microphone while the mesh believes the slot is empty.\n */\n const setLocalTrack = async (slot: string, track: MediaStreamTrack | null): Promise<void> => {\n if (stopped) return;\n localTracks.set(slot, track);\n const index = slots.findIndex((entry) => entry.name === slot);\n if (index < 0) return;\n\n await enqueueWrite(slot, async () => {\n for (const link of [...links.values()]) await writeSlot(link, slot, index);\n });\n await applyQuality(quality);\n };\n\n const stop = (): void => {\n if (stopped) return;\n stopped = true;\n for (const link of links.values()) link.pc.close();\n links.clear();\n peers.clear();\n samplers.clear();\n localTracks.clear();\n syncStatsTimer();\n emitPeers();\n options.onState?.(\"closed\");\n };\n\n options.onState?.(\"connecting\", \"signaling\");\n\n return {\n addPeer,\n removePeer,\n accept,\n setLocalTrack,\n applyQuality,\n setIceServers: (servers): void => {\n iceServers = servers;\n },\n stop,\n getConnection: (peerId: string): RTCPeerConnection | null => links.get(peerId)?.pc ?? null,\n get peers(): MeshPeer[] {\n return [...peers.values()].map((peer) => ({ ...peer }));\n },\n };\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tempest-react-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.77.0",
|
|
4
4
|
"description": "SDK público da Tempest com componentes, hooks e integrações para projetos React.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -87,6 +87,11 @@
|
|
|
87
87
|
"import": "./dist/vision.js",
|
|
88
88
|
"require": "./dist/vision.cjs"
|
|
89
89
|
},
|
|
90
|
+
"./neural": {
|
|
91
|
+
"types": "./dist/neural.d.ts",
|
|
92
|
+
"import": "./dist/neural.js",
|
|
93
|
+
"require": "./dist/neural.cjs"
|
|
94
|
+
},
|
|
90
95
|
"./br": {
|
|
91
96
|
"types": "./dist/br.d.ts",
|
|
92
97
|
"import": "./dist/br.js",
|
|
@@ -151,6 +156,7 @@
|
|
|
151
156
|
"zustand": "^4.0.0 || ^5.0.0"
|
|
152
157
|
},
|
|
153
158
|
"peerDependencies": {
|
|
159
|
+
"@sapphi-red/web-noise-suppressor": "^0.4.1",
|
|
154
160
|
"@tiptap/react": "^2.0.0 || ^3.0.0",
|
|
155
161
|
"@tiptap/starter-kit": "^2.0.0 || ^3.0.0",
|
|
156
162
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0 || ^6.0.0",
|
|
@@ -183,12 +189,16 @@
|
|
|
183
189
|
},
|
|
184
190
|
"onnxruntime-web": {
|
|
185
191
|
"optional": true
|
|
192
|
+
},
|
|
193
|
+
"@sapphi-red/web-noise-suppressor": {
|
|
194
|
+
"optional": true
|
|
186
195
|
}
|
|
187
196
|
},
|
|
188
197
|
"devDependencies": {
|
|
189
198
|
"@eslint/js": "^10.0.1",
|
|
190
199
|
"@microsoft/api-extractor": "^7.58.12",
|
|
191
200
|
"@playwright/test": "^1.62.0",
|
|
201
|
+
"@sapphi-red/web-noise-suppressor": "^0.4.1",
|
|
192
202
|
"@size-limit/preset-small-lib": "^13.0.1",
|
|
193
203
|
"@tanstack/react-query": "^5.90.0",
|
|
194
204
|
"@testing-library/jest-dom": "^7.0.0",
|
package/template-pwa/src/pwa.tsx
CHANGED
|
@@ -3,10 +3,12 @@ import type { ReactNode } from "react";
|
|
|
3
3
|
import {
|
|
4
4
|
OfflineIndicator,
|
|
5
5
|
UpdatePrompt,
|
|
6
|
+
provideBackgroundSyncCredentials,
|
|
6
7
|
registerPeriodicSync,
|
|
7
8
|
requestPersistentStorage,
|
|
8
9
|
useServiceWorkerUpdate,
|
|
9
10
|
} from "tempest-react-sdk";
|
|
11
|
+
import { useAuth } from "@/stores/auth";
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* PWA shell: registers the service worker, wires the consent-based update
|
|
@@ -14,6 +16,14 @@ import {
|
|
|
14
16
|
* offline cache/IndexedDB is not evicted) and registers a periodic background
|
|
15
17
|
* sync once the worker is ready.
|
|
16
18
|
*
|
|
19
|
+
* It also answers the worker's background-sync questions from the auth store.
|
|
20
|
+
* The queue never stores the bearer, so a mutation that failed offline is only
|
|
21
|
+
* replayed with the token read here at replay time, and only while the same
|
|
22
|
+
* user (`getPrincipal`) is signed in — another account's tab, or a signed-out
|
|
23
|
+
* one, gets it dropped. Without this provider, queued authenticated mutations
|
|
24
|
+
* would wait unsent until they expire. A project without accounts deletes this
|
|
25
|
+
* effect along with `@/stores/auth`.
|
|
26
|
+
*
|
|
17
27
|
* Wraps the app so the fixed-position overlays render as siblings of the tree.
|
|
18
28
|
* The SDK helpers no-op gracefully where a capability is unsupported.
|
|
19
29
|
*/
|
|
@@ -27,6 +37,18 @@ export function PwaShell({ children }: { children: ReactNode }) {
|
|
|
27
37
|
void requestPersistentStorage();
|
|
28
38
|
}, []);
|
|
29
39
|
|
|
40
|
+
useEffect(
|
|
41
|
+
() =>
|
|
42
|
+
provideBackgroundSyncCredentials(
|
|
43
|
+
() => {
|
|
44
|
+
const { token } = useAuth.getState();
|
|
45
|
+
return token ? { Authorization: `Bearer ${token}` } : null;
|
|
46
|
+
},
|
|
47
|
+
{ getPrincipal: () => useAuth.getState().user?.id },
|
|
48
|
+
),
|
|
49
|
+
[],
|
|
50
|
+
);
|
|
51
|
+
|
|
30
52
|
useEffect(() => {
|
|
31
53
|
if (!registration) return;
|
|
32
54
|
void registerPeriodicSync({ registration, minIntervalMinutes: 360 });
|
package/template-pwa/src/sw.ts
CHANGED
|
@@ -14,15 +14,33 @@ import {
|
|
|
14
14
|
*
|
|
15
15
|
* Layers, in order:
|
|
16
16
|
* 1. Push + notifications + skip-waiting (the SDK push helpers).
|
|
17
|
-
* 2. Background sync — failed
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* `
|
|
22
|
-
*
|
|
17
|
+
* 2. Background sync — failed POST/PUT/PATCH/DELETE to `/api` are queued and
|
|
18
|
+
* replayed online. Credential headers are never stored; on replay the
|
|
19
|
+
* worker asks the open pages for the current ones and for who is signed in,
|
|
20
|
+
* which `src/pwa.tsx` answers from the auth store
|
|
21
|
+
* (`provideBackgroundSyncCredentials`). Remove that and queued
|
|
22
|
+
* authenticated mutations never replay.
|
|
23
|
+
* 3. Runtime caching: unauthenticated API GETs (network-first) + media
|
|
24
|
+
* (cache-first with Range support, so audio/video seeking works offline).
|
|
25
|
+
* Every request the API client sends with a bearer (`Authorization`, or any
|
|
26
|
+
* other credential header) bypasses this cache and goes to the network: in
|
|
27
|
+
* an app with accounts, most `/api` GETs. To serve them offline, add
|
|
28
|
+
* `cacheAuthenticated: true` to the route — entries then vary on the
|
|
29
|
+
* credential, so another account never reads them — and keep
|
|
30
|
+
* `clearSessionData()` in the logout flow.
|
|
31
|
+
* 4. Precache of the app shell so the app launches offline (API navigations
|
|
32
|
+
* excluded). Reads the `precache-manifest.json` emitted by
|
|
33
|
+
* `tempestPwaManifest()` in `vite.config.ts`.
|
|
34
|
+
*
|
|
35
|
+
* Cache names carry the `tempest-` prefix so `inspectCaches({ filter: "tempest-" })`
|
|
36
|
+
* reports them. On logout, call `clearSessionData()` from the page: it drops
|
|
37
|
+
* the runtime caches and the background-sync queue, keeping the precache.
|
|
23
38
|
*
|
|
24
39
|
* `installRuntimeCache` is registered BEFORE `installPrecache` so its specific
|
|
25
|
-
* routes win over the precache catch-all.
|
|
40
|
+
* routes win over the precache catch-all. The trailing `activate` listener takes
|
|
41
|
+
* control of open pages as soon as this worker activates; `installPrecache`
|
|
42
|
+
* also calls `clients.claim()`, so it is harmless and stays useful if you drop
|
|
43
|
+
* precaching.
|
|
26
44
|
*/
|
|
27
45
|
declare const self: ServiceWorkerGlobalScope;
|
|
28
46
|
|
|
@@ -35,23 +53,21 @@ installPushHandler({
|
|
|
35
53
|
installNotificationClickHandler();
|
|
36
54
|
installSkipWaitingListener();
|
|
37
55
|
|
|
38
|
-
// Queue failed POST/PUT/PATCH/DELETE to /api and replay them when back online.
|
|
39
56
|
installBackgroundSync({ match: (url) => url.pathname.startsWith("/api/") });
|
|
40
57
|
|
|
41
58
|
installRuntimeCache([
|
|
42
59
|
{
|
|
43
60
|
match: (url) => url.pathname.startsWith("/api/"),
|
|
44
61
|
strategy: "network-first",
|
|
45
|
-
cacheName: "api",
|
|
62
|
+
cacheName: "tempest-api",
|
|
46
63
|
networkTimeoutSeconds: 5,
|
|
47
64
|
maxEntries: 50,
|
|
48
65
|
maxAgeSeconds: 60 * 5,
|
|
49
66
|
},
|
|
50
67
|
{
|
|
51
|
-
// Audio/video: cache-first + Range support (seek/scrub works offline).
|
|
52
68
|
match: (url) => /\.(mp3|mp4|webm|ogg|wav)$/.test(url.pathname),
|
|
53
69
|
strategy: "cache-first",
|
|
54
|
-
cacheName: "media",
|
|
70
|
+
cacheName: "tempest-media",
|
|
55
71
|
maxEntries: 20,
|
|
56
72
|
rangeRequests: true,
|
|
57
73
|
},
|
|
@@ -59,12 +75,9 @@ installRuntimeCache([
|
|
|
59
75
|
|
|
60
76
|
installPrecache({
|
|
61
77
|
navigateFallback: "/index.html",
|
|
62
|
-
// Don't serve the app shell for API navigations.
|
|
63
78
|
navigateFallbackDenylist: [/^\/api\//],
|
|
64
79
|
});
|
|
65
80
|
|
|
66
|
-
// Take control of open pages as soon as this worker activates. `installPrecache`
|
|
67
|
-
// also calls `clients.claim()`; this is harmless if you drop precaching.
|
|
68
81
|
self.addEventListener("activate", (event) => {
|
|
69
82
|
event.waitUntil(self.clients.claim());
|
|
70
83
|
});
|
|
@@ -1,2 +0,0 @@
|
|
|
1
|
-
var e=new Set([`http:`,`https:`,`mailto:`,`tel:`,`sms:`]),t=/^data:image\/(png|jpe?g|gif|webp|avif|bmp);base64,[a-z0-9+/=\s]+$/i;function n(e){return e.replace(/[\u0000-\u0020\u007F]/g,``).trim()}function r(t){let r=n(t);if(!r)return null;if(/^[a-z][a-z0-9+.-]*:/i.test(r)){let t=r.slice(0,r.indexOf(`:`)+1).toLowerCase();return e.has(t)?r:null}return r}function i(e){let i=n(e);return i?t.test(i)?i:/^data:/i.test(i)?null:r(i):null}exports.safeImageUrl=i,exports.safeLinkUrl=r;
|
|
2
|
-
//# sourceMappingURL=markdown-url.cjs.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"markdown-url.cjs","names":[],"sources":["../../../src/components/Markdown/markdown-url.ts"],"sourcesContent":["/**\n * URL vetting for links and images in rendered Markdown.\n *\n * This is the security boundary of the whole component. Markdown that arrives from\n * a user — a comment, a ticket description, a review — reaches the DOM as a real\n * `href`, and `javascript:alert(1)` in a link is script execution on click. So the\n * scheme is checked against an allowlist rather than a blocklist: a blocklist has\n * to enumerate `javascript:`, `JaVaScRiPt:`, `java\\tscript:`, `javascript:`\n * and whatever comes next, and misses the one nobody thought of.\n */\n\n/** Schemes a link may use. Anything else is dropped. */\nconst LINK_SCHEMES = new Set([\"http:\", \"https:\", \"mailto:\", \"tel:\", \"sms:\"]);\n\n/**\n * Image `data:` types that are safe to inline.\n *\n * `svg+xml` is deliberately absent: an SVG is a document, it can carry `<script>`\n * and event handlers, and browsers execute them when it loads from a `data:` URL in\n * some contexts. The raster formats cannot carry code.\n */\nconst IMAGE_DATA_TYPES = /^data:image\\/(png|jpe?g|gif|webp|avif|bmp);base64,[a-z0-9+/=\\s]+$/i;\n\n/**\n * Strip the characters a scheme can be smuggled through.\n *\n * `java\\tscript:` and `java\\nscript:` are both parsed as `javascript:` by browsers,\n * and a leading control character defeats a naive `startsWith` check.\n */\nfunction normalize(raw: string): string {\n // eslint-disable-next-line no-control-regex -- stripping control characters is the point\n return raw.replace(/[\\u0000-\\u0020\\u007F]/g, \"\").trim();\n}\n\n/**\n * The href to render, or `null` when the URL must not be linked.\n *\n * Relative URLs (`/docs`, `./x`, `#anchor`, `foo/bar`) are kept: they cannot carry\n * a scheme, so they cannot execute anything, and dropping them would break every\n * in-app link.\n *\n * @param raw - URL as written in the Markdown.\n * @returns A safe href, or `null`.\n */\nexport function safeLinkUrl(raw: string): string | null {\n const url = normalize(raw);\n if (!url) return null;\n if (/^[a-z][a-z0-9+.-]*:/i.test(url)) {\n const scheme = url.slice(0, url.indexOf(\":\") + 1).toLowerCase();\n return LINK_SCHEMES.has(scheme) ? url : null;\n }\n // Protocol-relative (`//host/path`) inherits the page scheme — safe, and real.\n return url;\n}\n\n/**\n * The `src` to render for an image, or `null`.\n *\n * @param raw - URL as written in the Markdown.\n * @returns A safe src, or `null`.\n */\nexport function safeImageUrl(raw: string): string | null {\n const url = normalize(raw);\n if (!url) return null;\n if (IMAGE_DATA_TYPES.test(url)) return url;\n if (/^data:/i.test(url)) return null;\n return safeLinkUrl(url);\n}\n"],"mappings":"AAYA,IAAM,EAAe,IAAI,IAAI,CAAC,QAAS,SAAU,UAAW,OAAQ,MAAM,CAAC,EASrE,EAAmB,qEAQzB,SAAS,EAAU,EAAqB,CAEpC,OAAO,EAAI,QAAQ,yBAA0B,EAAE,CAAC,CAAC,KAAK,CAC1D,CAYA,SAAgB,EAAY,EAA4B,CACpD,IAAM,EAAM,EAAU,CAAG,EACzB,GAAI,CAAC,EAAK,OAAO,KACjB,GAAI,uBAAuB,KAAK,CAAG,EAAG,CAClC,IAAM,EAAS,EAAI,MAAM,EAAG,EAAI,QAAQ,GAAG,EAAI,CAAC,CAAC,CAAC,YAAY,EAC9D,OAAO,EAAa,IAAI,CAAM,EAAI,EAAM,IAC5C,CAEA,OAAO,CACX,CAQA,SAAgB,EAAa,EAA4B,CACrD,IAAM,EAAM,EAAU,CAAG,EAIzB,OAHK,EACD,EAAiB,KAAK,CAAG,EAAU,EACnC,UAAU,KAAK,CAAG,EAAU,KACzB,EAAY,CAAG,EAHL,IAIrB"}
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
//#region src/components/Markdown/markdown-url.ts
|
|
2
|
-
var e = /* @__PURE__ */ new Set([
|
|
3
|
-
"http:",
|
|
4
|
-
"https:",
|
|
5
|
-
"mailto:",
|
|
6
|
-
"tel:",
|
|
7
|
-
"sms:"
|
|
8
|
-
]), t = /^data:image\/(png|jpe?g|gif|webp|avif|bmp);base64,[a-z0-9+/=\s]+$/i;
|
|
9
|
-
function n(e) {
|
|
10
|
-
return e.replace(/[\u0000-\u0020\u007F]/g, "").trim();
|
|
11
|
-
}
|
|
12
|
-
function r(t) {
|
|
13
|
-
let r = n(t);
|
|
14
|
-
if (!r) return null;
|
|
15
|
-
if (/^[a-z][a-z0-9+.-]*:/i.test(r)) {
|
|
16
|
-
let t = r.slice(0, r.indexOf(":") + 1).toLowerCase();
|
|
17
|
-
return e.has(t) ? r : null;
|
|
18
|
-
}
|
|
19
|
-
return r;
|
|
20
|
-
}
|
|
21
|
-
function i(e) {
|
|
22
|
-
let i = n(e);
|
|
23
|
-
return i ? t.test(i) ? i : /^data:/i.test(i) ? null : r(i) : null;
|
|
24
|
-
}
|
|
25
|
-
//#endregion
|
|
26
|
-
export { i as safeImageUrl, r as safeLinkUrl };
|
|
27
|
-
|
|
28
|
-
//# sourceMappingURL=markdown-url.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"markdown-url.js","names":[],"sources":["../../../src/components/Markdown/markdown-url.ts"],"sourcesContent":["/**\n * URL vetting for links and images in rendered Markdown.\n *\n * This is the security boundary of the whole component. Markdown that arrives from\n * a user — a comment, a ticket description, a review — reaches the DOM as a real\n * `href`, and `javascript:alert(1)` in a link is script execution on click. So the\n * scheme is checked against an allowlist rather than a blocklist: a blocklist has\n * to enumerate `javascript:`, `JaVaScRiPt:`, `java\\tscript:`, `javascript:`\n * and whatever comes next, and misses the one nobody thought of.\n */\n\n/** Schemes a link may use. Anything else is dropped. */\nconst LINK_SCHEMES = new Set([\"http:\", \"https:\", \"mailto:\", \"tel:\", \"sms:\"]);\n\n/**\n * Image `data:` types that are safe to inline.\n *\n * `svg+xml` is deliberately absent: an SVG is a document, it can carry `<script>`\n * and event handlers, and browsers execute them when it loads from a `data:` URL in\n * some contexts. The raster formats cannot carry code.\n */\nconst IMAGE_DATA_TYPES = /^data:image\\/(png|jpe?g|gif|webp|avif|bmp);base64,[a-z0-9+/=\\s]+$/i;\n\n/**\n * Strip the characters a scheme can be smuggled through.\n *\n * `java\\tscript:` and `java\\nscript:` are both parsed as `javascript:` by browsers,\n * and a leading control character defeats a naive `startsWith` check.\n */\nfunction normalize(raw: string): string {\n // eslint-disable-next-line no-control-regex -- stripping control characters is the point\n return raw.replace(/[\\u0000-\\u0020\\u007F]/g, \"\").trim();\n}\n\n/**\n * The href to render, or `null` when the URL must not be linked.\n *\n * Relative URLs (`/docs`, `./x`, `#anchor`, `foo/bar`) are kept: they cannot carry\n * a scheme, so they cannot execute anything, and dropping them would break every\n * in-app link.\n *\n * @param raw - URL as written in the Markdown.\n * @returns A safe href, or `null`.\n */\nexport function safeLinkUrl(raw: string): string | null {\n const url = normalize(raw);\n if (!url) return null;\n if (/^[a-z][a-z0-9+.-]*:/i.test(url)) {\n const scheme = url.slice(0, url.indexOf(\":\") + 1).toLowerCase();\n return LINK_SCHEMES.has(scheme) ? url : null;\n }\n // Protocol-relative (`//host/path`) inherits the page scheme — safe, and real.\n return url;\n}\n\n/**\n * The `src` to render for an image, or `null`.\n *\n * @param raw - URL as written in the Markdown.\n * @returns A safe src, or `null`.\n */\nexport function safeImageUrl(raw: string): string | null {\n const url = normalize(raw);\n if (!url) return null;\n if (IMAGE_DATA_TYPES.test(url)) return url;\n if (/^data:/i.test(url)) return null;\n return safeLinkUrl(url);\n}\n"],"mappings":";AAYA,IAAM,oBAAe,IAAI,IAAI;CAAC;CAAS;CAAU;CAAW;CAAQ;AAAM,CAAC,GASrE,IAAmB;AAQzB,SAAS,EAAU,GAAqB;CAEpC,OAAO,EAAI,QAAQ,0BAA0B,EAAE,CAAC,CAAC,KAAK;AAC1D;AAYA,SAAgB,EAAY,GAA4B;CACpD,IAAM,IAAM,EAAU,CAAG;CACzB,IAAI,CAAC,GAAK,OAAO;CACjB,IAAI,uBAAuB,KAAK,CAAG,GAAG;EAClC,IAAM,IAAS,EAAI,MAAM,GAAG,EAAI,QAAQ,GAAG,IAAI,CAAC,CAAC,CAAC,YAAY;EAC9D,OAAO,EAAa,IAAI,CAAM,IAAI,IAAM;CAC5C;CAEA,OAAO;AACX;AAQA,SAAgB,EAAa,GAA4B;CACrD,IAAM,IAAM,EAAU,CAAG;CAIzB,OAHK,IACD,EAAiB,KAAK,CAAG,IAAU,IACnC,UAAU,KAAK,CAAG,IAAU,OACzB,EAAY,CAAG,IAHL;AAIrB"}
|