Skip to content

[APPS-2792] Add: in-process local execution for backend functions - #479

Draft
tyffical wants to merge 2 commits into
masterfrom
tiffany.trinh/apps-2792-in-process-execution
Draft

[APPS-2792] Add: in-process local execution for backend functions#479
tyffical wants to merge 2 commits into
masterfrom
tiffany.trinh/apps-2792-in-process-execution

Conversation

@tyffical

@tyffical tyffical commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Motivation

Architecture

executeScriptLocally (local-execution.ts) introduces three collaborating pieces: an injected loadModule standing in for server.ssrLoadModule, a globalThis.$ context populated once per call, and a $.Actions Proxy that turns nested property access into a single executeAction call.

┌──────────────────────────────────────────────────────────────────────┐
│ Vite dev server process                                              │
│                                                                        │
│ executeScriptLocally(func, args, executeAction, loadModule, log)     │
│                                                                        │
│  1. globalThis.$ = {                                                 │
│       backendFunctionArgs: args,                                     │
│       Actions: makeActionsProxy(executeAction),                      │
│       Source: LOCAL_DEV_SOURCE,                                      │
│     }                                                                 │
│              │                                                        │
│              ▼                                                        │
│  2. loadModule(specifier)  ── resolves against the customer's own    │
│     │        │                project/deps, not build-plugins'      │
│     │        │                                                        │
│     │        ├─▶ registerActionCatalogIfInstalled                    │
│     │        │     loadModule('@datadog/action-catalog/              │
│     │        │       action-execution')                              │
│     │        │     → setExecuteActionImplementation(wraps            │
│     │        │       executeAction)   (no-op if not installed)       │
│     │        │                                                        │
│     │        └─▶ registerBackendRuntimeIfInstalled                   │
│     │              loadModule('@datadog/apps-backend/runtime/…')     │
│     │              → setBackend(buildRuntimeFromJsFunctionWith       │
│     │                Actions($))       (no-op if not installed)      │
│     │                                                                 │
│     └─▶ loadModule(func.absolutePath) → customer's real              │
│           *.backend.ts module (direct import, no bundling)           │
│              │                                                        │
│              ▼                                                        │
│  3. fn = mod[func.name]; result = await fn(...args)                  │
│              │                                                        │
│              │  customer code reads globalThis.$ directly, e.g.      │
│              │  $.Actions.slack.chat.postMessage({ inputs, … })      │
│              ▼                                                        │
│     $.Actions Proxy (makeActionsProxy)                               │
│       get()   → walks the nested path: ['slack','chat','postMessage']│
│       apply() → fqn = `com.datadoghq.${path.join('.')}`              │
│                → executeAction(fqn, inputs, connectionId)            │
│              │                                                        │
│              ▼                                                        │
│     executeAction (injected — dev server's real single-action call,  │
│     or a caller-supplied stub in tests)                              │
└────────────────────────────────────────────────────────────────────┘

Changes

What changed File
Added executeScriptLocally, which imports a backend function's real file directly via an injected loadModule (the dev server's real server.ssrLoadModule, or a test double) — no bundling, no wrapper module, no data: URL. local-execution.ts
Ported the $.Actions Proxy (nested-property-path walk → {fqn, inputs, connectionId}) from the closed fork-based prototype as a direct in-process function call to an injected ExecuteAction, which now carries connectionId from day one instead of dropping it. local-execution.ts
Added registerActionCatalogIfInstalled/registerBackendRuntimeIfInstalled, which register @datadog/action-catalog's setExecuteActionImplementation and @datadog/apps-backend's setBackend directly in TypeScript, loaded through the same loadModule (so they resolve against the customer's own project, not build-plugins' own dependency tree) — this replaces what the removed generated wrapper module used to do textually. local-execution.ts
The $ context passed to the customer's module (exposed via globalThis.$, since the customer's real function takes its own arguments, not a $ parameter) carries only backendFunctionArgs, Actions, and Source — verified by test — so a real auth token can later live in a module-private closure the customer's imported code has no way to reach. local-execution.ts
Added tests covering the happy path, changed-loadModule-result correctness, $.Actions call resolution (including connectionId forwarding) and validation, sync/async error propagation, timeout behavior, action-catalog typed-wrapper routing, and the no-token-exposure invariant. local-execution.test.ts

QA Instructions

yarn install
yarn test:unit packages/plugins/apps/src/vite/local-execution.test.ts
# Expected: Test Suites: 1 passed / Tests: 13 passed ✅ VERIFIED
yarn test:unit packages/plugins/apps
# Expected: Test Suites: 23 passed / Tests: 298 passed ✅ VERIFIED
yarn workspace @dd/apps-plugin run typecheck
# Expected: no output, clean exit ✅ VERIFIED
npx eslint packages/plugins/apps/src/vite/local-execution.ts packages/plugins/apps/src/vite/local-execution.test.ts --quiet
# Expected: no output, clean exit ✅ VERIFIED

No manual local or staging QA for this PR specifically: this module isn't wired into createDevServerMiddleware yet, so there's no npm run dev request path that reaches executeScriptLocally() — nothing a human can click through yet, matching the same situation the original fork-based prototype (#461) was in. The tests above exercise a real loadModule contract (the same shape server.ssrLoadModule fulfills), not a mocked substitute for the interesting logic. Real local + staging manual QA becomes possible once this is wired into the dev server (follow-up PR, #481).

Blast Radius

  • No behavior change yet: this module is net-new and not called from anywhere in the existing dev server. Zero effect on any currently-shipping behavior.
  • Risk: low. New, isolated file; existing test suite (298 tests) passes unchanged.

Out of Scope / Follow-ups

Item Status Next step
Wiring into the real dev server (handleExecuteAction, threading a real LoadModule from server.ssrLoadModule) Not started Follow-up PR, stacked on this one (#481)
Real auth token / closure-scoping for real $.Actions execution Blocked Needs the single-action execution endpoint (Action Platform team) to exist first — the injected ExecuteAction stays a caller-supplied stub until then
Hardening (concurrent-execution behavior, broader error-edge-case coverage) Not started Tracked as a separate milestone in the kickoff doc (#480)

Documentation

Executes a backend function by importing its real *.backend.ts file
directly (via an injected loadModule), inside the Vite dev server's
own process -- no bundling, no forked child process. The dev server is
already the isolation boundary from production, so a crash or hang
here only affects the developer's own dev server; process-level
isolation is deliberately not added preemptively.

$.Actions calls resolve through a Proxy (ported from the render.ts
$.Actions logic) that invokes an injected executeAction function
directly -- no IPC needed, since there's no separate process to cross.
The same executeAction backs an action-catalog typed-wrapper
registration and an apps-backend runtime-context registration, both
loaded through the same loadModule (so they resolve against the
customer's own project, not build-plugins' dependency tree) mirroring
what the removed generated wrapper module used to do textually. The
remote call itself is still a stub pending the single-action execution
endpoint.

The $ context passed to the customer's module -- exposed via
globalThis.$, since the customer's own function takes its own real
arguments rather than a $ parameter -- carries only backendFunctionArgs,
Actions, and Source, verified by test, so that once a real auth token
is wired in for real action execution, it can live in a module-private
closure the customer's imported code has no way to reach.
executeScriptLocally writes globalThis.$ synchronously on every call,
which two concurrent calls can race on -- the existing concurrency
test never read globalThis.$ from either customer function, so it
couldn't catch this. Add a test that does, confirming the race is
real in this un-serialized base; skipped here since the fix
(serializing executions via a promise-chain queue) lands in the
Hardening milestone stacked on this PR.

Co-Authored-By: Claude <noreply@anthropic.com>
tyffical added a commit that referenced this pull request Aug 10, 2026
Runs the readOwnArgsAfterDelay concurrency check through the real,
serialized executeScriptLocally entrypoint (its test.skip counterpart
against PR #479's un-serialized base fails with cross-contaminated
args). Passing here confirms the enqueue/queueTail promise-chain mutex
actually closes the globalThis.$ race, not just reorders interleaved
work.
@datadog-datadog-prod-us1-2

datadog-datadog-prod-us1-2 Bot commented Aug 10, 2026

Copy link
Copy Markdown

Pipelines  Tests

Unblock PR with BitsAI

⚠️ Warnings

🚦 1 Pipeline job failed

Continuous Integration | Linting   View in Datadog   GitHub Actions

🔧 Fix in code. This looks caused by changes in this PR. Detected differences in files after running 'yarn cli integrity'. Please run 'yarn cli integrity' and commit the changes.
📋 Copy prompt for your agent
CI on my pull request is failing. Help me find and fix the root cause of each failing job below — they were flagged as caused by changes in this PR, so focus on the diff. For each job, explain the failure and propose a fix.

Branch: tiffany.trinh/apps-2792-in-process-execution

Continuous Integration | Linting
Commit: f556a93c865687b8d40896ca4746180357e7a9f9
Error (code / quality):
Detected differences in files after running 'yarn cli integrity'. Please run 'yarn cli integrity' and commit the changes.
CI job: https://github.com/DataDog/build-plugins/actions/runs/31433933495/job/93603651065

ℹ️ Info

No other issues found (see more)

🧪 All tests passed
❄️ No new flaky tests detected

Useful? React with 👍 / 👎

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 046ca9a | Docs | Datadog PR Page | Give us feedback!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant