Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"test/vitest",
"test/mysql",
"test/postgres",
"test/sqlite"
"test/sqlite",
"test/next"
]
}
10 changes: 10 additions & 0 deletions src/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,16 @@ export function toArrowFunction(
};
}

export function awaitImport(source: string): ESTree.AwaitExpression {
return {
type: "AwaitExpression",
argument: {
type: "ImportExpression",
source: literal(source),
},
};
}

export function ret(argument: ESTree.Expression | null = null): ESTree.ReturnStatement {
return {
type: "ReturnStatement",
Expand Down
12 changes: 12 additions & 0 deletions src/hooks/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,20 @@ function handleClientResponse(

let remoteRunning = false;

// TODO: return ![next, ...].some(h => h.shouldIgnoreRequest?.(request))
function shouldIgnoreRequest(request: http.IncomingMessage) {
if (request.url?.includes("/_next/static/")) return true;
if (request.url?.includes("/_next/image/")) return true;
if (request.url?.endsWith(".ico")) return true;
if (request.url?.endsWith(".svg")) return true;
return false;
}

function handleRequest(request: http.IncomingMessage, response: http.ServerResponse) {
if (!(request.method && request.url)) return;

if (shouldIgnoreRequest(request)) return;

const url = new URL(request.url, "http://example");
const timestamp = remoteRunning ? undefined : startRequestRecording(url.pathname);
const requestEvent = recording.httpRequest(
Expand Down
73 changes: 73 additions & 0 deletions src/hooks/next.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import assert from "node:assert";
import { pathToFileURL } from "node:url";

import { ancestor as walk } from "acorn-walk";
import { ESTree } from "meriyah";

import { assignment, call_, identifier, literal, member, memberId } from "../generate";
import genericTransform from "../transform";

// TODO: We need to patch babel as well for older or babel configured projects.
// Probable place is: ...node_modules/next/dist/compiled/babel/bundle.js.
export function shouldInstrument(url: URL): boolean {
return url.href.endsWith("node_modules/next/dist/build/webpack/loaders/next-swc-loader.js");
}

export function shouldIgnore(url: URL): boolean {
return url.href.includes("/.next/");
}

export function transform(program: ESTree.Program): ESTree.Program {
walk(program, {
FunctionDeclaration(fun: ESTree.FunctionDeclaration) {
if (fun.id?.name === "loaderTransform") {
const funReturn = fun.body?.body.findLast(() => true);
assert(funReturn);
assert(funReturn.type == "ReturnStatement");
/* funReturn is this return statement below in loaderTransform.
We insert the new statement marked with +.
--
return swcSpan.traceAsyncFn(() =>
transform(source as any, programmaticOptions).then((output) => {
if (output.eliminatedPackages && this.eliminatedPackages) {
for (const pkg of JSON.parse(output.eliminatedPackages)) {
this.eliminatedPackages.add(pkg)
}
}
+ output.code = require(.../next.js).transformCode(output.code, programmaticOptions.code);
return [output.code, output.map ? JSON.parse(output.map) : undefined]
})
)
--
*/
assert(funReturn.argument?.type == "CallExpression"); // swcSpan.traceAsyncFn(...
assert(funReturn.argument.arguments[0].type == "ArrowFunctionExpression");
assert(funReturn.argument.arguments[0].body.type == "CallExpression"); // transform(source...
assert(funReturn.argument.arguments[0].body.arguments[0].type == "ArrowFunctionExpression"); // (output) =>...
assert(funReturn.argument.arguments[0].body.arguments[0].body.type == "BlockStatement");
const innerStatements = funReturn.argument.arguments[0].body.arguments[0].body.body; // [if..., return...]

const transformCodeStatement = assignment(
memberId("output", "code"),
call_(
member(
call_(identifier("require"), literal(__filename)),
identifier(transformCode.name),
),
memberId("output", "code"),
memberId("programmaticOptions", "filename"),
),
);
// insert it just before return
innerStatements.splice(innerStatements.length - 2, 0, transformCodeStatement);
}
},
});
return program;
}

export function transformCode(code: string, path: string): string {
const url = pathToFileURL(path);
const transformedCode = genericTransform(code, url);
return transformedCode;
}
12 changes: 1 addition & 11 deletions src/hooks/vitest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import Recording from "../Recording";
import {
args as args_,
assignment,
awaitImport,
call_,
identifier,
literal,
member,
memberId,
ret,
Expand Down Expand Up @@ -107,16 +107,6 @@ function createRecording(test: Test): Recording {
return recording;
}

function awaitImport(source: string): ESTree.AwaitExpression {
return {
type: "AwaitExpression",
argument: {
type: "ImportExpression",
source: literal(source),
},
};
}

function patchRunTest(fd: ESTree.FunctionDeclaration) {
const wrapped: ESTree.BlockStatement = {
type: "BlockStatement",
Expand Down
6 changes: 5 additions & 1 deletion src/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { RawSourceMap, SourceMapConsumer } from "source-map-js";
import * as instrument from "./hooks/instrument";
import * as jest from "./hooks/jest";
import * as mocha from "./hooks/mocha";
import * as next from "./hooks/next";
import * as vitest from "./hooks/vitest";
import { warn } from "./message";

Expand All @@ -21,9 +22,10 @@ const treeDebug = debuglog("appmap-tree");
export interface Hook {
shouldInstrument(url: URL): boolean;
transform(program: ESTree.Program, sourcemap?: SourceMapConsumer): ESTree.Program;
shouldIgnore?(url: URL): boolean;
}

const defaultHooks: Hook[] = [vitest, mocha, jest, instrument];
const defaultHooks: Hook[] = [next, vitest, mocha, jest, instrument];

export function findHook(url: URL, hooks = defaultHooks) {
return hooks.find((h) => h.shouldInstrument(url));
Expand All @@ -33,6 +35,8 @@ export default function transform(code: string, url: URL, hooks = defaultHooks):
const hook = findHook(url, hooks);
if (!hook) return code;

if (hooks.some((h) => h.shouldIgnore?.(url))) return code;

try {
const tree = parse(code, { source: url.toString(), next: true, loc: true, module: true });
const xformed = hook.transform(tree, getSourceMap(fixSourceMap(url, code)));
Expand Down
194 changes: 194 additions & 0 deletions test/__snapshots__/next.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`mapping a Next.js appmap 1`] = `
{
"./tmp/appmap/requests/<timestamp 0> -hello.appmap.json": {
"classMap": [
{
"children": [
{
"children": [
{
"location": "./app/hello/route.ts:2",
"name": "GET",
"static": true,
"type": "function",
},
],
"name": "route",
"type": "class",
},
],
"name": "route",
"type": "package",
},
],
"events": [
{
"event": "call",
"http_server_request": {
"headers": {
"Host": "localhost:3000",
},
"path_info": "/hello",
"protocol": "HTTP/1.1",
"request_method": "GET",
},
"id": 1,
"thread_id": 0,
},
{
"defined_class": "route",
"event": "call",
"id": 2,
"lineno": 2,
"method_id": "GET",
"parameters": [
{
"class": "bound NextRequest",
"object_id": 1,
"value": "NextRequest [Request] {
[Symbol(realm)]: { settingsObject: [Object] },
[Symbol(state)]: {
method: 'GET',
localURLsOnly: false,
unsafeRequest: false,
body: null,
client: [Object],
reservedClient: null,
replacesClientId: '',
window: 'client',
keepalive: false,
serviceWorkers: 'all',
initiator: '',
destination: '',
priority: null,
origin: 'client',
policyContainer: 'client',
referrer: 'client',
referrerPolicy: '',
mode: 'cors',
useCORSPreflightFlag: false,
credentials: 'same-origin',
useCredentials: false,
cache: 'default',
redirect: 'follow',
integrity: '',
cryptoGraphicsNonceMetadata: '',
parserMetadata: '',
reloadNavigation: false,
historyNavigation: false,
userActivation: false,
taintedOrigin: false,
redirectCount: 0,
responseTainting: 'basic',
preventNoCacheCacheControlHeaderModification: false,
done: false,
timingAllowFailed: false,
headersList: [HeadersList],
urlList: [Array],
url: URL {}
},
[Symbol(signal)]: AbortSignal { aborted: false },
[Symbol(abortController)]: AbortController { signal: [AbortSignal] },
[Symbol(headers)]: HeadersList {
cookies: null,
[Symbol(headers map)]: [Map],
[Symbol(headers map sorted)]: [Array]
},
[Symbol(internal request)]: {
cookies: [RequestCookies],
geo: {},
ip: undefined,
nextUrl: [NextURL],
url: 'http://localhost:3000/hello'
}
}",
},
{
"class": "Object",
"object_id": 2,
"properties": [
{
"class": "undefined",
"name": "params",
},
],
"value": "{ params: undefined }",
},
],
"path": "./app/hello/route.ts",
"static": true,
"thread_id": 0,
},
{
"elapsed": 31.337,
"event": "return",
"id": 3,
"parent_id": 2,
"return_value": {
"class": "NextResponse",
"object_id": 3,
"value": "NextResponse [Response] {
[Symbol(realm)]: { settingsObject: {} },
[Symbol(state)]: {
aborted: false,
rangeRequested: false,
timingAllowPassed: false,
requestIncludesCredentials: false,
type: 'default',
status: 200,
timingInfo: null,
cacheState: '',
statusText: '',
headersList: [HeadersList],
urlList: [],
body: [Object]
},
[Symbol(headers)]: HeadersList {
cookies: null,
[Symbol(headers map)]: [Map],
[Symbol(headers map sorted)]: null
},
[Symbol(internal response)]: { cookies: [ResponseCookies], url: undefined }
}",
},
"thread_id": 0,
},
{
"elapsed": 31.337,
"event": "return",
"http_server_response": {
"headers": {
"Content-Type": "application/json",
"Vary": "RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Url",
},
"status_code": 200,
},
"id": 4,
"parent_id": 1,
"thread_id": 0,
},
],
"metadata": {
"app": "next-appmap-node-test",
"client": {
"name": "appmap-node",
"url": "https://github.com/getappmap/appmap-node",
"version": "test node-appmap version",
},
"language": {
"engine": "Node.js",
"name": "javascript",
"version": "test node version",
},
"name": "GET /hello (200) — <timestamp 0>",
"recorder": {
"name": "requests",
"type": "requests",
},
},
"version": "1.12",
},
}
`;
Loading