From c4974e9b93a6c05b20a55db0fb1d9714a50b0a23 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 10:01:55 +0200 Subject: [PATCH] feat(stalker): add cookie-aware portal HTTP session --- .../stalker-http-session.spec.ts | 511 ++++++++++++++++++ .../stalker-session/stalker-http-session.ts | 300 ++++++++++ 2 files changed, 811 insertions(+) create mode 100644 apps/electron-backend/src/app/services/stalker-session/stalker-http-session.spec.ts create mode 100644 apps/electron-backend/src/app/services/stalker-session/stalker-http-session.ts diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-http-session.spec.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-http-session.spec.ts new file mode 100644 index 000000000..9541adde2 --- /dev/null +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-http-session.spec.ts @@ -0,0 +1,511 @@ +import axios, { AxiosError } from 'axios'; +import { StalkerCookieJar } from './stalker-cookie-jar'; +import { + STALKER_HTTP_REQUEST_MODES, + StalkerHttpSession, + type StalkerHttpRequestOutcome, +} from './stalker-http-session'; + +jest.mock('axios', () => { + const actual = jest.requireActual('axios'); + return { + ...actual, + __esModule: true, + default: Object.assign(jest.fn(), actual.default), + }; +}); + +const axiosMock = axios as unknown as jest.Mock; +const DEFAULT_TRANSPORT = { + maxResponseBytes: 1024, + timeoutMs: 2500, +}; + +function expectNoSecretEcho( + outcome: StalkerHttpRequestOutcome, + secrets: readonly string[] +): void { + const serialized = JSON.stringify(outcome); + for (const secret of secrets) { + expect(serialized).not.toContain(secret); + } +} + +describe('StalkerHttpSession', () => { + beforeEach(() => { + axiosMock.mockReset(); + }); + + it('uses bounded arraybuffer transport, Axios params, pinned LAN policy, and preserves HTTP statuses', async () => { + axiosMock.mockImplementationOnce(async (config) => ({ + config, + data: Buffer.from('denied', 'utf8'), + headers: {}, + status: 403, + })); + const session = new StalkerHttpSession( + new StalkerCookieJar(), + DEFAULT_TRANSPORT + ); + + const outcome = await session.request({ + mode: STALKER_HTTP_REQUEST_MODES.Anonymous, + params: { + action: 'handshake', + type: 'stb', + }, + url: 'http://192.168.1.20/server/load.php#ignored', + }); + + expect(outcome.kind).toBe('success'); + if (outcome.kind !== 'success') { + throw new Error('Expected transport success'); + } + expect(outcome.result).toMatchObject({ + finalUrl: 'http://192.168.1.20/server/load.php', + status: 403, + }); + expect(Buffer.from(outcome.result.body).toString('utf8')).toBe( + 'denied' + ); + + expect(axiosMock).toHaveBeenCalledTimes(1); + const config = axiosMock.mock.calls[0][0]; + expect(config).toMatchObject({ + maxBodyLength: DEFAULT_TRANSPORT.maxResponseBytes, + maxContentLength: DEFAULT_TRANSPORT.maxResponseBytes, + maxRedirects: 0, + method: 'GET', + params: { + action: 'handshake', + type: 'stb', + }, + proxy: false, + responseType: 'arraybuffer', + timeout: DEFAULT_TRANSPORT.timeoutMs, + url: 'http://192.168.1.20/server/load.php', + }); + expect(config.validateStatus(403)).toBe(true); + expect(config.validateStatus(500)).toBe(true); + expect(config.httpAgent).toBeDefined(); + expect(config.headers).not.toHaveProperty('Authorization'); + expect(config.headers).not.toHaveProperty('Cookie'); + expect(config.headers).not.toHaveProperty('Proxy-Authorization'); + }); + + it.each([200, 401, 404, 429, 500, 503])( + 'returns HTTP %i as a successful transport result', + async (status) => { + axiosMock.mockResolvedValueOnce({ + data: Buffer.from('{}'), + headers: {}, + status, + }); + const session = new StalkerHttpSession( + new StalkerCookieJar(), + DEFAULT_TRANSPORT + ); + + const outcome = await session.request({ + mode: STALKER_HTTP_REQUEST_MODES.Anonymous, + url: 'http://10.0.0.20/server/load.php', + }); + + expect(outcome).toMatchObject({ + kind: 'success', + result: { status }, + }); + } + ); + + it('prepares and collects the cookie jar on every same-origin hop without flattening Set-Cookie arrays', async () => { + const jar = new StalkerCookieJar({ + mac: '00:1A:79:00:00:01', + stb_lang: 'en', + timezone: 'UTC', + }); + await jar.collectResponseCookies( + 'http://192.168.1.20/server/load.php', + ['seed=present; Path=/'] + ); + const getCookieHeader = jest.spyOn(jar, 'getCookieHeader'); + const collectResponseCookies = jest.spyOn( + jar, + 'collectResponseCookies' + ); + axiosMock + .mockResolvedValueOnce({ + data: Buffer.alloc(0), + headers: { + location: '/server/next.php', + 'set-cookie': ['first=one; Path=/', 'second=two; Path=/'], + }, + status: 302, + }) + .mockResolvedValueOnce({ + data: Buffer.from('{"js":{}}'), + headers: { + 'set-cookie': ['final=three; Path=/'], + }, + status: 200, + }); + const session = new StalkerHttpSession(jar, DEFAULT_TRANSPORT); + + const outcome = await session.request({ + headers: { + Authorization: 'Bearer same-origin-token', + Cookie: 'caller-cookie=must-not-win', + 'Proxy-Authorization': 'Basic must-not-be-sent', + 'X-User-Agent': 'Model: MAG250', + }, + mode: STALKER_HTTP_REQUEST_MODES.IdentityBearing, + params: { action: 'get_profile', type: 'stb' }, + url: 'http://192.168.1.20/server/load.php', + }); + + expect(outcome.kind).toBe('success'); + expect(getCookieHeader).toHaveBeenNthCalledWith( + 1, + 'http://192.168.1.20/server/load.php' + ); + expect(getCookieHeader).toHaveBeenNthCalledWith( + 2, + 'http://192.168.1.20/server/next.php' + ); + expect(collectResponseCookies).toHaveBeenNthCalledWith( + 1, + 'http://192.168.1.20/server/load.php', + ['first=one; Path=/', 'second=two; Path=/'] + ); + expect(collectResponseCookies).toHaveBeenNthCalledWith( + 2, + 'http://192.168.1.20/server/next.php', + ['final=three; Path=/'] + ); + + const firstHeaders = axiosMock.mock.calls[0][0].headers; + const secondHeaders = axiosMock.mock.calls[1][0].headers; + expect(firstHeaders.Authorization).toBe('Bearer same-origin-token'); + expect(firstHeaders.Cookie).toContain('seed=present'); + expect(firstHeaders.Cookie).not.toContain('caller-cookie'); + expect(firstHeaders).not.toHaveProperty('Proxy-Authorization'); + expect(secondHeaders.Authorization).toBe('Bearer same-origin-token'); + expect(secondHeaders.Cookie).toContain('first=one'); + expect(secondHeaders.Cookie).toContain('second=two'); + expect(secondHeaders.Cookie).toContain('mac=00:1A:79:00:00:01'); + }); + + it('keeps anonymous requests free of identity headers and managed credentials', async () => { + axiosMock.mockResolvedValueOnce({ + data: Buffer.from('{}'), + headers: {}, + status: 200, + }); + const session = new StalkerHttpSession( + new StalkerCookieJar(), + DEFAULT_TRANSPORT + ); + + await session.request({ + mode: STALKER_HTTP_REQUEST_MODES.Anonymous, + params: { probe: 'landing' }, + url: 'http://172.16.0.10/c/', + }); + + const config = axiosMock.mock.calls[0][0]; + expect(config.params).toEqual({ probe: 'landing' }); + expect( + Object.keys(config.headers).map((name) => name.toLowerCase()) + ).not.toEqual( + expect.arrayContaining([ + 'authorization', + 'cookie', + 'proxy-authorization', + 'sn', + 'x-user-agent', + ]) + ); + }); + + it('pauses an identity-bearing cross-origin redirect before target preparation or contact', async () => { + const secrets = [ + 'Bearer source-secret', + 'caller-cookie-secret', + 'proxy-secret', + 'provider-user', + 'provider-password', + '00:1A:79:00:00:09', + ]; + const jar = new StalkerCookieJar({ + mac: '00:1A:79:00:00:09', + stb_lang: 'en', + timezone: 'UTC', + }); + const getCookieHeader = jest.spyOn(jar, 'getCookieHeader'); + const collectResponseCookies = jest.spyOn( + jar, + 'collectResponseCookies' + ); + axiosMock.mockResolvedValueOnce({ + data: Buffer.alloc(0), + headers: { + location: 'http://192.168.2.30/landing?display=portal', + 'set-cookie': [ + 'landing=retained-before-pause; Path=/', + 'route=blue; Path=/', + ], + }, + status: 302, + }); + const session = new StalkerHttpSession(jar, DEFAULT_TRANSPORT); + + const outcome = await session.request({ + headers: { + Authorization: 'Bearer source-secret', + Cookie: 'caller-cookie-secret', + 'Proxy-Authorization': 'Basic proxy-secret', + 'X-User-Agent': 'Model: MAG250', + }, + mode: STALKER_HTTP_REQUEST_MODES.IdentityBearing, + params: { + login: 'provider-user', + mac: '00:1A:79:00:00:09', + password: 'provider-password', + }, + url: 'http://192.168.1.20/server/load.php', + }); + + expect(outcome).toEqual({ + finalOrigin: 'http://192.168.2.30', + kind: 'origin-approval-required', + sourceOrigin: 'http://192.168.1.20', + }); + expectNoSecretEcho(outcome, secrets); + expect(axiosMock).toHaveBeenCalledTimes(1); + expect(getCookieHeader).toHaveBeenCalledTimes(1); + expect(collectResponseCookies).toHaveBeenCalledWith( + 'http://192.168.1.20/server/load.php', + ['landing=retained-before-pause; Path=/', 'route=blue; Path=/'] + ); + + const sourceConfig = axiosMock.mock.calls[0][0]; + expect(sourceConfig.headers.Authorization).toBe('Bearer source-secret'); + expect(sourceConfig.headers.Cookie).toContain('mac=00:1A:79:00:00:09'); + expect(sourceConfig.headers.Cookie).not.toContain( + 'caller-cookie-secret' + ); + expect(sourceConfig.headers).not.toHaveProperty('Proxy-Authorization'); + }); + + it.each([ + 'http://portal-user:portal-password@192.168.1.20/server/load.php', + 'http://192.168.1.20/server/load.php?token=query-secret', + 'http://192.168.1.20/server/load.php?USERNAME=query-secret', + 'http://192.168.1.20/server/load.php?mac=00:1A:79:00:00:07', + ])( + 'rejects an unsafe initial target without contact or secret echo', + async (url) => { + const session = new StalkerHttpSession( + new StalkerCookieJar(), + DEFAULT_TRANSPORT + ); + + const outcome = await session.request({ + mode: STALKER_HTTP_REQUEST_MODES.Anonymous, + url, + }); + + expect(outcome).toEqual({ + kind: 'failure', + reason: 'invalid-url', + retryable: false, + }); + expectNoSecretEcho(outcome, [ + 'portal-user', + 'portal-password', + 'query-secret', + '00:1A:79:00:00:07', + ]); + expect(axiosMock).not.toHaveBeenCalled(); + } + ); + + it.each([ + 'http://redirect-user:redirect-password@192.168.2.30/landing', + 'http://192.168.2.30/landing?authorization=redirect-secret', + 'http://192.168.2.30/landing?device_id=redirect-secret', + ])( + 'rejects an unsafe redirect before target contact or secret echo', + async (location) => { + axiosMock.mockResolvedValueOnce({ + data: Buffer.alloc(0), + headers: { location }, + status: 302, + }); + const session = new StalkerHttpSession( + new StalkerCookieJar(), + DEFAULT_TRANSPORT + ); + + const outcome = await session.request({ + mode: STALKER_HTTP_REQUEST_MODES.Anonymous, + url: 'http://192.168.1.20/server/load.php', + }); + + expect(outcome).toEqual({ + kind: 'failure', + reason: 'invalid-url', + retryable: false, + }); + expectNoSecretEcho(outcome, [ + 'redirect-user', + 'redirect-password', + 'redirect-secret', + ]); + expect(axiosMock).toHaveBeenCalledTimes(1); + } + ); + + it('checks the actual response byte length even when an adapter ignores maxContentLength', async () => { + const responseSecret = '12345'; + axiosMock.mockResolvedValueOnce({ + data: Buffer.from(responseSecret), + headers: {}, + status: 200, + }); + const session = new StalkerHttpSession(new StalkerCookieJar(), { + maxResponseBytes: 4, + timeoutMs: 1000, + }); + + const outcome = await session.request({ + mode: STALKER_HTTP_REQUEST_MODES.Anonymous, + url: 'http://192.168.1.20/server/load.php', + }); + + expect(outcome).toEqual({ + kind: 'failure', + reason: 'response-too-large', + retryable: false, + }); + expectNoSecretEcho(outcome, [responseSecret]); + expect(axiosMock.mock.calls[0][0]).toMatchObject({ + maxBodyLength: 4, + maxContentLength: 4, + }); + }); + + it.each([ + { + transport: { + maxResponseBytes: 1024, + timeoutMs: Number.POSITIVE_INFINITY, + }, + }, + { + transport: { + maxResponseBytes: Number.NaN, + timeoutMs: 1000, + }, + }, + { + transport: { + maxResponseBytes: 0, + timeoutMs: 1000, + }, + }, + ])( + 'rejects non-finite or non-positive transport bounds before contact', + async ({ transport }) => { + const session = new StalkerHttpSession( + new StalkerCookieJar(), + transport + ); + + const outcome = await session.request({ + mode: STALKER_HTTP_REQUEST_MODES.Anonymous, + url: 'http://192.168.1.20/server/load.php', + }); + + expect(outcome).toEqual({ + kind: 'failure', + reason: 'invalid-identity-input', + retryable: false, + }); + expect(axiosMock).not.toHaveBeenCalled(); + } + ); + + it.each([ + { + error: Object.assign( + new Error( + 'request to http://portal/?token=transport-secret timed out' + ), + { code: 'ETIMEDOUT' } + ), + expected: { + kind: 'failure', + reason: 'request-timeout', + retryable: true, + }, + }, + { + error: new AxiosError( + 'maxContentLength size of transport-secret exceeded', + 'ERR_BAD_RESPONSE' + ), + expected: { + kind: 'failure', + reason: 'response-too-large', + retryable: false, + }, + }, + ])( + 'maps transport failures to stable redacted outcomes', + async ({ error, expected }) => { + axiosMock.mockRejectedValueOnce(error); + const session = new StalkerHttpSession( + new StalkerCookieJar(), + DEFAULT_TRANSPORT + ); + + const outcome = await session.request({ + mode: STALKER_HTTP_REQUEST_MODES.Anonymous, + url: 'http://192.168.1.20/server/load.php', + }); + + expect(outcome).toEqual(expected); + expectNoSecretEcho(outcome, ['transport-secret']); + } + ); + + it('returns undecoded bytes so strict UTF-8 and JSON consumers run only after the cap', async () => { + const invalidUtf8 = Buffer.from([0xc3, 0x28]); + axiosMock.mockResolvedValueOnce({ + data: invalidUtf8, + headers: {}, + status: 200, + }); + const session = new StalkerHttpSession( + new StalkerCookieJar(), + DEFAULT_TRANSPORT + ); + + const outcome = await session.request({ + mode: STALKER_HTTP_REQUEST_MODES.Anonymous, + url: 'http://192.168.1.20/server/load.php', + }); + + expect(outcome.kind).toBe('success'); + if (outcome.kind !== 'success') { + throw new Error('Expected transport success'); + } + expect(Array.from(outcome.result.body)).toEqual([0xc3, 0x28]); + expect(() => + new TextDecoder('utf-8', { fatal: true }).decode( + outcome.result.body + ) + ).toThrow(); + }); +}); diff --git a/apps/electron-backend/src/app/services/stalker-session/stalker-http-session.ts b/apps/electron-backend/src/app/services/stalker-session/stalker-http-session.ts new file mode 100644 index 000000000..282f98b5c --- /dev/null +++ b/apps/electron-backend/src/app/services/stalker-session/stalker-http-session.ts @@ -0,0 +1,300 @@ +import type { RawAxiosRequestHeaders, RawAxiosResponseHeaders } from 'axios'; +import { + STALKER_FAILURE_REASONS, + normalizeStalkerSourceUrl, +} from '@iptvnator/portal/stalker/protocol'; +import type { StalkerSessionFailureReason } from '@iptvnator/shared/interfaces'; +import { requestWithValidatedRedirects } from '../../util/validated-axios'; +import type { ValidatedAxiosRedirectContext } from '../../util/validated-axios'; +import { StalkerCookieJar } from './stalker-cookie-jar'; +import { StalkerRuntimeValidationError } from './stalker-runtime-validation'; +import { + STALKER_RUNTIME_LIMITS, + type StalkerTransportConfig, + type StalkerTransportResult, +} from './stalker-session.types'; +import { mapStalkerTransportError } from './stalker-transport-errors'; + +export const STALKER_HTTP_REQUEST_MODES = { + Anonymous: 'anonymous', + IdentityBearing: 'identity-bearing', +} as const; + +export type StalkerHttpRequestMode = + (typeof STALKER_HTTP_REQUEST_MODES)[keyof typeof STALKER_HTTP_REQUEST_MODES]; + +export const STALKER_HTTP_OUTCOME_KINDS = { + Failure: 'failure', + OriginApprovalRequired: 'origin-approval-required', + Success: 'success', +} as const; + +export type StalkerHttpParameterValue = string | number; +export type StalkerHttpParameters = Readonly< + Record +>; + +export interface StalkerAnonymousHttpRequest { + readonly mode: typeof STALKER_HTTP_REQUEST_MODES.Anonymous; + readonly params?: StalkerHttpParameters; + readonly url: string; +} + +export interface StalkerIdentityBearingHttpRequest { + readonly headers: Readonly>; + readonly mode: typeof STALKER_HTTP_REQUEST_MODES.IdentityBearing; + readonly params?: StalkerHttpParameters; + readonly url: string; +} + +export type StalkerHttpRequest = + | StalkerAnonymousHttpRequest + | StalkerIdentityBearingHttpRequest; + +export interface StalkerHttpSuccessOutcome { + readonly kind: typeof STALKER_HTTP_OUTCOME_KINDS.Success; + readonly result: StalkerTransportResult; +} + +export interface StalkerHttpOriginApprovalRequiredOutcome { + readonly finalOrigin: string; + readonly kind: typeof STALKER_HTTP_OUTCOME_KINDS.OriginApprovalRequired; + readonly sourceOrigin: string; +} + +export interface StalkerHttpFailureOutcome { + readonly kind: typeof STALKER_HTTP_OUTCOME_KINDS.Failure; + readonly reason: StalkerSessionFailureReason; + readonly retryable: boolean; +} + +export type StalkerHttpRequestOutcome = + | StalkerHttpSuccessOutcome + | StalkerHttpOriginApprovalRequiredOutcome + | StalkerHttpFailureOutcome; + +const CALLER_MANAGED_HEADERS = new Set(['cookie', 'proxy-authorization']); + +class StalkerOriginApprovalPause extends Error { + constructor( + readonly sourceOrigin: string, + readonly finalOrigin: string + ) { + super(STALKER_HTTP_OUTCOME_KINDS.OriginApprovalRequired); + this.name = 'StalkerOriginApprovalPause'; + } +} + +export class StalkerHttpSession { + constructor( + private readonly cookieJar: StalkerCookieJar, + private readonly transport: StalkerTransportConfig + ) {} + + async request( + request: StalkerHttpRequest + ): Promise { + try { + assertTransportConfig(this.transport); + const sourceUrl = normalizeStalkerSourceUrl(request.url); + const sourceOrigin = new URL(sourceUrl).origin; + const identityBearing = + request.mode === STALKER_HTTP_REQUEST_MODES.IdentityBearing; + const baseHeaders = identityBearing + ? copyIdentityHeaders(request.headers) + : undefined; + let finalUrl = sourceUrl; + + const response = await requestWithValidatedRedirects( + sourceUrl, + { + headers: baseHeaders, + maxBodyLength: this.transport.maxResponseBytes, + maxContentLength: this.transport.maxResponseBytes, + method: 'GET', + params: + request.params === undefined + ? undefined + : { ...request.params }, + redirectHooks: { + beforeValidatedRedirect: (redirect) => { + validateRedirectTarget( + redirect, + identityBearing, + sourceOrigin + ); + }, + collectResponse: async (hop) => { + finalUrl = normalizeStalkerSourceUrl(hop.url); + await this.cookieJar.collectResponseCookies( + hop.url, + readSetCookieHeaders(hop.headers) + ); + }, + prepareHeaders: async (hop) => { + const cookieHeader = + await this.cookieJar.getCookieHeader(hop.url); + return prepareHopHeaders(hop.headers, cookieHeader); + }, + }, + responseType: 'arraybuffer', + timeout: this.transport.timeoutMs, + validateStatus: () => true, + }, + { + allowPrivateNetworks: true, + pinAllowedPrivateNetworkHosts: true, + } + ); + + const byteLength = responseByteLength(response.data); + if (byteLength === undefined) { + return failure( + STALKER_FAILURE_REASONS.IncompatibleResponse, + false + ); + } + if (byteLength > this.transport.maxResponseBytes) { + return failure(STALKER_FAILURE_REASONS.ResponseTooLarge, false); + } + if (!isHttpStatus(response.status)) { + return failure( + STALKER_FAILURE_REASONS.IncompatibleResponse, + false + ); + } + + return { + kind: STALKER_HTTP_OUTCOME_KINDS.Success, + result: { + body: copyResponseBytes(response.data), + finalUrl, + status: response.status, + }, + }; + } catch (error) { + if (error instanceof StalkerOriginApprovalPause) { + return { + finalOrigin: error.finalOrigin, + kind: STALKER_HTTP_OUTCOME_KINDS.OriginApprovalRequired, + sourceOrigin: error.sourceOrigin, + }; + } + + const mapped = mapStalkerTransportError(error); + return mapped + ? failure(mapped.reason, mapped.retryable) + : failure(STALKER_FAILURE_REASONS.IncompatibleResponse, false); + } + } +} + +function assertTransportConfig(config: StalkerTransportConfig): void { + if ( + !isPositiveFiniteBound( + config.timeoutMs, + STALKER_RUNTIME_LIMITS.timeoutMs + ) || + !isPositiveFiniteBound( + config.maxResponseBytes, + STALKER_RUNTIME_LIMITS.maxResponseBytes + ) + ) { + throw new StalkerRuntimeValidationError( + STALKER_FAILURE_REASONS.InvalidIdentityInput + ); + } +} + +function isPositiveFiniteBound(value: number, maximum: number): boolean { + return Number.isFinite(value) && value > 0 && value <= maximum; +} + +function copyIdentityHeaders( + headers: Readonly> +): RawAxiosRequestHeaders { + return Object.fromEntries( + Object.entries(headers).filter( + ([name]) => !CALLER_MANAGED_HEADERS.has(name.toLowerCase()) + ) + ); +} + +function prepareHopHeaders( + headers: RawAxiosRequestHeaders, + cookieHeader: string | undefined +): RawAxiosRequestHeaders { + const prepared = Object.fromEntries( + Object.entries(headers).filter( + ([name]) => !CALLER_MANAGED_HEADERS.has(name.toLowerCase()) + ) + ) as RawAxiosRequestHeaders; + if (cookieHeader !== undefined) { + prepared['Cookie'] = cookieHeader; + } + return prepared; +} + +function readSetCookieHeaders( + headers: RawAxiosResponseHeaders +): string | readonly string[] | undefined { + const value = headers['set-cookie']; + if (typeof value === 'string') { + return value; + } + if ( + Array.isArray(value) && + value.every((entry): entry is string => typeof entry === 'string') + ) { + return value; + } + return undefined; +} + +function validateRedirectTarget( + redirect: ValidatedAxiosRedirectContext, + identityBearing: boolean, + sourceOrigin: string +): void { + const normalizedTarget = normalizeStalkerSourceUrl(redirect.toUrl); + if (identityBearing && redirect.crossOrigin) { + throw new StalkerOriginApprovalPause( + sourceOrigin, + new URL(normalizedTarget).origin + ); + } +} + +function responseByteLength(value: unknown): number | undefined { + if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) { + return value.byteLength; + } + return undefined; +} + +function copyResponseBytes(value: unknown): Uint8Array { + if (value instanceof ArrayBuffer) { + return new Uint8Array(value.slice(0)); + } + if (ArrayBuffer.isView(value)) { + return Uint8Array.from( + new Uint8Array(value.buffer, value.byteOffset, value.byteLength) + ); + } + throw new Error(STALKER_FAILURE_REASONS.IncompatibleResponse); +} + +function isHttpStatus(status: number): boolean { + return Number.isInteger(status) && status >= 100 && status <= 599; +} + +function failure( + reason: StalkerSessionFailureReason, + retryable: boolean +): StalkerHttpFailureOutcome { + return { + kind: STALKER_HTTP_OUTCOME_KINDS.Failure, + reason, + retryable, + }; +}