@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 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
- // Memoize on the fetched config objects, not the per-render wrapper — the
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: contextKey,
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
- const targets = [
19
- home,
20
- pageId,
21
- url
22
- ].filter((target)=>target);
23
- if (targets.length > 1) {
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 functions that handle action params;
92
- // the auth object provides the BetterAuth client methods.
93
- async function login({ callbackUrl, captchaToken, email, magicLink, password, phoneNumber, providerId, ...rest } = {}) {
94
- const callbackURL = resolveCallbackURL({
95
- lowdefy,
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
- if (!type.isNone(email) || !type.isNone(password)) {
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
- ...captchaFetchOptions(captchaToken)
190
- }));
191
- // With requireEmailVerification the response carries no session - do not
192
- // navigate; the page shows a "verify your email" message instead.
193
- const window = lowdefy._internal?.globals?.window;
194
- if (data?.token && callbackURL && window) {
195
- window.location.assign(callbackURL);
196
- }
197
- return data;
198
- }
199
- async function logout({ callbackUrl } = {}) {
200
- const callbackURL = getCallbackUrl({
201
- lowdefy,
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 unwrap(auth.getSession({
255
- disableCookieCache: true
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
  }
@@ -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
- context._internal.updaters[block.id] = ()=>setUpdate(updates + 1);
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-20260720072521",
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-20260720072521",
39
- "@lowdefy/engine": "0.0.0-experimental-20260720072521",
40
- "@lowdefy/errors": "0.0.0-experimental-20260720072521",
41
- "@lowdefy/helpers": "0.0.0-experimental-20260720072521",
42
- "@lowdefy/layout": "0.0.0-experimental-20260720072521",
43
- "@lowdefy/logger": "0.0.0-experimental-20260720072521",
44
- "dayjs": "1.11.20",
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-20260720072521",
52
- "@swc/cli": "0.8.1",
53
- "@swc/core": "1.15.32",
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.6.1",
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",
@@ -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;
@@ -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;
@@ -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;