UNPKG

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
"use strict"; 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(); }