diff --git a/package.json b/package.json index 573f6d5206..929a4a1b56 100644 --- a/package.json +++ b/package.json @@ -116,6 +116,7 @@ "unstorage": "^1.8.0" }, "devDependencies": { + "@azure/functions": "^3.5.1", "@cloudflare/workers-types": "^4.20230710.1", "@types/aws-lambda": "^8.10.119", "@types/etag": "^1.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f9089deb1..d9819a4eda 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -205,6 +205,9 @@ importers: specifier: ^1.8.0 version: 1.8.0 devDependencies: + '@azure/functions': + specifier: ^3.5.1 + version: 3.5.1 '@cloudflare/workers-types': specifier: ^4.20230710.1 version: 4.20230710.1 @@ -341,6 +344,14 @@ packages: '@jridgewell/trace-mapping': 0.3.18 dev: true + /@azure/functions@3.5.1: + resolution: {integrity: sha512-6UltvJiuVpvHSwLcK/Zc6NfUwlkDLOFFx97BHCJzlWNsfiWwzwmTsxJXg4kE/LemKTHxPpfoPE+kOJ8hAdiKFQ==} + dependencies: + iconv-lite: 0.6.3 + long: 4.0.0 + uuid: 8.3.2 + dev: true + /@babel/code-frame@7.22.5: resolution: {integrity: sha512-Xmwn266vad+6DAqEB2A6V/CcZVp62BbwVmcOJc2RPuwih1kw02TjQvWVWlcKGbBPd+8/0V5DEkOcizRGYsspYQ==} engines: {node: '>=6.9.0'} @@ -3546,6 +3557,13 @@ packages: engines: {node: '>=14.18.0'} dev: true + /iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + dependencies: + safer-buffer: 2.1.2 + dev: true + /ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} dev: false @@ -4005,6 +4023,10 @@ packages: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} dev: true + /long@4.0.0: + resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==} + dev: true + /loupe@2.3.6: resolution: {integrity: sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==} dependencies: @@ -4849,6 +4871,10 @@ packages: regexp-tree: 0.1.27 dev: true + /safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + dev: true + /scule@1.0.0: resolution: {integrity: sha512-4AsO/FrViE/iDNEPaAQlb77tf0csuq27EsVpy6ett584EcRTp6pTDLoGWVxCD77y5iU5FauOvhsI4o1APwPoSQ==} @@ -5530,6 +5556,11 @@ packages: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: false + /uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + dev: true + /v8-to-istanbul@9.1.0: resolution: {integrity: sha512-6z3GW9x8G1gd+JIIgQQQxXuiJtCXeAjp6RaPEPLv62mH3iPHPxV6W3robxtCzNErRo6ZwTmzWhsbNvjyEBKzKA==} engines: {node: '>=10.12.0'} diff --git a/src/runtime/app.ts b/src/runtime/app.ts index f165d0859e..dd730c761b 100644 --- a/src/runtime/app.ts +++ b/src/runtime/app.ts @@ -18,6 +18,7 @@ import { createHooks, Hookable } from "hookable"; import type { NitroRuntimeHooks } from "./types"; import { useRuntimeConfig } from "./config"; import { cachedEventHandler } from "./cache"; +import { normalizeFetchResponse } from "./utils"; import { createRouteRulesHandler, getRouteRulesForPath } from "./route-rules"; import type { $Fetch, NitroFetchRequest } from "nitropack"; import { plugins } from "#internal/nitro/virtual/plugins"; @@ -48,12 +49,18 @@ function createNitroApp(): NitroApp { // Create local fetch callers const localCall = createCall(toNodeListener(h3App) as any); - const localFetch = createLocalFetch(localCall, globalThis.fetch); + const _localFetch = createLocalFetch(localCall, globalThis.fetch); + const localFetch = (...args: Parameters) => { + return _localFetch(...args).then((response) => + normalizeFetchResponse(response) + ); + }; const $fetch = createFetch({ fetch: localFetch, Headers, defaults: { baseURL: config.app.baseURL }, }); + // @ts-ignore globalThis.$fetch = $fetch; diff --git a/src/runtime/entries/aws-lambda.ts b/src/runtime/entries/aws-lambda.ts index 8fb0fca6f4..0300636684 100644 --- a/src/runtime/entries/aws-lambda.ts +++ b/src/runtime/entries/aws-lambda.ts @@ -1,6 +1,5 @@ import type { APIGatewayProxyEvent, - APIGatewayProxyEventHeaders, APIGatewayProxyEventV2, APIGatewayProxyResult, APIGatewayProxyResultV2, @@ -9,6 +8,10 @@ import type { import "#internal/nitro/virtual/polyfill"; import { withQuery } from "ufo"; import { nitroApp } from "../app"; +import { + normalizeLambdaIncomingHeaders, + normalizeLambdaOutgoingHeaders, +} from "../utils.lambda"; export async function handler( event: APIGatewayProxyEvent, @@ -44,51 +47,30 @@ export async function handler( event, url, context, - headers: normalizeIncomingHeaders(event.headers), + headers: normalizeLambdaIncomingHeaders(event.headers), method, query, body: event.body, // TODO: handle event.isBase64Encoded }); + // Lambda v2 https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html#http-api-develop-integrations-lambda.v2 if ("cookies" in event || "rawPath" in event) { const outgoingCookies = r.headers["set-cookie"]; const cookies = Array.isArray(outgoingCookies) ? outgoingCookies - : outgoingCookies?.split(",") || []; + : outgoingCookies?.split(/,\s?/) || []; return { cookies, statusCode: r.status, - headers: normalizeOutgoingHeaders(r.headers, true), + headers: normalizeLambdaOutgoingHeaders(r.headers, true), body: r.body.toString(), }; } return { statusCode: r.status, - headers: normalizeOutgoingHeaders(r.headers), + headers: normalizeLambdaOutgoingHeaders(r.headers), body: r.body.toString(), }; } - -function normalizeIncomingHeaders(headers?: APIGatewayProxyEventHeaders) { - return Object.fromEntries( - Object.entries(headers || {}).map(([key, value]) => [ - key.toLowerCase(), - value!, - ]) - ); -} - -function normalizeOutgoingHeaders( - headers: Record, - stripCookies = false -) { - const entries = stripCookies - ? Object.entries(headers).filter(([key]) => !["set-cookie"].includes(key)) - : Object.entries(headers); - - return Object.fromEntries( - entries.map(([k, v]) => [k, Array.isArray(v) ? v.join(",") : v!]) - ); -} diff --git a/src/runtime/entries/azure-functions.ts b/src/runtime/entries/azure-functions.ts index f5c4086087..5cdfd6f126 100644 --- a/src/runtime/entries/azure-functions.ts +++ b/src/runtime/entries/azure-functions.ts @@ -1,7 +1,10 @@ import "#internal/nitro/virtual/polyfill"; +import type { HttpRequest, HttpResponse } from "@azure/functions"; import { nitroApp } from "../app"; +import { getAzureParsedCookiesFromHeaders } from "../utils.azure"; +import { normalizeLambdaOutgoingHeaders } from "../utils.lambda"; -export async function handle(context, req) { +export async function handle(context: { res: HttpResponse }, req: HttpRequest) { const url = "/" + (req.params.url || ""); const { body, status, statusText, headers } = await nitroApp.localCall({ @@ -14,7 +17,9 @@ export async function handle(context, req) { context.res = { status, - headers, + // cookies https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-node?tabs=typescript%2Cwindows%2Cazure-cli&pivots=nodejs-model-v4#http-response + cookies: getAzureParsedCookiesFromHeaders(headers), + headers: normalizeLambdaOutgoingHeaders(headers, true), body: body ? body.toString() : statusText, }; } diff --git a/src/runtime/entries/azure.ts b/src/runtime/entries/azure.ts index e2aeffbfe6..8cd6d7e58c 100644 --- a/src/runtime/entries/azure.ts +++ b/src/runtime/entries/azure.ts @@ -1,8 +1,11 @@ import "#internal/nitro/virtual/polyfill"; +import type { HttpResponse, HttpRequest } from "@azure/functions"; import { parseURL } from "ufo"; import { nitroApp } from "../app"; +import { getAzureParsedCookiesFromHeaders } from "../utils.azure"; +import { normalizeLambdaOutgoingHeaders } from "../utils.lambda"; -export async function handle(context, req) { +export async function handle(context: { res: HttpResponse }, req: HttpRequest) { let url: string; if (req.headers["x-ms-original-url"]) { // This URL has been proxied as there was no static file matching it. @@ -24,7 +27,9 @@ export async function handle(context, req) { context.res = { status, - headers, + // cookies https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-node?tabs=typescript%2Cwindows%2Cazure-cli&pivots=nodejs-model-v4#http-response + cookies: getAzureParsedCookiesFromHeaders(headers), + headers: normalizeLambdaOutgoingHeaders(headers, true), body: body ? body.toString() : statusText, }; } diff --git a/src/runtime/entries/bun.ts b/src/runtime/entries/bun.ts index 3fdc605590..0f50d73b8d 100644 --- a/src/runtime/entries/bun.ts +++ b/src/runtime/entries/bun.ts @@ -12,8 +12,7 @@ const server = Bun.serve({ body = await request.arrayBuffer(); } - const response = await nitroApp.localFetch(url.pathname + url.search, { - url: url.pathname + url.search, + return nitroApp.localFetch(url.pathname + url.search, { host: url.hostname, protocol: url.protocol, headers: request.headers, @@ -21,8 +20,6 @@ const server = Bun.serve({ redirect: request.redirect, body, }); - - return response; }, }); diff --git a/src/runtime/entries/cloudflare.ts b/src/runtime/entries/cloudflare.ts index 2ec8d79797..5277b5e141 100644 --- a/src/runtime/entries/cloudflare.ts +++ b/src/runtime/entries/cloudflare.ts @@ -29,28 +29,22 @@ async function handleEvent(event: FetchEvent) { body = Buffer.from(await event.request.arrayBuffer()); } - const r = await nitroApp.localCall({ - event, + return nitroApp.localFetch(url.pathname + url.search, { context: { // https://developers.cloudflare.com/workers//runtime-apis/request#incomingrequestcfproperties cf: (event.request as any).cf, waitUntil: (promise) => event.waitUntil(promise), + cloudflare: { + event, + }, }, - url: url.pathname + url.search, host: url.hostname, protocol: url.protocol, - headers: Object.fromEntries(event.request.headers.entries()), + headers: event.request.headers, method: event.request.method, redirect: event.request.redirect, body, }); - - return new Response(r.body, { - // @ts-ignore TODO: Should be HeadersInit instead of string[][] - headers: normalizeOutgoingHeaders(r.headers), - status: r.status, - statusText: r.statusText, - }); } function assetsCacheControl(_request) { @@ -69,12 +63,3 @@ const baseURLModifier = (request: Request) => { const url = withoutBase(request.url, useRuntimeConfig().app.baseURL); return mapRequestToAsset(new Request(url, request)); }; - -function normalizeOutgoingHeaders( - headers: Record -) { - return Object.entries(headers).map(([k, v]) => [ - k, - Array.isArray(v) ? v.join(",") : v, - ]); -} diff --git a/src/runtime/entries/deno-deploy.ts b/src/runtime/entries/deno-deploy.ts index 5a8eab5ef4..b47556a63a 100644 --- a/src/runtime/entries/deno-deploy.ts +++ b/src/runtime/entries/deno-deploy.ts @@ -16,29 +16,12 @@ async function handleRequest(request: Request) { body = await request.arrayBuffer(); } - const r = await nitroApp.localCall({ - url: url.pathname + url.search, + return nitroApp.localFetch(url.pathname + url.search, { host: url.hostname, protocol: url.protocol, - headers: Object.fromEntries(request.headers.entries()), + headers: request.headers, method: request.method, redirect: request.redirect, body, }); - - return new Response(r.body || undefined, { - // @ts-ignore TODO: Should be HeadersInit instead of string[][] - headers: normalizeOutgoingHeaders(r.headers), - status: r.status, - statusText: r.statusText, - }); -} - -function normalizeOutgoingHeaders( - headers: Record -) { - return Object.entries(headers).map(([k, v]) => [ - k, - Array.isArray(v) ? v.join(",") : v, - ]); } diff --git a/src/runtime/entries/deno-server.ts b/src/runtime/entries/deno-server.ts index bbd2b486d9..18eec92007 100644 --- a/src/runtime/entries/deno-server.ts +++ b/src/runtime/entries/deno-server.ts @@ -50,33 +50,14 @@ async function handler(request: Request) { body = await request.arrayBuffer(); } - const r = await nitroApp.localCall({ - url: url.pathname + url.search, + return nitroApp.localFetch(url.pathname + url.search, { host: url.hostname, protocol: url.protocol, - headers: Object.fromEntries(request.headers.entries()), + headers: request.headers, method: request.method, redirect: request.redirect, body, }); - - // TODO: fix in runtime/static - const responseBody = r.status === 304 ? null : r.body; - return new Response(responseBody, { - // @ts-ignore TODO: Should be HeadersInit instead of string[][] - headers: normalizeOutgoingHeaders(r.headers), - status: r.status, - statusText: r.statusText, - }); -} - -function normalizeOutgoingHeaders( - headers: Record -) { - return Object.entries(headers).map(([k, v]) => [ - k, - Array.isArray(v) ? v.join(",") : v, - ]); } export default {}; diff --git a/src/runtime/entries/lagon.ts b/src/runtime/entries/lagon.ts index ae7e506950..ec073c658e 100644 --- a/src/runtime/entries/lagon.ts +++ b/src/runtime/entries/lagon.ts @@ -1,7 +1,7 @@ import "#internal/nitro/virtual/polyfill"; import { nitroApp } from "#internal/nitro/app"; -export async function handler(request: Request): Promise { +export async function handler(request: Request) { const url = new URL(request.url); let body; @@ -9,29 +9,12 @@ export async function handler(request: Request): Promise { body = await request.arrayBuffer(); } - const r = await nitroApp.localCall({ - url: url.pathname + url.search, + return nitroApp.localFetch(url.pathname + url.search, { host: url.hostname, protocol: url.protocol, - headers: Object.fromEntries(request.headers.entries()), + headers: request.headers, method: request.method, redirect: request.redirect, body, }); - - return new Response(r.body, { - // @ts-ignore TODO: Should be HeadersInit instead of string[][] - headers: normalizeOutgoingHeaders(r.headers), - status: r.status, - statusText: r.statusText, - }); -} - -function normalizeOutgoingHeaders( - headers: Record -) { - return Object.entries(headers).map(([k, v]) => [ - k, - Array.isArray(v) ? v.join(",") : v, - ]); } diff --git a/src/runtime/entries/netlify-edge.ts b/src/runtime/entries/netlify-edge.ts index aced8bc6c3..8d44df940e 100644 --- a/src/runtime/entries/netlify-edge.ts +++ b/src/runtime/entries/netlify-edge.ts @@ -19,20 +19,12 @@ export default async function (request: Request, _context) { body = await request.arrayBuffer(); } - const r = await nitroApp.localCall({ - url: url.pathname + url.search, + return nitroApp.localFetch(url.pathname + url.search, { host: url.hostname, protocol: url.protocol, - // @ts-ignore TODO headers: request.headers, method: request.method, redirect: request.redirect, body, }); - - return new Response(r.body, { - headers: r.headers as HeadersInit, - status: r.status, - statusText: r.statusText, - }); } diff --git a/src/runtime/entries/netlify-lambda.ts b/src/runtime/entries/netlify-lambda.ts index 1fdc27700a..a318742f9d 100644 --- a/src/runtime/entries/netlify-lambda.ts +++ b/src/runtime/entries/netlify-lambda.ts @@ -1,14 +1,19 @@ import "#internal/nitro/virtual/polyfill"; import type { - Handler, HandlerResponse, HandlerContext, HandlerEvent, -} from "@netlify/functions/dist/main"; -import type { APIGatewayProxyEventHeaders } from "aws-lambda"; +} from "@netlify/functions"; import { withQuery } from "ufo"; +import { splitCookiesString } from "h3"; import { nitroApp } from "../app"; +import { + normalizeLambdaIncomingHeaders, + normalizeLambdaOutgoingHeaders, +} from "../utils.lambda"; +import { normalizeCookieHeader } from "../utils"; +// Netlify functions uses lambda v1 https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html#http-api-develop-integrations-lambda.v2 export async function lambda( event: HandlerEvent, context: HandlerContext @@ -24,35 +29,20 @@ export async function lambda( event, url, context, - headers: normalizeIncomingHeaders(event.headers), + headers: normalizeLambdaIncomingHeaders(event.headers), method, query, body: event.body, // TODO: handle event.isBase64Encoded }); + const cookies = normalizeCookieHeader(r.headers["set-cookie"]); + return { statusCode: r.status, - headers: normalizeOutgoingHeaders(r.headers), + headers: normalizeLambdaOutgoingHeaders(r.headers, true), body: r.body.toString(), + multiValueHeaders: { + ...(cookies.length > 0 ? { "set-cookie": cookies } : {}), + }, }; } - -function normalizeIncomingHeaders(headers?: APIGatewayProxyEventHeaders) { - return Object.fromEntries( - Object.entries(headers || {}).map(([key, value]) => [ - key.toLowerCase(), - value!, - ]) - ); -} - -function normalizeOutgoingHeaders( - headers: Record -) { - return Object.fromEntries( - Object.entries(headers).map(([k, v]) => [ - k, - Array.isArray(v) ? v.join(",") : v!, - ]) - ); -} diff --git a/src/runtime/entries/service-worker.ts b/src/runtime/entries/service-worker.ts index e3f5a3abb9..bc1a8caf21 100644 --- a/src/runtime/entries/service-worker.ts +++ b/src/runtime/entries/service-worker.ts @@ -2,7 +2,7 @@ import "#internal/nitro/virtual/polyfill"; import { nitroApp } from "../app"; import { isPublicAssetURL } from "#internal/nitro/virtual/public-assets"; -addEventListener("fetch", (event: any) => { +addEventListener("fetch", (event: FetchEvent) => { const url = new URL(event.request.url); if (isPublicAssetURL(url.pathname) || url.pathname.includes("/_server/")) { return; @@ -17,9 +17,7 @@ async function handleEvent(url, event) { body = await event.request.arrayBuffer(); } - const r = await nitroApp.localCall({ - event, - url: url.pathname + url.search, + return nitroApp.localFetch(url.pathname + url.search, { host: url.hostname, protocol: url.protocol, headers: event.request.headers, @@ -27,12 +25,6 @@ async function handleEvent(url, event) { redirect: event.request.redirect, body, }); - - return new Response(r.body, { - headers: r.headers as HeadersInit, - status: r.status, - statusText: r.statusText, - }); } declare const self: ServiceWorkerGlobalScope; diff --git a/src/runtime/entries/stormkit.ts b/src/runtime/entries/stormkit.ts index 9979d4cd96..b90ac547ca 100644 --- a/src/runtime/entries/stormkit.ts +++ b/src/runtime/entries/stormkit.ts @@ -28,12 +28,13 @@ export const handler: Handler = async function ( event, url: event.url, context, - headers: event.headers, + headers: event.headers, // TODO: Normalize headers method, query: event.query, body: event.body, }); + // TODO: Handle cookies with lambda v1 or v2 ? return { statusCode: r.status, headers: normalizeOutgoingHeaders(r.headers), diff --git a/src/runtime/entries/vercel-edge.ts b/src/runtime/entries/vercel-edge.ts index e0f0d8c48c..4653b43706 100644 --- a/src/runtime/entries/vercel-edge.ts +++ b/src/runtime/entries/vercel-edge.ts @@ -1,7 +1,7 @@ import "#internal/nitro/virtual/polyfill"; import { nitroApp } from "#internal/nitro/app"; -export default async function handleEvent(request, event) { +export default async function handleEvent(request: Request, event: any) { const url = new URL(request.url); let body; @@ -9,29 +9,16 @@ export default async function handleEvent(request, event) { body = await request.arrayBuffer(); } - const r = await nitroApp.localCall({ - event, - url: url.pathname + url.search, + return nitroApp.localFetch(url.pathname + url.search, { host: url.hostname, protocol: url.protocol, - headers: Object.fromEntries(request.headers.entries()), + headers: request.headers, method: request.method, body, + context: { + vercel: { + event, + }, + }, }); - - return new Response(r.body, { - // @ts-ignore TODO: Should be HeadersInit instead of string[][] - headers: normalizeOutgoingHeaders(r.headers), - status: r.status, - statusText: r.statusText, - }); -} - -function normalizeOutgoingHeaders( - headers: Record -) { - return Object.entries(headers).map(([k, v]) => [ - k, - Array.isArray(v) ? v.join(",") : v, - ]); } diff --git a/src/runtime/utils.azure.ts b/src/runtime/utils.azure.ts new file mode 100644 index 0000000000..6b22832f83 --- /dev/null +++ b/src/runtime/utils.azure.ts @@ -0,0 +1,16 @@ +import type { Cookie } from "@azure/functions"; +import type { HeadersObject } from "unenv/runtime/_internal/types"; +import { parse } from "cookie-es"; +import { splitCookiesString } from "h3"; +import { joinHeaders } from "./utils"; + +export function getAzureParsedCookiesFromHeaders(headers: HeadersObject) { + const c = headers["set-cookie"]; + if (!c || c.length === 0) { + return []; + } + const cookies = splitCookiesString(joinHeaders(c)).map((cookie) => + parse(cookie) + ); + return cookies as unknown as Cookie[]; +} diff --git a/src/runtime/utils.lambda.ts b/src/runtime/utils.lambda.ts new file mode 100644 index 0000000000..680efac9ae --- /dev/null +++ b/src/runtime/utils.lambda.ts @@ -0,0 +1,26 @@ +import type { APIGatewayProxyEventHeaders } from "aws-lambda"; +import type { HeadersObject } from "unenv/runtime/_internal/types"; + +export function normalizeLambdaIncomingHeaders( + headers?: APIGatewayProxyEventHeaders +) { + return Object.fromEntries( + Object.entries(headers || {}).map(([key, value]) => [ + key.toLowerCase(), + value, + ]) + ); +} + +export function normalizeLambdaOutgoingHeaders( + headers: HeadersObject, + stripCookies = false +) { + const entries = stripCookies + ? Object.entries(headers).filter(([key]) => !["set-cookie"].includes(key)) + : Object.entries(headers); + + return Object.fromEntries( + entries.map(([k, v]) => [k, Array.isArray(v) ? v.join(",") : v]) + ); +} diff --git a/src/runtime/utils.ts b/src/runtime/utils.ts index 3a6cd9d805..1a12c8d0a8 100644 --- a/src/runtime/utils.ts +++ b/src/runtime/utils.ts @@ -1,5 +1,6 @@ import type { H3Event } from "h3"; -import { getRequestHeader } from "h3"; +import { getRequestHeader, splitCookiesString } from "h3"; + const METHOD_WITH_BODY_RE = /post|put|patch/i; const TEXT_MIME_RE = /application\/text|text\/html/; const JSON_MIME_RE = /application\/json/; @@ -100,3 +101,36 @@ export function trapUnhandledNodeErrors() { ); } } + +export function joinHeaders(value: string | string[]) { + return Array.isArray(value) ? value.join(", ") : value; +} + +export function normalizeFetchResponse(response: Response) { + if (!response.headers.has("set-cookie")) { + return response; + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: normalizeCookieHeaders(response.headers), + }); +} + +export function normalizeCookieHeader(header: string | string[] = "") { + return splitCookiesString(joinHeaders(header)); +} + +export function normalizeCookieHeaders(headers: Headers) { + const outgoingHeaders = new Headers(); + for (const [name, header] of headers) { + if (name === "set-cookie") { + for (const cookie of normalizeCookieHeader(header)) { + outgoingHeaders.append("set-cookie", cookie); + } + } else { + outgoingHeaders.set(name, joinHeaders(header)); + } + } + return outgoingHeaders; +} diff --git a/test/fixture/api/headers.ts b/test/fixture/api/headers.ts new file mode 100644 index 0000000000..b209272691 --- /dev/null +++ b/test/fixture/api/headers.ts @@ -0,0 +1,10 @@ +export default defineEventHandler((event) => { + setHeader(event, "x-foo", "bar"); + setHeader(event, "x-array", ["foo", "bar"]); + + setHeader(event, "Set-Cookie", "foo=bar, bar=baz"); + setCookie(event, "test", "value"); + setCookie(event, "test2", "value"); + + return "headers sent"; +}); diff --git a/test/presets/aws-lambda.test.ts b/test/presets/aws-lambda.test.ts index 97b75018b1..0a14798e5f 100644 --- a/test/presets/aws-lambda.test.ts +++ b/test/presets/aws-lambda.test.ts @@ -7,7 +7,7 @@ import { setupTest, testNitro } from "../tests"; describe("nitro:preset:aws-lambda", async () => { const ctx = await setupTest("aws-lambda"); // Lambda v1 paylod - testNitro(ctx, async () => { + testNitro({ ...ctx, lambdaV1: true }, async () => { const { handler } = await import(resolve(ctx.outDir, "server/index.mjs")); return async ({ url: rawRelativeUrl, headers, method, body }) => { // creating new URL object to parse query easier @@ -28,6 +28,7 @@ describe("nitro:preset:aws-lambda", async () => { data: destr(res.body), status: res.statusCode, headers: res.headers, + cookies: res.cookies, }; }; }); @@ -61,10 +62,20 @@ describe("nitro:preset:aws-lambda", async () => { body: body || "", }; const res = await handler(event); + const resHeaders = { ...res.headers }; + if (res.cookies) { + if (!resHeaders["set-cookie"]) { + resHeaders["set-cookie"] = []; + } + if (!Array.isArray(resHeaders["set-cookie"])) { + resHeaders["set-cookie"] = [resHeaders["set-cookie"]]; + } + resHeaders["set-cookie"].push(...res.cookies); + } return { data: destr(res.body), status: res.statusCode, - headers: res.headers, + headers: resHeaders, }; }; }); diff --git a/test/presets/netlify.test.ts b/test/presets/netlify.test.ts index 6bd7b3a254..e2675cf3ed 100644 --- a/test/presets/netlify.test.ts +++ b/test/presets/netlify.test.ts @@ -26,10 +26,11 @@ describe("nitro:preset:netlify", async () => { body: body || "", }; const res = await handler(event, {} as any, () => {}); + const resHeaders = { ...res.headers, ...res.multiValueHeaders }; return { data: destr(res.body), status: res.statusCode, - headers: res.headers, + headers: resHeaders, }; }; }); diff --git a/test/tests.ts b/test/tests.ts index 62ce8ca006..76b5b9a331 100644 --- a/test/tests.ts +++ b/test/tests.ts @@ -8,6 +8,9 @@ import { joinURL } from "ufo"; import * as _nitro from "../src"; import type { Nitro } from "../src"; +// Refactor: https://github.com/unjs/std-env/issues/60 +const nodeVersion = Number.parseInt(process.versions.node.match(/^v?(\d+)/)[0]); + const { createNitro, build, prepare, copyPublicAssets, prerender } = (_nitro as any as { default: typeof _nitro }).default || _nitro; @@ -19,6 +22,7 @@ export interface Context { fetch: (url: string, opts?: FetchOptions) => Promise; server?: Listener; isDev: boolean; + [key: string]: unknown; } // https://github.com/unjs/nitro/pull/1240 @@ -106,7 +110,7 @@ export async function startServer(ctx: Context, handle) { type TestHandlerResult = { data: any; status: number; - headers: Record; + headers: Record; }; type TestHandler = (options: any) => Promise; @@ -122,10 +126,27 @@ export function testNitro( if (result.constructor.name !== "Response") { return result as TestHandlerResult; } + + const headers: Record = {}; + for (const [key, value] of (result as Response).headers.entries()) { + if (headers[key]) { + if (!Array.isArray(headers[key])) { + headers[key] = [headers[key] as string]; + } + if (Array.isArray(value)) { + (headers[key] as string[]).push(...value); + } else { + (headers[key] as string[]).push(value); + } + } else { + headers[key] = value; + } + } + return { data: destr(await (result as Response).text()), status: result.status, - headers: Object.fromEntries((result as Response).headers.entries()), + headers, }; } @@ -414,4 +435,51 @@ export function testNitro( expect((await callHandler({ url: "/favicon.ico" })).status).toBe(200); }); }); + + describe("headers", () => { + it("handles headers correctly", async () => { + const { headers } = await callHandler({ url: "/api/headers" }); + expect(headers["content-type"]).toBe("text/html"); + expect(headers["x-foo"]).toBe("bar"); + expect(headers["x-array"]).toMatch(/^foo,\s?bar$/); + + let expectedCookies: string | string[] = [ + "foo=bar", + "bar=baz", + "test=value; Path=/", + "test2=value; Path=/", + ]; + + // TODO: Node presets do not split cookies + // https://github.com/unjs/nitro/issues/1462 + // (vercel and deno-server uses node only for tests only) + const notSplitingPresets = ["node", "nitro-dev", "vercel", "deno-server"]; + if (notSplitingPresets.includes(ctx.preset)) { + expectedCookies = + nodeVersion < 18 + ? "foo=bar, bar=baz, test=value; Path=/, test2=value; Path=/" + : ["foo=bar, bar=baz", "test=value; Path=/", "test2=value; Path=/"]; + } + + // TODO: verce-ledge joins all cookies for some reason! + if (ctx.preset === "vercel-edge") { + expectedCookies = + "foo=bar, bar=baz, test=value; Path=/, test2=value; Path=/"; + } + + // Aws lambda v1 + if (ctx.preset === "aws-lambda" && ctx.lambdaV1) { + expectedCookies = + "foo=bar, bar=baz,test=value; Path=/,test2=value; Path=/"; + } + + // TODO: Bun does not handles set-cookie at all + // https://github.com/unjs/nitro/issues/1461 + if (["bun"].includes(ctx.preset)) { + return; + } + + expect(headers["set-cookie"]).toMatchObject(expectedCookies); + }); + }); }