feat(stalker): bind secret playback contexts in main

This commit is contained in:
4gray committed 2026-07-27 10:37:27 +02:00
1 parent d5acb329e0
commit 580a056035
4 files changed
+656 -81

No files matched your search

@@ -2,6 +2,7 @@ import { LEGACY_DEFAULT_STALKER_SERIAL } from '@iptvnator/shared/interfaces';
import {
getStalkerPlaybackContextHeaders,
rememberStalkerPlaybackContext,
StalkerPlaybackContextService,
} from './stalker-playback-context.service';
describe('stalker playback context', () => {
@@ -52,4 +53,190 @@ describe('stalker playback context', () => {
expect(headers).not.toHaveProperty('SN');
expect(headers['Cookie']).not.toContain('__cfduid=');
});
it('does not reuse a legacy context for another query or origin path', () => {
const origin = 'http://legacy-context.example.test';
rememberStalkerPlaybackContext({
streamUrl: `${origin}/stream/1.ts?ticket=one`,
portalUrl: `${origin}/stalker_portal/server/load.php`,
macAddress,
token: 'token-1',
});
expect(
getStalkerPlaybackContextHeaders(`${origin}/stream/1.ts?ticket=two`)
).toBeNull();
expect(
getStalkerPlaybackContextHeaders(`${origin}/another.ts`)
).toBeNull();
});
});
describe('opaque Stalker playback contexts', () => {
let now: number;
let nextRef: number;
let service: StalkerPlaybackContextService;
beforeEach(() => {
now = 1_000;
nextRef = 0;
service = new StalkerPlaybackContextService({
createRef: () => `opaque-ref-${++nextRef}`,
now: () => now,
});
});
function register(
overrides: Partial<
Parameters<StalkerPlaybackContextService['register']>[0]
> = {}
): string {
return service.register({
authGeneration: 4,
coordinatorEpoch: 7,
headers: {
Authorization: 'Bearer synthetic-token',
Cookie: 'mac=02:00:00:00:00:01; session=synthetic',
},
leaseRef: 'lease-a',
senderId: 10,
sessionKey: 'session-a',
streamUrl: 'https://media.example.test/live.ts?ticket=one#ignored',
...overrides,
});
}
it('binds a single-use context to sender and exact normalized stream URL', () => {
const contextRef = register();
expect(
service.consume({
contextRef,
senderId: 11,
streamUrl: 'https://media.example.test/live.ts?ticket=one',
})
).toBeNull();
expect(
service.consume({
contextRef,
senderId: 10,
streamUrl: 'https://media.example.test/live.ts?ticket=other',
})
).toBeNull();
expect(
service.consume({
contextRef,
senderId: 10,
streamUrl: 'https://media.example.test/live.ts?ticket=one',
})
).toEqual({
Authorization: 'Bearer synthetic-token',
Cookie: 'mac=02:00:00:00:00:01; session=synthetic',
});
expect(
service.consume({
contextRef,
senderId: 10,
streamUrl: 'https://media.example.test/live.ts?ticket=one',
})
).toBeNull();
});
it('expires contexts after exactly two minutes', () => {
const contextRef = register();
now += 120_000;
expect(
service.consume({
contextRef,
senderId: 10,
streamUrl: 'https://media.example.test/live.ts?ticket=one',
})
).toBeNull();
expect(service.size).toBe(0);
});
it('invalidates by lease, session, auth generation, coordinator epoch, and sender', () => {
const leaseRef = register();
service.invalidateLease('lease-a');
expect(service.has(leaseRef)).toBe(false);
const sessionRef = register({ leaseRef: 'lease-b' });
service.invalidateSession('session-a');
expect(service.has(sessionRef)).toBe(false);
const generationRef = register({
authGeneration: 5,
sessionKey: 'session-b',
});
service.invalidateAuthGeneration('session-b', 5);
expect(service.has(generationRef)).toBe(false);
const epochRef = register({
coordinatorEpoch: 8,
sessionKey: 'session-c',
});
service.invalidateCoordinatorEpoch('session-c', 8);
expect(service.has(epochRef)).toBe(false);
const senderRef = register({ senderId: 12, sessionKey: 'session-d' });
service.cleanupSender(12);
expect(service.has(senderRef)).toBe(false);
});
it('invalidates an old generation without deleting the replacement generation', () => {
const oldRef = register({
authGeneration: 1,
sessionKey: 'session-shared',
});
const newRef = register({
authGeneration: 2,
sessionKey: 'session-shared',
streamUrl: 'https://media.example.test/new.ts',
});
service.invalidateAuthGeneration('session-shared', 1);
expect(service.has(oldRef)).toBe(false);
expect(service.has(newRef)).toBe(true);
});
it('copies headers into and out of the private registry', () => {
const headers = { Authorization: 'Bearer original' };
const contextRef = register({ headers });
headers.Authorization = 'Bearer mutated';
const resolved = service.consume({
contextRef,
senderId: 10,
streamUrl: 'https://media.example.test/live.ts?ticket=one',
});
expect(resolved).toEqual({ Authorization: 'Bearer original' });
if (resolved) {
resolved.Authorization = 'Bearer returned-mutation';
}
expect(JSON.stringify({ contextRef })).not.toContain('original');
});
it('rejects invalid URLs, headers, refs, and binding metadata', () => {
expect(() => register({ streamUrl: 'file:///tmp/video' })).toThrow(
'invalid-playback-context'
);
expect(() =>
register({ headers: { Cookie: 'session=one\r\nInjected: yes' } })
).toThrow('invalid-playback-context');
expect(() => register({ authGeneration: -1 })).toThrow(
'invalid-playback-context'
);
expect(() => register({ coordinatorEpoch: -1 })).toThrow(
'invalid-playback-context'
);
expect(() => register({ senderId: -1 })).toThrow(
'invalid-playback-context'
);
expect(() => register({ leaseRef: '' })).toThrow(
'invalid-playback-context'
);
});
});
@@ -1,3 +1,4 @@
import { randomBytes } from 'node:crypto';
import {
buildStalkerSerialCfduid,
normalizeStalkerSerialNumber,
@@ -7,69 +8,222 @@ const STALKER_MAG_USER_AGENT =
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250';
const STALKER_STREAM_USER_AGENT = 'KSPlayer';
const STALKER_STREAM_RANGE_HEADER = 'bytes=0-';
const PLAYBACK_CONTEXT_TTL_MS = 2 * 60 * 1000;
const MAX_CONTEXTS = 4096;
const MAX_REF_LENGTH = 512;
const MAX_BINDING_LENGTH = 1024;
const MAX_URL_LENGTH = 16 * 1024;
const MAX_HEADERS = 64;
const MAX_HEADER_NAME_LENGTH = 256;
const MAX_HEADER_VALUE_LENGTH = 16 * 1024;
const CONTEXT_TTL_MS = 15 * 60 * 1000;
type PlaybackContext = {
headers: Record<string, string>;
createdAt: number;
};
const playbackContextByStream = new Map<string, PlaybackContext>();
const playbackContextByOrigin = new Map<string, PlaybackContext>();
function normalizeStreamUrl(streamUrl: string): string {
const normalized = String(streamUrl ?? '').trim();
if (!normalized) return '';
const spaceIndex = normalized.indexOf(' ');
const maybeWrapped =
spaceIndex > 0 ? normalized.slice(spaceIndex + 1).trim() : normalized;
try {
const parsed = new URL(maybeWrapped);
return `${parsed.origin}${parsed.pathname}`;
} catch {
return maybeWrapped.split('?')[0];
}
export interface StalkerPlaybackContextRegistration {
readonly authGeneration: number;
readonly coordinatorEpoch: number;
readonly headers: Readonly<Record<string, string>>;
readonly leaseRef: string;
readonly senderId: number;
readonly sessionKey: string;
readonly streamUrl: string;
}
function getOrigin(streamUrl: string): string {
const normalized = String(streamUrl ?? '').trim();
if (!normalized) return '';
const spaceIndex = normalized.indexOf(' ');
const maybeWrapped =
spaceIndex > 0 ? normalized.slice(spaceIndex + 1).trim() : normalized;
try {
return new URL(maybeWrapped).origin;
} catch {
return '';
}
export interface StalkerPlaybackContextConsumption {
readonly contextRef: string;
readonly senderId: number;
readonly streamUrl: string;
}
function removeExpiredContextEntries() {
const now = Date.now();
for (const [key, value] of playbackContextByStream.entries()) {
if (now - value.createdAt > CONTEXT_TTL_MS) {
playbackContextByStream.delete(key);
export interface StalkerPlaybackContextServiceOptions {
readonly createRef?: () => string;
readonly now?: () => number;
}
interface OpaquePlaybackContext {
readonly authGeneration: number;
readonly coordinatorEpoch: number;
readonly expiresAt: number;
readonly headers: Readonly<Record<string, string>>;
readonly leaseRef: string;
readonly senderId: number;
readonly sessionKey: string;
readonly streamUrl: string;
}
/**
* Main-process-only, sender-bound registry for full-session playback.
*
* The renderer receives only the opaque key. Cookie and authorization values
* stay in this registry and are released once to an exact player request.
*/
export class StalkerPlaybackContextService {
readonly #contexts = new Map<string, OpaquePlaybackContext>();
readonly #createRef: () => string;
readonly #now: () => number;
constructor(options: StalkerPlaybackContextServiceOptions = {}) {
this.#createRef =
options.createRef ?? (() => randomBytes(32).toString('base64url'));
this.#now = options.now ?? Date.now;
}
get size(): number {
this.#pruneExpired();
return this.#contexts.size;
}
register(input: StalkerPlaybackContextRegistration): string {
this.#pruneExpired();
assertSafeInteger(input.senderId);
assertSafeInteger(input.authGeneration);
assertSafeInteger(input.coordinatorEpoch);
assertBinding(input.leaseRef);
assertBinding(input.sessionKey);
const streamUrl = normalizeExactStreamUrl(input.streamUrl);
const headers = copyHeaders(input.headers);
if (this.#contexts.size >= MAX_CONTEXTS) {
throw invalidContext();
}
let contextRef = '';
for (let attempt = 0; attempt < 4; attempt += 1) {
const candidate = this.#createRef();
assertBinding(candidate, MAX_REF_LENGTH);
if (!this.#contexts.has(candidate)) {
contextRef = candidate;
break;
}
}
if (!contextRef) {
throw invalidContext();
}
this.#contexts.set(contextRef, {
authGeneration: input.authGeneration,
coordinatorEpoch: input.coordinatorEpoch,
expiresAt: this.#now() + PLAYBACK_CONTEXT_TTL_MS,
headers,
leaseRef: input.leaseRef,
senderId: input.senderId,
sessionKey: input.sessionKey,
streamUrl,
});
return contextRef;
}
consume(
input: StalkerPlaybackContextConsumption
): Record<string, string> | null {
this.#pruneExpired();
if (
!isSafeInteger(input.senderId) ||
!isBinding(input.contextRef, MAX_REF_LENGTH)
) {
return null;
}
const context = this.#contexts.get(input.contextRef);
if (!context || context.senderId !== input.senderId) {
return null;
}
let streamUrl: string;
try {
streamUrl = normalizeExactStreamUrl(input.streamUrl);
} catch {
return null;
}
if (context.streamUrl !== streamUrl) {
return null;
}
this.#contexts.delete(input.contextRef);
return { ...context.headers };
}
has(contextRef: string): boolean {
this.#pruneExpired();
return this.#contexts.has(contextRef);
}
invalidateLease(leaseRef: string): void {
this.#deleteWhere((context) => context.leaseRef === leaseRef);
}
invalidateSession(sessionKey: string): void {
this.#deleteWhere((context) => context.sessionKey === sessionKey);
}
invalidateAuthGeneration(sessionKey: string, authGeneration: number): void {
this.#deleteWhere(
(context) =>
context.sessionKey === sessionKey &&
context.authGeneration === authGeneration
);
}
invalidateCoordinatorEpoch(
sessionKey: string,
coordinatorEpoch: number
): void {
this.#deleteWhere(
(context) =>
context.sessionKey === sessionKey &&
context.coordinatorEpoch === coordinatorEpoch
);
}
cleanupSender(senderId: number): void {
this.#deleteWhere((context) => context.senderId === senderId);
}
clear(): void {
this.#contexts.clear();
}
#deleteWhere(predicate: (context: OpaquePlaybackContext) => boolean): void {
for (const [contextRef, context] of this.#contexts) {
if (predicate(context)) {
this.#contexts.delete(contextRef);
}
}
}
for (const [key, value] of playbackContextByOrigin.entries()) {
if (now - value.createdAt > CONTEXT_TTL_MS) {
playbackContextByOrigin.delete(key);
}
#pruneExpired(): void {
const now = this.#now();
this.#deleteWhere((context) => context.expiresAt <= now);
}
}
export const stalkerPlaybackContextService =
new StalkerPlaybackContextService();
interface LegacyPlaybackContext {
readonly createdAt: number;
readonly headers: Readonly<Record<string, string>>;
}
const legacyPlaybackContexts = new Map<string, LegacyPlaybackContext>();
/**
* Compatibility bridge for the legacy STALKER_REQUEST path. New full-session
* traffic must use {@link StalkerPlaybackContextService} and an opaque ref.
*/
export function rememberStalkerPlaybackContext(input: {
streamUrl: string;
portalUrl: string;
macAddress: string;
serialNumber?: string;
token?: string;
}) {
const streamKey = normalizeStreamUrl(input.streamUrl);
if (!streamKey || !input.macAddress) return;
}): void {
let streamUrl: string;
try {
streamUrl = normalizeExactStreamUrl(input.streamUrl);
} catch {
return;
}
if (!input.macAddress) {
return;
}
const serialNumber = normalizeStalkerSerialNumber(input.serialNumber);
const cookieParts = [
@@ -77,33 +231,26 @@ export function rememberStalkerPlaybackContext(input: {
'stb_lang=en_US@rg=dezzzz',
'timezone=Europe/Berlin',
];
if (serialNumber) {
cookieParts.push(`__cfduid=${buildStalkerSerialCfduid(serialNumber)}`);
}
const streamOrigin = getOrigin(input.streamUrl) || undefined;
const streamOrigin = new URL(streamUrl).origin;
let portalOrigin: string | undefined;
try {
portalOrigin = new URL(input.portalUrl).origin;
} catch {
portalOrigin = undefined;
}
const crossOriginStream =
Boolean(streamOrigin) &&
Boolean(portalOrigin) &&
streamOrigin !== portalOrigin;
portalOrigin !== undefined && streamOrigin !== portalOrigin;
const headers: Record<string, string> = crossOriginStream
? {
// Align with known working clients for direct tokenized stream URLs.
'User-Agent': STALKER_STREAM_USER_AGENT,
Accept: '*/*',
Range: STALKER_STREAM_RANGE_HEADER,
'Icy-MetaData': '1',
Connection: 'keep-alive',
'Icy-MetaData': '1',
Range: STALKER_STREAM_RANGE_HEADER,
'User-Agent': STALKER_STREAM_USER_AGENT,
}
: {
Cookie: cookieParts.join('; '),
@@ -115,42 +262,119 @@ export function rememberStalkerPlaybackContext(input: {
headers['Origin'] = portalOrigin;
headers['Referer'] = portalOrigin;
}
if (!crossOriginStream && serialNumber) {
headers['SN'] = serialNumber;
}
if (!crossOriginStream && input.token) {
headers['Authorization'] = `Bearer ${input.token}`;
}
playbackContextByStream.set(streamKey, {
headers,
legacyPlaybackContexts.set(streamUrl, {
createdAt: Date.now(),
headers: Object.freeze({ ...headers }),
});
if (streamOrigin) {
playbackContextByOrigin.set(streamOrigin, {
headers,
createdAt: Date.now(),
});
}
removeExpiredContextEntries();
pruneLegacyContexts();
}
export function getStalkerPlaybackContextHeaders(
streamUrl: string
): Record<string, string> | null {
removeExpiredContextEntries();
const streamKey = normalizeStreamUrl(streamUrl);
if (streamKey) {
const entry = playbackContextByStream.get(streamKey);
if (entry) {
return { ...entry.headers };
pruneLegacyContexts();
let key: string;
try {
key = normalizeExactStreamUrl(streamUrl);
} catch {
return null;
}
const context = legacyPlaybackContexts.get(key);
return context ? { ...context.headers } : null;
}
function normalizeExactStreamUrl(streamUrl: string): string {
const trimmed = String(streamUrl ?? '').trim();
if (!trimmed || trimmed.length > MAX_URL_LENGTH) {
throw invalidContext();
}
const separator = trimmed.indexOf(' ');
const unwrapped =
separator > 0 ? trimmed.slice(separator + 1).trim() : trimmed;
const url = new URL(unwrapped);
if (
(url.protocol !== 'http:' && url.protocol !== 'https:') ||
url.username !== '' ||
url.password !== ''
) {
throw invalidContext();
}
url.hash = '';
return url.toString();
}
function copyHeaders(
headers: Readonly<Record<string, string>>
): Readonly<Record<string, string>> {
const entries = Object.entries(headers);
if (entries.length > MAX_HEADERS) {
throw invalidContext();
}
const result: Record<string, string> = {};
for (const [name, value] of entries) {
if (
!isHeaderName(name) ||
typeof value !== 'string' ||
value.length === 0 ||
value.length > MAX_HEADER_VALUE_LENGTH ||
/[\r\n\0]/u.test(value)
) {
throw invalidContext();
}
result[name] = value;
}
return Object.freeze(result);
}
function isHeaderName(value: string): boolean {
return (
value.length > 0 &&
value.length <= MAX_HEADER_NAME_LENGTH &&
/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u.test(value)
);
}
function assertSafeInteger(value: number): void {
if (!isSafeInteger(value)) {
throw invalidContext();
}
}
function isSafeInteger(value: number): boolean {
return Number.isSafeInteger(value) && value >= 0;
}
function assertBinding(value: string, maximum = MAX_BINDING_LENGTH): void {
if (!isBinding(value, maximum)) {
throw invalidContext();
}
}
function isBinding(value: string, maximum: number): boolean {
return (
typeof value === 'string' &&
value.length > 0 &&
value.length <= maximum &&
!/[\0\r\n]/u.test(value)
);
}
function pruneLegacyContexts(): void {
const now = Date.now();
for (const [streamUrl, context] of legacyPlaybackContexts) {
if (now - context.createdAt > PLAYBACK_CONTEXT_TTL_MS) {
legacyPlaybackContexts.delete(streamUrl);
}
}
const origin = getOrigin(streamUrl);
if (!origin) return null;
const originEntry = playbackContextByOrigin.get(origin);
return originEntry ? { ...originEntry.headers } : null;
}
function invalidContext(): Error {
return new Error('invalid-playback-context');
}
@@ -311,4 +311,100 @@ describe('StalkerAuthSession', () => {
expect(JSON.stringify(outcome)).not.toContain('token-secret');
expect(JSON.stringify(outcome)).not.toContain('private-password');
});
it('prepares exact same-origin playback headers from the private token and live cookie jar', async () => {
const outcome = resolved({
kind: 'ready',
profile: { login: 'player-user', status: 0 },
status: 0,
});
await outcome.cookieJar.collectResponseCookies(
outcome.endpoint,
'session=rotated; Path=/; HttpOnly'
);
const auth = new StalkerAuthSession(outcome, transport, {
createHttpSession: () => ({
request: async () => {
throw new Error('No request expected');
},
}),
});
await auth.start();
const playback = await auth.preparePlayback(
'/stream/live.ts?ticket=synthetic'
);
expect(playback.streamUrl).toBe(
'https://portal.test/stream/live.ts?ticket=synthetic'
);
expect(playback.headers).toMatchObject({
Authorization: 'Bearer token-secret',
Cookie: expect.stringContaining('session=rotated'),
Referer: 'https://portal.test/c/',
'User-Agent': expect.stringContaining('MAG250'),
'X-User-Agent': 'Model: MAG250',
});
expect(playback.headers.Cookie).toContain('mac=00:1A:79:AA:BB:CC');
expect(playback.headers).not.toHaveProperty('SN');
});
it('uses a secret-free direct-stream profile for cross-origin playback', async () => {
const { auth } = harness(
{
kind: 'ready',
profile: { login: 'player-user', status: 0 },
status: 0,
},
() => {
throw new Error('No request expected');
}
);
await auth.start();
await expect(
auth.preparePlayback(
'https://media.example.test/live.ts?ticket=synthetic'
)
).resolves.toEqual({
headers: {
Accept: '*/*',
Connection: 'keep-alive',
'Icy-MetaData': '1',
Range: 'bytes=0-',
'User-Agent': 'KSPlayer',
},
streamUrl: 'https://media.example.test/live.ts?ticket=synthetic',
});
});
it('rejects playback preparation before ready and for unsafe URL schemes', async () => {
const { auth: pendingAuth } = harness(
{
kind: 'credentials-required',
profile: { status: 2 },
status: 2,
},
() => success({ js: false })
);
await expect(
pendingAuth.preparePlayback('https://portal.test/live.ts')
).rejects.toThrow('stalker-playback-not-ready');
const { auth: readyAuth } = harness(
{
kind: 'ready',
profile: { login: 'player-user', status: 0 },
status: 0,
},
() => {
throw new Error('No request expected');
}
);
await readyAuth.start();
await expect(
readyAuth.preparePlayback('file:///tmp/live.ts')
).rejects.toThrow('stalker-playback-invalid-url');
});
});
@@ -23,9 +23,11 @@ import type {
StalkerTransportConfig,
StalkerTransportResult,
} from './stalker-session.types';
import type { StalkerCookieJar } from './stalker-cookie-jar';
const USERNAME_MAX_BYTES = 512;
const PASSWORD_MAX_BYTES = 2048;
const STALKER_STREAM_USER_AGENT = 'KSPlayer';
interface StalkerHttpSessionLike {
request(request: StalkerHttpRequest): Promise<StalkerHttpRequestOutcome>;
@@ -38,6 +40,11 @@ export interface StalkerAuthSessionDependencies {
) => StalkerHttpSessionLike;
}
export interface StalkerPreparedPlayback {
readonly headers: Readonly<Record<string, string>>;
readonly streamUrl: string;
}
export interface StalkerAuthCredentials {
password: string;
username: string;
@@ -85,6 +92,7 @@ export type StalkerAuthenticatedRequestOutcome =
type AuthState = 'new' | 'awaiting-credentials' | 'ready' | 'failed';
export class StalkerAuthSession {
readonly #cookieJar: StalkerCookieJar;
readonly #endpoint: string;
readonly #http: StalkerHttpSessionLike;
readonly #identity: StalkerIdentityProfile;
@@ -101,6 +109,7 @@ export class StalkerAuthSession {
transport: StalkerTransportConfig,
dependencies: StalkerAuthSessionDependencies = {}
) {
this.#cookieJar = resolved.cookieJar;
this.#endpoint = resolved.endpoint;
this.#identity = resolved.identity;
this.#profile = resolved.profile.profile;
@@ -280,6 +289,36 @@ export class StalkerAuthSession {
return this.#acceptedCredentials !== undefined;
}
async preparePlayback(streamUrl: string): Promise<StalkerPreparedPlayback> {
if (this.#state !== 'ready') {
throw new Error('stalker-playback-not-ready');
}
const normalizedUrl = normalizePlaybackUrl(streamUrl, this.#endpoint);
if (new URL(normalizedUrl).origin !== new URL(this.#endpoint).origin) {
return {
headers: {
Accept: '*/*',
Connection: 'keep-alive',
'Icy-MetaData': '1',
Range: 'bytes=0-',
'User-Agent': STALKER_STREAM_USER_AGENT,
},
streamUrl: normalizedUrl,
};
}
const cookie = await this.#cookieJar.getCookieHeader(normalizedUrl);
return {
headers: {
...this.#identity.headers,
Authorization: `Bearer ${this.#token}`,
...(cookie ? { Cookie: cookie } : {}),
},
streamUrl: normalizedUrl,
};
}
async request(
parameters: Readonly<Record<string, string | number>>
): Promise<StalkerAuthenticatedRequestOutcome> {
@@ -360,6 +399,35 @@ export class StalkerAuthSession {
}
}
function normalizePlaybackUrl(value: string, endpoint: string): string {
if (
typeof value !== 'string' ||
value.trim() === '' ||
value.length > 16 * 1024
) {
throw new Error('stalker-playback-invalid-url');
}
const trimmed = value.trim();
const separator = trimmed.indexOf(' ');
const command =
separator > 0 ? trimmed.slice(separator + 1).trim() : trimmed;
let url: URL;
try {
url = new URL(command, endpoint);
} catch {
throw new Error('stalker-playback-invalid-url');
}
if (
(url.protocol !== 'http:' && url.protocol !== 'https:') ||
url.username !== '' ||
url.password !== ''
) {
throw new Error('stalker-playback-invalid-url');
}
url.hash = '';
return url.toString();
}
function normalizeResponse(
outcome: StalkerHttpRequestOutcome,
stage: StalkerSessionStage,