HttpContext & WsContext

Формування контексту та валідація всіх даних

itvibe-template · packages/backend · vendor (ядро фреймворку)
uWebSockets.js → snapshot → validation → context → handler

Поглиблення на основі коду src/vendor та src/app

Про що ця презентація

  • Два контексти фреймворку: HttpContext<T, Q> та WsContext<T>
  • Покроковий life-cycle формування кожного з них
  • Валідація всіх даних, що потрапляють у контекст:
    • body (payload) · query · URL-params · content-type · body-size
    • WS-envelope · WS-payload · upgrade-token · session
  • Де народжуються коди 400 / 413 / 415 / 422 / 429 та WS-error envelopes
  • Чому WsContext не має session/auth, а HttpContext має

Ключова ідея: у handler потрапляють лише валідовані, типобезпечні структури.

Два контексти — порівняння

АспектHttpContext<T, Q>WsContext<T>
ТранспортHTTP request/responseWS frame (подія)
Дані запитуhttpDatawsData
ВідповідьresponseData (status/headers/cookies)responseData (event/data/error)
Identitysession + auth (наповнює middleware)userData (з handshake), session=null, auth=null
requestId / loggerє (scoped child)є (scoped child)
Валідація payloadper-route validatorper-route validator
Валідація queryper-route queryValidator
Auth-перевіркана кожен запит (middleware)один раз на handshake

Обидва будуються однаковою філософією: raw → snapshot → validate → context.

HttpContext — повний тип

// vendor/types/types.d.ts
export interface HttpContext<TPayload = unknown, TQuery = null> {
  requestId: string;            // randomUUID, у логах/Sentry
  logger: Logger;               // pino.child({ requestId })
  httpData: HttpData<TPayload, TQuery>;   // ← усі вхідні дані
  responseData: ResponseData;   // status / payload / headers / cookies
  session: Session;             // Redis-сесія (default → реальна в middleware)
  auth: Auth;                   // login/logout/getUserId/check
}

Дженерики TPayload / TQuery виводяться автоматично з validator / queryValidator маршруту. Якщо схеми немає — тип звужується до unknown / null, і звернення до полів стає помилкою компіляції.

WsContext — повний тип

// vendor/types/types.d.ts
export interface WsContext<TPayload = unknown> {
  requestId: string;
  ws: MyWebSocket;              // сам сокет
  wsData: WsData<TPayload>;     // payload + middlewareData + status
  responseData: WsResponseData; // event / data / error / timestamp / status
  userData: UserConnection | null; // identity з handshake
  session: null;                // ← навмисно завжди null
  auth: null;                   // ← навмисно завжди null
  logger: Logger;
}
  • session/auth жорстко типізовані як null — це контракт, не випадковість
  • Identity (userId, sessionId, role) живе у userData

Аксіома uWS: req живе до першого await

У uWebSockets.js об'єкт HttpRequest стає невалідним одразу після першого await у хендлері.

// vendor/start/server.ts
// uWS `req` is only valid until the first `await`. Collect everything
// synchronously here so rate-limit (and any other pre-body checks) can run
// against a detached snapshot.
const collectRequestMetadata = (req, res, route): RequestMetadata => { ... }
  • Тому весь подальший pipeline працює зі «снепшотом», а не з req
  • Зчитування з req після await = undefined behaviour / краш
  • Це причина існування HttpData — детачнутих звичайних структур (Map/об'єкти)

HTTP — конвеєр формування контексту

setHttpHandler(res, req, route) ← vendor/start/server.ts │ ├─ 1. collectRequestMetadata(req,res,route) синхронний snapshot │ cookies · query · headers · params · ip · contentType · bodyKind │ ├─ 2. checkRateLimit(ip, route, groupRateLimit) ← ДО body (anti-DoS) │ не пройшов → 429 + Retry-After, далі не йдемо │ ├─ 3. validateQuery(meta, route) flattenQuery → ArkType │ немає queryValidator → null; невалідне → 422 │ ├─ 4. await readAndParseBody(res, meta, route) 415 / 413 / parse │ validator → validateRoutePayload → 422 │ ├─ 5. збірка HttpData { payload, query, params, headers, files... } │ contextHandler(httpData, responseData) → HttpContext │ ├─ 6. executeMiddlewares(route.middlewares, context) │ session_web → csrf_guard → auth_guard ... (наповнюють session/auth) │ └─ 7. route.handler(context) → responseData.payload → sendResponse

Порядок не випадковий: rate-limit і guards стоять до читання тіла.

Крок 1 — snapshot (collectRequestMetadata)

interface RequestMetadata {
  ip: string; cookies: Map<string,string>; query: URLSearchParams;
  headers: Map<string,string>; params: Record<string,string>;
  contentType: string | undefined; bodyKind: BodyKind; hasBody: boolean;
}

const collectRequestMetadata = (req, res, route) => {
  const cookies = parseCookies(req.getHeader("cookie"));
  const query   = new URLSearchParams(req.getQuery());
  const headers = getHeaders(req);
  const params  = route.parametersKey?.length
    ? extractParameters(route.parametersKey, req) : {};   // ← :params з URL
  const contentType = headers.get("content-type");
  const ip = getIP(req, res);                              // trusted-proxy aware
  const hasBody  = route.method === "post" || route.method === "put";
  const bodyKind = contentType ? detectBodyKind(contentType) : "other";
  return { ip, cookies, query, headers, params, contentType, bodyKind, hasBody };
};

Усе синхронно. extractParameters дістає :param сегменти за parametersKey; невалідний параметр → ParameterValidationError (400).

Валідація cookies — санітайзер під час snapshot

Cookies — єдине поле контексту, що валідується синхронно прямо в collectRequestMetadata (не per-route схемою). Фіксований allowlist у ядрі, без ArkType.

// parseCookies → validateCookie (http-request-handlers.ts / cookie-checker.ts)
if (i === -1) return;                         // немає '=' → тихо відкинути
if (validateCookie(key, value))               // ← gate, повертає boolean (не кидає)
  list.set(key, decodeURIComponent(value));   // зберігаємо ДЕКОДОВАНЕ; биті %xx → drop

COOKIE_NAME_PATTERN  = /^[a-zA-Z0-9_-]+$/            // key,  < 255
COOKIE_VALUE_PATTERN = /^[a-zA-Z0-9\-_.~*+/=%]+$/    // value (ще закодоване), < 10000
PERCENT_ENCODING     = /^(?:%[0-9A-Fa-f]{2}|[^%])*$/ // якщо value містить '%'
  • Тиха відмова, без 400/422: невалідна cookie не потрапляє в Map → у контексті лише валідні, decoded значення
  • Зіпсована session-cookie → get() === undefinedresolveSessionCookie:"none" → запит стає анонімним, а не помилковим (cookie — необов'язковий канал)
  • Патерн — на raw value (тому є %); коректність encoding окремо; у Map лягає decoded
  • Семантику (валідний токен/CSRF) вирішують далі: resolveSessionCookiesessionHandlercheckToken

Allowlist-санітайзер проти injection через імена/значення cookie та abuse завеликими cookie — до того, як дані стануть частиною HttpContext. Той самий parseCookies працює і в static-server.

Валідація headers — санітайзер під час snapshot

Симетрично до cookies: headers валідуються синхронно в getHeaders фіксованим allowlist (header-checker.ts), не per-route схемою. uWS віддає lowercase ключі.

// getHeaders — http-request-handlers.ts
req.forEach((key, value) => {
  if (key === "cookie") return;          // ← навмисно: парситься окремо в httpData.cookies
  if (validateHeader(key, value)) headers.set(key, value.trim()); // зберігаємо trimmed
  else logger.warn(`Invalid header detected and skipped: ${key}`);// тиха відмова
});
// validateHeader — header-checker.ts  (boolean, НЕ кидає)
HEADER_NAME_PATTERN  = /^[a-zA-Z0-9\-_]+$/   // name: 1…256, жорсткіше за RFC-token
HEADER_VALUE_PATTERN = /^[\x09\x20-\x7E]*$/  // tab + printable ASCII; value ≤ 3072
//   '*' → порожнє value дозволене (на відміну від cookies)
  • Захист від header/response injection: CR/LF (0x0D/0x0A) поза діапазоном → відкидаються (блок response splitting / smuggling)
  • Тиха відмова, без 400: невалідний header просто не потрапляє в Map; запит продовжується без нього
  • Похідні значення snapshot беруться вже з цього Map: headers.get("content-type")detectBodyKind; відкинутий content-type → bodyKind:"other"
  • Семантику читають далі: csrf_guard (X-CSRF-Token/Origin), session_api (Bearer), getIP (X-Forwarded-For)
  • Cookie навмисно виключений із headers-мапи: сирий cookie несе session/csrf/ws-секрети, а доступний уже розпарсений у httpData.cookies. Це усуває подвійну валідацію та запобігає витоку при дампі заголовків (напр. testHeaders). authorization лишається (його читає session_api), тож його маскують на віддачі.

Ліміти узгоджені з uWS (~4 KB на всі заголовки). Той самий getHeaders працює і в static-server.

Крок 2 — rate-limit ДО тіла (чому саме тут)

const rateLimitPassed = checkRateLimit(
  meta.ip, responseData, route, route.groupRateLimit);
if (!rateLimitPassed) {
  if (abortSignal.aborted) return;
  res.cork(() => sendResponse(res, responseData, origin));  // 429
  return;
}
  • Anti-DoS: інакше зловмисник змусив би сервер буферизувати й парсити тіло (до maxOctetBodySize) навіть на заблокованих маршрутах
  • Sync by design: якщо тут зробити await, uWS може доставити тіло маленького POST до реєстрації res.onData() — і хендлер назавжди зависне в pending
  • На 429 додається Retry-After + X-RateLimit-*

Крок 3 — валідація query

export const validateQuery = (meta, route): unknown => {
  if (route.queryValidator === undefined) {
    return null;                       // ← немає схеми → query === null
  }
  const arrayKeys  = new Set(route.queryArrays ?? []);
  const flattened  = flattenQuery(meta.query, arrayKeys);   // URLSearchParams → obj
  return validateRoutePayload(route.queryValidator, flattened);  // 422 при помилці
};
  • Виконується після rate-limit та до body — не витрачаємо IO/parse на вже невалідну query
  • Middleware і handler завжди бачать або типізований об'єкт, або null — ніколи raw URLSearchParams
  • Без queryValidator читання httpData.query.* — помилка TypeScript

flattenQuery — масиви та дублікати

export function flattenQuery(q: URLSearchParams,
  arrayKeys: ReadonlySet<string> = new Set()): FlattenedQuery {
  const out: FlattenedQuery = {}; const seen = new Set<string>();
  for (const [key, value] of q) {
    if (arrayKeys.has(key)) {                       // оголошений як масив
      const slot = out[key];
      Array.isArray(slot) ? slot.push(value) : (out[key] = [value]);
      continue;                                     // 1 входження → ['x'], не 'x'
    }
    if (seen.has(key)) throw new ValidationError([`Duplicate query key: ${key}`]);
    seen.add(key); out[key] = value;
  }
  return out;
}
  • Ключ у queryArrays → завжди string[] (навіть одне значення) → схема сміливо пише 'string[]'
  • Повтор не-масивного ключа → 422 (а не «тихо лишити останній», як Object.fromEntries)

queryValidator — strict / loose / morphs

// app/routing/define-route.ts — обгортка схеми при defineRoute
const applyStrictPolicy = (schema, allowExtra) =>
  schema.onUndeclaredKey(allowExtra ? "delete" : "reject");

const wrappedQueryValidator = config.queryValidator
  ? defaultValidator(applyStrictPolicy(config.queryValidator,
                                       config.queryAllowExtra === true))
  : undefined;
НалаштуванняПоведінка зайвих ключів
default (strict)reject → зайвий ключ = 422
queryAllowExtra: truedelete → пропустити, але зрізати з query

ArkType onUndeclaredKey зберігає morphs/defaults — змінюється лише політика зайвих ключів. Приклад морфів: type('string.integer.parse').pipe(type('number > 0')) з дефолтом.

Крок 4 — body: guard ДО читання (415 / 413)

export const readAndParseBody = async (res, meta, route, abortSignal) => {
  if (!meta.hasBody || meta.contentType === undefined)
    return { payload: null, files: null };

  const allowed = route.allowedContentTypes ?? ["json"];
  if (!allowed.includes(meta.bodyKind))                    // ← 415
    throw new UnsupportedMediaTypeError(meta.contentType, allowed);

  const limit = resolveMaxBodySize(meta.bodyKind);         // json/multipart/octet
  const declared = /^\d+$/.test(rawContentLength) ? Number(rawContentLength) : null;
  if (declared !== null && declared > limit)               // ← 413 до читання!
    throw new PayloadTooLargeError(limit, meta.contentType);

  const result = await getData(res, meta.contentType, meta.headers, limit, abortSignal);
  ...

Content-Type поза allowlist → 415. Content-Length > ліміт → 413 до виділення буфера. getData ще раз ріже за limit під час стрімінгу (захист від брехливого Content-Length).

Крок 4 — валідація тіла (payload)

// продовження readAndParseBody
  if (route.validator !== undefined && result.payload !== null) {
    return {
      payload: validateRoutePayload(route.validator, result.payload) as Payload,
      files: result.files,
    };
  }
  return { payload: null, files: result.files };  // ← НЕМАЄ validator → payload відкидається
};

Критично: якщо у маршруту немає validator — тіло повністю відкидається, payload = null.

  • Це навмисно: жоден не-валідований об'єкт не доходить до handler
  • files (multipart) проходять окремо й завжди доступні через httpData.files

validateRoutePayload + ArkType adapter

// vendor/utils/validation/validate-route-payload.ts
export const validateRoutePayload = <T>(validator: Validator<T>, payload): T => {
  const out = validator.validate(payload);
  if (!out.ok) throw new ValidationError(out.messages);   // → 422 (HTTP) / envelope (WS)
  return out.value;
};
// app/validate/adapters/arktype-adapter.ts — єдиний engine
export function arkValidator<T>(schema: Type<T>): Validator<T> {
  return {
    validate(input): ValidationResult<T> {
      const result = schema(input);
      if (result instanceof type.errors)
        return { ok: false, messages: [result.summary] };
      return { ok: true, value: result as T };     // ← morphs/defaults застосовані
    },
  };
}

Контракт Validator<T> ({ validate(input) → {ok,value} | {ok,messages} }) абстрагує движок. Body, query, WS-payload, WS-envelope — усі йдуть через один інтерфейс.

Крок 5 — збірка HttpData (всі поля)

const httpData: HttpData = {
  method:      route.method,
  ip:          meta.ip,
  params:      meta.params,            // валідовані :params
  payload,                             // валідоване body | null
  query:       validatedQuery,         // валідована query | null
  headers:     meta.headers,           // Map
  contentType: meta.contentType,
  cookies:     meta.cookies,           // Map
  isJson:      meta.hasBody && meta.bodyKind === "json",
  files,                               // Map<name, UploadedFile> | null
  hasFile: (name) => files?.has(name) ?? false,
};
const context = contextHandler(httpData, responseData);

До цього моменту кожне поле, що могло бути «небезпечним» (payload/query/params/content-type/size), вже пройшло перевірку. cookies і headers вже просанітизовані (allowlist) при snapshot — без control-символів та CRLF, зі здоровими лімітами. Семантику обох читають конкретні middleware (csrf, session).

contextHandler — народження HttpContext

// vendor/utils/context/http-context.ts
const session: Session = getDefaultSession();   // no-op сесія (singleton)
const auth: Auth = getDefaultAuth();            // getUserId → null, check → false

export default (httpData, responseData): HttpContext => {
  const requestId = randomUUID();
  const requestLogger = logger.child({ requestId });   // scoped pino
  return { requestId, logger: requestLogger,
           httpData, responseData, session, auth };
};
  • Контекст народжується з default session/auth (анонім: check()===false)
  • requestId → у logger.child → у всіх логах і Sentry цього запиту
  • Реальні session/auth підставляє middleware (наступний слайд)

session / auth — наповнює middleware

// middleware session_web завантажує Redis-сесію з cookie
// та підмінює context.session / context.auth реальними реалізаціями
const adminGuard: Middleware = async (context, next) => {
  const userId = context.auth.getUserId();   // вже не null, якщо session_web відпрацював
  if (userId === null) {
    context.responseData.status = 403;
    context.responseData.payload = { status: 'forbidden', ... };
    return;                                   // коротке замикання — next() НЕ викликаємо
  }
  await next();
};
  • session_web → сесія з cookie · session_api → за Bearer-токеном
  • csrf_guard читає headers / cookies з httpData та session.data.csrfToken
  • auth_guard → 401, якщо context.auth.check() === false

Handler виконується лише якщо ланцюг middleware не замкнувся і status ∈ [200,300).

Типобезпечний доступ: getTypedPayload / getTypedQuery

// vendor/utils/validation/get-typed-payload.ts
export function getTypedPayload<T>(context: HttpContext<T> | WsContext<T>): T {
  const payload = "httpData" in context
    ? context.httpData.payload : context.wsData.payload;
  if (payload === null) throw new Error("Payload is missing");  // навмисно — баг конфігу
  return payload as T;            // runtime-валідація вже пройшла у server/dispatcher
}

export function getTypedQuery<T>(context: HttpContext): T {
  const query = context.httpData.query;
  if (query === null)
    throw new Error("getTypedQuery called on a route without queryValidator");
  return query;
}
  • Хелпери не валідують — лише звужують тип (валідація вже відбулась раніше)
  • null → throw: це сигнал, що маршрут забув оголосити схему — баг має «вилізти» одразу

HTTP — матриця валідації даних контексту

ДаніДе перевіряютьсяПомилка → статус
client IPgetIP (trusted-proxy / CIDRs)
rate limitcheckRateLimit (до body)429 + Retry-After
URL :paramsextractParameters / checker400 (ParameterValidationError)
queryflattenQuery + queryValidator422 (ValidationError)
Content-TypeallowedContentTypes415 (UnsupportedMediaType)
body sizeContent-Length + стрім-ліміт413 (PayloadTooLarge)
body (payload)validator (ArkType)422 (ValidationError)
auth / rolesession/csrf/auth/admin guards401 / 403

Усі ці помилки централізовано мапить handleError() у канонічний { status, code, message, details }.

WS — конвеєр формування контексту

handshake: handleUpgrade(res, req, ctx) ← vendor/utils/network/ws-handlers.ts │ token з Sec-WebSocket-Protocol → checkToken (формат) │ onUpgrade(token) → verifyUpgradeToken (Redis) └─ res.upgrade(userData{ userId, sessionId, userToken, ip, appType... }) per-message: onMessage(ws, buf, isBinary, routes) │ "ping" → "pong"; isBinary → reject │ JSON.parse → getWsMessageValidator().validate(envelope) ← 1. envelope │ wsSessionHandler(sessionId, userToken) → session | null ← 2. session (Redis) │ null → closeExpiredSession(4001) └─ wsApiHandler(message, ws, userData, session, routes) │ route = routes[event.split(":")[0]] not found → error envelope │ checkRateLimitWs(ws, route) → rate envelope │ validateRoutePayload(route.validator) ← 3. payload (422-аналог) │ WsData збірка → createWsContext(...) → middleware → handler

WS handshake — token → userData

// handleUpgrade — token приходить через Sec-WebSocket-Protocol
const token   = protocols[0] ?? "";
const appType = normalizeAppTypeProtocol(protocols[1] ?? "");  // web | pwa

if (!checkToken(token)) {                       // формат: довжина + [a-zA-Z0-9]
  res.writeStatus("401 Unauthorized").end("Invalid token format");
  return;
}
const dataAccess = await getAppHooks().onUpgrade(token);  // → Redis verify

res.upgrade({
  ip, ip2, token, uuid: generateUUID(),
  sessionId: dataAccess?.sessionId, userId: dataAccess?.userId,
  userToken: dataAccess?.userToken, appType, userAgent, timeStart: Date.now(),
}, secWebsocketKey, token, secWebsocketExtensions, context);

Браузерний WebSocket API дозволяє лише Sec-WebSocket-Protocol як кастомний заголовок — туди й кладеться токен. checkToken валідує формат ще до Redis-перевірки.

WS — верифікація сесії (verifyUpgradeToken)

// app/websocket/ws-session-auth.ts
const tokenData = await redis.get(`auth:ws:${token}`);   // короткоживучий ключ
const { sessionId, userId, userToken } = JSON.parse(tokenData);
// усі поля мають бути непорожні, інакше → null

const sessionKey  = `session:${userToken}:${sessionId}`;
const sessionData = await redis.get(sessionKey);
const sessionInfo = JSON.parse(sessionData);
if (String(sessionInfo.data.userId) === userId)          // ← звірка identity
  return { sessionId, userId, userToken };
return null;                                              // mismatch → відмова
  • Двоступенева перевірка: ws-token (TTL 120с) → session у Redis
  • Звірка session.data.userId === token.userId — захист від підміни
  • Будь-яке неспівпадіння/відсутність → identity не формується

WS — валідація envelope (рівень повідомлення)

// onMessage — кожен фрейм спершу проходить envelope-валідатор
const parsedMessage = JSON.parse(jsonMessage);
const validatedMessage = getWsMessageValidator().validate(parsedMessage);
if (!validatedMessage.ok) {
  sendJson(ws, { status: "error",
    error: createWsErrorEnvelope("validation_failed", "Validation failure",
      { details: { kind: "validation", messages: validatedMessage.messages } }),
    event, timestamp, data: null });
  return;
}
const message = validatedMessage.value;   // { event, payload, timestamp }
  • WsMessage = { event: string; payload: object|null; timestamp: number }
  • Валідатор реєструється на старті: registerWsMessageValidator(...) (fail-fast, якщо забули)
  • Це зовнішня оболонка — до маршрутизації; невалідний конверт = одразу error envelope

WS — dispatcher: маршрут, rate-limit, payload

// vendor/utils/routing/ws-api-dispatcher.ts
const nameRoute = message.event.split(":")[0];     // "event_typing:42" → "event_typing"
const route = wsRoutesByUrl[nameRoute];
if (route === undefined) { /* error: not_found */ }

const rl = checkRateLimitWs(ws, route, route.groupRateLimit);
if (!rl.allowed) return createWsRateLimitErrorResponse(rl.errorMessage, rl.retryAfter, event);

let payload = message.payload ?? null;
if (route.validator !== undefined) {                       // ← 3-й рівень валідації
  payload = validateRoutePayload(route.validator, rawPayload);  // throw ValidationError
}

Три рівні валідації WS: (1) envelope у onMessage(2) session у Redis → (3) per-route validator payload тут. ValidationError ловиться у catch і стає error: { code:"validation_failed", ... }.

WS — збірка WsData (immutable payload)

const wsData: WsData = {
  ws,
  middlewareData: {},          // ← аналог "session/auth" контейнера для WS-middleware
  status: "200",
  payload:
    payload !== null && typeof payload === "object" && !Buffer.isBuffer(payload)
      ? Object.freeze({ ...payload })   // ← заморожена копія: handler не мутує вхід
      : (payload ?? {}),
};
  • Object.freeze({...payload}) — валідований payload стає незмінним знімком
  • middlewareData — місце, куди WS-middleware кладе похідну identity (бо session/auth = null)
  • Не-об'єктний payload (буфер/скаляр) передається як є; null → {}

createWsContext — народження WsContext

// vendor/utils/context/ws-context.ts
export default (wsData, responseData, userData, _session): WsContext => {
  const requestId = randomUUID();
  const requestLogger = logger.child({ requestId });
  return {
    requestId, logger: requestLogger,
    ws: wsData.ws, wsData, responseData,
    userData,           // ← identity з handshake (userId, sessionId, role, appType)
    session: null,      // ← навмисно
    auth: null,         // ← навмисно
  };
};

Зверніть увагу: session передається аргументом, але навмисно ігнорується (_session) — у контекст кладеться null. Сесія вже перевірена на рівні onMessage; для логіки handler-у вистачає userData + middlewareData.

Чому WsContext без session/auth?

HTTP

  • Кожен запит — окрема перевірка
  • session/auth наповнюються на кожен запит у middleware
  • Stateless-транспорт → identity щоразу заново

WebSocket

  • Auth один раз — на handshake
  • Далі identity «прибита» до сокета у userData
  • Сесія перевіряється на кожне повідомлення (TTL!), але в контекст не кладеться
// controllers/ws — identity береться з userData / middlewareData, не з auth
eventTyping(context: WsContext<WSEventTypingPayload>) {
  return wsService.eventTyping(getTypedPayload(context));   // payload вже заморожений+валідний
}

Перевага: немає round-trip за сесією на кожен метод; недолік — потрібен явний контроль протермінованої сесії (closeExpiredSession 4001).

WS — матриця валідації & error envelope

ДаніДеРеакція
token форматcheckToken (handshake)401, end
ws-token + sessionverifyUpgradeToken (Redis)userData без identity / 4001
binary frameonMessageignore + log
envelopewsMessageValidatorvalidation_failed
session TTLwsSessionHandlerunauthorized + close 4001
routewsRoutesByUrl[name]not_found
rate limitcheckRateLimitWsrate + retryAfter
payloadroute.validatorvalidation_failed
WsResponseData = { event, status, timestamp,
                   data: Payload | null,
                   error: { code, message, reason?, details? } | null }

Замість HTTP-статусів — уніфікований error envelope; status: "error" + машинно-читабельний code.

Підсумок

  • Один принцип на обидва транспорти: raw → snapshot → validate → context → handler
  • uWS snapshot: req живе до першого awaitcollectRequestMetadata рятує дані синхронно
  • Порядок перевірок невипадковий: rate-limit → query → content-type → size → body — безпека й анти-DoS до читання тіла
  • Усе валідується одним движком (ArkType через Validator<T>): body, query, params, WS-envelope, WS-payload
  • Немає схеми → немає даних: body/query без validator → null; getTyped* кидає, щоб баг конфігу вилазив одразу
  • HttpContext: session/auth наповнює middleware на кожен запит
  • WsContext: identity з handshake (userData), payload Object.freeze, session/auth = null — auth один раз на з'єднання
  • Типобезпека наскрізна: HttpContext<T, Q> / WsContext<T> виводяться з validator-ів автоматично

Код: vendor/start/server.ts · vendor/utils/context/* · vendor/utils/validation/* · vendor/utils/routing/ws-api-dispatcher.ts · app/websocket/ws-session-auth.ts