next-test-api-route-handler
Version:
Confidently unit and integration test your Next.js API routes/handlers in an isolated Next.js-like environment
328 lines (327 loc) • 12.6 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.$originalGlobalFetch = exports.$isPatched = void 0;
exports.testApiHandler = testApiHandler;
require("core-js/modules/es.array.push.js");
require("core-js/modules/es.iterator.constructor.js");
require("core-js/modules/es.iterator.flat-map.js");
require("core-js/modules/es.iterator.map.js");
require("core-js/modules/esnext.json.parse.js");
var _nodeAssert = _interopRequireDefault(require("node:assert"));
var _nodeHttp = require("node:http");
var _types = require("node:util/types");
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
if (!globalThis.AsyncLocalStorage) {
globalThis.AsyncLocalStorage = require('node:async_hooks').AsyncLocalStorage;
}
if (!require('react').cache) {
require('react').cache = function (fn) {
return function (...args) {
return Reflect.apply(fn, null, args);
};
};
}
const defaultNextRequestMockUrl = 'ntarh://testApiHandler/';
const addDefaultHeaders = headers => {
if (!headers.has('x-msw-intention')) {
headers.set('x-msw-intention', 'bypass');
}
if (!headers.has('x-msw-bypass')) {
headers.set('x-msw-bypass', 'true');
}
return headers;
};
const $importFailed = Symbol('import-failed');
const $originalGlobalFetch = exports.$originalGlobalFetch = Symbol('original-global-fetch-function');
const $isPatched = exports.$isPatched = Symbol('object-has-been-patched-by-ntarh');
const apiResolver = findNextjsInternalResolver('apiResolver', ['next/dist/server/api-utils/node/api-resolver.js', 'next/dist/server/api-utils/node.js', 'next/dist/server/api-utils.js', 'next/dist/next-server/server/api-utils.js', 'next-server/dist/server/api-utils.js']);
const AppRouteRouteModule = findNextjsInternalResolver('AppRouteRouteModule', ['next/dist/server/route-modules/app-route/module.js', 'next/dist/server/future/route-modules/app-route/module.js']);
const originalGlobalFetch = globalThis.fetch;
async function testApiHandler({
rejectOnHandlerError,
requestPatcher,
responsePatcher,
paramsPatcher,
params,
url,
pagesHandler: pagesHandler_,
appHandler,
test
}) {
let server = undefined;
let deferredReject = undefined;
const pagesHandler = pagesHandler_ && typeof pagesHandler_ === 'object' && 'default' in pagesHandler_ ? Object.assign(pagesHandler_.default, pagesHandler_) : pagesHandler_;
try {
if (!!pagesHandler_ === !!appHandler) {
throw new TypeError('next-test-api-route-handler (NTARH) initialization failed: you must provide exactly one of: pagesHandler, appHandler');
}
server = pagesHandler ? createPagesRouterServer() : createAppRouterServer();
const {
address,
port
} = await new Promise((resolve, reject) => {
server?.listen(0, 'localhost', undefined, () => {
const addr = server?.address();
if (!addr || typeof addr === 'string') {
reject(new Error('assertion failed unexpectedly: server did not return AddressInfo instance'));
} else {
resolve({
port: addr.port,
address: addr.family === 'IPv6' ? `[${addr.address}]` : addr.address
});
}
});
});
const localUrl = `http://${address}:${port}`;
await new Promise((resolve, reject) => {
deferredReject = reject;
Promise.resolve(test({
fetch: Object.assign(fetch_, {
get [$originalGlobalFetch]() {
return originalGlobalFetch;
}
})
})).then(resolve, reject);
async function fetch_(customInit) {
const init = {
redirect: 'manual',
...customInit,
headers: addDefaultHeaders(new Headers(customInit?.headers))
};
return originalGlobalFetch(localUrl, init).then(response => {
Object.defineProperty(response, 'cookies', {
configurable: true,
enumerable: true,
get: () => {
const {
parse: parseCookieHeader
} = require('cookie');
delete response.cookies;
response.cookies = response.headers.getSetCookie().map(header => Object.fromEntries(Object.entries(parseCookieHeader(header)).flatMap(([k, v]) => {
return [[String(k), String(v)], [String(k).toLowerCase(), String(v)]];
})));
return response.cookies;
}
});
return rebindJsonMethodAsSummoner(response);
});
}
});
} finally {
server?.close();
server?.closeAllConnections();
}
function createAppRouterServer() {
const createServerAdapter = require('@whatwg-node/server').createServerAdapter;
const NextRequest = require('next/server').NextRequest;
return (0, _nodeHttp.createServer)((req, res) => {
const originalRes = res;
void createServerAdapter(async request => {
try {
(0, _nodeAssert.default)(appHandler !== undefined);
const {
cache,
credentials,
headers,
integrity,
keepalive,
method,
mode,
redirect,
referrer,
referrerPolicy,
signal
} = request;
const rawRequest = rebindJsonMethodAsSummoner(new NextRequest(normalizeUrlForceTrailingSlashIfPathnameEmpty(url || defaultNextRequestMockUrl), {
body: readableStreamOrNullFromAsyncIterable(request.body),
cache,
credentials,
duplex: 'half',
headers,
integrity,
keepalive,
method,
mode,
redirect,
referrer,
referrerPolicy,
signal
}));
const patchedRequest = (await requestPatcher?.(rawRequest)) || rawRequest;
const nextRequest = patchedRequest instanceof NextRequest ? patchedRequest : new NextRequest(patchedRequest, {
duplex: 'half'
});
const rawParameters = {
...params
};
const finalParameters = returnUndefinedIfEmptyObject((await paramsPatcher?.(rawParameters)) || rawParameters);
const appRouteRouteModule = await mockEnvVariable('NODE_ENV', 'development', () => {
if (typeof AppRouteRouteModule !== 'function') {
(0, _nodeAssert.default)(AppRouteRouteModule[$importFailed], 'assertion failed unexpectedly: AppRouteRouteModule was not a constructor (function)');
throw AppRouteRouteModule[$importFailed];
}
return new AppRouteRouteModule({
definition: {
kind: 'APP_ROUTE',
page: '/route',
pathname: 'ntarh://testApiHandler',
filename: 'route',
bundlePath: 'app/route'
},
nextConfigOutput: undefined,
resolvedPagePath: 'ntarh://testApiHandler',
userland: appHandler,
distDir: 'ntarh://fake-dir',
projectDir: 'ntarh://fake-dir'
});
});
const response_ = appRouteRouteModule.handle(rebindJsonMethodAsSummoner(nextRequest), {
params: finalParameters,
prerenderManifest: {
version: 4,
routes: {},
dynamicRoutes: {},
notFoundRoutes: [],
preview: {}
},
renderOpts: {
experimental: {
ppr: false,
isRoutePPREnabled: false
},
supportsDynamicHTML: true,
supportsDynamicResponse: true
},
staticGenerationContext: {
supportsDynamicHTML: true
},
sharedContext: {
buildId: 'ntarh'
}
});
const response = rebindJsonMethodAsSummoner(await response_.catch(error => {
console.error(error);
if (rejectOnHandlerError) {
throw error;
} else {
return new Response('Internal Server Error', {
status: 500
});
}
}));
return (await responsePatcher?.(response)) || response;
} catch (error) {
handleError(originalRes, error, deferredReject);
await new Promise(resolve => setImmediate(resolve));
return new Response(`[NTARH Internal Server Error]: an error occurred during this test that caused testApiHandler to reject (i.e. rejectOnHandlerError === true). This response was returned as a courtesy so your handler does not potentially hang forever.\n\nError: ${(0, _types.isNativeError)(error) ? error.stack || error : String(error)}`, {
status: 500
});
}
})(req, res);
});
}
function createPagesRouterServer() {
return (0, _nodeHttp.createServer)((req, res) => {
try {
(0, _nodeAssert.default)(pagesHandler_ !== undefined);
req.url = url || defaultNextRequestMockUrl;
Promise.resolve(requestPatcher?.(req)).then(() => responsePatcher?.(res)).then(async () => {
const {
parse: parseUrl
} = require('node:url');
const rawParameters = {
...parseUrl(req.url || '', true).query,
...params
};
return (await paramsPatcher?.(rawParameters)) || rawParameters;
}).then(finalParameters => {
if (typeof apiResolver !== 'function') {
(0, _nodeAssert.default)(apiResolver[$importFailed], 'assertion failed unexpectedly: apiResolver was not a function');
throw apiResolver[$importFailed];
}
void apiResolver(req, res, finalParameters, pagesHandler, {}, !!rejectOnHandlerError).catch(error => handleError(res, error, deferredReject));
}).catch(error => {
handleError(res, error, deferredReject);
});
} catch (error) {
handleError(res, error, deferredReject);
}
});
}
}
function returnUndefinedIfEmptyObject(o) {
return Object.keys(o).length ? o : undefined;
}
async function mockEnvVariable(name, updatedValue, callback) {
const oldEnvVariable = process.env[name];
process.env[name] = updatedValue;
try {
return await callback();
} finally {
process.env[name] = oldEnvVariable;
}
}
function readableStreamOrNullFromAsyncIterable(iterable) {
if (iterable === undefined || iterable === null) {
return null;
}
const asyncIterator = iterable[Symbol.asyncIterator]();
return new ReadableStream({
start: () => undefined,
async pull(controller) {
const nextChunk = await asyncIterator.next();
if (nextChunk.done) {
controller.close();
} else {
controller.enqueue(nextChunk.value);
}
},
async cancel(reason) {
await asyncIterator.return?.(reason);
return undefined;
}
}, {
highWaterMark: 0
});
}
function rebindJsonMethodAsSummoner(communication) {
if (!communication[$isPatched]) {
communication.json = async () => {
const text = await communication.text();
return JSON.parse(text);
};
communication[$isPatched] = true;
}
return communication;
}
function findNextjsInternalResolver(exportedName, possibleLocations) {
const errors = [];
let imported = undefined;
for (const path of possibleLocations) {
try {
const {
[exportedName]: xport
} = require(path);
imported = xport;
break;
} catch (error) {
errors.push((0, _types.isNativeError)(error) ? error.message.split(/(?<=')( imported)? from ('|\S)/)[0].split(`\nRequire`)[0] : String(error));
}
}
return imported ?? {
[$importFailed]: new Error(`next-test-api-route-handler (NTARH) failed to import ${exportedName}` + `\n\n This is usually caused by:` + `\n\n 1. Using a Node version that is end-of-life (review legacy install instructions)` + `\n 2. NTARH and the version of Next.js you installed are actually incompatible (please check documentation and/or submit a bug report)` + `\n\n Failed import attempts:` + `\n\n - ${errors.join('\n - ')}`)
};
}
function handleError(res, error, deferredReject) {
if (res && !res.writableEnded) {
res.end();
}
if (deferredReject) deferredReject(error);else throw error;
}
function normalizeUrlForceTrailingSlashIfPathnameEmpty(url) {
const url_ = new URL(url, 'ntarh://');
url_.pathname ||= '/';
return url_.toString();
}