@lowdefy/server-dev 0.0.0-experimental-20260822164337 → 0.0.0-experimental-20260827092002

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.
Files changed (83) hide show
  1. package/client/App.jsx +2 -2
  2. package/client/Page.jsx +4 -13
  3. package/client/Reload.jsx +0 -8
  4. package/client/Routing.jsx +23 -31
  5. package/lib/client/auth/Auth.jsx +5 -3
  6. package/lib/client/auth/AuthConfigured.jsx +37 -190
  7. package/lib/client/auth/AuthNotConfigured.js +2 -23
  8. package/lib/client/utils/usePageConfig.js +32 -36
  9. package/lib/docs/checkpointStore.js +2 -8
  10. package/lib/docs/createDocsMcpServer.js +9 -34
  11. package/lib/docs/createDocsMcpServer.test.mjs +0 -18
  12. package/lib/docs/evalOperator.js +10 -18
  13. package/lib/docs/evalOperator.test.mjs +2 -47
  14. package/lib/docs/evalOperatorHeadless.js +2 -2
  15. package/lib/docs/getBrowser.js +3 -15
  16. package/lib/docs/inspectState.js +9 -15
  17. package/lib/docs/inspectState.test.mjs +4 -53
  18. package/lib/docs/inspectStateHeadless.js +2 -2
  19. package/lib/docs/loadState.js +2 -15
  20. package/lib/docs/screenshotPage.js +1 -2
  21. package/lib/docs/stateCheckpoints.test.mjs +0 -18
  22. package/lib/server/auth/{getStrategies.js → getAuthConfig.js} +10 -12
  23. package/lib/server/auth/getDevSession.js +70 -0
  24. package/lib/server/auth/getDevSession.test.mjs +138 -0
  25. package/lib/server/auth/getHeadlessUser.js +8 -7
  26. package/lib/server/auth/getMockUser.js +7 -11
  27. package/lib/server/auth/headlessUser.js +2 -3
  28. package/lib/server/auth/session.js +39 -0
  29. package/lib/server/createLowdefyContext.js +38 -93
  30. package/lib/server/jitPageBuilder.js +2 -90
  31. package/lib/server/log/createHandleError.js +0 -9
  32. package/lib/server/log/logRequest.js +3 -1
  33. package/manager/processes/startServer.mjs +2 -0
  34. package/manager/processes/warnAuthUrlPortMismatch.mjs +47 -0
  35. package/manager/processes/warnAuthUrlPortMismatch.test.mjs +93 -0
  36. package/manager/utils/createCustomPluginTypesMap.mjs +2 -2
  37. package/manager/watchers/serverArtifactWatcher.mjs +2 -0
  38. package/package.json +34 -37
  39. package/package.original.json +34 -37
  40. package/src/app.js +19 -42
  41. package/src/html/renderDevPage.js +2 -5
  42. package/src/{middleware/mcpOrgGuard.js → lib/serveBuildJs.js} +10 -9
  43. package/src/middleware/errorHandler.js +9 -26
  44. package/src/routes/apiPage.js +37 -0
  45. package/src/routes/auth.js +21 -15
  46. package/src/routes/auth.test.mjs +84 -0
  47. package/src/routes/detached.js +6 -10
  48. package/src/routes/docs/evalOperator.js +2 -12
  49. package/src/routes/docs/inspectState.js +2 -12
  50. package/src/routes/docs/loadState.js +3 -14
  51. package/src/routes/docs/runRequest.js +4 -2
  52. package/src/routes/docs/screenshot.js +1 -7
  53. package/src/routes/endpoints.js +9 -14
  54. package/src/routes/{user.js → iconsDynamic.js} +7 -9
  55. package/src/routes/jitPage.js +4 -50
  56. package/src/routes/jsEnv.js +30 -0
  57. package/src/routes/request.js +8 -2
  58. package/lib/client/RedirectingPage.jsx +0 -56
  59. package/lib/client/auth/authTemplateSync.test.mjs +0 -40
  60. package/lib/client/auth/betterAuthSessionContract.test.mjs +0 -181
  61. package/lib/client/utils/usePageConfig.test.mjs +0 -62
  62. package/lib/docs/getBrowser.test.mjs +0 -120
  63. package/lib/docs/resolveSource.js +0 -48
  64. package/lib/docs/resolveSource.test.mjs +0 -100
  65. package/lib/server/auth/createSystemContext.js +0 -70
  66. package/lib/server/auth/getAuth.js +0 -46
  67. package/lib/server/auth/resolveHeadlessUser.js +0 -38
  68. package/lib/server/auth/resolveHeadlessUser.test.mjs +0 -74
  69. package/lib/server/auth/strategies.js +0 -47
  70. package/lib/server/getPageJitEnrichment.test.mjs +0 -105
  71. package/lib/server/loadDynamicJsMap.js +0 -47
  72. package/lib/server/log/createHandleError.test.mjs +0 -126
  73. package/src/middleware/errorHandler.test.mjs +0 -287
  74. package/src/middleware/mcpOrgGuard.test.mjs +0 -44
  75. package/src/routes/docs/parseUserParam.js +0 -52
  76. package/src/routes/docs/parseUserParam.test.mjs +0 -45
  77. package/src/routes/jitPage.test.mjs +0 -107
  78. package/src/routes/mcp.js +0 -94
  79. package/src/routes/mcp.test.mjs +0 -184
  80. package/src/routes/mcpProtectedResourceMetadata.js +0 -39
  81. package/src/routes/mcpProtectedResourceMetadata.test.mjs +0 -79
  82. package/src/routes/mountOauthDiscovery.js +0 -47
  83. package/src/routes/mountOauthDiscovery.test.mjs +0 -79
package/client/App.jsx CHANGED
@@ -40,7 +40,7 @@ function ThemeTokenResolver({ lowdefyRef, children }) {
40
40
  return children;
41
41
  }
42
42
 
43
- function App({ config, router }) {
43
+ function App({ router }) {
44
44
  const lowdefyRef = useRef({});
45
45
  const [runtimeErrors, setRuntimeErrors] = useState([]);
46
46
  // Subscribe to rootConfig SWR cache — deduplicates with Routing's fetch.
@@ -143,7 +143,7 @@ function App({ config, router }) {
143
143
  />
144
144
  }
145
145
  >
146
- <Auth user={config?.user}>
146
+ <Auth>
147
147
  {(auth) => {
148
148
  return <Routing auth={auth} lowdefy={lowdefyRef.current} router={router} />;
149
149
  }}
package/client/Page.jsx CHANGED
@@ -20,7 +20,7 @@ import Client from '@lowdefy/client';
20
20
 
21
21
  import BuildErrorPage from '../lib/client/BuildErrorPage.jsx';
22
22
  import InstallingPluginsPage from '../lib/client/InstallingPluginsPage.jsx';
23
- import RedirectingPage from '../lib/client/RedirectingPage.jsx';
23
+ import RestartingPage from '../lib/client/RestartingPage.jsx';
24
24
  import usePageConfig from '../lib/client/utils/usePageConfig.js';
25
25
 
26
26
  const Page = ({
@@ -47,22 +47,10 @@ const Page = ({
47
47
  }
48
48
  }, [pageConfig?._warnings, lowdefy]);
49
49
 
50
- // Full load to the sign-in page so it can return here after sign-in — an
51
- // effect, not a fetcher side effect, so the redirect re-fires if the same
52
- // cached result renders again.
53
- useEffect(() => {
54
- if (pageConfig?.authRedirect) {
55
- window.location.assign(pageConfig.authRedirect);
56
- }
57
- }, [pageConfig?.authRedirect]);
58
-
59
50
  if (!pageConfig) {
60
51
  router.replace({ pathname: '/404' });
61
52
  return '';
62
53
  }
63
- if (pageConfig.authRedirect) {
64
- return <RedirectingPage redirect={pageConfig.authRedirect} />;
65
- }
66
54
  if (pageConfig.buildError) {
67
55
  return (
68
56
  <BuildErrorPage
@@ -75,6 +63,9 @@ const Page = ({
75
63
  if (pageConfig.installing) {
76
64
  return <InstallingPluginsPage packages={pageConfig.packages} />;
77
65
  }
66
+ if (resetContext.restarting) {
67
+ return <RestartingPage />;
68
+ }
78
69
 
79
70
  // Merge dynamic JS entries fetched after JIT build with the static jsMap
80
71
  const mergedJsMap = pageConfig._jsEntries ? { ...jsMap, ...pageConfig._jsEntries } : jsMap;
package/client/Reload.jsx CHANGED
@@ -25,13 +25,6 @@ import waitForRestartedServer from '../lib/client/utils/waitForRestartedServer.j
25
25
  const Reload = ({ children, basePath, lowdefy }) => {
26
26
  const [reset, setReset] = useState(false);
27
27
  const [restarting, setRestarting] = useState(false);
28
- // reset is a one-shot boolean the engine lowers only after a page mounts
29
- // (getContext.js) — while a page is suspended it stays true and setReset(true)
30
- // bails out of re-rendering. The tick always changes, so every reload event
31
- // re-invokes Routing's render prop, which re-reads reloadVersion and mints a
32
- // fresh Suspense/SWR key — a suspended tab recovers instead of stranding on
33
- // the building screen.
34
- const [, setReloadTick] = useState(0);
35
28
  const mutateCache = useMutateCache(basePath);
36
29
  useEffect(() => {
37
30
  const sse = new EventSource(`${basePath}/api/reload`);
@@ -45,7 +38,6 @@ const Reload = ({ children, basePath, lowdefy }) => {
45
38
  lowdefy._internal.initialised = false;
46
39
  }
47
40
  setReset(true);
48
- setReloadTick((tick) => tick + 1);
49
41
  console.log('Reloaded config.');
50
42
  }, 600);
51
43
  });
@@ -20,7 +20,6 @@ import Head from '@lowdefy/client/adapters/Head.js';
20
20
  import createLinkComponent from '@lowdefy/client/adapters/Link.js';
21
21
 
22
22
  import BuildingPage from '../lib/client/BuildingPage.jsx';
23
- import RestartingPage from '../lib/client/RestartingPage.jsx';
24
23
  import FeedbackMount from './feedback/FeedbackMount.jsx';
25
24
  import Inspector from './Inspector.jsx';
26
25
  import OpenInEditorListener from './openInEditor/OpenInEditorListener.jsx';
@@ -76,36 +75,29 @@ function Routing({ auth, lowdefy, router }) {
76
75
  <FeedbackMount basePath={router.basePath} lowdefy={lowdefy} pageId={pageId} />
77
76
  <OpenInEditorListener basePath={router.basePath} pageId={pageId} />
78
77
  <Reload basePath={router.basePath} lowdefy={lowdefy}>
79
- {(resetContext) =>
80
- // Rendered here, not in Page — Page sits below the Suspense boundary
81
- // and cannot render anything while its config fetch is suspended, so
82
- // a restarting server would present as "Building page..." forever.
83
- resetContext.restarting ? (
84
- <RestartingPage />
85
- ) : (
86
- <Suspense key={`${pageId}_${getReloadVersion()}`} fallback={<BuildingPage />}>
87
- <Page
88
- auth={auth}
89
- Components={{ Head, Link }}
90
- config={{
91
- rootConfig,
92
- }}
93
- jsMap={staticJsMap}
94
- lowdefy={lowdefy}
95
- pageId={pageId}
96
- resetContext={resetContext}
97
- router={router}
98
- types={{
99
- actions,
100
- blockMetas,
101
- blocks,
102
- icons,
103
- operators,
104
- }}
105
- />
106
- </Suspense>
107
- )
108
- }
78
+ {(resetContext) => (
79
+ <Suspense key={`${pageId}_${getReloadVersion()}`} fallback={<BuildingPage />}>
80
+ <Page
81
+ auth={auth}
82
+ Components={{ Head, Link }}
83
+ config={{
84
+ rootConfig,
85
+ }}
86
+ jsMap={staticJsMap}
87
+ lowdefy={lowdefy}
88
+ pageId={pageId}
89
+ resetContext={resetContext}
90
+ router={router}
91
+ types={{
92
+ actions,
93
+ blockMetas,
94
+ blocks,
95
+ icons,
96
+ operators,
97
+ }}
98
+ />
99
+ </Suspense>
100
+ )}
109
101
  </Reload>
110
102
  </>
111
103
  );
@@ -14,6 +14,8 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
+ /* eslint-disable react/jsx-props-no-spreading */
18
+
17
19
  import React from 'react';
18
20
  import AuthConfigured from './AuthConfigured.jsx';
19
21
  import AuthNotConfigured from './AuthNotConfigured.js';
@@ -23,15 +25,15 @@ import { serializer } from '@lowdefy/helpers';
23
25
  // Client code imports the build JSON directly — Vite handles JSON imports;
24
26
  // the lib/build/*.js wrappers are server-only (they read from disk).
25
27
  // Deserialize to restore arrays from their ~arr build markers (providers
26
- // must be a real array for provider lookups in createAuthMethods).
28
+ // must be a real array for single-provider inference in createAuthMethods).
27
29
  import rawAuthConfig from '../../../build/auth.json';
28
30
 
29
31
  const authConfig = serializer.deserialize(rawAuthConfig);
30
32
 
31
- function Auth({ children, user }) {
33
+ function Auth({ children, session }) {
32
34
  if (authConfig.configured === true) {
33
35
  return (
34
- <AuthConfigured serverUser={user} authConfig={authConfig}>
36
+ <AuthConfigured serverSession={session} authConfig={authConfig}>
35
37
  {(auth) => children(auth)}
36
38
  </AuthConfigured>
37
39
  );
@@ -14,213 +14,60 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
- import React, { useEffect, useRef, useState } from 'react';
18
- import { createAuthClient } from 'better-auth/react';
17
+ /* eslint-disable react/jsx-props-no-spreading */
18
+
19
+ import React, { useEffect, useRef } from 'react';
19
20
  import {
20
- adminClient,
21
- magicLinkClient,
22
- organizationClient,
23
- phoneNumberClient,
24
- twoFactorClient,
25
- } from 'better-auth/client/plugins';
26
- import { oauthProviderClient } from '@better-auth/oauth-provider/client';
27
- import { passkeyClient } from '@better-auth/passkey/client';
21
+ authConfigManager,
22
+ getSession,
23
+ SessionProvider,
24
+ signIn,
25
+ signOut,
26
+ useSession,
27
+ } from '@hono/auth-js/react';
28
28
 
29
- import { normalizeCaller, serializer } from '@lowdefy/helpers';
29
+ import { serializer } from '@lowdefy/helpers';
30
30
 
31
31
  import rawLowdefyConfig from '../../../build/config.json';
32
32
 
33
33
  const lowdefyConfig = serializer.deserialize(rawLowdefyConfig);
34
34
 
35
- // GenericOAuth providers have no client plugin here. BetterAuth 1.7.0 dropped
36
- // genericOAuthClient from better-auth/client/plugins, and nothing replaces it:
37
- // through 1.6.x it was a marker plugin carrying only an id, a version, an empty
38
- // $InferServerPlugin for TypeScript inference, and the generic-oauth error
39
- // codes - no actions, atoms, fetch plugins or path methods. signIn.oauth2 is
40
- // resolved by the client's dynamic path proxy from the call itself
41
- // (signIn.oauth2 -> POST /sign-in/oauth2, POST because providerId is always
42
- // sent), so the sign-in a Lowdefy GenericOAuth provider makes is unaffected.
43
- const authClient = createAuthClient({
44
- baseURL: `${window.location.origin}${lowdefyConfig.basePath ?? ''}/api/auth`,
45
- plugins: [
46
- adminClient(),
47
- magicLinkClient(),
48
- oauthProviderClient(),
49
- organizationClient(),
50
- passkeyClient(),
51
- phoneNumberClient(),
52
- twoFactorClient(),
53
- ],
54
- });
55
-
56
- // UpdateSession owns session freshness - suppress BetterAuth's own
57
- // signal-driven refetch on the calls that would otherwise fire one, so
58
- // nothing competes with (and aborts) the refetch UpdateSession awaits.
59
- // Only calls that mutate an existing session are suppressed - calls that
60
- // can establish one (the sign-ins, and the two-factor, phone-number and
61
- // passkey verifies) keep their signal: its refetch is what makes the store
62
- // notice a login when no navigation follows.
63
- // disableSignal silences all atomListeners for the call, including the
64
- // organization plugin's org atoms - none are exposed to app config, so a
65
- // future design exposing one must refresh it through its own action.
66
- const sessionScoped = (params) => ({
67
- ...params,
68
- fetchOptions: { ...params?.fetchOptions, disableSignal: true },
69
- });
35
+ // @hono/auth-js/react configures its fetch paths through a module-level
36
+ // manager instead of SessionProvider props.
37
+ if (lowdefyConfig.basePath) {
38
+ authConfigManager.setConfig({ basePath: `${lowdefyConfig.basePath}/api/auth` });
39
+ }
70
40
 
71
- // The server resolves the caller per request and embeds it in the page
72
- // config, so the first render never flashes unauthenticated. The BetterAuth
73
- // client store takes over once its session fetch settles.
74
- //
75
- // Roles and merged attributes resolve server-side from the active member row
76
- // - the base session carries neither. The last server-resolved caller is
77
- // held in state: while the session user is unchanged it stays authoritative
78
- // for the fields the session does not carry, and UpdateSession refreshes it
79
- // from /api/user after a change (e.g. SetActiveOrganization). Holding it in
80
- // state, not a ref, re-renders Session when it changes so _user recomputes -
81
- // the same reactive footing the session half already stands on.
82
- //
83
- // Only the session half is passed through normalizeCaller. BetterAuth's store
84
- // returns camelCase keys whatever the columns are named, so untransformed it
85
- // would sit emailVerified beside the resolved caller's email_verified on one
86
- // object; anything arriving from the server is already snake_case.
87
- function Session({ children, reloadSuppressedRef, serverUser }) {
88
- const { data: session, isPending } = authClient.useSession();
89
- const [resolvedUser, setResolvedUser] = useState(serverUser);
90
- const wasAuthenticated = useRef(Boolean(serverUser));
41
+ function Session({ children }) {
42
+ const wasAuthenticated = useRef(false);
43
+ const { data: session, status } = useSession();
44
+ wasAuthenticated.current = wasAuthenticated.current || status === 'authenticated';
91
45
 
92
46
  useEffect(() => {
93
- if (session) {
94
- wasAuthenticated.current = true;
95
- }
96
- // Reload after sign-out (or session revocation) so the server can apply
97
- // the page auth fork - a protected page redirects to the login page.
98
- // A logout with a callbackUrl suppresses the reload - its own navigation
99
- // would otherwise race this one.
100
- if (wasAuthenticated.current && !isPending && !session && !reloadSuppressedRef.current) {
47
+ if (wasAuthenticated.current && status === 'unauthenticated') {
101
48
  window.location.reload();
102
49
  }
103
- }, [session, isPending]);
50
+ }, [status]);
104
51
 
105
- if (isPending) {
106
- return children(resolvedUser, setResolvedUser);
107
- }
108
- if (!session?.user) {
109
- return children(null, setResolvedUser);
110
- }
111
- const resolved = resolvedUser;
112
- if (!resolved) {
113
- return children({ roles: [], ...normalizeCaller(session.user) }, setResolvedUser);
114
- }
115
- if (resolved.id !== session.user.id) {
116
- // The ref has not caught up with a changed session user - a sign-in as a
117
- // different person lands between the session refetch and the /api/user
118
- // result. Render the last resolved caller whole rather than mixing the new
119
- // session user with the previous person's resolved fields, or falling back
120
- // to a caller with no roles - blocks re-evaluate operators on render, so a
121
- // role-guarded page would act on an empty role list. UpdateSession closes
122
- // the window when it lands the new caller; a flow that changes the session
123
- // user without chaining it leaves the previous caller rendered until the
124
- // next page load.
125
- return children(resolved, setResolvedUser);
52
+ // If session is passed to SessionProvider from the server-rendered config
53
+ // we won't have a loading state here, but unauthenticated first loads do.
54
+ if (status === 'loading') {
55
+ return '';
126
56
  }
127
- // _user is one object with one meaning on both sides: the resolved caller is
128
- // spread whole, so every field resolveAuthentication emits is readable in app
129
- // config without this file naming it. session.user is the floor - the client
130
- // store may hold a fresher one than the resolved state, and the resolved
131
- // object wins wherever both carry a key.
132
- const user = { ...normalizeCaller(session.user), ...resolved };
133
- return children(user, setResolvedUser);
57
+ return children(session);
134
58
  }
135
59
 
136
- function AuthConfigured({ authConfig, children, serverUser }) {
137
- const reloadSuppressedRef = useRef(false);
138
- const auth = {
139
- authConfig,
140
- getSession: ({ disableCookieCache } = {}) =>
141
- authClient.getSession(disableCookieCache ? { query: { disableCookieCache: true } } : {}),
142
- // Refetches the session through the session atom - the store's only
143
- // writer - and resolves only after the store is written. fetchSession
144
- // returns undefined and never rejects (failures are written into the
145
- // atom), so the atom's own { data, error } is read back and returned,
146
- // keeping unwrap working and surfacing a failed refresh to the action.
147
- refreshSession: async ({ disableCookieCache } = {}) => {
148
- const sessionAtom = authClient.$store.atoms.session;
149
- await sessionAtom
150
- .get()
151
- .refetch(disableCookieCache ? { query: { disableCookieCache: true } } : undefined);
152
- const { data, error } = sessionAtom.get();
153
- return { data, error };
154
- },
155
- // The server-resolved caller - roles from the active member row and the
156
- // merged attributes bag - for re-syncing after session changes.
157
- getResolvedUser: async () => {
158
- const response = await fetch(`${lowdefyConfig.basePath ?? ''}/api/user`, {
159
- credentials: 'same-origin',
160
- });
161
- if (!response.ok) {
162
- // /api/user itself always answers 200 { user } - user: null when no
163
- // caller is admitted, which updateSession branches on. A non-OK
164
- // response is a transport failure (gateway error page, 5xx) with no
165
- // parsable body.
166
- throw new Error(`Failed to fetch the resolved user (HTTP ${response.status}).`);
167
- }
168
- return response.json();
169
- },
170
- suppressSignOutReload: () => {
171
- reloadSuppressedRef.current = true;
172
- },
173
- acceptInvitation: (params) => authClient.organization.acceptInvitation(sessionScoped(params)),
174
- // addPasskey runs the WebAuthn browser ceremony itself - options fetch,
175
- // authenticator prompt, verification.
176
- addPasskey: (params) => authClient.passkey.addPasskey(params),
177
- changePassword: (params) => authClient.changePassword(sessionScoped(params)),
178
- deletePasskey: (params) => authClient.passkey.deletePasskey(params),
179
- updatePasskey: (params) => authClient.passkey.updatePasskey(params),
180
- leaveOrganization: (params) => authClient.organization.leave(sessionScoped(params)),
181
- // The caller's memberships from the org plugin, keyed on the session
182
- // alone - not the active organization. sessionScoped so the list read
183
- // does not also fire the org-list atom's own refetch of the same route.
184
- listOrganizations: () => authClient.organization.list(sessionScoped()),
185
- // POST /oauth2/consent. The signed authorization query is not passed
186
- // here - the oauthProviderClient fetch plugin stamps it onto the body as
187
- // oauth_query from window.location.search, so the call must run while
188
- // the page still holds the query the oauth-provider redirect arrived
189
- // with.
190
- oauth2Consent: (params) => authClient.oauth2.consent(params),
191
- phoneNumberRequestPasswordReset: (params) =>
192
- authClient.phoneNumber.requestPasswordReset(params),
193
- phoneNumberResetPassword: (params) => authClient.phoneNumber.resetPassword(params),
194
- phoneNumberSendOtp: (params) => authClient.phoneNumber.sendOtp(params),
195
- phoneNumberVerify: (params) => authClient.phoneNumber.verify(params),
196
- requestPasswordReset: (params) => authClient.requestPasswordReset(params),
197
- resetPassword: (params) => authClient.resetPassword(params),
198
- revokeOtherSessions: () => authClient.revokeOtherSessions(sessionScoped()),
199
- sendVerificationEmail: (params) => authClient.sendVerificationEmail(params),
200
- setActiveOrganization: (params) => authClient.organization.setActive(sessionScoped(params)),
201
- signInEmail: (params) => authClient.signIn.email(params),
202
- signInMagicLink: (params) => authClient.signIn.magicLink(params),
203
- signInOauth2: (params) => authClient.signIn.oauth2(params),
204
- signInPasskey: (params) => authClient.signIn.passkey(params),
205
- signInPhoneNumber: (params) => authClient.signIn.phoneNumber(params),
206
- signInSocial: (params) => authClient.signIn.social(params),
207
- signOut: () => authClient.signOut(),
208
- signUpEmail: (params) => authClient.signUp.email(params),
209
- twoFactorDisable: (params) => authClient.twoFactor.disable(sessionScoped(params)),
210
- twoFactorEnable: (params) => authClient.twoFactor.enable(sessionScoped(params)),
211
- twoFactorGenerateBackupCodes: (params) =>
212
- authClient.twoFactor.generateBackupCodes(sessionScoped(params)),
213
- twoFactorVerifyBackupCode: (params) => authClient.twoFactor.verifyBackupCode(params),
214
- twoFactorVerifyTotp: (params) => authClient.twoFactor.verifyTotp(params),
215
- };
60
+ function AuthConfigured({ authConfig, children, serverSession }) {
61
+ const auth = { authConfig, getSession, signIn, signOut };
216
62
  return (
217
- <Session reloadSuppressedRef={reloadSuppressedRef} serverUser={serverUser}>
218
- {(user, setResolvedUser) => {
219
- auth.user = user;
220
- auth.updateResolvedUser = (resolved) => setResolvedUser(resolved);
221
- return children(auth);
222
- }}
223
- </Session>
63
+ <SessionProvider session={serverSession}>
64
+ <Session>
65
+ {(session) => {
66
+ auth.session = session;
67
+ return children(auth);
68
+ }}
69
+ </Session>
70
+ </SessionProvider>
224
71
  );
225
72
  }
226
73
 
@@ -13,6 +13,7 @@
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
15
  */
16
+ /* eslint-disable react/jsx-props-no-spreading */
16
17
 
17
18
  function authNotConfigured() {
18
19
  throw new Error('Auth not configured.');
@@ -21,31 +22,9 @@ function authNotConfigured() {
21
22
  function AuthNotConfigured({ authConfig, children }) {
22
23
  const auth = {
23
24
  authConfig,
24
- user: null,
25
- acceptInvitation: authNotConfigured,
26
- addPasskey: authNotConfigured,
27
- changePassword: authNotConfigured,
28
- deletePasskey: authNotConfigured,
29
- updatePasskey: authNotConfigured,
30
- getResolvedUser: authNotConfigured,
31
25
  getSession: authNotConfigured,
32
- leaveOrganization: authNotConfigured,
33
- requestPasswordReset: authNotConfigured,
34
- resetPassword: authNotConfigured,
35
- revokeOtherSessions: authNotConfigured,
36
- sendVerificationEmail: authNotConfigured,
37
- setActiveOrganization: authNotConfigured,
38
- signInEmail: authNotConfigured,
39
- signInMagicLink: authNotConfigured,
40
- signInOauth2: authNotConfigured,
41
- signInSocial: authNotConfigured,
26
+ signIn: authNotConfigured,
42
27
  signOut: authNotConfigured,
43
- signUpEmail: authNotConfigured,
44
- twoFactorDisable: authNotConfigured,
45
- twoFactorEnable: authNotConfigured,
46
- twoFactorGenerateBackupCodes: authNotConfigured,
47
- twoFactorVerifyBackupCode: authNotConfigured,
48
- twoFactorVerifyTotp: authNotConfigured,
49
28
  };
50
29
 
51
30
  return children(auth);
@@ -27,42 +27,33 @@ function parseJsModule(text) {
27
27
  return mod.default ?? {};
28
28
  }
29
29
 
30
- export async function fetchPageConfig(url) {
31
- const basePath = url.replace(/\/api\/page\/.*$/, '');
32
- // A stalled request (server restart mid-request, exhausted sockets) must
33
- // become a visible error, never an eternal Suspense fallback — the reload
34
- // recovery path cannot fire while the page tree is suspended.
35
- let res;
30
+ async function fetchJsEntries(basePath) {
36
31
  try {
37
- res = await fetch(url, {
38
- headers: { 'Content-Type': 'application/json' },
39
- signal: AbortSignal.timeout(30_000),
40
- });
41
- } catch (error) {
42
- if (error.name === 'TimeoutError' || error.name === 'AbortError') {
43
- throw new Error(
44
- `Page config request "${url}" timed out - the dev server may be restarting. Reload the page.`
45
- );
46
- }
47
- throw error;
32
+ const res = await fetch(`${basePath}/api/js/client`);
33
+ if (!res.ok) return {};
34
+ return parseJsModule(await res.text());
35
+ } catch {
36
+ return {};
37
+ }
38
+ }
39
+
40
+ async function fetchDynamicIcons(basePath) {
41
+ try {
42
+ const res = await fetch(`${basePath}/api/icons/dynamic`);
43
+ if (!res.ok) return {};
44
+ return parseJsModule(await res.text());
45
+ } catch {
46
+ return {};
48
47
  }
48
+ }
49
+
50
+ async function fetchPageConfig(url) {
51
+ const res = await fetch(url, {
52
+ headers: { 'Content-Type': 'application/json' },
53
+ });
49
54
  if (res.status === 404) {
50
55
  return null;
51
56
  }
52
- if (res.status === 401 || res.status === 403) {
53
- // 401: logged-out navigation to a protected page. 403: authorised but second
54
- // factor not yet enrolled. Page renders a redirect screen and full-loads to
55
- // the destination so it can return here afterwards. Returning a settled value
56
- // (never a parked promise) keeps the SWR key healthy: if the navigation is
57
- // dropped, the tab still recovers on the next reload event or via the manual
58
- // link on the redirect screen.
59
- const { redirect } = await res.json();
60
- const authRedirect = redirect ?? `${basePath}/404`;
61
- console.warn(
62
- `Lowdefy dev: "${url}" returned ${res.status} - redirecting to "${authRedirect}".`
63
- );
64
- return { authRedirect };
65
- }
66
57
  const data = await res.json();
67
58
  if (data?.buildError) {
68
59
  return data;
@@ -74,11 +65,16 @@ export async function fetchPageConfig(url) {
74
65
  throw new Error(data.message || 'Request error');
75
66
  }
76
67
 
77
- // The JIT build folds this page's _js entries and dynamic icons into the
78
- // response, so first paint needs no secondary fetch. _jsEntries arrives as
79
- // module text — compile it to the { hash: fn } object Page expects.
80
- // _dynamicIcons is already plain data — leave it for Page to inject.
81
- if (data._jsEntries) data._jsEntries = parseJsModule(data._jsEntries);
68
+ // Fetch jsMap and dynamic icons after page build completes
69
+ // (JIT build may have added new entries).
70
+ // Extract basePath from the URL to construct the endpoints.
71
+ const basePath = url.replace(/\/api\/page\/.*$/, '');
72
+ const [jsEntries, dynamicIcons] = await Promise.all([
73
+ fetchJsEntries(basePath),
74
+ fetchDynamicIcons(basePath),
75
+ ]);
76
+ data._jsEntries = jsEntries;
77
+ data._dynamicIcons = dynamicIcons;
82
78
 
83
79
  if (data?.dynamic === true) {
84
80
  dynamicUrls.add(url);
@@ -16,7 +16,7 @@
16
16
 
17
17
  import fs from 'fs';
18
18
  import path from 'path';
19
- import { isReserved, serializer, setKey, type } from '@lowdefy/helpers';
19
+ import { serializer, type } from '@lowdefy/helpers';
20
20
 
21
21
  import { getConfigDirectory } from './checkpointPaths.js';
22
22
 
@@ -90,13 +90,7 @@ function readEntries({ dir, subdir }) {
90
90
  .filter((fileName) => fileName.endsWith('.json'))
91
91
  .forEach((fileName) => {
92
92
  const id = fileName.slice(0, -'.json'.length);
93
- // A checkpoint directory is on-disk state a developer can edit by hand.
94
- // A reserved file name can't round-trip through the entries map, so skip
95
- // it rather than fail the whole checkpoint load.
96
- if (isReserved(id)) {
97
- return;
98
- }
99
- setKey(entries, id, readJsonFile(path.join(entriesDir, fileName), null));
93
+ entries[id] = readJsonFile(path.join(entriesDir, fileName), null);
100
94
  });
101
95
  return entries;
102
96
  }