diff --git a/.changeset/mean-dolls-hope.md b/.changeset/mean-dolls-hope.md new file mode 100644 index 000000000..f9d30daef --- /dev/null +++ b/.changeset/mean-dolls-hope.md @@ -0,0 +1,5 @@ +--- +"frames.js": patch +--- + +fix: page router adapter for next.js node.js request handling diff --git a/examples/framesjs-starter/pages/api/frames/frames.ts b/examples/framesjs-starter/pages/api/frames/frames.ts new file mode 100644 index 000000000..29f7fac4d --- /dev/null +++ b/examples/framesjs-starter/pages/api/frames/frames.ts @@ -0,0 +1,5 @@ +import { createFrames } from "frames.js/next/pages-router"; + +export const frames = createFrames({ + basePath: "/api/frames", +}); diff --git a/examples/framesjs-starter/pages/api/frames.tsx b/examples/framesjs-starter/pages/api/frames/index.tsx similarity index 66% rename from examples/framesjs-starter/pages/api/frames.tsx rename to examples/framesjs-starter/pages/api/frames/index.tsx index 434c3bb68..70948e61e 100644 --- a/examples/framesjs-starter/pages/api/frames.tsx +++ b/examples/framesjs-starter/pages/api/frames/index.tsx @@ -1,9 +1,7 @@ /* eslint-disable react/jsx-key */ -import { createFrames, Button } from "frames.js/next/pages-router"; +import { Button } from "frames.js/next/pages-router"; +import { frames } from "./frames"; -const frames = createFrames({ - basePath: "/api/frames", -}); const handleRequest = frames(async (ctx) => { return { image: ( @@ -13,8 +11,9 @@ const handleRequest = frames(async (ctx) => { ), buttons: [ - , + , ], textInput: "Type something!", diff --git a/examples/framesjs-starter/pages/api/frames/next/index.tsx b/examples/framesjs-starter/pages/api/frames/next/index.tsx new file mode 100644 index 000000000..3acd8a763 --- /dev/null +++ b/examples/framesjs-starter/pages/api/frames/next/index.tsx @@ -0,0 +1,21 @@ +/* eslint-disable react/jsx-key */ +import { Button } from "frames.js/next/pages-router"; +import { frames } from "../frames"; + +const handleRequest = frames(async (ctx) => { + return { + image: ( + + This is next frame and you clicked button:{" "} + {ctx.pressedButton ? "✅" : "❌"} + + ), + buttons: [ + , + ], + }; +}); + +export default handleRequest; diff --git a/packages/frames.js/src/express/index.test.tsx b/packages/frames.js/src/express/index.test.tsx index 680c4e0ee..8c064cddb 100644 --- a/packages/frames.js/src/express/index.test.tsx +++ b/packages/frames.js/src/express/index.test.tsx @@ -1,4 +1,4 @@ -import express from "express"; +import express, { json } from "express"; import request from "supertest"; import { FRAMES_META_TAGS_HEADER } from "../core"; import * as lib from "."; @@ -96,13 +96,13 @@ describe("express adapter", () => { .expect(200) .expect("Content-type", "application/json") .expect((res) => { - expect((res.body as Record)["fc:frame:button:1:target"]).toMatch( - /http:\/\/127\.0\.0\.1:\d+\/api\/nested/ - ); + expect( + (res.body as Record)["fc:frame:button:1:target"] + ).toMatch(/http:\/\/127\.0\.0\.1:\d+\/api\/nested/); }); }); - it('works properly with state', async () => { + it("works properly with state", async () => { type State = { test: boolean; }; @@ -117,15 +117,38 @@ describe("express adapter", () => { expect(ctx.state).toEqual({ test: false }); return { - image: 'http://test.png', + image: "http://test.png", state: ctx.state satisfies State, }; }); app.use("/", expressHandler); + await request(app).get("/").expect("Content-Type", "text/html").expect(200); + }); + + it("works properly with body parser", async () => { + const app = express(); + const frames = lib.createFrames(); + const expressHandler = frames(async ({ request: req }) => { + await expect(req.clone().json()).resolves.toEqual({ test: "test" }); + + return { + image: Nehehe, + buttons: [ + + Click me + , + ], + }; + }); + + app.use("/", json(), expressHandler); + await request(app) - .get("/") + .post("/") + .set("Host", "localhost:3000") + .send({ test: "test" }) .expect("Content-Type", "text/html") .expect(200); }); diff --git a/packages/frames.js/src/express/index.ts b/packages/frames.js/src/express/index.ts index 7f9fbb976..269e503ee 100644 --- a/packages/frames.js/src/express/index.ts +++ b/packages/frames.js/src/express/index.ts @@ -1,4 +1,3 @@ -import type { IncomingHttpHeaders } from "node:http"; import type { Handler as ExpressHandler, Request as ExpressRequest, @@ -6,11 +5,11 @@ import type { } from "express"; import type { types } from "../core"; import { createFrames as coreCreateFrames } from "../core"; -import { - createReadableStreamFromReadable, - writeReadableStreamToWritable, -} from "../lib/stream-pump"; import type { CoreMiddleware } from "../middleware"; +import { + convertNodeJSRequestToWebAPIRequest, + sendWebAPIResponseToNodeJSResponse, +} from "../lib/node-server-helpers"; export { Button, type types } from "../core"; @@ -60,10 +59,14 @@ export const createFrames: CreateFramesForExpress = res: ExpressResponse ) { // convert express.js req to Web API Request - const response = framesHandler(createRequest(req, res)); + const response = framesHandler( + convertNodeJSRequestToWebAPIRequest(req, res) + ); Promise.resolve(response) - .then((resolvedResponse) => sendResponse(res, resolvedResponse)) + .then((resolvedResponse) => + sendWebAPIResponseToNodeJSResponse(res, resolvedResponse) + ) .catch((error) => { // eslint-disable-next-line no-console -- provide feedback console.error(error); @@ -73,70 +76,3 @@ export const createFrames: CreateFramesForExpress = }; }; }; - -function createRequest(req: ExpressRequest, res: ExpressResponse): Request { - // req.hostname doesn't include port information so grab that from - // `X-Forwarded-Host` or `Host` - const [, hostnamePort] = req.get("X-Forwarded-Host")?.split(":") ?? []; - const [, hostPort] = req.get("Host")?.split(":") ?? []; - const port = hostnamePort || hostPort; - // Use req.hostname here as it respects the "trust proxy" setting - const resolvedHost = `${req.hostname}${port ? `:${port}` : ""}`; - // Use `req.originalUrl` so Remix is aware of the full path - const url = new URL(`${req.protocol}://${resolvedHost}${req.originalUrl}`); - - // Abort action/loaders once we can no longer write a response - const controller = new AbortController(); - res.on("close", () => { - controller.abort(); - }); - - const init: RequestInit = { - method: req.method, - headers: convertIncomingHTTPHeadersToHeaders(req.headers), - signal: controller.signal, - }; - - if (req.method !== "GET" && req.method !== "HEAD") { - init.body = createReadableStreamFromReadable(req); - (init as { duplex: "half" }).duplex = "half"; - } - - return new Request(url.href, init); -} - -function convertIncomingHTTPHeadersToHeaders( - incomingHeaders: IncomingHttpHeaders -): Headers { - const headers = new Headers(); - - for (const [key, value] of Object.entries(incomingHeaders)) { - if (Array.isArray(value)) { - for (const item of value) { - headers.append(key, item); - } - } else if (value != null) { - headers.append(key, value); - } - } - - return headers; -} - -async function sendResponse( - res: ExpressResponse, - response: Response -): Promise { - res.statusMessage = response.statusText; - res.status(response.status); - - for (const [key, value] of response.headers.entries()) { - res.setHeader(key, value); - } - - if (response.body) { - await writeReadableStreamToWritable(response.body, res); - } else { - res.end(); - } -} diff --git a/packages/frames.js/src/lib/node-server-helpers.test.ts b/packages/frames.js/src/lib/node-server-helpers.test.ts new file mode 100644 index 000000000..a2bf68bd9 --- /dev/null +++ b/packages/frames.js/src/lib/node-server-helpers.test.ts @@ -0,0 +1,123 @@ +import { IncomingMessage, ServerResponse } from "node:http"; +import { Socket } from "node:net"; +import { TLSSocket } from "node:tls"; +import { + convertNodeJSRequestToWebAPIRequest, + sendWebAPIResponseToNodeJSResponse, +} from "./node-server-helpers"; + +describe("convertNodeJSRequestToWebAPIRequest", () => { + it("properly detects url from host header (with custom port)", () => { + const req = new IncomingMessage(new Socket()); + req.headers.host = "framesjs.org:3000"; + req.url = "/test"; + + const res = new ServerResponse(req); + const request = convertNodeJSRequestToWebAPIRequest(req, res); + + expect(request.url).toBe("http://framesjs.org:3000/test"); + }); + + it("properly detects url from host header (without port)", () => { + const req = new IncomingMessage(new Socket()); + req.headers.host = "framesjs.org"; + req.url = "/test"; + + const res = new ServerResponse(req); + const request = convertNodeJSRequestToWebAPIRequest(req, res); + + expect(request.url).toBe("http://framesjs.org/test"); + }); + + it("properly detects protocol from socket", () => { + const socket = new TLSSocket(new Socket()); + const req = new IncomingMessage(socket); + req.headers.host = "framesjs.org"; + req.url = "/test"; + + const res = new ServerResponse(req); + const request = convertNodeJSRequestToWebAPIRequest(req, res); + + expect(request.url).toBe("https://framesjs.org/test"); + }); + + it("uses x-forwarded-host if available", () => { + const req = new IncomingMessage(new Socket()); + req.headers["x-forwarded-host"] = "framesjs.org:3000"; + req.url = "/test"; + + const res = new ServerResponse(req); + const request = convertNodeJSRequestToWebAPIRequest(req, res); + + expect(request.url).toBe("http://framesjs.org:3000/test"); + }); + + it("uses x-forwarded-host over host header", () => { + const req = new IncomingMessage(new Socket()); + req.headers["x-forwarded-host"] = "framesjs.org:3000"; + req.headers.host = "framesjs.org"; + req.url = "/test"; + + const res = new ServerResponse(req); + const request = convertNodeJSRequestToWebAPIRequest(req, res); + + expect(request.url).toBe("http://framesjs.org:3000/test"); + }); + + it("uses x-forwarded-proto if available", () => { + const req = new IncomingMessage(new Socket()); + + req.headers["x-forwarded-proto"] = "https"; + req.headers.host = "framesjs.org"; + req.url = "/test"; + + const res = new ServerResponse(req); + const request = convertNodeJSRequestToWebAPIRequest(req, res); + + expect(request.url).toBe("https://framesjs.org/test"); + }); + + it("uses x-forwarded-proto over encrypted socket", () => { + const socket = new TLSSocket(new Socket()); + const req = new IncomingMessage(socket); + + req.headers["x-forwarded-proto"] = "http"; + req.headers.host = "framesjs.org"; + req.url = "/test"; + + const res = new ServerResponse(req); + const request = convertNodeJSRequestToWebAPIRequest(req, res); + + expect(request.url).toBe("http://framesjs.org/test"); + }); + + it("passes all headers to Request", () => { + const req = new IncomingMessage(new Socket()); + req.headers.host = "framesjs.org"; + req.url = "/test"; + req.headers["x-test"] = "test"; + req.headers["content-type"] = "test"; + + const res = new ServerResponse(req); + const request = convertNodeJSRequestToWebAPIRequest(req, res); + + expect(request.headers.get("x-test")).toBe("test"); + expect(request.headers.get("content-type")).toBe("test"); + }); +}); + +describe("sendWebAPIResponseToNodeJSResponse", () => { + it("sends response with headers to node.js response", async () => { + const response = Response.json( + { test: "test" }, + { headers: { "x-test": "test" }, statusText: "OK" } + ); + const res = new ServerResponse(new IncomingMessage(new Socket())); + + await sendWebAPIResponseToNodeJSResponse(res, response); + + expect(res.statusCode).toBe(200); + expect(res.statusMessage).toBe("OK"); + expect(res.getHeader("content-type")).toBe("application/json"); + }); +}); diff --git a/packages/frames.js/src/lib/node-server-helpers.ts b/packages/frames.js/src/lib/node-server-helpers.ts new file mode 100644 index 000000000..7cdf0210a --- /dev/null +++ b/packages/frames.js/src/lib/node-server-helpers.ts @@ -0,0 +1,160 @@ +import type { + IncomingMessage, + IncomingHttpHeaders, + ServerResponse, +} from "node:http"; +import { Readable } from "node:stream"; +import type { Socket } from "node:net"; +import type { TLSSocket } from "node:tls"; +import { + createReadableStreamFromReadable, + writeReadableStreamToWritable, +} from "./stream-pump"; + +function headerValue( + header: string | string[] | undefined +): string | undefined { + if (header === undefined) { + return undefined; + } + + if (Array.isArray(header)) { + return header[0]; + } + + return header; +} + +/** + * Incomming message uses TLSSocket if the request has been created by createServer from 'node:https' package. + */ +function isEncryptedSocket(socket: Socket | TLSSocket): boolean { + return "encrypted" in socket && socket.encrypted; +} + +/** + * Converts Node.js request to Web API request. + */ +export function convertNodeJSRequestToWebAPIRequest( + req: IncomingMessage, + res: ServerResponse +): Request { + /** + * This header should never be an array as according to spec it should be a single value. + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host + */ + const host = headerValue(req.headers["x-forwarded-host"]) ?? req.headers.host; + + if (!host) { + throw new TypeError( + 'Request headers must contain "host" or "x-forwarded-host" header' + ); + } + + /** + * This header should never be an array as according to spec it should be a single value. + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto + */ + const protocol = + headerValue(req.headers["x-forwarded-proto"]) || + (isEncryptedSocket(req.socket) ? "https" : "http"); + + let url: URL; + const resolvedUrl = `${protocol}://${host}${req.url}`; + + try { + url = new URL(resolvedUrl); + } catch (e) { + throw new TypeError(`Failed to resolve request URL. ${resolvedUrl}`); + } + + // Abort action/loaders once we can no longer write a response + const controller = new AbortController(); + + res.on("close", () => { + controller.abort(); + }); + + const init: RequestInit = { + method: req.method, + headers: createRequestHeaders(req.headers), + signal: controller.signal, + }; + + if (req.method !== "GET" && req.method !== "HEAD") { + init.body = createReadableStreamFromReadable( + convertIncomingMessageToReadable(req) + ); + (init as { duplex: "half" }).duplex = "half"; + } + + return new Request(url.href, init); +} + +function createRequestHeaders(requestHeaders: IncomingHttpHeaders): Headers { + const headers = new Headers(); + + for (const [key, values] of Object.entries(requestHeaders)) { + if (values) { + if (Array.isArray(values)) { + for (const value of values) { + headers.append(key, value); + } + } else { + headers.set(key, values); + } + } + } + + return headers; +} + +/** + * Next.js / Express body parser consume request body and replaces them with the decoded value. The stream is then not readable anymore. + * + * This function handles such case and creates a Readable from the body if it's not readable. + */ +function convertIncomingMessageToReadable(req: IncomingMessage): Readable { + if (req.method === "GET" || req.method === "HEAD") { + throw new TypeError("GET and HEAD requests should not have a body"); + } + + if (!req.readable) { + if (!("body" in req)) { + throw new TypeError( + "Request body is not available. If the request stream was consumed please assign a body with JSON serializable value to `req.body`" + ); + } + + const readable = new Readable(); + + readable.push(JSON.stringify(req.body)); + readable.push(null); + + return readable; + } + + return req; +} + +/** + * Sends Web API response to Node.js response. + */ +export async function sendWebAPIResponseToNodeJSResponse( + res: ServerResponse, + response: Response +): Promise { + res.statusMessage = response.statusText; + + for (const [header, value] of response.headers.entries()) { + res.setHeader(header, value); + } + + res.writeHead(response.status, response.statusText); + + if (response.body) { + await writeReadableStreamToWritable(response.body, res); + } else { + res.end(); + } +} diff --git a/packages/frames.js/src/next/pages-router.test.tsx b/packages/frames.js/src/next/pages-router.test.tsx new file mode 100644 index 000000000..07a113c0d --- /dev/null +++ b/packages/frames.js/src/next/pages-router.test.tsx @@ -0,0 +1,85 @@ +/* eslint-disable no-console -- we are logging errors */ +import { + createServer, + type RequestListener, + type IncomingMessage, +} from "node:http"; +import supertest from "supertest"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { Button, createFrames } from "./pages-router"; + +describe("next.js pages router integration", () => { + it("works with next.js pages router with disabled body parsing", async () => { + const frames = createFrames(); + const handler = frames(() => { + return { + image: Test, + buttons: [ + , + ], + }; + }); + + const server = createServer(handler as unknown as RequestListener); + + const request = supertest(server); + + const response = await request.get("/"); + + expect(response.status).toBe(200); + }); + + it("works with next.js pages router with enabled body parsing", async () => { + const frames = createFrames(); + const handler = frames(() => { + return { + image: Test, + buttons: [ + , + ], + }; + }); + + const server = createServer((req, res) => { + // simulate how next.js consumes the req and replaces body + async function streamToString(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + + for await (const chunk of request) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- we are testing this + chunks.push(Buffer.from(chunk)); + } + + return Buffer.concat(chunks).toString("utf-8"); + } + + streamToString(req) + .then((body) => { + // eslint-disable-next-line jest/no-conditional-expect -- we need to test this + expect(req.readable).toBe(false); + + // @ts-expect-error -- this is a test + req.body = body; + + return handler(req as unknown as NextApiRequest, res as unknown as NextApiResponse); + }) + .catch((e) => { + console.error(e); + res.writeHead(500, { "content-type": "text/plain" }); + res.end(String(e)); + }); + }); + + const request = supertest(server); + + const response = await request.post("/").send({ + test: "test", + }); + + expect(response.status).toBe(200); + }); +}); diff --git a/packages/frames.js/src/next/pages-router.tsx b/packages/frames.js/src/next/pages-router.tsx index e2987a842..c12ec0422 100644 --- a/packages/frames.js/src/next/pages-router.tsx +++ b/packages/frames.js/src/next/pages-router.tsx @@ -2,16 +2,43 @@ import type { Metadata, NextApiRequest, NextApiResponse } from "next"; import React from "react"; import type { types } from "../core"; import { createFrames as coreCreateFrames } from "../core"; -import { - createReadableStreamFromReadable, - writeReadableStreamToWritable, -} from "../lib/stream-pump"; +import type { CoreMiddleware } from "../middleware"; +import { convertNodeJSRequestToWebAPIRequest, sendWebAPIResponseToNodeJSResponse } from "../lib/node-server-helpers"; export { Button, type types } from "../core"; export { fetchMetadata } from "./fetchMetadata"; -export const createFrames: typeof coreCreateFrames = + +type CreateFramesForNextJSApiHandler = types.CreateFramesFunctionDefinition< + CoreMiddleware, + (req: NextApiRequest, res: NextApiResponse) => Promise +>; + +/** + * Creates Frames instance to use with you Next.js server API handler + * + * @example + * ```tsx + * import { createFrames, Button } from 'frames.js/next/pages-router'; + * + * const frames = createFrames(); + * const nextHandler = frames(async (ctx) => { + * return { + * image: Test, + * buttons: [ + * , + * ], + * }; + * }); + * + * export default nextHandler; + * ``` + */ +// @ts-expect-error -- this code is correct just function doesn't satisfy the type +export const createFrames: CreateFramesForNextJSApiHandler = function createFramesForNextJSPagesRouter(options: types.FramesOptions) { const frames = coreCreateFrames(options); @@ -27,11 +54,11 @@ export const createFrames: typeof coreCreateFrames = req: NextApiRequest, res: NextApiResponse ) { - const response = await requestHandler(createRequest(req, res)); - await sendResponse(res, response); + const response = await requestHandler(convertNodeJSRequestToWebAPIRequest(req, res)); + await sendWebAPIResponseToNodeJSResponse(res, response); }; }; - } as unknown as typeof coreCreateFrames; + } /** * Converts metadata returned from fetchMetadata() call to Next.js compatible components. @@ -63,7 +90,7 @@ export const createFrames: typeof coreCreateFrames = * } * ``` */ -export function metadataToMetaTags(metadata: NonNullable): React.JSX.Element { +export function metadataToMetaTags(metadata: NonNullable): JSX.Element { return ( <> {Object.entries(metadata).map(([key, value]) => { @@ -76,73 +103,3 @@ export function metadataToMetaTags(metadata: NonNullable): Re ); } - -function createRequest(req: NextApiRequest, res: NextApiResponse): Request { - // req.hostname doesn't include port information so grab that from - // `X-Forwarded-Host` or `Host` - const xForwardedHost = req.headers["x-forwarded-host"]; - const normalizedXForwardedHost = Array.isArray(xForwardedHost) - ? xForwardedHost[0] - : xForwardedHost; - const [, hostnamePort] = normalizedXForwardedHost?.split(":") ?? []; - const [, hostPort] = req.headers.host?.split(":") ?? []; - const port = hostnamePort || hostPort; - // Use req.hostname here as it respects the "trust proxy" setting - const resolvedHost = `${req.headers.host}${!hostPort ? `:${port}` : ""}`; - // Use `req.url` so NextJS is aware of the full path - const url = new URL( - `${"encrypted" in req.socket && req.socket.encrypted ? "https" : "http"}://${resolvedHost}${req.url}` - ); - - // Abort action/loaders once we can no longer write a response - const controller = new AbortController(); - res.on("close", () => { controller.abort(); }); - - const init: RequestInit = { - method: req.method, - headers: createRequestHeaders(req.headers), - signal: controller.signal, - }; - - if (req.method !== "GET" && req.method !== "HEAD") { - init.body = createReadableStreamFromReadable(req); - (init as { duplex: "half" }).duplex = "half"; - } - - return new Request(url.href, init); -} - -export function createRequestHeaders( - requestHeaders: NextApiRequest["headers"] -): Headers { - const headers = new Headers(); - - for (const [key, values] of Object.entries(requestHeaders)) { - if (values) { - if (Array.isArray(values)) { - for (const value of values) { - headers.append(key, value); - } - } else { - headers.set(key, values); - } - } - } - - return headers; -} - -async function sendResponse(res: NextApiResponse, response: Response): Promise { - res.statusMessage = response.statusText; - res.status(response.status); - - for (const [key, value] of response.headers.entries()) { - res.setHeader(key, value); - } - - if (response.body) { - await writeReadableStreamToWritable(response.body, res); - } else { - res.end(); - } -} diff --git a/yarn.lock b/yarn.lock index f7c5376ba..b6b1f0921 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13439,7 +13439,16 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -13546,7 +13555,14 @@ stringify-entities@^4.0.0: character-entities-html4 "^2.0.0" character-entities-legacy "^3.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -15001,7 +15017,7 @@ wrangler@3.39.0, wrangler@^3.39.0: optionalDependencies: fsevents "~2.3.2" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -15019,6 +15035,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"