@agent-native/core 0.90.4 → 0.90.7
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/README.md +34 -7
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +21 -0
- package/corpus/core/docs/content/agent-native-toolkit.mdx +1 -1
- package/corpus/core/docs/content/locales/ar-SA/agent-native-toolkit.mdx +1 -1
- package/corpus/core/docs/content/locales/de-DE/agent-native-toolkit.mdx +1 -1
- package/corpus/core/docs/content/locales/es-ES/agent-native-toolkit.mdx +1 -1
- package/corpus/core/docs/content/locales/fr-FR/agent-native-toolkit.mdx +1 -1
- package/corpus/core/docs/content/locales/hi-IN/agent-native-toolkit.mdx +1 -1
- package/corpus/core/docs/content/locales/ja-JP/agent-native-toolkit.mdx +1 -1
- package/corpus/core/docs/content/locales/ko-KR/agent-native-toolkit.mdx +1 -1
- package/corpus/core/docs/content/locales/pt-BR/agent-native-toolkit.mdx +1 -1
- package/corpus/core/docs/content/locales/zh-CN/agent-native-toolkit.mdx +1 -1
- package/corpus/core/docs/content/locales/zh-TW/agent-native-toolkit.mdx +1 -1
- package/corpus/core/docs/content/toolkit-agent-ux.mdx +1 -1
- package/corpus/core/docs/content/toolkit-app-adapters.mdx +1 -1
- package/corpus/core/docs/content/toolkit-collaboration-ui.mdx +1 -1
- package/corpus/core/docs/content/toolkit-collaboration.mdx +1 -1
- package/corpus/core/docs/content/toolkit-command-navigation.mdx +2 -2
- package/corpus/core/docs/content/toolkit-comments-review.mdx +1 -1
- package/corpus/core/docs/content/toolkit-history.mdx +1 -1
- package/corpus/core/docs/content/toolkit-observability.mdx +1 -1
- package/corpus/core/docs/content/toolkit-org-team.mdx +1 -1
- package/corpus/core/docs/content/toolkit-resources.mdx +1 -1
- package/corpus/core/docs/content/toolkit-settings.mdx +1 -1
- package/corpus/core/docs/content/toolkit-setup-connections.mdx +1 -1
- package/corpus/core/docs/content/toolkit-sharing-ui.mdx +1 -1
- package/corpus/core/docs/content/toolkit-sharing.mdx +1 -1
- package/corpus/core/docs/content/toolkit-shell-hooks.mdx +1 -1
- package/corpus/core/docs/content/toolkit-ui.mdx +1 -1
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/durable-background.ts +18 -18
- package/corpus/core/src/agent/engine/ai-sdk-engine.ts +8 -2
- package/corpus/core/src/agent/engine/anthropic-engine.ts +11 -1
- package/corpus/core/src/agent/production-agent.ts +22 -11
- package/corpus/core/src/agent/run-manager.ts +17 -6
- package/corpus/core/src/agent/run-store.ts +16 -11
- package/corpus/core/src/cli/design-connect.ts +16 -2
- package/corpus/core/src/client/KeepTabOpenNotice.tsx +20 -15
- package/corpus/core/src/client/RunStuckBanner.tsx +14 -13
- package/corpus/core/src/client/agent-chat-adapter.ts +28 -17
- package/corpus/core/src/client/use-db-sync.ts +99 -1
- package/corpus/core/src/client/use-run-stuck-detection.ts +8 -5
- package/corpus/core/src/collab/client.ts +49 -4
- package/corpus/core/src/deploy/build.ts +103 -0
- package/corpus/core/src/server/core-routes-plugin.ts +137 -8
- package/corpus/templates/clips/changelog/2026-07-07-faster-local-whisper-dictation-start.md +6 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_speech.rs +311 -31
- package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +2 -0
- package/corpus/templates/content/AGENTS.md +1 -0
- package/corpus/templates/content/actions/_database-source-utils.ts +73 -8
- package/corpus/templates/content/actions/_database-utils.ts +2 -1
- package/corpus/templates/content/actions/prepare-builder-source-review.ts +20 -1
- package/corpus/templates/content/actions/stage-builder-source-bulk-update.ts +663 -0
- package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +33 -7
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +53 -4
- package/corpus/templates/content/app/components/editor/database/settings.tsx +24 -0
- package/corpus/templates/content/app/hooks/use-content-database.ts +70 -1
- package/corpus/templates/content/app/hooks/use-document-properties.ts +63 -5
- package/corpus/templates/content/app/i18n/zh-TW.ts +3 -0
- package/corpus/templates/content/app/i18n-data.ts +31 -0
- package/corpus/templates/content/changelog/2026-07-01-builder-source-rows-can-be-staged-in-bulk-with-a-reviewable.md +6 -0
- package/corpus/templates/content/changelog/2026-07-07-builder-backed-database-fields-now-edit-like-normal-content-fields.md +6 -0
- package/corpus/templates/content/parity/matrix.md +24 -24
- package/corpus/templates/content/parity/matrix.ts +2 -0
- package/corpus/templates/content/shared/api.ts +59 -4
- package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +9 -0
- package/corpus/templates/design/actions/connect-localhost.ts +26 -3
- package/corpus/templates/design/actions/open-visual-edit.ts +4 -37
- package/corpus/templates/design/actions/read-source-file.ts +28 -0
- package/corpus/templates/design/actions/update-file.ts +76 -7
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +207 -11
- package/corpus/templates/design/app/components/design/EditPanel.tsx +4 -0
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +387 -25
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +1110 -88
- package/corpus/templates/design/app/components/design/bridge/hit-test.bridge.ts +368 -16
- package/corpus/templates/design/app/components/design/edit-panel/fill-gradient-helpers.ts +24 -0
- package/corpus/templates/design/app/components/design/edit-panel/panel-primitives.tsx +27 -6
- package/corpus/templates/design/app/components/design/edit-panel/position-helpers.ts +18 -0
- package/corpus/templates/design/app/components/design/edit-panel/stroke-properties.tsx +5 -4
- package/corpus/templates/design/app/components/design/multi-screen/handle-hit-zones.ts +124 -0
- package/corpus/templates/design/app/components/design/multi-screen/types.ts +23 -0
- package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
- package/corpus/templates/design/app/i18n-data.ts +19 -0
- package/corpus/templates/design/changelog/2026-07-07-dragging-a-free-floating-element-into-a-layout-column-or-row.md +6 -0
- package/corpus/templates/design/changelog/2026-07-07-dragging-a-repeated-list-item-like-a-to-do-card-generated-fr.md +6 -0
- package/corpus/templates/design/changelog/2026-07-07-dragging-an-element-onto-a-button-or-chip-s-middle-no-longer.md +6 -0
- package/corpus/templates/design/changelog/2026-07-07-elements-dropped-into-another-board-shape-now-stay-exactly-w.md +6 -0
- package/corpus/templates/design/changelog/2026-07-07-fixed-drag-reordering-elements-within-a-screen-no-longer-sil.md +6 -0
- package/corpus/templates/design/changelog/2026-07-07-fixed-dragging-an-element-from-a-screen-onto-the-canvas-now-.md +6 -0
- package/corpus/templates/design/changelog/2026-07-07-fixed-dragging-elements-between-screens-into-ai-generated-co.md +6 -0
- package/corpus/templates/design/changelog/2026-07-07-fixed-moving-elements-between-screens-no-longer-bakes-intern.md +6 -0
- package/corpus/templates/design/changelog/2026-07-07-fixed-the-cross-screen-drop-indicator-now-points-at-the-corr.md +6 -0
- package/corpus/templates/design/changelog/2026-07-07-fixed-the-zoom-menu-no-longer-leaves-the-canvas-unresponsive.md +6 -0
- package/corpus/templates/design/changelog/2026-07-07-local-visual-edit-keeps-bridge-access-when-opening-previousl.md +6 -0
- package/corpus/templates/design/changelog/2026-07-07-local-visual-edit-now-keeps-the-live-app-connection-authenti.md +6 -0
- package/corpus/templates/design/changelog/2026-07-07-text-drawn-on-the-canvas-is-now-readable-white-instead-of-in.md +6 -0
- package/corpus/templates/design/server/source-workspace.ts +27 -3
- package/corpus/templates/design/shared/board-file.ts +359 -0
- package/corpus/templates/design/shared/breakpoint-media.ts +21 -3
- package/corpus/templates/design/shared/code-layer.ts +145 -10
- package/dist/agent/durable-background.d.ts +12 -12
- package/dist/agent/durable-background.js +18 -18
- package/dist/agent/durable-background.js.map +1 -1
- package/dist/agent/engine/ai-sdk-engine.d.ts.map +1 -1
- package/dist/agent/engine/ai-sdk-engine.js +8 -2
- package/dist/agent/engine/ai-sdk-engine.js.map +1 -1
- package/dist/agent/engine/anthropic-engine.d.ts.map +1 -1
- package/dist/agent/engine/anthropic-engine.js +11 -1
- package/dist/agent/engine/anthropic-engine.js.map +1 -1
- package/dist/agent/production-agent.d.ts +2 -2
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +18 -11
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/agent/run-manager.d.ts +7 -1
- package/dist/agent/run-manager.d.ts.map +1 -1
- package/dist/agent/run-manager.js +6 -1
- package/dist/agent/run-manager.js.map +1 -1
- package/dist/agent/run-store.d.ts +1 -1
- package/dist/agent/run-store.d.ts.map +1 -1
- package/dist/agent/run-store.js +15 -10
- package/dist/agent/run-store.js.map +1 -1
- package/dist/cli/design-connect.d.ts.map +1 -1
- package/dist/cli/design-connect.js +12 -2
- package/dist/cli/design-connect.js.map +1 -1
- package/dist/client/KeepTabOpenNotice.d.ts +9 -10
- package/dist/client/KeepTabOpenNotice.d.ts.map +1 -1
- package/dist/client/KeepTabOpenNotice.js +8 -4
- package/dist/client/KeepTabOpenNotice.js.map +1 -1
- package/dist/client/RunStuckBanner.d.ts.map +1 -1
- package/dist/client/RunStuckBanner.js +14 -13
- package/dist/client/RunStuckBanner.js.map +1 -1
- package/dist/client/agent-chat-adapter.d.ts.map +1 -1
- package/dist/client/agent-chat-adapter.js +25 -17
- package/dist/client/agent-chat-adapter.js.map +1 -1
- package/dist/client/use-db-sync.d.ts +18 -0
- package/dist/client/use-db-sync.d.ts.map +1 -1
- package/dist/client/use-db-sync.js +88 -1
- package/dist/client/use-db-sync.js.map +1 -1
- package/dist/client/use-run-stuck-detection.d.ts +1 -1
- package/dist/client/use-run-stuck-detection.d.ts.map +1 -1
- package/dist/client/use-run-stuck-detection.js +6 -4
- package/dist/client/use-run-stuck-detection.js.map +1 -1
- package/dist/collab/client.d.ts.map +1 -1
- package/dist/collab/client.js +43 -4
- package/dist/collab/client.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/deploy/build.d.ts +1 -0
- package/dist/deploy/build.d.ts.map +1 -1
- package/dist/deploy/build.js +57 -0
- package/dist/deploy/build.js.map +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/resources/handlers.d.ts +3 -3
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/core-routes-plugin.d.ts +10 -0
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +103 -9
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/docs/content/agent-native-toolkit.mdx +1 -1
- package/docs/content/locales/ar-SA/agent-native-toolkit.mdx +1 -1
- package/docs/content/locales/de-DE/agent-native-toolkit.mdx +1 -1
- package/docs/content/locales/es-ES/agent-native-toolkit.mdx +1 -1
- package/docs/content/locales/fr-FR/agent-native-toolkit.mdx +1 -1
- package/docs/content/locales/hi-IN/agent-native-toolkit.mdx +1 -1
- package/docs/content/locales/ja-JP/agent-native-toolkit.mdx +1 -1
- package/docs/content/locales/ko-KR/agent-native-toolkit.mdx +1 -1
- package/docs/content/locales/pt-BR/agent-native-toolkit.mdx +1 -1
- package/docs/content/locales/zh-CN/agent-native-toolkit.mdx +1 -1
- package/docs/content/locales/zh-TW/agent-native-toolkit.mdx +1 -1
- package/docs/content/toolkit-agent-ux.mdx +1 -1
- package/docs/content/toolkit-app-adapters.mdx +1 -1
- package/docs/content/toolkit-collaboration-ui.mdx +1 -1
- package/docs/content/toolkit-collaboration.mdx +1 -1
- package/docs/content/toolkit-command-navigation.mdx +2 -2
- package/docs/content/toolkit-comments-review.mdx +1 -1
- package/docs/content/toolkit-history.mdx +1 -1
- package/docs/content/toolkit-observability.mdx +1 -1
- package/docs/content/toolkit-org-team.mdx +1 -1
- package/docs/content/toolkit-resources.mdx +1 -1
- package/docs/content/toolkit-settings.mdx +1 -1
- package/docs/content/toolkit-setup-connections.mdx +1 -1
- package/docs/content/toolkit-sharing-ui.mdx +1 -1
- package/docs/content/toolkit-sharing.mdx +1 -1
- package/docs/content/toolkit-shell-hooks.mdx +1 -1
- package/docs/content/toolkit-ui.mdx +1 -1
- package/package.json +1 -1
|
@@ -175,15 +175,16 @@ export function RunStuckBanner({
|
|
|
175
175
|
}
|
|
176
176
|
const ownerId = autoRetryOwnerId ?? generatedOwnerIdRef.current;
|
|
177
177
|
const backgroundWorkerStillAlive = isFreshBackgroundWorker(state);
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
const
|
|
178
|
+
// Server-continued runs are recovered by the SERVER (chained continuation
|
|
179
|
+
// chunks + lost-handoff sweep); an automatic client abort would kill a live
|
|
180
|
+
// server-chained run. Auto-retry is therefore disabled unconditionally for
|
|
181
|
+
// these modes — not just a fresh-heartbeat worker — and the
|
|
182
|
+
// localStorage/Web-Locks auto-retry claim below is never taken for them (the
|
|
183
|
+
// adapter's follow loop is read-only, so multiple tabs need no retry dedup).
|
|
184
|
+
// Only the manual banner remains, on the wider server-owned threshold from
|
|
185
|
+
// useRunStuckDetection.
|
|
186
|
+
const isServerContinuedDispatch =
|
|
187
|
+
state.dispatchMode === "foreground-self-chain" ||
|
|
187
188
|
state.dispatchMode?.startsWith("background") === true;
|
|
188
189
|
|
|
189
190
|
const lastReportedRef = useRef<{
|
|
@@ -224,9 +225,9 @@ export function RunStuckBanner({
|
|
|
224
225
|
useEffect(() => {
|
|
225
226
|
if (
|
|
226
227
|
!autoRetry ||
|
|
227
|
-
// Server owns recovery for
|
|
228
|
-
//
|
|
229
|
-
|
|
228
|
+
// Server owns recovery for these dispatch modes — never auto-abort (see
|
|
229
|
+
// comment on isServerContinuedDispatch above).
|
|
230
|
+
isServerContinuedDispatch ||
|
|
230
231
|
backgroundWorkerStillAlive ||
|
|
231
232
|
!state.isStuck ||
|
|
232
233
|
!state.runId ||
|
|
@@ -263,7 +264,7 @@ export function RunStuckBanner({
|
|
|
263
264
|
autoRetry,
|
|
264
265
|
backgroundWorkerStillAlive,
|
|
265
266
|
busy,
|
|
266
|
-
|
|
267
|
+
isServerContinuedDispatch,
|
|
267
268
|
onRetry,
|
|
268
269
|
ownerId,
|
|
269
270
|
state.isStuck,
|
|
@@ -133,16 +133,15 @@ const MAX_HISTORY_LARGE_TOOL_ARGS_CHARS = 200_000;
|
|
|
133
133
|
const STARTUP_RESPONSE_TIMEOUT_MS = 45_000;
|
|
134
134
|
|
|
135
135
|
// ── Background follow mode ──────────────────────────────────────────────────
|
|
136
|
-
// For
|
|
137
|
-
//
|
|
138
|
-
// (fresh runId, same turnId), pre-inserts the successor run row BEFORE
|
|
139
|
-
// chunk completes (so /runs/active shows an active run continuously
|
|
140
|
-
// chunk boundaries), and a server sweep reaps lost handoffs into loud
|
|
141
|
-
// errors. The client
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
// the
|
|
145
|
-
// run are safe (no localStorage/Web-Locks claim needed).
|
|
136
|
+
// For server-continued runs (dispatchMode starts with "background" or equals
|
|
137
|
+
// "foreground-self-chain") the SERVER normally chains continuation chunks
|
|
138
|
+
// itself (fresh runId, same turnId), pre-inserts the successor run row BEFORE
|
|
139
|
+
// the old chunk completes (so /runs/active shows an active run continuously
|
|
140
|
+
// across chunk boundaries), and a server sweep reaps lost handoffs into loud
|
|
141
|
+
// terminal errors. The client demotes itself to a READER of server state for
|
|
142
|
+
// healthy handoffs. Foreground self-chain keeps one escape hatch: if the server
|
|
143
|
+
// reports that the self-dispatch itself failed before a successor claimed the
|
|
144
|
+
// run, the existing client auto-continue POST remains the fallback.
|
|
146
145
|
const BACKGROUND_FOLLOW_POLL_INTERVAL_MS = 1_000;
|
|
147
146
|
// How long the follow loop tolerates seeing NO active run for this turn before
|
|
148
147
|
// treating the turn as ended. The server pre-inserts the successor row before
|
|
@@ -1687,12 +1686,24 @@ export function createAgentChatAdapter(
|
|
|
1687
1686
|
if (mode) currentRunDispatchMode = mode;
|
|
1688
1687
|
};
|
|
1689
1688
|
|
|
1690
|
-
// Mode switch for recovery ownership: background-dispatched runs
|
|
1691
|
-
// recovered by the
|
|
1692
|
-
//
|
|
1693
|
-
// client
|
|
1694
|
-
const
|
|
1689
|
+
// Mode switch for recovery ownership: durable/background-dispatched runs
|
|
1690
|
+
// are always recovered by the server. Foreground self-chain follows the
|
|
1691
|
+
// server on healthy handoffs, but a loud self-dispatch failure falls back
|
|
1692
|
+
// to the client POST path because no successor run owns recovery yet.
|
|
1693
|
+
const isDurableBackgroundDispatch = () =>
|
|
1695
1694
|
currentRunDispatchMode?.startsWith("background") === true;
|
|
1695
|
+
const isForegroundSelfChainDispatch = () =>
|
|
1696
|
+
currentRunDispatchMode === "foreground-self-chain";
|
|
1697
|
+
const shouldFollowServerContinuation = (
|
|
1698
|
+
signal?: AgentAutoContinueSignal,
|
|
1699
|
+
) => {
|
|
1700
|
+
if (isDurableBackgroundDispatch()) return true;
|
|
1701
|
+
if (!isForegroundSelfChainDispatch()) return false;
|
|
1702
|
+
return (
|
|
1703
|
+
signal?.errorInfo?.errorCode !==
|
|
1704
|
+
"background_continuation_dispatch_failed"
|
|
1705
|
+
);
|
|
1706
|
+
};
|
|
1696
1707
|
|
|
1697
1708
|
const rememberRunSeq = (seq: number) => {
|
|
1698
1709
|
lastSeq = seq;
|
|
@@ -2966,7 +2977,7 @@ export function createAgentChatAdapter(
|
|
|
2966
2977
|
// following of server state instead. This is the fix for the
|
|
2967
2978
|
// client/server recovery race: client watchdog signals here are
|
|
2968
2979
|
// just "reattach", not "recover".
|
|
2969
|
-
if (
|
|
2980
|
+
if (shouldFollowServerContinuation(err) && threadId) {
|
|
2970
2981
|
yield* followBackgroundTurn(err);
|
|
2971
2982
|
return;
|
|
2972
2983
|
}
|
|
@@ -3145,7 +3156,7 @@ export function createAgentChatAdapter(
|
|
|
3145
3156
|
// synthetic-continuation POST below. (An initial POST that failed
|
|
3146
3157
|
// before any response headers arrived has no dispatch mode yet
|
|
3147
3158
|
// and keeps the foreground startup-retry path.)
|
|
3148
|
-
if (
|
|
3159
|
+
if (shouldFollowServerContinuation() && threadId) {
|
|
3149
3160
|
yield* followBackgroundTurn(
|
|
3150
3161
|
new AgentAutoContinueSignal({ reason: "stream_ended" }),
|
|
3151
3162
|
);
|
|
@@ -22,6 +22,13 @@ interface QueryClient {
|
|
|
22
22
|
const POLL_ABORT_MIN_MS = 10_000;
|
|
23
23
|
const SSE_FALLBACK_INTERVAL_MS = 15_000;
|
|
24
24
|
const POLL_AUTH_FAILURE_COOLDOWN_MS = 60_000;
|
|
25
|
+
/**
|
|
26
|
+
* Max cadence for SSE/poll-driven query invalidation in `useDbSync`. Events
|
|
27
|
+
* that arrive within this window of the first one in a burst are merged into
|
|
28
|
+
* a single `invalidateForEvents` call instead of one call per event — see the
|
|
29
|
+
* `queueInvalidateBatch` comment at the call site.
|
|
30
|
+
*/
|
|
31
|
+
const INVALIDATE_COALESCE_MS = 250;
|
|
25
32
|
|
|
26
33
|
class HttpStatusError extends Error {
|
|
27
34
|
status: number;
|
|
@@ -94,6 +101,46 @@ function isAuthFailure(error: unknown): boolean {
|
|
|
94
101
|
);
|
|
95
102
|
}
|
|
96
103
|
|
|
104
|
+
/**
|
|
105
|
+
* App-state keys that drive immediate UI navigation/interaction and must
|
|
106
|
+
* never sit behind the invalidation coalesce window (see
|
|
107
|
+
* `isInteractionCriticalSyncEvent`).
|
|
108
|
+
*/
|
|
109
|
+
const INTERACTION_CRITICAL_APP_STATE_KEYS = [
|
|
110
|
+
"navigate",
|
|
111
|
+
"show-questions",
|
|
112
|
+
"__set_url__",
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* True for sync events that drive immediate, agent-initiated UI navigation
|
|
117
|
+
* or interaction rather than passive data invalidation — app-state writes in
|
|
118
|
+
* general (they back `["app-state"]` queries directly), and specifically the
|
|
119
|
+
* `navigate` / `show-questions` / `__set_url__` app-state keys that
|
|
120
|
+
* `invalidateForEvents` special-cases into their own query keys below.
|
|
121
|
+
*
|
|
122
|
+
* `useDbSync` batches ordinary invalidation-driving events (action/collab/db
|
|
123
|
+
* change events) into one flush per `INVALIDATE_COALESCE_MS` so a chatty doc
|
|
124
|
+
* doesn't refetch on every keystroke. That trade-off is wrong for these
|
|
125
|
+
* events: agent-driven navigation, `set-url`, and guided-questions prompts
|
|
126
|
+
* must land as close to instantly as possible, so any batch containing one
|
|
127
|
+
* of these bypasses the coalesce window and flushes immediately instead.
|
|
128
|
+
*
|
|
129
|
+
* Exported as a small pure predicate so this classification is unit-testable
|
|
130
|
+
* independent of the transport/timer plumbing around it.
|
|
131
|
+
*/
|
|
132
|
+
export function isInteractionCriticalSyncEvent(event: SyncEvent): boolean {
|
|
133
|
+
return (
|
|
134
|
+
event.source === "app-state" &&
|
|
135
|
+
(event.key === "*" ||
|
|
136
|
+
INTERACTION_CRITICAL_APP_STATE_KEYS.some(
|
|
137
|
+
(key) =>
|
|
138
|
+
event.key === key ||
|
|
139
|
+
(typeof event.key === "string" && event.key.startsWith(`${key}:`)),
|
|
140
|
+
))
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
97
144
|
async function fetchPollJson<T>(
|
|
98
145
|
pollUrl: string,
|
|
99
146
|
since: number,
|
|
@@ -615,6 +662,51 @@ export function useDbSync(
|
|
|
615
662
|
// processed by THIS subscriber so stale poll re-deliveries are ignored.
|
|
616
663
|
let subscriberVersion = 0;
|
|
617
664
|
|
|
665
|
+
// Coalesce bursts of SSE-driven invalidation into at most one flush per
|
|
666
|
+
// INVALIDATE_COALESCE_MS. A chatty doc (many small agent edits, several
|
|
667
|
+
// peers editing at once) can otherwise deliver a handful of `action`/
|
|
668
|
+
// `collab` events within a few hundred ms, each independently calling
|
|
669
|
+
// `queryClient.invalidateQueries` and firing `onEvent` — every one of
|
|
670
|
+
// those touches whatever query subscribers are mounted (e.g. a
|
|
671
|
+
// full-page editor) even though the end state only needs to be
|
|
672
|
+
// refreshed once. Version bookkeeping stays synchronous (below) so
|
|
673
|
+
// freshness filtering for the NEXT batch is unaffected by the delay.
|
|
674
|
+
//
|
|
675
|
+
// This coalesce window is wrong for interaction-critical events (agent
|
|
676
|
+
// navigation, `set-url`, guided questions — see
|
|
677
|
+
// `isInteractionCriticalSyncEvent`): those must reach the UI immediately,
|
|
678
|
+
// not up to INVALIDATE_COALESCE_MS late. So a fresh batch containing one
|
|
679
|
+
// of those flushes synchronously (queued + new events together,
|
|
680
|
+
// canceling any pending timer) instead of joining the coalesce window.
|
|
681
|
+
// Pure invalidation bursts with no interaction-critical members keep the
|
|
682
|
+
// coalesced behavior.
|
|
683
|
+
let pendingInvalidateEvents: SyncEvent[] = [];
|
|
684
|
+
let invalidateTimer: ReturnType<typeof setTimeout> | null = null;
|
|
685
|
+
|
|
686
|
+
function flushInvalidateBatch() {
|
|
687
|
+
if (invalidateTimer) {
|
|
688
|
+
clearTimeout(invalidateTimer);
|
|
689
|
+
invalidateTimer = null;
|
|
690
|
+
}
|
|
691
|
+
if (pendingInvalidateEvents.length === 0) return;
|
|
692
|
+
const batch = pendingInvalidateEvents;
|
|
693
|
+
pendingInvalidateEvents = [];
|
|
694
|
+
invalidateForEvents(batch);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function queueInvalidateBatch(events: SyncEvent[]) {
|
|
698
|
+
pendingInvalidateEvents.push(...events);
|
|
699
|
+
if (events.some(isInteractionCriticalSyncEvent)) {
|
|
700
|
+
flushInvalidateBatch();
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
if (invalidateTimer) return;
|
|
704
|
+
invalidateTimer = setTimeout(
|
|
705
|
+
flushInvalidateBatch,
|
|
706
|
+
INVALIDATE_COALESCE_MS,
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
|
|
618
710
|
function hasAppStateEvent(events: SyncEvent[], key: string): boolean {
|
|
619
711
|
return events.some(
|
|
620
712
|
(event) =>
|
|
@@ -722,7 +814,7 @@ export function useDbSync(
|
|
|
722
814
|
});
|
|
723
815
|
|
|
724
816
|
if (freshEvents.length > 0) {
|
|
725
|
-
|
|
817
|
+
queueInvalidateBatch(freshEvents);
|
|
726
818
|
}
|
|
727
819
|
|
|
728
820
|
const maxEventVersion = freshEvents.reduce(
|
|
@@ -746,6 +838,12 @@ export function useDbSync(
|
|
|
746
838
|
});
|
|
747
839
|
|
|
748
840
|
return () => {
|
|
841
|
+
if (invalidateTimer) {
|
|
842
|
+
clearTimeout(invalidateTimer);
|
|
843
|
+
// Flush synchronously on unmount so a pending batch isn't silently
|
|
844
|
+
// dropped (e.g. a route change right after an agent edit lands).
|
|
845
|
+
flushInvalidateBatch();
|
|
846
|
+
}
|
|
749
847
|
transport.remove(id);
|
|
750
848
|
// If the registry still holds this transport, and the transport is now
|
|
751
849
|
// empty, evict it so the next mount gets a fresh instance rather than a
|
|
@@ -26,7 +26,7 @@ export interface RunStuckState {
|
|
|
26
26
|
heartbeatAt: number | null;
|
|
27
27
|
/** Milliseconds since `heartbeatAt`, computed against the server clock. */
|
|
28
28
|
heartbeatSinceMs: number | null;
|
|
29
|
-
/** How the run was dispatched, e.g. foreground or background-processing. */
|
|
29
|
+
/** How the run was dispatched/continued, e.g. foreground-self-chain or background-processing. */
|
|
30
30
|
dispatchMode: string | null;
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -133,10 +133,13 @@ export function useRunStuckDetection({
|
|
|
133
133
|
heartbeatAt != null ? nowMs - heartbeatAt : null;
|
|
134
134
|
const dispatchMode =
|
|
135
135
|
typeof data.dispatchMode === "string" ? data.dispatchMode : null;
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
const
|
|
136
|
+
// Server-continued runs get the wider threshold: the server's own
|
|
137
|
+
// recovery (150s no-progress backstop + chained continuations) must
|
|
138
|
+
// get its chance before the user sees a "stuck" affordance.
|
|
139
|
+
const serverContinued =
|
|
140
|
+
dispatchMode === "foreground-self-chain" ||
|
|
141
|
+
dispatchMode?.startsWith("background") === true;
|
|
142
|
+
const effectiveThresholdMs = serverContinued
|
|
140
143
|
? backgroundStuckThresholdMs
|
|
141
144
|
: stuckThresholdMs;
|
|
142
145
|
const isStuck = Boolean(
|
|
@@ -94,6 +94,31 @@ function isDocumentHidden(): boolean {
|
|
|
94
94
|
);
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Content equality for deduped active-user lists. `dedupeCollabUsersByEmail`
|
|
99
|
+
* always allocates a fresh array (and fresh per-user objects), so callers
|
|
100
|
+
* that only want to know "did the actual user set change" need this instead
|
|
101
|
+
* of an identity check. Order-sensitive: the dedupe helper iterates awareness
|
|
102
|
+
* states in a stable Map insertion order, so a reordering here does reflect
|
|
103
|
+
* a real change (a client joined/left and re-joined).
|
|
104
|
+
*/
|
|
105
|
+
function collabUsersEqual(a: CollabUser[], b: CollabUser[]): boolean {
|
|
106
|
+
if (a === b) return true;
|
|
107
|
+
if (a.length !== b.length) return false;
|
|
108
|
+
for (let i = 0; i < a.length; i++) {
|
|
109
|
+
const left = a[i]!;
|
|
110
|
+
const right = b[i]!;
|
|
111
|
+
if (
|
|
112
|
+
left.email !== right.email ||
|
|
113
|
+
left.name !== right.name ||
|
|
114
|
+
left.color !== right.color
|
|
115
|
+
) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
97
122
|
/**
|
|
98
123
|
* Leader election for applying authoritative external snapshots into a shared
|
|
99
124
|
* collaborative document.
|
|
@@ -566,10 +591,30 @@ class CollabDocConnection {
|
|
|
566
591
|
}
|
|
567
592
|
}
|
|
568
593
|
});
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
594
|
+
const nextActiveUsers = dedupeCollabUsersByEmail(users);
|
|
595
|
+
// Awareness "change" fires for every remote broadcast — including cursor
|
|
596
|
+
// jiggles, re-published-but-unchanged heartbeat state, and edits to
|
|
597
|
+
// presence fields (recentEdits, selection) that don't affect who's
|
|
598
|
+
// active. dedupeCollabUsersByEmail always returns a fresh array, so
|
|
599
|
+
// without this comparison every one of those events would hand
|
|
600
|
+
// `activeUsers` a new identity and force every consumer (e.g. a
|
|
601
|
+
// full-page editor keying a useMemo/effect off it) to re-render even
|
|
602
|
+
// though the actual user list is unchanged. Reuse the previous
|
|
603
|
+
// reference when the deduped content is the same, matching the
|
|
604
|
+
// stable-ref discipline `usePresence`'s shallowEqualOthers already
|
|
605
|
+
// applies to `others`.
|
|
606
|
+
const activeUsers = collabUsersEqual(
|
|
607
|
+
this.snapshot.activeUsers,
|
|
608
|
+
nextActiveUsers,
|
|
609
|
+
)
|
|
610
|
+
? this.snapshot.activeUsers
|
|
611
|
+
: nextActiveUsers;
|
|
612
|
+
if (
|
|
613
|
+
activeUsers !== this.snapshot.activeUsers ||
|
|
614
|
+
hasAgent !== this.snapshot.agentPresent
|
|
615
|
+
) {
|
|
616
|
+
this.setSnapshot({ activeUsers, agentPresent: hasAgent });
|
|
617
|
+
}
|
|
573
618
|
|
|
574
619
|
// Fast awareness push: whenever awareness changes (e.g. cursor moves,
|
|
575
620
|
// setPresence() calls), schedule a throttled POST so peers receive updates
|
|
@@ -2427,6 +2427,105 @@ export const config = {
|
|
|
2427
2427
|
);
|
|
2428
2428
|
}
|
|
2429
2429
|
|
|
2430
|
+
export function assertSingleTemplateNetlifyBuildOutput(
|
|
2431
|
+
projectCwd: string,
|
|
2432
|
+
): void {
|
|
2433
|
+
const failures: string[] = [];
|
|
2434
|
+
const publishDir = path.join(projectCwd, "dist");
|
|
2435
|
+
const internalDir = path.join(projectCwd, ".netlify", "functions-internal");
|
|
2436
|
+
const serverDir = path.join(internalDir, "server");
|
|
2437
|
+
const serverEntryPath = path.join(serverDir, "server.mjs");
|
|
2438
|
+
const serverMainPath = path.join(serverDir, "main.mjs");
|
|
2439
|
+
|
|
2440
|
+
if (!fs.existsSync(publishDir)) {
|
|
2441
|
+
failures.push("missing publish directory: dist");
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
if (!fs.existsSync(serverDir)) {
|
|
2445
|
+
failures.push(
|
|
2446
|
+
"missing scanned Netlify server function: .netlify/functions-internal/server",
|
|
2447
|
+
);
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
if (!fs.existsSync(serverMainPath)) {
|
|
2451
|
+
failures.push(
|
|
2452
|
+
"missing Netlify server bundle: .netlify/functions-internal/server/main.mjs",
|
|
2453
|
+
);
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
if (!fs.existsSync(serverEntryPath)) {
|
|
2457
|
+
failures.push(
|
|
2458
|
+
"missing Netlify server entry: .netlify/functions-internal/server/server.mjs",
|
|
2459
|
+
);
|
|
2460
|
+
} else {
|
|
2461
|
+
const serverEntry = fs.readFileSync(serverEntryPath, "utf-8");
|
|
2462
|
+
if (!/\bpath\s*:\s*["']\/\*["']/.test(serverEntry)) {
|
|
2463
|
+
failures.push(
|
|
2464
|
+
'Netlify server entry is missing the "/*" catch-all function path',
|
|
2465
|
+
);
|
|
2466
|
+
}
|
|
2467
|
+
if (!serverEntry.includes('"/.netlify/*"')) {
|
|
2468
|
+
failures.push(
|
|
2469
|
+
'Netlify server catch-all is missing the "/.netlify/*" exclusion',
|
|
2470
|
+
);
|
|
2471
|
+
}
|
|
2472
|
+
if (!serverEntry.includes("./main.mjs")) {
|
|
2473
|
+
failures.push(
|
|
2474
|
+
"Netlify server entry does not reference the generated main.mjs bundle",
|
|
2475
|
+
);
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
if (isDurableBackgroundDeployEnabled()) {
|
|
2480
|
+
const backgroundDir = path.join(
|
|
2481
|
+
internalDir,
|
|
2482
|
+
AGENT_BACKGROUND_FUNCTION_NAME,
|
|
2483
|
+
);
|
|
2484
|
+
const backgroundEntryPath = path.join(
|
|
2485
|
+
backgroundDir,
|
|
2486
|
+
`${AGENT_BACKGROUND_FUNCTION_NAME}.mjs`,
|
|
2487
|
+
);
|
|
2488
|
+
if (!fs.existsSync(backgroundEntryPath)) {
|
|
2489
|
+
failures.push(
|
|
2490
|
+
`durable background is enabled but ${path.relative(
|
|
2491
|
+
projectCwd,
|
|
2492
|
+
backgroundEntryPath,
|
|
2493
|
+
)} was not emitted`,
|
|
2494
|
+
);
|
|
2495
|
+
} else {
|
|
2496
|
+
const backgroundEntry = fs.readFileSync(backgroundEntryPath, "utf-8");
|
|
2497
|
+
if (!/\bbackground\s*:\s*true\b/.test(backgroundEntry)) {
|
|
2498
|
+
failures.push(
|
|
2499
|
+
`durable background entry ${path.relative(
|
|
2500
|
+
projectCwd,
|
|
2501
|
+
backgroundEntryPath,
|
|
2502
|
+
)} is missing background: true`,
|
|
2503
|
+
);
|
|
2504
|
+
}
|
|
2505
|
+
if (/^\s*path\s*:/m.test(backgroundEntry)) {
|
|
2506
|
+
failures.push(
|
|
2507
|
+
`durable background entry ${path.relative(
|
|
2508
|
+
projectCwd,
|
|
2509
|
+
backgroundEntryPath,
|
|
2510
|
+
)} must not declare a custom path`,
|
|
2511
|
+
);
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
if (failures.length > 0) {
|
|
2517
|
+
throw new Error(
|
|
2518
|
+
"[deploy] Netlify deploy guard failed; refusing to publish an output " +
|
|
2519
|
+
"that would likely serve Netlify 404s:\n" +
|
|
2520
|
+
failures.map((failure) => `- ${failure}`).join("\n"),
|
|
2521
|
+
);
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
console.log(
|
|
2525
|
+
"[deploy] Netlify deploy guard passed: publish dir and catch-all server function are present.",
|
|
2526
|
+
);
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2430
2529
|
function copyInstalledLibsqlNativePackages(serverDir: string | undefined) {
|
|
2431
2530
|
if (!serverDir || !fs.existsSync(serverDir)) return;
|
|
2432
2531
|
const nodeModulesRoots = nodeModulesAncestors(cwd);
|
|
@@ -2976,6 +3075,10 @@ export default bundle;
|
|
|
2976
3075
|
}
|
|
2977
3076
|
}
|
|
2978
3077
|
|
|
3078
|
+
if (preset === "netlify") {
|
|
3079
|
+
assertSingleTemplateNetlifyBuildOutput(cwd);
|
|
3080
|
+
}
|
|
3081
|
+
|
|
2979
3082
|
// Resolve remaining bare npm imports by bundling them into _libs/.
|
|
2980
3083
|
// Nitro sometimes leaves small packages as externals even with noExternals.
|
|
2981
3084
|
if (preset.startsWith("cloudflare") || preset.startsWith("deno")) {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
setCookie,
|
|
9
9
|
deleteCookie,
|
|
10
10
|
getRequestURL,
|
|
11
|
+
getRequestIP,
|
|
11
12
|
} from "h3";
|
|
12
13
|
import type { H3Event } from "h3";
|
|
13
14
|
import { readMultipartFormData } from "h3";
|
|
@@ -285,9 +286,16 @@ const BUILDER_WAITLIST_DEFAULT_USE_CASE = "builder_agent_background_coding";
|
|
|
285
286
|
const BUILDER_WAITLIST_USE_CASES = new Set([
|
|
286
287
|
BUILDER_WAITLIST_DEFAULT_USE_CASE,
|
|
287
288
|
"design_publish_app",
|
|
289
|
+
"docs_build_online_waitlist",
|
|
288
290
|
]);
|
|
289
291
|
const BUILDER_WAITLIST_FORM_TIMEOUT_MS = 8000;
|
|
290
292
|
const BUILDER_WAITLIST_TEXT_LIMIT = 4000;
|
|
293
|
+
const BUILDER_WAITLIST_RATE_LIMIT_WINDOW_MS = 60_000;
|
|
294
|
+
const BUILDER_WAITLIST_RATE_LIMIT_MAX = 5;
|
|
295
|
+
const builderWaitlistRateLimitHits = new Map<
|
|
296
|
+
string,
|
|
297
|
+
{ count: number; resetAt: number }
|
|
298
|
+
>();
|
|
291
299
|
|
|
292
300
|
interface BuilderWaitlistFormTarget {
|
|
293
301
|
formId: string;
|
|
@@ -295,6 +303,7 @@ interface BuilderWaitlistFormTarget {
|
|
|
295
303
|
}
|
|
296
304
|
|
|
297
305
|
export interface BuilderWaitlistBody {
|
|
306
|
+
email?: unknown;
|
|
298
307
|
prompt?: unknown;
|
|
299
308
|
orgName?: unknown;
|
|
300
309
|
appUrl?: unknown;
|
|
@@ -332,6 +341,106 @@ function normalizeBuilderWaitlistUseCase(value: unknown): string {
|
|
|
332
341
|
: BUILDER_WAITLIST_DEFAULT_USE_CASE;
|
|
333
342
|
}
|
|
334
343
|
|
|
344
|
+
function isValidWaitlistEmail(email: string): boolean {
|
|
345
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function isAnonymousWaitlistSessionEmail(email: string): boolean {
|
|
349
|
+
return email.startsWith("anon-") && email.endsWith("@agent-native.com");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function resolveWaitlistEmail(
|
|
353
|
+
sessionEmail: string | undefined,
|
|
354
|
+
bodyEmail: unknown,
|
|
355
|
+
): string | null {
|
|
356
|
+
const provided = cleanBuilderWaitlistText(bodyEmail, 320);
|
|
357
|
+
if (provided && isValidWaitlistEmail(provided)) return provided;
|
|
358
|
+
if (sessionEmail && !isAnonymousWaitlistSessionEmail(sessionEmail)) {
|
|
359
|
+
return sessionEmail;
|
|
360
|
+
}
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function normalizeWaitlistRateLimitPart(value: string): string {
|
|
365
|
+
return value.trim().toLowerCase();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function getBuilderWaitlistClientIp(event: H3Event): string | undefined {
|
|
369
|
+
const trusted =
|
|
370
|
+
getHeader(event, "x-nf-client-connection-ip") ??
|
|
371
|
+
getHeader(event, "cf-connecting-ip") ??
|
|
372
|
+
getHeader(event, "true-client-ip") ??
|
|
373
|
+
getHeader(event, "x-real-ip");
|
|
374
|
+
if (trusted && trusted.trim()) return trusted.trim();
|
|
375
|
+
|
|
376
|
+
const forwardedFor = getHeader(event, "x-forwarded-for");
|
|
377
|
+
const forwardedClientIp = forwardedFor?.split(",")[0]?.trim();
|
|
378
|
+
if (forwardedClientIp) return forwardedClientIp;
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
return getRequestIP(event) ?? undefined;
|
|
382
|
+
} catch {
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function getBuilderWaitlistRateLimitKeys(
|
|
388
|
+
event: H3Event,
|
|
389
|
+
email: string,
|
|
390
|
+
): string[] {
|
|
391
|
+
const clientIp = getBuilderWaitlistClientIp(event);
|
|
392
|
+
return [
|
|
393
|
+
`email:${normalizeWaitlistRateLimitPart(email)}`,
|
|
394
|
+
`ip:${normalizeWaitlistRateLimitPart(clientIp ?? "unknown")}`,
|
|
395
|
+
];
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function checkBuilderWaitlistRateLimit(
|
|
399
|
+
event: H3Event,
|
|
400
|
+
email: string,
|
|
401
|
+
now = Date.now(),
|
|
402
|
+
): { ok: true } | { ok: false; retryAfterSeconds: number } {
|
|
403
|
+
const keys = getBuilderWaitlistRateLimitKeys(event, email);
|
|
404
|
+
let retryAfterMs = 0;
|
|
405
|
+
|
|
406
|
+
for (const key of keys) {
|
|
407
|
+
const entry = builderWaitlistRateLimitHits.get(key);
|
|
408
|
+
if (!entry) continue;
|
|
409
|
+
if (entry.resetAt <= now) {
|
|
410
|
+
builderWaitlistRateLimitHits.delete(key);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (entry.count >= BUILDER_WAITLIST_RATE_LIMIT_MAX) {
|
|
414
|
+
retryAfterMs = Math.max(retryAfterMs, entry.resetAt - now);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (retryAfterMs > 0) {
|
|
419
|
+
return {
|
|
420
|
+
ok: false,
|
|
421
|
+
retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000)),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
for (const key of keys) {
|
|
426
|
+
const entry = builderWaitlistRateLimitHits.get(key);
|
|
427
|
+
if (!entry || entry.resetAt <= now) {
|
|
428
|
+
builderWaitlistRateLimitHits.set(key, {
|
|
429
|
+
count: 1,
|
|
430
|
+
resetAt: now + BUILDER_WAITLIST_RATE_LIMIT_WINDOW_MS,
|
|
431
|
+
});
|
|
432
|
+
} else {
|
|
433
|
+
entry.count += 1;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return { ok: true };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function resetBuilderWaitlistRateLimitForTests() {
|
|
441
|
+
builderWaitlistRateLimitHits.clear();
|
|
442
|
+
}
|
|
443
|
+
|
|
335
444
|
function normalizeHttpOrigin(value: string): string | null {
|
|
336
445
|
try {
|
|
337
446
|
const url = new URL(value);
|
|
@@ -1869,15 +1978,35 @@ export function createCoreRoutesPlugin(
|
|
|
1869
1978
|
return { error: "Method not allowed" };
|
|
1870
1979
|
}
|
|
1871
1980
|
const session = await getSession(event).catch(() => null);
|
|
1872
|
-
if (!session?.email) {
|
|
1873
|
-
setResponseStatus(event, 401);
|
|
1874
|
-
return { error: "Authentication required" };
|
|
1875
|
-
}
|
|
1876
1981
|
const body = ((await readBody(event).catch(() => ({}))) ??
|
|
1877
1982
|
{}) as BuilderWaitlistBody;
|
|
1983
|
+
const waitlistEmail = resolveWaitlistEmail(
|
|
1984
|
+
session?.email,
|
|
1985
|
+
body.email,
|
|
1986
|
+
);
|
|
1987
|
+
if (!waitlistEmail) {
|
|
1988
|
+
setResponseStatus(event, 400);
|
|
1989
|
+
return { error: "Valid email required" };
|
|
1990
|
+
}
|
|
1991
|
+
const waitlistRateLimit = checkBuilderWaitlistRateLimit(
|
|
1992
|
+
event,
|
|
1993
|
+
waitlistEmail,
|
|
1994
|
+
);
|
|
1995
|
+
if (!waitlistRateLimit.ok) {
|
|
1996
|
+
setResponseStatus(event, 429);
|
|
1997
|
+
setResponseHeader(
|
|
1998
|
+
event,
|
|
1999
|
+
"Retry-After",
|
|
2000
|
+
String(waitlistRateLimit.retryAfterSeconds),
|
|
2001
|
+
);
|
|
2002
|
+
return {
|
|
2003
|
+
error:
|
|
2004
|
+
"Too many waitlist requests. Please try again in a minute.",
|
|
2005
|
+
};
|
|
2006
|
+
}
|
|
1878
2007
|
const waitlistPayload = buildBuilderWaitlistFormPayload(
|
|
1879
2008
|
event,
|
|
1880
|
-
|
|
2009
|
+
waitlistEmail,
|
|
1881
2010
|
body,
|
|
1882
2011
|
);
|
|
1883
2012
|
const waitlistSource = waitlistPayload.data.source;
|
|
@@ -1886,14 +2015,14 @@ export function createCoreRoutesPlugin(
|
|
|
1886
2015
|
try {
|
|
1887
2016
|
formSubmission = await submitBuilderWaitlistForm(
|
|
1888
2017
|
event,
|
|
1889
|
-
|
|
2018
|
+
waitlistEmail,
|
|
1890
2019
|
body,
|
|
1891
2020
|
);
|
|
1892
2021
|
} catch (err) {
|
|
1893
2022
|
await trackBuilderLifecycle(
|
|
1894
2023
|
event,
|
|
1895
2024
|
"builder branch waitlist form failed",
|
|
1896
|
-
|
|
2025
|
+
waitlistEmail,
|
|
1897
2026
|
{
|
|
1898
2027
|
reason:
|
|
1899
2028
|
err instanceof Error ? err.message : "unknown_waitlist_error",
|
|
@@ -1911,7 +2040,7 @@ export function createCoreRoutesPlugin(
|
|
|
1911
2040
|
await trackBuilderLifecycle(
|
|
1912
2041
|
event,
|
|
1913
2042
|
"builder branch waitlist joined",
|
|
1914
|
-
|
|
2043
|
+
waitlistEmail,
|
|
1915
2044
|
{
|
|
1916
2045
|
formId: formSubmission.formId ?? null,
|
|
1917
2046
|
formSubmitted: formSubmission.submitted,
|