Привет, Хабр!
URLPattern дорос до того, чтобы его можно было использовать как нормальный «роутер без фреймворка». В браузерах API уже поддерживается широким набором движков, а в Node 24 он доступен глобально без импортов. Один и тот же паттерн можно матчить и в Service Worker, и в HTTP‑сервере на Node.
URLPattern — это примитив платформы для сопоставления URLам по удобному паттерну. Он матчится по компонентам: protocol, hostname, port, pathname, search, hash. Есть test
и exec
, named groups и опции вроде ignoreCase
. Важный плюс в том, что у нас есть одинаковая семантика в браузере и в рантаймах, которые его тащат из той же спеки.
В Node 24 объект доступен глобально, как URL
, без require('node:url')
. Сам класс появился в 23.8, в 24-й ветке он отмечен как experimental, но доступ из коробки.
По поддержке в браузерах ситуация здоровая. По Can I use, URLPattern работает в актуальных ветках Chrome, Firefox, Safari, Edge, включая мобильные.
Коротко про синтаксис
Конструктор принимает либо строку с шортхендом, либо объект с частями. Внутри используется нормализующий парсер URL + конвертация паттернов в регулярки согласно спекам. Это нужно, потому что любые regexp‑группы валидируются на этапе компиляции паттерн
Три важных момента:
Именованные сегменты:
pathname: '/users/:id([0-9]+)'
дастmatches.pathname.groups.id === '42'
и отрежет нецифровые значения по месту.Вариативный хост:
hostname: '{:sub.}?api.example.com'
— необязательный субдомен с точкой внутри скобок.Базовый URL:
new URLPattern('../admin/*', 'https://example.com/app/')
— относительные паттерны распутываются так же, как относительные URL. (urlpattern.spec.whatwg.org)
Мини-роутер на URLPattern:
Задача простая: хотим объявлять список маршрутов с методами, безопасной валидацией параметров и единым API, который будет работать и в SW, и в Node. Паттерны компилим один раз, на горячем пути только test/exec
. Ошибки не протекают наружу как 500 без стектрейса, а превращаются в аккуратные ответы.
// router.js
export class Route {
constructor({ method = 'GET', pattern, baseURL, options, handler, coerce = {} }) {
if (!pattern) throw new TypeError('pattern is required');
this.method = method.toUpperCase();
this.pattern = typeof pattern === 'string'
? new URLPattern(pattern, baseURL, options)
: new URLPattern(pattern, options);
this.handler = handler;
this.coerce = coerce; // {paramName: fn(string) => any} для приведения типов
}
match(input, baseURL) {
const ok = this.pattern.test(input, baseURL);
if (!ok) return null;
const m = this.pattern.exec(input, baseURL);
// Собираем все группы из всех компонент в один объект params.
const params = {
...(m.protocol?.groups ?? {}),
...(m.hostname?.groups ?? {}),
...(m.port?.groups ?? {}),
...(m.pathname?.groups ?? {}),
...(m.search?.groups ?? {}),
...(m.hash?.groups ?? {}),
};
// Приведение типов и доменная валидация
for (const [k, f] of Object.entries(this.coerce)) {
if (params[k] != null) {
const v = f(params[k]);
if (v === undefined) return null; // отбраковка
params[k] = v;
}
}
return { params, match: m };
}
}
export class Router {
constructor({ baseURL } = {}) {
this.routes = [];
this.baseURL = baseURL;
}
add(routeInit) {
const r = new Route(routeInit);
this.routes.push(r);
return this;
}
// Унифицированный матч: {url, method}
find({ url, method = 'GET' }) {
const u = typeof url === 'string' ? url : url.href;
const m = method.toUpperCase();
for (const r of this.routes) {
if (r.method !== m) continue;
const res = r.match(u);
if (res) return { route: r, ...res };
}
return null;
}
}
Нет внешних зависимостей и нет глобального состояния. Параметры приводятся по желанию через coerce
. Если привести не удалось, то матч срываем.
Дальше добавим несколько маршрутов. Нужен CRUD для /users/:id
, список /search?q&limit
, и статический /health
. Валидация на уровне паттернов + простая проверка доменных ограничений.
// routes.js
import { Router } from './router.js';
export function buildRouter() {
const router = new Router();
// GET /health
router.add({
method: 'GET',
pattern: { pathname: '/health' },
handler: async () => new Response('ok', { status: 200 }),
});
// GET /users/:id, где id — положительное число
router.add({
method: 'GET',
pattern: { pathname: '/users/:id([1-9][0-9]*)' },
coerce: { id: s => { const n = Number(s); return Number.isSafeInteger(n) ? n : undefined; } },
handler: async ({ params }) => {
// тут достаём пользователя из БД; пока возвращаем echo
return json({ id: params.id });
},
});
// GET /search?q=строка&limit=число
router.add({
method: 'GET',
// search тоже матчится: порядок и разделители учитываются
pattern: { pathname: '/search', search: '?q=:q&limit=:limit([0-9]{1,2})' },
coerce: { limit: s => { const n = Number(s); return n >= 1 && n <= 50 ? n : 10; } },
handler: async ({ url, params }) => {
// url — экземпляр URL, с нормальными searchParams
const q = params.q ?? url.searchParams.get('q') ?? '';
const limit = params.limit ?? 10;
return json({ q, limit });
},
});
return router;
}
function json(obj, init = {}) {
return new Response(JSON.stringify(obj), {
headers: { 'content-type': 'application/json; charset=utf-8' },
...init,
});
}
search
в паттерне учитывает порядок. Если нужна гибкость с «порядок не важен», проще не тащить это в паттерн, а читать URLSearchParams
из url
и валидировать руками. На уровне спецификации search
— это одна строка целиком, её можно матчить группами, но без перестановок.
Теперь обвязка для двух сред. В Service Worker мы вешаемся на fetch
и проверяем только свой origin, чтобы не ловить чужие запросы.
// sw.js
import { buildRouter } from './routes.js';
const router = buildRouter();
self.addEventListener('fetch', event => {
const req = event.request;
const url = new URL(req.url);
// Перехватываем только свой origin и обычные навигации/API
if (url.origin !== self.location.origin) return;
const found = router.find({ url, method: req.method });
if (!found) return; // пусть сеть работает как обычно
event.respondWith(handle(found, req, url));
});
async function handle(found, req, url) {
try {
const ctx = { request: req, url, params: found.params, match: found.match };
const res = await found.route.handler(ctx);
return withSafeHeaders(res);
} catch (e) {
return new Response('internal error', { status: 500 });
}
}
function withSafeHeaders(res) {
const h = new Headers(res.headers);
if (!h.has('cache-control')) h.set('cache-control', 'no-store');
return new Response(res.body, { status: res.status, headers: h });
}
В Node 24 делаем то же самое на http.createServer
, req.url
относительный. Его нужно комбинировать с фиктивным base origin, иначе разбор невалиден. В Node доке это отмечено для URL.parse/canParse
, но принцип общий.
// server.js
import http from 'node:http';
import { buildRouter } from './routes.js';
const router = buildRouter();
const ORIGIN = process.env.BASE_ORIGIN || 'http://localhost';
const server = http.createServer(async (req, res) => {
// собираем абсолютный URL для матчей
const url = new URL(req.url, ORIGIN);
const found = router.find({ url, method: req.method });
if (!found) {
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
res.end('not found');
return;
}
try {
const ctx = { request: req, url, params: found.params, match: found.match };
const response = await found.route.handler(ctx);
await sendNode(res, response);
} catch (e) {
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
res.end('internal error');
}
});
server.listen(3000, () => {
console.log('listening on http://localhost:3000');
});
async function sendNode(res, webResponse) {
const buf = Buffer.from(await webResponse.arrayBuffer());
const headers = Object.fromEntries(webResponse.headers.entries());
res.writeHead(webResponse.status, headers);
res.end(buf);
}
На этом этапе у нас единый набор паттернов и одинаковое поведение в SW и Node. Мы не использовали сторонний роутер, не писали регулярки руками, не плодили дублирование логики между клиентом и сервером.
Теперь полезные детали, без которых потом может быть неприятно.
Во‑первых, опция ignoreCase
. По умолчанию матч чувствителен к регистру. Это видно и в Node‑документации, и в спеках. Если нужно нечувствительно по пути или хосту — включайте флаг явным образом при создании паттерна.
Во‑вторых, hasRegExpGroups
. Если паттерн содержит свои regexp‑группы, движок обязан скомпилировать соответствующую регулярку. Это означает потенциальный оверхед и риски, если вы позволяете конфигурацией подсовывать произвольные выражения. Тут политика простая: не принимать чужие регексы из непроверенных источников, а свои ограничить примитивами \d+
, [-a-z]+
и так далее
В‑третьих, производительность. Есть публичные сравнения, где URLPattern заметно уступает высокооптимизированным роутерам, заточенным под HTTP, например find‑my‑way. Автор Fastify показывает порядок проигрыша на «худших» кейсах, и похожие наблюдения встречались в issue‑трекерах других рантаймов. Мораль простая: если вам нужен миллион RPS и из каждой наносекунды выжимаете сок, берите специализированный роутер. Если хочется одинакового поведения в SW и Node без зависимостей — URLPattern нормально выполняет роль простого матчера.
Матчим хосты и протоколы без плясок с бубном
Пара рабочих заготовок:
// http и https, но не ws
const proto = new URLPattern({ protocol: 'http{s}?' });
// поддомен опционален: api.example.com и example.com
const host = new URLPattern({ hostname: '{:sub.}?example.com' });
// path с «хвостом»: /static/* и /static/img/logo.svg
const assets = new URLPattern({ pathname: '/static/*' });
// аккуратный id, только положительные
const user = new URLPattern({ pathname: '/users/:id([1-9][0-9]*)' });
// строгий поиск с порядком и ограничением на limit
const search = new URLPattern({ pathname: '/search', search: '?q=:q&limit=:limit([0-9]{1,2})' });
Отдельно про Service Worker. В Chrome есть Static Routing API с addRoutes
, где фигурирует поле urlPattern
. Это не URLPattern из WHATWG, а механизм маршрутизации до события fetch
. Он решает другую задачу. Мы здесь остаёмся в привычной модели fetch
‑хэндлера, чтобы не смешивать две линии развития API.
URLPattern против ручного RegExp
Нагреваем JIT, сравниваем test
URLPattern против заранее созданного RegExp. Это конечно не академический эталон, но для оценки порядка величин годится. Сценарий — матч пути /users/:id
и query ?active=1
.
// bench.js
const N = Number(process.env.N || 5_000_000);
const URLS = [
'http://localhost/users/1?active=1',
'http://localhost/users/9999?active=1',
'http://localhost/users/x?active=1',
'http://localhost/posts/1?active=1',
];
const up = new URLPattern({
pathname: '/users/:id([1-9][0-9]*)',
search: '?active=1'
});
const re = /^\/users\/(?<id>[1-9][0-9]*)$/;
function bench(name, fn) {
const t0 = process.hrtime.bigint();
let ok = 0;
for (let i = 0; i < N; i++) {
const u = new URL(URLS[i % URLS.length]);
if (fn(u)) ok++;
}
const t1 = process.hrtime.bigint();
const ms = Number(t1 - t0) / 1e6;
console.log(`${name}: ${(ms).toFixed(1)} ms, ok=${ok}`);
}
function withURLPattern(u) {
return up.test(u) === true;
}
function withRegExp(u) {
return u.search === '?active=1' && re.test(u.pathname) === true;
}
// прогреваем
for (let i = 0; i < 3; i++) {
bench('warm urlpattern', withURLPattern);
bench('warm regexp ', withRegExp);
}
// измеряем
bench('URLPattern', withURLPattern);
bench('RegExp ', withRegExp);
На практике RegExp чаще окажется быстрее, что ожидаемо. Выбор инструмента зависит от цели: унификация поведения клиент+сервер или максимальная пропускная способность.
Безопасност
Не генерируйте пользовательские regexp‑группы из непроверенных входных данных. Спека компилирует группы сразу, ошибки вылетят на старте, но сложные выражения и вложенные квантifikаторы могут повлиять на производительность. Лучше ограничить набор разрешённых выражений на уровне конфигурации.
Паттерны создавайте один раз и переиспользуйте. В SW это модульный скоуп воркера. В Node — инициализация до старта сервера. Перекомпиляция на каждый запрос — прямой путь к лишним миллисекундам.
Всегда собирайте абсолютный URL в Node, даже если вам кажется, что req.url
«и так нормальный». Это не так: без baseOrigin он невалиден как URL. Для Service Worker проверяйте origin, чтобы не пытаться перехватывать чужие запросы.
Если нужно нечувствительное сопоставление, используйте ignoreCase
на этапе создания паттерна, а не приводите вход к lower/upper — это ломает каноникализацию в непредсказуемых местах.
Поддержка браузеров хорошая, но если таргетируются редкие устройства, смотрим таблицы конкретных версий. В общем случае API можно считать применимым в продакшене на клиентах с актуальными версиями.
Один и тот же модуль маршрутов в двух средах
Небольшой приём для повторного использования хендлеров и маршрутов между SW и Node — договариваемся о минимальном контракте handler(ctx) => Response
. В Node оборачиваем Response
в нативный res
. Мы это уже сделали выше, но зафиксирую идею чётко:
// contracts.md.js (только для иллюстрации контракта, не исполняется)
/*
Context {
request: Request | http.IncomingMessage,
url: URL,
params: Record<string, string | number>,
match: URLPatternResult
}
Handler: (ctx: Context) => Promise<Response> | Response
*/
Не нужно думать, как достать query — всегда ctx.url.searchParams
. На тестах это тоже удобно: конструируете new Request(...)
или new URL(...)
, подменяете handler
, проверяете Response
.
URLPattern уже можно воспринимать как рабочий примитив для роутинга без фреймворка: одинаковая семантика в браузере и в Node 24, глобальный конструктор, внятный синтаксис с именованными группами и строгой валидацией на этапе компиляции; производительность ожидаемо уступает ручным RegExp и заточенным HTTP‑роутерам, но унификация клиент+сервер того стоит во многих задачах. Если у вас есть интересные кейсы, поделитесь в комментариях.
Приглашаем вас ознакомиться с курсом JavaScript Developer. Professional на платформе OTUS. Программа рассчитана на углубленное изучение современных возможностей JavaScript и связанных технологий, включая работу с фронтендом, серверной частью и современными инструментами экосистемы. А чтобы узнать, подойдет ли вам программа курса, пройдите вступительный тест.

А тем, кто настроен на серьезное системное обучение, рекомендуем рассмотреть Подписку — выбираете курсы под свои задачи, экономите на обучении, получаете профессиональный рост. Узнать подробнее