@estra/client 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +53 -2
- package/dist/_schema/index.d.ts +2 -0
- package/dist/_schema/index.js +2 -0
- package/dist/_schema/local.d.ts +14 -0
- package/dist/_schema/local.js +186 -0
- package/dist/_schema/query.d.ts +132 -0
- package/dist/_schema/query.js +34 -0
- package/dist/auth.d.ts +108 -0
- package/dist/auth.js +245 -0
- package/dist/channels.d.ts +42 -0
- package/dist/channels.js +176 -0
- package/dist/compat/app.d.ts +33 -0
- package/dist/compat/app.js +37 -0
- package/dist/compat/auth.d.ts +141 -0
- package/dist/compat/auth.js +221 -0
- package/dist/compat/firestore.d.ts +18 -0
- package/dist/compat/firestore.js +43 -0
- package/dist/compat/storage.d.ts +5 -0
- package/dist/compat/storage.js +5 -0
- package/dist/firestore.d.ts +256 -0
- package/dist/firestore.js +576 -0
- package/dist/functions.d.ts +20 -0
- package/dist/functions.js +43 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/local.d.ts +1 -0
- package/dist/local.js +2 -0
- package/dist/offline.d.ts +15 -0
- package/dist/offline.js +309 -0
- package/dist/realtime.d.ts +44 -0
- package/dist/realtime.js +112 -0
- package/dist/storage.d.ts +57 -0
- package/dist/storage.js +111 -0
- package/dist/store.d.ts +37 -0
- package/dist/store.js +44 -0
- package/dist/transport.d.ts +46 -0
- package/dist/transport.js +45 -0
- package/package.json +78 -3
package/dist/auth.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// Client auth for an Estra app: sign-in methods, a persisted session, ID tokens refreshed before
|
|
2
|
+
// they expire, and onAuthStateChanged. Pair it with the Firestore API:
|
|
3
|
+
// const auth = createAuth({ url });
|
|
4
|
+
// const db = getFirestore(httpTransport({ url, getToken: () => auth.getIdToken() }));
|
|
5
|
+
import { EstraClientError } from "./transport.js";
|
|
6
|
+
export const memoryStorage = () => {
|
|
7
|
+
const m = new Map();
|
|
8
|
+
return { get: (k) => m.get(k) ?? null, set: (k, v) => void m.set(k, v), remove: (k) => void m.delete(k) };
|
|
9
|
+
};
|
|
10
|
+
const webStorage = () => globalThis.localStorage;
|
|
11
|
+
export const browserStorage = () => ({
|
|
12
|
+
get: (k) => webStorage()?.getItem(k) ?? null,
|
|
13
|
+
set: (k, v) => webStorage()?.setItem(k, v),
|
|
14
|
+
remove: (k) => webStorage()?.removeItem(k),
|
|
15
|
+
});
|
|
16
|
+
export function createAuth(o) {
|
|
17
|
+
const base = o.url.replace(/\/$/, "");
|
|
18
|
+
const storage = o.storage ?? (webStorage() ? browserStorage() : memoryStorage());
|
|
19
|
+
const f = o.fetch ?? ((...a) => fetch(...a));
|
|
20
|
+
const margin = o.refreshMarginMs ?? 5 * 60_000;
|
|
21
|
+
const key = `estra.session:${base}`;
|
|
22
|
+
const listeners = new Set();
|
|
23
|
+
const win = () => o.window ?? globalThis.window ?? fail("no window: popup/redirect sign-in needs a browser");
|
|
24
|
+
const slug = (p) => p.replace(/\.com$/, "");
|
|
25
|
+
const startUrl = (p, q) => `${base}/v1/auth/oauth/${slug(p)}/start?${new URLSearchParams(q)}`;
|
|
26
|
+
const estraOrigin = new URL(base).origin;
|
|
27
|
+
/** Opens the provider popup (synchronously, so browsers allow it) and resolves with the one-time code. */
|
|
28
|
+
function popupCode(p, linkToken) {
|
|
29
|
+
const w = win();
|
|
30
|
+
const popup = w.open(linkToken ? "about:blank" : startUrl(p, { mode: "popup", origin: w.location.origin }), "estra-auth", "popup,width=500,height=640");
|
|
31
|
+
if (!popup)
|
|
32
|
+
return Promise.reject(new EstraClientError("popup-blocked", "the browser blocked the sign-in popup"));
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
let done = false;
|
|
35
|
+
const finish = (err, code) => {
|
|
36
|
+
if (done)
|
|
37
|
+
return;
|
|
38
|
+
done = true;
|
|
39
|
+
clearInterval(timer);
|
|
40
|
+
w.removeEventListener("message", onMessage);
|
|
41
|
+
if (err)
|
|
42
|
+
reject(err);
|
|
43
|
+
else
|
|
44
|
+
resolve(code);
|
|
45
|
+
};
|
|
46
|
+
const onMessage = (e) => {
|
|
47
|
+
if (e.origin !== estraOrigin)
|
|
48
|
+
return;
|
|
49
|
+
const d = e.data;
|
|
50
|
+
if (d?.type !== "estra-oauth")
|
|
51
|
+
return;
|
|
52
|
+
if (d.code)
|
|
53
|
+
finish(null, d.code);
|
|
54
|
+
else
|
|
55
|
+
finish(new EstraClientError(d.error?.code ?? "internal", d.error?.message ?? "sign-in failed"));
|
|
56
|
+
};
|
|
57
|
+
w.addEventListener("message", onMessage);
|
|
58
|
+
const timer = setInterval(() => {
|
|
59
|
+
if (popup.closed)
|
|
60
|
+
finish(new EstraClientError("popup-closed-by-user", "the sign-in popup was closed"));
|
|
61
|
+
}, 400);
|
|
62
|
+
if (linkToken)
|
|
63
|
+
linkToken.then((t) => (popup.location.href = startUrl(p, { mode: "popup", origin: w.location.origin, linkToken: t })), (e) => {
|
|
64
|
+
popup.close();
|
|
65
|
+
finish(e instanceof EstraClientError ? e : new EstraClientError("internal", String(e)));
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
let session = null;
|
|
70
|
+
let loaded = null;
|
|
71
|
+
let refreshing = null;
|
|
72
|
+
const emit = () => listeners.forEach((l) => l(session?.user ?? null));
|
|
73
|
+
async function load() {
|
|
74
|
+
loaded ??= (async () => {
|
|
75
|
+
const raw = await storage.get(key);
|
|
76
|
+
try {
|
|
77
|
+
session = raw ? JSON.parse(raw) : null;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
session = null;
|
|
81
|
+
}
|
|
82
|
+
})();
|
|
83
|
+
return loaded;
|
|
84
|
+
}
|
|
85
|
+
async function save(s) {
|
|
86
|
+
session = s;
|
|
87
|
+
if (s)
|
|
88
|
+
await storage.set(key, JSON.stringify(s));
|
|
89
|
+
else
|
|
90
|
+
await storage.remove(key);
|
|
91
|
+
emit();
|
|
92
|
+
}
|
|
93
|
+
async function post(route, body, bearer) {
|
|
94
|
+
const headers = { "content-type": "application/json" };
|
|
95
|
+
if (bearer)
|
|
96
|
+
headers.authorization = `Bearer ${bearer}`;
|
|
97
|
+
const r = await f(`${base}/v1/auth/${route}`, { method: "POST", headers, body: JSON.stringify(body) });
|
|
98
|
+
const j = (await r.json().catch(() => ({})));
|
|
99
|
+
if (!r.ok)
|
|
100
|
+
throw new EstraClientError(j.error?.code ?? "unavailable", j.error?.message ?? `HTTP ${r.status}`);
|
|
101
|
+
return j;
|
|
102
|
+
}
|
|
103
|
+
const start = async (p) => {
|
|
104
|
+
const s = await p;
|
|
105
|
+
await save({ idToken: s.idToken, refreshToken: s.refreshToken, expiresAt: Date.now() + s.expiresIn * 1000, user: s.user });
|
|
106
|
+
return s.user;
|
|
107
|
+
};
|
|
108
|
+
async function refresh() {
|
|
109
|
+
refreshing ??= (async () => {
|
|
110
|
+
const cur = session;
|
|
111
|
+
if (!cur)
|
|
112
|
+
return null;
|
|
113
|
+
try {
|
|
114
|
+
const s = await post("refresh", { refreshToken: cur.refreshToken });
|
|
115
|
+
await save({ idToken: s.idToken, refreshToken: s.refreshToken, expiresAt: Date.now() + s.expiresIn * 1000, user: s.user });
|
|
116
|
+
return session;
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
// Only a refusal signs out; a network failure keeps the session for the next try.
|
|
120
|
+
if (e instanceof EstraClientError && e.code === "invalid-credential")
|
|
121
|
+
await save(null);
|
|
122
|
+
throw e;
|
|
123
|
+
}
|
|
124
|
+
})().finally(() => {
|
|
125
|
+
refreshing = null;
|
|
126
|
+
});
|
|
127
|
+
return refreshing;
|
|
128
|
+
}
|
|
129
|
+
async function linkToken() {
|
|
130
|
+
const token = await currentToken();
|
|
131
|
+
return (await post("oauth/link-token", {}, token)).linkToken;
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
get currentUser() {
|
|
135
|
+
return session?.user ?? null;
|
|
136
|
+
},
|
|
137
|
+
/** Resolves once the stored session (if any) is loaded. */
|
|
138
|
+
ready: () => load(),
|
|
139
|
+
onAuthStateChanged(cb) {
|
|
140
|
+
listeners.add(cb);
|
|
141
|
+
void load().then(() => cb(session?.user ?? null));
|
|
142
|
+
return () => listeners.delete(cb);
|
|
143
|
+
},
|
|
144
|
+
/** A valid access token (refreshed when near expiry), or null when signed out. */
|
|
145
|
+
async getIdToken(forceRefresh = false) {
|
|
146
|
+
await load();
|
|
147
|
+
if (!session)
|
|
148
|
+
return null;
|
|
149
|
+
if (forceRefresh || session.expiresAt - Date.now() < margin)
|
|
150
|
+
return (await refresh())?.idToken ?? null;
|
|
151
|
+
return session.idToken;
|
|
152
|
+
},
|
|
153
|
+
signUp: async (email, password, displayName) => {
|
|
154
|
+
await load();
|
|
155
|
+
return start(post("signup", { email, password, ...(displayName ? { displayName } : {}) }, session?.user.anonymous ? session.idToken : undefined));
|
|
156
|
+
},
|
|
157
|
+
signIn: (email, password) => start(post("signin", { email, password })),
|
|
158
|
+
/** Google/Apple: pass the ID token from Google Identity Services or Sign in with Apple. */
|
|
159
|
+
signInWithIdToken: (provider, idToken, o = {}) => start(post("signin/idp", { provider, idToken, ...o })),
|
|
160
|
+
signInAnonymously: () => start(post("signin/anonymous", {})),
|
|
161
|
+
/** Google / Apple / GitHub in a popup (call from a click handler). */
|
|
162
|
+
signInWithPopup: async (provider) => start(post("oauth/exchange", { code: await popupCode(provider) })),
|
|
163
|
+
// Popups must open synchronously inside the click handler (browsers block them after an await),
|
|
164
|
+
// so every popup flow opens first and does its async work afterwards.
|
|
165
|
+
/** Link a provider to the signed-in account (an anonymous account becomes a full one, same uid). */
|
|
166
|
+
linkWithPopup: async (provider) => start(post("oauth/exchange", { code: await popupCode(provider, linkToken()) })),
|
|
167
|
+
/** Sign in again with the same account (before sensitive changes). Fails if a different account answers. */
|
|
168
|
+
reauthenticateWithPopup: async (provider) => {
|
|
169
|
+
const codeP = popupCode(provider);
|
|
170
|
+
await load();
|
|
171
|
+
const uid = session?.user.uid;
|
|
172
|
+
const code = await codeP;
|
|
173
|
+
if (!uid)
|
|
174
|
+
throw new EstraClientError("unauthenticated", "sign in first");
|
|
175
|
+
const s = await post("oauth/exchange", { code });
|
|
176
|
+
if (s.uid !== uid)
|
|
177
|
+
throw new EstraClientError("user-mismatch", "a different account signed in");
|
|
178
|
+
return start(Promise.resolve(s));
|
|
179
|
+
},
|
|
180
|
+
/** Full-page redirect sign-in; finish with getRedirectResult() on the page you return to. */
|
|
181
|
+
signInWithRedirect: (provider, returnTo) => {
|
|
182
|
+
const w = win();
|
|
183
|
+
w.location.assign(startUrl(provider, { mode: "redirect", returnTo: returnTo ?? w.location.href }));
|
|
184
|
+
},
|
|
185
|
+
linkWithRedirect: async (provider, returnTo) => {
|
|
186
|
+
const w = win();
|
|
187
|
+
w.location.assign(startUrl(provider, { mode: "redirect", returnTo: returnTo ?? w.location.href, linkToken: await linkToken() }));
|
|
188
|
+
},
|
|
189
|
+
/** After a redirect: the signed-in user, null when this page load isn't a sign-in return. */
|
|
190
|
+
getRedirectResult: async () => {
|
|
191
|
+
const w = win();
|
|
192
|
+
const h = new URLSearchParams(w.location.hash.replace(/^#/, ""));
|
|
193
|
+
const code = h.get("estra_auth");
|
|
194
|
+
const error = h.get("estra_auth_error");
|
|
195
|
+
if (!code && !error)
|
|
196
|
+
return null;
|
|
197
|
+
w.history?.replaceState(null, "", w.location.href.split("#")[0]);
|
|
198
|
+
if (error)
|
|
199
|
+
throw new EstraClientError(error, "sign-in failed");
|
|
200
|
+
return start(post("oauth/exchange", { code }));
|
|
201
|
+
},
|
|
202
|
+
/** Native apps: link a Google/Apple ID token to the signed-in account. */
|
|
203
|
+
linkWithIdToken: async (provider, idToken, nonce) => start(post("link/idp", { provider, idToken, ...(nonce ? { nonce } : {}) }, await currentToken())),
|
|
204
|
+
sendSignInLink: (email) => post("email/send", { mode: "signIn", email }),
|
|
205
|
+
/** Finish a link from the email: mode + oobCode come from its query string. */
|
|
206
|
+
signInWithLink: (oobCode) => start(post("email/confirm", { mode: "signIn", oobCode })),
|
|
207
|
+
sendPasswordReset: (email) => post("email/send", { mode: "resetPassword", email }),
|
|
208
|
+
confirmPasswordReset: (oobCode, newPassword) => post("email/confirm", { mode: "resetPassword", oobCode, newPassword }),
|
|
209
|
+
sendEmailVerification: async () => post("email/send", { mode: "verifyEmail" }, (await load(), session?.idToken)),
|
|
210
|
+
applyEmailVerification: async (oobCode) => {
|
|
211
|
+
await post("email/confirm", { mode: "verifyEmail", oobCode });
|
|
212
|
+
if (session)
|
|
213
|
+
await refresh().catch(() => null);
|
|
214
|
+
},
|
|
215
|
+
updateProfile: async (p) => {
|
|
216
|
+
const token = await currentToken();
|
|
217
|
+
const { user } = await post("me/update", p, token);
|
|
218
|
+
if (session)
|
|
219
|
+
await save({ ...session, user });
|
|
220
|
+
return user;
|
|
221
|
+
},
|
|
222
|
+
deleteUser: async () => {
|
|
223
|
+
await post("me/delete", {}, await currentToken());
|
|
224
|
+
await save(null);
|
|
225
|
+
},
|
|
226
|
+
signOut: async () => {
|
|
227
|
+
await load();
|
|
228
|
+
const s = session;
|
|
229
|
+
await save(null);
|
|
230
|
+
if (s)
|
|
231
|
+
await post("signout", { refreshToken: s.refreshToken }).catch(() => null);
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
async function currentToken() {
|
|
235
|
+
await load();
|
|
236
|
+
if (!session)
|
|
237
|
+
throw new EstraClientError("unauthenticated", "sign in first");
|
|
238
|
+
if (session.expiresAt - Date.now() < margin)
|
|
239
|
+
await refresh();
|
|
240
|
+
return session.idToken;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function fail(message, code = "internal") {
|
|
244
|
+
throw new EstraClientError(code, message);
|
|
245
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface Member {
|
|
2
|
+
/** Per connection; changes after a reconnect. */
|
|
3
|
+
id: string;
|
|
4
|
+
uid: string | null;
|
|
5
|
+
state: unknown;
|
|
6
|
+
}
|
|
7
|
+
export interface ChannelOptions {
|
|
8
|
+
url: string;
|
|
9
|
+
getToken?: () => Promise<string | null> | string | null;
|
|
10
|
+
WebSocket?: typeof WebSocket;
|
|
11
|
+
}
|
|
12
|
+
export type PresenceEvent = {
|
|
13
|
+
event: "join" | "leave" | "update" | "sync";
|
|
14
|
+
member?: Member;
|
|
15
|
+
};
|
|
16
|
+
type Unsubscribe = () => void;
|
|
17
|
+
export declare class ChannelError extends Error {
|
|
18
|
+
readonly code: string;
|
|
19
|
+
constructor(code: string, message: string);
|
|
20
|
+
}
|
|
21
|
+
export interface Channel {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
/** Joins (or re-joins) with a presence state; resolves with you and the members present. */
|
|
24
|
+
join(state?: unknown): Promise<{
|
|
25
|
+
self: Member;
|
|
26
|
+
members: Member[];
|
|
27
|
+
}>;
|
|
28
|
+
/** Replaces your presence state (others get a presence "update"). */
|
|
29
|
+
setState(state: unknown): void;
|
|
30
|
+
/** To every other member. */
|
|
31
|
+
broadcast(event: string, data?: unknown): void;
|
|
32
|
+
/** To one member (by member id). */
|
|
33
|
+
send(to: string, event: string, data?: unknown): void;
|
|
34
|
+
on(event: string, fn: (data: any, from: Member) => void): Unsubscribe;
|
|
35
|
+
onPresence(fn: (members: Member[], change: PresenceEvent) => void): Unsubscribe;
|
|
36
|
+
onError(fn: (e: ChannelError) => void): Unsubscribe;
|
|
37
|
+
members(): Member[];
|
|
38
|
+
self(): Member | null;
|
|
39
|
+
leave(): void;
|
|
40
|
+
}
|
|
41
|
+
export declare function channel(o: ChannelOptions, name: string): Channel;
|
|
42
|
+
export {};
|
package/dist/channels.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Ephemeral channels: presence, broadcast and direct messages (/v1/channels/<name>).
|
|
2
|
+
//
|
|
3
|
+
// const room = channel({ url, getToken }, "room:42");
|
|
4
|
+
// room.onPresence((members) => render(members));
|
|
5
|
+
// room.on("cursor", (pos, from) => moveCursor(from.id, pos));
|
|
6
|
+
// await room.join({ name: "Ann" });
|
|
7
|
+
// room.broadcast("cursor", { x, y });
|
|
8
|
+
// room.setState({ name: "Ann", typing: true });
|
|
9
|
+
// room.send(memberId, "offer", sdp); // WebRTC signalling
|
|
10
|
+
// room.leave();
|
|
11
|
+
//
|
|
12
|
+
// The socket reconnects with backoff and joins again with the last state (members get new ids
|
|
13
|
+
// then: others see a leave and a join). Nothing here is stored: use documents for history.
|
|
14
|
+
export class ChannelError extends Error {
|
|
15
|
+
code;
|
|
16
|
+
constructor(code, message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.name = "ChannelError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function channel(o, name) {
|
|
23
|
+
const WS = o.WebSocket ?? globalThis.WebSocket;
|
|
24
|
+
const url = `${o.url.replace(/^http/, "ws").replace(/\/$/, "")}/v1/channels/${encodeURIComponent(name)}`;
|
|
25
|
+
const handlers = new Map();
|
|
26
|
+
const presence = new Set();
|
|
27
|
+
const errors = new Set();
|
|
28
|
+
const members = new Map();
|
|
29
|
+
let me = null;
|
|
30
|
+
let ws = null;
|
|
31
|
+
let ready = false;
|
|
32
|
+
let wantJoin = false;
|
|
33
|
+
let state = undefined;
|
|
34
|
+
let backoff = 500;
|
|
35
|
+
let left = false;
|
|
36
|
+
let joining = null;
|
|
37
|
+
const queue = [];
|
|
38
|
+
const raw = (m) => {
|
|
39
|
+
const s = JSON.stringify(m);
|
|
40
|
+
if (ws && ready && me)
|
|
41
|
+
ws.send(s);
|
|
42
|
+
else
|
|
43
|
+
queue.push(s);
|
|
44
|
+
};
|
|
45
|
+
const emitPresence = (change) => {
|
|
46
|
+
const list = [...members.values()];
|
|
47
|
+
for (const f of presence)
|
|
48
|
+
f(list, change);
|
|
49
|
+
};
|
|
50
|
+
const fail = (e) => {
|
|
51
|
+
if (joining) {
|
|
52
|
+
joining.reject(e);
|
|
53
|
+
joining = null;
|
|
54
|
+
}
|
|
55
|
+
for (const f of errors)
|
|
56
|
+
f(e);
|
|
57
|
+
};
|
|
58
|
+
const auth = async () => ws?.send(JSON.stringify({ t: "auth", token: o.getToken ? await o.getToken() : null }));
|
|
59
|
+
function open() {
|
|
60
|
+
if (ws || left)
|
|
61
|
+
return;
|
|
62
|
+
if (!WS)
|
|
63
|
+
throw new Error("no WebSocket available: pass ChannelOptions.WebSocket");
|
|
64
|
+
const sock = new WS(url);
|
|
65
|
+
ws = sock;
|
|
66
|
+
ready = false;
|
|
67
|
+
sock.addEventListener("open", () => {
|
|
68
|
+
backoff = 500;
|
|
69
|
+
void auth();
|
|
70
|
+
});
|
|
71
|
+
sock.addEventListener("message", (e) => {
|
|
72
|
+
let m;
|
|
73
|
+
try {
|
|
74
|
+
m = JSON.parse(String(e.data));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (m.t === "ready") {
|
|
80
|
+
ready = true;
|
|
81
|
+
if (wantJoin)
|
|
82
|
+
sock.send(JSON.stringify({ t: "join", ...(state !== undefined ? { state } : {}) }));
|
|
83
|
+
}
|
|
84
|
+
else if (m.t === "joined") {
|
|
85
|
+
me = m.self;
|
|
86
|
+
members.clear();
|
|
87
|
+
for (const x of m.members)
|
|
88
|
+
members.set(x.id, x);
|
|
89
|
+
joining?.resolve({ self: me, members: [...members.values()] });
|
|
90
|
+
joining = null;
|
|
91
|
+
emitPresence({ event: "sync" });
|
|
92
|
+
while (queue.length)
|
|
93
|
+
sock.send(queue.shift());
|
|
94
|
+
}
|
|
95
|
+
else if (m.t === "presence") {
|
|
96
|
+
const mem = m.member;
|
|
97
|
+
if (m.event === "leave")
|
|
98
|
+
members.delete(mem.id);
|
|
99
|
+
else
|
|
100
|
+
members.set(mem.id, mem);
|
|
101
|
+
emitPresence({ event: m.event, member: mem });
|
|
102
|
+
}
|
|
103
|
+
else if (m.t === "message") {
|
|
104
|
+
for (const f of handlers.get(m.event) ?? [])
|
|
105
|
+
f(m.data, m.from);
|
|
106
|
+
}
|
|
107
|
+
else if (m.t === "error") {
|
|
108
|
+
if (m.code === "unauthenticated" && me)
|
|
109
|
+
void auth();
|
|
110
|
+
else
|
|
111
|
+
fail(new ChannelError(m.code ?? "internal", m.message ?? "channel error"));
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
sock.addEventListener("close", () => {
|
|
115
|
+
ws = null;
|
|
116
|
+
ready = false;
|
|
117
|
+
me = null;
|
|
118
|
+
if (left || !wantJoin)
|
|
119
|
+
return;
|
|
120
|
+
// Others see us leave; we'll join again and see a fresh member list.
|
|
121
|
+
setTimeout(open, backoff);
|
|
122
|
+
backoff = Math.min(backoff * 2, 30_000);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
name,
|
|
127
|
+
join(s) {
|
|
128
|
+
if (s !== undefined)
|
|
129
|
+
state = s;
|
|
130
|
+
wantJoin = true;
|
|
131
|
+
left = false;
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
joining = { resolve, reject };
|
|
134
|
+
if (ws && ready)
|
|
135
|
+
ws.send(JSON.stringify({ t: "join", ...(state !== undefined ? { state } : {}) }));
|
|
136
|
+
else
|
|
137
|
+
open();
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
setState(s) {
|
|
141
|
+
state = s;
|
|
142
|
+
raw({ t: "state", state: s });
|
|
143
|
+
},
|
|
144
|
+
broadcast(event, data) {
|
|
145
|
+
raw({ t: "broadcast", event, data: data ?? null });
|
|
146
|
+
},
|
|
147
|
+
send(to, event, data) {
|
|
148
|
+
raw({ t: "broadcast", event, data: data ?? null, to });
|
|
149
|
+
},
|
|
150
|
+
on(event, fn) {
|
|
151
|
+
if (!handlers.has(event))
|
|
152
|
+
handlers.set(event, new Set());
|
|
153
|
+
handlers.get(event).add(fn);
|
|
154
|
+
return () => void handlers.get(event)?.delete(fn);
|
|
155
|
+
},
|
|
156
|
+
onPresence(fn) {
|
|
157
|
+
presence.add(fn);
|
|
158
|
+
return () => void presence.delete(fn);
|
|
159
|
+
},
|
|
160
|
+
onError(fn) {
|
|
161
|
+
errors.add(fn);
|
|
162
|
+
return () => void errors.delete(fn);
|
|
163
|
+
},
|
|
164
|
+
members: () => [...members.values()],
|
|
165
|
+
self: () => me,
|
|
166
|
+
leave() {
|
|
167
|
+
left = true;
|
|
168
|
+
wantJoin = false;
|
|
169
|
+
queue.length = 0;
|
|
170
|
+
members.clear();
|
|
171
|
+
me = null;
|
|
172
|
+
ws?.close(1000, "left");
|
|
173
|
+
ws = null;
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type EstraAuth } from "../auth.js";
|
|
2
|
+
import { type Transport } from "../transport.js";
|
|
3
|
+
export interface FirebaseOptions {
|
|
4
|
+
/** The Estra server (required here; Firebase's own keys are accepted and ignored). */
|
|
5
|
+
estraUrl?: string;
|
|
6
|
+
apiKey?: string;
|
|
7
|
+
authDomain?: string;
|
|
8
|
+
projectId?: string;
|
|
9
|
+
storageBucket?: string;
|
|
10
|
+
messagingSenderId?: string;
|
|
11
|
+
appId?: string;
|
|
12
|
+
/** Tests / non-browser runtimes: the fetch and WebSocket to use. */
|
|
13
|
+
estraFetch?: typeof fetch;
|
|
14
|
+
estraWebSocket?: typeof WebSocket;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
export interface FirebaseApp {
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly options: FirebaseOptions;
|
|
20
|
+
readonly automaticDataCollectionEnabled: boolean;
|
|
21
|
+
/** Estra internals shared by getAuth / getFirestore. */
|
|
22
|
+
readonly _estra: {
|
|
23
|
+
url: string;
|
|
24
|
+
auth: EstraAuth;
|
|
25
|
+
transport: Transport;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export declare function initializeApp(options: FirebaseOptions, name?: string | {
|
|
29
|
+
name?: string;
|
|
30
|
+
}): FirebaseApp;
|
|
31
|
+
export declare const getApps: () => FirebaseApp[];
|
|
32
|
+
export declare function getApp(name?: string): FirebaseApp;
|
|
33
|
+
export declare function deleteApp(app: FirebaseApp): Promise<void>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Firebase-compatible `firebase/app` for apps moving to Estra by import swap (a bundler alias of
|
|
2
|
+
// firebase/app → @estra/client/compat/app, and likewise auth, firestore, storage). One app holds
|
|
3
|
+
// the Estra URL, the signed-in session and the transport, so getAuth(app) and getFirestore(app)
|
|
4
|
+
// share the user's token, as Firebase's do.
|
|
5
|
+
//
|
|
6
|
+
// initializeApp({ estraUrl: "https://api.example.com", ...the old firebaseConfig })
|
|
7
|
+
//
|
|
8
|
+
// The transport is offline() over HTTP with the in-memory store: latency compensation and
|
|
9
|
+
// hasPendingWrites as with Firestore's default memory cache.
|
|
10
|
+
import { createAuth } from "../auth.js";
|
|
11
|
+
import { offline } from "../offline.js";
|
|
12
|
+
import { EstraClientError, httpTransport } from "../transport.js";
|
|
13
|
+
const DEFAULT = "[DEFAULT]";
|
|
14
|
+
const apps = new Map();
|
|
15
|
+
export function initializeApp(options, name = DEFAULT) {
|
|
16
|
+
const n = typeof name === "string" ? name : (name.name ?? DEFAULT);
|
|
17
|
+
if (apps.has(n))
|
|
18
|
+
throw new EstraClientError("app/duplicate-app", `Firebase App named '${n}' already exists`);
|
|
19
|
+
const url = options.estraUrl;
|
|
20
|
+
if (!url)
|
|
21
|
+
throw new EstraClientError("app/invalid-app-argument", "initializeApp needs estraUrl (the Estra server URL)");
|
|
22
|
+
const f = options.estraFetch;
|
|
23
|
+
const auth = createAuth({ url, ...(f ? { fetch: f } : {}) });
|
|
24
|
+
const transport = offline(httpTransport({ url, getToken: () => auth.getIdToken(), ...(f ? { fetch: f } : {}), ...(options.estraWebSocket ? { WebSocket: options.estraWebSocket } : {}) }));
|
|
25
|
+
const app = { name: n, options, automaticDataCollectionEnabled: false, _estra: { url, auth, transport } };
|
|
26
|
+
apps.set(n, app);
|
|
27
|
+
return app;
|
|
28
|
+
}
|
|
29
|
+
export const getApps = () => [...apps.values()];
|
|
30
|
+
export function getApp(name = DEFAULT) {
|
|
31
|
+
return apps.get(name) ?? (() => {
|
|
32
|
+
throw new EstraClientError("app/no-app", `No Firebase App '${name}' has been created - call initializeApp() first`);
|
|
33
|
+
})();
|
|
34
|
+
}
|
|
35
|
+
export async function deleteApp(app) {
|
|
36
|
+
apps.delete(app.name);
|
|
37
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { AuthUser, EstraAuth } from "../auth.js";
|
|
2
|
+
import { type FirebaseApp } from "./app.js";
|
|
3
|
+
export type Unsubscribe = () => void;
|
|
4
|
+
/** Firebase's error shape: `name: "FirebaseError"`, `code: "auth/…"`. */
|
|
5
|
+
export declare class FirebaseError extends Error {
|
|
6
|
+
readonly code: string;
|
|
7
|
+
readonly customData?: Record<string, unknown>;
|
|
8
|
+
constructor(code: string, message: string);
|
|
9
|
+
}
|
|
10
|
+
export interface UserInfo {
|
|
11
|
+
readonly uid: string;
|
|
12
|
+
readonly providerId: string;
|
|
13
|
+
readonly email: string | null;
|
|
14
|
+
readonly displayName: string | null;
|
|
15
|
+
readonly photoURL: string | null;
|
|
16
|
+
readonly phoneNumber: string | null;
|
|
17
|
+
}
|
|
18
|
+
export declare class User implements UserInfo {
|
|
19
|
+
#private;
|
|
20
|
+
readonly providerId = "firebase";
|
|
21
|
+
readonly phoneNumber: null;
|
|
22
|
+
readonly tenantId: null;
|
|
23
|
+
constructor(auth: Auth, u: AuthUser);
|
|
24
|
+
get uid(): string;
|
|
25
|
+
get email(): string | null;
|
|
26
|
+
get emailVerified(): boolean;
|
|
27
|
+
get displayName(): string | null;
|
|
28
|
+
get photoURL(): string | null;
|
|
29
|
+
get isAnonymous(): boolean;
|
|
30
|
+
get providerData(): UserInfo[];
|
|
31
|
+
get metadata(): {
|
|
32
|
+
creationTime: string;
|
|
33
|
+
lastSignInTime: string | undefined;
|
|
34
|
+
};
|
|
35
|
+
/** @internal */ _update(u: AuthUser): void;
|
|
36
|
+
getIdToken(forceRefresh?: boolean): Promise<string>;
|
|
37
|
+
getIdTokenResult(forceRefresh?: boolean): Promise<{
|
|
38
|
+
token: string;
|
|
39
|
+
claims: Record<string, unknown>;
|
|
40
|
+
authTime: string;
|
|
41
|
+
issuedAtTime: string;
|
|
42
|
+
expirationTime: string;
|
|
43
|
+
signInProvider: null;
|
|
44
|
+
signInSecondFactor: null;
|
|
45
|
+
}>;
|
|
46
|
+
reload(): Promise<void>;
|
|
47
|
+
delete(): Promise<void>;
|
|
48
|
+
toJSON(): {
|
|
49
|
+
uid: string;
|
|
50
|
+
email: string | null;
|
|
51
|
+
emailVerified: boolean;
|
|
52
|
+
displayName: string | null;
|
|
53
|
+
photoURL: string | null;
|
|
54
|
+
isAnonymous: boolean;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export interface UserCredential {
|
|
58
|
+
user: User;
|
|
59
|
+
providerId: string | null;
|
|
60
|
+
operationType: "signIn" | "link" | "reauthenticate";
|
|
61
|
+
/** @internal */ _isNewUser: boolean;
|
|
62
|
+
}
|
|
63
|
+
export declare class Auth {
|
|
64
|
+
#private;
|
|
65
|
+
readonly app: FirebaseApp;
|
|
66
|
+
readonly name: string;
|
|
67
|
+
readonly _estra: EstraAuth;
|
|
68
|
+
languageCode: string | null;
|
|
69
|
+
constructor(app: FirebaseApp);
|
|
70
|
+
get currentUser(): User | null;
|
|
71
|
+
/** @internal */ _wrap(u: AuthUser | null): User | null;
|
|
72
|
+
authStateReady(): Promise<void>;
|
|
73
|
+
onAuthStateChanged(next: (u: User | null) => void): Unsubscribe;
|
|
74
|
+
signOut(): Promise<void>;
|
|
75
|
+
}
|
|
76
|
+
export declare function getAuth(app?: FirebaseApp): Auth;
|
|
77
|
+
export declare const initializeAuth: (app: FirebaseApp, _deps?: unknown) => Auth;
|
|
78
|
+
export declare const browserLocalPersistence: {
|
|
79
|
+
readonly type: "LOCAL";
|
|
80
|
+
};
|
|
81
|
+
export declare const browserSessionPersistence: {
|
|
82
|
+
readonly type: "SESSION";
|
|
83
|
+
};
|
|
84
|
+
export declare const inMemoryPersistence: {
|
|
85
|
+
readonly type: "NONE";
|
|
86
|
+
};
|
|
87
|
+
export declare const indexedDBLocalPersistence: {
|
|
88
|
+
readonly type: "LOCAL";
|
|
89
|
+
};
|
|
90
|
+
export declare const setPersistence: (_auth: Auth, _p: unknown) => Promise<void>;
|
|
91
|
+
export declare function onAuthStateChanged(auth: Auth, next: ((u: User | null) => void) | {
|
|
92
|
+
next?: (u: User | null) => void;
|
|
93
|
+
}, _error?: (e: Error) => void): Unsubscribe;
|
|
94
|
+
export declare const onIdTokenChanged: typeof onAuthStateChanged;
|
|
95
|
+
export declare const beforeAuthStateChanged: (_auth: Auth, _cb: unknown) => Unsubscribe;
|
|
96
|
+
export declare const signInWithEmailAndPassword: (auth: Auth, email: string, password: string) => Promise<UserCredential>;
|
|
97
|
+
export declare const createUserWithEmailAndPassword: (auth: Auth, email: string, password: string) => Promise<UserCredential>;
|
|
98
|
+
export declare const signInAnonymously: (auth: Auth) => Promise<UserCredential>;
|
|
99
|
+
export declare const signOut: (auth: Auth) => Promise<void>;
|
|
100
|
+
export declare const sendPasswordResetEmail: (auth: Auth, email: string, _settings?: unknown) => Promise<undefined>;
|
|
101
|
+
export declare const confirmPasswordReset: (auth: Auth, oobCode: string, newPassword: string) => Promise<undefined>;
|
|
102
|
+
export declare const sendSignInLinkToEmail: (auth: Auth, email: string, _settings?: unknown) => Promise<undefined>;
|
|
103
|
+
export declare const signInWithEmailLink: (auth: Auth, _email: string, link: string) => Promise<UserCredential>;
|
|
104
|
+
export declare const isSignInWithEmailLink: (_auth: Auth, link: string) => boolean;
|
|
105
|
+
export declare const sendEmailVerification: (user: User, _settings?: unknown) => Promise<undefined>;
|
|
106
|
+
export declare const applyActionCode: (auth: Auth, oobCode: string) => Promise<void>;
|
|
107
|
+
export declare const updateProfile: (user: User, p: {
|
|
108
|
+
displayName?: string | null;
|
|
109
|
+
photoURL?: string | null;
|
|
110
|
+
}) => Promise<void>;
|
|
111
|
+
export declare const updatePassword: (user: User, newPassword: string) => Promise<undefined>;
|
|
112
|
+
export declare const deleteUser: (user: User) => Promise<void>;
|
|
113
|
+
type Pid = "google.com" | "apple.com" | "github.com";
|
|
114
|
+
declare class ProviderBase {
|
|
115
|
+
readonly providerId: Pid;
|
|
116
|
+
constructor(id: Pid);
|
|
117
|
+
addScope(_s: string): this;
|
|
118
|
+
setCustomParameters(_p: Record<string, string>): this;
|
|
119
|
+
}
|
|
120
|
+
export declare class GoogleAuthProvider extends ProviderBase {
|
|
121
|
+
static readonly PROVIDER_ID = "google.com";
|
|
122
|
+
constructor();
|
|
123
|
+
}
|
|
124
|
+
export declare class GithubAuthProvider extends ProviderBase {
|
|
125
|
+
static readonly PROVIDER_ID = "github.com";
|
|
126
|
+
constructor();
|
|
127
|
+
}
|
|
128
|
+
export declare class OAuthProvider extends ProviderBase {
|
|
129
|
+
constructor(id: string);
|
|
130
|
+
}
|
|
131
|
+
export declare const signInWithPopup: (auth: Auth, provider: ProviderBase, _resolver?: unknown) => Promise<UserCredential>;
|
|
132
|
+
export declare const linkWithPopup: (user: User, provider: ProviderBase) => Promise<UserCredential>;
|
|
133
|
+
export declare const reauthenticateWithPopup: (user: User, provider: ProviderBase) => Promise<UserCredential>;
|
|
134
|
+
export declare const signInWithRedirect: (auth: Auth, provider: ProviderBase) => Promise<void>;
|
|
135
|
+
export declare const getRedirectResult: (auth: Auth) => Promise<UserCredential | null>;
|
|
136
|
+
export declare function getAdditionalUserInfo(cred: UserCredential): {
|
|
137
|
+
isNewUser: boolean;
|
|
138
|
+
providerId: string | null;
|
|
139
|
+
profile: Record<string, unknown> | null;
|
|
140
|
+
} | null;
|
|
141
|
+
export {};
|