Skip to content

[APPS-2792] Add: wire local execution into the real dev server - #481

Draft
tyffical wants to merge 7 commits into
tiffany.trinh/apps-2792-harden-local-execution-v2from
tiffany.trinh/apps-2792-wire-into-dev-server
Draft

[APPS-2792] Add: wire local execution into the real dev server#481
tyffical wants to merge 7 commits into
tiffany.trinh/apps-2792-harden-local-execution-v2from
tiffany.trinh/apps-2792-wire-into-dev-server

Conversation

@tyffical

@tyfficaltyffical commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Motivation

  • Part of APPS-2792 — local Node execution for App Builder backend functions. Milestone 2 in the Kickoff doc, stacked on Milestone 1 ([APPS-2792] Add: harden the in-process local execution path #480), which is stacked on Milestone 0 ([APPS-2792] Add: in-process local execution for backend functions #479).
  • [APPS-2792] Add: in-process local execution for backend functions #479 and [APPS-2792] Add: harden the in-process local execution path #480 built the direct-import in-process execution mechanism and hardened it, but neither is reachable from a real request yet — /__dd/executeAction still bundles and calls the full cloud round trip. This PR is what actually makes npm run dev fast: it swaps the customer-facing endpoint over to the in-process path and drops the bundling step from it entirely.
  • Also fixes a real correctness gap found while wiring this up: $.Actions calls dropped connectionId end-to-end, so an action naming a specific connection had no way to reach it. Now threaded through ExecuteActionmakeExecuteActionRemotely → the single-action preview-async query spec.
  • Real manual QA against a scaffolded app (see QA Instructions) found and fixed a critical bug: server.ssrLoadModule runs through the same transform hook that rewrites .backend.ts into the client-side RPC-proxy stub for frontend imports — so local execution's "real" import was actually still that stub, which crashes since the stub calls globalThis.DD_APPS_RUNTIME, a browser-only global that doesn't exist server-side. Invisible to every test in this stack, since they all inject a mocked loadModule that never touches the real transform pipeline.

Architecture

createDevServerMiddleware now routes the two execution endpoints down genuinely different paths — one bundle-free and in-process, one bundling and cloud-bound — that only reconverge at the shared submitQuery/pollQueryExecution helpers once an $.Actions call needs to reach the real Datadog API:

POST /__dd/executeAction POST /__dd/executeActionViaCloud
│ │
▼ ▼
handleExecuteAction handleExecuteActionViaCloud
│ │
▼ ▼
executeScriptLocally bundleBackendFunction (vite build,
(local-execution.ts) in-memory, no bundling on the
│ executeAction path anymore)
│ loadModule = │
│ server.ssrLoadModule ▼
│ (direct import of the executeScriptViaDatadog
│ customer's *.backend.ts, │
│ no bundling) wraps the whole bundled script as
│ a jsFunctionWithActions query
▼ │
runs in this process │
│ │
│ $.Actions call? │
▼ │
makeExecuteActionRemotely │
(single-action preview-async │
query: {fqn, inputs, connectionId}) │
│ │
└────────────────┬──────────────────────┘
▼
submitQuery + pollQueryExecution
(POST + long-poll api.<site>/api/v2/
app-builder/queries/preview-async)

The executeAction path never bundles at all — executeScriptLocally imports the customer's real file directly via loadModule (Vite's own ssrLoadModule, so it gets the same TS-transform/resolve rules and HMR-aware module cache a real request gets) and runs the exported function in this process. No auth check happens until the function actually calls $.Actions; that call becomes its own direct single-action preview-async query via makeExecuteActionRemotely, rather than being wrapped in a whole-script query. The executeActionViaCloud path is the unchanged production round trip: bundle the whole function with Rollup, wrap it as a jsFunctionWithActions query, and submit/poll it the same way. See the RFC's Proposed Solution for the design-level version of this split.

server.ssrLoadModule shares the same transform pipeline as every other module Vite serves — including vite/index.ts's own .backend.ts → RPC-proxy transform, which exists for frontend imports of the same file. Local execution's loadModule call marks its own request with a query suffix (LOCAL_EXECUTION_LOAD_SUFFIX, matching Vite's own ?raw/?url convention) so the transform hook can skip proxy generation specifically for that request, rather than for every SSR-context load of a .backend.ts file (which would also silently affect any unrelated future feature hitting the same hook).

Changes

What changedFile
/__dd/executeAction now looks up the requested function and runs it directly via executeScriptLocally — no bundling on this path at all. /__dd/debugBundle and the cloud round trip (/__dd/executeActionViaCloud) are unchanged and still bundle.dev-server.ts
makeExecuteActionRemotely now forwards connectionId into the single-action preview-async query spec ({fqn, inputs, connectionId}) instead of silently dropping it.dev-server.ts
createDevServerMiddleware takes a new loadModule: LoadModule parameter, threaded from vite/index.ts's configureServer(server) as server.ssrLoadModule.bind(server) — the real Vite dev server's own module loader, giving the local path the same TS-transform/resolve rules and HMR-aware module cache a real request gets.dev-server.ts, vite/index.ts
Added a config() hook returning ssr: { noExternal: [...] } for @datadog/apps-backend/@datadog/action-catalog. Found while testing: both ship ESM-only, and Vite's dev server externalizes node_modules by default (a plain require(), for speed) — which throws Cannot use import statement outside a module the first time a customer's function actually uses either SDK locally. noExternal forces Vite's SSR transform pipeline to handle them instead, matching how the production bundling path already inlines every dependency.vite/index.ts
New LOCAL_EXECUTION_LOAD_SUFFIX/LOCAL_EXECUTION_LOAD_RE: local execution's loadModule call now marks its request with this suffix so the transform hook can tell it apart from a normal frontend import of the same file and skip generating the RPC-proxy stub — fixes the bug described in Motivation.constants.ts, local-execution.ts, vite/index.ts
New regression test calling the transform handler directly with a suffixed vs. unsuffixed id — confirmed red (returned the proxy stub) against the pre-fix code, green after. This is the first test in the whole stack that exercises the real transform hook for this path.index.test.ts
Extracted the loadModule test double — previously hand-rolled separately in this file and in #480/#484's local-execution.test.ts — into a shared moduleResolverFor helper.mocks.ts
Real end-to-end test: spins up an actual Vite dev server (createServer, middleware mode, no port bound) rooted at the same apps_backend_project fixture, and lets its real ssrLoadModule import a real .backend.ts file directly — no mocked bundler, no mocked loadModule. Confirms a real @datadog/apps-backend typed import resolves $.Source correctly through this exact path.dev-server.integration.test.ts (rewritten)
New/updated unit tests: 400/404 for the local path, running with no auth configured at all (a function that never calls $.Actions), a clear error when a function does call $.Actions with no auth configured, the single-action preview-async request-body shape now including connectionId, and the new config() hook's ssr.noExternal contract. Existing cloud-path tests unchanged aside from the new loadModule parameter threaded through every createDevServerMiddleware call.dev-server.test.ts, index.test.ts

QA Instructions

yarn install
yarn test:unit packages/plugins/apps
# Expected: Test Suites: 24 passed / Tests: 316 passed ✅ VERIFIED
yarn workspace @dd/apps-plugin run typecheck
# Expected: no output, clean exit ✅ VERIFIED
npx eslint packages/plugins/apps/src/vite/dev-server.ts packages/plugins/apps/src/vite/dev-server.test.ts packages/plugins/apps/src/vite/dev-server.integration.test.ts packages/plugins/apps/src/vite/index.ts packages/plugins/apps/src/vite/index.test.ts packages/plugins/apps/src/vite/local-execution.ts packages/plugins/apps/src/vite/local-execution.test.ts packages/plugins/apps/src/constants.ts packages/tests/src/_jest/helpers/mocks.ts --quiet
# Expected: no output, clean exit ✅ VERIFIED

Manual QA — real scaffolded app, real dev server (both local and staging)

Built and npm link'd this branch's @datadog/vite-plugin, scaffolded a fresh app (npm create @datadog/apps@latest), linked the local build in, added real backend functions (a basic doubling function, a console.log-emitting function, a deliberate-throw function):

Local (npm run dev, POST /__dd/executeAction):

{"success":true,"result":{"data":{"doubled":42}}}

Confirmed sub-millisecond timing (no cloud round trip), real-time console.log streaming to the terminal (not the browser console, not delayed), and a deliberate throw returning a clean {"success":false,"error":"..."} rather than a crash — all against the fixed code. Against the pre-fix code, every one of these failed identically with Cannot read properties of undefined (reading 'executeBackendFunction'), which is what led to the fix in this PR. ✅ VERIFIED

Staging (dd-auth --domain dd.datad0g.com -- npm run dev, real preview-async round trip via /__dd/executeActionViaCloud): this combination is confirmed working — see #473's manual QA section for a real, verified run with actual staging responses.

A durable writeup of this QA flow (including the local↔staging↔app-builder-code architecture) is being consolidated into a Confluence guide, linked from here once published.

Blast Radius

  • This is the first PR in the stack that changes customer-visible behavior: npm run dev's /__dd/executeAction now executes locally by direct import, with no bundling step, instead of round-tripping to the cloud. Still gated behind this whole stack not being released yet (no version bump, no bump.yaml trigger in this PR).
  • The existing cloud round trip is fully preserved, just moved to a new URL (/__dd/executeActionViaCloud) — nothing currently calling /__dd/executeAction in production exists yet (this endpoint isn't released), so there's no live caller to break.
  • The ssr.noExternal config change affects every Vite dev-server session this plugin runs in, not just the local-execution path — low risk in practice (it only forces two specific, already-known-to-this-plugin packages through the transform pipeline instead of externalizing them), but worth noting as a config-surface change.
  • The LOCAL_EXECUTION_LOAD_SUFFIX transform-hook change only special-cases requests carrying that exact marker — no behavior change for any existing frontend import of a .backend.ts file.
  • Risk: medium — this is the PR that actually flips the execution model for any consumer of this endpoint once released, even though today there is none. The bug this PR fixes was a hard blocker for the whole feature working at all, so shipping it fixed (rather than discovering it post-release) is the main risk this PR retires, not one it introduces.
  • #484 (the network/subprocess guard) now stacks directly on this branch rather than sitting as its sibling on [APPS-2792] Add: harden the in-process local execution path #480 — both independently needed the same LOCAL_EXECUTION_LOAD_SUFFIX call-site change, so stacking lets that shared history reconcile once via rebase instead of as a merge conflict whichever PR landed second.

Out of Scope / Follow-ups

ItemStatusNext step
npm run dev:verify CLI (mode-aware routing to /__dd/executeActionViaCloud, web-ui template changes)Not startedMilestone 3, separate PRs (build-plugins + web-ui)
Real manual QA against a scaffolded appDoneSee QA Instructions above
A genuine local @datadog/action-catalog fixture package for a typed-import e2e testDeferredReasonable, cheap follow-up — not required for this coverage to be meaningful, since both SDKs funnel through the identical $.Actions routing
This branch was stale relative to #480's latest commit (8292a6aa, added after this branch diverged), which was failing CI's auto-merge checkDoneRebased onto #480's current tip

Documentation

@datadog-official

This comment has been minimized.

@tyffical
tyfficalforce-pushed the tiffany.trinh/apps-2792-wire-into-dev-server branch from 6e85225 to ae53df1CompareAugust 7, 2026 20:24
tyffical added a commit that referenced this pull request Aug 11, 2026
…function body
server.ssrLoadModule(func.absolutePath) goes through the same transform
hook (vite/index.ts) that rewrites *.backend.ts into the client-side
RPC-proxy stub — so local execution's "real" import can actually still be
the proxy stub, which crashes since globalThis.DD_APPS_RUNTIME doesn't
exist server-side. Every existing test here mocks loadModule directly, so
none of them exercise the real transform pipeline and would catch this.
Append the same query-suffix marker introduced in #481 (matching Vite's
own ?raw/?url convention) so the shared transform hook can recognize this
specific request and skip proxy generation for it. The transform-hook
side of this fix lives in #481, since that's where local execution is
actually wired to a real, plugin-registered dev server — this PR only
needs its own call site and mocks to stay consistent with that contract
so the two branches reconcile cleanly whichever merges first.
@tyffical
tyfficalforce-pushed the tiffany.trinh/apps-2792-wire-into-dev-server branch from 7cbbeec to d976f85CompareAugust 20, 2026 22:14
tyffical added a commit that referenced this pull request Aug 20, 2026
…function body
server.ssrLoadModule(func.absolutePath) goes through the same transform
hook (vite/index.ts) that rewrites *.backend.ts into the client-side
RPC-proxy stub — so local execution's "real" import can actually still be
the proxy stub, which crashes since globalThis.DD_APPS_RUNTIME doesn't
exist server-side. Every existing test here mocks loadModule directly, so
none of them exercise the real transform pipeline and would catch this.
Append the same query-suffix marker introduced in #481 (matching Vite's
own ?raw/?url convention) so the shared transform hook can recognize this
specific request and skip proxy generation for it. The transform-hook
side of this fix lives in #481, since that's where local execution is
actually wired to a real, plugin-registered dev server — this PR only
needs its own call site and mocks to stay consistent with that contract
so the two branches reconcile cleanly whichever merges first.
@tyffical
tyfficalforce-pushed the tiffany.trinh/apps-2792-wire-into-dev-server branch from d976f85 to 1900a78CompareAugust 20, 2026 23:16
@tyffical
tyfficalforce-pushed the tiffany.trinh/apps-2792-wire-into-dev-server branch from 1900a78 to a0bcc4fCompareAugust 20, 2026 23:38
Wires the new direct-import local-execution path (local-execution.ts)
into the real Vite dev server: threads server.ssrLoadModule through as
the loadModule dependency, drops the bundling step from
/__dd/executeAction entirely (debugBundle and executeActionViaCloud
still bundle, unchanged), and forwards connectionId end-to-end through
makeExecuteActionRemotely so a $.Actions call naming a specific
connection actually reaches it instead of being silently dropped.
Also forces @datadog/apps-backend and @datadog/action-catalog through
Vite's SSR transform pipeline (ssr.noExternal) rather than letting the
dev server's default node_modules externalization `require()` them
directly -- both ship ESM-only, so an externalized `require()` throws
"Cannot use import statement outside a module".
dev-server.test.ts and local-execution.test.ts (build-plugins#480/#484)
each defined their own near-identical LoadModule resolver double. Factor
the common resolve-or-throw logic into moduleResolverFor in the shared
mocks helper so both can build on it instead of duplicating it.
…C-proxy transform
server.ssrLoadModule(func.absolutePath) went through the same transform
hook that rewrites *.backend.ts into the client-side RPC-proxy stub
(globalThis.DD_APPS_RUNTIME.executeBackendFunction(...)) — so local
execution's "real" import was actually still the proxy stub, which
crashes immediately since that global doesn't exist server-side. Every
existing test mocked loadModule directly, so none of them exercised the
real transform pipeline and caught this.
Mark local execution's own load with a query suffix (matching Vite's own
?raw/?url convention) and have the transform hook skip proxy generation
for that specific marked request, deferring to Vite's normal TS/esbuild
transform instead. Checking the marker rather than the generic
Vite-supplied options.ssr flag keeps this from also affecting any other,
unrelated future SSR-context load of the same file.
…return
The transform handler's this.parse(code) call ran after the
LOCAL_EXECUTION_LOAD_SUFFIX early-return, so any static safety check
inserted at that point (e.g. rejecting Node builtin imports) would
silently never run for the one path that actually executes the code.
Moving the parse ahead of the early-return closes that gap and makes a
future merge with such a check conflict loudly instead of merging clean.
Also corrects dev-server.integration.test.ts's doc comment, which
claimed to exercise the transform hook's production wiring even though
its createServer() call never registers getVitePlugin()'s plugin.
…tch)
submitQuery's doc comment referenced executeSingleActionRemotely, a
function that never existed under that name — the real one is
makeExecuteActionRemotely, already correctly named elsewhere in this
file.
mockLoadModuleReturning's fn parameter used any[] as an escape hatch;
(...args: never[]) => unknown accepts the same range of test callback
signatures without it, since never is assignable to any parameter type
a caller's own lambda declares.
@tyffical
tyfficalforce-pushed the tiffany.trinh/apps-2792-wire-into-dev-server branch from a0bcc4f to dc33400CompareAugust 21, 2026 03:52
@tyffical
tyffical requested a balanced review from CopilotAugust 21, 2026 16:24
@tyffical

Copy link
Copy Markdown
ContributorAuthor

@cursor review
@codex review

CopilotAI 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 wires backend functions into Vite’s in-process local execution path while retaining cloud execution separately.

Changes:

  • Routes /__dd/executeAction locally and adds the cloud-specific endpoint.
  • Preserves real backend source during local Vite loading.
  • Adds action connection forwarding and regression coverage.

Reviewed changes

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

Show a summary per file
FileDescription
packages/tests/src/_jest/helpers/mocks.tsAdds a shared module resolver mock.
packages/plugins/apps/src/constants.tsDefines the local-load marker.
packages/plugins/apps/src/vite/local-execution.tsLoads marked backend modules.
packages/plugins/apps/src/vite/local-execution.test.tsUpdates module-loading tests.
packages/plugins/apps/src/vite/index.tsConfigures SSR loading and middleware.
packages/plugins/apps/src/vite/index.test.tsTests transforms and SSR configuration.
packages/plugins/apps/src/vite/dev-server.tsSplits local and cloud execution.
packages/plugins/apps/src/vite/dev-server.test.tsTests both execution routes.
packages/plugins/apps/src/vite/dev-server.integration.test.tsExercises real Vite module loading.

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

Comment threadpackages/plugins/apps/src/vite/index.ts
Comment threadpackages/plugins/apps/src/vite/dev-server.ts Outdated

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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:dc334004da

ℹ️ 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 threadpackages/plugins/apps/src/vite/index.ts
…-import gap
executeScriptViaDatadog's outputs return no longer needs an `as
BackendOutputs` cast — TypeScript already structurally narrows outputs
to the right shape after the preceding object/null/'data' in outputs
guard; returning it directly keeps the compiler checking the contract
instead of asserting past it.
Also documents (doesn't yet fix) a real gap found in review: the
LOCAL_EXECUTION_LOAD_SUFFIX marker only reaches the entry module
ssrLoadModule requests — a *.backend.ts file statically importing
another one would have that nested import replaced with the frontend
RPC-proxy stub instead of running for real. Deferred until backend
files importing each other is an actual pattern in use.
@tyffical
tyffical requested a balanced review from CopilotAugust 21, 2026 20:00
@tyffical

Copy link
Copy Markdown
ContributorAuthor

@codex review

CopilotAI 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 9 out of 9 changed files in this pull request and generated 2 comments.

Suppressed comments (1)

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

packages/plugins/apps/src/vite/index.test.ts:231

  • The added lint suppression and any cast remove type checking from the config-hook contract this test is intended to verify. Narrow the plugin and hook at runtime instead so an unexpected plugin shape fails clearly without bypassing TypeScript.
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
const config = (plugin as any).config();

filter: {
id: {
include: [BACKEND_FILE_RE],
include: [BACKEND_FILE_RE, LOCAL_EXECUTION_LOAD_RE],
server.middlewares.use(
createDevServerMiddleware(
bundler.build,
server.ssrLoadModule.bind(server),

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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:b9470c1614

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

log.debug(`Executing action locally: ${displayName} with args`);

const executeAction = makeExecuteActionRemotely(auth, doAuthenticatedRequest, log);
const result = await executeScriptLocally(func, args, executeAction, loadModule, log);

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 Allow remote action polling past ten seconds

When a locally executed function awaits $.Actions, makeExecuteActionRemotely may spend up to roughly 30 seconds in a single long-poll request, but this call invokes executeScriptLocally with its fixed 10-second default timeout. Any otherwise-valid action taking longer than ten seconds therefore returns a 500 timeout from /__dd/executeAction while its API request continues in the background; pass an action-aware timeout or reconcile the local timeout with the polling lifecycle.

Useful? React with 👍 / 👎.

…ports
LOCAL_EXECUTION_LOAD_SUFFIX only reached the one entry module ssrLoadModule
was called with. A .backend.ts file statically importing another
.backend.ts file resolved that nested import unsuffixed, hitting the
transform hook's "not suffixed" branch and getting replaced with the
frontend RPC-proxy stub — calling globalThis.DD_APPS_RUNTIME, which doesn't
exist server-side, instead of running the real code.
Adds a resolveId hook that propagates the suffix onto a nested .backend.ts
import when its importer was itself suffixed. The hook has to run with
order: 'pre' — a plain relative specifier is fully resolvable by Vite's own
built-in resolver at the default order, which would otherwise resolve and
short-circuit the plugin hook chain before this one ever saw it.
Verified against a real Vite dev server with the actual plugin registered
(not a mocked resolve), since the bug is specifically about Vite's own
resolution behavior — a loadModule test double can't reproduce it.
Sign up for freeto 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

@tyffical