From cfb7c1f747508f3d4a52dc8c9e681d28592d5eac Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 18:54:56 +0000 Subject: [PATCH] feat(core): server-action dispatcher factory createServerActionHandler (#2904) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ActionSchema.body executes server-side; the client dispatches, it never interprets. Until now only the console could dispatch: useConsoleActionRuntime and RecordDetailView each hand-rolled the same POST /api/v1/actions/{object}/ {action} and drifted twice (objectstack#3913 envelope, framework#3935 identity). Standalone @object-ui/core consumers had no supported way to run a body at all — #2895's improved error was a dead end. - core: createServerActionHandler({ fetch, baseUrl, resolveObject, resolveRecordId, onRefresh }) owns the protocol once — name-only identity (ADR-0110 D1), the record-id dance (also exported as resolveServerActionRecordId, plus isRecordScopedAction), a re-entrancy guard, the POST body contract, and the /actions envelope rule. interpretActionResponse / readActionPayload / actionErrorDetail move from app-shell internals into core as public exports. - app-shell: both console copies collapse into utils/consoleServerAction.createConsoleServerActionHandler — the DOM choreography (popup pre-open, newTabUrl zero-roundtrip fast path, redirectUrl convention) layered over the core factory. RecordDetailView thereby adopts name-only identity and stops leaking _rowRecord to the server. - ratchet: actions-envelope.ratchet.test.ts is now stronger — NO app-shell file may name the /actions route in code (marketplaceApi excepted). - docs: core README + guide/architecture-overview.md document the factory; the stale 'script = inline JavaScript' row corrected. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NVPjPzmmAJ2Ngtvgg5MSRa --- .changeset/core-server-dispatcher-factory.md | 39 ++ content/docs/guide/architecture-overview.md | 33 +- .../src/actions-envelope.ratchet.test.ts | 64 ++-- .../src/hooks/useConsoleActionRuntime.tsx | 221 ++--------- .../__tests__/consoleServerAction.test.tsx | 207 ++++++++++ .../src/utils/consoleServerAction.ts | 197 ++++++++++ packages/app-shell/src/utils/flowResponse.ts | 2 +- .../app-shell/src/views/RecordDetailView.tsx | 195 ++-------- packages/core/README.md | 29 ++ packages/core/src/actions/ActionRunner.ts | 8 +- .../actions}/__tests__/actionResponse.test.ts | 0 .../__tests__/serverActionHandler.test.ts | 360 ++++++++++++++++++ .../src/actions}/actionErrorDetail.ts | 12 + .../src/actions}/actionResponse.ts | 15 +- packages/core/src/actions/index.ts | 3 + .../core/src/actions/serverActionHandler.ts | 265 +++++++++++++ 16 files changed, 1258 insertions(+), 392 deletions(-) create mode 100644 .changeset/core-server-dispatcher-factory.md create mode 100644 packages/app-shell/src/utils/__tests__/consoleServerAction.test.tsx create mode 100644 packages/app-shell/src/utils/consoleServerAction.ts rename packages/{app-shell/src/utils => core/src/actions}/__tests__/actionResponse.test.ts (100%) create mode 100644 packages/core/src/actions/__tests__/serverActionHandler.test.ts rename packages/{app-shell/src/utils => core/src/actions}/actionErrorDetail.ts (74%) rename packages/{app-shell/src/utils => core/src/actions}/actionResponse.ts (90%) create mode 100644 packages/core/src/actions/serverActionHandler.ts diff --git a/.changeset/core-server-dispatcher-factory.md b/.changeset/core-server-dispatcher-factory.md new file mode 100644 index 0000000000..30187b34e3 --- /dev/null +++ b/.changeset/core-server-dispatcher-factory.md @@ -0,0 +1,39 @@ +--- +"@object-ui/core": minor +"@object-ui/app-shell": patch +--- + +`@object-ui/core` now ships the server-action dispatcher factory — +`createServerActionHandler({ fetch, baseUrl, resolveObject, ... })` — so any +consumer of the runner (standalone renderers, SDUI hosts, embedded usage) can +run `action.body` script actions by registering the produced handler, instead +of dead-ending on the built-in `executeScript`'s "must be executed server-side" +error with no supported way to make it run (objectui#2904, the follow-up +objectui#2896 deferred). + +The factory is deliberately opinion-free about the three things core has no +business deciding — auth (`fetch` is an injected authenticated wrapper), origin +(`baseUrl` string or thunk; no bundler env convention), and fallback object +scope (`resolveObject`) — and owns everything protocol-shaped, once: + +- name-based action identity (ADR-0110 D1 — `target` is a binding expression, + never an identity); +- the record-id resolution dance, also exported as + `resolveServerActionRecordId` (`_rowRecord`, `recordIdField`, toolbar + selection fallback with its single/zero-select guards, aggregate + `_selectedIds` bypass), replaceable wholesale via `resolveRecordId` for + hosts with their own policy (record pages); +- a re-entrancy guard per action+record; +- the `/actions` response-envelope rule: `interpretActionResponse`, + `readActionPayload` and `actionErrorDetail` moved from `@object-ui/app-shell` + internals into core and are now public exports. + +`@object-ui/app-shell`'s two hand-rolled copies of this POST — +`useConsoleActionRuntime.serverActionHandler` and `RecordDetailView`'s — are +collapsed into one console wrapper (`createConsoleServerActionHandler`) that +layers the browser-only choreography (popup pre-open dance, zero-roundtrip +`newTabUrl` fast path, `redirectUrl` convention) over the core factory. The +copies had already drifted twice (objectstack#3913 — envelope; framework#3935 — +identity, fixed in one copy only): RecordDetailView now also dispatches by +declarative `name` instead of `target || name`, and no longer leaks the +client-side `_rowRecord` stash to the server. diff --git a/content/docs/guide/architecture-overview.md b/content/docs/guide/architecture-overview.md index 5958156a43..efd06396a7 100644 --- a/content/docs/guide/architecture-overview.md +++ b/content/docs/guide/architecture-overview.md @@ -252,12 +252,43 @@ Executes action schemas and returns directives: | Type | Description | |------|-------------| -| `script` | Execute inline JavaScript | +| `script` | Dispatch a named/registered script action — an `action.body` always executes **server-side** | | `url` | Navigate to a URL | | `api` | Make an API request (AJAX) | | `modal` | Open a modal with a nested schema | | `flow` | Execute a multi-step action sequence | +### Server Action Dispatch (`serverActionHandler.ts`) + +`ActionSchema.body` (the spec's preferred binding for script actions) executes +server-side via `POST /api/v1/actions/{object}/{action}` — the client +dispatches, it never interprets a body (a browser cannot enforce the L2 +sandbox's capabilities/timeout/memory contract, and L1 is formula-engine CEL, +a different dialect from the `${...}` evaluator). + +`createServerActionHandler` is the core factory every consumer uses to build +that dispatch. It owns the protocol (name-based action identity, record-id +resolution, re-entrancy guard, the `/actions` response-envelope rule) and +injects the three things core has no opinion about: + +```typescript +import { createServerActionHandler } from '@object-ui/core'; + +const script = createServerActionHandler({ + fetch: myAuthenticatedFetch, // auth is yours + baseUrl: 'https://api.example.com', // origin is yours ('' = same-origin) + resolveObject: () => currentObject, // fallback object scope is yours + onRefresh: () => notifyDataChanged(), // data invalidation is yours +}); + +// Registered handlers beat the built-in executors: + +``` + +The console builds on the same factory (`@object-ui/app-shell`'s +`createConsoleServerActionHandler` adds the popup pre-open dance and the +`redirectUrl` convention on top). + ### TransactionManager (`TransactionManager.ts`) Wraps multi-step actions in transactions for consistency, supporting rollback on failure. diff --git a/packages/app-shell/src/actions-envelope.ratchet.test.ts b/packages/app-shell/src/actions-envelope.ratchet.test.ts index 0bfda44ac1..f18d7af048 100644 --- a/packages/app-shell/src/actions-envelope.ratchet.test.ts +++ b/packages/app-shell/src/actions-envelope.ratchet.test.ts @@ -2,8 +2,8 @@ * ObjectUI * Copyright (c) 2024-present ObjectStack Inc. * - * objectstack#3913 ratchet — every `/api/v1/actions` caller goes through - * `interpretActionResponse`. + * objectstack#3913 ratchet — no app-shell file dispatches `/api/v1/actions` + * by hand. * * The bug this guards: the `/actions` response wraps TWICE (the route's own * `{success, data}` inside the dispatcher's), and a failure has three shapes @@ -15,14 +15,16 @@ * `marketplaceApi.installPackage` had the same hole and could report a package * as installed when it was not. * - * Reading the envelope by hand is the anti-pattern, not any particular way of - * reading it wrong: four hand-rolled copies produced three different bugs - * (missed inner failure, a `{message}` object handed to `toast.error()` as a - * React child → React #31, and `redirectUrl` read one level too shallow so it - * never fired). One helper, one rule. + * Since #2904 the dispatch itself lives in `@object-ui/core` + * (`createServerActionHandler`, which applies `interpretActionResponse` — the + * envelope rule, also moved there) and the console layers its DOM choreography + * on top through `utils/consoleServerAction`. The ratchet is therefore + * STRONGER than it used to be: an app-shell file has no business naming the + * action route in code at all. * - * If this fails: don't hand-roll the check. Import `interpretActionResponse` - * from `utils/actionResponse` — or, better, call the action through + * If this fails: don't hand-roll the POST. Register a handler built with + * `createConsoleServerActionHandler` (console surfaces) or core's + * `createServerActionHandler` — or call the action through * `@objectstack/client`, which folds every shape into `{ success, data?, error? }`. */ @@ -34,16 +36,12 @@ import { fileURLToPath } from 'node:url'; const here = path.dirname(fileURLToPath(import.meta.url)); const appShellSrc = here; -/** The helper that owns the rule — exempt from its own guard. */ -const OWNER = path.join(appShellSrc, 'utils', 'actionResponse.ts'); - -/** Files allowed to name the route without going through the helper. */ +/** Files allowed to name the route without going through the core dispatcher. */ const EXEMPT = new Set([ - OWNER, // The cloud marketplace install posts to a *cloud* origin's action route // and consumes `InstallResponse`, not `ActionResult`. It still has to // honour the envelope, and does — covered by its own tests — but it is not - // an ActionRunner handler, so it does not use this helper. + // an ActionRunner handler, so it does not use the core dispatcher. path.join(appShellSrc, 'console', 'marketplace', 'marketplaceApi.ts'), ]); @@ -70,26 +68,34 @@ function stripComments(src: string): string { .replace(/(^|[^:])\/\/.*$/gm, '$1'); } -describe('objectstack#3913 ratchet — /actions callers use interpretActionResponse', () => { - const callers = walk(appShellSrc) - .filter((f) => !EXEMPT.has(f) && ROUTE.test(stripComments(readFileSync(f, 'utf8')))); +describe('objectstack#3913 ratchet — /actions dispatch goes through the core dispatcher', () => { + const files = walk(appShellSrc); - const offenders = callers - .filter((f) => !readFileSync(f, 'utf8').includes('interpretActionResponse')) + const offenders = files + .filter((f) => !EXEMPT.has(f) && ROUTE.test(stripComments(readFileSync(f, 'utf8')))) .map((f) => path.relative(appShellSrc, f)); - it('no app-shell file interprets an /actions response by hand', () => { + it('no app-shell file names the /actions route in code (dispatch is core-owned)', () => { expect(offenders).toEqual([]); }); - it('still guards something — the known callers are present', () => { + it('still guards something — the known dispatch surfaces route through the shared wrapper', () => { // A ratchet that matches nothing passes vacuously forever. Pin that the - // two handlers it exists for are actually being scanned. - expect(callers.map((f) => path.relative(appShellSrc, f))).toEqual( - expect.arrayContaining([ - path.join('hooks', 'useConsoleActionRuntime.tsx'), - path.join('views', 'RecordDetailView.tsx'), - ]), - ); + // two surfaces the hand-rolled copies lived in still exist and now build + // their handler with `createConsoleServerActionHandler` — the moment one + // reverts to a hand-rolled fetch, the route string reappears and the + // offenders assertion above catches it. + const surfaces = [ + path.join('hooks', 'useConsoleActionRuntime.tsx'), + path.join('views', 'RecordDetailView.tsx'), + ]; + for (const rel of surfaces) { + const src = readFileSync(path.join(appShellSrc, rel), 'utf8'); + expect(src, `${rel} should dispatch via createConsoleServerActionHandler`) + .toContain('createConsoleServerActionHandler'); + } + // …and the wrapper itself delegates to core rather than fetching. + const wrapper = readFileSync(path.join(appShellSrc, 'utils', 'consoleServerAction.ts'), 'utf8'); + expect(wrapper).toContain('createServerActionHandler'); }); }); diff --git a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx index 03138d7633..582901da17 100644 --- a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx +++ b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx @@ -39,6 +39,7 @@ import type { ResultDialogHandler, ToastHandler, } from '@object-ui/core'; +import { actionErrorDetail, isRecordScopedAction } from '@object-ui/core'; import { useActionModal } from './useActionModal'; import { ActionConfirmDialog, type ConfirmDialogState } from '../views/ActionConfirmDialog'; import { ActionParamDialog, type ParamDialogState } from '../views/ActionParamDialog'; @@ -48,32 +49,15 @@ import { resolveActionParams } from '../utils/resolveActionParams'; import { EnvironmentEntitlementDialog, type EntitlementDialogState } from '../environment/EnvironmentEntitlementDialog'; import { entitlementDialogFromError, type EntitlementDialogSpec } from '../environment/entitlements'; import { resolvePageVarTokens } from '../utils/resolvePageVarTokens'; -import { actionErrorDetail } from '../utils/actionErrorDetail'; -import { interpretActionResponse } from '../utils/actionResponse'; import { interpretFlowResponse } from '../utils/flowResponse'; +import { createConsoleServerActionHandler } from '../utils/consoleServerAction'; const FALLBACK_USER = { id: 'current-user', name: 'Demo User', isPlatformAdmin: false }; -/** - * An action that also mounts on list rows (`list_item`) or record pages is - * designed to run against a single record. When such an action is launched - * from the list toolbar with nothing selected, there is no record context to - * resolve — block up front instead of triggering a run that fails at its - * first record-bound step (#2210: "Update requires an ID"). Actions declaring - * only object-level locations (e.g. `['list_toolbar']`) are left alone: they - * legitimately run without a record. - */ -function isRecordScoped(action: ActionDef): boolean { - const locations = (action as { locations?: unknown }).locations; - if (!Array.isArray(locations)) return false; - return locations.some((l) => - l === 'list_item' || l === 'record_header' || l === 'record_more' || l === 'record_section'); -} - /** * Extract a human-readable message from an error response body — shared with - * `RecordDetailView`, which runs the same `/actions` request and needs the same - * React-#31 guard. See `../utils/actionErrorDetail`. + * every `/actions` caller, which needs the same React-#31 guard. Owned by + * `@object-ui/core` since #2904 (it moved there with the dispatch itself). */ const errorDetail = actionErrorDetail; @@ -157,8 +141,6 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons // Plan/capacity gate dialog (upgrade / limit), shared by the env-list toolbar // (proactive) and the api-action error path below (reactive safety net). const [entitlementDialog, setEntitlementDialog] = useState({ open: false }); - // Guards against double-firing a server action (slow SSO handoff, etc.). - const serverActionInFlight = useRef>(new Set()); const resultDialogHandler = useCallback( (spec: any, data: unknown, action?: any) => new Promise((resolve) => { @@ -466,7 +448,7 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons recordId = selected[0]?.id; } else if (selected.length > 1) { return { success: false, error: 'This flow runs on a single record — select exactly one row.' }; - } else if (isRecordScoped(action)) { + } else if (isRecordScopedAction(action)) { return { success: false, error: 'This flow runs on a single record — select a row first.' }; } } @@ -511,174 +493,31 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons } }, [authFetch, objApiName, refresh]); - // Server-side action handler — POST to /api/v1/actions/{object}/{action}. - // `context` is the shared ActionRunner context (registered handlers are - // invoked as `handler(action, runnerContext)`). - const serverActionHandler = useCallback(async (action: ActionDef, context?: ActionContext): Promise => { - // [ADR-0110 D1] The URL identifies the action by its declarative `name`, - // never by `target`. `target` is a BINDING EXPRESSION — the handler's - // registration key here, but a URL / flow id / FormView name for other - // types, `${param.X}`-interpolatable, and legitimately non-unique — so it - // cannot identify a declaration. Posting it (the old `target || name`) - // meant the server resolved NO declaration for a target-bound action and - // silently skipped both the ADR-0066 D4 capability gate and the ADR-0104 - // param contract (#3935). The server derives the handler key from the - // declaration it resolves by name. - const actionName = action.name; - if (!actionName) { - return { success: false, error: 'Action has no name — a server action must declare one' }; - } - const params = (action.params && !Array.isArray(action.params)) - ? { ...(action.params as Record) } - : {}; - const _rowRecord = (params as any)._rowRecord as Record | undefined; - delete (params as any)._rowRecord; - const recordIdField = (action as any).recordIdField || 'id'; - let resolvedRecordId = (params as any).recordId ?? _rowRecord?.[recordIdField]; - // An AGGREGATE bulk dispatch (objectui#3139) carries the whole selection - // as `params._selectedIds` — the server reads that array, not `recordId`. - // The single-record fallback below must not touch it: resolving a - // recordId would mislabel the run as record-scoped, and the multi-select - // guard would block exactly the selection this dispatch exists to carry. - const isAggregateDispatch = Array.isArray((params as any)._selectedIds); - // Same list_toolbar fallback as flowHandler: no `_rowRecord` means the - // action came from the toolbar — resolve the recordId from the grid's - // checkbox selection (published as `selectedRecords`). Multi-select is - // ambiguous for a single-record action, so block with a message; so is - // zero selection when the action is record-scoped (see isRecordScoped). - if (resolvedRecordId == null && !isAggregateDispatch) { - const selected = Array.isArray(context?.selectedRecords) ? context!.selectedRecords : []; - if (selected.length === 1) { - resolvedRecordId = selected[0]?.[recordIdField]; - } else if (selected.length > 1) { - // The runner's post-execution hook surfaces `error` as a toast. - return { success: false, error: 'This action runs on a single record — select exactly one row.' }; - } else if (isRecordScoped(action)) { - return { success: false, error: 'This action runs on a single record — select a row first.' }; - } - } - - // Re-entrancy guard. - const inflightKey = `${actionName}:${resolvedRecordId ?? ''}`; - if (serverActionInFlight.current.has(inflightKey)) { - return { success: false, error: 'Action already in progress' }; - } - serverActionInFlight.current.add(inflightKey); - - // Popup-blocker workaround: pre-open about:blank synchronously before the - // await so the user-gesture context is preserved. - let preOpenedTab: Window | null = null; - if ((action as any).opensInNewTab) { - try { - preOpenedTab = window.open('about:blank', '_blank'); - if (preOpenedTab) { - preOpenedTab.document.write('正在打开… Opening…
正在为你打开环境…
'); - preOpenedTab.document.close(); - } - } catch { preOpenedTab = null; } - } - try { - const baseUrl = import.meta.env.VITE_SERVER_URL || ''; - // ── Zero-roundtrip fast path ──────────────────────────────────────── - // `newTabUrl` names a GET endpoint that performs ALL auth/authz itself - // (e.g. /sso-open re-runs every check the POST half would have done), - // so the POST round trip would add nothing but click latency. Drive the - // pre-opened tab there immediately — the spinner page stays painted - // until the (possibly slow) endpoint commits its redirect. - const newTabUrl = typeof (action as any).newTabUrl === 'string' ? (action as any).newTabUrl as string : ''; - if ((action as any).opensInNewTab && newTabUrl) { - if (resolvedRecordId == null) { - if (preOpenedTab) { try { preOpenedTab.close(); } catch { /* ignore */ } } - return { success: false, error: 'This action runs on a single record — no record id available.' }; - } - // Absolute URL required: the pre-opened tab is an about:blank document, - // so a bare-relative href has no reliable resolution base. - const directUrl = `${baseUrl || window.location.origin}${newTabUrl.replace('{recordId}', encodeURIComponent(String(resolvedRecordId)))}`; - if (preOpenedTab) { - try { preOpenedTab.location.href = directUrl; } - catch { - try { preOpenedTab.close(); } catch { /* ignore */ } - window.location.href = directUrl; - } - } else { - let popup: Window | null = null; - try { popup = window.open(directUrl, '_blank'); } catch { popup = null; } - if (!popup) { - toast('浏览器拦截了弹窗 / Popup blocked', { - description: '点击在新标签页打开环境', - action: { label: '打开环境', onClick: () => { try { window.open(directUrl, '_blank'); } catch { window.location.href = directUrl; } } }, - duration: 10000, - }); - } - } - if (action.refreshAfter === true) refresh(); - return { success: true }; - } - const obj = action.objectName || objApiName || 'global'; - const res = await authFetch( - `${baseUrl}/api/v1/actions/${encodeURIComponent(obj)}/${encodeURIComponent(actionName)}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ recordId: resolvedRecordId, params }), - }, - ); - const json = await res.json().catch(() => null); - // Single source for the `/actions` envelope rule — shared with - // RecordDetailView, whose copy of this handler drifted from it and caused - // objectstack#3913's console symptom. See utils/actionResponse. - const outcome = interpretActionResponse(res, json, `Action "${actionName}"`); - if (!outcome.ok) { - if (preOpenedTab) { try { preOpenedTab.close(); } catch { /* ignore */ } } - // Don't toast here — the ActionRunner's post-execution hook surfaces - // `error` as a toast (see apiHandler/flowHandler, which likewise only - // return). Toasting here too double-fires the error (two identical toasts). - return { success: false, error: outcome.error }; - } - const shouldRefresh = action.refreshAfter !== false; - if (shouldRefresh) refresh(); - const data = outcome.envelope; - // Read `redirectUrl` off the HANDLER's return value, not the action - // envelope wrapping it. This used to read one level too shallow, where - // only `success`/`data` ever live — so an action returning - // `{ redirectUrl }` was silently ignored and an `opensInNewTab` action - // left its pre-opened tab parked on the spinner page forever. - const payload = outcome.payload; - const redirectUrl = (payload && typeof payload === 'object' && typeof (payload as any).redirectUrl === 'string') - ? (payload as any).redirectUrl as string - : null; - if (redirectUrl) { - if (preOpenedTab) { - try { preOpenedTab.location.href = redirectUrl; } - catch { - try { preOpenedTab.close(); } catch { /* ignore */ } - window.location.href = redirectUrl; - } - } else { - let popup: Window | null = null; - try { popup = window.open(redirectUrl, '_blank'); } catch { popup = null; } - if (!popup) { - toast('浏览器拦截了弹窗 / Popup blocked', { - description: '点击在新标签页打开环境', - action: { label: '打开环境', onClick: () => { try { window.open(redirectUrl, '_blank'); } catch { window.location.href = redirectUrl; } } }, - duration: 10000, - }); - } - } - } else if (preOpenedTab) { - try { preOpenedTab.close(); } catch { /* ignore */ } - } - return { success: true, data, reload: shouldRefresh }; - } catch (error) { - if (preOpenedTab) { try { preOpenedTab.close(); } catch { /* ignore */ } } - const msg = (error as Error).message; - // The ActionRunner's post-execution hook toasts `error`; returning it here - // (without a local toast.error) avoids the double toast. - return { success: false, error: msg }; - } finally { - serverActionInFlight.current.delete(inflightKey); - } - }, [authFetch, objApiName, refresh]); + // Server-side action handler — POST /api/v1/actions/{object}/{action}, built + // from @object-ui/core's `createServerActionHandler` via the shared console + // wrapper (#2904): core owns the dispatch (name-only identity per ADR-0110 + // D1, the record-id resolution dance, the re-entrancy guard, the /actions + // envelope rule), the wrapper owns the console DOM choreography (popup + // pre-open, `newTabUrl` fast path, `redirectUrl` convention). + // RecordDetailView builds ITS handler from the same two pieces — the drift + // between the two hand-rolled copies (objectstack#3913, framework#3935) + // cannot recur. + // + // The env ref keeps the handler INSTANCE stable across renders (authFetch is + // memoized once) while the config thunks read the latest object scope and + // refresh callback — the factory's in-flight guard only spans invocations of + // the same instance. + const serverActionEnvRef = useRef({ objApiName, refresh }); + serverActionEnvRef.current = { objApiName, refresh }; + const serverActionHandler = useMemo( + () => createConsoleServerActionHandler({ + fetch: authFetch, + baseUrl: () => import.meta.env.VITE_SERVER_URL || '', + resolveObject: () => serverActionEnvRef.current.objApiName, + onRefresh: () => serverActionEnvRef.current.refresh(), + }), + [authFetch], + ); // Client-side modal transport, shared with RecordDetailView so a // `type: 'modal'` action behaves the SAME on a list page, an SDUI page, a diff --git a/packages/app-shell/src/utils/__tests__/consoleServerAction.test.tsx b/packages/app-shell/src/utils/__tests__/consoleServerAction.test.tsx new file mode 100644 index 0000000000..9a1aabae01 --- /dev/null +++ b/packages/app-shell/src/utils/__tests__/consoleServerAction.test.tsx @@ -0,0 +1,207 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * `createConsoleServerActionHandler` (#2904) — the console wrapper around + * core's `createServerActionHandler`. The dispatch protocol itself is pinned in + * core (`serverActionHandler.test.ts`); these cover the DOM choreography the + * wrapper owns: the popup pre-open dance, the zero-roundtrip `newTabUrl` fast + * path, the `redirectUrl` convention, and tab cleanup on every failure path. + * `useConsoleActionRuntime.test.tsx` covers the same wrapper mounted in the + * real console runtime. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('sonner', () => { + const fn: any = vi.fn(); + fn.error = vi.fn(); + fn.success = vi.fn(); + return { toast: fn }; +}); + +import { toast } from 'sonner'; +import { createConsoleServerActionHandler } from '../consoleServerAction'; + +/** A pre-openable tab stub with the surface the wrapper drives. */ +function makeTab() { + return { + document: { write: vi.fn(), close: vi.fn() }, + close: vi.fn(), + location: { href: '' }, + } as unknown as Window & { close: ReturnType; location: { href: string } }; +} + +function okFetch(body: unknown = { success: true, data: {} }) { + return vi.fn(async () => ({ ok: true, status: 200, json: async () => body })); +} + +function makeHandler(overrides: Partial[0]> = {}) { + const fetch = overrides.fetch ?? (okFetch() as any); + const onRefresh = overrides.onRefresh ?? vi.fn(); + const handler = createConsoleServerActionHandler({ + fetch, + baseUrl: () => 'https://api.test', + resolveObject: () => 'env', + onRefresh, + ...overrides, + }); + return { handler, fetch: fetch as ReturnType, onRefresh: onRefresh as ReturnType }; +} + +beforeEach(() => { + vi.restoreAllMocks(); + (toast as any).mockClear?.(); +}); + +describe('newTabUrl fast path (zero roundtrip)', () => { + it('drives the pre-opened tab straight to the endpoint — no POST at all', async () => { + const tab = makeTab(); + const openSpy = vi.spyOn(window, 'open').mockReturnValue(tab as any); + const { handler, fetch, onRefresh } = makeHandler(); + + const res = await handler({ + type: 'script', name: 'sso_as_owner', opensInNewTab: true, + newTabUrl: '/api/v1/cloud/environments/{recordId}/sso-open', + params: { recordId: 'env 1' }, + } as any); + + expect(res).toEqual({ success: true }); + expect(fetch).not.toHaveBeenCalled(); + expect(openSpy).toHaveBeenCalledWith('about:blank', '_blank'); + expect(tab.location.href).toBe('https://api.test/api/v1/cloud/environments/env%201/sso-open'); + // Fast path refreshes only on an EXPLICIT refreshAfter: true. + expect(onRefresh).not.toHaveBeenCalled(); + }); + + it('refreshes when the action explicitly opts in (refreshAfter: true)', async () => { + vi.spyOn(window, 'open').mockReturnValue(makeTab() as any); + const { handler, onRefresh } = makeHandler(); + + await handler({ + type: 'script', name: 'sso_as_owner', opensInNewTab: true, refreshAfter: true, + newTabUrl: '/sso-open/{recordId}', params: { recordId: 'e1' }, + } as any); + + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it('a blocked record resolution errors BEFORE any tab opens (no flash)', async () => { + const openSpy = vi.spyOn(window, 'open'); + const { handler, fetch } = makeHandler(); + + const res = await handler( + { type: 'script', name: 'sso_as_owner', opensInNewTab: true, newTabUrl: '/sso-open/{recordId}' } as any, + { selectedRecords: [{ id: 'a' }, { id: 'b' }] } as any, + ); + + expect(res.success).toBe(false); + expect(res.error).toMatch(/exactly one row/i); + expect(openSpy).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('errors without a tab when no record id is resolvable', async () => { + const openSpy = vi.spyOn(window, 'open'); + const { handler } = makeHandler(); + + const res = await handler({ + type: 'script', name: 'sso_as_owner', opensInNewTab: true, newTabUrl: '/sso-open/{recordId}', + } as any); + + expect(res.success).toBe(false); + expect(res.error).toMatch(/no record id available/i); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it('honors an injected resolveRecordId (record-page policy) on the fast path', async () => { + const tab = makeTab(); + vi.spyOn(window, 'open').mockReturnValue(tab as any); + const { handler } = makeHandler({ + resolveRecordId: (action: any) => ({ recordId: action.recordId ?? 'page_rec' }), + }); + + await handler({ + type: 'script', name: 'sso_as_owner', opensInNewTab: true, newTabUrl: '/sso-open/{recordId}', + } as any); + + expect(tab.location.href).toBe('https://api.test/sso-open/page_rec'); + }); +}); + +describe('redirectUrl convention', () => { + it('drives the pre-opened tab to a handler-returned redirectUrl (legacy double-wrap read)', async () => { + const tab = makeTab(); + vi.spyOn(window, 'open').mockReturnValue(tab as any); + const { handler } = makeHandler({ + fetch: okFetch({ + success: true, + data: { success: true, data: { redirectUrl: 'https://example.test/sso' } }, + }) as any, + }); + + const res = await handler({ type: 'script', name: 'open_env', opensInNewTab: true, params: { recordId: 'e1' } } as any); + + expect(res.success).toBe(true); + expect(tab.location.href).toBe('https://example.test/sso'); + expect(tab.close).not.toHaveBeenCalled(); + }); + + it('opens lazily without a pre-opened tab, with a toast fallback when the popup is blocked', async () => { + const openSpy = vi.spyOn(window, 'open').mockReturnValue(null); + const { handler } = makeHandler({ + fetch: okFetch({ + success: true, + data: { success: true, data: { redirectUrl: 'https://example.test/sso' } }, + }) as any, + }); + + await handler({ type: 'script', name: 'open_env' } as any); // no opensInNewTab + + expect(openSpy).toHaveBeenCalledWith('https://example.test/sso', '_blank'); + expect(toast).toHaveBeenCalledTimes(1); // popup blocked → one-click fallback + }); + + it('closes the optimistically pre-opened tab when the handler returns no redirectUrl', async () => { + const tab = makeTab(); + vi.spyOn(window, 'open').mockReturnValue(tab as any); + const { handler } = makeHandler(); + + const res = await handler({ type: 'script', name: 'noop', opensInNewTab: true, params: { recordId: 'e1' } } as any); + + expect(res.success).toBe(true); + expect(tab.close).toHaveBeenCalledTimes(1); + }); +}); + +describe('failure paths close the pre-opened tab', () => { + it('on a failed dispatch', async () => { + const tab = makeTab(); + vi.spyOn(window, 'open').mockReturnValue(tab as any); + const { handler } = makeHandler({ + fetch: vi.fn(async () => ({ + ok: false, status: 403, json: async () => ({ success: false, error: 'Denied' }), + })) as any, + }); + + const res = await handler({ type: 'script', name: 'open_env', opensInNewTab: true, params: { recordId: 'e1' } } as any); + + expect(res).toMatchObject({ success: false, error: 'Denied' }); + expect(tab.close).toHaveBeenCalledTimes(1); + // The runner's post-execution hook owns the error toast — none here. + expect((toast as any).error).not.toHaveBeenCalled(); + }); + + it('on a thrown transport error', async () => { + const tab = makeTab(); + vi.spyOn(window, 'open').mockReturnValue(tab as any); + const { handler } = makeHandler({ + fetch: vi.fn(async () => { throw new Error('network down'); }) as any, + }); + + const res = await handler({ type: 'script', name: 'open_env', opensInNewTab: true, params: { recordId: 'e1' } } as any); + + expect(res).toEqual({ success: false, error: 'network down' }); + expect(tab.close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app-shell/src/utils/consoleServerAction.ts b/packages/app-shell/src/utils/consoleServerAction.ts new file mode 100644 index 0000000000..ab5ba5af70 --- /dev/null +++ b/packages/app-shell/src/utils/consoleServerAction.ts @@ -0,0 +1,197 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The CONSOLE server-action handler — `@object-ui/core`'s + * `createServerActionHandler` (objectui#2904) wrapped with the browser-only + * conventions the console layers on top of the dispatch: + * + * - the **popup-blocker pre-open dance** for `opensInNewTab` actions: open + * `about:blank` synchronously on click (preserving the user-gesture context + * across the awaited POST) and paint a spinner so the tab isn't blank during + * a slow SSO-handoff mint; + * - the **zero-roundtrip `newTabUrl` fast path**: a GET endpoint that performs + * all auth/authz itself (e.g. cloud's `/sso-open`) is navigated to directly, + * skipping the POST; + * - the **`redirectUrl` convention**: a handler returning `{ redirectUrl }` + * asks the UI to open it — into the pre-opened tab when there is one, else a + * lazily opened one with a popup-blocked toast fallback. + * + * This file exists because `useConsoleActionRuntime` and `RecordDetailView` + * each carried a near-verbatim copy of all of the above around their + * hand-rolled POSTs, and the copies drifted (objectstack#3913 — envelope; + * framework#3935 — action identity). The dispatch itself (name-only identity, + * record-id resolution, re-entrancy guard, envelope rule) lives in core; only + * the DOM/toast choreography belongs here. + * + * One deliberate delta from the old copies: the re-entrancy guard now lives + * INSIDE the core dispatch, after the pre-open. A double-click on an + * `opensInNewTab` action therefore pre-opens a second tab for one microtask + * before the blocked dispatch closes it — the double POST it guards against is + * still impossible. + */ + +import { toast } from 'sonner'; +import { + createServerActionHandler, + readActionPayload, + resolveServerActionRecordId, + type ActionContext, + type ActionDef, + type ServerActionFetch, + type ServerActionHandler, + type ServerActionRecordIdResolver, +} from '@object-ui/core'; + +export interface ConsoleServerActionOptions { + /** Authenticated fetch wrapper (Bearer + tenant + cookies). */ + fetch: ServerActionFetch; + /** Backend origin, read per dispatch (`''` = same-origin). */ + baseUrl: () => string; + /** Fallback object scope when the action declares no `objectName`. */ + resolveObject: (action: ActionDef, context: ActionContext | undefined) => string | undefined; + /** + * Replace the standard record-id dance — RecordDetailView resolves against + * ITS record (`action.recordId ?? pageRecordId`) instead of a grid + * selection. Omit for the standard list/page behavior. + */ + resolveRecordId?: ServerActionRecordIdResolver; + /** Data invalidation, invoked per the action's `refreshAfter` semantics. */ + onRefresh: () => void; +} + +/** + * Pre-open `about:blank` synchronously (user-gesture context) and paint + * progress immediately so the tab isn't blank/frozen during the (slow) + * server round trip. + * + * NOTE: do NOT pass 'noopener' — per spec it forces `window.open` to return + * null even when the tab opens, so the handle would be lost, the popup + * fallback would fire, and the CURRENT tab would navigate to the now-consumed + * SSO URL (the double-navigation bug). + */ +function preOpenSpinnerTab(): Window | null { + try { + const tab = window.open('about:blank', '_blank'); + if (tab) { + tab.document.write('正在打开… Opening…
正在为你打开环境…
'); + tab.document.close(); + } + return tab; + } catch { + return null; + } +} + +function closeTab(tab: Window | null): void { + if (!tab) return; + try { tab.close(); } catch { /* ignore */ } +} + +/** + * Open `url`: drive the pre-opened tab there when one exists (falling back to + * a current-tab navigation if the handle went stale); otherwise open lazily + * and, when the popup blocker eats it, offer a one-click toast instead of + * silently hijacking the current tab. + */ +function openInTab(preOpenedTab: Window | null, url: string): void { + if (preOpenedTab) { + try { + preOpenedTab.location.href = url; + } catch { + closeTab(preOpenedTab); + window.location.href = url; + } + return; + } + let popup: Window | null; + // No 'noopener' so a successful open returns a truthy handle; with it, the + // null return would always trip the toast fallback. + try { popup = window.open(url, '_blank'); } catch { popup = null; } + if (!popup) { + toast('浏览器拦截了弹窗 / Popup blocked', { + description: '点击在新标签页打开环境', + action: { label: '打开环境', onClick: () => { try { window.open(url, '_blank'); } catch { window.location.href = url; } } }, + duration: 10000, + }); + } +} + +export function createConsoleServerActionHandler(opts: ConsoleServerActionOptions): ServerActionHandler { + const dispatch = createServerActionHandler({ + fetch: opts.fetch, + baseUrl: opts.baseUrl, + resolveObject: opts.resolveObject, + resolveRecordId: opts.resolveRecordId, + onRefresh: opts.onRefresh, + }); + const resolveRecordId = opts.resolveRecordId ?? resolveServerActionRecordId; + + return async (action: ActionDef, context?: ActionContext) => { + // ── Zero-roundtrip fast path ──────────────────────────────────────── + // `newTabUrl` names a GET endpoint that performs ALL auth/authz itself + // (e.g. /sso-open re-runs every check the POST half would have done), + // so the POST round trip would add nothing but click latency. Drive the + // pre-opened tab there immediately — the spinner page stays painted + // until the (possibly slow) endpoint commits its redirect. Record-id + // resolution runs BEFORE the pre-open so a blocked resolution + // (multi-select, empty selection) never flashes a tab. + const newTabUrl = typeof action.newTabUrl === 'string' ? action.newTabUrl : ''; + if (action.opensInNewTab && newTabUrl) { + const resolution = resolveRecordId(action, context); + if (resolution.error) { + return { success: false, error: resolution.error }; + } + const recordId = resolution.recordId; + if (recordId == null) { + return { success: false, error: 'This action runs on a single record — no record id available.' }; + } + const preOpenedTab = preOpenSpinnerTab(); + // Absolute URL required: the pre-opened tab is an about:blank document, + // so a bare-relative href has no reliable resolution base. + const directUrl = `${opts.baseUrl() || window.location.origin}${newTabUrl.replace('{recordId}', encodeURIComponent(String(recordId)))}`; + openInTab(preOpenedTab, directUrl); + if (action.refreshAfter === true) opts.onRefresh(); + return { success: true }; + } + + // Popup-blocker workaround: pre-open about:blank synchronously before the + // await so the user-gesture context is preserved. + const preOpenedTab = action.opensInNewTab ? preOpenSpinnerTab() : null; + try { + const result = await dispatch(action, context); + if (!result.success) { + closeTab(preOpenedTab); + // Don't toast here — the ActionRunner's post-execution hook surfaces + // `error` as a toast; toasting here too double-fires it. + return result; + } + // ── redirectUrl convention ──────────────────────────────────────── + // Read off the HANDLER's return value, not the action envelope wrapping + // it (`result.data`). This used to read one level too shallow, where + // only `success`/`data` ever live — so an action returning + // `{ redirectUrl }` was silently ignored and an `opensInNewTab` action + // left its pre-opened tab parked on the spinner page forever. + const payload = readActionPayload(result.data); + const redirectUrl = (payload && typeof payload === 'object' && typeof (payload as { redirectUrl?: unknown }).redirectUrl === 'string') + ? (payload as { redirectUrl: string }).redirectUrl + : null; + if (redirectUrl) { + openInTab(preOpenedTab, redirectUrl); + } else { + // Handler didn't return a redirectUrl — close the empty tab we + // optimistically pre-opened so the user isn't left with about:blank. + closeTab(preOpenedTab); + } + return result; + } catch (error) { + closeTab(preOpenedTab); + return { success: false, error: (error as Error).message }; + } + }; +} diff --git a/packages/app-shell/src/utils/flowResponse.ts b/packages/app-shell/src/utils/flowResponse.ts index 4954146b66..b824c01182 100644 --- a/packages/app-shell/src/utils/flowResponse.ts +++ b/packages/app-shell/src/utils/flowResponse.ts @@ -44,7 +44,7 @@ * `actionErrorDetail` exists for. */ -import { actionErrorDetail } from './actionErrorDetail'; +import { actionErrorDetail } from '@object-ui/core'; /** * The `AutomationResult` fields the console reads. Deliberately loose: this is diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index 8cf7b44f7e..1c9eeb48b5 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -34,7 +34,7 @@ import { withPageTabsUrlSync } from '../utils/pageTabsUrlSync'; import { RECORD_DETAIL_TAB_PARAM, RECORD_TRAIL_PARAM, decodeRecordTrail, buildRecordTrailHref } from '../urlParams'; import { resolveActionParams } from '../utils/resolveActionParams'; import { decisionOutputDefs, decisionOutputParams, foldDecisionOutputs } from '../utils/decisionOutputParams'; -import { interpretActionResponse } from '../utils/actionResponse'; +import { createConsoleServerActionHandler } from '../utils/consoleServerAction'; import { interpretFlowResponse } from '../utils/flowResponse'; import { useRecordBreadcrumbTitle } from '../context/NavigationContext'; // Audit provenance renders as the one-line ; the other @@ -800,171 +800,34 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri } }, [authFetch, pureRecordId, objectName]); - // Server-side action handler — POST to /api/v1/actions/{object}/{action}. - // Used for `script` and `modal` actions where `action.target` matches a - // server-registered handler name (engine.registerAction). Sends the - // current recordId, objectName, and any collected/static params, and the - // server resolves the handler (with wildcard '*' fallback) and runs it. - const serverActionInFlight = useRef>(new Set()); - const serverActionHandler = useCallback(async (action: ActionDef) => { - const targetName = action.target || action.name; - if (!targetName) { - return { success: false, error: 'No action target provided' }; - } - const params = (action.params && !Array.isArray(action.params)) - ? (action.params as Record) - : {}; - - // Re-entrancy guard: ignore a repeat click while this action+record runs. - const inflightKey = `${targetName}:${pureRecordId ?? ''}`; - if (serverActionInFlight.current.has(inflightKey)) { - return { success: false, error: 'Action already in progress' }; - } - serverActionInFlight.current.add(inflightKey); - - // ── Popup-blocker workaround ────────────────────────────────────── - // When `action.opensInNewTab` is set, the handler is known to return - // `{ redirectUrl: ... }` for the UI to navigate to. We pre-open - // `about:blank` synchronously *here*, before the await fetch — this - // preserves the user-gesture context so Chrome/Safari don't block - // the eventual navigation. Drives the same tab to `redirectUrl` - // after the server replies. If pre-open fails (popup blocker on the - // initial gesture), we fall back to navigating the current tab so - // the user always gets there. - let preOpenedTab: Window | null = null; - if ((action as any).opensInNewTab) { - // NOTE: do NOT pass 'noopener' here — per spec it forces window.open to - // return null even when the tab opens, so we'd lose the handle, fall - // through to the popup branch below, and end up navigating the *current* - // tab to the redirectUrl (the double-navigation bug: env opens in a new - // tab AND the list/detail page jumps to the now-consumed SSO URL). We - // need the reference to drive the pre-opened tab to the SSO redirect. - try { - preOpenedTab = window.open('about:blank', '_blank'); - // Paint progress immediately so the new tab isn't blank/frozen during - // the (slow) SSO-handoff mint. - if (preOpenedTab) { - preOpenedTab.document.write('正在打开… Opening…
正在为你打开环境…
'); - preOpenedTab.document.close(); - } - } catch { preOpenedTab = null; } - } - - try { - const baseUrl = import.meta.env.VITE_SERVER_URL || ''; - // ── Zero-roundtrip fast path ──────────────────────────────────────── - // `newTabUrl` names a GET endpoint that performs ALL auth/authz itself - // (e.g. /sso-open re-runs every check the POST half would have done), - // so the POST round trip would add nothing but click latency. Drive the - // pre-opened tab there immediately — the spinner page stays painted - // until the (possibly slow) endpoint commits its redirect. - const newTabUrl = typeof (action as any).newTabUrl === 'string' ? (action as any).newTabUrl as string : ''; - if ((action as any).opensInNewTab && newTabUrl) { - if (pureRecordId == null) { - if (preOpenedTab) { try { preOpenedTab.close(); } catch { /* ignore */ } } - return { success: false, error: 'This action runs on a single record — no record id available.' }; - } - // Absolute URL required: the pre-opened tab is an about:blank document, - // so a bare-relative href has no reliable resolution base. - const directUrl = `${baseUrl || window.location.origin}${newTabUrl.replace('{recordId}', encodeURIComponent(String(pureRecordId)))}`; - if (preOpenedTab) { - try { preOpenedTab.location.href = directUrl; } - catch { - try { preOpenedTab.close(); } catch { /* ignore */ } - window.location.href = directUrl; - } - } else { - let popup: Window | null = null; - try { popup = window.open(directUrl, '_blank'); } catch { popup = null; } - if (!popup) { - toast('浏览器拦截了弹窗 / Popup blocked', { - description: '点击在新标签页打开环境', - action: { label: '打开环境', onClick: () => { try { window.open(directUrl, '_blank'); } catch { window.location.href = directUrl; } } }, - duration: 10000, - }); - } - } - if (action.refreshAfter === true) notifyRecordChanged(); - return { success: true }; - } - const obj = action.objectName || objectName || 'global'; - const res = await authFetch( - `${baseUrl}/api/v1/actions/${encodeURIComponent(obj)}/${encodeURIComponent(targetName)}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - // Related-list row actions retarget a CHILD record via explicit - // `recordId`; header/more actions carry none and use this page's id. - body: JSON.stringify({ recordId: (action as any).recordId ?? pureRecordId, params }), - }, - ); - const json = await res.json().catch(() => null); - // Single source for the `/actions` envelope rule — shared with - // useConsoleActionRuntime, from which this copy drifted (it learned to - // inspect the inner envelope; the shared runtime had not, which is - // objectstack#3913's console symptom). See utils/actionResponse. - const outcome = interpretActionResponse(res, json, `Action "${targetName}"`); - if (!outcome.ok) { - if (preOpenedTab) { try { preOpenedTab.close(); } catch { /* ignore */ } } - // Don't toast here. This handler always runs through the ActionRunner - // (registered as the `script` handler on the ActionProvider below, which - // wires `onToast`), whose post-execution hook surfaces the returned - // `error` as one toast. Toasting again double-fired the message - // (e.g. RECORD_LOCKED appeared twice). Mirrors useConsoleActionRuntime. - return { success: false, error: outcome.error }; - } - const shouldRefresh = action.refreshAfter !== false; - if (shouldRefresh) notifyRecordChanged(); - const result = outcome.envelope; - // ── redirectUrl convention ──────────────────────────────────────── - // A script-action handler can return `{ redirectUrl: 'https://…' }` - // to ask the UI to open the URL. If the action declared - // `opensInNewTab: true`, we drive the pre-opened tab to that URL - // (popup-blocker-safe). Otherwise we open lazily and, if blocked, - // fall back to navigating the current tab so the user always gets - // to the destination. - // - // Read off the HANDLER's return value, not the action envelope wrapping - // it — this used to read one level too shallow, where only - // `success`/`data` ever live, so the convention never fired at all. - const payload = outcome.payload; - if (payload && typeof payload === 'object' && typeof (payload as any).redirectUrl === 'string') { - const redirectUrl = (payload as any).redirectUrl as string; - if (preOpenedTab) { - try { preOpenedTab.location.href = redirectUrl; } catch { - try { preOpenedTab.close(); } catch { /* ignore */ } - window.location.href = redirectUrl; - } - } else { - let popup: Window | null = null; - // No 'noopener' so a successful open returns a truthy handle; with - // it, the null return would always trip the current-tab fallback. - try { popup = window.open(redirectUrl, '_blank'); } catch { popup = null; } - if (!popup) { - // Don't silently hijack the current tab — offer a one-click open. - toast('浏览器拦截了弹窗 / Popup blocked', { - description: '点击在新标签页打开环境', - action: { label: '打开环境', onClick: () => { try { window.open(redirectUrl, '_blank'); } catch { window.location.href = redirectUrl; } } }, - duration: 10000, - }); - } - } - } else if (preOpenedTab) { - // Handler didn't return a redirectUrl — close the empty tab we - // optimistically pre-opened so the user isn't left with about:blank. - try { preOpenedTab.close(); } catch { /* ignore */ } - } - return { success: true, data: result, reload: shouldRefresh }; - } catch (error) { - if (preOpenedTab) { try { preOpenedTab.close(); } catch { /* ignore */ } } - const msg = (error as Error).message; - // Don't toast here — the ActionRunner post-execution hook toasts the - // returned `error` once (see the failure branch above). - return { success: false, error: msg }; - } finally { - serverActionInFlight.current.delete(inflightKey); - } - }, [authFetch, pureRecordId, objectName]); + // Server-side action handler — POST /api/v1/actions/{object}/{action}, + // built from @object-ui/core's `createServerActionHandler` via the shared + // console wrapper (#2904), exactly like `useConsoleActionRuntime`: core owns + // the dispatch (name-only identity per ADR-0110 D1 — this copy used to post + // `target || name`, the drift framework#3935 fixed only in the shared + // runtime — plus the re-entrancy guard and the /actions envelope rule), the + // wrapper owns the popup pre-open / `newTabUrl` fast path / `redirectUrl` + // convention. + // + // The record page resolves records against ITSELF, not a grid selection: + // related-list row actions retarget a CHILD record via an explicit + // `action.recordId`; header/more actions carry none and use this page's id. + // The env ref keeps the handler instance stable across renders (authFetch is + // memoized once) while the thunks read the live record/object. + const serverActionEnvRef = useRef({ objectName, pureRecordId, notifyRecordChanged }); + serverActionEnvRef.current = { objectName, pureRecordId, notifyRecordChanged }; + const serverActionHandler = useMemo( + () => createConsoleServerActionHandler({ + fetch: authFetch, + baseUrl: () => import.meta.env.VITE_SERVER_URL || '', + resolveObject: () => serverActionEnvRef.current.objectName, + resolveRecordId: (action) => ({ + recordId: (action as { recordId?: unknown }).recordId ?? serverActionEnvRef.current.pureRecordId ?? undefined, + }), + onRefresh: () => serverActionEnvRef.current.notifyRecordChanged(), + }), + [authFetch], + ); /** * `type: 'modal'` dispatch — same rule as the shared console runtime (see diff --git a/packages/core/README.md b/packages/core/README.md index ab54e96e50..4a4fd579d9 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -58,6 +58,35 @@ const userName = scope.get('user.name') // 'John' const isAdmin = scope.evaluate('${user.role === "admin"}') // true ``` +### Server Action Dispatch (`createServerActionHandler`) + +`ActionSchema.body` (L1 expression / L2 sandboxed JS) executes **server-side** +— `POST /api/v1/actions/{object}/{action}` → the runtime sandbox. The client +dispatches; it never interprets a body. Build the dispatch handler with the +factory and register it — core stays opinion-free about auth, origin and +object scope, which are injected: + +```typescript +import { createServerActionHandler } from '@object-ui/core' + +const script = createServerActionHandler({ + fetch: myAuthenticatedFetch, // your auth wrapper (Bearer/cookies/...) + baseUrl: 'https://api.example.com', // '' or omitted = same-origin + resolveObject: () => currentObject, // fallback when the action has no objectName + onRefresh: () => refetchData(), // called per the action's refreshAfter +}) + +// Registered handlers beat the built-in executors: +runner.registerHandler('script', script) +// (React hosts: ) +``` + +The factory owns the protocol so consumers cannot drift on it: name-based +action identity (ADR-0110), the record-id resolution dance (`_rowRecord`, +`recordIdField`, selection fallback, aggregate `_selectedIds`), a re-entrancy +guard, and the `/actions` response-envelope rule (`interpretActionResponse` / +`readActionPayload`, also exported). + ### System Views (`defineView`) Schemas authored in source code are part of the product contract and must diff --git a/packages/core/src/actions/ActionRunner.ts b/packages/core/src/actions/ActionRunner.ts index f28dc94ba3..7e0e15fb48 100644 --- a/packages/core/src/actions/ActionRunner.ts +++ b/packages/core/src/actions/ActionRunner.ts @@ -189,8 +189,9 @@ export interface ActionDef { * would diverge silently rather than fail. * * Consumers dispatch bodies by registering a `script` handler that POSTs to - * `/api/v1/actions/{object}/{action}` (see app-shell's - * `useConsoleActionRuntime`); the server runs the body through its sandbox. + * `/api/v1/actions/{object}/{action}`; the server runs the body through its + * sandbox. Build that handler with `createServerActionHandler` (#2904) — + * app-shell's `useConsoleActionRuntime` and `RecordDetailView` both do. */ body?: unknown; /** For type: 'url' — where to open `target`. `'new-tab'` forces a new @@ -907,7 +908,8 @@ export class ActionRunner { error: 'Action body must be executed server-side — this client runner does not interpret ' + '`body` (sandboxed JS needs an isolated VM; expression bodies use the formula engine). ' + - 'Register a `script` handler that POSTs to /api/v1/actions/{object}/{action}.', + 'Register a `script` handler that POSTs to /api/v1/actions/{object}/{action} — ' + + 'build one with createServerActionHandler from @object-ui/core.', }; } // ActionDef is open-ended (`[key: string]: any`), so hand-authored diff --git a/packages/app-shell/src/utils/__tests__/actionResponse.test.ts b/packages/core/src/actions/__tests__/actionResponse.test.ts similarity index 100% rename from packages/app-shell/src/utils/__tests__/actionResponse.test.ts rename to packages/core/src/actions/__tests__/actionResponse.test.ts diff --git a/packages/core/src/actions/__tests__/serverActionHandler.test.ts b/packages/core/src/actions/__tests__/serverActionHandler.test.ts new file mode 100644 index 0000000000..7690c5cb63 --- /dev/null +++ b/packages/core/src/actions/__tests__/serverActionHandler.test.ts @@ -0,0 +1,360 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * `createServerActionHandler` (#2904) — the core server-action dispatcher + * factory. These pin the whole protocol surface the factory owns: name-only + * identity (ADR-0110 D1), the URL/body contract, the record-id resolution + * dance, the re-entrancy guard, the `/actions` envelope rule, and the refresh + * semantics. App-shell's console surfaces build on this exact behavior (see + * `utils/consoleServerAction`), so a regression here is a regression on every + * list, page and record surface at once. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + createServerActionHandler, + isRecordScopedAction, + resolveServerActionRecordId, + type ServerActionFetch, +} from '../serverActionHandler'; +import type { ActionDef } from '../ActionRunner'; + +/** A fetch stub answering the modern single-wrap envelope. */ +function okFetch(body: unknown = { success: true, data: { done: true } }) { + return vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => body, + })) as unknown as ServerActionFetch & ReturnType; +} + +describe('createServerActionHandler — URL and body contract', () => { + it('POSTs {recordId, params} to baseUrl + /api/v1/actions/{object}/{name}', async () => { + const fetch = okFetch(); + const handler = createServerActionHandler({ fetch, baseUrl: 'https://api.example.com' }); + + const res = await handler({ + type: 'script', name: 'archive', objectName: 'inv', + params: { recordId: 'rec_1', reason: 'stale' }, + } as ActionDef); + + expect(res).toMatchObject({ success: true, reload: true }); + expect(fetch).toHaveBeenCalledTimes(1); + const [url, init] = (fetch as any).mock.calls[0]; + expect(url).toBe('https://api.example.com/api/v1/actions/inv/archive'); + expect(init.method).toBe('POST'); + expect(init.headers['Content-Type']).toBe('application/json'); + expect(JSON.parse(init.body)).toEqual({ + recordId: 'rec_1', + params: { recordId: 'rec_1', reason: 'stale' }, + }); + }); + + it('URL-encodes the object and action segments', async () => { + const fetch = okFetch(); + const handler = createServerActionHandler({ fetch }); + + await handler({ type: 'script', name: 'do/it', objectName: 'a b' } as ActionDef); + + expect((fetch as any).mock.calls[0][0]).toBe('/api/v1/actions/a%20b/do%2Fit'); + }); + + it('reads a baseUrl thunk per dispatch (late-bound host config)', async () => { + const fetch = okFetch(); + let origin = 'https://one.example'; + const handler = createServerActionHandler({ fetch, baseUrl: () => origin }); + + await handler({ type: 'script', name: 'a' } as ActionDef); + origin = 'https://two.example'; + await handler({ type: 'script', name: 'b' } as ActionDef); + + expect((fetch as any).mock.calls[0][0]).toContain('https://one.example/'); + expect((fetch as any).mock.calls[1][0]).toContain('https://two.example/'); + }); + + it('strips the client-side _rowRecord stash from the POSTed params', async () => { + const fetch = okFetch(); + const handler = createServerActionHandler({ fetch }); + + await handler({ + type: 'script', name: 'promote', objectName: 'lead', + params: { _rowRecord: { id: 'row_9', name: 'Acme' }, tier: 'gold' }, + } as ActionDef); + + const body = JSON.parse((fetch as any).mock.calls[0][1].body); + expect(body.recordId).toBe('row_9'); // resolved FROM the stash… + expect(body.params).toEqual({ tier: 'gold' }); // …but the stash itself never crosses the wire + }); +}); + +describe('createServerActionHandler — identity (ADR-0110 D1)', () => { + it('dispatches by declarative name, never by target', async () => { + const fetch = okFetch(); + const handler = createServerActionHandler({ fetch }); + + await handler({ type: 'script', name: 'complete_task', target: 'completeTask', objectName: 'todo_task' } as ActionDef); + + const url = (fetch as any).mock.calls[0][0] as string; + expect(url).toContain('/api/v1/actions/todo_task/complete_task'); + expect(url).not.toContain('completeTask'); + }); + + it('refuses an unnamed action instead of falling back to target', async () => { + const fetch = okFetch(); + const handler = createServerActionHandler({ fetch }); + + const res = await handler({ type: 'script', target: 'completeTask' } as ActionDef); + + expect(res.success).toBe(false); + expect(String(res.error)).toMatch(/no name/i); + expect(fetch).not.toHaveBeenCalled(); + }); +}); + +describe('createServerActionHandler — object scope', () => { + it("the action's own objectName wins over the injected resolver", async () => { + const fetch = okFetch(); + const handler = createServerActionHandler({ fetch, resolveObject: () => 'page_object' }); + + await handler({ type: 'script', name: 'a', objectName: 'child_object' } as ActionDef); + + expect((fetch as any).mock.calls[0][0]).toContain('/api/v1/actions/child_object/a'); + }); + + it('falls back to the injected resolver, then to global', async () => { + const fetch = okFetch(); + const withResolver = createServerActionHandler({ fetch, resolveObject: () => 'inv' }); + await withResolver({ type: 'script', name: 'a' } as ActionDef); + expect((fetch as any).mock.calls[0][0]).toContain('/api/v1/actions/inv/a'); + + const fetch2 = okFetch(); + const bare = createServerActionHandler({ fetch: fetch2 }); + await bare({ type: 'script', name: 'provision' } as ActionDef); + expect((fetch2 as any).mock.calls[0][0]).toContain('/api/v1/actions/global/provision'); + }); +}); + +describe('resolveServerActionRecordId — the standard dance', () => { + it('explicit params.recordId wins', () => { + expect(resolveServerActionRecordId( + { name: 'a', params: { recordId: 'p_1', _rowRecord: { id: 'row_1' } } } as ActionDef, + { selectedRecords: [{ id: 'sel_1' }] }, + )).toEqual({ recordId: 'p_1' }); + }); + + it('falls back to _rowRecord[recordIdField] (default id, honoring an override)', () => { + expect(resolveServerActionRecordId( + { name: 'a', params: { _rowRecord: { id: 'row_1', code: 'INV-1' } } } as ActionDef, + undefined, + )).toEqual({ recordId: 'row_1' }); + expect(resolveServerActionRecordId( + { name: 'a', recordIdField: 'code', params: { _rowRecord: { id: 'row_1', code: 'INV-1' } } } as ActionDef, + undefined, + )).toEqual({ recordId: 'INV-1' }); + }); + + it('resolves a single toolbar selection from the runner context', () => { + expect(resolveServerActionRecordId( + { name: 'a', recordIdField: 'code' } as ActionDef, + { selectedRecords: [{ id: 'sel_1', code: 'C-9' }] }, + )).toEqual({ recordId: 'C-9' }); + }); + + it('blocks a multi-select for a single-record dispatch', () => { + const out = resolveServerActionRecordId( + { name: 'a' } as ActionDef, + { selectedRecords: [{ id: 'x' }, { id: 'y' }] }, + ); + expect(out.error).toMatch(/exactly one row/i); + }); + + it('blocks a record-scoped action with zero selection (#2210) but lets object-level actions through', () => { + const scoped = resolveServerActionRecordId( + { name: 'a', locations: ['list_item', 'list_toolbar'] } as ActionDef, + { selectedRecords: [] }, + ); + expect(scoped.error).toMatch(/select a row first/i); + + const objectLevel = resolveServerActionRecordId( + { name: 'a', locations: ['list_toolbar'] } as ActionDef, + { selectedRecords: [] }, + ); + expect(objectLevel).toEqual({ recordId: undefined }); + }); + + it('an aggregate dispatch (_selectedIds, objectui#3139) bypasses the selection fallback and its guards', () => { + const out = resolveServerActionRecordId( + { name: 'a', locations: ['list_item', 'list_toolbar'], params: { _selectedIds: ['x', 'y'] } } as ActionDef, + { selectedRecords: [{ id: 'x' }, { id: 'y' }] }, + ); + expect(out).toEqual({ recordId: undefined }); + }); +}); + +describe('isRecordScopedAction', () => { + it('true for any record location, false for object-level-only or undeclared', () => { + expect(isRecordScopedAction({ locations: ['list_item'] } as ActionDef)).toBe(true); + expect(isRecordScopedAction({ locations: ['record_header'] } as ActionDef)).toBe(true); + expect(isRecordScopedAction({ locations: ['list_toolbar'] } as ActionDef)).toBe(false); + expect(isRecordScopedAction({} as ActionDef)).toBe(false); + }); +}); + +describe('createServerActionHandler — resolution wiring', () => { + it('a blocking resolution returns its error without POSTing', async () => { + const fetch = okFetch(); + const handler = createServerActionHandler({ fetch }); + + const res = await handler( + { type: 'script', name: 'archive' } as ActionDef, + { selectedRecords: [{ id: 'a' }, { id: 'b' }] }, + ); + + expect(res.success).toBe(false); + expect(res.error).toMatch(/single record/i); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('a custom resolveRecordId replaces the standard dance wholesale (record-page policy)', async () => { + const fetch = okFetch(); + const handler = createServerActionHandler({ + fetch, + resolveRecordId: (action) => ({ recordId: (action as any).recordId ?? 'page_rec_1' }), + }); + + // Even with a grid-style selection in context, the injected policy wins. + await handler( + { type: 'script', name: 'approve', objectName: 'po' } as ActionDef, + { selectedRecords: [{ id: 'x' }, { id: 'y' }] }, + ); + + expect(JSON.parse((fetch as any).mock.calls[0][1].body).recordId).toBe('page_rec_1'); + }); +}); + +describe('createServerActionHandler — envelope rule (objectstack#3913)', () => { + it('treats an INNER success:false under HTTP 200 as a failure (no refresh)', async () => { + const fetch = okFetch({ + success: true, + data: { success: false, error: "Action 'log_call' on object '*' not found" }, + }); + const onRefresh = vi.fn(); + const handler = createServerActionHandler({ fetch, onRefresh }); + + const res = await handler({ type: 'script', name: 'log_call' } as ActionDef); + + expect(res).toEqual({ success: false, error: "Action 'log_call' on object '*' not found" }); + expect(onRefresh).not.toHaveBeenCalled(); + }); + + it('resolves a nested {error:{message}} dispatch failure to a STRING (React #31 guard)', async () => { + const fetch = vi.fn(async () => ({ + ok: false, + status: 404, + json: async () => ({ + success: false, + error: { message: "Action 'log_call' on object 'global' not found", code: 404 }, + }), + })) as unknown as ServerActionFetch; + const handler = createServerActionHandler({ fetch }); + + const res = await handler({ type: 'script', name: 'log_call' } as ActionDef); + + expect(res.success).toBe(false); + expect(typeof res.error).toBe('string'); + expect(res.error).toBe("Action 'log_call' on object 'global' not found"); + }); + + it('an unparseable body still fails with the HTTP status fallback', async () => { + const fetch = vi.fn(async () => ({ + ok: false, + status: 503, + json: async () => { throw new Error('not json'); }, + })) as unknown as ServerActionFetch; + const handler = createServerActionHandler({ fetch }); + + const res = await handler({ type: 'script', name: 'x' } as ActionDef); + + expect(res.success).toBe(false); + expect(res.error).toContain('HTTP 503'); + }); + + it('success carries the ACTION ENVELOPE in data (the level ActionResult has always carried)', async () => { + const fetch = okFetch({ + success: true, + data: { success: true, data: { redirectUrl: 'https://example.test/sso' } }, + }); + const handler = createServerActionHandler({ fetch }); + + const res = await handler({ type: 'script', name: 'open_env' } as ActionDef); + + expect(res.success).toBe(true); + expect(res.data).toEqual({ success: true, data: { redirectUrl: 'https://example.test/sso' } }); + }); +}); + +describe('createServerActionHandler — refresh semantics', () => { + it('calls onRefresh when refreshAfter is not false, skips it when false', async () => { + const onRefresh = vi.fn(); + const handler = createServerActionHandler({ fetch: okFetch(), onRefresh }); + + await handler({ type: 'script', name: 'a' } as ActionDef); + expect(onRefresh).toHaveBeenCalledTimes(1); + + const res = await handler({ type: 'script', name: 'a', refreshAfter: false } as ActionDef); + expect(onRefresh).toHaveBeenCalledTimes(1); + expect(res).toMatchObject({ success: true, reload: false }); + }); +}); + +describe('createServerActionHandler — re-entrancy guard', () => { + it('blocks a second dispatch of the same action+record while the first is in flight, then releases', async () => { + let release!: (v: { ok: boolean; status: number; json(): Promise }) => void; + const gate = new Promise<{ ok: boolean; status: number; json(): Promise }>((r) => { release = r; }); + const fetch = vi.fn(() => gate) as unknown as ServerActionFetch; + const handler = createServerActionHandler({ fetch }); + + const action = { type: 'script', name: 'sso_open', params: { recordId: 'env_1' } } as ActionDef; + const first = handler(action); + const second = await handler(action); // while the first awaits the server + + expect(second).toEqual({ success: false, error: 'Action already in progress' }); + expect(fetch).toHaveBeenCalledTimes(1); + + release({ ok: true, status: 200, json: async () => ({ success: true, data: {} }) }); + await expect(first).resolves.toMatchObject({ success: true }); + + // Guard released — the same action dispatches again. + const third = await handler(action); + expect(third).toMatchObject({ success: true }); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('different records of the same action are independent dispatches', async () => { + const fetch = okFetch(); + const handler = createServerActionHandler({ fetch }); + + await Promise.all([ + handler({ type: 'script', name: 'a', params: { recordId: 'r1' } } as ActionDef), + handler({ type: 'script', name: 'a', params: { recordId: 'r2' } } as ActionDef), + ]); + + expect(fetch).toHaveBeenCalledTimes(2); + }); +}); + +describe('createServerActionHandler — transport failure', () => { + it('a thrown fetch error becomes a failed result (and releases the guard)', async () => { + const fetch = vi.fn(async () => { throw new Error('network down'); }) as unknown as ServerActionFetch; + const handler = createServerActionHandler({ fetch }); + const action = { type: 'script', name: 'a' } as ActionDef; + + const res = await handler(action); + expect(res).toEqual({ success: false, error: 'network down' }); + + // finally released the in-flight key — the retry reaches the transport. + await handler(action); + expect(fetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/app-shell/src/utils/actionErrorDetail.ts b/packages/core/src/actions/actionErrorDetail.ts similarity index 74% rename from packages/app-shell/src/utils/actionErrorDetail.ts rename to packages/core/src/actions/actionErrorDetail.ts index 544dbc4723..1eec3f25ff 100644 --- a/packages/app-shell/src/utils/actionErrorDetail.ts +++ b/packages/core/src/actions/actionErrorDetail.ts @@ -1,3 +1,11 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + /** * Resolve a human-readable message out of an ObjectStack error payload. * @@ -11,6 +19,10 @@ * * Handles, in order: `{error: 'msg'}`, `{error: {message: 'msg'}}`, * `{message: 'msg'}`, else the caller's fallback. + * + * Moved here from `@object-ui/app-shell` (`utils/actionErrorDetail`) by + * objectui#2904 so `createServerActionHandler` — the core dispatch every + * consumer registers — can own the rule. */ export function actionErrorDetail(body: unknown, fallback: string): string { const b = body as { error?: unknown; message?: unknown } | null; diff --git a/packages/app-shell/src/utils/actionResponse.ts b/packages/core/src/actions/actionResponse.ts similarity index 90% rename from packages/app-shell/src/utils/actionResponse.ts rename to packages/core/src/actions/actionResponse.ts index ed89c7377b..dbe90c50c0 100644 --- a/packages/app-shell/src/utils/actionResponse.ts +++ b/packages/core/src/actions/actionResponse.ts @@ -1,5 +1,13 @@ /** - * The ONE place the console interprets a `POST /api/v1/actions/...` response. + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The ONE place a `POST /api/v1/actions/...` response is interpreted. * * This existed twice — `useConsoleActionRuntime.serverActionHandler` and * `RecordDetailView`'s copy of the same handler — and the two drifted, which is @@ -8,6 +16,11 @@ * action fired a green "completed" toast on every list/page surface. Two copies * of a subtle envelope rule is a bug generator; this is the rule, once. * + * Moved from `@object-ui/app-shell` (`utils/actionResponse`) into core by + * objectui#2904: `createServerActionHandler` — the dispatch every consumer + * registers as its `script` handler — lives here and must apply the rule, so + * the rule lives beside it. + * * ## The legacy envelope was DOUBLE, and that was the trap (pre-objectstack#3962) * * ``` diff --git a/packages/core/src/actions/index.ts b/packages/core/src/actions/index.ts index 12088f266e..d716897b71 100644 --- a/packages/core/src/actions/index.ts +++ b/packages/core/src/actions/index.ts @@ -12,3 +12,6 @@ export * from './actionKeys.js'; export * from './TransactionManager.js'; export * from './UndoManager.js'; export * from './bulkFastPath.js'; +export * from './actionErrorDetail.js'; +export * from './actionResponse.js'; +export * from './serverActionHandler.js'; diff --git a/packages/core/src/actions/serverActionHandler.ts b/packages/core/src/actions/serverActionHandler.ts new file mode 100644 index 0000000000..fca43f37a9 --- /dev/null +++ b/packages/core/src/actions/serverActionHandler.ts @@ -0,0 +1,265 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * `createServerActionHandler` — the core server-action dispatcher factory + * (objectui#2904, the follow-up #2896 deferred). + * + * `ActionSchema.body` (HookBodySchema — L1 expression / L2 sandboxed JS) is the + * spec's preferred binding for script actions, and bodies execute SERVER-side: + * `POST /api/v1/actions/{object}/{action}` → the runtime sandbox. This client + * never interprets a body (L2 needs an isolated VM enforcing capabilities / + * `timeoutMs` / `memoryMb`, which a browser cannot provide; L1 is formula-engine + * CEL, a different dialect from this package's `${…}` evaluator). The client + * DISPATCHES; it does not interpret — see the `ActionDef.body` doc and #2896. + * + * Until this factory existed, only the console could dispatch: app-shell's + * `useConsoleActionRuntime` and `RecordDetailView` each hand-rolled the same + * POST (and drifted — objectstack#3913, framework#3935), while every other + * `@object-ui/core` consumer fell through to the built-in `executeScript` and + * hit a dead end. The factory is deliberately opinion-free about the three + * things core has no business deciding (#2904): + * + * - **auth** — inject `fetch` (an authenticated wrapper: Bearer, tenant, + * cookies — whatever the host's transport needs); + * - **base URL** — inject `baseUrl` (a string or a thunk; core inherits no + * bundler env convention like `VITE_SERVER_URL`); + * - **object scope** — inject `resolveObject` for the fallback when the action + * declares no `objectName` of its own. + * + * Everything protocol-shaped lives here, once: name-only action identity + * (ADR-0110 D1), the record-id resolution dance (`_rowRecord`, `recordIdField`, + * aggregate `_selectedIds`, toolbar-selection fallback with its guards), the + * re-entrancy guard, the POST body shape `{ recordId, params }`, and the + * `/actions` envelope rule (`interpretActionResponse`). + * + * Standalone usage: + * + * ```ts + * import { createServerActionHandler } from '@object-ui/core'; + * + * const script = createServerActionHandler({ + * fetch: myAuthenticatedFetch, + * baseUrl: 'https://api.example.com', + * resolveObject: () => currentObjectName, + * onRefresh: () => notifyDataChanged(), + * }); + * // Register it — registered handlers beat the built-in executeScript: + * // (or runner.registerHandler('script', script)) + * ``` + */ + +import type { ActionContext, ActionDef, ActionResult } from './ActionRunner'; +import { interpretActionResponse } from './actionResponse'; + +/** The response surface the dispatcher needs — satisfied by a WHATWG `Response`. */ +export interface ServerActionFetchResponse { + ok: boolean; + status: number; + json(): Promise; +} + +/** + * The injected transport. Core has no auth opinion: pass an authenticated + * wrapper (e.g. app-shell's `createAuthenticatedFetch()`), not the bare global + * `fetch`, unless your deployment really needs no credentials. + */ +export type ServerActionFetch = ( + url: string, + init: { method: 'POST'; headers: Record; body: string }, +) => Promise; + +/** + * What a record-id resolver reports: a record id (absent for object-scoped + * dispatches), or a blocking error the handler returns without POSTing. + */ +export interface ServerActionRecordIdResolution { + recordId?: unknown; + error?: string; +} + +export type ServerActionRecordIdResolver = ( + action: ActionDef, + context: ActionContext | undefined, +) => ServerActionRecordIdResolution; + +/** + * A dispatch handler produced by {@link createServerActionHandler}. Assignable + * to `ActionRunnerHandler` — register it under `'script'` (and, where the host + * routes them server-side, other action types). + */ +export type ServerActionHandler = ( + action: ActionDef, + context?: ActionContext, +) => Promise; + +export interface ServerActionHandlerConfig { + /** Authenticated transport — see {@link ServerActionFetch}. */ + fetch: ServerActionFetch; + /** + * Server origin prefix (`''`/omitted = same-origin). A thunk is read per + * dispatch, so hosts with late-bound configuration can inject one. + */ + baseUrl?: string | (() => string); + /** + * Fallback object scope when the action declares no `objectName` — e.g. the + * current view's object. When neither yields a name the dispatch is `global`. + */ + resolveObject?: (action: ActionDef, context: ActionContext | undefined) => string | undefined; + /** + * Replace the standard record-id dance ({@link resolveServerActionRecordId}) + * wholesale — e.g. a record page resolves against ITS record, not a grid + * selection. + */ + resolveRecordId?: ServerActionRecordIdResolver; + /** + * Invoked after a successful dispatch that requests a refresh + * (`refreshAfter !== false`) — wire your data invalidation here. + */ + onRefresh?: () => void; +} + +/** + * An action that also mounts on list rows (`list_item`) or record pages is + * designed to run against a single record. When such an action is launched + * from the list toolbar with nothing selected, there is no record context to + * resolve — block up front instead of triggering a run that fails at its + * first record-bound step (#2210: "Update requires an ID"). Actions declaring + * only object-level locations (e.g. `['list_toolbar']`) are left alone: they + * legitimately run without a record. + */ +export function isRecordScopedAction(action: ActionDef): boolean { + const locations = action.locations; + if (!Array.isArray(locations)) return false; + return locations.some((l) => + l === 'list_item' || l === 'record_header' || l === 'record_more' || l === 'record_section'); +} + +/** + * The standard record-id resolution dance, shared by the dispatcher and by + * consumers that need the id BEFORE dispatching (e.g. app-shell's zero-roundtrip + * `newTabUrl` fast path). Pure — reads the action/context, mutates nothing. + * + * Order: explicit `params.recordId` → the stashed `_rowRecord`'s + * `recordIdField` (list_item invocations) → the grid's checkbox selection from + * the runner context (`selectedRecords`, list_toolbar invocations). An + * AGGREGATE bulk dispatch (objectui#3139) carries the whole selection as + * `params._selectedIds` — the server reads that array, not `recordId`, so the + * selection fallback must not touch it (resolving a recordId would mislabel + * the run as record-scoped, and the multi-select guard would block exactly the + * selection the dispatch exists to carry). Multi-select is ambiguous for a + * single-record action — block with a message; so is zero selection when the + * action is record-scoped (see {@link isRecordScopedAction}). + */ +export function resolveServerActionRecordId( + action: ActionDef, + context: ActionContext | undefined, +): ServerActionRecordIdResolution { + const params = (action.params && !Array.isArray(action.params)) + ? (action.params as Record) + : {}; + const rowRecord = params._rowRecord as Record | undefined; + const recordIdField = action.recordIdField || 'id'; + let recordId = (params as { recordId?: unknown }).recordId ?? rowRecord?.[recordIdField]; + const isAggregateDispatch = Array.isArray(params._selectedIds); + if (recordId == null && !isAggregateDispatch) { + const selected = Array.isArray(context?.selectedRecords) ? context!.selectedRecords : []; + if (selected.length === 1) { + recordId = selected[0]?.[recordIdField]; + } else if (selected.length > 1) { + // The runner's post-execution hook surfaces `error` as a toast. + return { error: 'This action runs on a single record — select exactly one row.' }; + } else if (isRecordScopedAction(action)) { + return { error: 'This action runs on a single record — select a row first.' }; + } + } + return { recordId: recordId ?? undefined }; +} + +/** + * Build a server-action dispatch handler. See the module doc for what the + * factory owns and what the config injects. + */ +export function createServerActionHandler(config: ServerActionHandlerConfig): ServerActionHandler { + const { fetch: dispatchFetch, resolveObject, onRefresh } = config; + const resolveRecordId = config.resolveRecordId ?? resolveServerActionRecordId; + // Guards against double-firing a server action (slow SSO handoff, etc.). + // Handler-instance state: keep the produced handler stable for its guard to + // span invocations. + const inFlight = new Set(); + + return async function serverActionDispatch( + action: ActionDef, + context?: ActionContext, + ): Promise { + // [ADR-0110 D1] The URL identifies the action by its declarative `name`, + // never by `target`. `target` is a BINDING EXPRESSION — the handler's + // registration key here, but a URL / flow id / FormView name for other + // types, `${param.X}`-interpolatable, and legitimately non-unique — so it + // cannot identify a declaration. Posting it (the old `target || name`) + // meant the server resolved NO declaration for a target-bound action and + // silently skipped both the ADR-0066 D4 capability gate and the ADR-0104 + // param contract (framework#3935). The server derives the handler key from + // the declaration it resolves by name. + const actionName = action.name; + if (!actionName) { + return { success: false, error: 'Action has no name — a server action must declare one' }; + } + + // The row record is a CLIENT-side stash (list_item invocations park it + // under `params._rowRecord` for param defaults and id resolution) — never + // part of the server contract, so it is stripped from the POSTed params. + const params = (action.params && !Array.isArray(action.params)) + ? { ...(action.params as Record) } + : {}; + delete params._rowRecord; + + const resolution = resolveRecordId(action, context); + if (resolution.error) { + return { success: false, error: resolution.error }; + } + const recordId = resolution.recordId; + + // Re-entrancy guard. + const inflightKey = `${actionName}:${recordId ?? ''}`; + if (inFlight.has(inflightKey)) { + return { success: false, error: 'Action already in progress' }; + } + inFlight.add(inflightKey); + try { + const baseUrl = typeof config.baseUrl === 'function' ? config.baseUrl() : (config.baseUrl ?? ''); + const objectName = action.objectName || resolveObject?.(action, context) || 'global'; + const res = await dispatchFetch( + `${baseUrl}/api/v1/actions/${encodeURIComponent(objectName)}/${encodeURIComponent(actionName)}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ recordId, params }), + }, + ); + const json = await res.json().catch(() => null); + // The `/actions` envelope rule, once — see ./actionResponse. + const outcome = interpretActionResponse(res, json, `Action "${actionName}"`); + if (!outcome.ok) { + // Don't toast here — the ActionRunner's post-execution hook surfaces + // `error` as a toast; a handler-side toast double-fires it. + return { success: false, error: outcome.error }; + } + const shouldRefresh = action.refreshAfter !== false; + if (shouldRefresh) onRefresh?.(); + // `data` carries the ACTION ENVELOPE (`{ success, data }`), the level + // `ActionResult.data` has always carried; consumers reading the + // handler's own return value go through `readActionPayload`. + return { success: true, data: outcome.envelope, reload: shouldRefresh }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } finally { + inFlight.delete(inflightKey); + } + }; +}