@lowdefy/server-dev 0.0.0-experimental-20260905100314 → 0.0.0-experimental-20260908103554

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.
@@ -0,0 +1,30 @@
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
+ */
16
+
17
+ import { createContext } from 'react';
18
+
19
+ // The one /api/reload EventSource a dev tab holds, owned by Reload.jsx and
20
+ // shared with Inspector.jsx: { source, tabId }. Dev runs on HTTP/1.1, where
21
+ // browsers cap connections per host at six and an open event stream holds
22
+ // one for the life of the tab. Two streams per tab meant three tabs of the
23
+ // same app saturated the pool and the next fetch queued forever, so every
24
+ // dev-only channel rides this single stream instead of opening its own.
25
+ // - source: the EventSource, null until Reload's effect has opened it.
26
+ // - tabId: the id the server assigned this connection in its tab registry
27
+ // (lib/docs/tabChannel.js), null until the stream's `tab` event arrives.
28
+ const DevStreamContext = createContext({ source: null, tabId: null });
29
+
30
+ export default DevStreamContext;
@@ -14,40 +14,51 @@
14
14
  limitations under the License.
15
15
  */
16
16
 
17
- import { useEffect, useRef } from 'react';
17
+ import { useContext, useEffect, useRef } from 'react';
18
18
 
19
19
  import { type, serializer } from '@lowdefy/helpers';
20
20
 
21
- // Dev-only agent-state-xray channel — a sibling of Reload.jsx, but with its
22
- // own /api/reload SSE connection (rather than reusing Reload's) so that
23
- // connection can carry ?pageId=<pageId>. routes/reload.js only registers a
24
- // connection as an inspectable tab (lib/docs/tabChannel.js) when that query
25
- // param is present, which keeps Reload.jsx's plain reload/ping connection
26
- // out of the tab registry entirely.
21
+ import DevStreamContext from './DevStreamContext.js';
22
+
23
+ // Dev-only agent-state-xray channel. Listens for inspect-request/eval-request
24
+ // events on the tab's single /api/reload EventSource (owned by Reload.jsx,
25
+ // shared through DevStreamContext) rather than opening its own — a second
26
+ // stream per tab exhausted the browser's HTTP/1.1 connection pool once a few
27
+ // tabs of the same app were open, and every later fetch queued forever.
27
28
  //
28
- // pageId tracking: this effect depends on `pageId`, so React tears down and
29
- // reopens the EventSource (a fresh connection, fresh tab id server-side)
30
- // whenever the developer navigates to a different page — see the longer
31
- // design note in src/routes/reload.js.
29
+ // pageId tracking: routes/reload.js registers every connection as an
30
+ // inspectable tab (lib/docs/tabChannel.js) and announces the tab id on the
31
+ // stream. This component then posts { tabId, pageId } to
32
+ // /api/dev-inspect/page whenever the developer navigates, so the registry's
33
+ // view of "what page is this tab on" stays current without reconnecting.
32
34
  //
33
35
  // Never let a bad payload or a plugin's operator error crash the app this is
34
36
  // piggybacking on — every handler is wrapped, and the component itself
35
37
  // always renders null.
36
- function postResult({ basePath, requestId, result }) {
38
+ function postJson({ basePath, path, body }) {
37
39
  try {
38
- fetch(`${basePath}/api/dev-inspect`, {
40
+ fetch(`${basePath}/api/dev-inspect${path}`, {
39
41
  method: 'POST',
40
42
  headers: { 'Content-Type': 'application/json' },
41
- body: JSON.stringify({ requestId, result }),
43
+ body: JSON.stringify(body),
42
44
  }).catch(() => {
43
45
  // Best-effort — a failed callback just leaves the agent's request to
44
- // time out server-side.
46
+ // time out server-side, and a failed page update leaves the tab
47
+ // registered on its previous page.
45
48
  });
46
49
  } catch {
47
50
  // JSON.stringify or fetch throwing synchronously — still best-effort.
48
51
  }
49
52
  }
50
53
 
54
+ function postResult({ basePath, requestId, result }) {
55
+ postJson({ basePath, path: '', body: { requestId, result } });
56
+ }
57
+
58
+ function postTabPage({ basePath, tabId, pageId }) {
59
+ postJson({ basePath, path: '/page', body: { tabId, pageId } });
60
+ }
61
+
51
62
  function buildSnapshot({ lowdefy, pageId }) {
52
63
  const context = lowdefy?.contexts?.[`page:${pageId}`];
53
64
  if (type.isNone(context)) {
@@ -133,17 +144,16 @@ function evalExpression({ lowdefy, pageId, expression }) {
133
144
  }
134
145
 
135
146
  const Inspector = ({ basePath, lowdefy, pageId }) => {
147
+ const { source, tabId } = useContext(DevStreamContext);
136
148
  const pageIdRef = useRef(pageId);
137
149
  pageIdRef.current = pageId;
138
150
 
139
151
  useEffect(() => {
140
- if (type.isNone(pageId)) {
152
+ if (type.isNone(source)) {
141
153
  return undefined;
142
154
  }
143
155
 
144
- const sse = new EventSource(`${basePath}/api/reload?pageId=${encodeURIComponent(pageId)}`);
145
-
146
- sse.addEventListener('inspect-request', (message) => {
156
+ const onInspectRequest = (message) => {
147
157
  let requestId;
148
158
  try {
149
159
  const data = JSON.parse(message.data);
@@ -154,9 +164,9 @@ const Inspector = ({ basePath, lowdefy, pageId }) => {
154
164
  } catch (error) {
155
165
  postResult({ basePath, requestId, result: { error: error.message } });
156
166
  }
157
- });
167
+ };
158
168
 
159
- sse.addEventListener('eval-request', (message) => {
169
+ const onEvalRequest = (message) => {
160
170
  let requestId;
161
171
  try {
162
172
  const data = JSON.parse(message.data);
@@ -171,12 +181,22 @@ const Inspector = ({ basePath, lowdefy, pageId }) => {
171
181
  } catch (error) {
172
182
  postResult({ basePath, requestId, result: { error: error.message } });
173
183
  }
174
- });
184
+ };
175
185
 
186
+ source.addEventListener('inspect-request', onInspectRequest);
187
+ source.addEventListener('eval-request', onEvalRequest);
176
188
  return () => {
177
- sse.close();
189
+ source.removeEventListener('inspect-request', onInspectRequest);
190
+ source.removeEventListener('eval-request', onEvalRequest);
178
191
  };
179
- }, [basePath, lowdefy, pageId]);
192
+ }, [basePath, lowdefy, source]);
193
+
194
+ useEffect(() => {
195
+ if (type.isNone(tabId) || type.isNone(pageId)) {
196
+ return;
197
+ }
198
+ postTabPage({ basePath, tabId, pageId });
199
+ }, [basePath, tabId, pageId]);
180
200
 
181
201
  useEffect(() => {
182
202
  if (type.isNone(pageId)) {
package/client/Reload.jsx CHANGED
@@ -16,15 +16,21 @@
16
16
 
17
17
  import React, { useEffect, useState } from 'react';
18
18
 
19
+ import DevStreamContext from './DevStreamContext.js';
19
20
  import useMutateCache from '../lib/client/utils/useMutateCache.js';
20
21
  import waitForRestartedServer from '../lib/client/utils/waitForRestartedServer.js';
21
22
 
22
23
  // SSE listener — config rebuilds notify via /api/reload. Tailwind CSS updates
23
24
  // arrive through Vite HMR now (globals.css is in the dev module graph), so
24
25
  // the old tailwind-jit.css link cache-bust is gone.
26
+ //
27
+ // This is the tab's only EventSource. It is shared through DevStreamContext
28
+ // so Inspector.jsx can listen on the same connection instead of opening a
29
+ // second one — see the connection-limit note in DevStreamContext.js.
25
30
  const Reload = ({ children, basePath, lowdefy }) => {
26
31
  const [reset, setReset] = useState(false);
27
32
  const [restarting, setRestarting] = useState(false);
33
+ const [devStream, setDevStream] = useState({ source: null, tabId: null });
28
34
  // reset is a one-shot boolean the engine lowers only after a page mounts
29
35
  // (getContext.js) — while a page is suspended it stays true and setReset(true)
30
36
  // bails out of re-rendering. The tick always changes, so every reload event
@@ -36,6 +42,15 @@ const Reload = ({ children, basePath, lowdefy }) => {
36
42
  useEffect(() => {
37
43
  const sse = new EventSource(`${basePath}/api/reload`);
38
44
 
45
+ // routes/reload.js registers every connection as an inspectable tab and
46
+ // announces the id it chose as the first event on the stream. The stream
47
+ // is published to consumers only then, in one state update, so the page
48
+ // subtree re-renders once at mount rather than once per field.
49
+ sse.addEventListener('tab', (message) => {
50
+ const { tabId } = JSON.parse(message.data);
51
+ setDevStream({ source: sse, tabId });
52
+ });
53
+
39
54
  sse.addEventListener('reload', () => {
40
55
  // add a update delay to prevent rerender before server is shut down for rebuild, ideally we don't want to do this.
41
56
  // TODO: We need to pass a flag when a rebuild will happen so that client does not trigger render.
@@ -60,7 +75,11 @@ const Reload = ({ children, basePath, lowdefy }) => {
60
75
  sse.close();
61
76
  };
62
77
  }, []);
63
- return <>{children({ reset, setReset, restarting })}</>;
78
+ return (
79
+ <DevStreamContext.Provider value={devStream}>
80
+ {children({ reset, setReset, restarting })}
81
+ </DevStreamContext.Provider>
82
+ );
64
83
  };
65
84
 
66
85
  export default Reload;
@@ -72,40 +72,43 @@ function Routing({ auth, lowdefy, router }) {
72
72
 
73
73
  return (
74
74
  <>
75
- <Inspector basePath={router.basePath} lowdefy={lowdefy} pageId={pageId} />
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
+ <>
80
+ {/* Inside Reload so it can share Reload's event stream (DevStreamContext). */}
81
+ <Inspector basePath={router.basePath} lowdefy={lowdefy} pageId={pageId} />
82
+ {/* Rendered here, not in Page — Page sits below the Suspense boundary
83
+ and cannot render anything while its config fetch is suspended, so
84
+ a restarting server would present as "Building page..." forever. */}
85
+ {resetContext.restarting ? (
86
+ <RestartingPage />
87
+ ) : (
88
+ <Suspense key={`${pageId}_${getReloadVersion()}`} fallback={<BuildingPage />}>
89
+ <Page
90
+ auth={auth}
91
+ Components={{ Head, Link }}
92
+ config={{
93
+ rootConfig,
94
+ }}
95
+ jsMap={staticJsMap}
96
+ lowdefy={lowdefy}
97
+ pageId={pageId}
98
+ resetContext={resetContext}
99
+ router={router}
100
+ types={{
101
+ actions,
102
+ blockMetas,
103
+ blocks,
104
+ icons,
105
+ operators,
106
+ }}
107
+ />
108
+ </Suspense>
109
+ )}
110
+ </>
111
+ )}
109
112
  </Reload>
110
113
  </>
111
114
  );
@@ -18,6 +18,7 @@ import React, { useEffect, useRef, useState } from 'react';
18
18
  import { createAuthClient } from 'better-auth/react';
19
19
  import {
20
20
  adminClient,
21
+ emailOTPClient,
21
22
  magicLinkClient,
22
23
  organizationClient,
23
24
  phoneNumberClient,
@@ -44,6 +45,7 @@ const authClient = createAuthClient({
44
45
  baseURL: `${window.location.origin}${lowdefyConfig.basePath ?? ''}/api/auth`,
45
46
  plugins: [
46
47
  adminClient(),
48
+ emailOTPClient(),
47
49
  magicLinkClient(),
48
50
  oauthProviderClient(),
49
51
  organizationClient(),
@@ -57,8 +59,8 @@ const authClient = createAuthClient({
57
59
  // signal-driven refetch on the calls that would otherwise fire one, so
58
60
  // nothing competes with (and aborts) the refetch UpdateSession awaits.
59
61
  // 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
+ // can establish one (the sign-ins, and the two-factor, phone-number,
63
+ // email-otp and passkey verifies) keep their signal: its refetch is what makes the store
62
64
  // notice a login when no navigation follows.
63
65
  // disableSignal silences all atomListeners for the call, including the
64
66
  // organization plugin's org atoms - none are exposed to app config, so a
@@ -193,6 +195,9 @@ function AuthConfigured({ authConfig, children, serverUser }) {
193
195
  // oauth_query contract as oauth2Consent: the signed query rides from
194
196
  // window.location.search, so the call must run before any navigation.
195
197
  oauth2Continue: (params) => authClient.oauth2.continue(params),
198
+ // The one-time email code: send, then sign in with it.
199
+ emailOtpSend: (params) => authClient.emailOtp.sendVerificationOtp(params),
200
+ emailOtpVerify: (params) => authClient.signIn.emailOtp(params),
196
201
  phoneNumberRequestPasswordReset: (params) =>
197
202
  authClient.phoneNumber.requestPasswordReset(params),
198
203
  phoneNumberResetPassword: (params) => authClient.phoneNumber.resetPassword(params),
@@ -39,6 +39,8 @@ function AuthNotConfigured({ authConfig, children, user = null }) {
39
39
  listOrganizations: authNotConfigured,
40
40
  oauth2Consent: authNotConfigured,
41
41
  oauth2Continue: authNotConfigured,
42
+ emailOtpSend: authNotConfigured,
43
+ emailOtpVerify: authNotConfigured,
42
44
  phoneNumberRequestPasswordReset: authNotConfigured,
43
45
  phoneNumberResetPassword: authNotConfigured,
44
46
  phoneNumberSendOtp: authNotConfigured,
@@ -445,13 +445,13 @@ function createDocsMcpServer({ origin, honoContext } = {}) {
445
445
  'lowdefy_run_journey',
446
446
  {
447
447
  description:
448
- 'Drive a page of the running dev server headless through declarative steps and assert what happens — the way to verify behaviour (a form submits, a modal opens, a filter works), not just layout. Blocks are addressed by blockId. A step that fails stops the journey and is returned as data (passed: false, failure with index/step/expected/actual/message, later steps "skipped") — never as a tool error. Always returns the final page state and any screenshots taken (as images after the JSON text).',
448
+ 'Drive a page of the running dev server headless through declarative steps and assert what happens — the way to verify behaviour (a form submits, a modal opens, a filter works), not just layout. Blocks are addressed by blockId; a target object narrows to a grid row/cell ({"blockId": "grid", "row": 1, "column": "actions"}), to the control with exactly some text ({"blockId": "grid", "row": 1, "text": "Edit"}), or reaches portal-rendered controls page-wide by text alone ({"text": "OK"} for a confirm dialog or modal footer button, a menu item). A step that fails stops the journey and is returned as data (passed: false, failure with index/step/expected/actual/message, later steps "skipped") — never as a tool error. Always returns the final page state and any screenshots taken (as images after the JSON text).',
449
449
  inputSchema: {
450
450
  pageId: z.string().describe('The page id to open.'),
451
451
  steps: z
452
452
  .array(z.record(z.any()))
453
453
  .describe(
454
- 'Ordered steps, one key each: {"click": blockId} | {"fill": {"blockId", "value"}} | {"select": {"blockId", "value"}} (option by exact text) | {"press": "Enter" | "Mod+k"} (Mod is Meta/Control per platform) | {"wait": {"ms": n} | {"request": requestId} | {"state": path}} | {"screenshot": name?} | {"expect": {"state": {"path", "equals"}} | {"visible": blockId} | {"text": {"blockId", "contains"}} | {"url": {"contains"}}}. Each step gets 5s; after an interaction the runner waits for the page\'s pending events and requests to settle.'
454
+ 'Ordered steps, one key each: {"click": target} | {"fill": {...target, "value"}} | {"select": {...target, "value"}} (option by exact text) | {"press": "Enter" | "Mod+k"} (Mod is Meta/Control per platform) | {"wait": {"ms": n} | {"request": requestId} | {"state": path}} | {"screenshot": name?} | {"expect": {"state": {"path", "equals"}} | {"visible": target} | {"text": {...target, "contains"}} | {"url": {"contains"}}}. A target is a blockId string, or an object of {"blockId", "row" (zero-based grid row as displayed), "column" (grid col-id), "text" (exact text of the interactive control to use), "nth" (zero-based pick among several matches)}; "text" without "blockId" searches the whole page, which is how confirm dialog / modal footer buttons and dropdown menu items are reached. fill, select and expect.text need a blockId. Each step gets 5s; after an interaction the runner waits for the page\'s pending events and requests to settle.'
455
455
  ),
456
456
  user: userSchema,
457
457
  urlQuery: z
@@ -71,7 +71,7 @@ function getOverview() {
71
71
  ' Or subscribe instead of polling: `GET /lowdefy-docs/events` streams `build`, `client_error`, `server_error` and `restart` events over SSE (MCP clients receive the same as `notifications/message` from logger `lowdefy`).',
72
72
  '2. **Verify the page**: `GET /lowdefy-docs/page-config/{pageId}` — or `lowdefy_get_page_config`. The fully built page config, or its structured build errors.',
73
73
  '3. **See it rendered**: `GET /lowdefy-docs/screenshot/{pageId}` — or `lowdefy_screenshot_page`. PNG of the page in headless Chromium.',
74
- '4. **Verify behaviour**: `POST /lowdefy-docs/journey` `{pageId, steps, user, urlQuery}` — or `lowdefy_run_journey`. Drive the page headless with declarative steps (`click`, `fill`, `select`, `press`, `wait`, `screenshot`, `expect: { state | visible | text | url }`, blocks addressed by blockId) and assert what happens. A failing step stops the journey and returns `passed: false` with `failure: { index, step, expected, actual, message }` and the final page `state` — data, not an error.',
74
+ '4. **Verify behaviour**: `POST /lowdefy-docs/journey` `{pageId, steps, user, urlQuery}` — or `lowdefy_run_journey`. Drive the page headless with declarative steps (`click`, `fill`, `select`, `press`, `wait`, `screenshot`, `expect: { state | visible | text | url }`) and assert what happens. Targets are a blockId, or `{ blockId, row, column, text, nth }` to reach the button in a grid cell, a control by its exact text, or — with `text` alone — a confirm dialog / modal footer button rendered outside every block. A failing step stops the journey and returns `passed: false` with `failure: { index, step, expected, actual, message }` and the final page `state` — data, not an error.',
75
75
  '5. **Locate config**: `GET /lowdefy-docs/find/{id}?pageId=...` — or `lowdefy_find_config`. Which yaml file defines a page/block/request id.',
76
76
  '6. **Scaffold**: `lowdefy_scaffold_page` creates a canonical new page file (then register it in lowdefy.yaml).',
77
77
  '',