@omg-dev/sdk 0.4.24
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/dist/OmgBadge-LAcimQp0.mjs +345 -0
- package/dist/VibesFeedback-BF2Vf6FK.mjs +808 -0
- package/dist/brand/auto.mjs +18 -0
- package/dist/feedback/auto.mjs +26 -0
- package/dist/index.mjs +1611 -0
- package/package.json +43 -0
- package/src/auth/auto-prompt.tsx +50 -0
- package/src/auth/bridge.ts +63 -0
- package/src/auth/client.ts +222 -0
- package/src/auth/fetch.ts +23 -0
- package/src/auth/guard.tsx +24 -0
- package/src/auth/index.ts +23 -0
- package/src/auth/login.tsx +267 -0
- package/src/auth/mail-apps.ts +52 -0
- package/src/auth/react.tsx +248 -0
- package/src/brand/OmgBadge.tsx +366 -0
- package/src/brand/auto.tsx +35 -0
- package/src/brand/index.ts +1 -0
- package/src/feedback/VibesFeedback.tsx +360 -0
- package/src/feedback/auto.tsx +47 -0
- package/src/feedback/gestures.ts +296 -0
- package/src/feedback/index.ts +18 -0
- package/src/feedback/screenshot.ts +37 -0
- package/src/feedback/trace.ts +166 -0
- package/src/index.ts +1042 -0
- package/src/notifications/index.tsx +179 -0
- package/src/sandbox.test.ts +61 -0
- package/src/sandbox.ts +106 -0
- package/src/storage/VibesUpload.tsx +140 -0
- package/src/storage/index.ts +12 -0
- package/src/storage/useUpload.ts +167 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1611 @@
|
|
|
1
|
+
import { a as installTrace, c as requestMotionPermission, d as getAuthContext, f as notifyAuthRequired, h as subscribeAuthRequired, i as getTrace, l as useFeedbackGesture, m as subscribeAuthChange, n as captureScreenshot, o as attachGestureListeners, p as setAuthContext, r as clearTrace, s as motionPermissionState, t as VibesFeedback, u as useUpload } from "./VibesFeedback-BF2Vf6FK.mjs";
|
|
2
|
+
import { t as OmgBadge } from "./OmgBadge-LAcimQp0.mjs";
|
|
3
|
+
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { fetchEventSource } from "@microsoft/fetch-event-source";
|
|
5
|
+
import { createAuthClient } from "better-auth/react";
|
|
6
|
+
import { passkeyClient } from "@better-auth/passkey/client";
|
|
7
|
+
import { emailOTPClient, magicLinkClient } from "better-auth/client/plugins";
|
|
8
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
9
|
+
//#region src/auth/client.ts
|
|
10
|
+
const DEFAULT_AUTH_URL = "https://auth.omg.dev";
|
|
11
|
+
const AUTH_BRIDGE_PREFIX = "/__vibes/auth";
|
|
12
|
+
/**
|
|
13
|
+
* Derive the appId from the current host. The slug is the first DNS label.
|
|
14
|
+
* Canonical deployed apps live at `<slug>.apps.omg.dev`; cross-site app hosts
|
|
15
|
+
* (no shared `.omg.dev` cookie) live at `<slug>.omgs.app`. Returns null when
|
|
16
|
+
* the host doesn't match (local dev, custom domains).
|
|
17
|
+
*/
|
|
18
|
+
function deriveAppId(host) {
|
|
19
|
+
const h = host ?? (typeof window !== "undefined" ? window.location.host : "");
|
|
20
|
+
if (host === void 0) {
|
|
21
|
+
const injected = readInjectedAppId();
|
|
22
|
+
if (injected) return injected;
|
|
23
|
+
}
|
|
24
|
+
const m = /^([a-z0-9-]+)\.apps\.omg\.dev$/.exec(h) ?? /^([a-z0-9-]+)\.omgs\.app$/.exec(h);
|
|
25
|
+
return m ? m[1] : null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A cross-site app host is one served outside the shared `.omg.dev` cookie
|
|
29
|
+
* scope, so the slug JWT must be minted same-origin (the Worker reads the
|
|
30
|
+
* first-party `omg_access` cookie and exchanges it). Today that's `*.omgs.app`.
|
|
31
|
+
* Custom domains are out of scope here — they fall through to the default
|
|
32
|
+
* `${authUrl}/token` for now.
|
|
33
|
+
*/
|
|
34
|
+
function isCrossSiteAppHost(host) {
|
|
35
|
+
return host.endsWith(".omgs.app");
|
|
36
|
+
}
|
|
37
|
+
function deriveTokenUrl(authUrl = DEFAULT_AUTH_URL) {
|
|
38
|
+
const injected = readInjectedTokenUrl();
|
|
39
|
+
if (injected) return injected;
|
|
40
|
+
if (typeof window !== "undefined" && isCrossSiteAppHost(window.location.host)) return new URL("/__omg/auth/token", window.location.origin).toString();
|
|
41
|
+
return `${authUrl}/token`;
|
|
42
|
+
}
|
|
43
|
+
function readInjectedAppId() {
|
|
44
|
+
if (typeof window === "undefined") return null;
|
|
45
|
+
const value = window.__VIBES_APP_ID;
|
|
46
|
+
return typeof value === "string" && /^[a-z0-9-]+$/.test(value) ? value : null;
|
|
47
|
+
}
|
|
48
|
+
function readInjectedTokenUrl() {
|
|
49
|
+
if (typeof window === "undefined") return null;
|
|
50
|
+
const value = window.__VIBES_AUTH_TOKEN_URL;
|
|
51
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
52
|
+
try {
|
|
53
|
+
return new URL(value, window.location.origin).toString();
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Same-origin session-read endpoint, injected by the vite-plugin in preview
|
|
60
|
+
* (`{sandboxId}-5173.preview.omg.dev`). Present only there; null for deployed
|
|
61
|
+
* apps and local dev.
|
|
62
|
+
*/
|
|
63
|
+
function readInjectedSessionUrl() {
|
|
64
|
+
if (typeof window === "undefined") return null;
|
|
65
|
+
const value = window.__VIBES_AUTH_SESSION_URL;
|
|
66
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
67
|
+
try {
|
|
68
|
+
return new URL(value, window.location.origin).toString();
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function createVibesAuth(config) {
|
|
74
|
+
const authUrl = config.authUrl ?? DEFAULT_AUTH_URL;
|
|
75
|
+
const bridgeBase = typeof window !== "undefined" && isCrossSiteAppHost(window.location.host) ? `${window.location.origin}${AUTH_BRIDGE_PREFIX}` : null;
|
|
76
|
+
const baseURL = bridgeBase ?? authUrl;
|
|
77
|
+
const tokenUrl = config.tokenUrl ?? (bridgeBase ? `${bridgeBase}/token` : deriveTokenUrl(authUrl));
|
|
78
|
+
const sessionUrl = readInjectedSessionUrl();
|
|
79
|
+
const customFetchImpl = (input, init) => {
|
|
80
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
81
|
+
if (sessionUrl && url.includes("/api/auth/get-session")) {
|
|
82
|
+
const qs = url.includes("?") ? url.slice(url.indexOf("?")) : "";
|
|
83
|
+
return fetch(sessionUrl + qs, {
|
|
84
|
+
...init,
|
|
85
|
+
credentials: "include"
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return fetch(input, init);
|
|
89
|
+
};
|
|
90
|
+
const authClient = createAuthClient({
|
|
91
|
+
baseURL,
|
|
92
|
+
...sessionUrl ? { fetchOptions: { customFetchImpl } } : {},
|
|
93
|
+
plugins: [
|
|
94
|
+
magicLinkClient(),
|
|
95
|
+
emailOTPClient(),
|
|
96
|
+
passkeyClient()
|
|
97
|
+
]
|
|
98
|
+
});
|
|
99
|
+
let cachedToken = null;
|
|
100
|
+
let tokenExp = 0;
|
|
101
|
+
async function getToken() {
|
|
102
|
+
if (cachedToken && Date.now() < tokenExp - 6e4) return cachedToken;
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetch(tokenUrl, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: { "Content-Type": "application/json" },
|
|
107
|
+
credentials: "include",
|
|
108
|
+
body: JSON.stringify({ appId: config.appId })
|
|
109
|
+
});
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
cachedToken = null;
|
|
112
|
+
tokenExp = 0;
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const data = await res.json();
|
|
116
|
+
cachedToken = data.token;
|
|
117
|
+
if (data.expiresAt) tokenExp = new Date(data.expiresAt).getTime();
|
|
118
|
+
else try {
|
|
119
|
+
tokenExp = (JSON.parse(atob(data.token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))).exp ?? 0) * 1e3;
|
|
120
|
+
} catch {
|
|
121
|
+
tokenExp = Date.now() + 36e5;
|
|
122
|
+
}
|
|
123
|
+
return cachedToken;
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function clearToken() {
|
|
129
|
+
cachedToken = null;
|
|
130
|
+
tokenExp = 0;
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
authClient,
|
|
134
|
+
appId: config.appId,
|
|
135
|
+
authUrl,
|
|
136
|
+
tokenUrl,
|
|
137
|
+
getToken,
|
|
138
|
+
clearToken
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region src/auth/mail-apps.ts
|
|
143
|
+
const PROVIDERS = [
|
|
144
|
+
{
|
|
145
|
+
pattern: /^(gmail|googlemail)\./,
|
|
146
|
+
app: {
|
|
147
|
+
provider: "gmail",
|
|
148
|
+
label: "Open Gmail",
|
|
149
|
+
url: "https://mail.google.com/mail/u/0/"
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
pattern: /^(outlook|hotmail|live|msn)\./,
|
|
154
|
+
app: {
|
|
155
|
+
provider: "outlook",
|
|
156
|
+
label: "Open Outlook",
|
|
157
|
+
url: "https://outlook.live.com/mail/"
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
pattern: /^(yahoo|ymail|rocketmail)\./,
|
|
162
|
+
app: {
|
|
163
|
+
provider: "yahoo",
|
|
164
|
+
label: "Open Yahoo",
|
|
165
|
+
url: "https://mail.yahoo.com/"
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
pattern: /^(icloud|me|mac)\./,
|
|
170
|
+
app: {
|
|
171
|
+
provider: "icloud",
|
|
172
|
+
label: "Open iCloud Mail",
|
|
173
|
+
url: "https://www.icloud.com/mail/"
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
pattern: /^(proton|protonmail|pm)\./,
|
|
178
|
+
app: {
|
|
179
|
+
provider: "proton",
|
|
180
|
+
label: "Open Proton",
|
|
181
|
+
url: "https://mail.proton.me/"
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
];
|
|
185
|
+
/**
|
|
186
|
+
* Returns the webmail app for the address's provider, or null for custom
|
|
187
|
+
* domains (no reliable way to know where their mail is hosted).
|
|
188
|
+
*/
|
|
189
|
+
function mailAppForEmail(email) {
|
|
190
|
+
const domain = email.split("@")[1]?.toLowerCase().trim() ?? "";
|
|
191
|
+
if (!domain) return null;
|
|
192
|
+
for (const { pattern, app } of PROVIDERS) if (pattern.test(domain)) return app;
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
//#endregion
|
|
196
|
+
//#region src/auth/login.tsx
|
|
197
|
+
function Spinner({ className = "" }) {
|
|
198
|
+
return /* @__PURE__ */ jsxs("svg", {
|
|
199
|
+
className: `animate-spin ${className}`,
|
|
200
|
+
viewBox: "0 0 24 24",
|
|
201
|
+
fill: "none",
|
|
202
|
+
"aria-hidden": "true",
|
|
203
|
+
children: [/* @__PURE__ */ jsx("circle", {
|
|
204
|
+
className: "opacity-25",
|
|
205
|
+
cx: "12",
|
|
206
|
+
cy: "12",
|
|
207
|
+
r: "10",
|
|
208
|
+
stroke: "currentColor",
|
|
209
|
+
strokeWidth: "4"
|
|
210
|
+
}), /* @__PURE__ */ jsx("path", {
|
|
211
|
+
className: "opacity-75",
|
|
212
|
+
fill: "currentColor",
|
|
213
|
+
d: "M4 12a8 8 0 0 1 8-8v4a4 4 0 0 0-4 4H4z"
|
|
214
|
+
})]
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
const CODE_LENGTH = 6;
|
|
218
|
+
function VibesLogin({ title = "Sign in", subtitle = "Sign in to continue." }) {
|
|
219
|
+
const { sendSignInCode, verifySignInCode, signInWithPasskey } = useAuth();
|
|
220
|
+
const [email, setEmail] = useState("");
|
|
221
|
+
const [stage, setStage] = useState("email");
|
|
222
|
+
const [code, setCode] = useState("");
|
|
223
|
+
const [error, setError] = useState(null);
|
|
224
|
+
const [pending, setPending] = useState(null);
|
|
225
|
+
const busy = pending !== null;
|
|
226
|
+
const verifyingRef = useRef(false);
|
|
227
|
+
const mailApp = mailAppForEmail(email);
|
|
228
|
+
async function onSendCode(e) {
|
|
229
|
+
e.preventDefault();
|
|
230
|
+
if (!email.trim() || busy) return;
|
|
231
|
+
setPending("send");
|
|
232
|
+
setError(null);
|
|
233
|
+
try {
|
|
234
|
+
await sendSignInCode(email.trim());
|
|
235
|
+
setCode("");
|
|
236
|
+
setStage("code");
|
|
237
|
+
} catch (err) {
|
|
238
|
+
setError(err instanceof Error ? err.message : "Failed to send code");
|
|
239
|
+
} finally {
|
|
240
|
+
setPending(null);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async function verify(next) {
|
|
244
|
+
if (verifyingRef.current) return;
|
|
245
|
+
verifyingRef.current = true;
|
|
246
|
+
setPending("verify");
|
|
247
|
+
setError(null);
|
|
248
|
+
try {
|
|
249
|
+
await verifySignInCode(email.trim(), next);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
setError(err instanceof Error ? err.message : "Invalid code");
|
|
252
|
+
setCode("");
|
|
253
|
+
} finally {
|
|
254
|
+
verifyingRef.current = false;
|
|
255
|
+
setPending(null);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function onCodeChange(raw) {
|
|
259
|
+
const digits = raw.replace(/\D/g, "").slice(0, CODE_LENGTH);
|
|
260
|
+
setCode(digits);
|
|
261
|
+
if (digits.length === CODE_LENGTH && !busy) verify(digits);
|
|
262
|
+
}
|
|
263
|
+
async function onResend() {
|
|
264
|
+
if (busy) return;
|
|
265
|
+
setPending("send");
|
|
266
|
+
setError(null);
|
|
267
|
+
try {
|
|
268
|
+
await sendSignInCode(email.trim());
|
|
269
|
+
setCode("");
|
|
270
|
+
} catch (err) {
|
|
271
|
+
setError(err instanceof Error ? err.message : "Failed to send code");
|
|
272
|
+
} finally {
|
|
273
|
+
setPending(null);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
async function onPasskey() {
|
|
277
|
+
if (busy) return;
|
|
278
|
+
setPending("passkey");
|
|
279
|
+
setError(null);
|
|
280
|
+
try {
|
|
281
|
+
await signInWithPasskey();
|
|
282
|
+
} catch (err) {
|
|
283
|
+
setError(err instanceof Error ? err.message : "Passkey sign-in failed");
|
|
284
|
+
} finally {
|
|
285
|
+
setPending(null);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return /* @__PURE__ */ jsx("div", {
|
|
289
|
+
className: "flex min-h-[60vh] w-full items-center justify-center p-6",
|
|
290
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
291
|
+
className: "w-full max-w-sm space-y-6 rounded-2xl border bg-card p-6 text-card-foreground shadow-sm",
|
|
292
|
+
children: [
|
|
293
|
+
/* @__PURE__ */ jsxs("div", {
|
|
294
|
+
className: "space-y-1.5",
|
|
295
|
+
children: [/* @__PURE__ */ jsx("h2", {
|
|
296
|
+
className: "text-lg font-semibold tracking-tight",
|
|
297
|
+
children: title
|
|
298
|
+
}), stage === "code" ? /* @__PURE__ */ jsxs("p", {
|
|
299
|
+
className: "text-sm text-muted-foreground",
|
|
300
|
+
children: [
|
|
301
|
+
"Enter the code sent to",
|
|
302
|
+
" ",
|
|
303
|
+
/* @__PURE__ */ jsx("span", {
|
|
304
|
+
className: "font-medium text-foreground",
|
|
305
|
+
children: email.trim()
|
|
306
|
+
}),
|
|
307
|
+
"."
|
|
308
|
+
]
|
|
309
|
+
}) : subtitle ? /* @__PURE__ */ jsx("p", {
|
|
310
|
+
className: "text-sm text-muted-foreground",
|
|
311
|
+
children: subtitle
|
|
312
|
+
}) : null]
|
|
313
|
+
}),
|
|
314
|
+
stage === "code" ? /* @__PURE__ */ jsxs("div", {
|
|
315
|
+
className: "space-y-3",
|
|
316
|
+
children: [
|
|
317
|
+
/* @__PURE__ */ jsxs("form", {
|
|
318
|
+
onSubmit: (e) => {
|
|
319
|
+
e.preventDefault();
|
|
320
|
+
if (code.length === CODE_LENGTH && !busy) verify(code);
|
|
321
|
+
},
|
|
322
|
+
className: "space-y-3",
|
|
323
|
+
children: [/* @__PURE__ */ jsx("input", {
|
|
324
|
+
type: "text",
|
|
325
|
+
inputMode: "numeric",
|
|
326
|
+
autoComplete: "one-time-code",
|
|
327
|
+
pattern: "[0-9]*",
|
|
328
|
+
maxLength: CODE_LENGTH,
|
|
329
|
+
placeholder: "000000",
|
|
330
|
+
value: code,
|
|
331
|
+
onChange: (e) => onCodeChange(e.target.value),
|
|
332
|
+
disabled: busy,
|
|
333
|
+
autoFocus: true,
|
|
334
|
+
"aria-label": "Sign-in code",
|
|
335
|
+
className: "flex h-12 w-full rounded-md border bg-background px-3 py-2 text-center font-mono text-xl tracking-[0.5em] shadow-sm placeholder:text-muted-foreground/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
336
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
337
|
+
type: "submit",
|
|
338
|
+
disabled: busy || code.length !== CODE_LENGTH,
|
|
339
|
+
className: "inline-flex h-10 w-full items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
|
340
|
+
children: pending === "verify" ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Spinner, { className: "h-4 w-4" }), "Signing in…"] }) : "Sign in"
|
|
341
|
+
})]
|
|
342
|
+
}),
|
|
343
|
+
mailApp ? /* @__PURE__ */ jsx("a", {
|
|
344
|
+
href: mailApp.url,
|
|
345
|
+
target: "_blank",
|
|
346
|
+
rel: "noreferrer",
|
|
347
|
+
className: "inline-flex h-10 w-full items-center justify-center gap-2 rounded-md border bg-background px-4 py-2 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground",
|
|
348
|
+
children: mailApp.label
|
|
349
|
+
}) : null,
|
|
350
|
+
/* @__PURE__ */ jsxs("div", {
|
|
351
|
+
className: "flex items-center justify-between text-xs text-muted-foreground",
|
|
352
|
+
children: [/* @__PURE__ */ jsx("button", {
|
|
353
|
+
type: "button",
|
|
354
|
+
onClick: onResend,
|
|
355
|
+
disabled: busy,
|
|
356
|
+
className: "transition-colors hover:text-foreground disabled:opacity-50",
|
|
357
|
+
children: pending === "send" ? "Sending…" : "Resend code"
|
|
358
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
359
|
+
type: "button",
|
|
360
|
+
onClick: () => {
|
|
361
|
+
setStage("email");
|
|
362
|
+
setCode("");
|
|
363
|
+
setError(null);
|
|
364
|
+
},
|
|
365
|
+
disabled: busy,
|
|
366
|
+
className: "transition-colors hover:text-foreground disabled:opacity-50",
|
|
367
|
+
children: "Change email"
|
|
368
|
+
})]
|
|
369
|
+
})
|
|
370
|
+
]
|
|
371
|
+
}) : /* @__PURE__ */ jsxs("form", {
|
|
372
|
+
onSubmit: onSendCode,
|
|
373
|
+
className: "space-y-3",
|
|
374
|
+
children: [/* @__PURE__ */ jsx("input", {
|
|
375
|
+
type: "email",
|
|
376
|
+
required: true,
|
|
377
|
+
placeholder: "you@example.com",
|
|
378
|
+
value: email,
|
|
379
|
+
onChange: (e) => setEmail(e.target.value),
|
|
380
|
+
disabled: busy,
|
|
381
|
+
className: "flex h-10 w-full rounded-md border bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
382
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
383
|
+
type: "submit",
|
|
384
|
+
disabled: busy,
|
|
385
|
+
className: "inline-flex h-10 w-full items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
|
386
|
+
children: pending === "send" ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Spinner, { className: "h-4 w-4" }), "Sending…"] }) : "Send code"
|
|
387
|
+
})]
|
|
388
|
+
}),
|
|
389
|
+
stage === "email" ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("div", {
|
|
390
|
+
className: "flex items-center gap-3 text-xs text-muted-foreground",
|
|
391
|
+
children: [
|
|
392
|
+
/* @__PURE__ */ jsx("div", { className: "h-px flex-1 bg-border" }),
|
|
393
|
+
"or",
|
|
394
|
+
/* @__PURE__ */ jsx("div", { className: "h-px flex-1 bg-border" })
|
|
395
|
+
]
|
|
396
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
397
|
+
type: "button",
|
|
398
|
+
onClick: onPasskey,
|
|
399
|
+
disabled: busy,
|
|
400
|
+
className: "inline-flex h-10 w-full items-center justify-center gap-2 rounded-md border bg-background px-4 py-2 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
|
401
|
+
children: pending === "passkey" ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Spinner, { className: "h-4 w-4" }), "Signing in…"] }) : "Sign in with passkey"
|
|
402
|
+
})] }) : null,
|
|
403
|
+
error ? /* @__PURE__ */ jsx("div", {
|
|
404
|
+
className: "text-sm text-destructive",
|
|
405
|
+
children: error
|
|
406
|
+
}) : null
|
|
407
|
+
]
|
|
408
|
+
})
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
//#endregion
|
|
412
|
+
//#region src/auth/auto-prompt.tsx
|
|
413
|
+
function VibesAuthAutoPrompt() {
|
|
414
|
+
const user = useUser();
|
|
415
|
+
const [open, setOpen] = useState(false);
|
|
416
|
+
useEffect(() => subscribeAuthRequired(() => setOpen(true)), []);
|
|
417
|
+
useEffect(() => {
|
|
418
|
+
if (user) setOpen(false);
|
|
419
|
+
}, [user]);
|
|
420
|
+
if (!open || user) return null;
|
|
421
|
+
return /* @__PURE__ */ jsx("div", {
|
|
422
|
+
role: "dialog",
|
|
423
|
+
"aria-modal": "true",
|
|
424
|
+
onClick: (e) => {
|
|
425
|
+
if (e.target === e.currentTarget) setOpen(false);
|
|
426
|
+
},
|
|
427
|
+
style: {
|
|
428
|
+
position: "fixed",
|
|
429
|
+
inset: 0,
|
|
430
|
+
zIndex: 2147483e3,
|
|
431
|
+
display: "flex",
|
|
432
|
+
alignItems: "center",
|
|
433
|
+
justifyContent: "center",
|
|
434
|
+
background: "rgba(0,0,0,0.5)",
|
|
435
|
+
backdropFilter: "blur(2px)"
|
|
436
|
+
},
|
|
437
|
+
children: /* @__PURE__ */ jsx(VibesLogin, { subtitle: "Sign in to continue." })
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
//#endregion
|
|
441
|
+
//#region src/auth/react.tsx
|
|
442
|
+
const VibesAuthContext = createContext(null);
|
|
443
|
+
function VibesAuthProvider({ client: providedClient, appId, authUrl, autoPrompt = true, children }) {
|
|
444
|
+
const client = useMemo(() => {
|
|
445
|
+
if (providedClient) return providedClient;
|
|
446
|
+
return createVibesAuth({
|
|
447
|
+
appId: appId ?? deriveAppId() ?? "local",
|
|
448
|
+
authUrl
|
|
449
|
+
});
|
|
450
|
+
}, [
|
|
451
|
+
providedClient,
|
|
452
|
+
appId,
|
|
453
|
+
authUrl
|
|
454
|
+
]);
|
|
455
|
+
const { data: session, isPending } = client.authClient.useSession();
|
|
456
|
+
const [token, setToken] = useState(null);
|
|
457
|
+
const [authReady, setAuthReady] = useState(false);
|
|
458
|
+
useEffect(() => {
|
|
459
|
+
if (isPending) return;
|
|
460
|
+
if (!session?.user) {
|
|
461
|
+
setToken(null);
|
|
462
|
+
client.clearToken();
|
|
463
|
+
setAuthReady(true);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
let cancelled = false;
|
|
467
|
+
client.getToken().then((t) => {
|
|
468
|
+
if (cancelled) return;
|
|
469
|
+
setToken(t);
|
|
470
|
+
setAuthReady(true);
|
|
471
|
+
});
|
|
472
|
+
return () => {
|
|
473
|
+
cancelled = true;
|
|
474
|
+
};
|
|
475
|
+
}, [
|
|
476
|
+
isPending,
|
|
477
|
+
session?.user?.id,
|
|
478
|
+
client
|
|
479
|
+
]);
|
|
480
|
+
useEffect(() => {
|
|
481
|
+
setAuthContext({
|
|
482
|
+
user: session?.user ? {
|
|
483
|
+
id: session.user.id,
|
|
484
|
+
email: session.user.email,
|
|
485
|
+
name: session.user.name ?? void 0
|
|
486
|
+
} : null,
|
|
487
|
+
token,
|
|
488
|
+
authReady
|
|
489
|
+
});
|
|
490
|
+
}, [
|
|
491
|
+
session?.user?.id,
|
|
492
|
+
session?.user?.email,
|
|
493
|
+
session?.user?.name,
|
|
494
|
+
token,
|
|
495
|
+
authReady
|
|
496
|
+
]);
|
|
497
|
+
const signOut = useCallback(async () => {
|
|
498
|
+
await client.authClient.signOut();
|
|
499
|
+
client.clearToken();
|
|
500
|
+
setToken(null);
|
|
501
|
+
}, [client]);
|
|
502
|
+
const refreshToken = useCallback(async () => {
|
|
503
|
+
const t = await client.getToken();
|
|
504
|
+
setToken(t);
|
|
505
|
+
return t;
|
|
506
|
+
}, [client]);
|
|
507
|
+
const user = session?.user ? {
|
|
508
|
+
id: session.user.id,
|
|
509
|
+
email: session.user.email,
|
|
510
|
+
name: session.user.name ?? void 0
|
|
511
|
+
} : null;
|
|
512
|
+
const value = useMemo(() => ({
|
|
513
|
+
user,
|
|
514
|
+
loading: isPending,
|
|
515
|
+
token,
|
|
516
|
+
signOut,
|
|
517
|
+
refreshToken,
|
|
518
|
+
client
|
|
519
|
+
}), [
|
|
520
|
+
user?.id,
|
|
521
|
+
isPending,
|
|
522
|
+
token,
|
|
523
|
+
signOut,
|
|
524
|
+
refreshToken,
|
|
525
|
+
client
|
|
526
|
+
]);
|
|
527
|
+
return /* @__PURE__ */ jsxs(VibesAuthContext.Provider, {
|
|
528
|
+
value,
|
|
529
|
+
children: [children, autoPrompt ? /* @__PURE__ */ jsx(VibesAuthAutoPrompt, {}) : null]
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
function useVibesAuth() {
|
|
533
|
+
const ctx = useContext(VibesAuthContext);
|
|
534
|
+
if (!ctx) throw new Error("useVibesAuth must be used within <VibesAuthProvider>");
|
|
535
|
+
return ctx;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Hook that returns just the JWT token, refreshing it if needed.
|
|
539
|
+
* Useful for passing to fetch calls.
|
|
540
|
+
*/
|
|
541
|
+
function useVibesToken() {
|
|
542
|
+
const { token } = useVibesAuth();
|
|
543
|
+
return token;
|
|
544
|
+
}
|
|
545
|
+
function useUser() {
|
|
546
|
+
return useVibesAuth().user;
|
|
547
|
+
}
|
|
548
|
+
function useAuth() {
|
|
549
|
+
const { user, loading, token, signOut, client } = useVibesAuth();
|
|
550
|
+
return {
|
|
551
|
+
user,
|
|
552
|
+
loading,
|
|
553
|
+
token,
|
|
554
|
+
signOut,
|
|
555
|
+
signInWithMagicLink: useCallback(async (email, callbackURL) => {
|
|
556
|
+
const cb = callbackURL ?? (typeof window !== "undefined" ? window.location.href : void 0);
|
|
557
|
+
await fetch(`${client.authUrl}/api/auth/sign-in/magic-link`, {
|
|
558
|
+
method: "POST",
|
|
559
|
+
credentials: "include",
|
|
560
|
+
headers: { "Content-Type": "application/json" },
|
|
561
|
+
body: JSON.stringify({
|
|
562
|
+
email,
|
|
563
|
+
callbackURL: cb
|
|
564
|
+
})
|
|
565
|
+
});
|
|
566
|
+
}, [client]),
|
|
567
|
+
sendSignInCode: useCallback(async (email) => {
|
|
568
|
+
const c = client.authClient;
|
|
569
|
+
if (typeof c.emailOtp?.sendVerificationOtp !== "function") throw new Error("Email OTP plugin not loaded");
|
|
570
|
+
const res = await c.emailOtp.sendVerificationOtp({
|
|
571
|
+
email,
|
|
572
|
+
type: "sign-in"
|
|
573
|
+
});
|
|
574
|
+
if (res.error) throw new Error(res.error.message ?? "Failed to send code");
|
|
575
|
+
}, [client]),
|
|
576
|
+
verifySignInCode: useCallback(async (email, code) => {
|
|
577
|
+
const c = client.authClient;
|
|
578
|
+
if (typeof c.signIn?.emailOtp !== "function") throw new Error("Email OTP plugin not loaded");
|
|
579
|
+
const res = await c.signIn.emailOtp({
|
|
580
|
+
email,
|
|
581
|
+
otp: code
|
|
582
|
+
});
|
|
583
|
+
if (res.error) {
|
|
584
|
+
const code_ = res.error.code;
|
|
585
|
+
if (code_ === "OTP_EXPIRED") throw new Error("That code expired — request a new one.");
|
|
586
|
+
if (code_ === "TOO_MANY_ATTEMPTS") throw new Error("Too many attempts — request a new code.");
|
|
587
|
+
throw new Error(res.error.message ?? "Invalid code");
|
|
588
|
+
}
|
|
589
|
+
}, [client]),
|
|
590
|
+
signInWithPasskey: useCallback(async () => {
|
|
591
|
+
const c = client.authClient;
|
|
592
|
+
if (typeof c.signIn?.passkey === "function") await c.signIn.passkey();
|
|
593
|
+
else throw new Error("Passkey plugin not loaded");
|
|
594
|
+
}, [client])
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
//#endregion
|
|
598
|
+
//#region src/auth/fetch.ts
|
|
599
|
+
/**
|
|
600
|
+
* Creates a fetch wrapper that automatically attaches the JWT Bearer token.
|
|
601
|
+
* If no token is available (user not signed in), falls through without auth header.
|
|
602
|
+
*/
|
|
603
|
+
function vibesFetch(client) {
|
|
604
|
+
return async function(input, init) {
|
|
605
|
+
const token = await client.getToken();
|
|
606
|
+
const headers = new Headers(init?.headers);
|
|
607
|
+
if (token) headers.set("Authorization", `Bearer ${token}`);
|
|
608
|
+
return fetch(input, {
|
|
609
|
+
...init,
|
|
610
|
+
headers
|
|
611
|
+
});
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
//#endregion
|
|
615
|
+
//#region src/auth/guard.tsx
|
|
616
|
+
function VibesAuthGuard({ children, fallback, loadingFallback }) {
|
|
617
|
+
const { loading } = useVibesAuth();
|
|
618
|
+
const user = useUser();
|
|
619
|
+
if (loading) return /* @__PURE__ */ jsx(Fragment, { children: loadingFallback ?? null });
|
|
620
|
+
if (!user) return /* @__PURE__ */ jsx(Fragment, { children: fallback ?? /* @__PURE__ */ jsx(VibesLogin, {}) });
|
|
621
|
+
return /* @__PURE__ */ jsx(Fragment, { children });
|
|
622
|
+
}
|
|
623
|
+
//#endregion
|
|
624
|
+
//#region src/storage/VibesUpload.tsx
|
|
625
|
+
function defaultKeyFor(file) {
|
|
626
|
+
const safe = file.name.replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
627
|
+
return `uploads/${typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : Math.random().toString(36).slice(2)}-${safe}`;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Drop-zone + file picker. Calls onUploaded with the stored key + signed
|
|
631
|
+
* download URL on success. Renders nothing during prerender (SSR) — the
|
|
632
|
+
* hook depends on browser-only APIs (XHR, FormData).
|
|
633
|
+
*/
|
|
634
|
+
function VibesUpload(props) {
|
|
635
|
+
const { upload, uploading, progress, error } = useUpload();
|
|
636
|
+
const [dragOver, setDragOver] = useState(false);
|
|
637
|
+
const inputRef = useRef(null);
|
|
638
|
+
const inputId = useId();
|
|
639
|
+
const handleFiles = useCallback(async (files) => {
|
|
640
|
+
const list = Array.from(files);
|
|
641
|
+
for (const file of list) {
|
|
642
|
+
const opts = {
|
|
643
|
+
key: (props.keyFor ?? defaultKeyFor)(file),
|
|
644
|
+
contentType: file.type || "application/octet-stream",
|
|
645
|
+
scope: props.scope ?? "user",
|
|
646
|
+
presignUrl: props.presignUrl
|
|
647
|
+
};
|
|
648
|
+
try {
|
|
649
|
+
const result = await upload(file, opts);
|
|
650
|
+
props.onUploaded?.(result, file);
|
|
651
|
+
} catch (err) {
|
|
652
|
+
props.onError?.(err);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}, [props, upload]);
|
|
656
|
+
const onChange = useCallback((e) => {
|
|
657
|
+
if (e.target.files && e.target.files.length > 0) {
|
|
658
|
+
handleFiles(e.target.files);
|
|
659
|
+
e.target.value = "";
|
|
660
|
+
}
|
|
661
|
+
}, [handleFiles]);
|
|
662
|
+
const onDrop = useCallback((e) => {
|
|
663
|
+
e.preventDefault();
|
|
664
|
+
setDragOver(false);
|
|
665
|
+
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) handleFiles(e.dataTransfer.files);
|
|
666
|
+
}, [handleFiles]);
|
|
667
|
+
const onDragOver = useCallback((e) => {
|
|
668
|
+
e.preventDefault();
|
|
669
|
+
if (!dragOver) setDragOver(true);
|
|
670
|
+
}, [dragOver]);
|
|
671
|
+
const onDragLeave = useCallback(() => setDragOver(false), []);
|
|
672
|
+
return /* @__PURE__ */ jsxs("label", {
|
|
673
|
+
htmlFor: inputId,
|
|
674
|
+
style: {
|
|
675
|
+
border: `1px dashed ${dragOver ? "#2563eb" : "rgba(0,0,0,0.18)"}`,
|
|
676
|
+
background: dragOver ? "rgba(37, 99, 235, 0.05)" : "transparent",
|
|
677
|
+
borderRadius: 10,
|
|
678
|
+
padding: "16px 18px",
|
|
679
|
+
display: "flex",
|
|
680
|
+
flexDirection: "column",
|
|
681
|
+
gap: 6,
|
|
682
|
+
alignItems: "center",
|
|
683
|
+
justifyContent: "center",
|
|
684
|
+
cursor: props.disabled ? "not-allowed" : "pointer",
|
|
685
|
+
opacity: props.disabled ? .5 : 1,
|
|
686
|
+
fontSize: 14,
|
|
687
|
+
color: "rgba(0,0,0,0.7)",
|
|
688
|
+
minHeight: 88,
|
|
689
|
+
userSelect: "none",
|
|
690
|
+
transition: "border-color 80ms ease, background 80ms ease",
|
|
691
|
+
...props.style
|
|
692
|
+
},
|
|
693
|
+
className: props.className,
|
|
694
|
+
onDrop,
|
|
695
|
+
onDragOver,
|
|
696
|
+
onDragLeave,
|
|
697
|
+
"data-uploading": uploading || void 0,
|
|
698
|
+
children: [/* @__PURE__ */ jsx("input", {
|
|
699
|
+
ref: inputRef,
|
|
700
|
+
id: inputId,
|
|
701
|
+
type: "file",
|
|
702
|
+
accept: props.accept,
|
|
703
|
+
onChange,
|
|
704
|
+
disabled: props.disabled || uploading,
|
|
705
|
+
style: { display: "none" }
|
|
706
|
+
}), uploading ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", { children: "Uploading…" }), /* @__PURE__ */ jsxs("span", {
|
|
707
|
+
style: {
|
|
708
|
+
fontSize: 12,
|
|
709
|
+
opacity: .6
|
|
710
|
+
},
|
|
711
|
+
children: [Math.round(progress * 100), "%"]
|
|
712
|
+
})] }) : error ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", {
|
|
713
|
+
style: { color: "#dc2626" },
|
|
714
|
+
children: "Upload failed"
|
|
715
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
716
|
+
style: {
|
|
717
|
+
fontSize: 12,
|
|
718
|
+
opacity: .7
|
|
719
|
+
},
|
|
720
|
+
children: error
|
|
721
|
+
})] }) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("span", { children: props.label ?? "Drop file or click to upload" }), /* @__PURE__ */ jsxs("span", {
|
|
722
|
+
style: {
|
|
723
|
+
fontSize: 12,
|
|
724
|
+
opacity: .55
|
|
725
|
+
},
|
|
726
|
+
children: [props.accept ?? "any file", " · up to 25MB"]
|
|
727
|
+
})] })]
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
//#endregion
|
|
731
|
+
//#region src/notifications/index.tsx
|
|
732
|
+
function authHeaders$1(extra) {
|
|
733
|
+
const h = new Headers(extra);
|
|
734
|
+
const { token } = getAuthContext();
|
|
735
|
+
if (token) h.set("Authorization", `Bearer ${token}`);
|
|
736
|
+
return h;
|
|
737
|
+
}
|
|
738
|
+
async function notificationFetch(input, init) {
|
|
739
|
+
const res = await fetch(input, {
|
|
740
|
+
...init,
|
|
741
|
+
headers: authHeaders$1(init?.headers)
|
|
742
|
+
});
|
|
743
|
+
if (res.status === 401) {
|
|
744
|
+
if (getAuthContext().authReady) notifyAuthRequired();
|
|
745
|
+
throw new Error("Authentication required");
|
|
746
|
+
}
|
|
747
|
+
if (!res.ok) {
|
|
748
|
+
let msg = `request failed (${res.status})`;
|
|
749
|
+
try {
|
|
750
|
+
const body = await res.clone().json();
|
|
751
|
+
if (body && typeof body.error === "string") msg = body.error;
|
|
752
|
+
} catch {}
|
|
753
|
+
throw new Error(msg);
|
|
754
|
+
}
|
|
755
|
+
return res;
|
|
756
|
+
}
|
|
757
|
+
function base64UrlToUint8Array(value) {
|
|
758
|
+
const base64 = `${value}${"=".repeat((4 - value.length % 4) % 4)}`.replace(/-/g, "+").replace(/_/g, "/");
|
|
759
|
+
const raw = atob(base64);
|
|
760
|
+
const out = new Uint8Array(raw.length);
|
|
761
|
+
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
|
762
|
+
return out;
|
|
763
|
+
}
|
|
764
|
+
function notificationSupport() {
|
|
765
|
+
if (typeof window === "undefined" || typeof navigator === "undefined") return {
|
|
766
|
+
supported: false,
|
|
767
|
+
permission: "default"
|
|
768
|
+
};
|
|
769
|
+
const supported = "Notification" in window && "serviceWorker" in navigator && "PushManager" in window;
|
|
770
|
+
return {
|
|
771
|
+
supported,
|
|
772
|
+
permission: supported ? Notification.permission : "default"
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
function useNotificationPermission() {
|
|
776
|
+
const initial = notificationSupport();
|
|
777
|
+
const [permission, setPermission] = useState(initial.permission);
|
|
778
|
+
const [busy, setBusy] = useState(false);
|
|
779
|
+
const supported = initial.supported;
|
|
780
|
+
return {
|
|
781
|
+
supported,
|
|
782
|
+
permission,
|
|
783
|
+
busy,
|
|
784
|
+
enablePush: useCallback(async () => {
|
|
785
|
+
if (!supported) return false;
|
|
786
|
+
setBusy(true);
|
|
787
|
+
try {
|
|
788
|
+
const config = await (await notificationFetch("/api/_notifications/config")).json();
|
|
789
|
+
if (!config.vapidPublicKey) return false;
|
|
790
|
+
const nextPermission = Notification.permission === "default" ? await Notification.requestPermission() : Notification.permission;
|
|
791
|
+
setPermission(nextPermission);
|
|
792
|
+
if (nextPermission !== "granted") return false;
|
|
793
|
+
const registration = await navigator.serviceWorker.register("/__vibes_push/sw.js", { scope: "/__vibes_push/" });
|
|
794
|
+
const subscription = await registration.pushManager.getSubscription() ?? await registration.pushManager.subscribe({
|
|
795
|
+
userVisibleOnly: true,
|
|
796
|
+
applicationServerKey: base64UrlToUint8Array(config.vapidPublicKey)
|
|
797
|
+
});
|
|
798
|
+
await notificationFetch("/api/_notifications/subscribe", {
|
|
799
|
+
method: "POST",
|
|
800
|
+
headers: { "content-type": "application/json" },
|
|
801
|
+
body: JSON.stringify({ subscription: subscription.toJSON() })
|
|
802
|
+
});
|
|
803
|
+
return true;
|
|
804
|
+
} finally {
|
|
805
|
+
setBusy(false);
|
|
806
|
+
}
|
|
807
|
+
}, [supported])
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
function useNotifications(options) {
|
|
811
|
+
const where = useMemo(() => {
|
|
812
|
+
if (!options?.unreadOnly) return void 0;
|
|
813
|
+
return {
|
|
814
|
+
op: "eq",
|
|
815
|
+
column: "status",
|
|
816
|
+
value: "unread"
|
|
817
|
+
};
|
|
818
|
+
}, [options?.unreadOnly]);
|
|
819
|
+
const query = useQuery({
|
|
820
|
+
collection: "vibesNotifications",
|
|
821
|
+
api: options?.unreadOnly ? "/api/_notifications/list?unread=1" : "/api/_notifications/list",
|
|
822
|
+
where
|
|
823
|
+
});
|
|
824
|
+
const notifications = useMemo(() => [...query.data].sort((a, b) => b.createdAt - a.createdAt), [query.data]);
|
|
825
|
+
const unreadCount = useMemo(() => notifications.reduce((n, item) => n + (item.status === "unread" ? 1 : 0), 0), [notifications]);
|
|
826
|
+
const markRead = useCallback(async (ids) => {
|
|
827
|
+
if (ids.length === 0) return;
|
|
828
|
+
await notificationFetch("/api/_notifications/read", {
|
|
829
|
+
method: "POST",
|
|
830
|
+
headers: { "content-type": "application/json" },
|
|
831
|
+
body: JSON.stringify({ ids })
|
|
832
|
+
});
|
|
833
|
+
query.refresh();
|
|
834
|
+
}, [query]);
|
|
835
|
+
const markAllRead = useCallback(async () => {
|
|
836
|
+
await notificationFetch("/api/_notifications/read", {
|
|
837
|
+
method: "POST",
|
|
838
|
+
headers: { "content-type": "application/json" },
|
|
839
|
+
body: JSON.stringify({ all: true })
|
|
840
|
+
});
|
|
841
|
+
query.refresh();
|
|
842
|
+
}, [query]);
|
|
843
|
+
return {
|
|
844
|
+
...query,
|
|
845
|
+
notifications,
|
|
846
|
+
unreadCount,
|
|
847
|
+
markRead,
|
|
848
|
+
markAllRead
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
async function createNotification(input) {
|
|
852
|
+
return (await notificationFetch("/api/_notifications/create", {
|
|
853
|
+
method: "POST",
|
|
854
|
+
headers: { "content-type": "application/json" },
|
|
855
|
+
body: JSON.stringify(input)
|
|
856
|
+
})).json();
|
|
857
|
+
}
|
|
858
|
+
//#endregion
|
|
859
|
+
//#region src/sandbox.ts
|
|
860
|
+
const runtimeClaims = {
|
|
861
|
+
llmInvoke: "llm.invoke",
|
|
862
|
+
mediaInvoke: "media.invoke",
|
|
863
|
+
browserUse: "browser.use",
|
|
864
|
+
sandboxCreate: "sandbox.create",
|
|
865
|
+
sandboxDelegate: "sandbox.delegate"
|
|
866
|
+
};
|
|
867
|
+
function defaultSandboxRouterBase() {
|
|
868
|
+
const envBase = typeof process !== "undefined" ? process.env?.VIBES_SANDBOX_ROUTER_URL || process.env?.VIBES_AGENT_URL : void 0;
|
|
869
|
+
if (envBase) return envBase.replace(/\/$/, "");
|
|
870
|
+
if (typeof window !== "undefined") throw new Error("sandbox SDK calls must run in a server function inside a Vibes sandbox");
|
|
871
|
+
return "http://localhost:8080/_sandbox";
|
|
872
|
+
}
|
|
873
|
+
async function postSandboxRouter(path, body, opts = {}) {
|
|
874
|
+
const res = await (opts.fetch ?? fetch)(`${(opts.routerBase ?? defaultSandboxRouterBase()).replace(/\/$/, "")}${path}`, {
|
|
875
|
+
method: "POST",
|
|
876
|
+
headers: { "content-type": "application/json" },
|
|
877
|
+
body: JSON.stringify(body ?? {})
|
|
878
|
+
});
|
|
879
|
+
if (!res.ok) {
|
|
880
|
+
let message = `sandbox router ${res.status}`;
|
|
881
|
+
try {
|
|
882
|
+
const parsed = await res.clone().json();
|
|
883
|
+
if (parsed && typeof parsed.error === "string") message = parsed.error;
|
|
884
|
+
} catch {
|
|
885
|
+
const text = await res.text().catch(() => "");
|
|
886
|
+
if (text) message = text;
|
|
887
|
+
}
|
|
888
|
+
throw new Error(message);
|
|
889
|
+
}
|
|
890
|
+
return res.json();
|
|
891
|
+
}
|
|
892
|
+
function createSandbox(options = {}, routerOptions) {
|
|
893
|
+
return postSandboxRouter("/create", options, routerOptions);
|
|
894
|
+
}
|
|
895
|
+
function forkSandbox(options, routerOptions) {
|
|
896
|
+
if (!options?.snapshotId) throw new Error("forkSandbox requires snapshotId");
|
|
897
|
+
return postSandboxRouter("/fork", options, routerOptions);
|
|
898
|
+
}
|
|
899
|
+
//#endregion
|
|
900
|
+
//#region src/index.ts
|
|
901
|
+
function authHeaders(extra) {
|
|
902
|
+
const h = new Headers(extra);
|
|
903
|
+
const { token } = getAuthContext();
|
|
904
|
+
if (token) h.set("Authorization", `Bearer ${token}`);
|
|
905
|
+
return h;
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Thrown when a request comes back with the server's auth-required signal
|
|
909
|
+
* (HTTP 401). Callers that don't catch it still won't get garbage data — the
|
|
910
|
+
* old paths swallowed 401 bodies and returned them as if they were rows.
|
|
911
|
+
*/
|
|
912
|
+
var VibesAuthRequiredError = class extends Error {
|
|
913
|
+
constructor(message = "Authentication required") {
|
|
914
|
+
super(message);
|
|
915
|
+
this.name = "VibesAuthRequiredError";
|
|
916
|
+
}
|
|
917
|
+
};
|
|
918
|
+
async function apiFetch(input, init) {
|
|
919
|
+
const res = await fetch(input, {
|
|
920
|
+
...init,
|
|
921
|
+
headers: authHeaders(init?.headers)
|
|
922
|
+
});
|
|
923
|
+
if (res.status === 401) {
|
|
924
|
+
if (getAuthContext().authReady) notifyAuthRequired();
|
|
925
|
+
let message = "Authentication required";
|
|
926
|
+
try {
|
|
927
|
+
const body = await res.clone().json();
|
|
928
|
+
if (body && typeof body.error === "string") message = body.error;
|
|
929
|
+
} catch {}
|
|
930
|
+
throw new VibesAuthRequiredError(message);
|
|
931
|
+
}
|
|
932
|
+
return res;
|
|
933
|
+
}
|
|
934
|
+
function useQuery(opts) {
|
|
935
|
+
const optsRef = useRef(opts);
|
|
936
|
+
optsRef.current = opts;
|
|
937
|
+
const url = defaultSubscribeUrl();
|
|
938
|
+
const collectionKey = opts.collection;
|
|
939
|
+
const store = useMemo(() => getCollectionQueryStore(url, opts.collection, opts.where), [
|
|
940
|
+
url,
|
|
941
|
+
collectionKey,
|
|
942
|
+
canonicalPredicateString(opts.where)
|
|
943
|
+
]);
|
|
944
|
+
const snapshot = useSyncExternalStore((listener) => store.subscribe(listener), () => store.getSnapshot(), () => store.getSnapshot());
|
|
945
|
+
useEffect(() => {
|
|
946
|
+
const api = optsRef.current.api;
|
|
947
|
+
if (snapshot.status === "unavailable" && api) store.ensureLegacyFallback(api);
|
|
948
|
+
}, [snapshot.status, store]);
|
|
949
|
+
const refresh = useCallback(() => {
|
|
950
|
+
const api = optsRef.current.api;
|
|
951
|
+
if (!api) return;
|
|
952
|
+
store.refreshFromApi(api);
|
|
953
|
+
}, [store]);
|
|
954
|
+
const create = useCallback(async (item) => {
|
|
955
|
+
const api = optsRef.current.api;
|
|
956
|
+
if (!api) throw new Error("useQuery: `api` is required to call create()");
|
|
957
|
+
return (await apiFetch(api, {
|
|
958
|
+
method: "POST",
|
|
959
|
+
headers: { "content-type": "application/json" },
|
|
960
|
+
body: JSON.stringify(item)
|
|
961
|
+
})).json();
|
|
962
|
+
}, []);
|
|
963
|
+
const update = useCallback(async (id, patch) => {
|
|
964
|
+
const api = optsRef.current.api;
|
|
965
|
+
if (!api) throw new Error("useQuery: `api` is required to call update()");
|
|
966
|
+
return (await apiFetch(`${api}/${id}`, {
|
|
967
|
+
method: "PATCH",
|
|
968
|
+
headers: { "content-type": "application/json" },
|
|
969
|
+
body: JSON.stringify(patch)
|
|
970
|
+
})).json();
|
|
971
|
+
}, []);
|
|
972
|
+
const remove = useCallback(async (id) => {
|
|
973
|
+
const api = optsRef.current.api;
|
|
974
|
+
if (!api) throw new Error("useQuery: `api` is required to call remove()");
|
|
975
|
+
await apiFetch(`${api}/${id}`, { method: "DELETE" });
|
|
976
|
+
}, []);
|
|
977
|
+
return {
|
|
978
|
+
data: snapshot.data,
|
|
979
|
+
loading: snapshot.loading,
|
|
980
|
+
error: snapshot.error,
|
|
981
|
+
status: snapshot.status,
|
|
982
|
+
create,
|
|
983
|
+
update,
|
|
984
|
+
remove,
|
|
985
|
+
refresh
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* @deprecated Use {@link useQuery} instead — it's the WS-first default and adds
|
|
990
|
+
* server-side `where` predicate scoping. This wrapper forwards to `useQuery`
|
|
991
|
+
* (full collection, no predicate) and remains only for back-compat.
|
|
992
|
+
*/
|
|
993
|
+
function useCollection(opts) {
|
|
994
|
+
return useQuery({
|
|
995
|
+
collection: opts.collection,
|
|
996
|
+
api: opts.api
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Best-effort client-side evaluation of a SubscribePredicate, used only by the
|
|
1001
|
+
* legacy SSE+REST fallback to scope the REST result the way the WS snapshot
|
|
1002
|
+
* would. The WS path is authoritative; this mirrors the common cases (SQL
|
|
1003
|
+
* three-valued-logic edge cases around NULL are not reproduced exactly).
|
|
1004
|
+
*/
|
|
1005
|
+
function matchesPredicate(row, p) {
|
|
1006
|
+
switch (p.op) {
|
|
1007
|
+
case "and": return p.clauses.every((c) => matchesPredicate(row, c));
|
|
1008
|
+
case "or": return p.clauses.some((c) => matchesPredicate(row, c));
|
|
1009
|
+
case "not": return !matchesPredicate(row, p.clause);
|
|
1010
|
+
case "eq": return row[p.column] === p.value;
|
|
1011
|
+
case "ne": return row[p.column] !== p.value;
|
|
1012
|
+
case "gt": return row[p.column] > p.value;
|
|
1013
|
+
case "gte": return row[p.column] >= p.value;
|
|
1014
|
+
case "lt": return row[p.column] < p.value;
|
|
1015
|
+
case "lte": return row[p.column] <= p.value;
|
|
1016
|
+
case "in": return p.values.includes(row[p.column]);
|
|
1017
|
+
case "like": {
|
|
1018
|
+
const v = row[p.column];
|
|
1019
|
+
if (typeof v !== "string") return false;
|
|
1020
|
+
return new RegExp("^" + p.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/%/g, ".*").replace(/_/g, ".") + "$", "i").test(v);
|
|
1021
|
+
}
|
|
1022
|
+
case "isNull": return row[p.column] == null;
|
|
1023
|
+
case "isNotNull": return row[p.column] != null;
|
|
1024
|
+
default: return true;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Legacy SSE + REST refetch loop. Used only as fallback when the WS
|
|
1029
|
+
* subscription path (subscribeCollection) gives up — typically on an old
|
|
1030
|
+
* deploy that doesn't serve /__vibes_sub. Same shape as the pre-Phase-1
|
|
1031
|
+
* useCollection internals so behavior on older backends is unchanged.
|
|
1032
|
+
*
|
|
1033
|
+
* Returns an AbortController whose abort() tears the SSE stream down.
|
|
1034
|
+
*/
|
|
1035
|
+
function startLegacySseRefetch(optsRef, refresh, setError) {
|
|
1036
|
+
const evtUrl = `${typeof window !== "undefined" ? window.location.origin : ""}/__vibes_events`;
|
|
1037
|
+
const RECONNECT_BASE_MS = 1e3;
|
|
1038
|
+
const RECONNECT_CAP_MS = 6e4;
|
|
1039
|
+
const RECONNECT_GIVE_UP = 6;
|
|
1040
|
+
const REFRESH_THROTTLE_MS = 2e3;
|
|
1041
|
+
const ctl = new AbortController();
|
|
1042
|
+
let consecutiveErrors = 0;
|
|
1043
|
+
let lastRefresh = 0;
|
|
1044
|
+
function throttledRefresh() {
|
|
1045
|
+
const now = Date.now();
|
|
1046
|
+
if (now - lastRefresh < REFRESH_THROTTLE_MS) return;
|
|
1047
|
+
lastRefresh = now;
|
|
1048
|
+
refresh();
|
|
1049
|
+
}
|
|
1050
|
+
class FatalError extends Error {}
|
|
1051
|
+
let isFirstOpen = true;
|
|
1052
|
+
refresh();
|
|
1053
|
+
fetchEventSource(evtUrl, {
|
|
1054
|
+
signal: ctl.signal,
|
|
1055
|
+
async onopen(res) {
|
|
1056
|
+
if (res.ok && res.headers.get("content-type")?.includes("text/event-stream")) {
|
|
1057
|
+
consecutiveErrors = 0;
|
|
1058
|
+
if (!isFirstOpen) throttledRefresh();
|
|
1059
|
+
isFirstOpen = false;
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
if (res.status === 401 || res.status === 403 || res.status === 400) {
|
|
1063
|
+
if (res.status === 401 && getAuthContext().authReady) notifyAuthRequired();
|
|
1064
|
+
throw new FatalError(`fatal ${res.status}`);
|
|
1065
|
+
}
|
|
1066
|
+
throw new Error(`unexpected ${res.status}`);
|
|
1067
|
+
},
|
|
1068
|
+
onmessage(ev) {
|
|
1069
|
+
if (!ev.data) return;
|
|
1070
|
+
try {
|
|
1071
|
+
const event = JSON.parse(ev.data);
|
|
1072
|
+
if (event.type === "invalidate" && event.collection === optsRef.current.collection) throttledRefresh();
|
|
1073
|
+
} catch {}
|
|
1074
|
+
},
|
|
1075
|
+
onclose() {
|
|
1076
|
+
throw new Error("connection closed");
|
|
1077
|
+
},
|
|
1078
|
+
onerror(err) {
|
|
1079
|
+
if (err instanceof FatalError) throw err;
|
|
1080
|
+
consecutiveErrors++;
|
|
1081
|
+
if (consecutiveErrors >= RECONNECT_GIVE_UP) {
|
|
1082
|
+
setError("realtime stream unavailable — falling back to manual refresh");
|
|
1083
|
+
throw new FatalError("give up after consecutive errors");
|
|
1084
|
+
}
|
|
1085
|
+
return Math.min(RECONNECT_CAP_MS, RECONNECT_BASE_MS * Math.pow(2, consecutiveErrors - 1));
|
|
1086
|
+
}
|
|
1087
|
+
}).catch(() => {});
|
|
1088
|
+
return ctl;
|
|
1089
|
+
}
|
|
1090
|
+
function defaultSubscribeUrl() {
|
|
1091
|
+
if (typeof window === "undefined") return "";
|
|
1092
|
+
return `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/__vibes_sub`;
|
|
1093
|
+
}
|
|
1094
|
+
function makeSubId() {
|
|
1095
|
+
return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `s${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
1096
|
+
}
|
|
1097
|
+
function protocolsForAuth(token) {
|
|
1098
|
+
return token ? [`vibes-bearer.${token}`] : void 0;
|
|
1099
|
+
}
|
|
1100
|
+
const RECONNECT_BASE_MS = 1e3;
|
|
1101
|
+
const RECONNECT_CAP_MS = 6e4;
|
|
1102
|
+
const RECONNECT_GIVE_UP = 6;
|
|
1103
|
+
function canonicalJson(value) {
|
|
1104
|
+
return JSON.stringify(canonicalizeValue(value));
|
|
1105
|
+
}
|
|
1106
|
+
function canonicalizeValue(value) {
|
|
1107
|
+
if (value === null) return null;
|
|
1108
|
+
if (Array.isArray(value)) return value.map(canonicalizeValue);
|
|
1109
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
|
|
1110
|
+
if (typeof value !== "object") return null;
|
|
1111
|
+
const out = {};
|
|
1112
|
+
for (const key of Object.keys(value).sort()) out[key] = canonicalizeValue(value[key]);
|
|
1113
|
+
return out;
|
|
1114
|
+
}
|
|
1115
|
+
function normalizePredicate(predicate) {
|
|
1116
|
+
if (!predicate) return void 0;
|
|
1117
|
+
switch (predicate.op) {
|
|
1118
|
+
case "and":
|
|
1119
|
+
case "or": {
|
|
1120
|
+
const clauses = predicate.clauses.map((clause) => normalizePredicate(clause)).sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b)));
|
|
1121
|
+
return {
|
|
1122
|
+
op: predicate.op,
|
|
1123
|
+
clauses
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
case "not": return {
|
|
1127
|
+
op: "not",
|
|
1128
|
+
clause: normalizePredicate(predicate.clause)
|
|
1129
|
+
};
|
|
1130
|
+
case "in": return {
|
|
1131
|
+
...predicate,
|
|
1132
|
+
values: [...predicate.values].sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b)))
|
|
1133
|
+
};
|
|
1134
|
+
default: return { ...predicate };
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
function canonicalPredicateString(predicate) {
|
|
1138
|
+
return canonicalJson(normalizePredicate(predicate) ?? null);
|
|
1139
|
+
}
|
|
1140
|
+
function rowOrderValue(row) {
|
|
1141
|
+
const createdAt = row.createdAt ?? row.created_at;
|
|
1142
|
+
if (typeof createdAt === "string" || typeof createdAt === "number") return createdAt;
|
|
1143
|
+
const updatedAt = row.updatedAt ?? row.updated_at;
|
|
1144
|
+
if (typeof updatedAt === "string" || typeof updatedAt === "number") return updatedAt;
|
|
1145
|
+
return null;
|
|
1146
|
+
}
|
|
1147
|
+
function compareRows(a, b) {
|
|
1148
|
+
const av = rowOrderValue(a);
|
|
1149
|
+
const bv = rowOrderValue(b);
|
|
1150
|
+
if (av !== null && bv !== null && av !== bv) return av > bv ? -1 : 1;
|
|
1151
|
+
if (av !== null && bv === null) return -1;
|
|
1152
|
+
if (av === null && bv !== null) return 1;
|
|
1153
|
+
return b.id.localeCompare(a.id);
|
|
1154
|
+
}
|
|
1155
|
+
function orderedRows(rows) {
|
|
1156
|
+
return Array.from(rows).sort((a, b) => compareRows(a, b));
|
|
1157
|
+
}
|
|
1158
|
+
var SharedCollectionSocket = class {
|
|
1159
|
+
constructor(url, token) {
|
|
1160
|
+
this.url = url;
|
|
1161
|
+
this.token = token;
|
|
1162
|
+
this.ws = null;
|
|
1163
|
+
this.connecting = false;
|
|
1164
|
+
this.closedByManager = false;
|
|
1165
|
+
this.consecutiveErrors = 0;
|
|
1166
|
+
this.reconnectTimer = null;
|
|
1167
|
+
this.flushQueued = false;
|
|
1168
|
+
this.subs = /* @__PURE__ */ new Map();
|
|
1169
|
+
this.pendingSubIds = /* @__PURE__ */ new Set();
|
|
1170
|
+
}
|
|
1171
|
+
add(sub) {
|
|
1172
|
+
this.subs.set(sub.subId, sub);
|
|
1173
|
+
this.pendingSubIds.add(sub.subId);
|
|
1174
|
+
if (this.isOpen()) this.queueFlush();
|
|
1175
|
+
else this.connect();
|
|
1176
|
+
}
|
|
1177
|
+
remove(subId) {
|
|
1178
|
+
const existed = this.subs.delete(subId);
|
|
1179
|
+
this.pendingSubIds.delete(subId);
|
|
1180
|
+
if (existed && this.isOpen()) this.sendFrame({
|
|
1181
|
+
op: "unsub",
|
|
1182
|
+
subId
|
|
1183
|
+
});
|
|
1184
|
+
if (this.subs.size === 0) this.shutdown();
|
|
1185
|
+
}
|
|
1186
|
+
isIdle() {
|
|
1187
|
+
return this.subs.size === 0;
|
|
1188
|
+
}
|
|
1189
|
+
shutdown() {
|
|
1190
|
+
if (this.reconnectTimer) {
|
|
1191
|
+
clearTimeout(this.reconnectTimer);
|
|
1192
|
+
this.reconnectTimer = null;
|
|
1193
|
+
}
|
|
1194
|
+
this.pendingSubIds.clear();
|
|
1195
|
+
this.closedByManager = true;
|
|
1196
|
+
try {
|
|
1197
|
+
this.ws?.close();
|
|
1198
|
+
} catch {}
|
|
1199
|
+
this.ws = null;
|
|
1200
|
+
this.connecting = false;
|
|
1201
|
+
this.consecutiveErrors = 0;
|
|
1202
|
+
}
|
|
1203
|
+
connect() {
|
|
1204
|
+
if (this.connecting || this.isOpen() || this.subs.size === 0) return;
|
|
1205
|
+
this.connecting = true;
|
|
1206
|
+
this.closedByManager = false;
|
|
1207
|
+
const protocols = protocolsForAuth(this.token);
|
|
1208
|
+
try {
|
|
1209
|
+
this.ws = protocols ? new WebSocket(this.url, protocols) : new WebSocket(this.url);
|
|
1210
|
+
} catch (err) {
|
|
1211
|
+
this.connecting = false;
|
|
1212
|
+
this.broadcastError("ws_construct_failed", err.message);
|
|
1213
|
+
this.scheduleReconnect();
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
this.ws.addEventListener("open", () => {
|
|
1217
|
+
this.connecting = false;
|
|
1218
|
+
this.consecutiveErrors = 0;
|
|
1219
|
+
this.broadcastStatus("live");
|
|
1220
|
+
for (const subId of this.subs.keys()) this.pendingSubIds.add(subId);
|
|
1221
|
+
this.queueFlush();
|
|
1222
|
+
});
|
|
1223
|
+
this.ws.addEventListener("message", (ev) => {
|
|
1224
|
+
this.handleMessage(ev.data);
|
|
1225
|
+
});
|
|
1226
|
+
this.ws.addEventListener("close", () => {
|
|
1227
|
+
this.ws = null;
|
|
1228
|
+
this.connecting = false;
|
|
1229
|
+
if (this.closedByManager || this.subs.size === 0) return;
|
|
1230
|
+
this.broadcastStatus("reconnecting");
|
|
1231
|
+
this.scheduleReconnect();
|
|
1232
|
+
});
|
|
1233
|
+
this.ws.addEventListener("error", () => {});
|
|
1234
|
+
}
|
|
1235
|
+
queueFlush() {
|
|
1236
|
+
if (this.flushQueued) return;
|
|
1237
|
+
this.flushQueued = true;
|
|
1238
|
+
queueMicrotask(() => {
|
|
1239
|
+
this.flushQueued = false;
|
|
1240
|
+
this.flushPendingSubs();
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
flushPendingSubs() {
|
|
1244
|
+
if (!this.isOpen()) return;
|
|
1245
|
+
const ids = Array.from(this.pendingSubIds);
|
|
1246
|
+
this.pendingSubIds.clear();
|
|
1247
|
+
for (const subId of ids) {
|
|
1248
|
+
const sub = this.subs.get(subId);
|
|
1249
|
+
if (!sub) continue;
|
|
1250
|
+
const frame = {
|
|
1251
|
+
op: "sub",
|
|
1252
|
+
subId,
|
|
1253
|
+
collection: sub.collection
|
|
1254
|
+
};
|
|
1255
|
+
if (sub.where !== void 0) frame.where = sub.where;
|
|
1256
|
+
if (sub.lastSeq !== null) frame.resumeFromSeq = sub.lastSeq;
|
|
1257
|
+
this.sendFrame(frame);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
handleMessage(data) {
|
|
1261
|
+
let frame;
|
|
1262
|
+
try {
|
|
1263
|
+
frame = JSON.parse(data);
|
|
1264
|
+
} catch {
|
|
1265
|
+
this.broadcastError("bad_frame", "non-JSON frame from server");
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
if (!frame.subId) {
|
|
1269
|
+
if (frame.type === "error") this.broadcastError(frame.code ?? "unknown", frame.message ?? "");
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
const sub = this.subs.get(frame.subId);
|
|
1273
|
+
if (!sub) return;
|
|
1274
|
+
if (frame.type === "snapshot") {
|
|
1275
|
+
sub.state.clear();
|
|
1276
|
+
for (const row of frame.rows ?? []) if (row && typeof row.id === "string") sub.state.set(row.id, row);
|
|
1277
|
+
if (typeof frame.seq === "number") sub.lastSeq = frame.seq;
|
|
1278
|
+
if (typeof frame.seq === "number") sub.onSeq?.(frame.seq);
|
|
1279
|
+
this.publish(sub);
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
if (frame.type === "delta") {
|
|
1283
|
+
if (frame.op === "insert" || frame.op === "update") {
|
|
1284
|
+
const row = frame.row;
|
|
1285
|
+
if (!row || typeof row.id !== "string") {
|
|
1286
|
+
sub.onError?.("bad_delta", "delta missing row.id");
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
sub.state.set(row.id, row);
|
|
1290
|
+
if (typeof frame.seq === "number") sub.lastSeq = frame.seq;
|
|
1291
|
+
if (typeof frame.seq === "number") sub.onSeq?.(frame.seq);
|
|
1292
|
+
this.publish(sub);
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
if (frame.op === "delete") {
|
|
1296
|
+
if (typeof frame.id !== "string") {
|
|
1297
|
+
sub.onError?.("bad_delta", "delete delta missing id");
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
sub.state.delete(frame.id);
|
|
1301
|
+
if (typeof frame.seq === "number") sub.lastSeq = frame.seq;
|
|
1302
|
+
if (typeof frame.seq === "number") sub.onSeq?.(frame.seq);
|
|
1303
|
+
this.publish(sub);
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
sub.onError?.("bad_delta", `unknown delta op: ${String(frame.op)}`);
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
if (frame.type === "resumed") {
|
|
1310
|
+
if (typeof frame.toSeq === "number") sub.lastSeq = frame.toSeq;
|
|
1311
|
+
if (typeof frame.toSeq === "number") sub.onSeq?.(frame.toSeq);
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
if (frame.type === "error") sub.onError?.(frame.code ?? "unknown", frame.message ?? "");
|
|
1315
|
+
}
|
|
1316
|
+
publish(sub) {
|
|
1317
|
+
try {
|
|
1318
|
+
sub.onSnapshot(orderedRows(sub.state.values()));
|
|
1319
|
+
} catch {}
|
|
1320
|
+
}
|
|
1321
|
+
scheduleReconnect() {
|
|
1322
|
+
if (this.subs.size === 0 || this.reconnectTimer) return;
|
|
1323
|
+
this.consecutiveErrors++;
|
|
1324
|
+
if (this.consecutiveErrors >= RECONNECT_GIVE_UP) {
|
|
1325
|
+
this.broadcastError("give_up", `gave up after ${this.consecutiveErrors} reconnect attempts`);
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
const delay = Math.min(RECONNECT_CAP_MS, RECONNECT_BASE_MS * Math.pow(2, this.consecutiveErrors - 1));
|
|
1329
|
+
this.reconnectTimer = setTimeout(() => {
|
|
1330
|
+
this.reconnectTimer = null;
|
|
1331
|
+
this.connect();
|
|
1332
|
+
}, delay);
|
|
1333
|
+
}
|
|
1334
|
+
broadcastError(code, message) {
|
|
1335
|
+
for (const sub of this.subs.values()) sub.onError?.(code, message);
|
|
1336
|
+
}
|
|
1337
|
+
broadcastStatus(status) {
|
|
1338
|
+
for (const sub of this.subs.values()) sub.onStatus?.(status);
|
|
1339
|
+
}
|
|
1340
|
+
sendFrame(frame) {
|
|
1341
|
+
try {
|
|
1342
|
+
this.ws?.send(JSON.stringify(frame));
|
|
1343
|
+
} catch (err) {
|
|
1344
|
+
this.broadcastError("ws_send_failed", err.message);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
isOpen() {
|
|
1348
|
+
return this.ws?.readyState === 1;
|
|
1349
|
+
}
|
|
1350
|
+
};
|
|
1351
|
+
const sharedCollectionSockets = /* @__PURE__ */ new Map();
|
|
1352
|
+
function sharedSocketKey(url, token) {
|
|
1353
|
+
return `${url}\n${token ?? ""}`;
|
|
1354
|
+
}
|
|
1355
|
+
function getSharedCollectionSocket(url, token) {
|
|
1356
|
+
const key = sharedSocketKey(url, token);
|
|
1357
|
+
let socket = sharedCollectionSockets.get(key);
|
|
1358
|
+
if (!socket) {
|
|
1359
|
+
socket = new SharedCollectionSocket(url, token);
|
|
1360
|
+
sharedCollectionSockets.set(key, socket);
|
|
1361
|
+
}
|
|
1362
|
+
return socket;
|
|
1363
|
+
}
|
|
1364
|
+
function openSocketSubscription(opts) {
|
|
1365
|
+
const url = opts.url ?? defaultSubscribeUrl();
|
|
1366
|
+
const token = getAuthContext().token;
|
|
1367
|
+
const key = sharedSocketKey(url, token);
|
|
1368
|
+
const socket = getSharedCollectionSocket(url, token);
|
|
1369
|
+
const sub = {
|
|
1370
|
+
subId: makeSubId(),
|
|
1371
|
+
collection: opts.collection,
|
|
1372
|
+
where: opts.where,
|
|
1373
|
+
state: /* @__PURE__ */ new Map(),
|
|
1374
|
+
lastSeq: opts.resumeFromSeq ?? null,
|
|
1375
|
+
onSnapshot: opts.onSnapshot,
|
|
1376
|
+
onError: opts.onError,
|
|
1377
|
+
onSeq: opts.onSeq,
|
|
1378
|
+
onStatus: opts.onStatus
|
|
1379
|
+
};
|
|
1380
|
+
socket.add(sub);
|
|
1381
|
+
return { unsubscribe() {
|
|
1382
|
+
socket.remove(sub.subId);
|
|
1383
|
+
if (socket.isIdle()) sharedCollectionSockets.delete(key);
|
|
1384
|
+
} };
|
|
1385
|
+
}
|
|
1386
|
+
function makeInitialQuerySnapshot() {
|
|
1387
|
+
return {
|
|
1388
|
+
data: [],
|
|
1389
|
+
loading: true,
|
|
1390
|
+
error: null,
|
|
1391
|
+
status: "loading"
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
var CollectionQueryStore = class {
|
|
1395
|
+
constructor(key, url, collection, where, onIdle) {
|
|
1396
|
+
this.key = key;
|
|
1397
|
+
this.url = url;
|
|
1398
|
+
this.collection = collection;
|
|
1399
|
+
this.where = where;
|
|
1400
|
+
this.onIdle = onIdle;
|
|
1401
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
1402
|
+
this.rowSubscribers = /* @__PURE__ */ new Set();
|
|
1403
|
+
this.socketHandle = null;
|
|
1404
|
+
this.fallbackHandle = null;
|
|
1405
|
+
this.authUnsub = null;
|
|
1406
|
+
this.authToken = getAuthContext().token;
|
|
1407
|
+
this.authReady = getAuthContext().authReady;
|
|
1408
|
+
this.lastSeq = null;
|
|
1409
|
+
this.pendingAuthRequired = null;
|
|
1410
|
+
this.notifyQueued = false;
|
|
1411
|
+
this.rowsDirty = false;
|
|
1412
|
+
this.snapshot = makeInitialQuerySnapshot();
|
|
1413
|
+
}
|
|
1414
|
+
subscribe(listener) {
|
|
1415
|
+
this.listeners.add(listener);
|
|
1416
|
+
this.start();
|
|
1417
|
+
return () => {
|
|
1418
|
+
this.listeners.delete(listener);
|
|
1419
|
+
this.stopIfIdle();
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
subscribeRows(subscriber) {
|
|
1423
|
+
this.rowSubscribers.add(subscriber);
|
|
1424
|
+
this.start();
|
|
1425
|
+
if (!this.snapshot.loading) queueMicrotask(() => {
|
|
1426
|
+
if (this.rowSubscribers.has(subscriber)) subscriber.onSnapshot(this.snapshot.data);
|
|
1427
|
+
});
|
|
1428
|
+
return () => {
|
|
1429
|
+
this.rowSubscribers.delete(subscriber);
|
|
1430
|
+
this.stopIfIdle();
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
destroy() {
|
|
1434
|
+
this.stop();
|
|
1435
|
+
this.listeners.clear();
|
|
1436
|
+
this.rowSubscribers.clear();
|
|
1437
|
+
this.lastSeq = null;
|
|
1438
|
+
this.notifyQueued = false;
|
|
1439
|
+
this.rowsDirty = false;
|
|
1440
|
+
this.snapshot = makeInitialQuerySnapshot();
|
|
1441
|
+
}
|
|
1442
|
+
getSnapshot() {
|
|
1443
|
+
return this.snapshot;
|
|
1444
|
+
}
|
|
1445
|
+
ensureLegacyFallback(api) {
|
|
1446
|
+
if (this.fallbackHandle) return;
|
|
1447
|
+
this.fallbackHandle = startLegacySseRefetch({ current: { collection: this.collection } }, () => this.refreshFromApi(api, "stale"), (message) => this.setError("unavailable", message));
|
|
1448
|
+
}
|
|
1449
|
+
async refreshFromApi(api, status = this.socketHandle ? "live" : "stale") {
|
|
1450
|
+
try {
|
|
1451
|
+
const body = await (await apiFetch(api)).json();
|
|
1452
|
+
let rows = Array.isArray(body) ? body : [];
|
|
1453
|
+
if (this.where) rows = rows.filter((row) => matchesPredicate(row, this.where));
|
|
1454
|
+
this.setRows(orderedRows(rows), status);
|
|
1455
|
+
} catch (err) {
|
|
1456
|
+
this.setError("error", err instanceof Error ? err.message : String(err));
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
start() {
|
|
1460
|
+
if (!this.authUnsub) this.authUnsub = subscribeAuthChange((snap) => this.handleAuthChange(snap.token, snap.authReady));
|
|
1461
|
+
if (!this.socketHandle) this.openSocket();
|
|
1462
|
+
}
|
|
1463
|
+
stopIfIdle() {
|
|
1464
|
+
if (this.listeners.size > 0 || this.rowSubscribers.size > 0) return;
|
|
1465
|
+
this.stop();
|
|
1466
|
+
this.onIdle(this.key);
|
|
1467
|
+
}
|
|
1468
|
+
stop() {
|
|
1469
|
+
this.socketHandle?.unsubscribe();
|
|
1470
|
+
this.socketHandle = null;
|
|
1471
|
+
this.fallbackHandle?.abort();
|
|
1472
|
+
this.fallbackHandle = null;
|
|
1473
|
+
this.authUnsub?.();
|
|
1474
|
+
this.authUnsub = null;
|
|
1475
|
+
this.pendingAuthRequired = null;
|
|
1476
|
+
}
|
|
1477
|
+
openSocket() {
|
|
1478
|
+
this.authToken = getAuthContext().token;
|
|
1479
|
+
this.authReady = getAuthContext().authReady;
|
|
1480
|
+
this.socketHandle = openSocketSubscription({
|
|
1481
|
+
url: this.url,
|
|
1482
|
+
collection: this.collection,
|
|
1483
|
+
where: this.where,
|
|
1484
|
+
resumeFromSeq: this.lastSeq,
|
|
1485
|
+
onSeq: (seq) => {
|
|
1486
|
+
this.lastSeq = seq;
|
|
1487
|
+
},
|
|
1488
|
+
onStatus: (status) => {
|
|
1489
|
+
if (status === "reconnecting") this.setStatus("reconnecting", null);
|
|
1490
|
+
},
|
|
1491
|
+
onSnapshot: (rows) => this.setRows(rows, "live"),
|
|
1492
|
+
onError: (code, message) => this.handleSocketError(code, message)
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
handleAuthChange(token, authReady) {
|
|
1496
|
+
const tokenChanged = token !== this.authToken;
|
|
1497
|
+
const readyChanged = authReady !== this.authReady;
|
|
1498
|
+
this.authToken = token;
|
|
1499
|
+
this.authReady = authReady;
|
|
1500
|
+
if (tokenChanged) {
|
|
1501
|
+
this.socketHandle?.unsubscribe();
|
|
1502
|
+
this.socketHandle = null;
|
|
1503
|
+
this.fallbackHandle?.abort();
|
|
1504
|
+
this.fallbackHandle = null;
|
|
1505
|
+
this.lastSeq = null;
|
|
1506
|
+
this.pendingAuthRequired = null;
|
|
1507
|
+
this.setRows([], "loading", true);
|
|
1508
|
+
this.openSocket();
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
if (readyChanged && authReady && this.pendingAuthRequired) {
|
|
1512
|
+
const message = this.pendingAuthRequired;
|
|
1513
|
+
this.pendingAuthRequired = null;
|
|
1514
|
+
notifyAuthRequired();
|
|
1515
|
+
this.setStatus("auth_required", `auth_required: ${message}`, true);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
handleSocketError(code, message) {
|
|
1519
|
+
this.broadcastRowError(code, message);
|
|
1520
|
+
if (code === "auth_required") {
|
|
1521
|
+
if (!getAuthContext().authReady) {
|
|
1522
|
+
this.pendingAuthRequired = message;
|
|
1523
|
+
this.setStatus("loading", null);
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
notifyAuthRequired();
|
|
1527
|
+
this.setStatus("auth_required", `${code}: ${message}`, true);
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
if (code === "give_up") {
|
|
1531
|
+
this.setStatus("unavailable", "realtime stream unavailable", true);
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
this.setStatus("error", `${code}: ${message}`, true);
|
|
1535
|
+
}
|
|
1536
|
+
setRows(rows, status, forceLoading = false) {
|
|
1537
|
+
this.snapshot = {
|
|
1538
|
+
data: rows,
|
|
1539
|
+
loading: forceLoading || status === "loading",
|
|
1540
|
+
error: null,
|
|
1541
|
+
status
|
|
1542
|
+
};
|
|
1543
|
+
this.rowsDirty = true;
|
|
1544
|
+
this.queueNotify();
|
|
1545
|
+
}
|
|
1546
|
+
setStatus(status, error, doneLoading = false) {
|
|
1547
|
+
this.snapshot = {
|
|
1548
|
+
...this.snapshot,
|
|
1549
|
+
loading: status === "loading" ? true : doneLoading ? false : this.snapshot.loading,
|
|
1550
|
+
error,
|
|
1551
|
+
status
|
|
1552
|
+
};
|
|
1553
|
+
this.queueNotify();
|
|
1554
|
+
}
|
|
1555
|
+
setError(status, error) {
|
|
1556
|
+
this.snapshot = {
|
|
1557
|
+
...this.snapshot,
|
|
1558
|
+
loading: false,
|
|
1559
|
+
error,
|
|
1560
|
+
status
|
|
1561
|
+
};
|
|
1562
|
+
this.queueNotify();
|
|
1563
|
+
}
|
|
1564
|
+
queueNotify() {
|
|
1565
|
+
if (this.notifyQueued) return;
|
|
1566
|
+
this.notifyQueued = true;
|
|
1567
|
+
queueMicrotask(() => {
|
|
1568
|
+
this.notifyQueued = false;
|
|
1569
|
+
const rowsDirty = this.rowsDirty;
|
|
1570
|
+
this.rowsDirty = false;
|
|
1571
|
+
for (const listener of this.listeners) listener();
|
|
1572
|
+
if (rowsDirty) for (const subscriber of this.rowSubscribers) try {
|
|
1573
|
+
subscriber.onSnapshot(this.snapshot.data);
|
|
1574
|
+
} catch {}
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1577
|
+
broadcastRowError(code, message) {
|
|
1578
|
+
for (const subscriber of this.rowSubscribers) subscriber.onError?.(code, message);
|
|
1579
|
+
}
|
|
1580
|
+
};
|
|
1581
|
+
const collectionQueryStores = /* @__PURE__ */ new Map();
|
|
1582
|
+
function collectionQueryKey(url, collection, where) {
|
|
1583
|
+
return `${url}\n${collection}\n${canonicalPredicateString(where)}`;
|
|
1584
|
+
}
|
|
1585
|
+
function getCollectionQueryStore(url, collection, where) {
|
|
1586
|
+
const normalizedWhere = normalizePredicate(where);
|
|
1587
|
+
const key = collectionQueryKey(url, collection, normalizedWhere);
|
|
1588
|
+
let store = collectionQueryStores.get(key);
|
|
1589
|
+
if (!store) {
|
|
1590
|
+
store = new CollectionQueryStore(key, url, collection, normalizedWhere, (idleKey) => collectionQueryStores.delete(idleKey));
|
|
1591
|
+
collectionQueryStores.set(key, store);
|
|
1592
|
+
}
|
|
1593
|
+
return store;
|
|
1594
|
+
}
|
|
1595
|
+
function subscribeCollection(opts) {
|
|
1596
|
+
return { unsubscribe: getCollectionQueryStore(opts.url ?? defaultSubscribeUrl(), opts.collection, opts.where).subscribeRows({
|
|
1597
|
+
onSnapshot: opts.onSnapshot,
|
|
1598
|
+
onError: opts.onError
|
|
1599
|
+
}) };
|
|
1600
|
+
}
|
|
1601
|
+
function _resetSharedCollectionSocketsForTests() {
|
|
1602
|
+
for (const store of collectionQueryStores.values()) store.destroy();
|
|
1603
|
+
collectionQueryStores.clear();
|
|
1604
|
+
for (const socket of sharedCollectionSockets.values()) socket.shutdown();
|
|
1605
|
+
sharedCollectionSockets.clear();
|
|
1606
|
+
}
|
|
1607
|
+
function _canonicalPredicateForTests(predicate) {
|
|
1608
|
+
return canonicalPredicateString(predicate);
|
|
1609
|
+
}
|
|
1610
|
+
//#endregion
|
|
1611
|
+
export { OmgBadge, VibesAuthAutoPrompt, VibesAuthGuard, VibesAuthProvider, VibesAuthRequiredError, VibesFeedback, VibesLogin, VibesUpload, _canonicalPredicateForTests, _resetSharedCollectionSocketsForTests, attachGestureListeners, captureScreenshot, clearTrace, createNotification, createSandbox, createVibesAuth, deriveAppId, deriveTokenUrl, forkSandbox, getAuthContext, getTrace, installTrace, mailAppForEmail, motionPermissionState, notificationSupport, notifyAuthRequired, requestMotionPermission, runtimeClaims, subscribeAuthRequired, subscribeCollection, useAuth, useCollection, useFeedbackGesture, useNotificationPermission, useNotifications, useQuery, useUpload, useUser, useVibesAuth, useVibesToken, vibesFetch };
|