@lowdefy/client 0.0.0-experimental-20260720072521 → 0.0.0-experimental-20260720135014
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/Client.js +2 -24
- package/dist/Context.js +0 -11
- package/dist/auth/createAuthMethods.js +27 -433
- package/dist/block/Block.js +1 -1
- package/dist/initLowdefyContext.js +4 -4
- package/package.json +12 -12
- package/dist/adapters/Head.js +0 -32
- package/dist/adapters/Link.js +0 -63
- package/dist/adapters/createRouter.js +0 -115
- package/dist/adapters/url.js +0 -35
- package/dist/websocket/createWebSocketClient.js +0 -273
package/dist/Client.js
CHANGED
|
@@ -24,29 +24,7 @@ const Client = ({ auth, Components, config: rawConfig, jsMap, lowdefy, resetCont
|
|
|
24
24
|
reset: false,
|
|
25
25
|
setReset: ()=>undefined
|
|
26
26
|
}, router, stage, types, window })=>{
|
|
27
|
-
|
|
28
|
-
// engine memoizes dynamic page contexts by config object identity, so the
|
|
29
|
-
// deserialized config must be stable across re-renders and only change when
|
|
30
|
-
// a new page fetch delivers new config.
|
|
31
|
-
const config = React.useMemo(()=>serializer.deserialize(rawConfig), [
|
|
32
|
-
rawConfig.pageConfig,
|
|
33
|
-
rawConfig.rootConfig
|
|
34
|
-
]);
|
|
35
|
-
// Dynamic pages rebuild their engine context per fetch — remount the page
|
|
36
|
-
// tree with the rebuilt context instead of reconciling mounted blocks
|
|
37
|
-
// against it, which React can silently drop. Static pages keep their
|
|
38
|
-
// context and tree across navigations, so their key stays stable.
|
|
39
|
-
const buildRef = React.useRef({
|
|
40
|
-
config: null,
|
|
41
|
-
build: 0
|
|
42
|
-
});
|
|
43
|
-
if (buildRef.current.config !== config) {
|
|
44
|
-
buildRef.current = {
|
|
45
|
-
config,
|
|
46
|
-
build: buildRef.current.build + 1
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
const contextKey = config.pageConfig.dynamic === true ? `${config.pageConfig.id}:${buildRef.current.build}` : config.pageConfig.id;
|
|
27
|
+
const config = serializer.deserialize(rawConfig);
|
|
50
28
|
initLowdefyContext({
|
|
51
29
|
auth,
|
|
52
30
|
Components,
|
|
@@ -73,7 +51,7 @@ const Client = ({ auth, Components, config: rawConfig, jsMap, lowdefy, resetCont
|
|
|
73
51
|
}
|
|
74
52
|
}
|
|
75
53
|
}), /*#__PURE__*/ React.createElement(Context, {
|
|
76
|
-
key:
|
|
54
|
+
key: config.pageConfig.id,
|
|
77
55
|
config: config.pageConfig,
|
|
78
56
|
jsMap: jsMap,
|
|
79
57
|
lowdefy: lowdefy,
|
package/dist/Context.js
CHANGED
|
@@ -26,15 +26,6 @@ const ShortcutEffect = ({ context })=>{
|
|
|
26
26
|
]);
|
|
27
27
|
return null;
|
|
28
28
|
};
|
|
29
|
-
const WebSocketsEffect = ({ context })=>{
|
|
30
|
-
useEffect(()=>{
|
|
31
|
-
context._internal.WebSockets.subscribeAll();
|
|
32
|
-
return ()=>context._internal.WebSockets.unsubscribeAll();
|
|
33
|
-
}, [
|
|
34
|
-
context
|
|
35
|
-
]);
|
|
36
|
-
return null;
|
|
37
|
-
};
|
|
38
29
|
const Context = ({ children, config, jsMap, lowdefy, resetContext })=>{
|
|
39
30
|
const context = getContext({
|
|
40
31
|
config,
|
|
@@ -62,8 +53,6 @@ const Context = ({ children, config, jsMap, lowdefy, resetContext })=>{
|
|
|
62
53
|
if (loadingOnInit) return '';
|
|
63
54
|
return /*#__PURE__*/ React.createElement(React.Fragment, null, /*#__PURE__*/ React.createElement(ShortcutEffect, {
|
|
64
55
|
context: context
|
|
65
|
-
}), /*#__PURE__*/ React.createElement(WebSocketsEffect, {
|
|
66
|
-
context: context
|
|
67
56
|
}), children(context));
|
|
68
57
|
});
|
|
69
58
|
};
|
|
@@ -15,13 +15,12 @@
|
|
|
15
15
|
*/ import { type, urlQuery as urlQueryFn } from '@lowdefy/helpers';
|
|
16
16
|
function getCallbackUrl({ lowdefy, callbackUrl = {} }) {
|
|
17
17
|
const { home, pageId, urlQuery, url } = callbackUrl;
|
|
18
|
-
|
|
19
|
-
home,
|
|
20
|
-
pageId,
|
|
21
|
-
url
|
|
22
|
-
].filter((
|
|
23
|
-
|
|
24
|
-
throw new Error(`Invalid callbackUrl: To avoid ambiguity, only one of 'home', 'pageId' or 'url' can be defined.`);
|
|
18
|
+
if ([
|
|
19
|
+
!home,
|
|
20
|
+
!pageId,
|
|
21
|
+
!url
|
|
22
|
+
].filter((v)=>!v).length > 1) {
|
|
23
|
+
throw new Error(`Invalid Link: To avoid ambiguity, only one of 'home', 'pageId' or 'url' can be defined.`);
|
|
25
24
|
}
|
|
26
25
|
const query = type.isNone(urlQuery) ? '' : `${urlQueryFn.stringify(urlQuery)}`;
|
|
27
26
|
if (home === true) {
|
|
@@ -35,442 +34,37 @@ function getCallbackUrl({ lowdefy, callbackUrl = {} }) {
|
|
|
35
34
|
}
|
|
36
35
|
return undefined;
|
|
37
36
|
}
|
|
38
|
-
// The action's callbackUrl param wins; otherwise honor the callbackUrl query
|
|
39
|
-
// param set by the unauthenticated page redirect, so login returns to the
|
|
40
|
-
// page the user asked for. Only relative paths are accepted from the query
|
|
41
|
-
// to avoid open redirects.
|
|
42
|
-
function resolveCallbackURL({ lowdefy, callbackUrl }) {
|
|
43
|
-
const explicit = getCallbackUrl({
|
|
44
|
-
lowdefy,
|
|
45
|
-
callbackUrl
|
|
46
|
-
});
|
|
47
|
-
if (!type.isNone(explicit)) {
|
|
48
|
-
if (explicit.startsWith('/')) {
|
|
49
|
-
return `${lowdefy.basePath ?? ''}${explicit}`;
|
|
50
|
-
}
|
|
51
|
-
return explicit;
|
|
52
|
-
}
|
|
53
|
-
const window = lowdefy._internal?.globals?.window;
|
|
54
|
-
const fromQuery = new URLSearchParams(window?.location?.search ?? '').get('callbackUrl');
|
|
55
|
-
if (type.isString(fromQuery) && fromQuery.startsWith('/')) {
|
|
56
|
-
return fromQuery;
|
|
57
|
-
}
|
|
58
|
-
return undefined;
|
|
59
|
-
}
|
|
60
|
-
// Maps the captchaToken action param onto the BetterAuth client's per-call
|
|
61
|
-
// fetch options as the x-captcha-response header - the header the captcha
|
|
62
|
-
// middleware reads. One helper so no auth method implements its own header
|
|
63
|
-
// plumbing; the token never rides in the request body. Tokens are single-use
|
|
64
|
-
// and short-lived: a failed submit consumes the token, so retry chains reset
|
|
65
|
-
// the Captcha block for a fresh one.
|
|
66
|
-
function captchaFetchOptions(captchaToken) {
|
|
67
|
-
if (type.isNone(captchaToken)) {
|
|
68
|
-
return {};
|
|
69
|
-
}
|
|
70
|
-
return {
|
|
71
|
-
fetchOptions: {
|
|
72
|
-
headers: {
|
|
73
|
-
'x-captcha-response': captchaToken
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
// BetterAuth client calls resolve with { data, error } instead of throwing -
|
|
79
|
-
// rethrow so action onError chains fire on failed sign-in attempts.
|
|
80
|
-
async function unwrap(promise) {
|
|
81
|
-
const { data, error } = await promise;
|
|
82
|
-
if (error) {
|
|
83
|
-
const authError = new Error(error.message ?? error.statusText ?? 'Authentication failed.');
|
|
84
|
-
authError.code = error.code;
|
|
85
|
-
authError.status = error.status;
|
|
86
|
-
throw authError;
|
|
87
|
-
}
|
|
88
|
-
return data;
|
|
89
|
-
}
|
|
90
37
|
function createAuthMethods(lowdefy, auth) {
|
|
91
|
-
// login and logout are Lowdefy
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
callbackUrl
|
|
97
|
-
});
|
|
98
|
-
const captchaOptions = captchaFetchOptions(captchaToken);
|
|
99
|
-
const providers = auth.authConfig?.providers ?? [];
|
|
100
|
-
if (type.isNone(providerId) && type.isNone(email) && type.isNone(phoneNumber) && providers.length === 1) {
|
|
101
|
-
providerId = providers[0].id;
|
|
102
|
-
}
|
|
103
|
-
if (!type.isNone(providerId)) {
|
|
104
|
-
const provider = providers.find((configured)=>configured.id === providerId);
|
|
105
|
-
if (type.isNone(provider)) {
|
|
106
|
-
throw new Error(`Login provider "${providerId}" is not a configured auth provider.`);
|
|
107
|
-
}
|
|
108
|
-
if (provider.type === 'GenericOAuth') {
|
|
109
|
-
return unwrap(auth.signInOauth2({
|
|
110
|
-
providerId,
|
|
111
|
-
callbackURL,
|
|
112
|
-
...rest,
|
|
113
|
-
...captchaOptions
|
|
114
|
-
}));
|
|
115
|
-
}
|
|
116
|
-
return unwrap(auth.signInSocial({
|
|
117
|
-
provider: provider.type.toLowerCase(),
|
|
118
|
-
callbackURL,
|
|
119
|
-
...rest,
|
|
120
|
-
...captchaOptions
|
|
121
|
-
}));
|
|
122
|
-
}
|
|
123
|
-
if (magicLink === true) {
|
|
124
|
-
if (!type.isString(email)) {
|
|
125
|
-
throw new Error('Login with magicLink requires an "email" param.');
|
|
126
|
-
}
|
|
127
|
-
return unwrap(auth.signInMagicLink({
|
|
128
|
-
email,
|
|
129
|
-
callbackURL,
|
|
130
|
-
...rest,
|
|
131
|
-
...captchaOptions
|
|
132
|
-
}));
|
|
133
|
-
}
|
|
134
|
-
if (!type.isNone(phoneNumber)) {
|
|
135
|
-
if (!type.isString(password)) {
|
|
136
|
-
throw new Error('Login with phoneNumber requires a "password" param.');
|
|
137
|
-
}
|
|
138
|
-
const data = await unwrap(auth.signInPhoneNumber({
|
|
139
|
-
phoneNumber,
|
|
140
|
-
password,
|
|
141
|
-
...rest,
|
|
142
|
-
...captchaOptions
|
|
143
|
-
}));
|
|
144
|
-
if (data?.twoFactorRedirect) {
|
|
145
|
-
return data;
|
|
146
|
-
}
|
|
147
|
-
const window = lowdefy._internal?.globals?.window;
|
|
148
|
-
if (callbackURL && window) {
|
|
149
|
-
window.location.assign(callbackURL);
|
|
150
|
-
}
|
|
151
|
-
return data;
|
|
38
|
+
// login and logout are Lowdefy function that handle action params
|
|
39
|
+
// signIn and signOut are the next-auth methods
|
|
40
|
+
function login({ authUrl, callbackUrl, providerId, ...rest } = {}) {
|
|
41
|
+
if (type.isNone(providerId) && auth.authConfig?.providers.length === 1) {
|
|
42
|
+
providerId = auth.authConfig.providers[0].id;
|
|
152
43
|
}
|
|
153
|
-
|
|
154
|
-
const data = await unwrap(auth.signInEmail({
|
|
155
|
-
email,
|
|
156
|
-
password,
|
|
157
|
-
...rest,
|
|
158
|
-
...captchaOptions
|
|
159
|
-
}));
|
|
160
|
-
// A 2FA-enrolled user gets a challenge, not a session - do not navigate
|
|
161
|
-
// as if signed in. The login page reads the outcome via _actions and
|
|
162
|
-
// routes to the app's challenge page, where TwoFactorVerify completes
|
|
163
|
-
// the session.
|
|
164
|
-
if (data?.twoFactorRedirect) {
|
|
165
|
-
return data;
|
|
166
|
-
}
|
|
167
|
-
const window = lowdefy._internal?.globals?.window;
|
|
168
|
-
if (callbackURL && window) {
|
|
169
|
-
window.location.assign(callbackURL);
|
|
170
|
-
}
|
|
171
|
-
return data;
|
|
172
|
-
}
|
|
173
|
-
throw new Error('Login requires a "providerId", "email" and "password", "phoneNumber" and "password", or "magicLink: true" param.');
|
|
174
|
-
}
|
|
175
|
-
// Creates an email/password account (BetterAuth's one signup endpoint).
|
|
176
|
-
// Social, magic-link and passkey have no separate signup - the account is
|
|
177
|
-
// created on first sign-in via login - so SignUp is email/password only.
|
|
178
|
-
async function signUp({ callbackUrl, captchaToken, email, name, password, ...rest } = {}) {
|
|
179
|
-
const callbackURL = resolveCallbackURL({
|
|
180
|
-
lowdefy,
|
|
181
|
-
callbackUrl
|
|
182
|
-
});
|
|
183
|
-
const data = await unwrap(auth.signUpEmail({
|
|
184
|
-
email,
|
|
185
|
-
password,
|
|
186
|
-
name,
|
|
187
|
-
callbackURL,
|
|
44
|
+
return auth.signIn(providerId, {
|
|
188
45
|
...rest,
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
callbackUrl
|
|
46
|
+
callbackUrl: getCallbackUrl({
|
|
47
|
+
lowdefy,
|
|
48
|
+
callbackUrl
|
|
49
|
+
})
|
|
50
|
+
}, authUrl?.urlQuery);
|
|
51
|
+
}
|
|
52
|
+
function logout({ callbackUrl, redirect } = {}) {
|
|
53
|
+
return auth.signOut({
|
|
54
|
+
callbackUrl: getCallbackUrl({
|
|
55
|
+
lowdefy,
|
|
56
|
+
callbackUrl
|
|
57
|
+
}),
|
|
58
|
+
redirect
|
|
203
59
|
});
|
|
204
|
-
const window = lowdefy._internal?.globals?.window;
|
|
205
|
-
const willNavigate = Boolean(callbackURL && window);
|
|
206
|
-
if (willNavigate && auth.suppressSignOutReload) {
|
|
207
|
-
// The sign-out reload in the session provider would race the callback
|
|
208
|
-
// navigation - suppress it for this sign-out.
|
|
209
|
-
auth.suppressSignOutReload();
|
|
210
|
-
}
|
|
211
|
-
const data = await unwrap(auth.signOut());
|
|
212
|
-
if (willNavigate) {
|
|
213
|
-
// Prefix basePath only onto app-relative callbacks - absolute URLs
|
|
214
|
-
// (external logout landing pages) navigate as given.
|
|
215
|
-
const target = callbackURL.startsWith('/') ? `${lowdefy.basePath ?? ''}${callbackURL}` : callbackURL;
|
|
216
|
-
window.location.assign(target);
|
|
217
|
-
}
|
|
218
|
-
return data;
|
|
219
|
-
}
|
|
220
|
-
// Switches the session's active organization - the session-scoped org
|
|
221
|
-
// switch. Roles and attributes resolve from the new active member row
|
|
222
|
-
// server-side; chain UpdateSession after to re-sync the client.
|
|
223
|
-
async function setActiveOrganization({ organizationId, organizationSlug } = {}) {
|
|
224
|
-
if (type.isNone(organizationId) && type.isNone(organizationSlug)) {
|
|
225
|
-
throw new Error('SetActiveOrganization requires an "organizationId" or "organizationSlug" param.');
|
|
226
|
-
}
|
|
227
|
-
return unwrap(auth.setActiveOrganization({
|
|
228
|
-
organizationId,
|
|
229
|
-
organizationSlug
|
|
230
|
-
}));
|
|
231
|
-
}
|
|
232
|
-
// Impersonates a user for the session's remaining lifetime. Authorization
|
|
233
|
-
// is BetterAuth's own admin access control, enforced server-side against
|
|
234
|
-
// the caller's role - this method adds no gate of its own. Chain
|
|
235
|
-
// UpdateSession after to re-sync the client with the impersonated user.
|
|
236
|
-
async function impersonateUser({ userId } = {}) {
|
|
237
|
-
if (!type.isString(userId)) {
|
|
238
|
-
throw new Error('ImpersonateUser requires a "userId" param.');
|
|
239
|
-
}
|
|
240
|
-
return unwrap(auth.impersonateUser({
|
|
241
|
-
userId
|
|
242
|
-
}));
|
|
243
|
-
}
|
|
244
|
-
// Ends impersonation and restores the original session. Chain
|
|
245
|
-
// UpdateSession after to re-sync the client with the original user.
|
|
246
|
-
async function stopImpersonating() {
|
|
247
|
-
return unwrap(auth.stopImpersonating());
|
|
248
60
|
}
|
|
249
|
-
// Bypasses the cookie cache (a live re-resolve), so role, attribute or
|
|
250
|
-
// session changes surface immediately instead of after cookieCache.maxAge.
|
|
251
|
-
// Roles and merged attributes come from the server-resolved caller - the
|
|
252
|
-
// active member row read the base session does not carry.
|
|
253
61
|
async function updateSession() {
|
|
254
|
-
await
|
|
255
|
-
|
|
256
|
-
}));
|
|
257
|
-
const { user } = await auth.getResolvedUser();
|
|
258
|
-
if (auth.updateResolvedUser) {
|
|
259
|
-
auth.updateResolvedUser(user ?? null);
|
|
260
|
-
}
|
|
261
|
-
lowdefy.user = user ?? null;
|
|
262
|
-
}
|
|
263
|
-
// Accepts an organization invitation. BetterAuth's endpoint enforces the
|
|
264
|
-
// session-email to invitation-email match - no re-check here.
|
|
265
|
-
async function acceptInvitation({ invitationId } = {}) {
|
|
266
|
-
if (!type.isString(invitationId)) {
|
|
267
|
-
throw new Error('AcceptInvitation requires an "invitationId" param.');
|
|
268
|
-
}
|
|
269
|
-
return unwrap(auth.acceptInvitation({
|
|
270
|
-
invitationId
|
|
271
|
-
}));
|
|
272
|
-
}
|
|
273
|
-
async function changePassword({ currentPassword, newPassword, revokeOtherSessions } = {}) {
|
|
274
|
-
if (!type.isString(currentPassword) || !type.isString(newPassword)) {
|
|
275
|
-
throw new Error('ChangePassword requires "currentPassword" and "newPassword" params.');
|
|
276
|
-
}
|
|
277
|
-
return unwrap(auth.changePassword({
|
|
278
|
-
currentPassword,
|
|
279
|
-
newPassword,
|
|
280
|
-
revokeOtherSessions
|
|
281
|
-
}));
|
|
282
|
-
}
|
|
283
|
-
async function passkeyDelete({ passkeyId } = {}) {
|
|
284
|
-
if (!type.isString(passkeyId)) {
|
|
285
|
-
throw new Error('PasskeyDelete requires a "passkeyId" param.');
|
|
286
|
-
}
|
|
287
|
-
return unwrap(auth.deletePasskey({
|
|
288
|
-
id: passkeyId
|
|
289
|
-
}));
|
|
290
|
-
}
|
|
291
|
-
// The BetterAuth client method runs the whole WebAuthn browser ceremony
|
|
292
|
-
// itself - it fetches the registration options, prompts the authenticator
|
|
293
|
-
// and verifies the result - so the ceremony runs inside the action.
|
|
294
|
-
async function passkeyRegister(params = {}) {
|
|
295
|
-
return unwrap(auth.addPasskey(params));
|
|
296
|
-
}
|
|
297
|
-
// Passkey sign-in: the BetterAuth client method runs the whole WebAuthn
|
|
298
|
-
// assertion ceremony itself (options fetch, navigator.credentials.get(),
|
|
299
|
-
// verification), so the ceremony runs inside the action. verify-authentication
|
|
300
|
-
// creates the session, so on success navigate to callbackUrl like login's
|
|
301
|
-
// email path. A successful assertion is terminal - passkey never returns a
|
|
302
|
-
// two-factor challenge - so there is no twoFactorRedirect branch.
|
|
303
|
-
async function passkeySignIn({ callbackUrl } = {}) {
|
|
304
|
-
const callbackURL = resolveCallbackURL({
|
|
305
|
-
lowdefy,
|
|
306
|
-
callbackUrl
|
|
307
|
-
});
|
|
308
|
-
const data = await unwrap(auth.signInPasskey());
|
|
309
|
-
const window = lowdefy._internal?.globals?.window;
|
|
310
|
-
if (callbackURL && window) {
|
|
311
|
-
window.location.assign(callbackURL);
|
|
312
|
-
}
|
|
313
|
-
return data;
|
|
314
|
-
}
|
|
315
|
-
// Dispatches by parameter, matching login: a phoneNumber param requests the
|
|
316
|
-
// reset code over SMS (the "phone.passwordReset.send" hook), otherwise
|
|
317
|
-
// email carries the reset link.
|
|
318
|
-
async function requestPasswordReset({ captchaToken, email, phoneNumber, redirectTo, ...rest } = {}) {
|
|
319
|
-
const captchaOptions = captchaFetchOptions(captchaToken);
|
|
320
|
-
if (type.isString(phoneNumber)) {
|
|
321
|
-
return unwrap(auth.phoneNumberRequestPasswordReset({
|
|
322
|
-
phoneNumber,
|
|
323
|
-
...rest,
|
|
324
|
-
...captchaOptions
|
|
325
|
-
}));
|
|
326
|
-
}
|
|
327
|
-
if (!type.isString(email)) {
|
|
328
|
-
throw new Error('RequestPasswordReset requires an "email" or "phoneNumber" param.');
|
|
329
|
-
}
|
|
330
|
-
return unwrap(auth.requestPasswordReset({
|
|
331
|
-
email,
|
|
332
|
-
redirectTo,
|
|
333
|
-
...rest,
|
|
334
|
-
...captchaOptions
|
|
335
|
-
}));
|
|
336
|
-
}
|
|
337
|
-
// Dispatches by parameter: a phoneNumber param resets with the SMS otp,
|
|
338
|
-
// otherwise token carries the emailed reset link's token.
|
|
339
|
-
async function resetPassword({ newPassword, otp, phoneNumber, token, ...rest } = {}) {
|
|
340
|
-
if (!type.isString(newPassword)) {
|
|
341
|
-
throw new Error('ResetPassword requires a "newPassword" param.');
|
|
342
|
-
}
|
|
343
|
-
if (type.isString(phoneNumber)) {
|
|
344
|
-
if (!type.isString(otp)) {
|
|
345
|
-
throw new Error('ResetPassword with phoneNumber requires an "otp" param.');
|
|
346
|
-
}
|
|
347
|
-
return unwrap(auth.phoneNumberResetPassword({
|
|
348
|
-
phoneNumber,
|
|
349
|
-
otp,
|
|
350
|
-
newPassword,
|
|
351
|
-
...rest
|
|
352
|
-
}));
|
|
353
|
-
}
|
|
354
|
-
return unwrap(auth.resetPassword({
|
|
355
|
-
newPassword,
|
|
356
|
-
token,
|
|
357
|
-
...rest
|
|
358
|
-
}));
|
|
359
|
-
}
|
|
360
|
-
// Revokes every session except the current one - the only session revoke
|
|
361
|
-
// exposed to config; per-session revoke would put session tokens in
|
|
362
|
-
// config reach.
|
|
363
|
-
async function revokeOtherSessions() {
|
|
364
|
-
return unwrap(auth.revokeOtherSessions());
|
|
365
|
-
}
|
|
366
|
-
// Resends the verification email for an unverified account - an unverified
|
|
367
|
-
// user holds no session, so this is a public call. The callbackUrl is
|
|
368
|
-
// where the emailed verification link lands after verifying, matching the
|
|
369
|
-
// signUp param of the same name.
|
|
370
|
-
async function sendVerificationEmail({ callbackUrl, captchaToken, email, ...rest } = {}) {
|
|
371
|
-
if (!type.isString(email)) {
|
|
372
|
-
throw new Error('SendVerificationEmail requires an "email" param.');
|
|
373
|
-
}
|
|
374
|
-
const callbackURL = resolveCallbackURL({
|
|
375
|
-
lowdefy,
|
|
376
|
-
callbackUrl
|
|
377
|
-
});
|
|
378
|
-
return unwrap(auth.sendVerificationEmail({
|
|
379
|
-
email,
|
|
380
|
-
callbackURL,
|
|
381
|
-
...rest,
|
|
382
|
-
...captchaFetchOptions(captchaToken)
|
|
383
|
-
}));
|
|
384
|
-
}
|
|
385
|
-
// Sends a sign-in/verification OTP over SMS through the app's
|
|
386
|
-
// "phone.otp.send" hook binding.
|
|
387
|
-
async function phoneNumberSendOtp({ captchaToken, phoneNumber, ...rest } = {}) {
|
|
388
|
-
if (!type.isString(phoneNumber)) {
|
|
389
|
-
throw new Error('PhoneNumberSendOtp requires a "phoneNumber" param.');
|
|
390
|
-
}
|
|
391
|
-
return unwrap(auth.phoneNumberSendOtp({
|
|
392
|
-
phoneNumber,
|
|
393
|
-
...rest,
|
|
394
|
-
...captchaFetchOptions(captchaToken)
|
|
395
|
-
}));
|
|
396
|
-
}
|
|
397
|
-
// The OTP sign-in: on success BetterAuth sets the session cookie (and
|
|
398
|
-
// creates the account under signUpOnVerification). Like TwoFactorVerify and
|
|
399
|
-
// unlike login it does not auto-navigate - verify serves sign-in, sign-up
|
|
400
|
-
// and phone-change confirmation, and only the app knows which page follows.
|
|
401
|
-
async function phoneNumberVerify({ code, phoneNumber, ...rest } = {}) {
|
|
402
|
-
if (!type.isString(phoneNumber) || !type.isString(code)) {
|
|
403
|
-
throw new Error('PhoneNumberVerify requires "phoneNumber" and "code" params.');
|
|
404
|
-
}
|
|
405
|
-
return unwrap(auth.phoneNumberVerify({
|
|
406
|
-
phoneNumber,
|
|
407
|
-
code,
|
|
408
|
-
...rest
|
|
409
|
-
}));
|
|
410
|
-
}
|
|
411
|
-
async function twoFactorDisable({ password, ...rest } = {}) {
|
|
412
|
-
if (!type.isString(password)) {
|
|
413
|
-
throw new Error('TwoFactorDisable requires a "password" param.');
|
|
414
|
-
}
|
|
415
|
-
return unwrap(auth.twoFactorDisable({
|
|
416
|
-
password,
|
|
417
|
-
...rest
|
|
418
|
-
}));
|
|
419
|
-
}
|
|
420
|
-
// Returns the totpURI and backup codes the page must render once - the
|
|
421
|
-
// action response is the only carrier (readable via _actions in the same
|
|
422
|
-
// event chain); no side-channel state.
|
|
423
|
-
async function twoFactorEnable({ password, ...rest } = {}) {
|
|
424
|
-
if (!type.isString(password)) {
|
|
425
|
-
throw new Error('TwoFactorEnable requires a "password" param.');
|
|
426
|
-
}
|
|
427
|
-
return unwrap(auth.twoFactorEnable({
|
|
428
|
-
password,
|
|
429
|
-
...rest
|
|
430
|
-
}));
|
|
431
|
-
}
|
|
432
|
-
// Serves both enrolment confirmation and the sign-in challenge, dispatching
|
|
433
|
-
// by parameter (matching login): a backupCode param verifies a backup code,
|
|
434
|
-
// otherwise code verifies TOTP. The sign-in challenge verify sets the
|
|
435
|
-
// session cookie itself - navigation after is the app's business.
|
|
436
|
-
async function twoFactorVerify({ backupCode, code, trustDevice, ...rest } = {}) {
|
|
437
|
-
if (type.isString(backupCode)) {
|
|
438
|
-
return unwrap(auth.twoFactorVerifyBackupCode({
|
|
439
|
-
code: backupCode,
|
|
440
|
-
trustDevice,
|
|
441
|
-
...rest
|
|
442
|
-
}));
|
|
443
|
-
}
|
|
444
|
-
if (type.isString(code)) {
|
|
445
|
-
return unwrap(auth.twoFactorVerifyTotp({
|
|
446
|
-
code,
|
|
447
|
-
trustDevice,
|
|
448
|
-
...rest
|
|
449
|
-
}));
|
|
450
|
-
}
|
|
451
|
-
throw new Error('TwoFactorVerify requires a "code" or "backupCode" param.');
|
|
62
|
+
const session = await auth.getSession();
|
|
63
|
+
lowdefy.user = session?.user ?? null;
|
|
452
64
|
}
|
|
453
65
|
return {
|
|
454
|
-
acceptInvitation,
|
|
455
|
-
changePassword,
|
|
456
|
-
impersonateUser,
|
|
457
66
|
login,
|
|
458
67
|
logout,
|
|
459
|
-
passkeyDelete,
|
|
460
|
-
passkeyRegister,
|
|
461
|
-
passkeySignIn,
|
|
462
|
-
phoneNumberSendOtp,
|
|
463
|
-
phoneNumberVerify,
|
|
464
|
-
requestPasswordReset,
|
|
465
|
-
resetPassword,
|
|
466
|
-
revokeOtherSessions,
|
|
467
|
-
sendVerificationEmail,
|
|
468
|
-
setActiveOrganization,
|
|
469
|
-
signUp,
|
|
470
|
-
stopImpersonating,
|
|
471
|
-
twoFactorDisable,
|
|
472
|
-
twoFactorEnable,
|
|
473
|
-
twoFactorVerify,
|
|
474
68
|
updateSession
|
|
475
69
|
};
|
|
476
70
|
}
|
package/dist/block/Block.js
CHANGED
|
@@ -19,7 +19,7 @@ import MountEvents from '../MountEvents.js';
|
|
|
19
19
|
const Block = ({ block, Blocks, context, lowdefy, parentLoading })=>{
|
|
20
20
|
const [updates, setUpdate] = useState(0);
|
|
21
21
|
const loggedErrorsRef = useRef(new Set());
|
|
22
|
-
|
|
22
|
+
lowdefy._internal.updaters[block.id] = ()=>setUpdate(updates + 1);
|
|
23
23
|
const handleError = (error)=>{
|
|
24
24
|
if (lowdefy._internal.handleError) {
|
|
25
25
|
lowdefy._internal.handleError(error);
|
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
import createCallAPI from './createCallAPI.js';
|
|
17
17
|
import createAuthMethods from './auth/createAuthMethods.js';
|
|
18
18
|
import createCallRequest from './createCallRequest.js';
|
|
19
|
-
import createWebSocketClient from './websocket/createWebSocketClient.js';
|
|
20
19
|
import createIcon from './createIcon.js';
|
|
21
20
|
import createShortcutBadge from './createShortcutBadge.js';
|
|
22
21
|
import createLinkComponent from './createLinkComponent.js';
|
|
@@ -51,7 +50,8 @@ function initLowdefyContext({ auth, Components, config, lowdefy, router, stage,
|
|
|
51
50
|
},
|
|
52
51
|
dispatch: ()=>undefined
|
|
53
52
|
},
|
|
54
|
-
router
|
|
53
|
+
router,
|
|
54
|
+
updaters: {}
|
|
55
55
|
};
|
|
56
56
|
lowdefy.apiResponses = {};
|
|
57
57
|
lowdefy.basePath = router.basePath;
|
|
@@ -63,7 +63,6 @@ function initLowdefyContext({ auth, Components, config, lowdefy, router, stage,
|
|
|
63
63
|
lowdefy._internal.callAPI = createCallAPI(lowdefy);
|
|
64
64
|
lowdefy._internal.auth = createAuthMethods(lowdefy, auth);
|
|
65
65
|
lowdefy._internal.callRequest = createCallRequest(lowdefy);
|
|
66
|
-
lowdefy._internal.websocketClient = createWebSocketClient(lowdefy);
|
|
67
66
|
lowdefy._internal.components.Link = createLinkComponent(lowdefy, Components.Link);
|
|
68
67
|
lowdefy._internal.link = setupLink(lowdefy);
|
|
69
68
|
lowdefy._internal.translate = (key, values)=>translate({
|
|
@@ -71,6 +70,7 @@ function initLowdefyContext({ auth, Components, config, lowdefy, router, stage,
|
|
|
71
70
|
values,
|
|
72
71
|
i18n: lowdefy.i18n
|
|
73
72
|
});
|
|
73
|
+
lowdefy._internal.updateBlock = (blockId)=>lowdefy._internal.updaters[blockId] && lowdefy._internal.updaters[blockId]();
|
|
74
74
|
lowdefy._internal.logger = createBrowserLogger();
|
|
75
75
|
lowdefy._internal.handleError = createHandleError(lowdefy);
|
|
76
76
|
lowdefy._internal.components.handleError = lowdefy._internal.handleError;
|
|
@@ -81,7 +81,7 @@ function initLowdefyContext({ auth, Components, config, lowdefy, router, stage,
|
|
|
81
81
|
lowdefy.home = config.rootConfig.home || {};
|
|
82
82
|
lowdefy.menus = config.rootConfig.menus;
|
|
83
83
|
lowdefy.pageId = config.pageConfig.pageId;
|
|
84
|
-
lowdefy.user = auth?.user ?? null;
|
|
84
|
+
lowdefy.user = auth?.session?.user ?? null;
|
|
85
85
|
return lowdefy;
|
|
86
86
|
}
|
|
87
87
|
export default initLowdefyContext;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lowdefy/client",
|
|
3
|
-
"version": "0.0.0-experimental-
|
|
3
|
+
"version": "0.0.0-experimental-20260720135014",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Lowdefy Client",
|
|
6
6
|
"homepage": "https://lowdefy.com",
|
|
@@ -35,26 +35,26 @@
|
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@ant-design/icons": "6.1.0",
|
|
37
37
|
"antd": "6.3.1",
|
|
38
|
-
"@lowdefy/block-utils": "0.0.0-experimental-
|
|
39
|
-
"@lowdefy/engine": "0.0.0-experimental-
|
|
40
|
-
"@lowdefy/errors": "0.0.0-experimental-
|
|
41
|
-
"@lowdefy/helpers": "0.0.0-experimental-
|
|
42
|
-
"@lowdefy/layout": "0.0.0-experimental-
|
|
43
|
-
"@lowdefy/logger": "0.0.0-experimental-
|
|
44
|
-
"dayjs": "1.11.
|
|
38
|
+
"@lowdefy/block-utils": "0.0.0-experimental-20260720135014",
|
|
39
|
+
"@lowdefy/engine": "0.0.0-experimental-20260720135014",
|
|
40
|
+
"@lowdefy/errors": "0.0.0-experimental-20260720135014",
|
|
41
|
+
"@lowdefy/helpers": "0.0.0-experimental-20260720135014",
|
|
42
|
+
"@lowdefy/layout": "0.0.0-experimental-20260720135014",
|
|
43
|
+
"@lowdefy/logger": "0.0.0-experimental-20260720135014",
|
|
44
|
+
"dayjs": "1.11.19",
|
|
45
45
|
"react": "18.2.0",
|
|
46
46
|
"react-dom": "18.2.0",
|
|
47
47
|
"tinykeys": "^3.0.0"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@jest/globals": "28.1.3",
|
|
51
|
-
"@lowdefy/jest-yaml-transform": "0.0.0-experimental-
|
|
52
|
-
"@swc/cli": "0.8.
|
|
53
|
-
"@swc/core": "1.15.
|
|
51
|
+
"@lowdefy/jest-yaml-transform": "0.0.0-experimental-20260720135014",
|
|
52
|
+
"@swc/cli": "0.8.0",
|
|
53
|
+
"@swc/core": "1.15.18",
|
|
54
54
|
"@swc/jest": "0.2.39",
|
|
55
55
|
"@testing-library/dom": "8.19.1",
|
|
56
56
|
"@testing-library/react": "13.4.0",
|
|
57
|
-
"@testing-library/user-event": "14.
|
|
57
|
+
"@testing-library/user-event": "14.4.3",
|
|
58
58
|
"copyfiles": "2.4.1",
|
|
59
59
|
"jest": "28.1.3",
|
|
60
60
|
"jest-environment-jsdom": "28.1.3",
|
package/dist/adapters/Head.js
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
-
|
|
4
|
-
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
-
you may not use this file except in compliance with the License.
|
|
6
|
-
You may obtain a copy of the License at
|
|
7
|
-
|
|
8
|
-
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
-
|
|
10
|
-
Unless required by applicable law or agreed to in writing, software
|
|
11
|
-
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
-
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
-
See the License for the specific language governing permissions and
|
|
14
|
-
limitations under the License.
|
|
15
|
-
*/ import React, { useEffect } from 'react';
|
|
16
|
-
// Replaces next/head for the single use in @lowdefy/client's Head.js:
|
|
17
|
-
// <Component><title>{properties.title}</title></Component>.
|
|
18
|
-
// Extracts the title text and sets document.title; renders nothing.
|
|
19
|
-
function Head({ children }) {
|
|
20
|
-
useEffect(()=>{
|
|
21
|
-
React.Children.forEach(children, (child)=>{
|
|
22
|
-
if (child?.type === 'title') {
|
|
23
|
-
const text = React.Children.toArray(child.props.children).join('');
|
|
24
|
-
if (text) {
|
|
25
|
-
document.title = text;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
32
|
-
export default Head;
|
package/dist/adapters/Link.js
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
-
|
|
4
|
-
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
-
you may not use this file except in compliance with the License.
|
|
6
|
-
You may obtain a copy of the License at
|
|
7
|
-
|
|
8
|
-
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
-
|
|
10
|
-
Unless required by applicable law or agreed to in writing, software
|
|
11
|
-
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
-
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
-
See the License for the specific language governing permissions and
|
|
14
|
-
limitations under the License.
|
|
15
|
-
*/ import React from 'react';
|
|
16
|
-
import { type } from '@lowdefy/helpers';
|
|
17
|
-
import { createUrl } from './url.js';
|
|
18
|
-
// Replaces next/link for the contract used by @lowdefy/client's
|
|
19
|
-
// createLinkComponent: href as { pathname, query } or string, replace,
|
|
20
|
-
// scroll, onClick fired before navigation. Modified clicks (new tab,
|
|
21
|
-
// middle click, download) fall through to native browser handling.
|
|
22
|
-
function createLinkComponent({ router }) {
|
|
23
|
-
function Link({ children, href, onClick, replace, scroll, ...props }) {
|
|
24
|
-
const hrefObject = type.isString(href) ? {
|
|
25
|
-
pathname: href
|
|
26
|
-
} : href ?? {};
|
|
27
|
-
const { pathname, query } = hrefObject;
|
|
28
|
-
const url = createUrl({
|
|
29
|
-
basePath: router.basePath,
|
|
30
|
-
pathname,
|
|
31
|
-
query
|
|
32
|
-
});
|
|
33
|
-
function handleClick(event) {
|
|
34
|
-
if (onClick) {
|
|
35
|
-
onClick(event);
|
|
36
|
-
}
|
|
37
|
-
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || props.target === '_blank') {
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
event.preventDefault();
|
|
41
|
-
if (replace) {
|
|
42
|
-
router.replace({
|
|
43
|
-
pathname,
|
|
44
|
-
query,
|
|
45
|
-
scroll
|
|
46
|
-
});
|
|
47
|
-
} else {
|
|
48
|
-
router.push({
|
|
49
|
-
pathname,
|
|
50
|
-
query,
|
|
51
|
-
scroll
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
return /*#__PURE__*/ React.createElement("a", {
|
|
56
|
-
...props,
|
|
57
|
-
href: url,
|
|
58
|
-
onClick: handleClick
|
|
59
|
-
}, children);
|
|
60
|
-
}
|
|
61
|
-
return Link;
|
|
62
|
-
}
|
|
63
|
-
export default createLinkComponent;
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
-
|
|
4
|
-
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
-
you may not use this file except in compliance with the License.
|
|
6
|
-
You may obtain a copy of the License at
|
|
7
|
-
|
|
8
|
-
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
-
|
|
10
|
-
Unless required by applicable law or agreed to in writing, software
|
|
11
|
-
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
-
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
-
See the License for the specific language governing permissions and
|
|
14
|
-
limitations under the License.
|
|
15
|
-
*/ import { createUrl, parsePageId } from './url.js';
|
|
16
|
-
// History-API router backing the @lowdefy/client router contract:
|
|
17
|
-
// push({ pathname, query }), back(), basePath — plus subscribe() for the
|
|
18
|
-
// page component and sessionStorage-backed scroll restoration.
|
|
19
|
-
function createRouter({ basePath = '', window }) {
|
|
20
|
-
const listeners = new Set();
|
|
21
|
-
let entryKey = window.history.state?.lowdefyKey ?? `k${Date.now().toString(36)}`;
|
|
22
|
-
if ('scrollRestoration' in window.history) {
|
|
23
|
-
window.history.scrollRestoration = 'manual';
|
|
24
|
-
}
|
|
25
|
-
if (!window.history.state?.lowdefyKey) {
|
|
26
|
-
window.history.replaceState({
|
|
27
|
-
lowdefyKey: entryKey
|
|
28
|
-
}, '', window.location.href);
|
|
29
|
-
}
|
|
30
|
-
function readScrollPositions() {
|
|
31
|
-
try {
|
|
32
|
-
return JSON.parse(window.sessionStorage.getItem('lowdefy_scroll') ?? '{}');
|
|
33
|
-
} catch (e) {
|
|
34
|
-
return {};
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
function saveScrollPosition() {
|
|
38
|
-
try {
|
|
39
|
-
const positions = readScrollPositions();
|
|
40
|
-
positions[entryKey] = {
|
|
41
|
-
x: window.scrollX,
|
|
42
|
-
y: window.scrollY
|
|
43
|
-
};
|
|
44
|
-
window.sessionStorage.setItem('lowdefy_scroll', JSON.stringify(positions));
|
|
45
|
-
} catch (e) {
|
|
46
|
-
// sessionStorage unavailable (private mode) — scroll restoration degrades gracefully.
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
function restoreScrollPosition() {
|
|
50
|
-
const position = readScrollPositions()[entryKey];
|
|
51
|
-
window.requestAnimationFrame(()=>{
|
|
52
|
-
window.scrollTo(position?.x ?? 0, position?.y ?? 0);
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
function getLocation() {
|
|
56
|
-
return {
|
|
57
|
-
pageId: parsePageId(window.location.href, basePath),
|
|
58
|
-
pathname: window.location.pathname,
|
|
59
|
-
search: window.location.search
|
|
60
|
-
};
|
|
61
|
-
}
|
|
62
|
-
function notify() {
|
|
63
|
-
const location = getLocation();
|
|
64
|
-
listeners.forEach((listener)=>listener(location));
|
|
65
|
-
}
|
|
66
|
-
function navigate({ forceReload, pathname, query, replace = false, scroll = true }) {
|
|
67
|
-
const url = createUrl({
|
|
68
|
-
basePath,
|
|
69
|
-
pathname,
|
|
70
|
-
query
|
|
71
|
-
});
|
|
72
|
-
if (forceReload) {
|
|
73
|
-
window.location.assign(url);
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
saveScrollPosition();
|
|
77
|
-
entryKey = `k${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
78
|
-
if (replace) {
|
|
79
|
-
window.history.replaceState({
|
|
80
|
-
lowdefyKey: entryKey
|
|
81
|
-
}, '', url);
|
|
82
|
-
} else {
|
|
83
|
-
window.history.pushState({
|
|
84
|
-
lowdefyKey: entryKey
|
|
85
|
-
}, '', url);
|
|
86
|
-
}
|
|
87
|
-
notify();
|
|
88
|
-
if (scroll) {
|
|
89
|
-
window.requestAnimationFrame(()=>{
|
|
90
|
-
window.scrollTo(0, 0);
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
window.addEventListener('popstate', (event)=>{
|
|
95
|
-
saveScrollPosition();
|
|
96
|
-
entryKey = event.state?.lowdefyKey ?? `k${Date.now().toString(36)}`;
|
|
97
|
-
notify();
|
|
98
|
-
restoreScrollPosition();
|
|
99
|
-
});
|
|
100
|
-
return {
|
|
101
|
-
basePath,
|
|
102
|
-
back: ()=>window.history.back(),
|
|
103
|
-
getLocation,
|
|
104
|
-
push: async (args)=>navigate(args),
|
|
105
|
-
replace: async (args)=>navigate({
|
|
106
|
-
...args,
|
|
107
|
-
replace: true
|
|
108
|
-
}),
|
|
109
|
-
subscribe: (listener)=>{
|
|
110
|
-
listeners.add(listener);
|
|
111
|
-
return ()=>listeners.delete(listener);
|
|
112
|
-
}
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
export default createRouter;
|
package/dist/adapters/url.js
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
-
|
|
4
|
-
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
-
you may not use this file except in compliance with the License.
|
|
6
|
-
You may obtain a copy of the License at
|
|
7
|
-
|
|
8
|
-
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
-
|
|
10
|
-
Unless required by applicable law or agreed to in writing, software
|
|
11
|
-
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
-
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
-
See the License for the specific language governing permissions and
|
|
14
|
-
limitations under the License.
|
|
15
|
-
*/ import { type } from '@lowdefy/helpers';
|
|
16
|
-
function serializeQuery(query) {
|
|
17
|
-
if (type.isNone(query) || query === '') {
|
|
18
|
-
return '';
|
|
19
|
-
}
|
|
20
|
-
if (type.isString(query)) {
|
|
21
|
-
return query;
|
|
22
|
-
}
|
|
23
|
-
return new URLSearchParams(query).toString();
|
|
24
|
-
}
|
|
25
|
-
function createUrl({ basePath = '', pathname, query }) {
|
|
26
|
-
const serializedQuery = serializeQuery(query);
|
|
27
|
-
return `${basePath}${pathname}${serializedQuery ? `?${serializedQuery}` : ''}`;
|
|
28
|
-
}
|
|
29
|
-
function parsePageId(url, basePath = '') {
|
|
30
|
-
const pathname = new URL(url, 'http://localhost').pathname;
|
|
31
|
-
const stripped = basePath && pathname.startsWith(basePath) ? pathname.slice(basePath.length) : pathname;
|
|
32
|
-
const pageId = stripped.replace(/^\//, '').replace(/\/$/, '');
|
|
33
|
-
return pageId === '' ? null : pageId;
|
|
34
|
-
}
|
|
35
|
-
export { createUrl, parsePageId, serializeQuery };
|
|
@@ -1,273 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
-
|
|
4
|
-
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
-
you may not use this file except in compliance with the License.
|
|
6
|
-
You may obtain a copy of the License at
|
|
7
|
-
|
|
8
|
-
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
-
|
|
10
|
-
Unless required by applicable law or agreed to in writing, software
|
|
11
|
-
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
-
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
-
See the License for the specific language governing permissions and
|
|
14
|
-
limitations under the License.
|
|
15
|
-
*/ const ACK_TIMEOUT_MS = 10 * 1000;
|
|
16
|
-
const IDLE_CLOSE_GRACE_MS = 5 * 1000;
|
|
17
|
-
const RECONNECT_BASE_MS = 500;
|
|
18
|
-
const RECONNECT_CAP_MS = 15 * 1000;
|
|
19
|
-
// One websocket per browser tab, shared by all channels, created lazily on
|
|
20
|
-
// the first subscribe or publish. Connections are ephemeral in production
|
|
21
|
-
// (serverless platforms close them at function max duration), so reconnect
|
|
22
|
-
// with backoff and resubscribe is the steady state, not an edge case.
|
|
23
|
-
function createWebSocketClient(lowdefy) {
|
|
24
|
-
const { window } = lowdefy._internal.globals;
|
|
25
|
-
// websocketId → { payload, handlers }
|
|
26
|
-
const subscriptions = new Map();
|
|
27
|
-
// websocketId → { resolve, reject, timer } for pending subscribe acks
|
|
28
|
-
const pendingSubscribes = new Map();
|
|
29
|
-
// requestId → { resolve, reject, timer } for pending publish acks
|
|
30
|
-
const pendingPublishes = new Map();
|
|
31
|
-
// Frames queued while the socket is (re)connecting.
|
|
32
|
-
const sendQueue = [];
|
|
33
|
-
let socket = null;
|
|
34
|
-
let openPromise = null;
|
|
35
|
-
let reconnectAttempt = 0;
|
|
36
|
-
let reconnectTimer = null;
|
|
37
|
-
let idleTimer = null;
|
|
38
|
-
let closedByClient = false;
|
|
39
|
-
let publishCounter = 0;
|
|
40
|
-
function url() {
|
|
41
|
-
const { location } = window;
|
|
42
|
-
const scheme = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
43
|
-
return `${scheme}://${location.host}${lowdefy.basePath ?? ''}/api/websocket`;
|
|
44
|
-
}
|
|
45
|
-
function clearIdleTimer() {
|
|
46
|
-
if (idleTimer) {
|
|
47
|
-
clearTimeout(idleTimer);
|
|
48
|
-
idleTimer = null;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
function scheduleIdleClose() {
|
|
52
|
-
clearIdleTimer();
|
|
53
|
-
if (subscriptions.size > 0) {
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
// Grace period so rapid page navigation doesn't thrash connections.
|
|
57
|
-
idleTimer = setTimeout(()=>{
|
|
58
|
-
if (subscriptions.size === 0 && socket) {
|
|
59
|
-
closedByClient = true;
|
|
60
|
-
socket.close();
|
|
61
|
-
socket = null;
|
|
62
|
-
openPromise = null;
|
|
63
|
-
}
|
|
64
|
-
}, IDLE_CLOSE_GRACE_MS);
|
|
65
|
-
}
|
|
66
|
-
function send(frame) {
|
|
67
|
-
const message = JSON.stringify(frame);
|
|
68
|
-
if (socket && socket.readyState === window.WebSocket.OPEN) {
|
|
69
|
-
socket.send(message);
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
sendQueue.push(message);
|
|
73
|
-
connect();
|
|
74
|
-
}
|
|
75
|
-
function flushQueue() {
|
|
76
|
-
while(sendQueue.length > 0 && socket && socket.readyState === window.WebSocket.OPEN){
|
|
77
|
-
socket.send(sendQueue.shift());
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
function resubscribeAll() {
|
|
81
|
-
subscriptions.forEach(({ payload }, websocketId)=>{
|
|
82
|
-
socket.send(JSON.stringify({
|
|
83
|
-
type: 'subscribe',
|
|
84
|
-
websocketId,
|
|
85
|
-
payload
|
|
86
|
-
}));
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
function handleFrame(frame) {
|
|
90
|
-
const { message, payload, requestId, websocketId } = frame;
|
|
91
|
-
const subscription = subscriptions.get(websocketId);
|
|
92
|
-
switch(frame.type){
|
|
93
|
-
case 'message':
|
|
94
|
-
subscription?.handlers.onMessage(payload);
|
|
95
|
-
return;
|
|
96
|
-
case 'subscribed':
|
|
97
|
-
{
|
|
98
|
-
const pending = pendingSubscribes.get(websocketId);
|
|
99
|
-
if (pending) {
|
|
100
|
-
clearTimeout(pending.timer);
|
|
101
|
-
pendingSubscribes.delete(websocketId);
|
|
102
|
-
pending.resolve();
|
|
103
|
-
}
|
|
104
|
-
subscription?.handlers.onConnected();
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
case 'unsubscribed':
|
|
108
|
-
return;
|
|
109
|
-
case 'published':
|
|
110
|
-
{
|
|
111
|
-
const pending = pendingPublishes.get(requestId);
|
|
112
|
-
if (pending) {
|
|
113
|
-
clearTimeout(pending.timer);
|
|
114
|
-
pendingPublishes.delete(requestId);
|
|
115
|
-
pending.resolve();
|
|
116
|
-
}
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
case 'error':
|
|
120
|
-
{
|
|
121
|
-
const error = new Error(message ?? 'WebSocket error.');
|
|
122
|
-
if (requestId && pendingPublishes.has(requestId)) {
|
|
123
|
-
const pending = pendingPublishes.get(requestId);
|
|
124
|
-
clearTimeout(pending.timer);
|
|
125
|
-
pendingPublishes.delete(requestId);
|
|
126
|
-
pending.reject(error);
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
if (websocketId && pendingSubscribes.has(websocketId)) {
|
|
130
|
-
const pending = pendingSubscribes.get(websocketId);
|
|
131
|
-
clearTimeout(pending.timer);
|
|
132
|
-
pendingSubscribes.delete(websocketId);
|
|
133
|
-
subscriptions.delete(websocketId);
|
|
134
|
-
pending.reject(error);
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
if (subscription) {
|
|
138
|
-
subscription.handlers.onError(error.message);
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
lowdefy._internal.logger.warn(error);
|
|
142
|
-
return;
|
|
143
|
-
}
|
|
144
|
-
default:
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
function handleClose() {
|
|
148
|
-
socket = null;
|
|
149
|
-
openPromise = null;
|
|
150
|
-
subscriptions.forEach(({ handlers })=>{
|
|
151
|
-
handlers.onDisconnected();
|
|
152
|
-
});
|
|
153
|
-
if (closedByClient) {
|
|
154
|
-
closedByClient = false;
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
if (subscriptions.size === 0 && sendQueue.length === 0) {
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
// Capped exponential backoff with full jitter.
|
|
161
|
-
const base = Math.min(RECONNECT_BASE_MS * 2 ** reconnectAttempt, RECONNECT_CAP_MS);
|
|
162
|
-
const delay = Math.random() * base;
|
|
163
|
-
reconnectAttempt += 1;
|
|
164
|
-
reconnectTimer = setTimeout(()=>{
|
|
165
|
-
reconnectTimer = null;
|
|
166
|
-
connect();
|
|
167
|
-
}, delay);
|
|
168
|
-
}
|
|
169
|
-
function connect() {
|
|
170
|
-
if (openPromise) {
|
|
171
|
-
return openPromise;
|
|
172
|
-
}
|
|
173
|
-
clearIdleTimer();
|
|
174
|
-
openPromise = new Promise((resolve, reject)=>{
|
|
175
|
-
const ws = new window.WebSocket(url());
|
|
176
|
-
ws.onopen = ()=>{
|
|
177
|
-
socket = ws;
|
|
178
|
-
reconnectAttempt = 0;
|
|
179
|
-
resubscribeAll();
|
|
180
|
-
flushQueue();
|
|
181
|
-
resolve();
|
|
182
|
-
};
|
|
183
|
-
ws.onmessage = (event)=>{
|
|
184
|
-
let frame;
|
|
185
|
-
try {
|
|
186
|
-
frame = JSON.parse(event.data);
|
|
187
|
-
} catch (e) {
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
handleFrame(frame);
|
|
191
|
-
};
|
|
192
|
-
ws.onclose = ()=>{
|
|
193
|
-
if (socket !== ws) {
|
|
194
|
-
// Never opened — treat as a failed connect and retry.
|
|
195
|
-
openPromise = null;
|
|
196
|
-
reject(new Error('WebSocket connection failed.'));
|
|
197
|
-
}
|
|
198
|
-
handleClose();
|
|
199
|
-
};
|
|
200
|
-
ws.onerror = ()=>{
|
|
201
|
-
// onclose always follows onerror — reconnect is handled there.
|
|
202
|
-
};
|
|
203
|
-
});
|
|
204
|
-
// Connection errors surface through subscribe/publish ack timeouts.
|
|
205
|
-
openPromise.catch(()=>{});
|
|
206
|
-
return openPromise;
|
|
207
|
-
}
|
|
208
|
-
function subscribe({ handlers, payload, websocketId }) {
|
|
209
|
-
clearIdleTimer();
|
|
210
|
-
subscriptions.set(websocketId, {
|
|
211
|
-
handlers,
|
|
212
|
-
payload
|
|
213
|
-
});
|
|
214
|
-
return new Promise((resolve, reject)=>{
|
|
215
|
-
const timer = setTimeout(()=>{
|
|
216
|
-
pendingSubscribes.delete(websocketId);
|
|
217
|
-
subscriptions.delete(websocketId);
|
|
218
|
-
reject(new Error(`Subscribe to "${websocketId}" timed out.`));
|
|
219
|
-
}, ACK_TIMEOUT_MS);
|
|
220
|
-
pendingSubscribes.set(websocketId, {
|
|
221
|
-
resolve,
|
|
222
|
-
reject,
|
|
223
|
-
timer
|
|
224
|
-
});
|
|
225
|
-
send({
|
|
226
|
-
type: 'subscribe',
|
|
227
|
-
websocketId,
|
|
228
|
-
payload
|
|
229
|
-
});
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
function unsubscribe({ websocketId }) {
|
|
233
|
-
if (!subscriptions.has(websocketId)) {
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
236
|
-
subscriptions.delete(websocketId);
|
|
237
|
-
if (socket && socket.readyState === window.WebSocket.OPEN) {
|
|
238
|
-
socket.send(JSON.stringify({
|
|
239
|
-
type: 'unsubscribe',
|
|
240
|
-
websocketId
|
|
241
|
-
}));
|
|
242
|
-
}
|
|
243
|
-
scheduleIdleClose();
|
|
244
|
-
}
|
|
245
|
-
function publish({ payload, websocketId }) {
|
|
246
|
-
publishCounter += 1;
|
|
247
|
-
const requestId = `p${publishCounter}`;
|
|
248
|
-
return new Promise((resolve, reject)=>{
|
|
249
|
-
const timer = setTimeout(()=>{
|
|
250
|
-
pendingPublishes.delete(requestId);
|
|
251
|
-
reject(new Error(`Publish to "${websocketId}" timed out.`));
|
|
252
|
-
}, ACK_TIMEOUT_MS);
|
|
253
|
-
pendingPublishes.set(requestId, {
|
|
254
|
-
resolve,
|
|
255
|
-
reject,
|
|
256
|
-
timer
|
|
257
|
-
});
|
|
258
|
-
send({
|
|
259
|
-
type: 'publish',
|
|
260
|
-
websocketId,
|
|
261
|
-
requestId,
|
|
262
|
-
payload
|
|
263
|
-
});
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
return {
|
|
267
|
-
connect,
|
|
268
|
-
publish,
|
|
269
|
-
subscribe,
|
|
270
|
-
unsubscribe
|
|
271
|
-
};
|
|
272
|
-
}
|
|
273
|
-
export default createWebSocketClient;
|