diff --git a/.changeset/core-server-dispatcher-factory.md b/.changeset/core-server-dispatcher-factory.md
new file mode 100644
index 000000000..30187b34e
--- /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 5958156a4..efd06396a 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 0bfda44ac..f18d7af04 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 03138d763..582901da1 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 000000000..9a1aabae0
--- /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 000000000..ab5ba5af7
--- /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 4954146b6..b824c0118 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 8cf7b44f7..1c9eeb48b 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 ab54e96e5..4a4fd579d 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 f28dc94ba..7e0e15fb4 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 000000000..7690c5cb6
--- /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 544dbc472..1eec3f25f 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 ed89c7377..dbe90c50c 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 12088f266..d716897b7 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 000000000..fca43f37a
--- /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);
+ }
+ };
+}