itvibe-template · packages/backend · vendor (ядро фреймворку)
uWebSockets.js → snapshot → validation → context → handler
Поглиблення на основі коду src/vendor та src/app
HttpContext<T, Q> та WsContext<T>WsContext не має session/auth, а HttpContext маєКлючова ідея: у handler потрапляють лише валідовані, типобезпечні структури.
| Аспект | HttpContext<T, Q> | WsContext<T> |
|---|---|---|
| Транспорт | HTTP request/response | WS frame (подія) |
| Дані запиту | httpData | wsData |
| Відповідь | responseData (status/headers/cookies) | responseData (event/data/error) |
| Identity | session + auth (наповнює middleware) | userData (з handshake), session=null, auth=null |
| requestId / logger | є (scoped child) | є (scoped child) |
| Валідація payload | per-route validator | per-route validator |
| Валідація query | per-route queryValidator | — |
| Auth-перевірка | на кожен запит (middleware) | один раз на handshake |
Обидва будуються однаковою філософією: raw → snapshot → validate → context.
// 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, і звернення до полів стає помилкою компіляції.
// 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 — це контракт, не випадковістьuserDatareq живе до першого 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 => { ... }
reqreq після await = undefined behaviour / крашHttpData — детачнутих звичайних структур (Map/об'єкти)Порядок не випадковий: rate-limit і guards стоять до читання тіла.
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 — єдине поле контексту, що валідується синхронно прямо в 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 містить '%'
Map → у контексті лише валідні, decoded значенняget() === undefined → resolveSessionCookie:"none" → запит стає анонімним, а не помилковим (cookie — необов'язковий канал)%); коректність encoding окремо; у Map лягає decodedresolveSessionCookie → sessionHandler → checkTokenAllowlist-санітайзер проти injection через імена/значення cookie та abuse завеликими cookie — до того, як дані стануть частиною HttpContext. Той самий parseCookies працює і в static-server.
Симетрично до 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)
CR/LF (0x0D/0x0A) поза діапазоном → відкидаються (блок response splitting / smuggling)headers.get("content-type") → detectBodyKind; відкинутий content-type → bodyKind:"other"csrf_guard (X-CSRF-Token/Origin), session_api (Bearer), getIP (X-Forwarded-For)cookie несе session/csrf/ws-секрети, а доступний уже розпарсений у httpData.cookies. Це усуває подвійну валідацію та запобігає витоку при дампі заголовків (напр. testHeaders). authorization лишається (його читає session_api), тож його маскують на віддачі.Ліміти узгоджені з uWS (~4 KB на всі заголовки). Той самий getHeaders працює і в static-server.
const rateLimitPassed = checkRateLimit(
meta.ip, responseData, route, route.groupRateLimit);
if (!rateLimitPassed) {
if (abortSignal.aborted) return;
res.cork(() => sendResponse(res, responseData, origin)); // 429
return;
}
maxOctetBodySize) навіть на заблокованих маршрутахawait, uWS може доставити тіло маленького POST до реєстрації res.onData() — і хендлер назавжди зависне в pendingRetry-After + X-RateLimit-*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 при помилці
};
null — ніколи raw URLSearchParamsqueryValidator читання httpData.query.* — помилка TypeScriptexport 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[]'Object.fromEntries)// 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: true | delete → пропустити, але зрізати з query |
ArkType onUndeclaredKey зберігає morphs/defaults — змінюється лише політика зайвих ключів. Приклад морфів: type('string.integer.parse').pipe(type('number > 0')) з дефолтом.
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).
// продовження 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.
files (multipart) проходять окремо й завжди доступні через httpData.files// 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 — усі йдуть через один інтерфейс.
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).
// 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 };
};
check()===false)requestId → у logger.child → у всіх логах і Sentry цього запиту// 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.csrfTokenauth_guard → 401, якщо context.auth.check() === falseHandler виконується лише якщо ланцюг middleware не замкнувся і status ∈ [200,300).
// 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: це сигнал, що маршрут забув оголосити схему — баг має «вилізти» одразу| Дані | Де перевіряються | Помилка → статус |
|---|---|---|
| client IP | getIP (trusted-proxy / CIDRs) | — |
| rate limit | checkRateLimit (до body) | 429 + Retry-After |
URL :params | extractParameters / checker | 400 (ParameterValidationError) |
| query | flattenQuery + queryValidator | 422 (ValidationError) |
| Content-Type | allowedContentTypes | 415 (UnsupportedMediaType) |
| body size | Content-Length + стрім-ліміт | 413 (PayloadTooLarge) |
| body (payload) | validator (ArkType) | 422 (ValidationError) |
| auth / role | session/csrf/auth/admin guards | 401 / 403 |
Усі ці помилки централізовано мапить handleError() у канонічний { status, code, message, details }.
// 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-перевірки.
// 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 → відмова
session.data.userId === token.userId — захист від підміни// 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, якщо забули)// 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", ... }.
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)null → {}// 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.
userData// controllers/ws — identity береться з userData / middlewareData, не з auth
eventTyping(context: WsContext<WSEventTypingPayload>) {
return wsService.eventTyping(getTypedPayload(context)); // payload вже заморожений+валідний
}
Перевага: немає round-trip за сесією на кожен метод; недолік — потрібен явний контроль протермінованої сесії (closeExpiredSession 4001).
| Дані | Де | Реакція |
|---|---|---|
| token формат | checkToken (handshake) | 401, end |
| ws-token + session | verifyUpgradeToken (Redis) | userData без identity / 4001 |
| binary frame | onMessage | ignore + log |
| envelope | wsMessageValidator | validation_failed |
| session TTL | wsSessionHandler | unauthorized + close 4001 |
| route | wsRoutesByUrl[name] | not_found |
| rate limit | checkRateLimitWs | rate + retryAfter |
| payload | route.validator | validation_failed |
WsResponseData = { event, status, timestamp,
data: Payload | null,
error: { code, message, reason?, details? } | null }
Замість HTTP-статусів — уніфікований error envelope; status: "error" + машинно-читабельний code.
raw → snapshot → validate → context → handlerreq живе до першого await → collectRequestMetadata рятує дані синхронноValidator<T>): body, query, params, WS-envelope, WS-payloadnull; getTyped* кидає, щоб баг конфігу вилазив одразу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