Repository: vercel/next.js
Pull request: Replace node:url usage in server-utils

This updates to avoid using `node:url` in the `pages-api` template file and our `server-utils` which is used across all templates. The main reason is to avoid pulling in `node` polyfill un-necessarily in non-node runtimes. 

Validated against deploy tests here https://github.com/vercel/vercel/actions/runs/14977933925/job/42074926257?pr=13324

Changed files:
filename: packages/next/src/build/templates/pages-api.ts
patch: @@ -4,7 +4,6 @@ import type { PrerenderManifest } from '..'
 import type { DevRoutesManifest } from '../../server/lib/router-utils/setup-dev-bundler'
 import type { InstrumentationOnRequestError } from '../../server/instrumentation/types'
 
-import { parse } from 'node:url'
 import { sendError } from '../../server/api-utils'
 import { RouteKind } from '../../server/route-kind'
 import type { Span } from '../../server/lib/trace/tracer'
@@ -35,6 +34,7 @@ import {
 import { loadManifestFromRelativePath } from '../../server/load-manifest.external'
 import { getHostname } from '../../shared/lib/get-hostname'
 import { detectDomainLocale } from '../../shared/lib/i18n/detect-domain-locale'
+import { parseReqUrl } from '../../lib/url'
 
 // Re-export the handler (should be the default export).
 export default hoist(userland, 'default')
@@ -109,7 +109,15 @@ export async function handler(
     }
   }
 
-  const parsedUrl = parse(req.url || '/', true)
+  const parsedUrl = parseReqUrl(req.url || '/')
+
+  if (!parsedUrl) {
+    res.statusCode = 400
+    res.end('Bad Request')
+    ctx.waitUntil?.(Promise.resolve())
+    return
+  }
+
   const pageIsDynamic = isDynamicRoute(srcPage)
 
   const serverUtils = getUtils({

filename: packages/next/src/lib/url.ts
patch: @@ -1,3 +1,4 @@
+import type { UrlWithParsedQuery } from 'url'
 import { NEXT_RSC_UNION_QUERY } from '../client/components/app-router-headers'
 
 const DUMMY_ORIGIN = 'http://n'
@@ -7,13 +8,44 @@ export function isFullStringUrl(url: string) {
 }
 
 export function parseUrl(url: string): URL | undefined {
-  let parsed = undefined
+  let parsed: URL | undefined = undefined
   try {
     parsed = new URL(url, DUMMY_ORIGIN)
   } catch {}
   return parsed
 }
 
+export function parseReqUrl(url: string): UrlWithParsedQuery | undefined {
+  const parsedUrl: URL | undefined = parseUrl(url)
+
+  if (!parsedUrl) {
+    return
+  }
+
+  const query: Record<string, string | string[]> = {}
+
+  for (const key of parsedUrl.searchParams.keys()) {
+    const values = parsedUrl.searchParams.getAll(key)
+    query[key] = values.length > 1 ? values : values[0]
+  }
+
+  const legacyUrl: UrlWithParsedQuery = {
+    query,
+    hash: parsedUrl.hash,
+    search: parsedUrl.search,
+    path: parsedUrl.pathname,
+    pathname: parsedUrl.pathname,
+    href: `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`,
+    host: '',
+    hostname: '',
+    auth: '',
+    protocol: '',
+    slashes: null,
+    port: '',
+  }
+  return legacyUrl
+}
+
 export function stripNextRscUnionQuery(relativeUrl: string): string {
   const urlInstance = new URL(relativeUrl, DUMMY_ORIGIN)
   urlInstance.searchParams.delete(NEXT_RSC_UNION_QUERY)

filename: packages/next/src/server/server-utils.ts
patch: @@ -5,7 +5,6 @@ import type { BaseNextRequest } from './base-http'
 import type { ParsedUrlQuery } from 'querystring'
 import type { UrlWithParsedQuery } from 'url'
 
-import { format as formatUrl, parse as parseUrl } from 'url'
 import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path'
 import { getPathMatch } from '../shared/lib/router/utils/path-match'
 import { getNamedRouteRegex } from '../shared/lib/router/utils/route-regex'
@@ -26,6 +25,8 @@ import { normalizeNextQueryParam } from './web/utils'
 import type { IncomingHttpHeaders, IncomingMessage } from 'http'
 import { decodeQueryPathParameter } from './lib/decode-query-path-parameter'
 import type { DeepReadonly } from '../shared/lib/deep-readonly'
+import { parseReqUrl } from '../lib/url'
+import { formatUrl } from '../shared/lib/router/utils/format-url'
 
 export function normalizeCdnUrl(
   req: BaseNextRequest | IncomingMessage,
@@ -34,7 +35,13 @@ export function normalizeCdnUrl(
 ) {
   // make sure to normalize req.url from CDNs to strip dynamic and rewrite
   // params from the query which are added during routing
-  const _parsedUrl = parseUrl(req.url!, true)
+  const _parsedUrl = parseReqUrl(req.url!)
+
+  // we can't normalize if we can't parse
+  if (!_parsedUrl) {
+    return req.url
+  }
+
   delete (_parsedUrl as any).search
 
   for (const key of Object.keys(_parsedUrl.query)) {