diff --git a/packages/plugins/apps/src/constants.ts b/packages/plugins/apps/src/constants.ts index 53732b39c..e2e04d7a1 100644 --- a/packages/plugins/apps/src/constants.ts +++ b/packages/plugins/apps/src/constants.ts @@ -17,6 +17,9 @@ export const LOCAL_EXECUTION_LOAD_SUFFIX = '?dd-local-exec'; export const LOCAL_EXECUTION_LOAD_RE = new RegExp( `${BACKEND_FILE_RE.source.slice(0, -1)}\\${LOCAL_EXECUTION_LOAD_SUFFIX}$`, ); + +/** Vite's own `--mode` value for `npm run dev:verify`, read server-side from `server.config.mode` rather than `import.meta.env.MODE`, which has no CommonJS equivalent and breaks Jest's ts-jest transform. */ +export const DEV_VERIFY_MODE = 'dev-verify'; export const BACKEND_CODE_EXTENSIONS = [ '.ts', '.tsx', diff --git a/packages/plugins/apps/src/vite/dev-server.integration.test.ts b/packages/plugins/apps/src/vite/dev-server.integration.test.ts index 814461a43..2e513e690 100644 --- a/packages/plugins/apps/src/vite/dev-server.integration.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.integration.test.ts @@ -141,6 +141,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { undefined, FIXTURE_ROOT, getMockLogger(), + 'development', ); const req = createMockRequest('/__dd/executeAction', { @@ -182,6 +183,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { undefined, FIXTURE_ROOT, getMockLogger(), + 'development', ); const req = createMockRequest('/__dd/executeAction', { @@ -219,6 +221,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { undefined, FIXTURE_ROOT, getMockLogger(), + 'development', ); const req = createMockRequest('/__dd/executeAction', { @@ -299,6 +302,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { undefined, FIXTURE_ROOT, getMockLogger(), + 'development', ); const req = createMockRequest('/__dd/executeAction', { diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index a00c23350..1aca2cc01 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -15,6 +15,7 @@ import { parseAst } from 'rollup/parseAst'; import { encodeQueryName } from '../backend/encodeQueryName'; import type { BackendFunction } from '../backend/types'; +import { DEV_VERIFY_MODE } from '../constants'; jest.mock('@dd/core/helpers/oauth-request', () => ({ doOAuthRequest: jest.fn(async (opts) => { @@ -194,6 +195,7 @@ describe('Dev Server Middleware', () => { getApiKeyRequest(), '/project', mockLog, + 'development', ); test('Should call next() for non-POST requests', () => { @@ -293,6 +295,54 @@ describe('Dev Server Middleware', () => { expect(body.result).toEqual({ data: { result: 'hello' } }); expect(apiScope.isDone()).toBe(true); }); + + test('Should route /__dd/executeAction to the cloud path when the dev server was started in dev-verify mode', async () => { + const verifyModeMiddleware = createDevServerMiddleware( + mockViteBuild, + mockLoadModule, + () => mockFunctions, + () => [], + mockAuth, + getApiKeyRequest(), + '/project', + mockLog, + DEV_VERIFY_MODE, + ); + + mockBuildWithParsedBackend(); + + const apiScope = nock(DD_API_ORIGIN) + .post('/api/v2/app-builder/queries/preview-async') + .reply(200, { data: { id: 'receipt-456' } }) + .get('/api/v2/app-builder/queries/execution-long-polling/receipt-456') + .reply(200, { + data: { + attributes: { + done: true, + outputs: { data: { result: 'via cloud' } }, + }, + }, + }); + + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(mockFunctions[0]), + args: ['world'], + }); + const res = createMockResponse(); + const next = jest.fn(); + + verifyModeMiddleware(req, res, next); + expect(next).not.toHaveBeenCalled(); + + await res.done; + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.getBody()); + expect(body.success).toBe(true); + expect(body.result).toEqual({ data: { result: 'via cloud' } }); + expect(apiScope.isDone()).toBe(true); + expect(mockLoadModule).not.toHaveBeenCalled(); + }); }); describe('debugBundle handler', () => { @@ -305,6 +355,7 @@ describe('Dev Server Middleware', () => { getApiKeyRequest(), '/project', mockLog, + 'development', ); test('Should return 400 for missing functionRef', async () => { @@ -383,6 +434,7 @@ describe('Dev Server Middleware', () => { getApiKeyRequest(), '/project', mockLog, + 'development', ); test('Should return 400 for missing functionRef', async () => { @@ -510,6 +562,7 @@ describe('Dev Server Middleware', () => { getOAuthRequest(), '/project', mockLog, + 'development', ); const apiScope = nock(DD_API_ORIGIN, { @@ -551,6 +604,7 @@ describe('Dev Server Middleware', () => { undefined, '/project', mockLog, + 'development', ); const req = createMockRequest('/__dd/executeActionViaCloud', { @@ -640,6 +694,7 @@ describe('Dev Server Middleware', () => { getApiKeyRequest(), '/project', mockLog, + 'development', ); type PreviewAsyncBody = { @@ -802,6 +857,7 @@ describe('Dev Server Middleware', () => { getApiKeyRequest(), '/project', mockLog, + 'development', ); test('Should return 400 for missing functionRef', async () => { @@ -855,6 +911,7 @@ describe('Dev Server Middleware', () => { undefined, '/project', mockLog, + 'development', ); mockLoadModuleReturning(mockFunctions[0], () => 1); @@ -883,6 +940,7 @@ describe('Dev Server Middleware', () => { undefined, '/project', mockLog, + 'development', ); mockLoadModuleReturning(mockFunctions[0], () => ( @@ -922,6 +980,7 @@ describe('Dev Server Middleware', () => { getApiKeyRequest(), '/project', mockLog, + 'development', ); mockLoadModuleReturning(funcWithConnection, () => ( @@ -1069,6 +1128,7 @@ describe('Dev Server Middleware', () => { getApiKeyRequest(), '/project', mockLog, + 'development', ); // Simulate HMR: greet is renamed to greetV2 in the same file. diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index a3ac6f952..039d7478c 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -15,7 +15,7 @@ import { encodeQueryName } from '../backend/encodeQueryName'; import type { ExecuteActionRequest, ExecuteActionResponse } from '../backend/protocol'; import type { BackendFunction, BackendOutputs } from '../backend/types'; import { generateDevVirtualEntryContent } from '../backend/virtual-entry'; -import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; +import { DEV_VERIFY_MODE, LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; import { createBackendConnectionIdCollector } from './backend-connection-id-collector'; import { getBaseBackendBuildConfig } from './build-config'; @@ -447,14 +447,7 @@ async function handleExecuteAction( } } -/** - * Handle POST /__dd/executeActionViaCloud — bundles a backend function and - * executes it via the existing production round trip (queue + Deno - * subprocess), the same way `/__dd/executeAction` did before local - * execution existed. Kept as a distinctly-purposed command (`npm run - * dev:verify`, Milestone 3) for pre-publish parity checks, not a mode flag - * on the same endpoint. - */ +/** Handle POST /__dd/executeActionViaCloud — bundles and executes via the existing production round trip (queue + Deno subprocess); also reached from `/__dd/executeAction` when the dev server itself is running in `dev-verify` mode, via `routeToCloudHandler`. */ async function handleExecuteActionViaCloud( req: IncomingMessage, res: ServerResponse, @@ -490,6 +483,33 @@ async function handleExecuteActionViaCloud( } } +/** Shared by both routes that reach the cloud round trip, so a fix to auth-checking or error handling can't drift between them. */ +function routeToCloudHandler( + req: IncomingMessage, + res: ServerResponse, + functionsByName: Map, + bundle: BundleFn, + auth: AuthConfig, + doAuthenticatedRequest: DoAuthenticatedRequest | undefined, + log: Logger, +): void { + if (!doAuthenticatedRequest) { + sendError(res, 400, `Auth credentials not configured. ${AUTH_GUIDANCE}`); + return; + } + handleExecuteActionViaCloud( + req, + res, + functionsByName, + bundle, + auth, + doAuthenticatedRequest, + log, + ).catch(() => { + sendError(res, 500, 'Unexpected error'); + }); +} + /** * Build a lookup map from encoded query names to BackendFunction objects. */ @@ -514,6 +534,7 @@ export function createDevServerMiddleware( doAuthenticatedRequest: DoAuthenticatedRequest | undefined, projectRoot: string, log: Logger, + mode: string, ): (req: IncomingMessage, res: ServerResponse, next: () => void) => void { const bundle = (func: BackendFunction) => bundleBackendFunction(viteBuild, func, projectRoot, log); @@ -544,6 +565,19 @@ export function createDevServerMiddleware( sendError(res, 500, 'Unexpected error'); }); } else if (req.url === '/__dd/executeAction') { + // Routes server-side on the resolved mode, since the client always calls this one URL regardless of dev/dev-verify mode. + if (mode === DEV_VERIFY_MODE) { + routeToCloudHandler( + req, + res, + functionsByName, + bundle, + auth, + doAuthenticatedRequest, + log, + ); + return; + } handleExecuteAction( req, res, @@ -558,11 +592,7 @@ export function createDevServerMiddleware( sendError(res, 500, 'Unexpected error'); }); } else if (req.url === '/__dd/executeActionViaCloud') { - if (!doAuthenticatedRequest) { - sendError(res, 400, `Auth credentials not configured. ${AUTH_GUIDANCE}`); - return; - } - handleExecuteActionViaCloud( + routeToCloudHandler( req, res, functionsByName, @@ -570,9 +600,7 @@ export function createDevServerMiddleware( auth, doAuthenticatedRequest, log, - ).catch(() => { - sendError(res, 500, 'Unexpected error'); - }); + ); } else { next(); } diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index 60b03f064..f3907b073 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -8,11 +8,14 @@ import { getVitePlugin } from '@dd/apps-plugin/vite/index'; import type { ViteBundler } from '@dd/apps-plugin/vite/index'; import { InjectPosition } from '@dd/core/types'; import { getContextMock, getRepositoryDataMock } from '@dd/tests/_jest/helpers/mocks'; +import { EventEmitter } from 'events'; +import type { IncomingMessage, ServerResponse } from 'http'; +import nock from 'nock'; import { parseAst } from 'rollup/parseAst'; import { encodeQueryName } from '../backend/encodeQueryName'; import type { BackendFunction } from '../backend/types'; -import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; +import { DEV_VERIFY_MODE, LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; const functions: BackendFunction[] = [ { @@ -97,6 +100,40 @@ function mockBuildWithParsedBackend() { }); } +const DD_API_ORIGIN = 'https://api.datadoghq.com'; + +function createMockRequest(url: string, body: Record): IncomingMessage { + const req = new EventEmitter() as unknown as IncomingMessage; + req.method = 'POST'; + req.url = url; + process.nextTick(() => { + (req as unknown as EventEmitter).emit('data', Buffer.from(JSON.stringify(body))); + (req as unknown as EventEmitter).emit('end'); + }); + return req; +} + +function createMockResponse() { + let body = ''; + let resolveDone: () => void; + const done = new Promise((resolve) => { + resolveDone = resolve; + }); + const res = { + statusCode: 200, + setHeader: jest.fn(), + end: jest.fn((data: string) => { + body = data || ''; + resolveDone(); + }), + getBody() { + return body; + }, + done, + }; + return res as typeof res & ServerResponse; +} + const defaultOptions = { bundler: mockVite, context: getContextMock({ @@ -141,6 +178,10 @@ describe('Backend Functions - getVitePlugin', () => { jest.spyOn(assets, 'collectAssets').mockResolvedValue([]); }); + afterEach(() => { + nock.cleanAll(); + }); + test('Should return a vite plugin object with closeBundle', () => { const plugin = getVitePlugin(defaultOptions); expect(plugin).toBeDefined(); @@ -260,4 +301,82 @@ describe('Backend Functions - getVitePlugin', () => { }, }); }); + + // Exercises the real configureServer hook (not createDevServerMiddleware directly), since only that catches a regression in how it forwards server.config.mode. + test('Should route /__dd/executeAction to the cloud path when configureServer sees a dev-verify server.config.mode', async () => { + const plugin = getVitePlugin(defaultOptions); + const transform = plugin!.transform as { + handler: (code: string, id: string) => unknown; + }; + + await transform.handler.call( + { + parse: parseAst, + resolve: jest.fn(async () => null), + load: jest.fn(async () => null), + addWatchFile: jest.fn(), + }, + ` + export function myHandler() {} + export function otherFunc() {} + `, + '/build/src/backend/myHandler.backend.ts', + ); + + // Unlike closeBundle's default mock (chunk metadata only), the cloud path bundles first and logs code.length, so this needs a real chunk `code`. + mockViteBuild.mockImplementation(async (config) => { + emitModuleParsed( + config, + '/build/src/backend/myHandler.backend.ts', + 'export function myHandler() {} export function otherFunc() {}', + ); + return { + output: [{ type: 'chunk', isEntry: true, name: bundleName1, code: '// bundled' }], + }; + }); + + const use = jest.fn(); + const ssrLoadModule = jest.fn(); + const configureServer = plugin!.configureServer as (server: unknown) => void; + configureServer({ + middlewares: { use }, + ssrLoadModule, + config: { mode: DEV_VERIFY_MODE }, + }); + + expect(use).toHaveBeenCalledTimes(1); + const middleware = use.mock.calls[0][0] as ( + req: IncomingMessage, + res: ServerResponse, + next: () => void, + ) => void; + + const apiScope = nock(DD_API_ORIGIN) + .post('/api/v2/app-builder/queries/preview-async') + .reply(200, { data: { id: 'receipt-dev-verify' } }) + .get('/api/v2/app-builder/queries/execution-long-polling/receipt-dev-verify') + .reply(200, { + data: { + attributes: { + done: true, + outputs: { data: { result: 'via cloud' } }, + }, + }, + }); + + const req = createMockRequest('/__dd/executeAction', { + functionName: bundleName1, + args: ['world'], + }); + const res = createMockResponse(); + + middleware(req, res, jest.fn()); + await res.done; + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.getBody()); + expect(body.result).toEqual({ data: { result: 'via cloud' } }); + expect(apiScope.isDone()).toBe(true); + expect(ssrLoadModule).not.toHaveBeenCalled(); + }); }); diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index 7c8286de8..38f60cf95 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -327,6 +327,7 @@ export const getVitePlugin = ({ doAuthenticatedRequest, context.buildRoot, log, + server.config.mode, ); server.middlewares.use(middleware); },