Skip to content

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

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

[APPS-2792] Add: in-process local execution for backend functions#479
tyffical wants to merge 11 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 from the closed fork-based prototype (nested-property-path walk → {fqn, inputs, connectionId}) as a direct in-process call to an injected ExecuteAction. local-execution.ts
$.Actions now carries connectionId from day one instead of dropping it. local-execution.ts
Added registerActionCatalogIfInstalled/registerBackendRuntimeIfInstalled, replacing what the removed generated wrapper module used to do via text injection. local-execution.ts
Both gate on the same synchronous isActionCatalogInstalled/isDatadogAppsBackendInstalled checks production's bundler path already uses, rather than catching a loadModule failure — Vite's ssrLoadModule doesn't guarantee a stable error code for a missing bare specifier. local-execution.ts
The $ context exposed to the customer's module 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 code has no way to reach. local-execution.ts
The debug log on entry no longer serializes the function's own arguments (customer data, may carry secrets/PII) — it now logs only that arguments were supplied, matching the cloud path's existing convention. local-execution.ts
BackendOutputs, previously declared identically in both this file and dev-server.ts, is now a single shared type in backend/types.ts. types.ts
Added tests covering the happy path, changed-loadModule-result correctness, $.Actions call resolution/validation (including connectionId forwarding), sync/async error propagation, timeout behavior, action-catalog typed-wrapper routing (including a real, non-"not installed" load failure), and the no-token-exposure invariant. local-execution.test.ts
The action-catalog dispatcher rejects a call missing an inputs field, same as the raw $.Actions proxy already does — both entry points funnel into the same executeAction and must reject the same malformed shape. local-execution.ts, local-execution.test.ts
getGlobalDollar reads globalThis.$ via Reflect.get instead of an as cast, matching how deleteGlobalDollar already reads/writes the same property. local-execution.ts
The abandoned-execution debug log now only fires once the caller's own timeout race has actually settled — previously it logged on every rejection, including ones the caller was still waiting on and about to receive normally. local-execution.ts, 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: 27 passed, 1 skipped ✅ VERIFIED
yarn test:unit packages/plugins/apps
# Expected: Test Suites: 24 passed / Tests: 316 passed, 1 skipped ✅ 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) In progress 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) In progress Stacked on this PR (#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 CI checks and tests passed.

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: ba70a6e | 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
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 Outdated
Comment thread packages/plugins/apps/src/vite/local-execution.ts Outdated
Comment thread packages/plugins/apps/src/vite/local-execution.ts Outdated

@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 Outdated
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.

Confirmed — this PR's own "await Promise.race" has no cancellation/isolation semantics, exactly as described (JS can't actually cancel a settled promise's continuation). #480 (stacking on this PR) addresses this with an abandon-not-cancel model: a timed-out execution is marked abandoned and its shared action-catalog/apps-backend registrations are poisoned with rejecting stubs, so a late-resuming $.Actions call from the stale execution is rejected instead of silently running under a newer execution's identity. It doesn't literally stop the JS execution (not possible), but it does prevent the side-effect/identity-confusion risk you're flagging.

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.

Deferring the full fix, documenting the tradeoff instead — true cancellation of a timed-out execution requires terminating a Worker thread, which in-process execution cannot do. Added a WHY comment at the Promise.race call explaining this, and this is the reason the pending worker-pool execution model (a separate, already-planned milestone) exists: it gets real isolation/cancellation semantics that this interim model structurally cannot. Manual QA already verified the current abandon-without-crashing behavior is safe for the dev server itself; the residual risk is scoped to a local developer's own retried test run, not production.

Comment thread packages/plugins/apps/src/vite/local-execution.ts Outdated
chatgpt-codex-connector[bot]

This comment was marked as resolved.

This comment was marked as resolved.

This comment was marked as resolved.

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.
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.
tyffical added a commit that referenced this pull request Aug 25, 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 added a commit that referenced this pull request Aug 25, 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 added a commit that referenced this pull request Aug 25, 2026
…xecution.test.ts

The actual ssrLoadModule/frontend-proxy-bypass fix this commit
originally introduced now lives further down the stack (PR #479) —
only the switch to the shared moduleResolverFor helper (extracted in
the previous commit) remains here.
tyffical added a commit that referenced this pull request Aug 25, 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.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

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 7 out of 7 changed files in this pull request and generated 1 comment.

registerBackendRuntimeIfInstalled(loadModule, projectRoot, $),
]);

const mod = await loadModule(func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX);

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.

Same finding as the earlier Codex comment on this file (index.ts:139) — already answered there: the transitive-propagation fix (a resolveId hook that propagates LOCAL_EXECUTION_LOAD_SUFFIX onto every nested *.backend.ts import reached from a suffixed entry) already exists in tiffany.trinh/apps-2792-wire-into-dev-server (#481), which #479 must be merged into before it takes effect. Verified live on #481's current tip, not stale. Leaving the fix scoped there per this stack's P0/P1-only root-PR policy, given severity here is P2.

@DataDog DataDog deleted a comment from chatgpt-codex-connector Bot Aug 27, 2026
Executes a backend function's file directly in-process inside the Vite dev
server, mirroring executeScriptViaDatadog's BackendOutputs contract as a
drop-in alternate implementation for local dev.
A customer function returning $.Actions.foo.bar without calling it
made the outer await treat the callable Proxy as a thenable (its get
trap returned another callable Proxy for .then too), hanging until
the timeout instead of just returning the value. Also converts the
apply trap to async, since the manual Promise.reject/try-catch
wrapping was only there to turn a synchronous throw into a rejection.
…ng it

The regex re-typed BACKEND_FILE_RE's extension list and the literal
suffix as an independent pattern, so a change to either one could
silently stop matching real backend files without any compiler or
lint signal.
Replace direct globalThis.$ assignment/deletion and Partial<ActionCallArgs>
casts with helper functions and a type guard, since TypeScript can narrow
these without an assertion.
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-in-process-execution branch from 495d2dc to 4e40aae Compare August 27, 2026 05:38
@tyffical
tyffical requested a balanced review from Copilot August 27, 2026 05:46

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

…dule before installing $

The suffix alone was spoofable from frontend source (e.g. a literal
./secrets.backend.ts?dd-local-exec import); requiring SSR context too means
a spoofed client-side import still gets the safe RPC-proxy stub instead of
the real backend module body. Also loads and evaluates the customer module
before installing $ and the SDK bridges, matching production's own ordering,
so code that reaches for $ during its own top-level evaluation fails the
same way locally as it would in Datadog.
… avoid unhandled rejection

A spoofed client-side import falling through to buildProxyModule still
carried the ?dd-local-exec suffix in its id, so BACKEND_FILE_RE (anchored to
end-of-string) never stripped it — the function registered under a
corrupted relativePath/query-name distinct from the file's real
registration. Strips the suffix first so it dedupes onto the same entry.

Also attaches a no-op catch to run()'s promise once the timeout has already
settled the race, since nothing else awaits it — a customer function that
rejects after its own timeout would otherwise be an unhandled rejection
that crashes the whole dev server. Replaces a remaining raw as-cast in
registerActionCatalogIfInstalled with the file's existing isIndexableRecord
guard.
…owing it

runPromise.catch(() => {}) discarded the rejection reason from a
hung customer function once the outer timeout race already settled,
leaving the real cause of a slow failure undiagnosable.
The raw $.Actions proxy already rejects a call missing an inputs field;
the action-catalog dispatcher funnels into the same executeAction but
skipped this check, silently forwarding inputs: undefined instead.
…ints

The raw proxy and the action-catalog dispatcher each re-implemented
the same inputs/connectionId validation by hand — the exact duplication
that let the action-catalog path drift out of sync and skip the
inputs check in the first place. Extracted into one validateActionCall
helper both entry points now call, so the two can no longer diverge.

Also corrects a test comment referencing Object.defineProperty, which
this file doesn't use — globalThis.$ is set via setGlobalDollar's
Object.assign.
getGlobalDollar read globalThis.$ via an `as Record<string, unknown>`
cast; Reflect.get reads it without one, matching the pattern
deleteGlobalDollar already used for the same property.

The "caller had already stopped waiting" debug log fired on every
run() rejection, not just ones abandoned after the timeout race
already settled, since the .catch handler had no way to tell the two
cases apart. Gates it on whether the race has settled yet.

Also restores the BackendOutputs doc comment's explanation of why the
shape is `{ data: unknown }` (mirrors the app-builder query response),
dropped when the type was consolidated into backend/types.ts.
@tyffical
tyffical marked this pull request as ready for review August 27, 2026 22:05
@tyffical
tyffical requested review from a team as code owners August 27, 2026 22:05
@tyffical
tyffical requested review from aananiadis and ksun154 and a balanced review from Copilot and removed request for a team and aananiadis August 27, 2026 22:05

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 7 out of 7 changed files in this pull request and generated 1 comment.

Comment thread packages/plugins/apps/src/vite/index.ts Outdated
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: 5de3ace0a6

ℹ️ 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".

… filter

An include filter scoped only to no-query and the exact `?dd-local-exec`
suffix lets an unrecognized query (e.g. `?x`, or a malformed
`?dd-local-exec&x`) bypass the transform filter entirely, so Vite falls
back to its default loader instead of the safe RPC-proxy stub. Matching
every query on a backend file and deciding safety in the handler closes
that gap.
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