Skip to content

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

Draft
tyffical wants to merge 4 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 4 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

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-in-process-execution branch from 98ded08 to 7b74053 Compare August 7, 2026 19:15
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

Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 0eb11e8 | Docs | View more details | Give us feedback!

tyffical added a commit that referenced this pull request Aug 20, 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.
tyffical and others added 2 commits August 20, 2026 23:43
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
tyffical force-pushed the tiffany.trinh/apps-2792-in-process-execution branch from 046ca9a to 2f10d6a Compare August 21, 2026 03:46
tyffical added a commit that referenced this pull request Aug 21, 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.
@tyffical
tyffical requested a lite review from Copilot and removed request for Copilot August 21, 2026 16:23
@DataDog DataDog deleted a comment from chatgpt-codex-connector Bot Aug 21, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Friend, this PR adds the initial in-process backend-function execution mechanism for Vite local development.

Changes:

  • Directly loads and invokes backend modules.
  • Provides globalThis.$ and SDK runtime registration.
  • Adds execution, action-routing, timeout, and concurrency tests.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
local-execution.ts Implements local execution and action proxies.
local-execution.test.ts Tests execution behavior and known race conditions.
Suppressed comments (2)

packages/plugins/apps/src/vite/local-execution.ts:198

  • The intended server.ssrLoadModule implementation will not load the real backend export here. The Apps Vite transform matches every .backend.ts ID and replaces it with the frontend RPC proxy (vite/index.ts:121-154), whose function calls globalThis.DD_APPS_RUNTIME (backend/proxy-codegen.ts:25-33). Local execution therefore invokes the proxy instead of the customer's function. Mark this load with a distinct query suffix and make the transform bypass proxy generation for that marker; cover it through the real Vite transform pipeline rather than only injected module doubles.
        const mod = await loadModule(func.absolutePath);

packages/plugins/apps/src/vite/local-execution.ts:218

  • Promise.race only stops waiting; it does not stop run(). After this timeout rejects, customer code continues in the dev-server process and can later call the injected executeAction, potentially after another execution has replaced the global SDK registrations. Track the execution as abandoned and reject all subsequent raw and action-catalog action calls (in addition to serializing executions), so timed-out work cannot perform authenticated side effects.
    try {
        return await Promise.race([run(), timeout]);
    } finally {
        clearTimeout(timer);

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/plugins/apps/src/vite/local-execution.ts
Comment thread packages/plugins/apps/src/vite/local-execution.ts
Comment thread packages/plugins/apps/src/vite/local-execution.ts
@tyffical

Copy link
Copy Markdown
Contributor Author

@cursor review
@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2f10d6ad88

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/plugins/apps/src/vite/local-execution.ts
Comment on lines +215 to +216
try {
return await Promise.race([run(), timeout]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop timed-out executions before reporting failure

When an asynchronous backend function exceeds the timeout but later resumes, Promise.race rejects only the caller-facing promise and leaves run() executing. That function can subsequently invoke real $.Actions side effects after the client has already received a timeout, and a retry can duplicate those effects; the timeout needs cancellation/isolation semantics, or it must not claim the execution has ended.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the intentional "abandoned, not cancelled" model for v1 — documented in the RFC's Decisions and Trade-Offs and the Kickoff doc's own accepted-limitations list (real cancellation would need an AbortSignal threaded through code we don't control, or re-architecting onto a killable worker thread — bigger than this milestone's scope). Within that model, side effects from a stale execution are the real risk, not the wasted CPU alone — that's closed via an epoch-gated poisoning mechanism in the stacked Hardening PR (#480): the shared action-catalog/apps-backend registrations get proactively replaced with a rejecting stub once an execution concludes (including via timeout, gated so it can't clobber a newer execution's own valid registration). See #480's own PR description for the full mechanism. I'm also adding, in this same review pass, an explicit early-return so a detected-abandoned run skips invoking the customer function at all rather than continuing pointlessly once we already know it's stale.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed this is real, and it's the root of a limitation the rest of this PR stack already builds mitigations around, rather than something fixable in isolation here: this execution model abandons a timed-out function rather than cancelling it (there's no real cancellation primitive for arbitrary third-party async JS without either AbortController cooperation from every dependency, which can't be assumed, or full OS-process isolation, which this design doesn't use). Later PRs in this stack (#480, #484) add the epoch-gated poisoning of shared registrations and the network/subprocess guard specifically to contain the consequences of exactly this — a stale execution's late-settling code still running and potentially still calling $.Actions — as much as is practical without changing the underlying execution model.

Closing this fully would mean either real cancellation semantics or real process isolation, both larger architectural decisions than this PR. Leaving unresolved to keep it visible, and flagging it as a named limitation worth calling out explicitly in the design doc rather than silently accepted.

Comment thread packages/plugins/apps/src/vite/local-execution.ts
…led"

registerActionCatalogIfInstalled/registerBackendRuntimeIfInstalled
caught every loadModule failure and silently treated it as "package
isn't installed" — including a real syntax/transform/evaluation error
in an actually-installed package. That left the SDK unregistered with
no indication anything went wrong; typed action calls would then fail
later with a confusing, unrelated error instead of the real one.

Only swallow errors carrying Node's own module-not-found codes now;
everything else propagates.
@tyffical
tyffical requested a balanced review from Copilot August 21, 2026 19:24
@tyffical

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e5f0b19a7b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

registerBackendRuntimeIfInstalled(loadModule, $),
]);

const mod = await loadModule(func.absolutePath);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bypass the frontend proxy transform for local loads

When loadModule is the intended Vite server.ssrLoadModule, this absolute .backend.ts ID still passes through the apps plugin transform in vite/index.ts, whose backend-file handler replaces every export with a frontend proxy. Therefore fn is the generated globalThis.DD_APPS_RUNTIME.executeBackendFunction(...) wrapper rather than the customer's implementation, so execution either fails because that browser runtime is absent in SSR or dispatches recursively without running the backend body. The local loader needs a raw-source marker or another path that bypasses the frontend proxy transform.

Useful? React with 👍 / 👎.

Comment on lines +116 to +118
function isModuleNotFoundError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException | undefined)?.code;
return code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Distinguish transitive dependency failures from package absence

When an installed action-catalog or apps-backend module imports a missing transitive dependency, Node reports the same MODULE_NOT_FOUND or ERR_MODULE_NOT_FOUND code used for a missing top-level package. The updated implementation provides fresh evidence that the previous issue remains because it classifies solely by that code, so this case is silently treated as an absent optional SDK and later produces an unrelated uninitialized typed-action/runtime error. Check that the resolution failure identifies the exact probed specifier, or resolve package presence separately before suppressing the load failure.

Useful? React with 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (6)

Previously missed (2) — in code that hasn't changed since the last review.

packages/plugins/apps/src/vite/local-execution.ts:187

  • The repository's coding rules prohibit passing a function call directly as another call's argument. Store the constructed runtime in a named local before registering it.

This issue also appears on line 206 of the same file.

    setBackend(buildRuntimeFromJsFunctionWithActions($));

packages/plugins/apps/src/vite/local-execution.ts:91

  • This assertion makes untrusted runtime data satisfy ActionCallArgs without validation. For example, { inputs: [], connectionId: 123 } passes the current inputs check and invokes an ExecuteAction whose contract requires an object input and a string connection ID. Narrow the first argument and validate both fields instead of casting it.

This issue also appears on line 147 of the same file.

            const { inputs, connectionId } = (args[0] ?? {}) as Partial<ActionCallArgs>;
            if (typeof inputs !== 'object' || !inputs) {

packages/plugins/apps/src/vite/local-execution.ts:206

  • Logging the serialized backend arguments exposes arbitrary customer payloads—including credentials or personal data—in dev-server logs. The existing cloud path intentionally logs only that arguments were supplied (dev-server.ts:326). Keep the function name but omit argument values here.
    log.debug(`Executing "${func.name}" in-process with args=${JSON.stringify(args)}`);

packages/plugins/apps/src/vite/local-execution.ts:118

  • Checking only the error code also classifies a missing transitive dependency of an installed SDK as “SDK not installed.” Node uses MODULE_NOT_FOUND/ERR_MODULE_NOT_FOUND in both cases, so this silently skips registration and hides the actual broken installation, contrary to the propagation behavior documented above. Confirm that the missing specifier is the optional package being probed before swallowing the error.
function isModuleNotFoundError(error: unknown): boolean {
    const code = (error as NodeJS.ErrnoException | undefined)?.code;
    return code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND';

packages/plugins/apps/src/vite/local-execution.ts:211

  • This reuses one mutable LOCAL_DEV_SOURCE object for every invocation. Customer code can mutate $.Source during one execution, causing later executions—and the apps-backend runtime built from this object—to observe a modified identity. Create a fresh source object per call or expose a deeply immutable value.
        Source: LOCAL_DEV_SOURCE,

packages/plugins/apps/src/vite/local-execution.ts:149

  • The action-catalog path bypasses the validation applied by makeActionsProxy and casts an arbitrary request before forwarding it. Malformed JavaScript calls can therefore send missing inputs or non-string connection IDs despite the ExecuteAction contract. Reuse one runtime validator for both call paths.
    setExecuteActionImplementation(async (actionId: string, request: unknown) => {
        const { inputs, connectionId } = (request ?? {}) as Partial<ActionCallArgs>;
        return executeAction(actionId, inputs, connectionId);

@tyffical
tyffical requested a balanced review from Copilot August 24, 2026 08:06
@tyffical

Copy link
Copy Markdown
Contributor Author

@cursor review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

packages/plugins/apps/src/vite/local-execution.ts:118

  • MODULE_NOT_FOUND and ERR_MODULE_NOT_FOUND can also report a missing transitive import from an installed SDK. Since this helper does not verify which requested module is missing, both registration paths silently treat that broken installation as “not installed,” leaving the SDK unregistered and surfacing a misleading failure later. Only swallow the error when it identifies the exact SDK export being probed; rethrow missing transitive dependencies.
function isModuleNotFoundError(error: unknown): boolean {
    const code = (error as NodeJS.ErrnoException | undefined)?.code;
    return code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND';

Comment on lines +169 to +170
example: () =>
Object.keys(globalThis).some((k) => k.toLowerCase().includes('token')),
tyffical added a commit that referenced this pull request Aug 24, 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.
isModuleNotFoundError only matched Node's own MODULE_NOT_FOUND/
ERR_MODULE_NOT_FOUND codes, but loadModule here is Vite's ssrLoadModule,
which throws its own ERR_LOAD_URL when a specifier can't be resolved at
all — a real dev server session without @datadog/action-catalog or
@datadog/apps-backend installed would hit a hard error instead of local
execution silently skipping the optional registration. Caught via a
downstream real end-to-end Vite server test, not a mock.
tyffical added a commit that referenced this pull request Aug 24, 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.
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.

2 participants