Skip to content

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

Draft
tyffical wants to merge 14 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 14 commits into
tiffany.trinh/apps-2792-harden-local-execution-v2from
tiffany.trinh/apps-2792-wire-into-dev-server

Conversation

@tyffical

@tyffical tyffical 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.
  • $.Actions calls now thread connectionId end-to-end through ExecuteActionmakeExecuteActionRemotely → the single-action preview-async query spec, so an action naming a specific connection can reach it.
  • The module-graph collector's rollup/parseAst import moved rollup from a devDependency to a real dependency of every published package. rollupConfig.mjs's external list only matched a dependency's exact bare specifier, not a subpath import of it, so rollup/parseAst got bundled instead of externalized — pulling Rollup's own native-binary loader into the published output and crashing any real consumer's vite.config.ts load. Fixed at the source (subpath-aware externalization) rather than avoiding the rollup/parseAst import.

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. Auth is checked upfront for the whole endpoint, matching production's auth-before-execution ordering, so a function that never calls $.Actions isn't a loophole around that requirement; when a function does call $.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 changed File
/__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 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, which would otherwise crash server-side by calling a browser-only global. 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
getAllowedConnectionIds's collectModuleGraphFromServer call looks up the entry node by its fully-resolved (suffixed) id, matching what loadModule actually resolved, while extractConnectionIdsFromModuleGraph still receives the bare id to match its records map's keys. Regression test drives the real middleware for a cold entry with no priming import. vite/index.ts, dev-server.integration.test.ts
bundle()'s external option is now a matcher function instead of a plain string array, so a dependency's subpath imports (e.g. rollup/parseAst) are externalized the same as its bare specifier. Affects every published package's build, not just vite-plugin. rollupConfig.mjs
handleExecuteAction's module-graph priming load now goes through a new loadCustomerModuleEntry helper (shared with executeScriptLocally's own load) instead of calling loadModule directly. The priming load is the only place a customer module's top-level code actually runs — Vite caches the module, so the later load inside executeScriptLocally just reuses the resolved object — so it needs the same customerModuleLoadContext scoping, or a customer module reaching for $ during its own top-level evaluation would silently resolve to whatever $ a prior execution left behind instead of the undefined a fresh top-level access should see. local-execution.ts, dev-server.ts
Extracted three inlined function-call arguments into named locals (matching this file's own convention at every other withTimeout call site), replaced a bare as any cast in a test file with a narrower as unknown as ViteDevServer, and added a test asserting the exact startup auth-warning wording. dev-server.ts, local-execution.ts, dev-server-module-graph.test.ts, dev-server.test.ts
handleExecuteAction passes the priming load's resolved module into executeScriptLocally as its own primedEntry parameter instead of wrapping loadModule in a per-request closure — keeps loadModule the same stable reference local-execution.ts's once-ever SDK registration caches key on. local-execution.ts, dev-server.ts
dev-server-module-graph.ts reads module source via the shared @dd/core/helpers/fs readFile, matching every other file in this package, instead of importing node:fs/promises directly. dev-server-module-graph.ts
The suffixed-subgraph tracking resolveId uses to propagate LOCAL_EXECUTION_LOAD_SUFFIX through nested backend imports is now scoped to one local execution via AsyncLocalStorage (established in loadCustomerModuleEntry, alongside the existing customerModuleLoadContext), instead of a single Set shared for the dev server's whole lifetime. vite/index.ts, local-execution.ts
New tests: bundle()'s subpath-aware external matcher (dependency, peer dependency, Node built-in, explicit config entry, subpath import, and same-prefix-but-not-subpath false positive), collectModuleGraphFromServer's unreadable/unparseable source and self-referential import cycle handling, and a no-auth-configured case for /__dd/executeActionViaCloud mirroring the existing /__dd/executeAction coverage. rollupConfig.test.ts, dev-server-module-graph.test.ts, dev-server.test.ts
pollQueryExecution's outputs check is an explicit attrs.outputs === undefined || attrs.outputs === null, not a bare falsy check — a real action result of 0, false, or '' would otherwise be misclassified as "no outputs" and thrown as an error. dev-server.ts

QA Instructions

yarn install
yarn test:unit packages/plugins/apps
# Expected: Test Suites: 27 passed / Tests: 402 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 (local + staging)

# 1. Build and link the plugin from this branch
cd packages/published/vite-plugin
yarn build
npm link

# 2. Scaffold a real app and link this branch's build in
npm create @datadog/apps@latest ~/apps-2792-qa-481 -- --yes
cd ~/apps-2792-qa-481
npm link @datadog/vite-plugin
cat > src/functions.backend.ts <<'EOF'
export async function doubleNumber(input: number) {
    return { doubled: input * 2 };
}
export async function logAndReturn(msg: string) {
    console.log('[qa]', msg);
    return { logged: msg };
}
export async function alwaysThrows() {
    throw new Error('deliberate QA failure');
}
EOF

# 3. Local: confirm in-process execution, no cloud round trip
npm run dev &
sleep 3
curl -s -X POST http://localhost:5173/__dd/executeAction \
  -H 'content-type: application/json' \
  -d '{"functionName":"<hash>.doubleNumber","args":[21]}'
# Expected: {"success":true,"result":{"data":{"doubled":42}}} ✅ VERIFIED
curl -s -X POST http://localhost:5173/__dd/executeAction \
  -H 'content-type: application/json' \
  -d '{"functionName":"<hash>.alwaysThrows","args":[]}'
# Expected: {"success":false,"error":"deliberate QA failure"} — clean error, not a crash ✅ VERIFIED
kill %1

(<hash> is the SHA-256-encoded query name encodeQueryName generates per function — read it off the generated frontend RPC-proxy stub, e.g. curl -s http://localhost:5173/src/functions.backend.ts.)

Re-verified against the current tip: step 2's npm link currently hits the same pre-existing packaging issue noted in the driver section below (a workspace-linked source import — packages/factory/src/validate — that Node's native ESM loader can't resolve through the packaged dist/), unrelated to this PR. Re-ran the equivalent checks through the direct-source driver instead (real createServer, real createDevServerMiddleware, real ssrLoadModule — no mocks), extended with a case exercising this round's own fix:

doubleNumber (real in-process execution) -> [200] {"success":true,"result":{"data":{"doubled":42}}}
alwaysThrows (clean error, not a crash) -> [500] {"success":false,"error":"deliberate QA failure"}
reportTopLevelDollar (cold entry, no prior priming import) -> [200] {"success":true,"result":{"data":{"outcome":"undefined"}}}

The third case is a customer module that reads $ during its own top-level evaluation (not inside the exported function), on a cold entry Vite hasn't loaded before in this process — confirms the priming load's customerModuleLoadContext scoping resolves $ to undefined through the real ssrLoadModule path, matching dollarGetter's spec-correct semantics (an unresolvable $ reads as undefined, never throws). ✅ VERIFIED

Staging (real dd-auth --domain dd.datad0g.com credentials, real preview-async request to api.datad0g.com, via the same direct-source driver wired with getAuthenticatedRequest('apiKey', ...) instead of a stub):

Calling Datadog API: https://api.datad0g.com/api/v2/app-builder/queries/preview-async
callRealActionEndpoint (real dd.datad0g.com preview-async round trip) -> [500] {"success":false,"error":"HTTP 400 Bad Request\nstatus: 400, ... code: ACTION_NOT_FOUND, ... no action registered for ID com.datadoghq.qa.staging.fakeAction"}

A fake action ID was used deliberately — the point is confirming the whole pipeline (auth headers, request submission, response parsing, error surfacing) reaches the real API and round-trips a real error correctly, not exercising a specific action. ✅ VERIFIED (this session, current tip — supersedes the stale reference to #473, which is now closed)

Manual QA — getAllowedConnectionIds module-graph wiring, direct driver

npm link above goes through @datadog/vite-plugin's packaged dist/ output, which bundles rollup and hits a pre-existing, unrelated native-binary resolution issue (documented in the Confluence QA guide) when loaded this way — unrelated to this PR, but it blocks using the scaffolded app above to test getAllowedConnectionIds on a cold entry specifically. This driver imports straight from this branch's TS source instead, sidestepping that packaging layer entirely while still exercising the real createDevServerMiddleware/collectModuleGraphFromServer code:

# 1. Real fixture backend files — placed inside this checkout (not /tmp) so the
# driver script below resolves `vite` from this repo's own node_modules.
mkdir -p tmp-apps-2792-qa/src
cat > tmp-apps-2792-qa/src/normalDouble.backend.ts <<'EOF'
export async function doubleNumber(input: number) {
    return { doubled: input * 2 };
}
EOF
cat > tmp-apps-2792-qa/src/callAction.backend.ts <<'EOF'
export async function callFakeAction() {
    const result = await $.Actions.qa.fakeAction({ inputs: { hello: 'world' } });
    return { result };
}
EOF

# 2. Driver script — real Vite server, real createDevServerMiddleware, no mocks
cat > tmp-apps-2792-qa/run.mjs <<'EOF'
import { createServer } from 'vite';
import { PassThrough } from 'stream';
import path from 'path';

const REPO = path.resolve(import.meta.dirname, '..');
const QA_ROOT = import.meta.dirname;

function fakeRequest(body) {
    const req = new PassThrough();
    req.method = 'POST';
    req.url = '/__dd/executeAction';
    req.headers = { 'content-type': 'application/json' };
    req.end(JSON.stringify(body));
    return req;
}
function fakeResponse() {
    const chunks = [];
    return {
        statusCode: 200, headers: {},
        setHeader(k, v) { this.headers[k] = v; },
        end(chunk) { if (chunk) chunks.push(chunk); this._body = chunks.join(''); this._resolved?.(); },
        waitForEnd() { return new Promise((r) => { if (this._body !== undefined) return r(); this._resolved = r; }); },
    };
}

const server = await createServer({
    root: QA_ROOT, configFile: false, server: { middlewareMode: true },
    logLevel: 'error', appType: 'custom', optimizeDeps: { noDiscovery: true },
});
const loadModule = server.ssrLoadModule.bind(server);
const { createDevServerMiddleware } = await loadModule(`${REPO}/packages/plugins/apps/src/vite/dev-server.ts`);
const { collectModuleGraphFromServer } = await loadModule(`${REPO}/packages/plugins/apps/src/vite/dev-server-module-graph.ts`);
const { extractConnectionIdsFromModuleGraph } = await loadModule(`${REPO}/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.ts`);
const { LOCAL_EXECUTION_LOAD_SUFFIX } = await loadModule(`${REPO}/packages/plugins/apps/src/constants.ts`);
const { encodeQueryName } = await loadModule(`${REPO}/packages/plugins/apps/src/backend/encodeQueryName.ts`);

// Exactly how vite/index.ts's configureServer wires it — not a mock.
const getAllowedConnectionIds = async (entryId) =>
    extractConnectionIdsFromModuleGraph(
        entryId,
        await collectModuleGraphFromServer(server, entryId, QA_ROOT),
        QA_ROOT,
    );

const doubleNumberFn = { name: 'doubleNumber', relativePath: 'normalDouble.backend.ts', absolutePath: `${QA_ROOT}/src/normalDouble.backend.ts`, allowedConnectionIds: [] };
const callFakeActionFn = { name: 'callFakeAction', relativePath: 'callAction.backend.ts', absolutePath: `${QA_ROOT}/src/callAction.backend.ts`, allowedConnectionIds: [] };

const middleware = createDevServerMiddleware(
    async () => { throw new Error('bundler.build should not be called on the local-execution path'); },
    loadModule,
    () => [doubleNumberFn, callFakeActionFn],
    getAllowedConnectionIds,
    { method: 'apiKey', apiKey: 'qa-fake-key', appKey: 'qa-fake-app-key' },
    async () => { throw new Error('doAuthenticatedRequest reached — proves the call was routed all the way to auth, not a stub'); },
    QA_ROOT,
    { debug: console.log, info: console.log, warn: console.log, error: console.log },
);

async function check(name, functionName, args) {
    const req = fakeRequest({ functionName, args });
    const res = fakeResponse();
    await middleware(req, res, (err) => { if (err) throw err; });
    await res.waitForEnd();
    console.log(`${name} -> [${res.statusCode}]`, res._body);
}

await check('doubleNumber via real middleware', encodeQueryName(doubleNumberFn), [21]);
await check('callFakeAction via real middleware (auth stub throws by design)', encodeQueryName(callFakeActionFn), []);

// The specific mechanism this PR's fix touches: a COLD entry, no priming import
// beforehand — the exact condition that requires collectModuleGraphFromServer to
// find the module Vite just registered under its suffixed id.
await server.ssrLoadModule(`${doubleNumberFn.absolutePath}${LOCAL_EXECUTION_LOAD_SUFFIX}`);
console.log('Module-graph connectionId extraction:', JSON.stringify(await getAllowedConnectionIds(doubleNumberFn.absolutePath)));

await server.close();
EOF

# 3. Run it from the repo root of this branch
node tmp-apps-2792-qa/run.mjs
# Expected:
#   doubleNumber via real middleware -> [200] {"success":true,"result":{"data":{"doubled":42}}}
#   callFakeAction via real middleware (auth stub throws by design) -> [500] {"success":false,"error":"doAuthenticatedRequest reached — proves the call was routed all the way to auth, not a stub"}
#   Module-graph connectionId extraction: []
# ✅ VERIFIED — against the pre-fix code, the last line instead threw
# "Unsupported local module graph ... missing module record for <entry> could hide an action-catalog connectionId"

# 4. Clean up
rm -rf tmp-apps-2792-qa

A durable writeup of this QA flow (including the local↔staging↔app-builder-code architecture) is in the Confluence QA guide.

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.
  • The rollupConfig.mjs externalization fix touches the build of all five published packages (esbuild-plugin, rollup-plugin, rspack-plugin, vite-plugin, webpack-plugin), not just vite-plugin — it's strictly more correct (a declared dependency's subpath imports are now externalized like its bare specifier already was) and yarn build:all plus the full rollupConfig.test.ts bundling suite pass clean for every package after the change.
  • esbuild is now a real (not dev) dependency of all five published packages — dev-server-module-graph.ts uses esbuild.transform to strip TS/JSX from a module's source read fresh off disk, since neither Vite's client transform nor its SSR transform result is usable for that purpose during an SSR-only load.
  • 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

Item Status Next step
npm run dev:verify CLI (mode-aware routing to /__dd/executeActionViaCloud, web-ui template changes) Not started Milestone 3, separate PRs (build-plugins + web-ui)
Real manual QA against a scaffolded app Done See QA Instructions above
npm link @datadog/vite-plugin against a real scaffolded app currently fails (ERR_MODULE_NOT_FOUND on a workspace-linked source import, packages/factory/src/validate, that Node's native ESM loader can't resolve through the packaged dist/) Pre-existing, unrelated to this PR Same class of packaging issue the Confluence QA guide already documents for the getAllowedConnectionIds driver section below; worth a dedicated fix so the scaffolded-app QA path in this PR's own instructions works again
A genuine local @datadog/action-catalog fixture package for a typed-import e2e test Deferred Reasonable, cheap follow-up — not required for this coverage to be meaningful, since both SDKs funnel through the identical $.Actions routing
A customer's own additional Vite plugin (added to their own vite.config.ts — a real, hand-editable file, not something App Builder generates or hides) can register a load/transform hook that rewrites a .backend.ts-reachable file; dev-server-module-graph.ts's connection-ID collector reads that file fresh off disk plus an isolated esbuild.transform, not through Vite's full plugin pipeline, so a call the plugin's rewrite injects is invisible to the allowlist calculation Deferred Customer-reachable today, but fails safe — the call gets rejected with a clear allowlist error, not leaked, and needs the customer's plugin to both target a backend-reachable file and inject action-catalog-relevant code specifically. A real fix means teaching collectActionCatalogImports to also parse Vite's SSR-rewritten __vite_ssr_import__ call syntax (server.transformRequest's actual output), not just plain ImportDeclaration — real parser work, not a mechanical change, so tracked as a follow-up rather than folded into this pass

Documentation

@datadog-official

datadog-official Bot commented Aug 7, 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: fbddcb4 | Docs | View more details | Give us feedback!

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-wire-into-dev-server branch from 6e85225 to ae53df1 Compare August 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
tyffical force-pushed the tiffany.trinh/apps-2792-wire-into-dev-server branch from 7cbbeec to d976f85 Compare August 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
tyffical force-pushed the tiffany.trinh/apps-2792-wire-into-dev-server branch from d976f85 to 1900a78 Compare August 20, 2026 23:16
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-wire-into-dev-server branch from 1900a78 to a0bcc4f Compare August 20, 2026 23:38
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-wire-into-dev-server branch from a0bcc4f to dc33400 Compare August 21, 2026 03:52
@tyffical
tyffical requested a balanced review from Copilot August 21, 2026 16:24
@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 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
File Description
packages/tests/src/_jest/helpers/mocks.ts Adds a shared module resolver mock.
packages/plugins/apps/src/constants.ts Defines the local-load marker.
packages/plugins/apps/src/vite/local-execution.ts Loads marked backend modules.
packages/plugins/apps/src/vite/local-execution.test.ts Updates module-loading tests.
packages/plugins/apps/src/vite/index.ts Configures SSR loading and middleware.
packages/plugins/apps/src/vite/index.test.ts Tests transforms and SSR configuration.
packages/plugins/apps/src/vite/dev-server.ts Splits local and cloud execution.
packages/plugins/apps/src/vite/dev-server.test.ts Tests both execution routes.
packages/plugins/apps/src/vite/dev-server.integration.test.ts Exercises 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 thread packages/plugins/apps/src/vite/index.ts Outdated
Comment thread packages/plugins/apps/src/vite/dev-server.ts Outdated
chatgpt-codex-connector[bot]

This comment was marked as resolved.

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

This comment was marked as resolved.

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-wire-into-dev-server branch from 4578c5c to c2676bc Compare August 24, 2026 16:41
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-wire-into-dev-server branch from 6878e2a to 1563928 Compare August 27, 2026 18:44
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-wire-into-dev-server branch 3 times, most recently from 094c394 to 9507db8 Compare August 27, 2026 20:32
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-wire-into-dev-server branch 2 times, most recently from 6fc021f to d5751a4 Compare August 27, 2026 21:43
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-wire-into-dev-server branch 2 times, most recently from 05c85bc to 8a73a78 Compare August 27, 2026 22:43
@tyffical

This comment was marked as outdated.

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

Comment thread packages/plugins/apps/src/vite/dev-server.ts Outdated
Comment thread packages/plugins/apps/src/vite/dev-server-module-graph.ts Outdated
Comment thread packages/plugins/apps/src/vite/local-execution.ts
Comment thread packages/plugins/apps/src/vite/dev-server.integration.test.ts
@tyffical

Copy link
Copy Markdown
Contributor Author

@codex 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 28 out of 30 changed files in this pull request and generated 3 comments.

Suppressed comments (2)

packages/plugins/apps/src/vite/index.ts:185

  • This set makes the local-execution context persist by module ID instead of by module load. Helpers are deliberately left unsuffixed, so after one local traversal any later unrelated SSR load from the same helper is treated as local and receives real backend code; in the reverse order, Vite can reuse the helper's already-cached SSR transform that points at the RPC proxy, breaking local execution. Keep the context in distinct suffixed module IDs throughout the local subgraph (or otherwise scope it to one resolution graph) rather than storing bare helper IDs globally.
                if (!BACKEND_FILE_RE.test(resolved.id)) {
                    suffixedSubgraphImporters.add(resolved.id);
                    return resolved;

packages/plugins/apps/src/vite/dev-server.ts:415

  • This priming call evaluates arbitrary customer top-level code before executeScriptLocally enters its serialization queue. Concurrent requests for different cold entries can therefore interleave top-level side effects, and a timed-out priming promise can continue evaluating during a later execution without an epoch guard. Move the priming load and graph collection inside the same serialized execution boundary as the function invocation.
        const entrySpecifier = func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX;
        const primingLoadPromise = loadCustomerModuleEntry(loadModule, entrySpecifier);
        const primedModule = await withTimeout(
            primingLoadPromise,
            DEFAULT_TIMEOUT_MS,
            `Loading "${displayName}"`,
        );

Comment thread packages/plugins/apps/src/vite/dev-server.ts Outdated
Comment thread packages/plugins/apps/src/vite/dev-server.ts
Comment thread packages/plugins/apps/src/vite/dev-server-module-graph.ts

@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: c1cab6a900

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

source = await readFile(node.file, 'utf-8');
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw unsupportedModuleGraphDependency(

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 Analyze the source produced by Vite plugins

When a custom Vite load or transform hook rewrites an app-local TypeScript module, ssrLoadModule executes that rewritten source, but this collector analyzes the original file from disk. For example, an action-catalog call or import inserted by a transform is absent from the resulting connection allowlist and is then rejected during local execution; a load hook serving a synthetic filesystem ID can instead fail here as unreadable. The production collector avoids this mismatch by analyzing post-transform moduleInfo.code, so the dev collector also needs to consume source from the Vite plugin pipeline rather than readFile(node.file).

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 real and customer-reachable — a customer's own vite.config.ts is a real, hand-editable file that can add any Vite plugin. It fails safe though: an undercollected allowlist causes a runtime rejection, not a leak, and it requires a customer's own plugin to specifically rewrite a backend-reachable file with new action-catalog-relevant code. A proper fix means teaching collectActionCatalogImports to also parse Vite's SSR-rewritten __vite_ssr_import__ call syntax (confirmed via server.transformRequest's actual output), not just plain ImportDeclaration — real parser work, not a mechanical change. Tracked as a deferred follow-up in the PR description's Out of Scope table rather than folded into this pass.

Comment thread packages/plugins/apps/src/vite/index.ts Outdated
rollupConfig.mjs's `external` list only matched a dependency's bare
specifier exactly, so `rollup/parseAst` (needed by the new dev-server
module-graph collector) got bundled instead of externalized despite
`rollup` being declared as a dependency. The bundled copy pulls in
Rollup's own native-binary platform loader, which throws through
`@rollup/plugin-commonjs`'s dynamic-require interop the moment a
consumer's real dev server calls it — breaking every published plugin
package for real users, not just this repo's own tests. Switched the
`external` option to a function that also matches subpath imports
(`id === name || id.startsWith(name + '/')`) for every declared
dependency and peer dependency.

Also drops a stray blank line introduced between a comment and the
test it documents in local-execution.test.ts.
The priming loadModule call (needed to populate the module graph for
connection-ID collection before the real execution starts) evaluates
the entry's real top-level code and runs before executeScriptLocally
installs its own hang-detection timeout — so it had no bound of its
own. A customer module with a hanging top-level await would wedge the
request forever. Wraps it in the same withTimeout helper the rest of
this file already uses, now exported for this cross-module use.
…rver output

The SSR transform Vite actually runs for a dev-server-only load rewrites every
import into a __vite_ssr_import__(...) call and resolves specifiers to
absolute paths, which the plain-ImportDeclaration AST search built for a real
Rollup build can't parse. Read each module's original source from disk and
strip TS/JSX with esbuild in isolation instead, so the parser sees the same
untransformed import syntax the production build path already trusts.
…ound total execution time

collectModuleGraphFromServer silently fell back to a static import's raw
specifier text when Vite's resolveId failed to resolve it, instead of
failing closed like every sibling module-graph error path — a
connectionId-scoped action call behind an unresolvable import would
silently drop out of the allowlist instead of the request failing loudly.

/__dd/executeAction ran a customer's real backend code with no auth check
upfront, only lazily inside a $.Actions call — unlike production, which
authenticates before any query/execution logic runs (app-builder-api's
PreviewAsyncQueryHandler). A function that never calls $.Actions was a
loophole around the same requirement production always enforces. Checked
upfront as a local credential-presence check, not a network call, so it
costs no latency on the local dev loop.

guardedExecuteAction's hang-detection pause can't distinguish a customer
function genuinely awaiting a slow $.Actions call from one that fired a
call without awaiting it and then hung on something unrelated — an
unawaited call masked a real hang for up to MAX_ACTION_CALL_TIMEOUT_MS (10
minutes). A second, independent absolute ceiling now bounds one
execution's total wall-clock time regardless of pendingActionCalls, set
just above pollQueryExecution's own ~300s worst-case long-poll budget so a
legitimate slow call still always finishes.

Also fixes a comment narrating this PR's own before/after history and an
embedded milestone number, both against repo convention.
makeExecuteActionRemotely's own inner auth check and doc comment
("no auth needed until a call is actually made") went stale once the
caller started requiring auth upfront — its only caller is only ever
reached after that upfront check already passed, making the inner
check unreachable and the comment actively contradictory. Narrows both
functions' doAuthenticatedRequest parameter to required, matching how
the sibling /__dd/executeActionViaCloud path already types it.

Removes the now-redundant "no auth + calls $.Actions" test, fully
subsumed by the upfront-check test right after it — no test exercises
the removed lazy check anymore since nothing can reach it.
…eScriptLocally's own load

handleExecuteAction's priming load is the only place a customer module's
top-level code actually runs (Vite caches the module, so
executeScriptLocally's own load below just reuses the resolved object) —
but it called loadModule directly instead of going through
customerModuleLoadContext, so a customer module reaching for $ during its
own top-level evaluation silently resolved to whatever $ a prior execution
left behind instead of throwing the same way it does inside
executeScriptLocally. Extracts the scoping into loadCustomerModuleEntry,
shared by both call sites.

Also corrects the startup warning logged when auth isn't configured: it
still described only $.Actions calls as failing, but the earlier
auth-upfront hardening rejects the whole /__dd/executeAction endpoint
before any backend-function code runs, regardless of whether it calls
$.Actions.
…violations found in round-6 review

- Add test coverage for the startup auth-warning log message (dev-server.ts)
  — no test asserted its exact wording, which is how a stale claim about
  which endpoints fail escaped an earlier review round.
- guardedExecuteAction is async, so its not-current branch's
  `return Promise.reject(...)` was needless wrapping — use `throw` instead.
- Extract three inlined function-call arguments (withTimeout's first
  argument at two dev-server.ts call sites, executeAction's result in
  local-execution.ts) into named locals, matching this file's own existing
  convention at every other withTimeout call site.
- Replace a bare `as any` cast (with an eslint-disable to suppress the rule
  that would flag it) in dev-server-module-graph.test.ts with a narrower
  `as unknown as ViteDevServer`.
- Move two same-line comments onto their own line.
- Reword four regression-test comments in dev-server.integration.test.ts
  that narrated "before the fix, X happened" — restated as the present-tense
  invariant each test guards.
…istration caching

handleExecuteAction wrapped loadModule in a fresh closure every request to
reuse an already-primed entry, but local-execution.ts's action-catalog and
apps-backend registration caches are keyed on loadModule's own identity —
so every request looked like a fresh, unregistered loadModule and re-ran
SDK registration in full instead of hitting the cache. executeScriptLocally
now takes the primed entry as its own parameter, so loadModule itself stays
the stable reference the registration caches expect.
…ot the whole dev server

resolveId's suffixedSubgraphImporters Set lived for the dev server's entire
process lifetime, with no distinction between "this helper is part of the
local execution currently running" and "this helper was part of some past
execution." A later, unrelated SSR resolution of the same helper module
(e.g. from an app that also does its own ordinary SSR) would inherit the
marker and serve real backend code instead of the frontend RPC-proxy stub.

Scopes the Set via AsyncLocalStorage instead, established in
loadCustomerModuleEntry alongside the existing customerModuleLoadContext —
every caller that loads a customer entry, real dev-server request or test
harness alike, funnels through that one function, so the fix reaches every
path uniformly rather than only whichever loadModule happened to be wrapped.
The external matcher exists specifically to externalize a dependency's
subpath imports (e.g. rollup/parseAst) alongside its bare specifier, fixing
a real crash this PR's Motivation calls out — but had no test coverage.
Runs the real .mjs module in a node subprocess since ts-jest compiles this
test file to CommonJS, which can't import a real ES module directly.
…ViaCloud auth gate

Adds regression tests for unreadable/unparseable module source and a
self-referential import cycle in collectModuleGraphFromServer, plus a
no-auth-configured case for /__dd/executeActionViaCloud mirroring the
existing /__dd/executeAction coverage.
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