diff --git a/ilc/config/custom-environment-variables.json5 b/ilc/config/custom-environment-variables.json5 index 69edc51a..30694cf1 100644 --- a/ilc/config/custom-environment-variables.json5 +++ b/ilc/config/custom-environment-variables.json5 @@ -29,6 +29,9 @@ client: { protocol: 'ILC_CLIENT_PROTOCOL', }, + tailor: { + maxFragmentRequestSize: 'ILC_MAX_FRAGMENT_REQUEST_SIZE', + }, experiments: { enabled: 'ILC_EXPERIMENTS_ENABLED', }, diff --git a/ilc/config/default.json5 b/ilc/config/default.json5 index 190ce688..b2cbaac1 100644 --- a/ilc/config/default.json5 +++ b/ilc/config/default.json5 @@ -3,10 +3,10 @@ port: 8233, cdnUrl: null, staticError: { - disasterFileContentPath: null + disasterFileContentPath: null, }, registry: { - address: 'http://127.0.0.1:4001' + address: 'http://127.0.0.1:4001', }, newrelic: { licenseKey: null, @@ -16,13 +16,13 @@ */ customClientJsWrapper: null, automaticallyInjectBrowserMonitoring: true, - appName: null + appName: null, }, overrideConfigTrustedOrigins: null, logger: { accessLog: { - ignoreUrls: '' - } + ignoreUrls: '', + }, }, static: { internalUrl: '/_ilc/', @@ -33,6 +33,14 @@ client: { protocol: 'https', }, + tailor: { + // Disabled by default: enabling it changes what renders, so a deployment opts in + // rather than inheriting the decision. Set it to the byte ceiling your fragment + // servers actually accept — below their --max-http-header-size, whose own default is + // 16384. For stock node fragments 15872 is a reasonable starting point. 0 keeps the + // guard off, and an unusable value leaves it off with a WARN at startup. + maxFragmentRequestSize: 0, + }, experiments: { // Global kill-switch. Set to false (e.g. via env in an incident) to send every // visitor to control with no assignment. Toggling this is a config change, not a deploy. diff --git a/ilc/server/TransitionHooksExecutor.ts b/ilc/server/TransitionHooksExecutor.ts index 4ec9243d..cbaaf64b 100644 --- a/ilc/server/TransitionHooksExecutor.ts +++ b/ilc/server/TransitionHooksExecutor.ts @@ -37,7 +37,7 @@ export class TransitionHooksExecutor { meta: route.meta, url: route.reqUrl, hostname: req.host, - route: route.route, + route: route.route as string, }, log: req.log, req: req.raw, diff --git a/ilc/server/app.js b/ilc/server/app.js index 713a0707..5c222676 100644 --- a/ilc/server/app.js +++ b/ilc/server/app.js @@ -8,7 +8,7 @@ import { pingPluginFactroy } from './routes/pingPluginFactory'; import { renderTemplateHandlerFactory } from './routes/renderTemplateHandlerFactory'; import { wildcardRequestHandlerFactory } from './routes/wildcardRequestHandlerFactory'; import { registerStatic } from './serveStatic'; -import tailorFactory from './tailor/factory'; +import { tailorFactory } from './tailor/factory'; import { TransitionHooksExecutor } from './TransitionHooksExecutor'; const { Test500Error } = require('./errorHandler/ErrorHandler'); @@ -118,6 +118,7 @@ module.exports = async function createApplication(registryService, pluginManager config.get('newrelic.customClientJsWrapper'), autoInjectNrMonitoring, logger, + config.get('tailor.maxFragmentRequestSize'), ); app.all('*', wildcardRequestHandlerFactory(logger, registryService, errorHandler, transitionHooksExecutor, tailor)); diff --git a/ilc/server/routes/wildcardRequestHandlerFactory.ts b/ilc/server/routes/wildcardRequestHandlerFactory.ts index 4dce176e..77a0f950 100644 --- a/ilc/server/routes/wildcardRequestHandlerFactory.ts +++ b/ilc/server/routes/wildcardRequestHandlerFactory.ts @@ -5,11 +5,11 @@ import { SlotCollection } from '../../common/Slot/SlotCollection'; import UrlProcessor from '../../common/UrlProcessor'; import i18n from '../i18n'; import CspBuilderService from '../services/CspBuilderService'; -import tailorFactory from '../tailor/factory'; +import { tailorFactory } from '../tailor/factory'; import { mergeConfigs, type OverrideConfig } from '../tailor/merge-configs'; import { buildForwardedHeaders } from '../utils/helpers'; import parseOverrideConfig from '../tailor/parse-override-config'; -import ServerRouter from '../tailor/server-router'; +import { ServerRouter } from '../tailor/server-router'; import { TransitionHooksExecutor } from '../TransitionHooksExecutor'; import { ErrorHandler } from '../types/ErrorHandler'; import { IlcRouteHandlerMethod } from '../types/IlcRouteHandlerMethod'; diff --git a/ilc/server/tailor/factory.js b/ilc/server/tailor/factory.js deleted file mode 100644 index ba51c4a9..00000000 --- a/ilc/server/tailor/factory.js +++ /dev/null @@ -1,53 +0,0 @@ -'use strict'; - -const _ = require('lodash'); -const newrelic = require('newrelic'); - -const Tailor = require('@namecheap/tailorx'); -const { fetchTemplate } = require('./fetch-template'); -const { filterHeaders } = require('./filter-headers'); -const errorHandlerSetup = require('./error-handler'); -const fragmentHooks = require('./fragment-hooks'); -const { ConfigsInjector } = require('./configs-injector'); -const processFragmentResponse = require('./process-fragment-response'); -const requestFragment = require('./request-fragment'); - -module.exports = function ( - registryService, - errorHandlingService, - cdnUrl, - nrCustomClientJsWrapper = null, - nrAutomaticallyInjectClientScript = true, - logger, -) { - const configsInjector = new ConfigsInjector( - newrelic, - cdnUrl, - nrCustomClientJsWrapper, - nrAutomaticallyInjectClientScript, - ); - - const tailor = new Tailor({ - fetchContext: async function (request) { - return request.router.getFragmentsContext(); - }, - fetchTemplate: fetchTemplate(configsInjector, newrelic, registryService), - requestFragment: requestFragment(filterHeaders, processFragmentResponse, logger), - processFragmentResponse, - systemScripts: '', - filterHeaders, - fragmentHooks: { - insertStart: fragmentHooks.insertStart.bind(null, logger), - insertEnd: fragmentHooks.insertEnd, - }, - botsGuardEnabled: true, - getAssetsToPreload: configsInjector.getAssetsToPreload, - filterResponseHeaders: (attributes, headers) => _.pick(headers, ['set-cookie']), - baseTemplatesCacheSize: 1, - shouldSetPrimaryFragmentAssetsToPreload: false, - }); - - errorHandlerSetup(tailor, errorHandlingService); - - return tailor; -}; diff --git a/ilc/server/tailor/factory.ts b/ilc/server/tailor/factory.ts new file mode 100644 index 00000000..314b723f --- /dev/null +++ b/ilc/server/tailor/factory.ts @@ -0,0 +1,63 @@ +import newrelic from 'newrelic'; + +import { Tailor, type TailorOptions } from './tailorx'; +import { fetchTemplate } from './fetch-template'; +import { filterHeaders } from './filter-headers'; +import errorHandlerSetup from './error-handler'; +import fragmentHooks from './fragment-hooks'; +import { ConfigsInjector } from './configs-injector'; +import processFragmentResponse from './process-fragment-response'; +import { requestFragmentFactory } from './request-fragment'; +import type { ServerRouter } from './server-router'; +import type { Registry } from '../types/Registry'; + +type Logger = Pick; + +/** + * The error-handling service is injected by app.js and typed where it is defined; this module + * only passes it through, so it takes it as opaque. + */ +export function tailorFactory( + registryService: Registry, + errorHandlingService: unknown, + cdnUrl: string | null, + nrCustomClientJsWrapper: string | null = null, + nrAutomaticallyInjectClientScript = true, + logger: Logger, + maxFragmentRequestSize?: unknown, +) { + const configsInjector = new ConfigsInjector( + newrelic, + cdnUrl, + nrCustomClientJsWrapper, + nrAutomaticallyInjectClientScript, + ); + + const tailorOptions: TailorOptions = { + fetchContext: async function (request: { router: ServerRouter }) { + return request.router.getFragmentsContext(); + }, + fetchTemplate: fetchTemplate(configsInjector, newrelic, registryService), + requestFragment: requestFragmentFactory(filterHeaders, processFragmentResponse, logger, { + maxRequestSize: maxFragmentRequestSize, + }), + processFragmentResponse, + filterHeaders, + fragmentHooks: { + insertStart: fragmentHooks.insertStart.bind(null, logger), + insertEnd: fragmentHooks.insertEnd, + }, + botsGuardEnabled: true, + getAssetsToPreload: configsInjector.getAssetsToPreload, + filterResponseHeaders: (attributes: unknown, headers: Record) => + 'set-cookie' in headers ? { 'set-cookie': headers['set-cookie'] } : {}, + baseTemplatesCacheSize: 1, + shouldSetPrimaryFragmentAssetsToPreload: false, + }; + + const tailor = new Tailor(tailorOptions); + + errorHandlerSetup(tailor, errorHandlingService); + + return tailor; +} diff --git a/ilc/server/tailor/header-block.spec.ts b/ilc/server/tailor/header-block.spec.ts new file mode 100644 index 00000000..f6e4527d --- /dev/null +++ b/ilc/server/tailor/header-block.spec.ts @@ -0,0 +1,242 @@ +import net from 'node:net'; +import http from 'node:http'; +import Agent from 'agentkeepalive'; +import { expect } from 'chai'; + +import { + findLargestHeader, + headFor, + MAX_LOGGED_HEADER_NAME, + measureHeadTotal, + serializeOutgoingRequest, +} from './header-block'; + +/** Reads the block node built, without asking this module to build it. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const nodeHead = (request: http.ClientRequest): string | null => (request as any)._header; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const materialize = (request: http.ClientRequest): string => ((request as any)._implicitHeader(), nodeHead(request)!); + +describe('measureHeadTotal', () => { + it('counts the request line, every header line and the terminator', () => { + const head = 'GET /x HTTP/1.1\r\nHost: h\r\nConnection: keep-alive\r\n\r\n'; + + expect(measureHeadTotal(head)).to.equal(head.length); + }); + + it('counts bytes as latin1, the way node writes them', () => { + const multiByte = 'GET /x HTTP/1.1\r\nx-u: ééééé\r\n\r\n'; + + expect(measureHeadTotal(multiByte)).to.equal(Buffer.byteLength(multiByte, 'latin1')); + }); +}); + +describe('findLargestHeader', () => { + it('reports the largest header line and ignores the request line', () => { + const head = `GET /${'p'.repeat(200)} HTTP/1.1\r\nx-a: ${'v'.repeat(40)}\r\nx-b: short\r\n\r\n`; + + expect(findLargestHeader(head)).to.deep.equal({ name: 'x-a', bytes: 47 }); + }); + + it('bounds the reported name so one request cannot write kilobytes of logs', () => { + const name = `x-${'n'.repeat(5000)}`; + const largest = findLargestHeader(`GET /x HTTP/1.1\r\n${name}: v\r\n\r\n`); + + expect(largest.name.length).to.be.at.most(MAX_LOGGED_HEADER_NAME + 1); + expect(largest.bytes).to.equal(name.length + 5); + }); + + it('counts the final line when the head is not CRLF-terminated', () => { + // Regression: the index scanner treated a missing CRLF as end-of-block and dropped the + // last header, reporting "A" instead of "B". Both producers terminate their heads, but + // this function is exported and can be handed any string. + expect(findLargestHeader('GET /x HTTP/1.1\r\nA: 1\r\nB: 22222')).to.deep.equal({ name: 'B', bytes: 10 }); + }); + + it('returns an empty result for a head with no header lines', () => { + expect(findLargestHeader('GET /x HTTP/1.1\r\n\r\n')).to.deep.equal({ name: '', bytes: 0 }); + }); +}); + +describe('serializeOutgoingRequest — the oracle', () => { + // The reconstruction reproduces node's _storeHeader by hand, so nothing about it is + // guaranteed. This suite is what makes it trustworthy: every shape is built both ways and + // the strings must agree. An earlier model on this branch had no oracle, and its tests + // asserted the same wrong rules its code did; both errors survived until raw bytes were + // captured. Comparison is case-insensitive because getHeaders() lower-cases names while the + // wire keeps the caller's casing — which cannot change any byte count, so lengths are + // asserted separately and exactly. + const keepAlive = new Agent(); + + const shapes: Array<[string, http.ClientRequestArgs]> = [ + ['plain GET', { headers: { 'x-y': 'z' } }], + ['keep-alive agent', { headers: { 'x-y': 'z' }, agent: keepAlive }], + ['connection close', { headers: { 'x-y': 'z' }, agent: false }], + ['explicit connection', { headers: { connection: 'close', 'x-y': 'z' } }], + [ + 'keepAlive false, maxSockets finite', + { agent: new http.Agent({ keepAlive: false, maxSockets: 100 }), headers: { 'x-y': 'z' } }, + ], + [ + 'keepAlive false, maxSockets Infinity', + { agent: new http.Agent({ keepAlive: false }), headers: { 'x-y': 'z' } }, + ], + ['cookie array of two', { headers: { cookie: ['a=1', 'b=2'] } }], + ['cookie array of one', { headers: { cookie: ['a=1'] } }], + ['cookie array of five', { headers: { cookie: ['a=1', 'b=2', 'c=3', 'd=4', 'e=5'] } }], + ['cookie as a string', { headers: { cookie: 'a=1; b=2' } }], + ['non-cookie array', { headers: { 'x-a': ['1', '2', '3'] } }], + ['mixed-case names', { headers: { 'Content-Type': 'text/html', 'X-Req-Uri': '/a/b' } }], + ['latin1 value', { headers: { 'x-n': 'café-ü' } }], + ['numeric value', { headers: { 'x-num': 42 } }], + ['empty value', { headers: { 'x-e': '' } }], + ['auth from userinfo', { auth: 'user:secret', headers: { 'x-y': 'z' } }], + ['long path', { path: `/${'p'.repeat(3000)}`, headers: { 'x-y': 'z' } }], + [ + 'many headers', + { headers: Object.fromEntries(Array.from({ length: 25 }, (_, i) => [`x-h${i}`, 'v'.repeat(i * 7)])) }, + ], + ['HEAD', { method: 'HEAD', headers: { 'x-y': 'z' } }], + ]; + + for (const [label, args] of shapes) { + it(`serializes exactly what node serializes: ${label}`, () => { + const request = http.request({ host: 'example.invalid', path: '/frag?a=1', ...args }); + request.on('error', () => {}); + + try { + const reconstructed = serializeOutgoingRequest(request); + const authoritative = materialize(request); + + expect(reconstructed, `${label} should be reconstructable`).to.be.a('string'); + expect(measureHeadTotal(reconstructed!), `byte count for ${label}`).to.equal( + measureHeadTotal(authoritative), + ); + expect(reconstructed!.toLowerCase(), `structure for ${label}`).to.equal(authoritative.toLowerCase()); + } finally { + request.destroy(); + } + }); + } + + it('refuses a request that may carry a body', () => { + // node appends Content-Length or Transfer-Encoding from private state no public API + // exposes, so reconstruction would under-count by ~28 bytes. + const request = http.request({ host: 'example.invalid', path: '/x', method: 'POST' }); + request.on('error', () => {}); + + expect(serializeOutgoingRequest(request)).to.equal(null); + request.destroy(); + }); + + it('refuses a transport with no getHeaders, such as an HTTP/2 stream', () => { + expect(serializeOutgoingRequest({ method: 'GET', path: '/x' })).to.equal(null); + }); + + it('refuses a request with no path rather than serializing the string "undefined"', () => { + expect(serializeOutgoingRequest({ method: 'GET', getHeaders: () => ({ 'x-y': 'z' }) })).to.equal(null); + }); + + it('refuses when getHeaders() cannot see the headers, as with a flat array', () => { + const request = http.request({ host: 'example.invalid', path: '/x', agent: false, headers: ['X-A', '1'] }); + request.on('error', () => {}); + + expect(serializeOutgoingRequest(request)).to.equal(null); + request.destroy(); + }); + + it('over-counts rather than under-counts when uniqueHeaders joins an array', () => { + // uniqueHeaders has no public getter, so reconstruction cannot see it: it writes one + // line per entry where node writes one joined line. The safe direction. + const request = http.request({ + host: 'example.invalid', + path: '/x', + agent: false, + headers: { 'x-a': ['1', '2'] }, + uniqueHeaders: ['x-a'], + } as http.ClientRequestArgs); + request.on('error', () => {}); + + expect(measureHeadTotal(serializeOutgoingRequest(request)!)).to.be.above( + measureHeadTotal(materialize(request)), + ); + request.destroy(); + }); +}); + +describe('headFor', () => { + it('reconstructs when node has not serialized the head yet', () => { + const request = http.request({ host: 'example.invalid', path: '/x', headers: { 'x-y': 'z' }, agent: false }); + request.on('error', () => {}); + + expect(nodeHead(request), 'node should not have built it yet').to.equal(null); + expect(headFor(request)).to.equal(serializeOutgoingRequest(request)); + request.destroy(); + }); + + for (const [label, args] of [ + ['a forwarded expect header', { headers: { expect: '100-continue', 'x-y': 'z' } }], + ['headers passed as a flat array', { headers: ['X-A', '1', 'X-B', '2'] }], + ] as Array<[string, http.ClientRequestArgs]>) { + it(`uses node's own head, exactly, for ${label}`, () => { + // node builds the head inside the constructor for these shapes — the two cases + // reconstruction handles badly or not at all. Preferring its buffer makes them + // exact, and reads `_header` without ever calling `_implicitHeader()`, so this + // cannot throw ERR_HTTP_HEADERS_SENT or alter the request's body framing. + const request = http.request({ host: 'example.invalid', path: '/x', agent: false, ...args }); + request.on('error', () => {}); + + const authoritative = nodeHead(request); + + expect(authoritative, 'node should have built it in the constructor').to.be.a('string'); + expect(headFor(request)).to.equal(authoritative); + request.destroy(); + }); + } + + it('returns null when the head is neither available nor reconstructable', () => { + const request = http.request({ host: 'example.invalid', path: '/x', method: 'POST' }); + request.on('error', () => {}); + + expect(headFor(request)).to.equal(null); + request.destroy(); + }); +}); + +describe('the head node materializes', () => { + // Detector for the private-API dependency: if a future node changes _header, this fails + // rather than the guard silently drifting. + it('matches the bytes node actually writes, for a request with every awkward shape', async () => { + let materialized = ''; + + const wire: string = await new Promise((resolve) => { + const srv = net.createServer((sock) => { + let buf = ''; + sock.on('data', (d) => { + buf += d.toString('latin1'); + if (buf.includes('\r\n\r\n')) { + sock.end('HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n'); + srv.close(); + resolve(buf.slice(0, buf.indexOf('\r\n\r\n') + 4)); + } + }); + }); + srv.listen(0, '127.0.0.1', () => { + const req = http.request({ + host: '127.0.0.1', + port: (srv.address() as net.AddressInfo).port, + path: '/x?a=1', + auth: 'user:secret', + headers: { 'x-a': ['1', '2'], cookie: ['a=1', 'b=2'], 'x-u': 'é'.repeat(5), host: 'fwd.example' }, + }); + req.on('error', () => {}); + materialized = materialize(req); + req.end(); + }); + }); + + expect(materialized).to.be.a('string').and.to.not.equal(''); + expect(materialized).to.equal(wire); + expect(measureHeadTotal(materialized)).to.equal(Buffer.byteLength(wire, 'latin1')); + }); +}); diff --git a/ilc/server/tailor/header-block.ts b/ilc/server/tailor/header-block.ts new file mode 100644 index 00000000..78d09b4a --- /dev/null +++ b/ilc/server/tailor/header-block.ts @@ -0,0 +1,146 @@ +export const MAX_LOGGED_HEADER_NAME = 64; + +const CRLF = '\r\n'; +const CRLF_BYTES = 2; +const HTTP_VERSION = 'HTTP/1.1'; +const BODYLESS_METHODS = new Set(['GET', 'HEAD']); + +type HeaderValue = number | string | string[]; + +export interface LargestHeader { + name: string; + bytes: number; +} + +export interface OutgoingRequest { + method?: string; + path?: string; + agent?: { keepAlive?: boolean; maxSockets?: number } | false | null; + getHeaders?: () => NodeJS.Dict; +} + +export function measureHeadTotal(head: string): number { + return byteLength(head); +} + +/** + * Largest single header line, CRLF included; the request line is excluded. Scans by index + * rather than splitting: the head is attacker-influenced and can be tens of kilobytes, and + * split() would allocate a copy of all of it to read one line at a time. + */ +export function findLargestHeader(head: string): LargestHeader { + let largest: LargestHeader = { name: '', bytes: 0 }; + + const requestLineEnd = head.indexOf(CRLF); + + if (requestLineEnd === -1) { + return largest; + } + + let start = requestLineEnd + CRLF.length; + + while (start < head.length) { + const end = head.indexOf(CRLF, start); + + if (end === start) { + break; // the blank line that terminates the block + } + + // A head with no terminating CRLF still has a final line: a missing CRLF means + // end-of-input, not termination, or that line would go uncounted. Both producers here + // do terminate, but this is exported and can be handed any string. + const lineEnd = end === -1 ? head.length : end; + const bytes = lineEnd - start + CRLF_BYTES; + + if (bytes > largest.bytes) { + const line = head.slice(start, lineEnd); + largest = { name: truncateForLog(headerNameFromLine(line), MAX_LOGGED_HEADER_NAME), bytes }; + } + + start = lineEnd + CRLF.length; + } + + return largest; +} + +export function serializeOutgoingRequest(request: OutgoingRequest): string | null { + if (!isSupportedRequest(request)) { + return null; + } + + const headers = request.getHeaders(); + + if (Object.keys(headers).length === 0) { + return null; + } + + const lines = [`${request.method} ${request.path} ${HTTP_VERSION}`]; + + for (const [name, value] of Object.entries(headers)) { + if (value === undefined) { + continue; + } + + if (Array.isArray(value)) { + if (value.length >= 2 && name === 'cookie') { + lines.push(`${name}: ${value.join('; ')}`); + } else { + for (const entry of value) { + lines.push(`${name}: ${entry}`); + } + } + + continue; + } + + lines.push(`${name}: ${value}`); + } + + if (headers.connection === undefined) { + lines.push(`Connection: ${shouldKeepAlive(request) ? 'keep-alive' : 'close'}`); + } + + return `${lines.join(CRLF)}${CRLF}${CRLF}`; +} + +export function headFor(request: OutgoingRequest): string | null { + const materialized = (request as { _header?: string | null })._header; + + if (typeof materialized === 'string') { + return materialized; + } + + return serializeOutgoingRequest(request); +} + +function isSupportedRequest( + request: OutgoingRequest, +): request is OutgoingRequest & { method: string; path: string; getHeaders: () => NodeJS.Dict } { + return ( + typeof request.method === 'string' && + BODYLESS_METHODS.has(request.method) && + typeof request.path === 'string' && + typeof request.getHeaders === 'function' + ); +} + +function shouldKeepAlive(request: OutgoingRequest): boolean { + const agent = request.agent; + + return !!agent && (agent.keepAlive === true || Number.isFinite(agent.maxSockets)); +} + +function headerNameFromLine(line: string): string { + const separator = line.indexOf(':'); + + return separator === -1 ? line : line.slice(0, separator); +} + +/** latin1 is one byte per UTF-16 code unit, so this equals `value.length`; the name states intent. */ +function byteLength(value: string): number { + return Buffer.byteLength(value, 'latin1'); +} + +export function truncateForLog(value: string, maxLength: number): string { + return value.length > maxLength ? `${value.slice(0, maxLength)}…` : value; +} diff --git a/ilc/server/tailor/process-fragment-response.js b/ilc/server/tailor/process-fragment-response.js index c870ac69..a85d78b3 100644 --- a/ilc/server/tailor/process-fragment-response.js +++ b/ilc/server/tailor/process-fragment-response.js @@ -8,7 +8,7 @@ const errors = require('./errors'); * @param {http.IncomingMessage} context.request - incoming request from browser * @param {Object} context.fragmentAttributes - fragment attributes map * @param {String} context.fragmentUrl - URL that was requested on fragment - * @param {String} context.isWrapper - Indicates if App Wrapper is requested + * @param {boolean} [context.isWrapper] - Indicates if App Wrapper is requested */ module.exports = (response, context) => { const currRoute = context.request.router.getRoute(); diff --git a/ilc/server/tailor/request-fragment.js b/ilc/server/tailor/request-fragment.js deleted file mode 100644 index ba6f6df4..00000000 --- a/ilc/server/tailor/request-fragment.js +++ /dev/null @@ -1,302 +0,0 @@ -'use strict'; - -const http = require('node:http'); -const https = require('node:https'); -const { URL } = require('node:url'); -const Agent = require('agentkeepalive'); -const HttpsAgent = require('agentkeepalive').HttpsAgent; -const deepmerge = require('deepmerge'); -const { appIdToNameAndSlot } = require('../../common/utils'); -const { SdkOptions } = require('../../common/SdkOptions'); -const { objectToBase64 } = require('../objectToBase64'); - -const errors = require('./errors'); - -const NS_IN_SEC = 1e6; -const MS_IN_SEC = 1000; - -// By default tailor supports gzipped response from fragments -const requiredHeaders = { - 'accept-encoding': 'gzip, deflate', -}; - -const kaAgent = new Agent(); -const kaAgentHttps = new HttpsAgent(); - -/** - * Simple Request Promise Function that requests the fragment server with - * - filtered headers - * - Specified timeout from fragment attributes - * - * @param {filterHeaders} - Function that handles the header forwarding - * @param {processFragmentResponse} - Function that handles response processing - * @param {string} fragmentUrl - URL of the fragment server - * @param {Object} attributes - Attributes passed via fragment tags - * @param {Object} request - HTTP request stream - * @returns {Promise} Response from the fragment server - */ -module.exports = (filterHeaders, processFragmentResponse, logger) => - function requestFragment(fragmentUrl, attributes, request) { - return new Promise((resolve, reject) => { - const currRoute = request.router.getRoute(); - - if (attributes.wrapperConf) { - const wrapperConf = attributes.wrapperConf; - const reqUrl = makeFragmentUrl({ - route: currRoute, - baseUrl: wrapperConf.src, - appId: wrapperConf.appId, - props: wrapperConf.props, - ignoreBasePath: true, - wrappedAppProps: attributes.appProps, - }); - - logger.debug( - { - url: currRoute.route, - id: request.id, - domain: request.host, - detailsJSON: JSON.stringify({ - attributes, - }), - }, - 'Request Fragment. Init processing for wrapper', - ); - - const fragmentRequest = makeRequest( - reqUrl, - { - ...filterHeaders(attributes, request, request.registryConfig?.settings?.fragmentProxyHeaders), - ...requiredHeaders, - }, - wrapperConf.timeout, - attributes.ignoreInvalidSsl || wrapperConf.ignoreInvalidSsl, - ); - - fragmentRequest.on('response', (response) => { - try { - logger.debug( - { - url: currRoute.route, - id: request.id, - domain: request.host, - detailsJSON: JSON.stringify({ - statusCode: response.statusCode, - 'x-props-override': response.headers['x-props-override'], - }), - }, - 'Request Fragment. Wrapper Fragment Response', - ); - - // Wrapper says that we need to request wrapped application - if (response.statusCode === 210) { - logger.debug( - { url: currRoute.route, operationId: request.id }, - 'Request Fragment. Wrapper Fragment Response. ForwardRequest', - ); - const propsOverride = response.headers['x-props-override']; - attributes.wrapperPropsOverride = {}; - if (propsOverride) { - const props = JSON.parse(Buffer.from(propsOverride, 'base64').toString('utf8')); - attributes.appProps = deepmerge(attributes.appProps, props); - attributes.wrapperPropsOverride = props; - } - attributes.wrapperConf = null; - - logger.debug( - { - url: currRoute.route, - id: request.id, - domain: request.host, - detailsJSON: JSON.stringify({ - attributes, - }), - }, - 'Request Fragment. Wrapper Fragment Processing. Attribute overriding', - ); - - resolve(requestFragment(fragmentUrl, attributes, request)); - - return; - } - - logger.debug( - { url: currRoute.route, operationId: request.id }, - 'Request Fragment. Wrapper Fragment Response. Using App Wrapper.', - ); - - resolve( - processFragmentResponse(response, { - request, - fragmentUrl: currRoute.route, - fragmentAttributes: attributes, - isWrapper: true, - }), - ); - } catch (e) { - logger.debug( - { - url: currRoute.route, - id: request.id, - domain: request.host, - }, - 'Request Fragment. Wrapper Fragment Processing. Fragment Response Processing Error', - ); - reject(e); - } - }); - fragmentRequest.on('error', (error) => { - logger.debug( - { - url: currRoute.route, - id: request.id, - domain: request.host, - }, - 'Request Fragment. Wrapper Fragment Processing. Fragment Request Error', - ); - reject( - new errors.FragmentRequestError({ - message: `Error during SSR request to fragment wrapper at URL: ${fragmentUrl}`, - cause: error, - }), - ); - }); - fragmentRequest.end(); - } else { - const { appName } = appIdToNameAndSlot(attributes.id); - - const sdkOptions = new SdkOptions({ - i18n: { - manifestPath: request.registryConfig['apps'][appName].l10nManifest, - }, - }); - - const reqUrl = makeFragmentUrl({ - route: currRoute, - baseUrl: fragmentUrl, - appId: attributes.id, - props: attributes.appProps, - sdkOptions: sdkOptions.toJSON(), - }); - - logger.debug( - { - url: currRoute.route, - id: request.id, - domain: request.host, - detailsJSON: JSON.stringify({ - route: currRoute, - baseUrl: fragmentUrl, - appId: attributes.id, - props: attributes.appProps, - }), - }, - 'Request Fragment. Fragment Processing.', - ); - - const startTime = process.hrtime(); - const fragmentRequest = makeRequest( - reqUrl, - { - ...filterHeaders(attributes, request, request.registryConfig?.settings?.fragmentProxyHeaders), - ...requiredHeaders, - }, - attributes.timeout, - attributes.ignoreInvalidSsl, - ); - - fragmentRequest.on('response', (response) => { - try { - resolve( - processFragmentResponse(response, { - request, - fragmentUrl: reqUrl, - fragmentAttributes: attributes, - }), - ); - logger.debug( - { url: currRoute.route, id: request.id, domain: request.host }, - 'Fragment Processing. Finished', - ); - } catch (e) { - reject(e); - } - }); - fragmentRequest.on('timeout', () => { - const endTime = process.hrtime(startTime); - reject( - new errors.FragmentRequestError({ - message: `Error during SSR request to fragment at URL: ${fragmentUrl} due to timeout after ${ - endTime[0] * MS_IN_SEC + endTime[1] / NS_IN_SEC - }ms`, - }), - ); - }); - fragmentRequest.on('error', (error) => { - reject( - new errors.FragmentRequestError({ - message: `Error during SSR request to fragment at URL: ${fragmentUrl}`, - cause: error, - }), - ); - }); - fragmentRequest.end(); - } - }); - }; - -function makeFragmentUrl({ route, baseUrl, appId, props, ignoreBasePath = false, sdkOptions, wrappedAppProps }) { - const url = new URL(baseUrl); - - const reqProps = { - basePath: ignoreBasePath ? '/' : route.basePath, - reqUrl: route.reqUrl, - fragmentName: appId, - }; - - url.searchParams.append('routerProps', objectToBase64(reqProps)); - - if (props) { - url.searchParams.append('appProps', objectToBase64(props)); - } - - if (sdkOptions) { - url.searchParams.append('sdk', objectToBase64(sdkOptions)); - } - - if (wrappedAppProps) { - url.searchParams.append('wrappedProps', objectToBase64(wrappedAppProps)); - } - - return url.toString(); -} - -function makeRequest(reqUrl, headers, timeout, ignoreInvalidSsl = false) { - const url = new URL(reqUrl); - const { hostname, port, pathname, search, username, password, protocol } = url; - const options = { - headers, - timeout, - auth: username && password ? `${username}:${password}` : undefined, - host: hostname, // the difference between "host" and "hostname" is that "host" includes port - port, - path: pathname + search, - protocol, - }; - - const hasHttpsProtocol = protocol === 'https:'; - const httpLib = hasHttpsProtocol ? https : http; - options.agent = hasHttpsProtocol ? kaAgentHttps : kaAgent; - - if (hasHttpsProtocol && ignoreInvalidSsl) { - options.rejectUnauthorized = false; - } - - const fragmentRequest = httpLib.request(options); - - if (timeout) { - fragmentRequest.setTimeout(timeout, fragmentRequest.abort); - } - - return fragmentRequest; -} diff --git a/ilc/server/tailor/request-fragment.spec.js b/ilc/server/tailor/request-fragment.spec.js deleted file mode 100644 index c8ceba35..00000000 --- a/ilc/server/tailor/request-fragment.spec.js +++ /dev/null @@ -1,446 +0,0 @@ -const chai = require('chai'); -const nock = require('nock'); -const sinon = require('sinon'); - -const requestFragmentSetup = require('./request-fragment'); -const ServerRouter = require('./server-router'); -const { getRegistryMock } = require('../../tests/helpers'); -const { getFragmentAttributes } = require('../../tests/helpers'); -const errors = require('./errors'); - -describe('request-fragment', () => { - /** - * Mock filter - * To be observed to be sure this one has been called - * Returns always empty headers object - * @returns {{}} - */ - const filterHeadersMock = sinon.spy(() => ({})); - - /** - * Mock fragment response processor - * To be observed to be sure this one has been called - */ - const processFragmentResponseMock = sinon.spy(); - - const logger = { - warn: () => {}, - debug: () => {}, - }; - - const requestFragment = requestFragmentSetup(filterHeadersMock, processFragmentResponseMock, logger); - - afterEach(() => { - processFragmentResponseMock.resetHistory(); - filterHeadersMock.resetHistory(); - }); - - it('should request fragment with correct routerProps, appProps and required headers', async () => { - // Initialisation - - const registryConfig = getRegistryMock().getConfig(); - - const attributes = getFragmentAttributes({ - id: 'primary__at__primary', - appProps: { publicPath: 'http://apps.test/primary' }, - wrapperConf: null, - url: 'http://apps.test/primary', - async: false, - primary: false, - public: false, - timeout: 1000, - returnHeaders: false, - forwardQuerystring: false, - ignoreInvalidSsl: false, - }); - - const request = { - registryConfig, - ilcState: {}, - host: 'apps.test', - }; - request.router = new ServerRouter(logger, request, '/primary'); - - // Expectations - - const expectedRouterProps = { basePath: '/primary', reqUrl: '/primary', fragmentName: 'primary__at__primary' }; - const expectedAppProps = { publicPath: 'http://apps.test/primary' }; - const expectedSdkOptions = { i18n: { manifestPath: '/l10n/primary/manifest.json' } }; - - const expectedRouterPropsEncoded = Buffer.from(JSON.stringify(expectedRouterProps)).toString('base64'); - const expectedAppPropsEncoded = Buffer.from(JSON.stringify(expectedAppProps)).toString('base64'); - const expectedSdkEncoded = Buffer.from(JSON.stringify(expectedSdkOptions)).toString('base64'); - - const mockRequestScope = nock('http://apps.test', { reqheaders: { 'accept-encoding': 'gzip, deflate' } }) - .get('/primary') - .query({ - routerProps: expectedRouterPropsEncoded, - appProps: expectedAppPropsEncoded, - sdk: expectedSdkEncoded, - }) - .reply(200); - - // Processing - - await requestFragment(attributes.url, attributes, request); - mockRequestScope.done(); - chai.expect(processFragmentResponseMock.calledOnce).to.be.equal(true); - chai.expect(filterHeadersMock.calledOnce).to.be.equal(true); - }); - - it('should request fragment wrapper with correct routerProps, appProps and required headers', async () => { - // Initialization - - const registryConfig = getRegistryMock().getConfig(); - - const attributes = getFragmentAttributes({ - id: 'wrapperApp__at__primary', - appProps: { page: 'wrapped' }, - wrapperConf: { - appId: 'wrapper__at__primary', - name: '@portal/wrapper', - src: 'http://apps.test/wrapper', - timeout: 2000, - props: { param1: 'value1' }, - }, - url: 'http://apps.test/wrappedApp', - async: false, - primary: true, - public: false, - timeout: 1000, - returnHeaders: false, - forwardQuerystring: false, - ignoreInvalidSsl: false, - }); - - const request = { - registryConfig, - ilcState: {}, - host: 'apps.test', - }; - request.router = new ServerRouter(logger, request, '/wrapper'); - - // Expectations - - const expectedRouterProps = { basePath: '/', reqUrl: '/wrapper', fragmentName: 'wrapper__at__primary' }; - const expectedAppProps = { param1: 'value1' }; - const wrappedAppProps = { page: 'wrapped' }; - - const expectedRouterPropsEncoded = Buffer.from(JSON.stringify(expectedRouterProps)).toString('base64'); - const expectedAppPropsEncoded = Buffer.from(JSON.stringify(expectedAppProps)).toString('base64'); - const expectedWrappedAppPropsEncoded = Buffer.from(JSON.stringify(wrappedAppProps)).toString('base64'); - - const mockRequestScope = nock('http://apps.test', { reqheaders: { 'accept-encoding': 'gzip, deflate' } }) - .get('/wrapper') - .query({ - routerProps: expectedRouterPropsEncoded, - appProps: expectedAppPropsEncoded, - wrappedProps: expectedWrappedAppPropsEncoded, - }) - .reply(200); - - // Processing - - await requestFragment(attributes.url, attributes, request); - mockRequestScope.done(); - chai.expect(processFragmentResponseMock.calledOnce).to.be.equal(true); - chai.expect(filterHeadersMock.calledOnce).to.be.equal(true); - }); - - it('should request fragment of wrapped application with correct routerProps, appProps and required headers', async () => { - // Initialisation - - const registryConfig = getRegistryMock().getConfig(); - - const attributes = getFragmentAttributes({ - id: 'wrapperApp__at__primary', - appProps: { page: 'wrapped' }, - wrapperConf: { - appId: 'wrapper__at__primary', - name: '@portal/wrapper', - src: 'http://apps.test/wrapper', - timeout: 2000, - props: { param1: 'value1' }, - }, - url: 'http://apps.test/wrappedApp', - async: false, - primary: true, - public: false, - timeout: 1000, - returnHeaders: false, - forwardQuerystring: false, - ignoreInvalidSsl: false, - }); - - const request = { - registryConfig, - ilcState: {}, - host: 'apps.test', - }; - request.router = new ServerRouter(logger, request, '/wrapper'); - - // Expectations - - const expectedWrapperRouterProps = { basePath: '/', reqUrl: '/wrapper', fragmentName: 'wrapper__at__primary' }; - const expectedWrapperAppProps = { param1: 'value1' }; - const wrapperPropsOverride = { param2: 'value2' }; - const wrappedAppProps = { page: 'wrapped' }; - - const expectedWrapperRouterPropsEncoded = Buffer.from(JSON.stringify(expectedWrapperRouterProps)).toString( - 'base64', - ); - const expectedWrapperAppPropsEncoded = Buffer.from(JSON.stringify(expectedWrapperAppProps)).toString('base64'); - const wrapperPropsOverrideEncoded = Buffer.from(JSON.stringify(wrapperPropsOverride)).toString('base64'); - const expectedWrappedAppPropsEncoded = Buffer.from(JSON.stringify(wrappedAppProps)).toString('base64'); - - const mockRequestWrapperScope = nock('http://apps.test', { reqheaders: { 'accept-encoding': 'gzip, deflate' } }) - .get('/wrapper') - .query({ - routerProps: expectedWrapperRouterPropsEncoded, - appProps: expectedWrapperAppPropsEncoded, - wrappedProps: expectedWrappedAppPropsEncoded, - }) - .reply(210, '', { 'x-props-override': wrapperPropsOverrideEncoded }); - - const expectedWrappedAppRouterProps = { - basePath: '/wrapper', - reqUrl: '/wrapper', - fragmentName: 'wrapperApp__at__primary', - }; - // returned props from wrapper must be overrode for wrapped application - const expectedWrappedAppAppProps = { page: 'wrapped', param2: 'value2' }; - - const expectedWrappedAppRouterPropsEncoded = Buffer.from( - JSON.stringify(expectedWrappedAppRouterProps), - ).toString('base64'); - const expectedWrappedAppAppPropsEncoded = Buffer.from(JSON.stringify(expectedWrappedAppAppProps)).toString( - 'base64', - ); - - const mockRequestWrappedAppScope = nock('http://apps.test', { - reqheaders: { 'accept-encoding': 'gzip, deflate' }, - }) - .get('/wrappedApp') - .query({ - routerProps: expectedWrappedAppRouterPropsEncoded, - appProps: expectedWrappedAppAppPropsEncoded, - }) - .reply(200); - - // Processing - - await requestFragment(attributes.url, attributes, request); - - mockRequestWrapperScope.done(); - mockRequestWrappedAppScope.done(); - chai.expect(processFragmentResponseMock.calledOnce).to.be.equal(true); - chai.expect(filterHeadersMock.calledTwice).to.be.equal(true); - }); - - it('should return timeout if timeout is specified for fragment', async () => { - const registryConfig = getRegistryMock().getConfig(); - - let timeoutMs = 200; - const attributes = getFragmentAttributes({ - id: 'primary__at__primary', - appProps: { publicPath: 'http://apps.test/primary' }, - wrapperConf: null, - url: 'http://apps.test/primary', - async: false, - primary: false, - public: false, - timeout: timeoutMs, - returnHeaders: false, - forwardQuerystring: false, - ignoreInvalidSsl: false, - }); - - const request = { - registryConfig, - ilcState: {}, - host: 'apps.test', - }; - request.router = new ServerRouter(logger, request, '/primary'); - - // Expectations - - const expectedRouterProps = { basePath: '/primary', reqUrl: '/primary', fragmentName: 'primary__at__primary' }; - const expectedAppProps = { publicPath: 'http://apps.test/primary' }; - const expectedSdkOptions = { i18n: { manifestPath: '/l10n/primary/manifest.json' } }; - - const expectedRouterPropsEncoded = Buffer.from(JSON.stringify(expectedRouterProps)).toString('base64'); - const expectedAppPropsEncoded = Buffer.from(JSON.stringify(expectedAppProps)).toString('base64'); - const expectedSdkEncoded = Buffer.from(JSON.stringify(expectedSdkOptions)).toString('base64'); - - const mockRequestScope = nock('http://apps.test', { reqheaders: { 'accept-encoding': 'gzip, deflate' } }) - .get('/primary') - .query({ - routerProps: expectedRouterPropsEncoded, - appProps: expectedAppPropsEncoded, - sdk: expectedSdkEncoded, - }) - .delay(timeoutMs + 20) - .reply(200); - - try { - await requestFragment(attributes.url, attributes, request); - mockRequestScope.done(); - chai.expect.fail('This code should not be reached, because error expected to be thrown above'); - } catch (e) { - chai.expect(e).to.be.an.instanceof(errors.FragmentRequestError); - chai.expect(e.message).to.contain('timeout'); - } - }); - - it('should handle network error when requesting fragment', async () => { - const registryConfig = getRegistryMock().getConfig(); - - const attributes = getFragmentAttributes({ - id: 'primary__at__primary', - appProps: { publicPath: 'http://apps.test/primary' }, - wrapperConf: null, - url: 'http://apps.test/primary', - async: false, - primary: false, - public: false, - timeout: 1000, - returnHeaders: false, - forwardQuerystring: false, - ignoreInvalidSsl: false, - }); - - const request = { - registryConfig, - ilcState: {}, - host: 'apps.test', - }; - request.router = new ServerRouter(logger, request, '/primary'); - - const networkError = new Error('Network error'); - networkError.code = 'ECONNREFUSED'; - - const mockRequestScope = nock('http://apps.test').get('/primary').query(true).replyWithError(networkError); - - try { - await requestFragment(attributes.url, attributes, request); - mockRequestScope.done(); - chai.expect.fail('This code should not be reached, because error expected to be thrown above'); - } catch (e) { - chai.expect(e).to.be.an.instanceof(errors.FragmentRequestError); - chai.expect(e.message).to.contain('Error during SSR request to fragment'); - } - }); - - it('should handle network error when requesting wrapper fragment', async () => { - const registryConfig = getRegistryMock().getConfig(); - - const attributes = getFragmentAttributes({ - id: 'wrapperApp__at__primary', - appProps: { page: 'wrapped' }, - wrapperConf: { - appId: 'wrapper__at__primary', - name: '@portal/wrapper', - src: 'http://apps.test/wrapper', - timeout: 2000, - props: { param1: 'value1' }, - }, - url: 'http://apps.test/wrappedApp', - async: false, - primary: true, - public: false, - timeout: 1000, - returnHeaders: false, - forwardQuerystring: false, - ignoreInvalidSsl: false, - }); - - const request = { - registryConfig, - ilcState: {}, - host: 'apps.test', - }; - request.router = new ServerRouter(logger, request, '/wrapper'); - - const networkError = new Error('Network error'); - networkError.code = 'ECONNREFUSED'; - - const mockRequestScope = nock('http://apps.test').get('/wrapper').query(true).replyWithError(networkError); - - try { - await requestFragment(attributes.url, attributes, request); - mockRequestScope.done(); - chai.expect.fail('This code should not be reached, because error expected to be thrown above'); - } catch (e) { - chai.expect(e).to.be.an.instanceof(errors.FragmentRequestError); - chai.expect(e.message).to.contain('Error during SSR request to fragment wrapper'); - } - }); - - it('should handle HTTPS requests', async () => { - const registryConfig = getRegistryMock().getConfig(); - - const attributes = getFragmentAttributes({ - id: 'primary__at__primary', - appProps: { publicPath: 'https://secure.test/primary' }, - wrapperConf: null, - url: 'https://secure.test/primary', - async: false, - primary: false, - public: false, - timeout: 1000, - returnHeaders: false, - forwardQuerystring: false, - ignoreInvalidSsl: false, - }); - - const request = { - registryConfig, - ilcState: {}, - host: 'secure.test', - }; - request.router = new ServerRouter(logger, request, '/primary'); - - const mockRequestScope = nock('https://secure.test', { reqheaders: { 'accept-encoding': 'gzip, deflate' } }) - .get('/primary') - .query(true) - .reply(200); - - await requestFragment(attributes.url, attributes, request); - mockRequestScope.done(); - chai.expect(processFragmentResponseMock.calledOnce).to.be.equal(true); - }); - - it('should ignore invalid SSL certificates when ignoreInvalidSsl is true', async () => { - const registryConfig = getRegistryMock().getConfig(); - - const attributes = getFragmentAttributes({ - id: 'primary__at__primary', - appProps: { publicPath: 'https://secure.test/primary' }, - wrapperConf: null, - url: 'https://secure.test/primary', - async: false, - primary: false, - public: false, - timeout: 1000, - returnHeaders: false, - forwardQuerystring: false, - ignoreInvalidSsl: true, - }); - - const request = { - registryConfig, - ilcState: {}, - host: 'secure.test', - }; - request.router = new ServerRouter(logger, request, '/primary'); - - const mockRequestScope = nock('https://secure.test', { reqheaders: { 'accept-encoding': 'gzip, deflate' } }) - .get('/primary') - .query(true) - .reply(200); - - await requestFragment(attributes.url, attributes, request); - mockRequestScope.done(); - chai.expect(processFragmentResponseMock.calledOnce).to.be.equal(true); - }); -}); diff --git a/ilc/server/tailor/request-fragment.spec.ts b/ilc/server/tailor/request-fragment.spec.ts new file mode 100644 index 00000000..43ed8840 --- /dev/null +++ b/ilc/server/tailor/request-fragment.spec.ts @@ -0,0 +1,1151 @@ +import chai from 'chai'; +import nock from 'nock'; +import sinon from 'sinon'; +import net from 'node:net'; +import http from 'node:http'; + +import { requestFragmentFactory as requestFragmentSetup } from './request-fragment'; +import { ServerRouter } from './server-router'; +import { getFragmentAttributes, getRegistryMock } from '../../tests/helpers'; +import errors from './errors'; + +/* eslint-disable @typescript-eslint/no-explicit-any -- the fixtures below deliberately build + partial tailor requests and fragment attributes; typing them fully would assert shapes these + tests do not exercise. */ + +describe('request-fragment', () => { + /** + * Mock filter + * To be observed to be sure this one has been called + * Returns always empty headers object + * @returns {{}} + */ + const filterHeadersMock = sinon.spy(() => ({})); + + /** + * Mock fragment response processor + * To be observed to be sure this one has been called + */ + const processFragmentResponseMock = sinon.spy(); + + const logger = { + warn: () => {}, + debug: () => {}, + }; + + const requestFragment = requestFragmentSetup(filterHeadersMock, processFragmentResponseMock, logger); + + afterEach(() => { + processFragmentResponseMock.resetHistory(); + filterHeadersMock.resetHistory(); + // A test whose request is intentionally never dispatched leaves its interceptor + // pending, which would otherwise be consumed by — and fail — a later test. + nock.cleanAll(); + }); + + it('should request fragment with correct routerProps, appProps and required headers', async () => { + // Initialisation + + const registryConfig = getRegistryMock().getConfig(); + + const attributes = getFragmentAttributes({ + id: 'primary__at__primary', + appProps: { publicPath: 'http://apps.test/primary' }, + wrapperConf: null, + url: 'http://apps.test/primary', + async: false, + primary: false, + public: false, + timeout: 1000, + returnHeaders: false, + forwardQuerystring: false, + ignoreInvalidSsl: false, + }); + + const request: any = { + registryConfig, + ilcState: {}, + host: 'apps.test', + }; + request.router = new ServerRouter(logger, request, '/primary'); + + // Expectations + + const expectedRouterProps = { basePath: '/primary', reqUrl: '/primary', fragmentName: 'primary__at__primary' }; + const expectedAppProps = { publicPath: 'http://apps.test/primary' }; + const expectedSdkOptions = { i18n: { manifestPath: '/l10n/primary/manifest.json' } }; + + const expectedRouterPropsEncoded = Buffer.from(JSON.stringify(expectedRouterProps)).toString('base64'); + const expectedAppPropsEncoded = Buffer.from(JSON.stringify(expectedAppProps)).toString('base64'); + const expectedSdkEncoded = Buffer.from(JSON.stringify(expectedSdkOptions)).toString('base64'); + + const mockRequestScope = nock('http://apps.test', { reqheaders: { 'accept-encoding': 'gzip, deflate' } }) + .get('/primary') + .query({ + routerProps: expectedRouterPropsEncoded, + appProps: expectedAppPropsEncoded, + sdk: expectedSdkEncoded, + }) + .reply(200); + + // Processing + + await requestFragment(attributes.url, attributes, request); + mockRequestScope.done(); + chai.expect(processFragmentResponseMock.calledOnce).to.be.equal(true); + chai.expect(filterHeadersMock.calledOnce).to.be.equal(true); + }); + + it('should request fragment wrapper with correct routerProps, appProps and required headers', async () => { + // Initialization + + const registryConfig = getRegistryMock().getConfig(); + + const attributes = getFragmentAttributes({ + id: 'wrapperApp__at__primary', + appProps: { page: 'wrapped' }, + wrapperConf: { + appId: 'wrapper__at__primary', + name: '@portal/wrapper', + src: 'http://apps.test/wrapper', + timeout: 2000, + props: { param1: 'value1' }, + }, + url: 'http://apps.test/wrappedApp', + async: false, + primary: true, + public: false, + timeout: 1000, + returnHeaders: false, + forwardQuerystring: false, + ignoreInvalidSsl: false, + }); + + const request: any = { + registryConfig, + ilcState: {}, + host: 'apps.test', + }; + request.router = new ServerRouter(logger, request, '/wrapper'); + + // Expectations + + const expectedRouterProps = { basePath: '/', reqUrl: '/wrapper', fragmentName: 'wrapper__at__primary' }; + const expectedAppProps = { param1: 'value1' }; + const wrappedAppProps = { page: 'wrapped' }; + + const expectedRouterPropsEncoded = Buffer.from(JSON.stringify(expectedRouterProps)).toString('base64'); + const expectedAppPropsEncoded = Buffer.from(JSON.stringify(expectedAppProps)).toString('base64'); + const expectedWrappedAppPropsEncoded = Buffer.from(JSON.stringify(wrappedAppProps)).toString('base64'); + + const mockRequestScope = nock('http://apps.test', { reqheaders: { 'accept-encoding': 'gzip, deflate' } }) + .get('/wrapper') + .query({ + routerProps: expectedRouterPropsEncoded, + appProps: expectedAppPropsEncoded, + wrappedProps: expectedWrappedAppPropsEncoded, + }) + .reply(200); + + // Processing + + await requestFragment(attributes.url, attributes, request); + mockRequestScope.done(); + chai.expect(processFragmentResponseMock.calledOnce).to.be.equal(true); + chai.expect(filterHeadersMock.calledOnce).to.be.equal(true); + }); + + it('should request fragment of wrapped application with correct routerProps, appProps and required headers', async () => { + // Initialisation + + const registryConfig = getRegistryMock().getConfig(); + + const attributes = getFragmentAttributes({ + id: 'wrapperApp__at__primary', + appProps: { page: 'wrapped' }, + wrapperConf: { + appId: 'wrapper__at__primary', + name: '@portal/wrapper', + src: 'http://apps.test/wrapper', + timeout: 2000, + props: { param1: 'value1' }, + }, + url: 'http://apps.test/wrappedApp', + async: false, + primary: true, + public: false, + timeout: 1000, + returnHeaders: false, + forwardQuerystring: false, + ignoreInvalidSsl: false, + }); + + const request: any = { + registryConfig, + ilcState: {}, + host: 'apps.test', + }; + request.router = new ServerRouter(logger, request, '/wrapper'); + + // Expectations + + const expectedWrapperRouterProps = { basePath: '/', reqUrl: '/wrapper', fragmentName: 'wrapper__at__primary' }; + const expectedWrapperAppProps = { param1: 'value1' }; + const wrapperPropsOverride = { param2: 'value2' }; + const wrappedAppProps = { page: 'wrapped' }; + + const expectedWrapperRouterPropsEncoded = Buffer.from(JSON.stringify(expectedWrapperRouterProps)).toString( + 'base64', + ); + const expectedWrapperAppPropsEncoded = Buffer.from(JSON.stringify(expectedWrapperAppProps)).toString('base64'); + const wrapperPropsOverrideEncoded = Buffer.from(JSON.stringify(wrapperPropsOverride)).toString('base64'); + const expectedWrappedAppPropsEncoded = Buffer.from(JSON.stringify(wrappedAppProps)).toString('base64'); + + const mockRequestWrapperScope = nock('http://apps.test', { reqheaders: { 'accept-encoding': 'gzip, deflate' } }) + .get('/wrapper') + .query({ + routerProps: expectedWrapperRouterPropsEncoded, + appProps: expectedWrapperAppPropsEncoded, + wrappedProps: expectedWrappedAppPropsEncoded, + }) + .reply(210, '', { 'x-props-override': wrapperPropsOverrideEncoded }); + + const expectedWrappedAppRouterProps = { + basePath: '/wrapper', + reqUrl: '/wrapper', + fragmentName: 'wrapperApp__at__primary', + }; + // returned props from wrapper must be overrode for wrapped application + const expectedWrappedAppAppProps = { page: 'wrapped', param2: 'value2' }; + + const expectedWrappedAppRouterPropsEncoded = Buffer.from( + JSON.stringify(expectedWrappedAppRouterProps), + ).toString('base64'); + const expectedWrappedAppAppPropsEncoded = Buffer.from(JSON.stringify(expectedWrappedAppAppProps)).toString( + 'base64', + ); + + const mockRequestWrappedAppScope = nock('http://apps.test', { + reqheaders: { 'accept-encoding': 'gzip, deflate' }, + }) + .get('/wrappedApp') + .query({ + routerProps: expectedWrappedAppRouterPropsEncoded, + appProps: expectedWrappedAppAppPropsEncoded, + }) + .reply(200); + + // Processing + + await requestFragment(attributes.url, attributes, request); + + mockRequestWrapperScope.done(); + mockRequestWrappedAppScope.done(); + chai.expect(processFragmentResponseMock.calledOnce).to.be.equal(true); + chai.expect(filterHeadersMock.calledTwice).to.be.equal(true); + }); + + it('should return timeout if timeout is specified for fragment', async () => { + const registryConfig = getRegistryMock().getConfig(); + + let timeoutMs = 200; + const attributes = getFragmentAttributes({ + id: 'primary__at__primary', + appProps: { publicPath: 'http://apps.test/primary' }, + wrapperConf: null, + url: 'http://apps.test/primary', + async: false, + primary: false, + public: false, + timeout: timeoutMs, + returnHeaders: false, + forwardQuerystring: false, + ignoreInvalidSsl: false, + }); + + const request: any = { + registryConfig, + ilcState: {}, + host: 'apps.test', + }; + request.router = new ServerRouter(logger, request, '/primary'); + + // Expectations + + const expectedRouterProps = { basePath: '/primary', reqUrl: '/primary', fragmentName: 'primary__at__primary' }; + const expectedAppProps = { publicPath: 'http://apps.test/primary' }; + const expectedSdkOptions = { i18n: { manifestPath: '/l10n/primary/manifest.json' } }; + + const expectedRouterPropsEncoded = Buffer.from(JSON.stringify(expectedRouterProps)).toString('base64'); + const expectedAppPropsEncoded = Buffer.from(JSON.stringify(expectedAppProps)).toString('base64'); + const expectedSdkEncoded = Buffer.from(JSON.stringify(expectedSdkOptions)).toString('base64'); + + const mockRequestScope = nock('http://apps.test', { reqheaders: { 'accept-encoding': 'gzip, deflate' } }) + .get('/primary') + .query({ + routerProps: expectedRouterPropsEncoded, + appProps: expectedAppPropsEncoded, + sdk: expectedSdkEncoded, + }) + .delay(timeoutMs + 20) + .reply(200); + + try { + await requestFragment(attributes.url, attributes, request); + mockRequestScope.done(); + chai.expect.fail('This code should not be reached, because error expected to be thrown above'); + } catch (e: any) { + chai.expect(e).to.be.an.instanceof(errors.FragmentRequestError); + chai.expect(e.message).to.contain('timeout'); + } + }); + + it('should handle network error when requesting fragment', async () => { + const registryConfig = getRegistryMock().getConfig(); + + const attributes = getFragmentAttributes({ + id: 'primary__at__primary', + appProps: { publicPath: 'http://apps.test/primary' }, + wrapperConf: null, + url: 'http://apps.test/primary', + async: false, + primary: false, + public: false, + timeout: 1000, + returnHeaders: false, + forwardQuerystring: false, + ignoreInvalidSsl: false, + }); + + const request: any = { + registryConfig, + ilcState: {}, + host: 'apps.test', + }; + request.router = new ServerRouter(logger, request, '/primary'); + + const networkError: NodeJS.ErrnoException = new Error('Network error'); + networkError.code = 'ECONNREFUSED'; + + const mockRequestScope = nock('http://apps.test').get('/primary').query(true).replyWithError(networkError); + + try { + await requestFragment(attributes.url, attributes, request); + mockRequestScope.done(); + chai.expect.fail('This code should not be reached, because error expected to be thrown above'); + } catch (e: any) { + chai.expect(e).to.be.an.instanceof(errors.FragmentRequestError); + chai.expect(e.message).to.contain('Error during SSR request to fragment'); + } + }); + + it('should handle network error when requesting wrapper fragment', async () => { + const registryConfig = getRegistryMock().getConfig(); + + const attributes = getFragmentAttributes({ + id: 'wrapperApp__at__primary', + appProps: { page: 'wrapped' }, + wrapperConf: { + appId: 'wrapper__at__primary', + name: '@portal/wrapper', + src: 'http://apps.test/wrapper', + timeout: 2000, + props: { param1: 'value1' }, + }, + url: 'http://apps.test/wrappedApp', + async: false, + primary: true, + public: false, + timeout: 1000, + returnHeaders: false, + forwardQuerystring: false, + ignoreInvalidSsl: false, + }); + + const request: any = { + registryConfig, + ilcState: {}, + host: 'apps.test', + }; + request.router = new ServerRouter(logger, request, '/wrapper'); + + const networkError: NodeJS.ErrnoException = new Error('Network error'); + networkError.code = 'ECONNREFUSED'; + + const mockRequestScope = nock('http://apps.test').get('/wrapper').query(true).replyWithError(networkError); + + try { + await requestFragment(attributes.url, attributes, request); + mockRequestScope.done(); + chai.expect.fail('This code should not be reached, because error expected to be thrown above'); + } catch (e: any) { + chai.expect(e).to.be.an.instanceof(errors.FragmentRequestError); + chai.expect(e.message).to.contain('Error during SSR request to fragment wrapper'); + } + }); + + it('should handle HTTPS requests', async () => { + const registryConfig = getRegistryMock().getConfig(); + + const attributes = getFragmentAttributes({ + id: 'primary__at__primary', + appProps: { publicPath: 'https://secure.test/primary' }, + wrapperConf: null, + url: 'https://secure.test/primary', + async: false, + primary: false, + public: false, + timeout: 1000, + returnHeaders: false, + forwardQuerystring: false, + ignoreInvalidSsl: false, + }); + + const request: any = { + registryConfig, + ilcState: {}, + host: 'secure.test', + }; + request.router = new ServerRouter(logger, request, '/primary'); + + const mockRequestScope = nock('https://secure.test', { reqheaders: { 'accept-encoding': 'gzip, deflate' } }) + .get('/primary') + .query(true) + .reply(200); + + await requestFragment(attributes.url, attributes, request); + mockRequestScope.done(); + chai.expect(processFragmentResponseMock.calledOnce).to.be.equal(true); + }); + + it('should ignore invalid SSL certificates when ignoreInvalidSsl is true', async () => { + const registryConfig = getRegistryMock().getConfig(); + + const attributes = getFragmentAttributes({ + id: 'primary__at__primary', + appProps: { publicPath: 'https://secure.test/primary' }, + wrapperConf: null, + url: 'https://secure.test/primary', + async: false, + primary: false, + public: false, + timeout: 1000, + returnHeaders: false, + forwardQuerystring: false, + ignoreInvalidSsl: true, + }); + + const request: any = { + registryConfig, + ilcState: {}, + host: 'secure.test', + }; + request.router = new ServerRouter(logger, request, '/primary'); + + const mockRequestScope = nock('https://secure.test', { reqheaders: { 'accept-encoding': 'gzip, deflate' } }) + .get('/primary') + .query(true) + .reply(200); + + await requestFragment(attributes.url, attributes, request); + mockRequestScope.done(); + chai.expect(processFragmentResponseMock.calledOnce).to.be.equal(true); + }); + it('should reject with a named error when an app wrapper has no ssr src', async () => { + // A wrapper declaring `ssr: {}` passes the router's "does this wrapper support SSR" + // check and arrives here with src absent, because WrapperConf.src is optional for + // exactly that reason. + // + // The guard is enforced by the compiler, not by this test: delete it and the build + // fails, because wrapperConf.src stops satisfying makeFragmentUrl's `baseUrl: string`. + // What this pins is the observable behaviour — a FragmentRequestError naming the + // wrapper, rather than the anonymous TypeError `new URL(undefined)` would raise from + // inside the promise executor. + const registryConfig = getRegistryMock().getConfig(); + const attributes = getFragmentAttributes({ + id: 'wrapperApp__at__primary', + appProps: {}, + wrapperConf: { + appId: 'wrapper__at__primary', + name: '@portal/wrapper', + timeout: 2000, + props: {}, + }, + url: 'http://apps.test/wrappedApp', + primary: true, + }); + const request: any = { registryConfig, ilcState: {}, host: 'apps.test' }; + request.router = new ServerRouter(logger, request, '/wrapper'); + + let rejected; + try { + await requestFragment(attributes.url, attributes, request); + } catch (error: any) { + rejected = error; + } + + chai.expect(rejected).to.be.an.instanceof(errors.FragmentRequestError); + chai.expect(rejected.message).to.contain('wrapper__at__primary'); + }); + + describe('request size pre-flight', () => { + const buildFragment = (maxRequestSize?: unknown) => { + const warn = sinon.spy(); + const processResponse = sinon.spy(); + const requestFragmentWithLimit = requestFragmentSetup( + filterHeadersMock, + processResponse, + { warn, debug: () => {} }, + { maxRequestSize }, + ); + + return { warn, processResponse, requestFragmentWithLimit }; + }; + + const buildRequest = (extraAppProps = {}) => { + const registryConfig = getRegistryMock().getConfig(); + const attributes = getFragmentAttributes({ + id: 'primary__at__primary', + appProps: { publicPath: 'http://apps.test/primary', ...extraAppProps }, + wrapperConf: null, + url: 'http://apps.test/primary', + async: false, + primary: false, + public: false, + timeout: 1000, + returnHeaders: false, + forwardQuerystring: false, + ignoreInvalidSsl: false, + }); + const request: any = { registryConfig, ilcState: {}, host: 'apps.test', id: 'test-operation-id' }; + request.router = new ServerRouter(logger, request, '/primary'); + + return { attributes, request }; + }; + + it('should not dispatch to the fragment when the request would exceed the limit', async () => { + const { attributes, request } = buildRequest(); + const { warn, processResponse, requestFragmentWithLimit } = buildFragment(200); + + // No nock interceptor is registered: a dispatch would fail the test. + await requestFragmentWithLimit(attributes.url, attributes, request); + + chai.expect(warn.calledOnce).to.be.equal(true); + chai.expect(processResponse.calledOnce).to.be.equal(true); + }); + + it('should report size, limit, appId and the largest header name without any header value', async () => { + const { attributes, request } = buildRequest(); + const { warn, requestFragmentWithLimit } = buildFragment(200); + + await requestFragmentWithLimit(attributes.url, attributes, request); + + const [payload] = warn.firstCall.args; + chai.expect(payload.limit).to.be.equal(200); + chai.expect(payload.size).to.be.above(200); + chai.expect(payload.appId).to.be.equal('primary__at__primary'); + // The ticket names size, limit, path and operationId as the WARN's required + // fields; operationId must appear under that exact key, as the other log + // lines in request-fragment.js report it, not only under the enhanced + // logger's own label. + chai.expect(payload.operationId).to.be.equal('test-operation-id'); + chai.expect(payload.path).to.be.equal('/primary'); + chai.expect(payload.largestHeader).to.have.property('name'); + chai.expect(payload.largestHeader).to.have.property('bytes'); + chai.expect(Object.keys(payload)).to.not.include('headers'); + }); + + it('should leave the guard disabled when the configured value is not a usable number', async () => { + // An unparseable value must not make every comparison false and skip every + // fragment. The guard is opt-in, so it stays off and says so once at setup rather + // than running at a ceiling nobody chose. + const { attributes, request } = buildRequest(); + const warn = sinon.spy(); + const requestFragmentWithBadLimit = requestFragmentSetup( + filterHeadersMock, + sinon.spy(), + { warn, debug: () => {} }, + { maxRequestSize: Number('not-a-number') }, + ); + + chai.expect(warn.calledOnce).to.be.equal(true); + chai.expect(warn.firstCall.args[0].limit).to.be.equal(0); + + const mockRequestScope = nock('http://apps.test').get('/primary').query(true).reply(200); + + await requestFragmentWithBadLimit(attributes.url, attributes, request); + + // The guard is off, so the request dispatches without a further warn. + mockRequestScope.done(); + chai.expect(warn.calledOnce).to.be.equal(true); + }); + + it('should warn on a blank string, unlike the explicit zero off-switch', async () => { + // Number(' ') is 0, which is also the off switch, so with an opt-in guard both + // resolve to the same limit. What still separates them is the log line: a stray + // space in the env var is a misconfiguration and says so, where an explicit 0 is a + // deliberate choice and stays silent. + const warn = sinon.spy(); + requestFragmentSetup(filterHeadersMock, sinon.spy(), { warn, debug: () => {} }, { maxRequestSize: ' ' }); + + chai.expect(warn.calledOnce).to.be.equal(true); + chai.expect(warn.firstCall.args[0].limit).to.be.equal(0); + + const silent = sinon.spy(); + requestFragmentSetup( + filterHeadersMock, + sinon.spy(), + { warn: silent, debug: () => {} }, + { maxRequestSize: 0 }, + ); + + chai.expect(silent.called).to.be.equal(false); + }); + + it('should accept a numeric string limit, as an env var supplies it', async () => { + const { attributes, request } = buildRequest(); + const warn = sinon.spy(); + const requestFragmentWithStringLimit = requestFragmentSetup( + filterHeadersMock, + sinon.spy(), + { warn, debug: () => {} }, + { maxRequestSize: '200' }, + ); + + await requestFragmentWithStringLimit(attributes.url, attributes, request); + + chai.expect(warn.calledOnce).to.be.equal(true); + chai.expect(warn.firstCall.args[0].limit).to.be.equal(200); + }); + + it('should treat a limit of zero as the guard being switched off', async () => { + // Zero is the off switch and also the shipped default. It must stay off rather + // than falling back to a ceiling of ILC's choosing. + const { attributes, request } = buildRequest(); + const warn = sinon.spy(); + // The same request is skipped at a limit of 200 (covered above), so dispatching + // it here is what proves zero means off rather than "fall back to the default". + const requestFragmentDisabled = requestFragmentSetup( + filterHeadersMock, + sinon.spy(), + { warn, debug: () => {} }, + { maxRequestSize: 0 }, + ); + const mockRequestScope = nock('http://apps.test').get('/primary').query(true).reply(200); + + await requestFragmentDisabled(attributes.url, attributes, request); + + mockRequestScope.done(); + chai.expect(warn.called).to.be.equal(false); + }); + + it('should degrade with a WARN and no rejection when the app has no client bundle', async () => { + // The acceptance criteria allow no ERROR for an over-limit request, and a + // rejection becomes one via tailor's fragment-error handling. The slot stays + // blank for an app that cannot render client-side, so the WARN records that. + const registryConfig = getRegistryMock().getConfig(); + const attributes = getFragmentAttributes({ + id: 'primary__at__primary', + appProps: {}, + wrapperConf: null, + url: 'http://apps.test/primary', + spaBundleUrl: undefined, + primary: true, + }); + const request: any = { registryConfig, ilcState: {}, host: 'apps.test' }; + request.router = new ServerRouter(logger, request, '/primary'); + + const warn = sinon.spy(); + const processResponse = sinon.spy(); + const requestFragmentWithLimit = requestFragmentSetup( + filterHeadersMock, + processResponse, + { warn, debug: () => {} }, + { + maxRequestSize: 200, + }, + ); + + await requestFragmentWithLimit(attributes.url, attributes, request); + + chai.expect(warn.calledOnce).to.be.equal(true); + chai.expect(warn.firstCall.args[0].hasClientBundle).to.be.equal(false); + chai.expect(processResponse.calledOnce).to.be.equal(true); + }); + + it('should name both the wrapper and the wrapped app when a wrapper is skipped', async () => { + // The url was built for wrapperConf.appId, so reporting only attributes.id + // leaves operators unable to tell which wrapper went over. + const registryConfig = getRegistryMock().getConfig(); + const attributes = getFragmentAttributes({ + id: 'wrapperApp__at__primary', + appProps: { page: 'wrapped' }, + wrapperConf: { + appId: 'wrapper__at__primary', + name: '@portal/wrapper', + src: 'http://apps.test/wrapper', + timeout: 2000, + props: { param1: 'value1' }, + spaBundleUrl: 'http://apps.test/wrapper-bundle.js', + }, + url: 'http://apps.test/wrappedApp', + primary: true, + }); + const request: any = { registryConfig, ilcState: {}, host: 'apps.test' }; + request.router = new ServerRouter(logger, request, '/wrapper'); + + const warn = sinon.spy(); + const requestFragmentWithLimit = requestFragmentSetup( + filterHeadersMock, + sinon.spy(), + { warn, debug: () => {} }, + { + maxRequestSize: 200, + }, + ); + + await requestFragmentWithLimit(attributes.url, attributes, request); + + const [payload] = warn.firstCall.args; + chai.expect(payload.appId).to.be.equal('wrapper__at__primary'); + chai.expect(payload.wrappedAppId).to.be.equal('wrapperApp__at__primary'); + }); + + it('should treat a wrapped slot as unfillable when only the wrapper has a client bundle', async () => { + // Client-side recovery combines the wrapped app with the wrapper's bundle, so + // a wrapper bundle alone cannot fill the slot of an SSR-only wrapped app: the + // suppression must report hasClientBundle false and answer 431 for a primary. + const registryConfig = getRegistryMock().getConfig(); + const attributes = getFragmentAttributes({ + id: 'wrapperApp__at__primary', + appProps: {}, + wrapperConf: { + appId: 'wrapper__at__primary', + name: '@portal/wrapper', + src: 'http://apps.test/wrapper', + timeout: 2000, + props: {}, + spaBundleUrl: 'http://apps.test/wrapper-bundle.js', + }, + url: 'http://apps.test/wrappedApp', + spaBundleUrl: undefined, + primary: true, + }); + const request: any = { registryConfig, ilcState: {}, host: 'apps.test' }; + request.router = new ServerRouter(logger, request, '/wrapper'); + + const warn = sinon.spy(); + const processResponse = sinon.spy(); + const requestFragmentWithLimit = requestFragmentSetup( + filterHeadersMock, + processResponse, + { warn, debug: () => {} }, + { maxRequestSize: 200 }, + ); + + await requestFragmentWithLimit(attributes.url, attributes, request); + + chai.expect(warn.firstCall.args[0].hasClientBundle).to.be.equal(false); + chai.expect(processResponse.firstCall.args[0].statusCode).to.be.equal(431); + }); + + it('should cancel the built request without raising an unhandled error', async () => { + // destroy() makes node emit ECONNRESET asynchronously; if the guard cancels + // before a listener is attached, that becomes an uncaught exception. + const { attributes, request } = buildRequest(); + const { requestFragmentWithLimit } = buildFragment(200); + + const unhandled: unknown[] = []; + const onUncaught = (error: unknown) => unhandled.push(error); + process.on('uncaughtException', onUncaught); + process.on('unhandledRejection', onUncaught); + + try { + await requestFragmentWithLimit(attributes.url, attributes, request); + await new Promise((resolve) => setTimeout(resolve, 150)); + } finally { + process.removeListener('uncaughtException', onUncaught); + process.removeListener('unhandledRejection', onUncaught); + } + + chai.expect(unhandled).to.deep.equal([]); + }); + + it('should measure a request whose expect header materialized the block at construction', async () => { + // Node builds `_header` inside the ClientRequest constructor when an `expect` + // header is present, and a second _implicitHeader() call throws + // ERR_HTTP_HEADERS_SENT — the guard must measure such a request, not crash. + const { attributes, request } = buildRequest(); + const warn = sinon.spy(); + const processResponse = sinon.spy(); + const expectForwardingFilter = () => ({ expect: '100-continue' }); + const requestFragmentWithExpect = requestFragmentSetup( + expectForwardingFilter, + processResponse, + { warn, debug: () => {} }, + { maxRequestSize: 200 }, + ); + + // No nock interceptor is registered: a dispatch would fail the test. + await requestFragmentWithExpect(attributes.url, attributes, request); + + chai.expect(warn.calledOnce).to.be.equal(true); + chai.expect(processResponse.calledOnce).to.be.equal(true); + }); + + it('should dispatch an over-limit request under the shipped default configuration', async () => { + // The guard is opt-in: shipped disabled so upgrading ILC does not change an existing + // deployment's behaviour. This same fixture IS suppressed at a limit of 200 (covered + // above), so dispatching here is what proves the shipped configuration leaves it off. + // Reading the value from config rather than a literal also fails if default.json5 and + // the module's fallback ever drift apart. + const shippedLimit = require('config').get('tailor.maxFragmentRequestSize'); + + chai.expect(shippedLimit, 'the guard must ship disabled').to.be.equal(0); + + const { attributes, request } = buildRequest(); + const warn = sinon.spy(); + const processResponse = sinon.spy(); + // No options object at all — this is the shipped path. + const requestFragmentShipped = requestFragmentSetup(filterHeadersMock, processResponse, { + warn, + debug: () => {}, + }); + const mockRequestScope = nock('http://apps.test').get('/primary').query(true).reply(200); + + await requestFragmentShipped(attributes.url, attributes, request); + + // Dispatched, and silent: the interceptor being consumed is the proof. + mockRequestScope.done(); + chai.expect(warn.called).to.be.equal(false); + chai.expect(processResponse.calledOnce).to.be.equal(true); + }); + + it('should keep an over-limit SSR-only wrapper on the degradation path', async () => { + // AC-003 names the wrapper case explicitly: no dispatch, WARN only, and no + // FragmentRequestError even though the wrapper has no client bundle. + const registryConfig = getRegistryMock().getConfig(); + const attributes = getFragmentAttributes({ + id: 'wrapperApp__at__primary', + appProps: {}, + wrapperConf: { + appId: 'wrapper__at__primary', + name: '@portal/wrapper', + src: 'http://apps.test/wrapper', + timeout: 2000, + props: {}, + }, + url: 'http://apps.test/wrappedApp', + spaBundleUrl: undefined, + primary: true, + }); + const request: any = { registryConfig, ilcState: {}, host: 'apps.test' }; + request.router = new ServerRouter(logger, request, '/wrapper'); + + const warn = sinon.spy(); + const processResponse = sinon.spy(); + const requestFragmentWithLimit = requestFragmentSetup( + filterHeadersMock, + processResponse, + { warn, debug: () => {} }, + { + maxRequestSize: 200, + }, + ); + + let rejected; + try { + await requestFragmentWithLimit(attributes.url, attributes, request); + } catch (error: any) { + rejected = error; + } + + chai.expect(rejected).to.be.equal(undefined); + chai.expect(warn.calledOnce).to.be.equal(true); + chai.expect(warn.firstCall.args[0].appId).to.be.equal('wrapper__at__primary'); + chai.expect(processResponse.calledOnce).to.be.equal(true); + }); + + const suppressedResponse = async (overrides: Record) => { + const registryConfig = getRegistryMock().getConfig(); + const attributes = getFragmentAttributes({ + id: 'primary__at__primary', + appProps: {}, + wrapperConf: null, + url: 'http://apps.test/primary', + ...overrides, + }); + const request: any = { registryConfig, ilcState: {}, host: 'apps.test' }; + request.router = new ServerRouter(logger, request, '/primary'); + + const warn = sinon.spy(); + const processResponse = sinon.spy(); + const run = requestFragmentSetup( + filterHeadersMock, + processResponse, + { warn, debug: () => {} }, + { + maxRequestSize: 200, + }, + ); + + let rejected; + try { + await run(attributes.url, attributes, request); + } catch (error: any) { + rejected = error; + } + + return { rejected, warn, response: processResponse.firstCall && processResponse.firstCall.args[0] }; + }; + + it('should answer 431 for a suppressed primary fragment that cannot render client-side', async () => { + // Q-001: with no client bundle the slot cannot be filled, so a 200 would report + // success for a page that has no main content. 431 stays under 500, which + // process-fragment-response passes through for a primary fragment, so the page + // reports the truth without entering the fragment ERROR path. + const { rejected, warn, response } = await suppressedResponse({ + spaBundleUrl: undefined, + primary: true, + }); + + chai.expect(rejected).to.be.equal(undefined); + chai.expect(response.statusCode).to.be.equal(431); + chai.expect(warn.calledOnce).to.be.equal(true); + }); + + it('should answer 200 for a suppressed primary fragment that can render client-side', async () => { + const { rejected, response } = await suppressedResponse({ + spaBundleUrl: 'http://apps.test/bundle.js', + primary: true, + }); + + chai.expect(rejected).to.be.equal(undefined); + chai.expect(response.statusCode).to.be.equal(200); + }); + + it('should answer 200 for a suppressed non-primary fragment without a bundle', async () => { + // A non-primary slot is not the page's main content, so a blank slot is the + // degradation AC-003 asks for and the page status must stay untouched. + const { rejected, response } = await suppressedResponse({ + spaBundleUrl: undefined, + primary: false, + }); + + chai.expect(rejected).to.be.equal(undefined); + chai.expect(response.statusCode).to.be.equal(200); + }); + + it('should still dispatch when the size is exactly at the limit', async () => { + const { attributes, request } = buildRequest(); + + // Learn the real size from a run that is guaranteed to be over the limit, + // then re-run with the limit set to exactly that size. The guard trips on + // "greater than", so this is the just-under-limit boundary. + const probe = buildFragment(1); + await probe.requestFragmentWithLimit(attributes.url, attributes, request); + const exactSize = probe.warn.firstCall.args[0].size; + + const mockRequestScope = nock('http://apps.test').get('/primary').query(true).reply(200); + const { warn, processResponse, requestFragmentWithLimit } = buildFragment(exactSize); + + await requestFragmentWithLimit(attributes.url, attributes, request); + + mockRequestScope.done(); + chai.expect(warn.called).to.be.equal(false); + chai.expect(processResponse.calledOnce).to.be.equal(true); + }); + + it('should dispatch fragment requests as GET with no body framing', async () => { + // The pre-flight's safety rests on this. `_implicitHeader()` freezes framing early: + // on a request that has a body it turns `Content-Length: n` into + // `Transfer-Encoding: chunked`, so measuring would change what goes on the wire. + // That mutation is inert only while fragment requests stay GET with no body. This + // test fails if makeRequest ever sends another method or a body. + const { server, port, chunks } = await new Promise<{ + server: net.Server; + port: number; + chunks: Buffer[]; + }>((resolve) => { + const received: Buffer[] = []; + const wireServer = net.createServer((socket) => { + socket.on('data', (chunk) => { + received.push(chunk); + socket.end('HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok'); + }); + }); + + wireServer.listen(0, '127.0.0.1', () => + resolve({ + server: wireServer, + port: (wireServer.address() as net.AddressInfo).port, + chunks: received, + }), + ); + }); + const { attributes, request } = buildRequest(); + // Guard switched off: this is about what gets dispatched, not about suppression. + const { requestFragmentWithLimit } = buildFragment(0); + + try { + await requestFragmentWithLimit(`http://127.0.0.1:${port}/primary`, attributes, request); + } finally { + server.close(); + } + + const block = Buffer.concat(chunks).toString('latin1'); + + chai.expect(block).to.match(/^GET /); + chai.expect(block).to.not.match(/^transfer-encoding:/im); + chai.expect(block).to.not.match(/^content-length:/im); + }); + + it('should warn and stay disabled when the configured value is not a usable type', async () => { + // null, false and [] all coerce to 0 through Number(). 0 is also the off switch, so + // without a type check ahead of the numeric one the guard would disable itself with + // no log line at all — which is the difference this asserts. + for (const unusable of [null, false, []]) { + const warn = sinon.spy(); + + requestFragmentSetup( + filterHeadersMock, + sinon.spy(), + { warn, debug: () => {} }, + { maxRequestSize: unusable }, + ); + + chai.expect(warn.calledOnce, `${JSON.stringify(unusable)} should be rejected`).to.be.equal(true); + chai.expect(warn.firstCall.args[0].limit).to.be.equal(0); + } + }); + + it('should cancel a suppressed request before any macrotask boundary', async () => { + // With an `expect` header node's constructor both renders the block and queues it + // for sending, so the request is already dispatch-committed by the time the guard + // measures it. Nothing reaches the fragment only because isOverSizeLimit -> + // cancelRequest runs in the same tick: cancelling this shape one macrotask later + // really does put it on the wire, and the WARN would then be reporting a dispatch + // that happened. Asserting the timing rather than "no bytes arrived" keeps this + // meaningful — with a cold connection pool no bytes arrive either way, so a + // byte-level assertion passes even if the guard becomes asynchronous. + const { attributes, request } = buildRequest(); + const warn = sinon.spy(); + + let macrotaskElapsed = false; + let cancelledBeforeMacrotask: boolean | null = null; + const originalRequest = http.request; + (http as any).request = (options: any, callback: any) => { + const fragmentRequest = originalRequest(options, callback); + setImmediate(() => { + macrotaskElapsed = true; + }); + + const originalDestroy = fragmentRequest.destroy.bind(fragmentRequest); + (fragmentRequest as any).destroy = (...args: any[]) => { + if (cancelledBeforeMacrotask === null) { + cancelledBeforeMacrotask = !macrotaskElapsed; + } + + return originalDestroy(...args); + }; + + return fragmentRequest; + }; + + try { + const requestFragmentWithExpect = requestFragmentSetup( + () => ({ expect: '100-continue' }), + sinon.spy(), + { warn, debug: () => {} }, + { maxRequestSize: 200 }, + ); + + await requestFragmentWithExpect(attributes.url, attributes, request); + } finally { + (http as any).request = originalRequest; + } + + // Without the first assertion this could pass because the guard never ran at all. + chai.expect(warn.calledOnce).to.be.equal(true); + chai.expect(cancelledBeforeMacrotask).to.be.equal(true); + }); + + it('should skip the guard with a WARN when the transport is not HTTP/1.1', async () => { + // An HTTP/2 stream has no getHeaders() and no _header, so there is no HTTP/1.1 head to + // measure. The guard must degrade to "switched off" rather than throw: it exists to + // prevent a 431 for a small fraction of requests, so it must never be the reason + // every fragment request fails. The limit here is small enough that a measurable + // request would be suppressed, so a dispatch is what proves the skip path ran. + const { attributes, request } = buildRequest(); + const warn = sinon.spy(); + const processResponse = sinon.spy(); + + const originalRequest = http.request; + (http as any).request = (options: any, callback: any) => { + const realRequest = originalRequest(options, callback); + const facade = { + getHeaders: undefined, + abort: (...args: any[]) => (realRequest as any).abort(...args), + destroy: (...args: any[]) => (realRequest as any).destroy(...args), + end: (...args: any[]) => (realRequest as any).end(...args), + setTimeout: (...args: any[]) => (realRequest as any).setTimeout(...args), + on: (...args: any[]) => { + (realRequest as any).on(...args); + + return facade; + }, + }; + + return facade; + }; + + const mockRequestScope = nock('http://apps.test').get('/primary').query(true).reply(200); + + try { + const requestFragmentWithLimit = requestFragmentSetup( + filterHeadersMock, + processResponse, + { warn, debug: () => {} }, + { maxRequestSize: 200 }, + ); + + await requestFragmentWithLimit(attributes.url, attributes, request); + } finally { + (http as any).request = originalRequest; + } + + mockRequestScope.done(); + chai.expect(warn.calledOnce).to.be.equal(true); + chai.expect(warn.firstCall.args[1]).to.contain('request head could not be determined'); + chai.expect(Object.keys(warn.firstCall.args[0])).to.not.include('size'); + chai.expect(processResponse.calledOnce).to.be.equal(true); + }); + + it('should not log any cookie value, even when cookie is the largest header', async () => { + // AC: no cookie values appear in the log line. The payload reports a header's name + // and byte count, never its value. Asserting that with a cookie big enough to BE the + // largest header is what makes this meaningful — it fails the moment diagnostics + // start carrying values, which is the plausible future regression. + const { attributes, request } = buildRequest(); + const secret = `SESSIONSECRET${'x'.repeat(900)}`; + const warn = sinon.spy(); + const requestFragmentWithCookie = requestFragmentSetup( + () => ({ cookie: `sess=${secret}`, 'user-agent': 'Mozilla/5.0' }), + sinon.spy(), + { warn, debug: () => {} }, + { maxRequestSize: 200 }, + ); + + await requestFragmentWithCookie(attributes.url, attributes, request); + + chai.expect(warn.calledOnce).to.be.equal(true); + + const payload = warn.firstCall.args[0]; + + // Without this the test could pass while proving nothing: the cookie has to be the + // header the guard singles out. + chai.expect(payload.largestHeader.name).to.be.equal('cookie'); + chai.expect(JSON.stringify(payload)).to.not.contain('SESSIONSECRET'); + }); + }); +}); diff --git a/ilc/server/tailor/request-fragment.ts b/ilc/server/tailor/request-fragment.ts new file mode 100644 index 00000000..f9b7719d --- /dev/null +++ b/ilc/server/tailor/request-fragment.ts @@ -0,0 +1,587 @@ +import http from 'node:http'; +import https from 'node:https'; +import { PassThrough } from 'node:stream'; +import { URL } from 'node:url'; +import Agent, { HttpsAgent } from 'agentkeepalive'; +import deepmerge from 'deepmerge'; +import type { ClientRequest, IncomingMessage, OutgoingHttpHeaders } from 'node:http'; + +import { appIdToNameAndSlot } from '../../common/utils'; +import { SdkOptions } from '../../common/SdkOptions'; +import { objectToBase64 } from '../objectToBase64'; +import { findLargestHeader, headFor, measureHeadTotal, truncateForLog } from './header-block'; +import type { OutgoingRequest } from './header-block'; +import type { ServerRouter, WrapperConf } from './server-router'; +import type { TransformedRegistryConfig } from '../types/Registry'; + +import errors from './errors'; + +type Logger = Pick; + +interface FragmentAttributes { + id: string; + url?: string; + primary?: boolean; + timeout?: number; + ignoreInvalidSsl?: boolean; + spaBundleUrl?: string; + appProps?: Record; + wrapperConf?: WrapperConf | null; + wrapperPropsOverride?: Record | null; + [key: string]: unknown; +} + +interface FragmentRequestContext { + router: ServerRouter; + /** Required, not optional: this module reads apps[].l10nManifest and cannot build a + * fragment url without it. Tailor always supplies it. */ + registryConfig: TransformedRegistryConfig; + host?: string; + id?: string; + headers?: http.IncomingHttpHeaders; +} + +/** A PassThrough dressed as a fragment response, for a request that was never sent. */ +interface SuppressedResponse extends PassThrough { + statusCode: number; + headers: OutgoingHttpHeaders; +} + +type FilterHeaders = ( + attributes: FragmentAttributes, + request: FragmentRequestContext, + extraHeaders?: string[], +) => Record; + +interface FragmentResponseContext { + request: FragmentRequestContext; + fragmentUrl: string; + fragmentAttributes: FragmentAttributes; + isWrapper?: boolean; +} + +type ProcessFragmentResponse = ( + response: IncomingMessage | SuppressedResponse, + context: FragmentResponseContext, +) => unknown; + +interface SizeGuardContext { + sizeLimit: number; + logger: Logger; + operationId?: string; + appId: string; + wrappedAppId?: string; + hasClientBundle: boolean; + path?: string; +} + +const NS_IN_SEC = 1e6; +const MS_IN_SEC = 1000; + +// Must stay below the fragments' own --max-http-header-size, which is node's default +// 16384 unless a fragment raises it. ILC itself runs at 30000 (see package.json), so +// without this ceiling it accepts inbound requests it cannot forward. +const MAX_LOGGED_PATH_LENGTH = 256; + +// A suppressed primary fragment with no client bundle has no way to produce its content +const SUPPRESSED_WITHOUT_CONTENT_STATUS = 431; +const SUPPRESSED_STATUS = 200; + +// By default tailor supports gzipped response from fragments +const requiredHeaders = { + 'accept-encoding': 'gzip, deflate', +}; + +const kaAgent = new Agent(); +const kaAgentHttps = new HttpsAgent(); + +/** + * Simple Request Promise Function that requests the fragment server with + * - filtered headers + * - Specified timeout from fragment attributes + * + * @param {filterHeaders} - Function that handles the header forwarding + * @param {processFragmentResponse} - Function that handles response processing + * @param {string} fragmentUrl - URL of the fragment server + * @param {Object} attributes - Attributes passed via fragment tags + * @param {Object} request - HTTP request stream + * @returns {Promise} Response from the fragment server + */ +export function requestFragmentFactory( + filterHeaders: FilterHeaders, + processFragmentResponse: ProcessFragmentResponse, + logger: Logger, + { maxRequestSize }: { maxRequestSize?: unknown } = {}, +) { + const sizeLimit = resolveSizeLimit(maxRequestSize, logger); + + return function requestFragment( + fragmentUrl: string, + attributes: FragmentAttributes, + request: FragmentRequestContext, + ): Promise { + return new Promise((resolve, reject) => { + const currRoute = request.router.getRoute(); + + if (attributes.wrapperConf) { + const wrapperConf = attributes.wrapperConf; + + if (!wrapperConf.src) { + // A wrapper declaring `ssr: {}` reaches here with no src. Without this the + // URL constructor below throws an opaque TypeError; naming the cause costs + // one branch and does not change which requests succeed. + reject( + new errors.FragmentRequestError({ + message: `No SSR url specified for app wrapper "${wrapperConf.appId}"`, + }), + ); + + return; + } + + const reqUrl = makeFragmentUrl({ + route: currRoute, + baseUrl: wrapperConf.src, + appId: wrapperConf.appId, + props: wrapperConf.props, + ignoreBasePath: true, + wrappedAppProps: attributes.appProps, + }); + + logger.debug( + { + url: currRoute.route, + id: request.id, + domain: request.host, + detailsJSON: JSON.stringify({ + attributes, + }), + }, + 'Request Fragment. Init processing for wrapper', + ); + + const wrapperHeaders = { + ...filterHeaders(attributes, request, request.registryConfig?.settings?.fragmentProxyHeaders), + ...requiredHeaders, + }; + + const fragmentRequest = makeRequest( + reqUrl, + wrapperHeaders, + wrapperConf.timeout, + attributes.ignoreInvalidSsl || wrapperConf.ignoreInvalidSsl, + ); + + const wrapperHasClientBundle = Boolean(wrapperConf.spaBundleUrl) && Boolean(attributes.spaBundleUrl); + + if ( + isOverSizeLimit(fragmentRequest, { + sizeLimit, + logger, + operationId: request.id, + // The url was built for the wrapper; attributes.id names the wrapped app. + appId: wrapperConf.appId, + wrappedAppId: attributes.id, + hasClientBundle: wrapperHasClientBundle, + path: currRoute.reqUrl, + }) + ) { + cancelRequest(fragmentRequest); + resolve( + processFragmentResponse( + emptyFragmentResponse(suppressedStatusFor(attributes, wrapperHasClientBundle)), + { + request, + fragmentUrl: reqUrl, + fragmentAttributes: attributes, + isWrapper: true, + }, + ), + ); + + return; + } + + fragmentRequest.on('response', (response) => { + try { + logger.debug( + { + url: currRoute.route, + id: request.id, + domain: request.host, + detailsJSON: JSON.stringify({ + statusCode: response.statusCode, + 'x-props-override': response.headers['x-props-override'], + }), + }, + 'Request Fragment. Wrapper Fragment Response', + ); + + // Wrapper says that we need to request wrapped application + if (response.statusCode === 210) { + logger.debug( + { url: currRoute.route, operationId: request.id }, + 'Request Fragment. Wrapper Fragment Response. ForwardRequest', + ); + const propsOverride = response.headers['x-props-override']; + attributes.wrapperPropsOverride = {}; + if (typeof propsOverride === 'string') { + const props = JSON.parse(Buffer.from(propsOverride, 'base64').toString('utf8')); + attributes.appProps = deepmerge(attributes.appProps ?? {}, props); + attributes.wrapperPropsOverride = props; + } + attributes.wrapperConf = null; + + logger.debug( + { + url: currRoute.route, + id: request.id, + domain: request.host, + detailsJSON: JSON.stringify({ + attributes, + }), + }, + 'Request Fragment. Wrapper Fragment Processing. Attribute overriding', + ); + + resolve(requestFragment(fragmentUrl, attributes, request)); + + return; + } + + logger.debug( + { url: currRoute.route, operationId: request.id }, + 'Request Fragment. Wrapper Fragment Response. Using App Wrapper.', + ); + + resolve( + processFragmentResponse(response, { + request, + // A special route (404 and friends) has no `route` pattern, so + // this is undefined for those and always has been. The cast + // preserves the pre-TypeScript behaviour; the downstream + // JSDoc declaring it required is a separate defect. + fragmentUrl: currRoute.route as string, + fragmentAttributes: attributes, + isWrapper: true, + }), + ); + } catch (e) { + logger.debug( + { + url: currRoute.route, + id: request.id, + domain: request.host, + }, + 'Request Fragment. Wrapper Fragment Processing. Fragment Response Processing Error', + ); + reject(e); + } + }); + fragmentRequest.on('error', (error) => { + logger.debug( + { + url: currRoute.route, + id: request.id, + domain: request.host, + }, + 'Request Fragment. Wrapper Fragment Processing. Fragment Request Error', + ); + reject( + new errors.FragmentRequestError({ + message: `Error during SSR request to fragment wrapper at URL: ${fragmentUrl}`, + cause: error, + }), + ); + }); + fragmentRequest.end(); + } else { + const { appName } = appIdToNameAndSlot(attributes.id); + + const sdkOptions = new SdkOptions({ + i18n: { + manifestPath: request.registryConfig['apps'][appName].l10nManifest, + }, + }); + + const reqUrl = makeFragmentUrl({ + route: currRoute, + baseUrl: fragmentUrl, + appId: attributes.id, + props: attributes.appProps, + sdkOptions: sdkOptions.toJSON(), + }); + + logger.debug( + { + url: currRoute.route, + id: request.id, + domain: request.host, + detailsJSON: JSON.stringify({ + route: currRoute, + baseUrl: fragmentUrl, + appId: attributes.id, + props: attributes.appProps, + }), + }, + 'Request Fragment. Fragment Processing.', + ); + + const fragmentHeaders = { + ...filterHeaders(attributes, request, request.registryConfig?.settings?.fragmentProxyHeaders), + ...requiredHeaders, + }; + + const startTime = process.hrtime(); + const fragmentRequest = makeRequest( + reqUrl, + fragmentHeaders, + attributes.timeout, + attributes.ignoreInvalidSsl, + ); + + const hasClientBundle = Boolean(attributes.spaBundleUrl); + + if ( + isOverSizeLimit(fragmentRequest, { + sizeLimit, + logger, + operationId: request.id, + appId: attributes.id, + hasClientBundle, + path: currRoute.reqUrl, + }) + ) { + cancelRequest(fragmentRequest); + resolve( + processFragmentResponse( + emptyFragmentResponse(suppressedStatusFor(attributes, hasClientBundle)), + { + request, + fragmentUrl: reqUrl, + fragmentAttributes: attributes, + }, + ), + ); + + return; + } + + fragmentRequest.on('response', (response) => { + try { + resolve( + processFragmentResponse(response, { + request, + fragmentUrl: reqUrl, + fragmentAttributes: attributes, + }), + ); + logger.debug( + { url: currRoute.route, id: request.id, domain: request.host }, + 'Fragment Processing. Finished', + ); + } catch (e) { + reject(e); + } + }); + fragmentRequest.on('timeout', () => { + const endTime = process.hrtime(startTime); + reject( + new errors.FragmentRequestError({ + message: `Error during SSR request to fragment at URL: ${fragmentUrl} due to timeout after ${ + endTime[0] * MS_IN_SEC + endTime[1] / NS_IN_SEC + }ms`, + }), + ); + }); + fragmentRequest.on('error', (error) => { + reject( + new errors.FragmentRequestError({ + message: `Error during SSR request to fragment at URL: ${fragmentUrl}`, + cause: error, + }), + ); + }); + fragmentRequest.end(); + } + }); + }; +} + +function isOverSizeLimit( + fragmentRequest: ClientRequest, + { sizeLimit, logger, operationId, appId, wrappedAppId, hasClientBundle, path }: SizeGuardContext, +): boolean { + if (sizeLimit === 0) { + return false; + } + + const head = headFor(fragmentRequest); + + if (head === null) { + logger.warn( + { operationId, appId, wrappedAppId, path: truncateForLog(path ?? '', MAX_LOGGED_PATH_LENGTH) }, + 'Request Fragment. Size guard skipped, request head could not be determined', + ); + + // Behave exactly as if the guard were switched off. It exists to prevent a 431 for a + // small fraction of requests, so it must never become the reason every fragment fails. + return false; + } + + const total = measureHeadTotal(head); + + if (total <= sizeLimit) { + // The overwhelmingly common path: one byte count, no per-line scan. + return false; + } + + logger.warn( + { + operationId, + appId, + wrappedAppId, + size: total, + limit: sizeLimit, + largestHeader: findLargestHeader(head), + hasClientBundle, + path: truncateForLog(path ?? '', MAX_LOGGED_PATH_LENGTH), + }, + 'Request Fragment. Skipped dispatch, computed request size exceeds the limit', + ); + + return true; +} + +// A limit of 0 switches the guard off, so it is also what an unconfigured or unusable value +// resolves to: the guard is opt-in and must not inherit a ceiling ILC invented. +function resolveSizeLimit(maxRequestSize: unknown, logger: Logger): number { + if (maxRequestSize === undefined) { + return 0; + } + + const isUsableType = + typeof maxRequestSize === 'number' || (typeof maxRequestSize === 'string' && maxRequestSize.trim() !== ''); + const parsedLimit = Number(maxRequestSize); + + if (!isUsableType || !Number.isFinite(parsedLimit) || parsedLimit < 0) { + logger.warn( + { + maxRequestSize: truncateForLog(String(maxRequestSize), MAX_LOGGED_PATH_LENGTH), + limit: 0, + }, + 'Request Fragment. Configured max request size is not usable, size guard stays disabled', + ); + + return 0; + } + + return parsedLimit; +} + +function cancelRequest(fragmentRequest: ClientRequest): void { + fragmentRequest.on('error', () => {}); + fragmentRequest.destroy(); +} + +/** + * The status a suppressed fragment answers with. Only a primary fragment that cannot render + * client-side changes it: its slot stays blank, and the page must say so. + */ +function suppressedStatusFor(attributes: FragmentAttributes, hasClientBundle: boolean): number { + return !hasClientBundle && attributes.primary ? SUPPRESSED_WITHOUT_CONTENT_STATUS : SUPPRESSED_STATUS; +} + +/** + * Stands in for a fragment response that was never requested. Resolving with this keeps + * the failure out of the tailor error handlers — which would report it as an ERROR — and + * lets the fragment degrade to a client-side render. + */ +function emptyFragmentResponse(statusCode: number): SuppressedResponse { + const response = new PassThrough() as SuppressedResponse; + + response.statusCode = statusCode; + response.headers = {}; + response.end(); + + return response; +} + +interface FragmentUrlParts { + route: { basePath?: string; reqUrl?: string }; + baseUrl: string; + appId: string; + props?: Record; + ignoreBasePath?: boolean; + sdkOptions?: Record; + wrappedAppProps?: Record; +} + +function makeFragmentUrl({ + route, + baseUrl, + appId, + props, + ignoreBasePath = false, + sdkOptions, + wrappedAppProps, +}: FragmentUrlParts): string { + const url = new URL(baseUrl); + + const reqProps = { + basePath: ignoreBasePath ? '/' : route.basePath, + reqUrl: route.reqUrl, + fragmentName: appId, + }; + + url.searchParams.append('routerProps', objectToBase64(reqProps)); + + if (props) { + url.searchParams.append('appProps', objectToBase64(props)); + } + + if (sdkOptions) { + url.searchParams.append('sdk', objectToBase64(sdkOptions)); + } + + if (wrappedAppProps) { + url.searchParams.append('wrappedProps', objectToBase64(wrappedAppProps)); + } + + return url.toString(); +} + +function makeRequest( + reqUrl: string, + headers: Record, + timeout?: number, + ignoreInvalidSsl = false, +): ClientRequest { + const url = new URL(reqUrl); + const { hostname, port, pathname, search, username, password, protocol } = url; + const options: https.RequestOptions = { + headers, + timeout, + auth: username && password ? `${username}:${password}` : undefined, + host: hostname, // the difference between "host" and "hostname" is that "host" includes port + port, + path: pathname + search, + protocol, + }; + + const hasHttpsProtocol = protocol === 'https:'; + const httpLib = hasHttpsProtocol ? https : http; + options.agent = hasHttpsProtocol ? kaAgentHttps : kaAgent; + + if (hasHttpsProtocol && ignoreInvalidSsl) { + options.rejectUnauthorized = false; + } + + const fragmentRequest = httpLib.request(options); + + if (timeout) { + fragmentRequest.setTimeout(timeout, fragmentRequest.abort); + } + + return fragmentRequest; +} diff --git a/ilc/server/tailor/server-router.js b/ilc/server/tailor/server-router.js deleted file mode 100644 index d8e9115e..00000000 --- a/ilc/server/tailor/server-router.js +++ /dev/null @@ -1,182 +0,0 @@ -const _ = require('lodash'); -const deepmerge = require('deepmerge'); - -const { RouterError } = require('../../common/router/errors'); -const { Router } = require('../../common/router/Router'); -const { makeAppId } = require('../../common/utils'); - -module.exports = class ServerRouter { - /** @type Console */ - #logger; - /** @type http.IncomingMessage */ - #request; - #registryConfig; - /** @type string */ - #url; - #router = null; - - /** - * @param {Logger} logger - * @param {http.IncomingMessage} request - * @param {string} url - */ - constructor(logger, request, url) { - this.#logger = logger; - this.#request = request; - this.#registryConfig = request.registryConfig; - this.#url = url; - } - - getFragmentsTpl() { - const route = this.getRoute(); - - const fragmentsTpl = _.reduce( - this.#getSsrSlotsList(route.slots, this.#registryConfig.apps), - (res, row) => { - return res + ``; - }, - '', - ); - - this.#logger.debug( - { - detailsJSON: JSON.stringify({ - fragmentsTpl, - }), - }, - 'getFragmentsTpl', - ); - - return fragmentsTpl; - } - - getFragmentsContext() { - const route = this.getRoute(); - const apps = this.#registryConfig.apps; - let primarySlotDetected = false; - - const fragmentsContext = _.reduce( - this.#getSsrSlotsList(route.slots, apps), - (res, row) => { - const appId = row.appId; - const appInfo = row.appInfo; - - const ssrOpts = _.pick(row.appInfo.ssr, ['src', 'timeout', 'ignoreInvalidSsl']); - if (!ssrOpts.src || typeof ssrOpts.src !== 'string') { - throw new RouterError({ message: 'No url specified for fragment!', data: { appInfo } }); - } - - if (ssrOpts.ignoreInvalidSsl === true) { - ssrOpts['ignore-invalid-ssl'] = true; - } - delete ssrOpts.ignoreInvalidSsl; - - const fragmentKind = row.kind || appInfo.kind; - if (fragmentKind === 'primary' && primarySlotDetected === false) { - ssrOpts.primary = true; - primarySlotDetected = true; - } else { - if (fragmentKind === 'primary') { - this.#logger.warn( - `More then one primary slot "${row.name}" found for "${this.#url}".\n` + - 'Make it regular to avoid unexpected behaviour.', - ); - } - } - - const ilcState = this.#getIlcState(); - // Nest experiments inside an `appProps` sub-field — that's where a client - // consumer reads user-app props from `requestData.getCurrentPathProps().appProps`. - // The outer object also carries `appConfig` (registry-defined infra config) as a sibling. - const experimentsProps = ilcState.experiments - ? { appProps: { experiments: ilcState.experiments } } - : {}; - ssrOpts.appProps = deepmerge.all([ - appInfo.props || {}, - appInfo.ssrProps || {}, - row.props || {}, - experimentsProps, - ]); - ssrOpts.wrapperConf = row.wrapperConf; - ssrOpts.spaBundleUrl = appInfo.spaBundle; - - res[appId] = ssrOpts; - - return res; - }, - {}, - ); - - this.#logger.debug( - { - detailsJSON: JSON.stringify({ - fragmentsContext, - }), - }, - 'getFragmentsContext', - ); - - return fragmentsContext; - } - - getRoute() { - if (this.#router === null) { - this.#router = new Router(this.#registryConfig); - } - - const ilcState = this.#getIlcState(); - - if (ilcState.forceSpecialRoute) { - return this.#router.matchSpecial(this.#url, ilcState.forceSpecialRoute); - } else { - return this.#router.match(this.#url); - } - } - - #getSsrSlotsList = (routeSlots, apps) => - _.reduce( - routeSlots, - (res, slotData, slotName) => { - let appName = slotData.appName; - const appId = makeAppId(appName, slotName); - const appInfo = apps[appName]; - - if (appInfo === undefined) { - throw new RouterError({ message: "Can't find info about app.", data: { appName } }); - } - if (appInfo.ssr === undefined) { - return res; - } - - let wrapperConf = null; - if (appInfo.wrappedWith) { - const wrapper = apps[appInfo.wrappedWith]; - - if (wrapper.ssr === undefined) { - // If wrapper doesn't support SSR - it will be disabled for all wrapped apps - return res; - } - - wrapperConf = { - appId: makeAppId(appInfo.wrappedWith, slotName), - name: appInfo.wrappedWith, - ...wrapper.ssr, - props: wrapper.props, - }; - } - - res.push({ - name: slotName, - ...slotData, - appId, - appInfo, - wrapperConf, - }); - - return res; - }, - [], - ); - - #getIlcState = () => this.#request.ilcState || {}; -}; diff --git a/ilc/server/tailor/server-router.spec.js b/ilc/server/tailor/server-router.spec.ts similarity index 91% rename from ilc/server/tailor/server-router.spec.js rename to ilc/server/tailor/server-router.spec.ts index 709041ec..a8c2d11e 100644 --- a/ilc/server/tailor/server-router.spec.js +++ b/ilc/server/tailor/server-router.spec.ts @@ -1,9 +1,8 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const _ = require('lodash'); -const { getRegistryMock } = require('../../tests/helpers'); +import chai from 'chai'; +import sinon from 'sinon'; -const ServerRouter = require('./server-router.js'); +import { getRegistryMock } from '../../tests/helpers'; +import { ServerRouter } from './server-router'; describe('server router', () => { const logger = { @@ -180,6 +179,18 @@ describe('server router', () => { }, }; + // Named so the fixture and the expectation share one source of truth, rather than the + // expectation reaching back into the fixture by index. + const footerSlotProps = { + firstFooterSlotProp: 'firstFooterSlotProp', + secondFooterSlotProp: 'secondFooterSlotProp', + }; + const contactSlotProps = { + contactFirstProp: 'changedContactFirstProp', + firstContactSlotProp: 'firstContactSlotProp', + secondContactSlotProp: 'secondContactSlotProp', + }; + const routes = [ { route: '*', @@ -192,10 +203,7 @@ describe('server router', () => { }, footer: { appName: '@portal/footer', - props: { - firstFooterSlotProp: 'firstFooterSlotProp', - secondFooterSlotProp: 'secondFooterSlotProp', - }, + props: footerSlotProps, kind: 'primary', }, }, @@ -214,11 +222,7 @@ describe('server router', () => { }, contact: { appName: 'contact', - props: { - contactFirstProp: 'changedContactFirstProp', - firstContactSlotProp: 'firstContactSlotProp', - secondContactSlotProp: 'secondContactSlotProp', - }, + props: contactSlotProps, }, }, meta: { @@ -291,28 +295,21 @@ describe('server router', () => { navbar__at__navbar: { ...apps['@portal/navbar'].ssr, spaBundleUrl: apps['@portal/navbar'].spaBundle, - appProps: { - ...apps['@portal/navbar'].props, - ...routes[0].slots.navbar.props, - }, + // The navbar slot declares no props of its own, so only the app's apply. + appProps: { ...apps['@portal/navbar'].props }, wrapperConf: null, }, footer__at__footer: { ...apps['@portal/footer'].ssr, spaBundleUrl: apps['@portal/footer'].spaBundle, primary: true, - appProps: { - ...routes[0].slots.footer.props, - }, + appProps: { ...footerSlotProps }, wrapperConf: null, }, contact__at__contact: { ...apps.contact.ssr, spaBundleUrl: apps.contact.spaBundle, - appProps: { - ...apps.contact.props, - ...routes[1].slots.contact.props, - }, + appProps: { ...apps.contact.props, ...contactSlotProps }, wrapperConf: null, }, apps__at__apps: { @@ -326,6 +323,7 @@ describe('server router', () => { name: '@portal/news', ...apps['@portal/news'].ssr, props: apps['@portal/news'].props, + spaBundleUrl: apps['@portal/news'].spaBundle, }, }, }); @@ -362,7 +360,7 @@ describe('server router', () => { }).getConfig(); const request = { - ilcState: { forceSpecialRoute: 404 }, + ilcState: { forceSpecialRoute: '404' }, url: '/all?prop=value', registryConfig, }; diff --git a/ilc/server/tailor/server-router.ts b/ilc/server/tailor/server-router.ts new file mode 100644 index 00000000..885d87c7 --- /dev/null +++ b/ilc/server/tailor/server-router.ts @@ -0,0 +1,229 @@ +import deepmerge from 'deepmerge'; + +import { RouterError } from '../../common/router/errors'; +import { Router } from '../../common/router/Router'; +import { makeAppId } from '../../common/utils'; +import type { Slot } from '../../common/types/Router'; +import type { App } from '../types/RegistryConfig'; +import type { TransformedRegistryConfig } from '../types/Registry'; +import type { IlcState, PatchedHttpRequest } from '../types/PatchedHttpRequest'; + +/** + * The wrapper half of a slot's SSR context. Exported because request-fragment consumes it: one + * definition, so producer and consumer cannot drift. + * + * `src` is optional on purpose — it is spread from `App['ssr']`, where it is optional, and a + * wrapper declaring `ssr: {}` reaches here. The consumer is responsible for rejecting that. + */ +export interface WrapperConf { + appId: string; + name: string; + props?: Record; + /** + * A wrapped slot is filled client-side by loading BOTH bundles and combining them + * (registerSpaApps -> wrapper.wrapWith), so the size guard needs the wrapper's URL + * alongside the wrapped app's. spaBundle sits outside `ssr`, so the spread does not + * carry it and it is copied across explicitly. + */ + spaBundleUrl?: string; + src?: string; + timeout?: number; + ignoreInvalidSsl?: boolean; +} + +interface SsrSlot extends Slot { + name: string; + appId: string; + appInfo: App; + wrapperConf: WrapperConf | null; +} + +/** + * The per-fragment context tailor receives from this router. It is NOT the same shape + * request-fragment sees: the pipeline is + * + * FragmentContext (here) -> attributes on the tag -> camel-cased by tailor + * -> FragmentAttributes (request-fragment.ts) + * + * Two consequences that look like bugs if you read either file alone. `ignore-invalid-ssl` is + * hyphenated here and read as `ignoreInvalidSsl` there, because tailor camel-cases tag + * attributes in between. And `id` never appears here — it reaches the consumer from the + * `id="..."` attribute that getFragmentsTpl() writes into the template. + */ +interface FragmentContext { + src?: string; + timeout?: number; + 'ignore-invalid-ssl'?: boolean; + primary?: boolean; + appProps?: Record; + wrapperConf: WrapperConf | null; + spaBundleUrl?: string; +} + +type Logger = Pick; + +/** + * Only the two fields this router reads off the request. Declaring that rather than the whole + * PatchedHttpRequest keeps the dependency honest, and lets callers — tests included — pass + * exactly what it needs. + */ +interface RouterRequest { + registryConfig?: TransformedRegistryConfig; + ilcState?: IlcState; +} + +export class ServerRouter { + private logger: Logger; + private request: RouterRequest; + private registryConfig?: TransformedRegistryConfig; + private url: string; + private router: Router | null = null; + + constructor(logger: Logger, request: RouterRequest, url: string) { + this.logger = logger; + this.request = request; + this.registryConfig = request.registryConfig; + this.url = url; + } + + getFragmentsTpl(): string { + const route = this.getRoute(); + + const fragmentsTpl = this.getSsrSlotsList(route.slots, this.apps()).reduce( + (res, row) => res + ``, + '', + ); + + this.logger.debug({ detailsJSON: JSON.stringify({ fragmentsTpl }) }, 'getFragmentsTpl'); + + return fragmentsTpl; + } + + getFragmentsContext(): Record { + const route = this.getRoute(); + const apps = this.apps(); + let primarySlotDetected = false; + + const fragmentsContext = this.getSsrSlotsList(route.slots, apps).reduce>( + (res, row) => { + const { appId, appInfo } = row; + + const ssr = appInfo.ssr; + + if (!ssr?.src || typeof ssr.src !== 'string') { + throw new RouterError({ message: 'No url specified for fragment!', data: { appInfo } }); + } + + const fragmentContext: FragmentContext = { + src: ssr.src, + timeout: ssr.timeout, + wrapperConf: row.wrapperConf, + }; + + if (ssr.ignoreInvalidSsl === true) { + fragmentContext['ignore-invalid-ssl'] = true; + } + + const fragmentKind = row.kind || appInfo.kind; + + if (fragmentKind === 'primary' && primarySlotDetected === false) { + fragmentContext.primary = true; + primarySlotDetected = true; + } else if (fragmentKind === 'primary') { + this.logger.warn( + `More then one primary slot "${row.name}" found for "${this.url}".\n` + + 'Make it regular to avoid unexpected behaviour.', + ); + } + + const ilcState = this.getIlcState(); + // Nest experiments inside an `appProps` sub-field — that's where a client + // consumer reads user-app props from `requestData.getCurrentPathProps().appProps`. + // The outer object also carries `appConfig` (registry-defined infra config) as a sibling. + const experimentsProps = ilcState.experiments + ? { appProps: { experiments: ilcState.experiments } } + : {}; + + fragmentContext.appProps = deepmerge.all([ + appInfo.props || {}, + appInfo.ssrProps || {}, + row.props || {}, + experimentsProps, + ]) as Record; + fragmentContext.spaBundleUrl = appInfo.spaBundle; + + res[appId] = fragmentContext; + + return res; + }, + {}, + ); + + this.logger.debug({ detailsJSON: JSON.stringify({ fragmentsContext }) }, 'getFragmentsContext'); + + return fragmentsContext; + } + + getRoute() { + if (this.router === null) { + if (!this.registryConfig) { + // Router destructures routes straight away, so this threw a TypeError before. + // Saying which precondition failed is strictly more useful. + throw new RouterError({ message: 'Registry config is required to match a route' }); + } + + this.router = new Router(this.registryConfig); + } + + const ilcState = this.getIlcState(); + + // IlcState types forceSpecialRoute as a string, but Router.matchSpecial takes a numeric + // route id and callers set 404 as a number. Converting here keeps both honest. + return ilcState.forceSpecialRoute + ? this.router.matchSpecial(this.url, Number(ilcState.forceSpecialRoute)) + : this.router.match(this.url); + } + + private apps(): Record { + return this.registryConfig?.apps ?? {}; + } + + private getSsrSlotsList = (routeSlots: Record | undefined, apps: Record): SsrSlot[] => + Object.entries(routeSlots ?? {}).reduce((res, [slotName, slotData]) => { + const appName = slotData.appName; + const appId = makeAppId(appName, slotName); + const appInfo = apps[appName]; + + if (appInfo === undefined) { + throw new RouterError({ message: "Can't find info about app.", data: { appName } }); + } + if (appInfo.ssr === undefined) { + return res; + } + + let wrapperConf: WrapperConf | null = null; + + if (appInfo.wrappedWith) { + const wrapper = apps[appInfo.wrappedWith]; + + if (wrapper.ssr === undefined) { + // If wrapper doesn't support SSR - it will be disabled for all wrapped apps + return res; + } + + wrapperConf = { + appId: makeAppId(appInfo.wrappedWith, slotName), + name: appInfo.wrappedWith, + ...wrapper.ssr, + props: wrapper.props, + spaBundleUrl: wrapper.spaBundle, + }; + } + + res.push({ name: slotName, ...slotData, appId, appInfo, wrapperConf }); + + return res; + }, []); + + private getIlcState = (): IlcState => this.request.ilcState || {}; +} diff --git a/ilc/server/tailor/tailorx.ts b/ilc/server/tailor/tailorx.ts new file mode 100644 index 00000000..e0bb9064 --- /dev/null +++ b/ilc/server/tailor/tailorx.ts @@ -0,0 +1,71 @@ +import UntypedTailor from '@namecheap/tailorx'; + +/** + * The typed seam for @namecheap/tailorx@8.2.1. + * + * The index.d.ts published with the package still describes upstream zalando/tailor's + * constructor: it omits eight options the fork consumes (processFragmentResponse, the + * filterHeaders alias, fragmentHooks, botsGuardEnabled, getAssetsToPreload, + * baseTemplatesCacheSize, shouldSetPrimaryFragmentAssetsToPreload, fetchContext's real + * shape), documents three it no longer reads (amdLoaderUrl, pipeInstanceName, + * pipeAttributes), and mistypes requestFragment (`url: Url`, `Promise`) + * and filterResponseHeaders (its second argument is a headers object, not a ServerResponse). + * + * TailorOptions is derived from the package source (index.js constructor, + * lib/request-handler.js, lib/fragment.js, lib/request-fragment.js) and pins what the + * contract actually fixes: option names, callback arity and return types, scalar option + * types. Callback request/attributes parameters are `any` on purpose — tailorx passes + * them through untouched, and each handler declares the precise slice it consumes at its + * own definition (e.g. request-fragment.ts's FragmentRequestContext). + * + * The lasting fix is correcting index.d.ts upstream in namecheap/tailorx; when that + * ships, reduce this file to a plain re-export. + */ +export interface TailorOptions { + /** lib/request-handler.js — called once per request with the live (ILC-patched) IncomingMessage; default resolves {} */ + fetchContext?: (request: any) => Promise; + /** lib/request-handler.js — called as fetchTemplate(request, parseTemplate); default serves from templatesPath */ + fetchTemplate?: (request: any, parseTemplate: any) => Promise; + /** index.js:17 — canonical name for the request-header filter */ + filterRequestHeaders?: (attributes: any, request: any) => object; + /** index.js:17 — accepted alias for filterRequestHeaders */ + filterHeaders?: (attributes: any, request: any) => object; + /** lib/request-fragment.js:74 — called as processFragmentResponse(response, { request, fragmentUrl, fragmentAttributes }) */ + processFragmentResponse?: (response: any, context: any) => unknown; + /** lib/fragment.js:157 — called as requestFragment(url, attributes, request, span) */ + requestFragment?: (url: string, attributes: any, request: any, span?: unknown) => Promise; + /** lib/request-handler.js:146 — second argument is the fragment's response-headers object */ + filterResponseHeaders?: (attributes: any, headers: any) => object; + /** index.js — default 'fragment' */ + fragmentTag?: string; + /** lib/parse-template.js */ + handledTags?: string[]; + /** lib/process-template.js — serializes custom tags */ + handleTag?: (request: any, tag: any, options: any, context: any) => unknown; + /** index.js — clamped to >= 1, default 1 */ + maxAssetLinks?: number; + /** lib/fetch-template.js — default path.join(process.cwd(), 'templates') */ + templatesPath?: string; + /** lib/tracing.js — opentracing-compliant tracer */ + tracer?: unknown; + /** lib/parse-template.js — default 0 */ + baseTemplatesCacheSize?: number; + /** lib/request-handler.js — default false */ + botsGuardEnabled?: boolean; + /** lib/fragment.js:254,282 — insertStart/insertEnd(stream, attributes, headers, index); default {} */ + fragmentHooks?: { + insertStart?: (stream: any, attributes: any, headers: any, index: any) => void; + insertEnd?: (stream: any, attributes: any, headers: any, index: any) => void; + }; + /** lib/request-handler.js:155 — read as configAssets.styleRefs || [], so members may be omitted */ + getAssetsToPreload?: (request: any) => Promise<{ styleRefs?: string[]; scriptRefs?: string[] }>; + /** lib/request-handler.js:162 — default true */ + shouldSetPrimaryFragmentAssetsToPreload?: boolean; +} + +/** + * The one deliberate assertion in the tailorx integration: the shipped constructor type + * is wrong (see above), so the source-verified one is asserted here — once — and every + * construction site gets a checked options object. + */ +export const Tailor = UntypedTailor as unknown as new (options?: TailorOptions) => InstanceType; diff --git a/ilc/server/types/PatchedHttpRequest.ts b/ilc/server/types/PatchedHttpRequest.ts index fe254d09..4bd4a3e6 100644 --- a/ilc/server/types/PatchedHttpRequest.ts +++ b/ilc/server/types/PatchedHttpRequest.ts @@ -1,5 +1,5 @@ import type { IncomingMessage, Server } from 'http'; -import ServerRouter from '../tailor/server-router'; +import { ServerRouter } from '../tailor/server-router'; import { TransformedRegistryConfig } from './Registry'; import { FastifyRequest, RouteGenericInterface } from 'fastify'; diff --git a/ilc/server/types/RegistryConfig.ts b/ilc/server/types/RegistryConfig.ts index bc86d42b..05234185 100644 --- a/ilc/server/types/RegistryConfig.ts +++ b/ilc/server/types/RegistryConfig.ts @@ -10,6 +10,8 @@ export type App = { ssr?: { timeout?: number; src?: string; + /** Read by server-router when building a fragment's context, and set by registry data. */ + ignoreInvalidSsl?: boolean; }; props?: Record; ssrProps?: Record;