diff --git a/.gitattributes b/.gitattributes index 3eb52bd81..1f8b44bb4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,16 +1,24 @@ -*.rs text eol=lf -*.toml text eol=lf -*.cs text eol=lf -*.js text eol=lf -*.ps1 text eol=lf -*.sln text eol=crlf -*.md text eol=lf -*.mustache text eol=lf -*.yaml text eol=lf - -devolutions-gateway/openapi/doc/index.adoc linguist-generated merge=binary -devolutions-gateway/openapi/dotnet-client/src/** linguist-generated merge=binary -devolutions-gateway/openapi/dotnet-client/docs/** linguist-generated merge=binary -devolutions-gateway/openapi/dotnet-subscriber/src/** linguist-generated merge=binary -devolutions-gateway/openapi/ts-angular-client/api/** linguist-generated merge=binary -devolutions-gateway/openapi/ts-angular-client/model/** linguist-generated merge=binary +*.rs text eol=lf +*.toml text eol=lf +*.cs text eol=lf +*.js text eol=lf +*.ts text eol=lf +*.tsx text eol=lf +*.json text eol=lf +*.ps1 text eol=lf +*.sln text eol=crlf +*.md text eol=lf +*.mustache text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.css text eol=lf +*.scss text eol=lf +*.html text eol=lf +*.slog text eol=lf + +devolutions-gateway/openapi/doc/index.adoc linguist-generated merge=binary +devolutions-gateway/openapi/dotnet-client/src/** linguist-generated merge=binary +devolutions-gateway/openapi/dotnet-client/docs/** linguist-generated merge=binary +devolutions-gateway/openapi/dotnet-subscriber/src/** linguist-generated merge=binary +devolutions-gateway/openapi/ts-angular-client/api/** linguist-generated merge=binary +devolutions-gateway/openapi/ts-angular-client/model/** linguist-generated merge=binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed07d1f10..1bad9a55c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -490,6 +490,10 @@ jobs: run: pnpm check working-directory: webapp + - name: Run session-recording-log tests + run: pnpm --filter @devolutions/session-recording-log test + working-directory: webapp + - name: Upload gateway-ui artifact uses: actions/upload-artifact@v7 with: diff --git a/.github/workflows/publish-libraries.yml b/.github/workflows/publish-libraries.yml index 4a2d4a53a..c267366f6 100644 --- a/.github/workflows/publish-libraries.yml +++ b/.github/workflows/publish-libraries.yml @@ -95,7 +95,7 @@ jobs: strategy: fail-fast: false matrix: - library: [ ts-angular-client, multi-video-player, shadow-player, web-recorder ] + library: [ ts-angular-client, multi-video-player, shadow-player, web-recorder, session-recording-log ] include: - library: ts-angular-client libpath: ./devolutions-gateway/openapi/ts-angular-client @@ -105,6 +105,8 @@ jobs: libpath: ./webapp/packages/shadow-player - library: web-recorder libpath: ./webapp/packages/web-recorder + - library: session-recording-log + libpath: ./webapp/packages/session-recording-log steps: - name: Check out ${{ github.repository }} diff --git a/webapp/.editorconfig b/webapp/.editorconfig index 59d9a3a3e..23760c1d5 100644 --- a/webapp/.editorconfig +++ b/webapp/.editorconfig @@ -1,16 +1,17 @@ -# Editor configuration, see https://editorconfig.org -root = true - -[*] -charset = utf-8 -indent_style = space -indent_size = 2 -insert_final_newline = true -trim_trailing_whitespace = true - -[*.ts] -quote_type = single - -[*.md] -max_line_length = off -trim_trailing_whitespace = false +# Editor configuration, see https://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.ts] +quote_type = single + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/webapp/README.md b/webapp/README.md index ec0ecaafc..5c641d654 100644 --- a/webapp/README.md +++ b/webapp/README.md @@ -47,6 +47,10 @@ The workspace is organized into three main categories: - Web component for real-time session streaming - Used by recording-player for live shadowing +**@devolutions/session-recording-log** - Session recording log parser/model package +- Framework-agnostic parser, models, ordering, and in-file search for `.slog` NDJSON +- Intended for host reuse (DVLS, Gateway UI sandbox, legacy recording-player) + #### Tools (`tools/`) **recording-player-tester** - Testing utility diff --git a/webapp/packages/session-recording-log/README.md b/webapp/packages/session-recording-log/README.md new file mode 100644 index 000000000..d59ee473f --- /dev/null +++ b/webapp/packages/session-recording-log/README.md @@ -0,0 +1,178 @@ +# @devolutions/session-recording-log + +Framework-agnostic models, parser, ordering, and in-file search for Session Recording Log (`.slog`) files. + +## Features + +- NDJSON parser that keeps valid entries even when other lines are malformed. +- Structured warning codes for parser behavior and host branching. +- Source metadata (`sourceLineNumber`, `sourceIndex`, `sourceText`) preserved for each parsed entry. +- Non-mutating display-order helper (sort by `seq`, tie-break on `sourceIndex`). +- Bounded in-memory search over visible fields with optional event/warning-linked filtering. +- Hardened bounds for large/malformed inputs (line size, string length, parameter count, depth, entry/scan caps). + +## Usage + +### Basic usage + +```ts +import { parseSessionRecordingLog } from '@devolutions/session-recording-log'; + +const result = parseSessionRecordingLog(slogText); +console.log(result.completionState); +console.log(result.entries.length); +console.log(result.warnings.map((warning) => warning.code)); +``` + +### Full runnable example + +```ts +import { + getSessionRecordingLogDisplayEntries, + parseSessionRecordingLog, + searchSessionRecordingLogEntries, +} from '@devolutions/session-recording-log'; + +const slogText = [ + '{"timestamp":"2026-07-15T21:17:49.777Z","seq":0,"event":"session.start","description":"Session started","actor":"Administrator","host":"IT-HELP-DC","sessionType":"ADConsole"}', + '{"timestamp":"2026-07-15T21:19:14.351Z","seq":1,"event":"session.action","description":"Renamed Object","object":"Help Desk Ottawa","parameters":{"Members added":"Sarah O\'Connor, Bob Smith"}}', + // Missing session.end on purpose. +].join('\n'); + +const parseResult = parseSessionRecordingLog(slogText); + +// Parse status + warning surfacing. +console.log(parseResult.completionState); // 'ended-unexpectedly' +for (const warning of parseResult.warnings) { + console.log(`${warning.code} line=${warning.sourceLineNumber ?? '-'} seq=${warning.seq ?? '-'}`); +} + +// Non-mutating display order by seq/sourceIndex. +const displayEntries = getSessionRecordingLogDisplayEntries(parseResult); +console.log(displayEntries.map((entry) => `${entry.entry.seq}:${entry.entry.event}`)); + +// Basic search/filter. +const actionHits = searchSessionRecordingLogEntries(parseResult.entries, "o'connor", { + eventTypes: ['session.action'], +}); +const warningLinked = searchSessionRecordingLogEntries(parseResult.entries, '', { + onlyWithWarnings: true, + warnings: parseResult.warnings, +}); + +console.log(actionHits.length); // 1 +console.log(warningLinked.length); // entries linked to parser warnings +``` + +## API Reference + +Public API: + +- `parseSessionRecordingLog(text, options?)` +- `getSessionRecordingLogDisplayEntries(parseResult)` +- `searchSessionRecordingLogEntries(entries, query, options?)` +- `isSessionRecordingLogFileName(fileName)` + +Host contract: + +- Hosts detect `.slog` artifacts from `recording.json` file names (for example `recording-0.slog`). +- Hosts keep original bytes for download scenarios. +- Hosts decode UTF-8 text and pass the decoded NDJSON text to the parser. +- This package does not reconstruct downloadable `.slog` content. + +Architecture Decision Record 2 (ADR-2) canonical contract: + +- `parseSessionRecordingLog` returns: + - `entries: ParsedSessionRecordingLogEntry[]` in original file order. + - `warnings: SessionRecordingLogWarning[]` with structured `code`. + - `completionState: 'complete' | 'ended-unexpectedly'`. +- `ParsedSessionRecordingLogEntry` exposes `entry`, `sourceLineNumber`, and `sourceIndex`. +- Public warning codes follow ADR-2 categories: + - `malformed-line` + - `missing-session-start` + - `missing-session-end` + - `duplicate-sequence` + - `missing-sequence` + - `sequence-order-mismatch` + - `unknown-event-type` + - `invalid-field` + - `entry-limit-exceeded` + - `string-truncated` +- Additive warning: + - `unterminated-final-line` (informational; valid final line without newline is not semantically invalid). + - Intended for Architecture Decision Record (ADR) addendum documentation rather than replacement of canonical malformed handling. + +Schema-limit options: + +- `maxLineLengthBytes` +- `maxRetainedSourceTextBytes` +- `maxStringLength` +- `maxParameterCount` +- `maxObjectDepth` +- `maxParsedEntries` +- `maxScannedLines` +- `maxMissingSequenceWarnings` +- `maxUnknownFieldCount` +- `maxWarnings` + +Ratified v1 defaults (review decision): + +- `maxLineLengthBytes`: `262144` (256 KiB) +- `maxRetainedSourceTextBytes`: `8388608` (8 MiB) +- `maxStringLength`: `4096` +- `maxParameterCount`: `200` +- `maxObjectDepth`: `8` +- `maxParsedEntries`: `10000` +- `maxScannedLines`: `20000` +- `maxMissingSequenceWarnings`: `1000` +- `maxUnknownFieldCount`: `100` +- `maxWarnings`: `10000` +- search default limit: `100` +- search max limit: `1000` + +## Project Structure + +```txt +session-recording-log/ + src/ + fixtures/ # sample .slog fixtures used by package tests + parser.ts + ordering.ts + search.ts + manifest.ts + model.ts + index.ts + parser.test.ts + helpers.test.ts + README.md + package.json + package.dist.json + tsconfig.json + vite.config.ts +``` + +## Dependencies + +- Runtime dependencies: none +- Dev dependencies: TypeScript, Vite, Vitest, Biome, `vite-plugin-dts`, `vite-plugin-static-copy` + +## For Developers + +From `webapp/`: + +```bash +pnpm --filter @devolutions/session-recording-log check:write +pnpm --filter @devolutions/session-recording-log check +pnpm --filter @devolutions/session-recording-log test +pnpm --filter @devolutions/session-recording-log build +``` + +## Review-driven hardening updates (2026-07-17) + +- Added bounded missing-sequence emission to prevent unbounded gap loops on sparse large `seq` ranges. +- Replaced recursive depth traversal with iterative depth checks to prevent stack-overflow failure on deeply nested JSON. +- Added malformed-heavy input bounding with `maxScannedLines`, and finite default `maxParsedEntries`. +- Fixed lifecycle classification so only empty/whitespace-only input is `complete`; non-empty input without `session.end` remains `ended-unexpectedly`. +- Added top-level unknown field cap (`maxUnknownFieldCount`) with `entry-limit-exceeded` warning when truncated. +- Split unknown future events into explicit `unknown-event-type` warning code. +- Clarified alias intent: `SessionRecordingLogRecord` is retained for AD historical naming compatibility. diff --git a/webapp/packages/session-recording-log/build.ps1 b/webapp/packages/session-recording-log/build.ps1 new file mode 100644 index 000000000..26e9012f9 --- /dev/null +++ b/webapp/packages/session-recording-log/build.ps1 @@ -0,0 +1,19 @@ +#!/usr/bin/env pwsh + +$ErrorActionPreference = "Stop" + +Push-Location -Path $PSScriptRoot + +try +{ + pnpm install + + pnpm --filter @devolutions/session-recording-log... build + + Set-Location -Path ./dist/ + npm pack +} +finally +{ + Pop-Location +} diff --git a/webapp/packages/session-recording-log/package.dist.json b/webapp/packages/session-recording-log/package.dist.json new file mode 100644 index 000000000..1c8211b61 --- /dev/null +++ b/webapp/packages/session-recording-log/package.dist.json @@ -0,0 +1,30 @@ +{ + "name": "@devolutions/session-recording-log", + "version": "0.1.0", + "description": "Framework-agnostic parser and models for Devolutions Gateway Session Recording Log NDJSON files", + "type": "module", + "main": "./index.js", + "module": "./index.js", + "types": "./index.d.ts", + "exports": { + ".": { + "import": "./index.js", + "types": "./index.d.ts" + } + }, + "repository": { + "type": "git", + "url": "https://github.com/Devolutions/devolutions-gateway.git" + }, + "keywords": [ + "recording", + "session-recording", + "ndjson", + "slog", + "parser" + ], + "license": "MIT OR Apache-2.0", + "publishConfig": { + "access": "public" + } +} diff --git a/webapp/packages/session-recording-log/package.json b/webapp/packages/session-recording-log/package.json new file mode 100644 index 000000000..75cc1fbfb --- /dev/null +++ b/webapp/packages/session-recording-log/package.json @@ -0,0 +1,28 @@ +{ + "name": "@devolutions/session-recording-log", + "private": true, + "version": "0.0.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc && vite build", + "check": "biome check ./src vite.config.ts", + "check:write": "biome check --write ./src vite.config.ts", + "test": "vitest run" + }, + "devDependencies": { + "@types/node": "^20.19.0", + "typescript": "~5.6.2", + "vite": "^6.2.1", + "vite-plugin-dts": "^4.3.0", + "vite-plugin-static-copy": "^2.3.0", + "vitest": "^3.1.1" + } +} diff --git a/webapp/packages/session-recording-log/src/fixtures/sample-complete.slog b/webapp/packages/session-recording-log/src/fixtures/sample-complete.slog new file mode 100644 index 000000000..acc49b34d --- /dev/null +++ b/webapp/packages/session-recording-log/src/fixtures/sample-complete.slog @@ -0,0 +1,3 @@ +{"timestamp":"2026-07-15T16:15:35.140Z","seq":0,"actor":"Administrator","locale":"en-US","host":"IT-HELP-DC","sessionType":"ADConsole","event":"session.start","description":"Session started"} +{"timestamp":"2026-07-15T16:15:41.054Z","seq":1,"event":"session.action","description":"Created User","object":"Help Desk Ottawa","parameters":{"New name":"Help Desk Ottawa/Hull"}} +{"timestamp":"2026-07-15T16:16:32.759Z","seq":2,"event":"session.end","description":"Session ended"} diff --git a/webapp/packages/session-recording-log/src/fixtures/sample-missing-end.slog b/webapp/packages/session-recording-log/src/fixtures/sample-missing-end.slog new file mode 100644 index 000000000..065ebe938 --- /dev/null +++ b/webapp/packages/session-recording-log/src/fixtures/sample-missing-end.slog @@ -0,0 +1,2 @@ +{"timestamp":"2026-07-15T21:17:49.777Z","seq":0,"actor":"Administrator","host":"IT-HELP-DC","sessionType":"ADConsole","event":"session.start","description":"Session started"} +{"timestamp":"2026-07-15T21:19:14.351Z","seq":1,"event":"session.action","description":"Renamed Object","object":"Help Desk Ottawa","parameters":{"New name":"Help Desk Ottawa/Hull","Members added":"Sarah O'Connor, Bob Smith"}} diff --git a/webapp/packages/session-recording-log/src/fixtures/sample-warning-linked.slog b/webapp/packages/session-recording-log/src/fixtures/sample-warning-linked.slog new file mode 100644 index 000000000..8672fb6d8 --- /dev/null +++ b/webapp/packages/session-recording-log/src/fixtures/sample-warning-linked.slog @@ -0,0 +1,3 @@ +{"timestamp":"2026-07-15T21:17:49.777Z","seq":0,"event":"session.start","description":"Session started"} +{"timestamp":"2026-07-15T21:19:14.351Z","seq":1,"event":"session.future","description":"Future action","parameters":{"Members added":"Sarah O'Connor, Bob Smith"}} +{"timestamp":"2026-07-15T21:20:00.000Z","seq":2,"event":"session.end","description":"Session ended"} diff --git a/webapp/packages/session-recording-log/src/helpers.test.ts b/webapp/packages/session-recording-log/src/helpers.test.ts new file mode 100644 index 000000000..c754e5b14 --- /dev/null +++ b/webapp/packages/session-recording-log/src/helpers.test.ts @@ -0,0 +1,116 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { isSessionRecordingLogFileName } from './manifest'; +import type { ParsedSessionRecordingLogEntry, SessionRecordingLogEntry, SessionRecordingLogWarning } from './model'; +import { getSessionRecordingLogDisplayEntries } from './ordering'; +import { parseSessionRecordingLog } from './parser'; +import { searchSessionRecordingLogEntries } from './search'; + +function readFixture(name: string): string { + const fixturePath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'fixtures', name); + return readFileSync(fixturePath, 'utf8'); +} + +function makeEntry( + sourceIndex: number, + seq: number, + description: string, + overrides: Partial = {}, +): ParsedSessionRecordingLogEntry { + return { + sourceIndex, + sourceLineNumber: sourceIndex + 1, + sourceText: '{}', + entry: { + timestamp: `2026-07-16T10:00:0${seq}.000Z`, + seq, + event: 'session.action', + description, + ...overrides, + }, + }; +} + +describe('helpers', () => { + it('detects slog files by filename extension only', () => { + expect(isSessionRecordingLogFileName('recording-0.slog')).toBe(true); + expect(isSessionRecordingLogFileName('recording-0.SLOG')).toBe(true); + expect(isSessionRecordingLogFileName('recording-0.webm')).toBe(false); + }); + + it('derives display order by seq then sourceIndex without mutating source', () => { + const entries = [makeEntry(2, 2, 'third'), makeEntry(1, 1, 'second'), makeEntry(0, 1, 'first tie')]; + const original = entries.map((entry) => entry.sourceIndex); + + const sorted = getSessionRecordingLogDisplayEntries({ entries }); + + expect(sorted.map((entry) => `${entry.entry.seq}:${entry.sourceIndex}`)).toEqual(['1:0', '1:1', '2:2']); + expect(entries.map((entry) => entry.sourceIndex)).toEqual(original); + }); + + it('searches visible fields only and supports event/warning filters', () => { + const entries = [ + makeEntry(0, 0, 'Renamed Object', { + event: 'session.action', + object: 'Help Desk Ottawa', + actor: 'Administrator', + host: 'IT-HELP-DC', + sessionType: 'ADConsole', + parameters: { + 'Members added': "Sarah O'Connor, Bob Smith", + }, + unknownFields: { + hidden: 'do-not-match', + }, + }), + makeEntry(1, 1, 'Session started', { + event: 'session.start', + }), + ]; + + const warnings: SessionRecordingLogWarning[] = [ + { code: 'invalid-field', sourceLineNumber: 1, seq: 0, message: 'x' }, + ]; + + expect(searchSessionRecordingLogEntries(entries, "o'connor")).toHaveLength(1); + expect(searchSessionRecordingLogEntries(entries, 'adconsole')).toHaveLength(1); + expect(searchSessionRecordingLogEntries(entries, 'do-not-match')).toHaveLength(0); + expect(searchSessionRecordingLogEntries(entries, 'session', { eventTypes: ['session.start'] })).toHaveLength(1); + expect(searchSessionRecordingLogEntries(entries, '', { onlyWithWarnings: true, warnings })).toHaveLength(1); + }); + + it('supports fixture-driven ordering and search usage flow', () => { + const parseResult = parseSessionRecordingLog(readFixture('sample-warning-linked.slog')); + const orderedEntries = getSessionRecordingLogDisplayEntries(parseResult); + + expect(orderedEntries).toHaveLength(3); + expect(orderedEntries.map((entry) => entry.entry.seq)).toEqual([0, 1, 2]); + + const warningOnly = searchSessionRecordingLogEntries(parseResult.entries, '', { + onlyWithWarnings: true, + warnings: parseResult.warnings, + }); + const queryHits = searchSessionRecordingLogEntries(parseResult.entries, "o'connor"); + + expect(parseResult.warnings.some((warning) => warning.code === 'unknown-event-type')).toBe(true); + expect(warningOnly).toHaveLength(1); + expect(queryHits).toHaveLength(1); + }); + + it('treats sourceLineNumber as authoritative for warning-linked filtering', () => { + const entries = [makeEntry(0, 1, 'Original sequence'), makeEntry(1, 1, 'Duplicate sequence')]; + const warnings: SessionRecordingLogWarning[] = [ + { code: 'duplicate-sequence', sourceLineNumber: 2, seq: 1, message: 'duplicate sequence number' }, + ]; + + const warningOnly = searchSessionRecordingLogEntries(entries, '', { + onlyWithWarnings: true, + warnings, + }); + + expect(warningOnly).toHaveLength(1); + expect(warningOnly[0]?.sourceLineNumber).toBe(2); + }); +}); diff --git a/webapp/packages/session-recording-log/src/index.ts b/webapp/packages/session-recording-log/src/index.ts new file mode 100644 index 000000000..fd98b7d42 --- /dev/null +++ b/webapp/packages/session-recording-log/src/index.ts @@ -0,0 +1,21 @@ +export { isSessionRecordingLogFileName } from './manifest'; +export type { + ParsedSessionRecordingLog, + ParsedSessionRecordingLogEntry, + ParseSessionRecordingLogOptions, + SearchableSessionRecordingLogField, + SearchSessionRecordingLogOptions, + SessionRecordingArtifact, + SessionRecordingArtifactContent, + SessionRecordingLogCompletionState, + SessionRecordingLogEntry, + SessionRecordingLogKnownEvent, + SessionRecordingLogParseResult, + SessionRecordingLogRecord, + SessionRecordingLogSearchHit, + SessionRecordingLogWarning, + SessionRecordingLogWarningCode, +} from './model'; +export { getSessionRecordingLogDisplayEntries } from './ordering'; +export { parseSessionRecordingLog } from './parser'; +export { searchSessionRecordingLogEntries } from './search'; diff --git a/webapp/packages/session-recording-log/src/manifest.ts b/webapp/packages/session-recording-log/src/manifest.ts new file mode 100644 index 000000000..3c72a81f1 --- /dev/null +++ b/webapp/packages/session-recording-log/src/manifest.ts @@ -0,0 +1,8 @@ +export function isSessionRecordingLogFileName(fileName: string): boolean { + if (!fileName) { + return false; + } + + const lowerCased = fileName.toLowerCase(); + return lowerCased.endsWith('.slog'); +} diff --git a/webapp/packages/session-recording-log/src/model.ts b/webapp/packages/session-recording-log/src/model.ts new file mode 100644 index 000000000..2aec3d85d --- /dev/null +++ b/webapp/packages/session-recording-log/src/model.ts @@ -0,0 +1,111 @@ +export type SessionRecordingLogKnownEvent = 'session.start' | 'session.action' | 'session.end'; + +export interface SessionRecordingLogEntry { + timestamp: string; + seq: number; + event: string; + description: string; + actor?: string; + locale?: string; + host?: string; + sessionType?: string; + object?: string; + parameters?: Record; + unknownFields?: Record; +} + +export interface ParsedSessionRecordingLogEntry { + entry: SessionRecordingLogEntry; + sourceLineNumber: number; + sourceIndex: number; + sourceText: string; +} + +export type SessionRecordingLogWarningCode = + | 'malformed-line' + | 'missing-session-start' + | 'missing-session-end' + | 'duplicate-sequence' + | 'missing-sequence' + | 'sequence-order-mismatch' + | 'unknown-event-type' + | 'invalid-field' + | 'entry-limit-exceeded' + | 'string-truncated' + | 'unterminated-final-line'; + +export interface SessionRecordingLogWarning { + code: SessionRecordingLogWarningCode; + sourceLineNumber?: number; + seq?: number; + message: string; +} + +export interface SessionRecordingLogParseResult { + entries: ParsedSessionRecordingLogEntry[]; + warnings: SessionRecordingLogWarning[]; + completionState: 'complete' | 'ended-unexpectedly'; +} + +export type SessionRecordingLogCompletionState = SessionRecordingLogParseResult['completionState']; + +export interface ParseSessionRecordingLogOptions { + warnOnInvalidTimestamp?: boolean; + maxLineLengthBytes?: number; + maxRetainedSourceTextBytes?: number; + maxStringLength?: number; + maxParameterCount?: number; + maxObjectDepth?: number; + maxParsedEntries?: number; + maxScannedLines?: number; + maxMissingSequenceWarnings?: number; + maxUnknownFieldCount?: number; + maxWarnings?: number; +} + +export interface SearchSessionRecordingLogOptions { + limit?: number; + caseSensitive?: boolean; + eventTypes?: string[]; + warnings?: SessionRecordingLogWarning[]; + onlyWithWarnings?: boolean; +} + +export type SearchableSessionRecordingLogField = + | 'timestamp' + | 'description' + | 'object' + | 'actor' + | 'host' + | 'sessionType' + | 'parameter-key' + | 'parameter-value'; + +export interface SessionRecordingLogSearchHit { + entry: ParsedSessionRecordingLogEntry; + sourceIndex: number; + sourceLineNumber: number; + matchedFields: SearchableSessionRecordingLogField[]; +} + +export interface SessionRecordingArtifact { + recordingId: string; + artifactId: string; + fileName: string; + /** Derived from fileName extension, not read from recording.json. */ + artifactType: string; + contentType?: string; + displayName: string; + startTime?: number; + duration?: number; +} + +export interface SessionRecordingArtifactContent { + artifact: SessionRecordingArtifact; + blob: Blob; + text: string; +} + +// Alias preserved to match AD producer historical naming. +export type SessionRecordingLogRecord = SessionRecordingLogEntry; +export type ParsedSessionRecordingLog = SessionRecordingLogParseResult; diff --git a/webapp/packages/session-recording-log/src/ordering.ts b/webapp/packages/session-recording-log/src/ordering.ts new file mode 100644 index 000000000..e83847e82 --- /dev/null +++ b/webapp/packages/session-recording-log/src/ordering.ts @@ -0,0 +1,14 @@ +import type { ParsedSessionRecordingLogEntry, SessionRecordingLogParseResult } from './model'; + +export function getSessionRecordingLogDisplayEntries( + parseResult: Pick, +): ParsedSessionRecordingLogEntry[] { + return [...parseResult.entries].sort((left, right) => { + const bySeq = left.entry.seq - right.entry.seq; + if (bySeq !== 0) { + return bySeq; + } + + return left.sourceIndex - right.sourceIndex; + }); +} diff --git a/webapp/packages/session-recording-log/src/parser.test.ts b/webapp/packages/session-recording-log/src/parser.test.ts new file mode 100644 index 000000000..a650c4a8d --- /dev/null +++ b/webapp/packages/session-recording-log/src/parser.test.ts @@ -0,0 +1,420 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { parseSessionRecordingLog } from './parser'; + +function readFixture(name: string): string { + const fixturePath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'fixtures', name); + return readFileSync(fixturePath, 'utf8'); +} + +describe('parseSessionRecordingLog', () => { + it('parses sample-complete fixture as complete with no warnings', () => { + const result = parseSessionRecordingLog(readFixture('sample-complete.slog')); + + expect(result.completionState).toBe('complete'); + expect(result.entries).toHaveLength(3); + expect(result.warnings).toHaveLength(0); + }); + + it('parses sample-missing-end fixture and surfaces missing-session-end warning', () => { + const result = parseSessionRecordingLog(readFixture('sample-missing-end.slog')); + + expect(result.completionState).toBe('ended-unexpectedly'); + expect(result.entries).toHaveLength(2); + expect(result.warnings.some((warning) => warning.code === 'missing-session-end')).toBe(true); + }); + + it('parses completed AD-like slog and preserves source metadata', () => { + const text = [ + '{"timestamp":"2026-07-15T16:15:35.140Z","seq":0,"actor":"Administrator","locale":"en-US","host":"IT-HELP-DC","sessionType":"ADConsole","event":"session.start","description":"Session started"}', + '{"timestamp":"2026-07-15T16:15:41.054Z","seq":1,"event":"session.action","description":"Created User","object":"asdasdasd"}', + '{"timestamp":"2026-07-15T16:16:32.759Z","seq":2,"event":"session.end","description":"Session ended"}', + '', + ].join('\n'); + + const result = parseSessionRecordingLog(text); + + expect(result.completionState).toBe('complete'); + expect(result.entries).toHaveLength(3); + expect(result.entries[0].sourceLineNumber).toBe(1); + expect(result.entries[1].sourceLineNumber).toBe(2); + expect(result.entries[0].entry.sessionType).toBe('ADConsole'); + }); + + it('marks missing session.end as ended unexpectedly while keeping valid entries', () => { + const text = [ + '{"timestamp":"2026-07-15T21:17:49.777Z","seq":0,"actor":"Administrator","host":"IT-HELP-DC","sessionType":"ADConsole","event":"session.start","description":"Session started"}', + '{"timestamp":"2026-07-15T21:19:14.351Z","seq":1,"event":"session.action","description":"Renamed Object","object":"Help Desk Ottawa","parameters":{"New name":"Help Desk Ottawa/Hull"}}', + '', + ].join('\n'); + + const result = parseSessionRecordingLog(text); + + expect(result.entries).toHaveLength(2); + expect(result.completionState).toBe('ended-unexpectedly'); + expect(result.warnings.some((warning) => warning.code === 'missing-session-end')).toBe(true); + }); + + it('maps malformed/invalid conditions to canonical warning codes', () => { + const text = [ + '{"timestamp":"2026-07-16T10:00:00.000Z","seq":0,"event":"session.start","description":"Start","producer":"custom-host"}', + '{not valid json}', + '{"timestamp":"2026-07-16T10:00:01.000Z","seq":2,"event":"session.action","description":"Action"}', + '{"timestamp":"not-a-date","seq":2,"event":"session.unknown","description":"Future event"}', + '{"timestamp":"2026-07-16T10:00:03.000Z","seq":4,"event":"session.end","description":"End"}', + '', + ].join('\n'); + + const result = parseSessionRecordingLog(text); + + expect(result.entries).toHaveLength(4); + expect(result.warnings.some((warning) => warning.code === 'malformed-line')).toBe(true); + expect(result.warnings.some((warning) => warning.code === 'invalid-field')).toBe(true); + expect(result.warnings.some((warning) => warning.code === 'duplicate-sequence')).toBe(true); + expect(result.warnings.some((warning) => warning.code === 'missing-sequence')).toBe(true); + expect(result.warnings.some((warning) => warning.code === 'unknown-event-type')).toBe(true); + expect(result.warnings.some((warning) => warning.code === 'sequence-order-mismatch')).toBe(false); + }); + + it('reports unterminated final line as additive informational warning', () => { + const text = [ + '{"timestamp":"2026-07-16T10:00:00.000Z","seq":0,"event":"session.start","description":"Start"}', + '{"timestamp":"2026-07-16T10:00:01.000Z","seq":1,"event":"session.end","description":"End"}', + ].join('\n'); + + const result = parseSessionRecordingLog(text); + expect(result.warnings.some((warning) => warning.code === 'unterminated-final-line')).toBe(true); + }); + + it('applies schema limits using canonical warning categories', () => { + const deepObject = + '{"timestamp":"2026-07-16T10:00:00.000Z","seq":0,"event":"session.start","description":"Start","x":{"y":{"z":{"k":1}}}}'; + const hugeLine = `{"timestamp":"2026-07-16T10:00:01.000Z","seq":1,"event":"session.action","description":"${'a'.repeat(600)}"}`; + const tooManyParameters = + '{"timestamp":"2026-07-16T10:00:02.000Z","seq":2,"event":"session.action","description":"Action","parameters":{"a":"1","b":"2","c":"3"}}'; + const endLine = '{"timestamp":"2026-07-16T10:00:03.000Z","seq":3,"event":"session.end","description":"End"}'; + const text = [deepObject, hugeLine, tooManyParameters, endLine, ''].join('\n'); + + const result = parseSessionRecordingLog(text, { + maxLineLengthBytes: 2000, + maxObjectDepth: 3, + maxParameterCount: 2, + maxStringLength: 64, + }); + + expect(result.warnings.some((warning) => warning.code === 'entry-limit-exceeded')).toBe(true); + expect(result.warnings.some((warning) => warning.code === 'invalid-field')).toBe(true); + expect(result.warnings.some((warning) => warning.code === 'string-truncated')).toBe(true); + expect(result.entries.some((entry) => entry.entry.event === 'session.end')).toBe(true); + }); + + it('caps missing sequence warnings for very large sequence gaps', () => { + const text = [ + '{"timestamp":"2026-07-16T10:00:00.000Z","seq":0,"event":"session.start","description":"Start"}', + '{"timestamp":"2026-07-16T10:00:01.000Z","seq":1000000000,"event":"session.end","description":"End"}', + '', + ].join('\n'); + + const result = parseSessionRecordingLog(text, { + maxMissingSequenceWarnings: 3, + }); + + expect(result.entries).toHaveLength(2); + expect(result.warnings.filter((warning) => warning.code === 'missing-sequence')).toHaveLength(3); + expect( + result.warnings.some( + (warning) => + warning.code === 'entry-limit-exceeded' && warning.message.startsWith('missing sequence warnings truncated'), + ), + ).toBe(true); + }); + + it('normalizes invalid numeric limit options to safe finite defaults', () => { + const text = [ + '{"timestamp":"2026-07-16T10:00:00.000Z","seq":0,"event":"session.start","description":"Start"}', + '{"timestamp":"2026-07-16T10:00:01.000Z","seq":2000,"event":"session.end","description":"End"}', + '', + ].join('\n'); + + const result = parseSessionRecordingLog(text, { + maxLineLengthBytes: Number.NaN, + maxStringLength: Number.POSITIVE_INFINITY, + maxParameterCount: Number.NaN, + maxObjectDepth: Number.POSITIVE_INFINITY, + maxParsedEntries: Number.NaN, + maxScannedLines: Number.NaN, + maxMissingSequenceWarnings: Number.POSITIVE_INFINITY, + maxUnknownFieldCount: Number.NaN, + }); + + expect(result.entries).toHaveLength(2); + expect(result.warnings.filter((warning) => warning.code === 'missing-sequence').length).toBeLessThanOrEqual(1000); + expect( + result.warnings.some( + (warning) => + warning.code === 'entry-limit-exceeded' && warning.message.includes('additional missing sequences'), + ), + ).toBe(true); + }); + + it('caps total warning volume and emits one truncation warning', () => { + const invalidParameters = Array.from({ length: 40 }, (_, index) => `"p${index}":${index}`).join(','); + const text = `{"timestamp":"2026-07-16T10:00:00.000Z","seq":0,"event":"session.start","description":"Start","parameters":{${invalidParameters}}}\n`; + + const result = parseSessionRecordingLog(text, { maxWarnings: 5 }); + + expect(result.warnings.length).toBeLessThanOrEqual(6); + expect( + result.warnings.some( + (warning) => + warning.code === 'entry-limit-exceeded' && + warning.message === 'warning limit exceeded; additional warnings were truncated', + ), + ).toBe(true); + }); + + it('caps retained source text bytes across parsed entries', () => { + const text = [ + '{"timestamp":"2026-07-16T10:00:00.000Z","seq":0,"event":"session.start","description":"Start"}', + '{"timestamp":"2026-07-16T10:00:01.000Z","seq":1,"event":"session.action","description":"Action"}', + '{"timestamp":"2026-07-16T10:00:02.000Z","seq":2,"event":"session.end","description":"End"}', + '', + ].join('\n'); + + const result = parseSessionRecordingLog(text, { maxRetainedSourceTextBytes: 120 }); + + expect(result.entries).toHaveLength(1); + expect( + result.warnings.some( + (warning) => + warning.code === 'entry-limit-exceeded' && warning.message === 'retained source text byte budget exceeded', + ), + ).toBe(true); + }); + + it('does not mark session complete when source-text budget drops session.end entry', () => { + const text = [ + '{"timestamp":"2026-07-16T10:00:00.000Z","seq":0,"event":"session.start","description":"Start"}', + '{"timestamp":"2026-07-16T10:00:01.000Z","seq":1,"event":"session.end","description":"End"}', + '', + ].join('\n'); + + const result = parseSessionRecordingLog(text, { maxRetainedSourceTextBytes: 100 }); + + expect(result.entries).toHaveLength(1); + expect(result.completionState).toBe('ended-unexpectedly'); + expect(result.warnings.some((warning) => warning.code === 'missing-session-end')).toBe(true); + }); + + it('caps warning volume at the default aggregate limit', () => { + const invalidParameters = Array.from({ length: 200 }, (_, index) => `"p${index}":${index}`).join(','); + const lines = Array.from( + { length: 60 }, + (_, index) => + `{"timestamp":"2026-07-16T10:00:${(index % 60).toString().padStart(2, '0')}.000Z","seq":${index},"event":"session.action","description":"Action","parameters":{${invalidParameters}}}`, + ); + const text = `${lines.join('\n')}\n`; + + const result = parseSessionRecordingLog(text); + + expect(result.warnings.length).toBeLessThanOrEqual(10001); + expect( + result.warnings.some( + (warning) => + warning.code === 'entry-limit-exceeded' && + warning.message === 'warning limit exceeded; additional warnings were truncated', + ), + ).toBe(true); + }); + + it('handles deeply nested records without crashing parse flow', () => { + const depth = 1000; + let nested = '1'; + for (let index = 0; index < depth; index += 1) { + nested = `{"k":${nested}}`; + } + + const text = [ + `{"timestamp":"2026-07-16T10:00:00.000Z","seq":0,"event":"session.start","description":"Start","deep":${nested}}`, + '{"timestamp":"2026-07-16T10:00:01.000Z","seq":1,"event":"session.end","description":"End"}', + '', + ].join('\n'); + + const result = parseSessionRecordingLog(text, { maxObjectDepth: 8 }); + + expect(result.warnings.some((warning) => warning.code === 'invalid-field')).toBe(true); + expect(result.entries).toHaveLength(1); + expect(result.entries[0].entry.event).toBe('session.end'); + }); + + it('stops scanning after configured non-empty line limit even when lines are malformed', () => { + const malformed = Array.from({ length: 20 }, () => '{not valid json}'); + const text = `${malformed.join('\n')}\n`; + + const result = parseSessionRecordingLog(text, { maxScannedLines: 5 }); + + expect(result.entries).toHaveLength(0); + expect(result.warnings.some((warning) => warning.code === 'entry-limit-exceeded')).toBe(true); + }); + + it('counts blank lines toward scanned line limits', () => { + const text = Array.from({ length: 20 }, () => '').join('\n'); + + const result = parseSessionRecordingLog(text, { maxScannedLines: 5 }); + + expect(result.warnings.some((warning) => warning.code === 'entry-limit-exceeded')).toBe(true); + }); + + it('marks truncated non-empty input as ended unexpectedly when scan limit is zero', () => { + const text = '{"timestamp":"2026-07-16T10:00:00.000Z","seq":0,"event":"session.start","description":"Start"}\n'; + + const result = parseSessionRecordingLog(text, { maxScannedLines: 0 }); + + expect(result.completionState).toBe('ended-unexpectedly'); + expect(result.warnings.some((warning) => warning.code === 'entry-limit-exceeded')).toBe(true); + }); + + it('marks truncated input as ended unexpectedly when blank prefix consumes scan limit', () => { + const text = '\n{"timestamp":"2026-07-16T10:00:00.000Z","seq":0,"event":"session.start","description":"Start"}\n'; + + const result = parseSessionRecordingLog(text, { maxScannedLines: 0 }); + + expect(result.completionState).toBe('ended-unexpectedly'); + expect(result.warnings.some((warning) => warning.code === 'entry-limit-exceeded')).toBe(true); + }); + + it('treats an empty file as complete and emits no missing-session warnings', () => { + const result = parseSessionRecordingLog(''); + + expect(result.completionState).toBe('complete'); + expect(result.warnings.some((warning) => warning.code === 'missing-session-start')).toBe(false); + expect(result.warnings.some((warning) => warning.code === 'missing-session-end')).toBe(false); + }); + + it('treats non-empty logs with no valid records as ended unexpectedly', () => { + const result = parseSessionRecordingLog('{not valid json}\n'); + + expect(result.entries).toHaveLength(0); + expect(result.completionState).toBe('ended-unexpectedly'); + }); + + it('emits unknown-event-type warning for unrecognized events', () => { + const text = [ + '{"timestamp":"2026-07-16T10:00:00.000Z","seq":0,"event":"session.start","description":"Start"}', + '{"timestamp":"2026-07-16T10:00:01.000Z","seq":1,"event":"session.future","description":"Future"}', + '{"timestamp":"2026-07-16T10:00:02.000Z","seq":2,"event":"session.end","description":"End"}', + '', + ].join('\n'); + + const result = parseSessionRecordingLog(text); + + expect(result.entries).toHaveLength(3); + expect(result.warnings.some((warning) => warning.code === 'unknown-event-type')).toBe(true); + }); + + it('caps unknown top-level field collection per record', () => { + const text = [ + '{"timestamp":"2026-07-16T10:00:00.000Z","seq":0,"event":"session.start","description":"Start","a":1,"b":2,"c":3}', + '{"timestamp":"2026-07-16T10:00:01.000Z","seq":1,"event":"session.end","description":"End"}', + '', + ].join('\n'); + + const result = parseSessionRecordingLog(text, { maxUnknownFieldCount: 2 }); + const startEntry = result.entries.find((entry) => entry.entry.event === 'session.start'); + + expect(startEntry).toBeDefined(); + expect(Object.keys(startEntry?.entry.unknownFields ?? {})).toHaveLength(2); + expect( + result.warnings.some( + (warning) => + warning.code === 'entry-limit-exceeded' && + warning.message === 'unknown top-level field count exceeded max limit', + ), + ).toBe(true); + }); + + it('rejects unsafe sequence integers', () => { + const text = [ + '{"timestamp":"2026-07-16T10:00:00.000Z","seq":0,"event":"session.start","description":"Start"}', + '{"timestamp":"2026-07-16T10:00:01.000Z","seq":9007199254740993,"event":"session.action","description":"Unsafe seq"}', + '{"timestamp":"2026-07-16T10:00:02.000Z","seq":1,"event":"session.end","description":"End"}', + '', + ].join('\n'); + + const result = parseSessionRecordingLog(text); + + expect(result.entries).toHaveLength(2); + expect( + result.warnings.some( + (warning) => warning.code === 'invalid-field' && warning.message === 'seq must be a safe integer', + ), + ).toBe(true); + }); + + it('reports missing sequences before the first observed sequence', () => { + const text = '{"timestamp":"2026-07-16T10:00:00.000Z","seq":5,"event":"session.end","description":"End"}\n'; + + const result = parseSessionRecordingLog(text, { maxMissingSequenceWarnings: 3 }); + + expect(result.warnings.filter((warning) => warning.code === 'missing-sequence')).toHaveLength(3); + expect( + result.warnings.some( + (warning) => + warning.code === 'entry-limit-exceeded' && warning.message.includes('additional missing sequences'), + ), + ).toBe(true); + }); + + it('rejects overlong events without deriving completion from truncated text', () => { + const text = + '{"timestamp":"2026-07-16T10:00:00.000Z","seq":0,"event":"session.end.extra","description":"End-like"}\n'; + + const result = parseSessionRecordingLog(text, { maxStringLength: 11 }); + + expect(result.entries).toHaveLength(0); + expect(result.completionState).toBe('ended-unexpectedly'); + expect( + result.warnings.some( + (warning) => warning.code === 'invalid-field' && warning.message === 'event exceeded max string length', + ), + ).toBe(true); + }); + + it('preserves __proto__ keys in parameters and unknown fields as data', () => { + const text = [ + '{"timestamp":"2026-07-16T10:00:00.000Z","seq":0,"event":"session.start","description":"Start","parameters":{"__proto__":"kept"},"__proto__":{"safe":"value"}}', + '{"timestamp":"2026-07-16T10:00:01.000Z","seq":1,"event":"session.end","description":"End"}', + '', + ].join('\n'); + + const result = parseSessionRecordingLog(text); + const startEntry = result.entries.find((entry) => entry.entry.event === 'session.start'); + + expect(startEntry).toBeDefined(); + expect(startEntry?.entry.parameters?.__proto__).toBe('kept'); + expect((startEntry?.entry.unknownFields?.__proto__ as { safe?: string } | undefined)?.safe).toBe('value'); + }); + + it('warns and preserves the first value on parameter key collisions after truncation', () => { + const text = [ + '{"timestamp":"2026-07-16T10:00:00.000Z","seq":0,"event":"evt1","description":"Start","parameters":{"abcd1":"one","abcd2":"two"}}', + '{"timestamp":"2026-07-16T10:00:01.000Z","seq":1,"event":"evt2","description":"End"}', + '', + ].join('\n'); + + const result = parseSessionRecordingLog(text, { maxStringLength: 4 }); + const firstEntry = result.entries[0]; + + expect(firstEntry).toBeDefined(); + expect(firstEntry?.entry.parameters).toEqual({ abcd: 'one' }); + expect( + result.warnings.some( + (warning) => + warning.code === 'invalid-field' && + warning.message === 'parameter abcd collided after truncation and was discarded', + ), + ).toBe(true); + }); +}); diff --git a/webapp/packages/session-recording-log/src/parser.ts b/webapp/packages/session-recording-log/src/parser.ts new file mode 100644 index 000000000..ac66ec88f --- /dev/null +++ b/webapp/packages/session-recording-log/src/parser.ts @@ -0,0 +1,599 @@ +import type { + ParsedSessionRecordingLogEntry, + ParseSessionRecordingLogOptions, + SessionRecordingLogEntry, + SessionRecordingLogKnownEvent, + SessionRecordingLogParseResult, + SessionRecordingLogWarning, + SessionRecordingLogWarningCode, +} from './model'; + +const KNOWN_EVENTS = new Set(['session.start', 'session.action', 'session.end']); +const KNOWN_FIELDS = new Set([ + 'timestamp', + 'seq', + 'event', + 'description', + 'actor', + 'locale', + 'host', + 'sessionType', + 'object', + 'parameters', +]); + +const DEFAULT_MAX_LINE_LENGTH_BYTES = 256 * 1024; +const DEFAULT_MAX_RETAINED_SOURCE_TEXT_BYTES = 8 * 1024 * 1024; +const DEFAULT_MAX_STRING_LENGTH = 4096; +const DEFAULT_MAX_PARAMETER_COUNT = 200; +const DEFAULT_MAX_OBJECT_DEPTH = 8; +const DEFAULT_MAX_PARSED_ENTRIES = 10_000; +const DEFAULT_MAX_SCANNED_LINES = 20_000; +const DEFAULT_MAX_MISSING_SEQUENCE_WARNINGS = 1_000; +const DEFAULT_MAX_UNKNOWN_FIELD_COUNT = 100; +const DEFAULT_MAX_WARNINGS = 10_000; + +function createWarning( + code: SessionRecordingLogWarningCode, + message: string, + details?: Pick, +): SessionRecordingLogWarning { + return { + code, + message, + ...details, + }; +} + +interface WarningCollector { + warnings: SessionRecordingLogWarning[]; + add(warning: SessionRecordingLogWarning): void; +} + +function createWarningCollector(maxWarnings: number): WarningCollector { + const warnings: SessionRecordingLogWarning[] = []; + let emittedTruncationWarning = false; + + return { + warnings, + add(warning) { + if (warnings.length < maxWarnings) { + warnings.push(warning); + return; + } + + if (emittedTruncationWarning) { + return; + } + + warnings.push( + createWarning('entry-limit-exceeded', 'warning limit exceeded; additional warnings were truncated'), + ); + emittedTruncationWarning = true; + }, + }; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function createLineIterator(text: string): Iterable<{ line: string; sourceLineNumber: number }> { + return { + [Symbol.iterator](): Iterator<{ line: string; sourceLineNumber: number }> { + let offset = 0; + let sourceLineNumber = 1; + let finished = false; + + return { + next(): IteratorResult<{ line: string; sourceLineNumber: number }> { + if (finished) { + return { done: true, value: undefined }; + } + + if (offset >= text.length) { + finished = true; + return { done: true, value: undefined }; + } + + const nextNewLine = text.indexOf('\n', offset); + if (nextNewLine === -1) { + const line = text.slice(offset).replace(/\r$/, ''); + finished = true; + return { + done: false, + value: { line, sourceLineNumber }, + }; + } + + const line = text.slice(offset, nextNewLine).replace(/\r$/, ''); + const currentLineNumber = sourceLineNumber; + sourceLineNumber += 1; + offset = nextNewLine + 1; + return { + done: false, + value: { line, sourceLineNumber: currentLineNumber }, + }; + }, + }; + }, + }; +} + +function normalizeNonNegativeLimit(value: number | undefined, fallback: number): number { + if (value === undefined || !Number.isFinite(value) || value < 0) { + return fallback; + } + + return Math.trunc(value); +} + +function getUtf8ByteLength(value: string, maxBytes?: number): number { + let byteLength = 0; + + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + + if (code <= 0x7f) { + byteLength += 1; + } else if (code <= 0x7ff) { + byteLength += 2; + } else if (code >= 0xd800 && code <= 0xdbff) { + const nextCode = value.charCodeAt(index + 1); + if (nextCode >= 0xdc00 && nextCode <= 0xdfff) { + byteLength += 4; + index += 1; + } else { + byteLength += 3; + } + } else { + byteLength += 3; + } + if (maxBytes !== undefined && byteLength > maxBytes) { + return byteLength; + } + } + + return byteLength; +} + +function exceedsMaxObjectDepth(value: unknown, maxObjectDepth: number): boolean { + const stack: Array<{ value: unknown; depth: number }> = [{ value, depth: 1 }]; + + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + + if (current.depth > maxObjectDepth) { + return true; + } + + if (Array.isArray(current.value)) { + for (const item of current.value) { + if (isPlainObject(item) || Array.isArray(item)) { + stack.push({ value: item, depth: current.depth + 1 }); + } + } + continue; + } + + if (isPlainObject(current.value)) { + for (const item of Object.values(current.value)) { + if (isPlainObject(item) || Array.isArray(item)) { + stack.push({ value: item, depth: current.depth + 1 }); + } + } + } + } + + return false; +} + +function normalizeString( + value: unknown, + field: string, + warningCollector: WarningCollector, + sourceLineNumber: number, + maxStringLength: number, +): string | null { + if (typeof value !== 'string') { + warningCollector.add(createWarning('invalid-field', `${field} must be a string`, { sourceLineNumber })); + return null; + } + + if (value.length > maxStringLength) { + warningCollector.add( + createWarning('string-truncated', `${field} exceeded max string length and was truncated`, { sourceLineNumber }), + ); + return value.slice(0, maxStringLength); + } + + return value; +} + +function parseParameters( + value: unknown, + warningCollector: WarningCollector, + sourceLineNumber: number, + maxStringLength: number, + maxParameterCount: number, +): Record | undefined { + if (value === undefined) { + return undefined; + } + + if (!isPlainObject(value)) { + warningCollector.add(createWarning('invalid-field', 'parameters must be an object', { sourceLineNumber })); + return undefined; + } + + const entries = Object.entries(value); + if (entries.length > maxParameterCount) { + warningCollector.add( + createWarning('entry-limit-exceeded', 'parameter count exceeded max limit', { sourceLineNumber }), + ); + } + + const output: Record = Object.create(null) as Record; + for (const [rawKey, rawValue] of entries.slice(0, maxParameterCount)) { + let key = rawKey; + if (rawKey.length > maxStringLength) { + warningCollector.add( + createWarning('string-truncated', 'parameter key exceeded max string length and was truncated', { + sourceLineNumber, + }), + ); + key = rawKey.slice(0, maxStringLength); + } + + if (typeof rawValue !== 'string') { + warningCollector.add(createWarning('invalid-field', `parameter ${key} must be a string`, { sourceLineNumber })); + continue; + } + + if (Object.hasOwn(output, key)) { + warningCollector.add( + createWarning('invalid-field', `parameter ${key} collided after truncation and was discarded`, { + sourceLineNumber, + }), + ); + continue; + } + + if (rawValue.length > maxStringLength) { + warningCollector.add( + createWarning('string-truncated', `parameter ${key} exceeded max string length and was truncated`, { + sourceLineNumber, + }), + ); + output[key] = rawValue.slice(0, maxStringLength); + continue; + } + + output[key] = rawValue; + } + + return output; +} + +function parseLineRecord( + parsed: Record, + warningCollector: WarningCollector, + sourceLineNumber: number, + options: Required< + Pick< + ParseSessionRecordingLogOptions, + 'maxStringLength' | 'maxParameterCount' | 'warnOnInvalidTimestamp' | 'maxUnknownFieldCount' + > + >, +): { entry: SessionRecordingLogEntry; originalEvent: string } | null { + const timestamp = normalizeString( + parsed.timestamp, + 'timestamp', + warningCollector, + sourceLineNumber, + options.maxStringLength, + ); + const description = normalizeString( + parsed.description, + 'description', + warningCollector, + sourceLineNumber, + options.maxStringLength, + ); + const seq = parsed.seq; + const rawEvent = parsed.event; + + if (typeof rawEvent !== 'string') { + warningCollector.add(createWarning('invalid-field', 'event must be a string', { sourceLineNumber })); + return null; + } + + if (rawEvent.length > options.maxStringLength) { + warningCollector.add(createWarning('invalid-field', 'event exceeded max string length', { sourceLineNumber })); + return null; + } + + if (timestamp === null || description === null) { + return null; + } + + if (typeof seq !== 'number' || !Number.isInteger(seq) || seq < 0) { + warningCollector.add(createWarning('invalid-field', 'seq must be a non-negative integer', { sourceLineNumber })); + return null; + } + if (!Number.isSafeInteger(seq)) { + warningCollector.add(createWarning('invalid-field', 'seq must be a safe integer', { sourceLineNumber })); + return null; + } + + if (options.warnOnInvalidTimestamp && Number.isNaN(Date.parse(timestamp))) { + warningCollector.add( + createWarning('invalid-field', 'timestamp is not a valid date string', { sourceLineNumber, seq }), + ); + } + + if (!KNOWN_EVENTS.has(rawEvent as SessionRecordingLogKnownEvent)) { + warningCollector.add( + createWarning('unknown-event-type', 'event is not one of the known lifecycle events', { sourceLineNumber, seq }), + ); + } + + const actor = + parsed.actor === undefined + ? undefined + : normalizeString(parsed.actor, 'actor', warningCollector, sourceLineNumber, options.maxStringLength); + const locale = + parsed.locale === undefined + ? undefined + : normalizeString(parsed.locale, 'locale', warningCollector, sourceLineNumber, options.maxStringLength); + const host = + parsed.host === undefined + ? undefined + : normalizeString(parsed.host, 'host', warningCollector, sourceLineNumber, options.maxStringLength); + const sessionType = + parsed.sessionType === undefined + ? undefined + : normalizeString(parsed.sessionType, 'sessionType', warningCollector, sourceLineNumber, options.maxStringLength); + const object = + parsed.object === undefined + ? undefined + : normalizeString(parsed.object, 'object', warningCollector, sourceLineNumber, options.maxStringLength); + const parameters = parseParameters( + parsed.parameters, + warningCollector, + sourceLineNumber, + options.maxStringLength, + options.maxParameterCount, + ); + + const unknownEntries = Object.entries(parsed).filter(([key]) => !KNOWN_FIELDS.has(key)); + if (unknownEntries.length > options.maxUnknownFieldCount) { + warningCollector.add( + createWarning('entry-limit-exceeded', 'unknown top-level field count exceeded max limit', { sourceLineNumber }), + ); + } + + const unknownFields: Record = Object.create(null) as Record; + for (const [key, value] of unknownEntries.slice(0, options.maxUnknownFieldCount)) { + unknownFields[key] = value; + } + + return { + entry: { + timestamp, + seq, + event: rawEvent, + description, + ...(actor === null || actor === undefined ? {} : { actor }), + ...(locale === null || locale === undefined ? {} : { locale }), + ...(host === null || host === undefined ? {} : { host }), + ...(sessionType === null || sessionType === undefined ? {} : { sessionType }), + ...(object === null || object === undefined ? {} : { object }), + ...(parameters ? { parameters } : {}), + ...(Object.keys(unknownFields).length === 0 ? {} : { unknownFields }), + }, + originalEvent: rawEvent, + }; +} + +export function parseSessionRecordingLog( + text: string, + options?: ParseSessionRecordingLogOptions, +): SessionRecordingLogParseResult { + const entries: ParsedSessionRecordingLogEntry[] = []; + const maxLineLengthBytes = normalizeNonNegativeLimit(options?.maxLineLengthBytes, DEFAULT_MAX_LINE_LENGTH_BYTES); + const maxRetainedSourceTextBytes = normalizeNonNegativeLimit( + options?.maxRetainedSourceTextBytes, + DEFAULT_MAX_RETAINED_SOURCE_TEXT_BYTES, + ); + const maxStringLength = normalizeNonNegativeLimit(options?.maxStringLength, DEFAULT_MAX_STRING_LENGTH); + const maxParameterCount = normalizeNonNegativeLimit(options?.maxParameterCount, DEFAULT_MAX_PARAMETER_COUNT); + const maxObjectDepth = normalizeNonNegativeLimit(options?.maxObjectDepth, DEFAULT_MAX_OBJECT_DEPTH); + const maxParsedEntries = normalizeNonNegativeLimit(options?.maxParsedEntries, DEFAULT_MAX_PARSED_ENTRIES); + const maxScannedLines = normalizeNonNegativeLimit(options?.maxScannedLines, DEFAULT_MAX_SCANNED_LINES); + const maxMissingSequenceWarnings = normalizeNonNegativeLimit( + options?.maxMissingSequenceWarnings, + DEFAULT_MAX_MISSING_SEQUENCE_WARNINGS, + ); + const maxUnknownFieldCount = normalizeNonNegativeLimit( + options?.maxUnknownFieldCount, + DEFAULT_MAX_UNKNOWN_FIELD_COUNT, + ); + const maxWarnings = normalizeNonNegativeLimit(options?.maxWarnings, DEFAULT_MAX_WARNINGS); + const warningCollector = createWarningCollector(maxWarnings); + const warnOnInvalidTimestamp = options?.warnOnInvalidTimestamp ?? true; + let encounteredNonEmptyLine = false; + let truncatedByScannedLineLimit = false; + let hasSessionStart = false; + let hasSessionEnd = false; + let retainedSourceTextBytes = 0; + let scannedLines = 0; + + const hasTrailingNewline = text.endsWith('\n'); + for (const { line, sourceLineNumber } of createLineIterator(text)) { + const isNonEmptyLine = line.trim().length > 0; + if (isNonEmptyLine) { + encounteredNonEmptyLine = true; + } + + scannedLines += 1; + if (scannedLines > maxScannedLines) { + truncatedByScannedLineLimit = true; + warningCollector.add(createWarning('entry-limit-exceeded', 'scanned line limit exceeded', { sourceLineNumber })); + break; + } + + if (!isNonEmptyLine) { + continue; + } + + if (entries.length >= maxParsedEntries) { + warningCollector.add(createWarning('entry-limit-exceeded', 'parsed entry limit exceeded', { sourceLineNumber })); + break; + } + + const lineByteLength = getUtf8ByteLength(line, maxLineLengthBytes); + if (lineByteLength > maxLineLengthBytes) { + warningCollector.add(createWarning('entry-limit-exceeded', 'line exceeds max byte size', { sourceLineNumber })); + continue; + } + + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + warningCollector.add(createWarning('malformed-line', 'line is not valid JSON', { sourceLineNumber })); + continue; + } + + if (!isPlainObject(parsed)) { + warningCollector.add(createWarning('malformed-line', 'line JSON must be an object', { sourceLineNumber })); + continue; + } + + if (exceedsMaxObjectDepth(parsed, maxObjectDepth)) { + warningCollector.add(createWarning('invalid-field', 'line object exceeds max depth', { sourceLineNumber })); + continue; + } + + const record = parseLineRecord(parsed, warningCollector, sourceLineNumber, { + maxStringLength, + maxParameterCount, + maxUnknownFieldCount, + warnOnInvalidTimestamp, + }); + if (!record) { + continue; + } + + if (retainedSourceTextBytes + lineByteLength > maxRetainedSourceTextBytes) { + warningCollector.add( + createWarning('entry-limit-exceeded', 'retained source text byte budget exceeded', { sourceLineNumber }), + ); + break; + } + retainedSourceTextBytes += lineByteLength; + + if (record.originalEvent === 'session.start') { + hasSessionStart = true; + } else if (record.originalEvent === 'session.end') { + hasSessionEnd = true; + } + + entries.push({ + entry: record.entry, + sourceLineNumber, + sourceIndex: entries.length, + sourceText: line, + }); + } + + if (!hasTrailingNewline && text.length > 0) { + warningCollector.add(createWarning('unterminated-final-line', 'final line does not end with newline')); + } + + const seenSequences = new Set(); + let previousSeq = -1; + + for (const parsedEntry of entries) { + const seq = parsedEntry.entry.seq; + + if (seenSequences.has(seq)) { + warningCollector.add( + createWarning('duplicate-sequence', 'sequence number is duplicated', { + sourceLineNumber: parsedEntry.sourceLineNumber, + seq, + }), + ); + } else { + seenSequences.add(seq); + } + + if (seq < previousSeq) { + warningCollector.add( + createWarning('sequence-order-mismatch', 'file order does not match sequence order', { + sourceLineNumber: parsedEntry.sourceLineNumber, + seq, + }), + ); + } + previousSeq = seq; + } + + if (seenSequences.size > 0) { + const sortedSeq = [-1, ...Array.from(seenSequences).sort((left, right) => left - right)]; + let missingTotal = 0; + + for (let index = 1; index < sortedSeq.length; index += 1) { + const gap = sortedSeq[index] - sortedSeq[index - 1] - 1; + if (gap > 0) { + missingTotal += gap; + } + } + + let emittedMissingWarnings = 0; + if (missingTotal > 0 && maxMissingSequenceWarnings > 0) { + for (let index = 1; index < sortedSeq.length; index += 1) { + const start = sortedSeq[index - 1] + 1; + const end = sortedSeq[index]; + for (let seq = start; seq < end; seq += 1) { + warningCollector.add(createWarning('missing-sequence', 'sequence number is missing', { seq })); + emittedMissingWarnings += 1; + if (emittedMissingWarnings >= maxMissingSequenceWarnings) { + break; + } + } + + if (emittedMissingWarnings >= maxMissingSequenceWarnings) { + break; + } + } + } + + if (missingTotal > emittedMissingWarnings) { + warningCollector.add( + createWarning( + 'entry-limit-exceeded', + `missing sequence warnings truncated: ${missingTotal - emittedMissingWarnings} additional missing sequences`, + ), + ); + } + } + + if (entries.length > 0 && !hasSessionStart) { + warningCollector.add(createWarning('missing-session-start', 'session.start is missing')); + } + if (entries.length > 0 && !hasSessionEnd) { + warningCollector.add(createWarning('missing-session-end', 'session.end is missing')); + } + + return { + entries, + warnings: warningCollector.warnings, + completionState: + hasSessionEnd || (!encounteredNonEmptyLine && !truncatedByScannedLineLimit) ? 'complete' : 'ended-unexpectedly', + }; +} diff --git a/webapp/packages/session-recording-log/src/search.ts b/webapp/packages/session-recording-log/src/search.ts new file mode 100644 index 000000000..c765de5f7 --- /dev/null +++ b/webapp/packages/session-recording-log/src/search.ts @@ -0,0 +1,120 @@ +import type { + ParsedSessionRecordingLogEntry, + SearchableSessionRecordingLogField, + SearchSessionRecordingLogOptions, + SessionRecordingLogSearchHit, +} from './model'; + +const DEFAULT_SEARCH_LIMIT = 100; +const MAX_SEARCH_LIMIT = 1000; + +function normalizeLimit(limit: number | undefined): number { + if (!Number.isFinite(limit)) { + return DEFAULT_SEARCH_LIMIT; + } + + return Math.max(1, Math.min(MAX_SEARCH_LIMIT, Math.trunc(limit ?? DEFAULT_SEARCH_LIMIT))); +} + +function maybeNormalize(value: string, caseSensitive: boolean): string { + return caseSensitive ? value : value.toLowerCase(); +} + +function buildWarningAssociationIndex(warnings: SearchSessionRecordingLogOptions['warnings']): { + lineNumbers: Set; + sequences: Set; +} { + const lineNumbers = new Set(); + const sequences = new Set(); + for (const warning of warnings ?? []) { + if (warning.sourceLineNumber !== undefined) { + lineNumbers.add(warning.sourceLineNumber); + continue; + } + if (warning.seq !== undefined) { + sequences.add(warning.seq); + } + } + + return { lineNumbers, sequences }; +} + +function tryMatchField( + value: string | undefined, + query: string, + caseSensitive: boolean, + field: SearchableSessionRecordingLogField, + matchedFields: Set, +): void { + if (!value) { + return; + } + + const candidate = maybeNormalize(value, caseSensitive); + if (candidate.includes(query)) { + matchedFields.add(field); + } +} + +export function searchSessionRecordingLogEntries( + entries: ParsedSessionRecordingLogEntry[], + query: string, + options?: SearchSessionRecordingLogOptions, +): SessionRecordingLogSearchHit[] { + const trimmed = query.trim(); + const limit = normalizeLimit(options?.limit); + const caseSensitive = options?.caseSensitive ?? false; + const normalizedQuery = maybeNormalize(trimmed, caseSensitive); + const eventTypes = options?.eventTypes; + const onlyWithWarnings = options?.onlyWithWarnings ?? false; + const warningAssociations = buildWarningAssociationIndex(options?.warnings); + const hits: SessionRecordingLogSearchHit[] = []; + + for (const parsedEntry of entries) { + if (eventTypes && eventTypes.length > 0 && !eventTypes.includes(parsedEntry.entry.event)) { + continue; + } + + if ( + onlyWithWarnings && + !warningAssociations.lineNumbers.has(parsedEntry.sourceLineNumber) && + !warningAssociations.sequences.has(parsedEntry.entry.seq) + ) { + continue; + } + + const matchedFields = new Set(); + const entry = parsedEntry.entry; + if (normalizedQuery.length > 0) { + tryMatchField(entry.timestamp, normalizedQuery, caseSensitive, 'timestamp', matchedFields); + tryMatchField(entry.description, normalizedQuery, caseSensitive, 'description', matchedFields); + tryMatchField(entry.object, normalizedQuery, caseSensitive, 'object', matchedFields); + tryMatchField(entry.actor, normalizedQuery, caseSensitive, 'actor', matchedFields); + tryMatchField(entry.host, normalizedQuery, caseSensitive, 'host', matchedFields); + tryMatchField(entry.sessionType, normalizedQuery, caseSensitive, 'sessionType', matchedFields); + + const parameters = entry.parameters ?? {}; + for (const [key, value] of Object.entries(parameters)) { + tryMatchField(key, normalizedQuery, caseSensitive, 'parameter-key', matchedFields); + tryMatchField(value, normalizedQuery, caseSensitive, 'parameter-value', matchedFields); + } + } + + if (normalizedQuery.length > 0 && matchedFields.size === 0) { + continue; + } + + hits.push({ + entry: parsedEntry, + sourceIndex: parsedEntry.sourceIndex, + sourceLineNumber: parsedEntry.sourceLineNumber, + matchedFields: [...matchedFields], + }); + + if (hits.length >= limit) { + break; + } + } + + return hits; +} diff --git a/webapp/packages/session-recording-log/tsconfig.declaration.json b/webapp/packages/session-recording-log/tsconfig.declaration.json new file mode 100644 index 000000000..c28817bc3 --- /dev/null +++ b/webapp/packages/session-recording-log/tsconfig.declaration.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "rootDir": "src", + "outDir": "dist" + } +} diff --git a/webapp/packages/session-recording-log/tsconfig.json b/webapp/packages/session-recording-log/tsconfig.json new file mode 100644 index 000000000..792dc76c2 --- /dev/null +++ b/webapp/packages/session-recording-log/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"], + "exclude": ["dist", "node_modules"] +} diff --git a/webapp/packages/session-recording-log/vite.config.ts b/webapp/packages/session-recording-log/vite.config.ts new file mode 100644 index 000000000..886fb9bdb --- /dev/null +++ b/webapp/packages/session-recording-log/vite.config.ts @@ -0,0 +1,31 @@ +import path from 'node:path'; +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; +import { viteStaticCopy } from 'vite-plugin-static-copy'; + +const staticCopyPlugin = viteStaticCopy({ + targets: [ + { + src: './package.dist.json', + dest: './', + rename: 'package.json', + }, + ], +}); + +export default defineConfig({ + build: { + lib: { + entry: path.resolve(__dirname, 'src/index.ts'), + name: 'SessionRecordingLog', + fileName: 'index', + formats: ['es'], + }, + rollupOptions: { + output: { + globals: {}, + }, + }, + }, + plugins: [dts({ tsconfigPath: './tsconfig.declaration.json' }), staticCopyPlugin], +}); diff --git a/webapp/pnpm-lock.yaml b/webapp/pnpm-lock.yaml index 76c231983..295ff1c75 100644 --- a/webapp/pnpm-lock.yaml +++ b/webapp/pnpm-lock.yaml @@ -138,7 +138,7 @@ importers: devDependencies: '@angular-devkit/build-angular': specifier: 20.3.21 - version: 20.3.21(@angular/compiler-cli@20.3.18(@angular/compiler@20.3.18)(typescript@5.9.3))(@angular/compiler@20.3.18)(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@20.3.18(@angular/animations@20.3.18(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@20.3.18(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0)))(@types/node@20.19.27)(chokidar@4.0.3)(jest-environment-jsdom@29.7.0)(jest@29.7.0(@types/node@20.19.27)(ts-node@10.9.2(@types/node@20.19.27)(typescript@5.9.3)))(jiti@2.6.1)(lightningcss@1.30.2)(tailwindcss@4.1.18)(typescript@5.9.3) + version: 20.3.21(@angular/compiler-cli@20.3.18(@angular/compiler@20.3.18)(typescript@5.9.3))(@angular/compiler@20.3.18)(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@20.3.18(@angular/animations@20.3.18(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@20.3.18(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0)))(@types/node@20.19.27)(chokidar@4.0.3)(jest-environment-jsdom@29.7.0)(jest@29.7.0(@types/node@20.19.27)(ts-node@10.9.2(@types/node@20.19.27)(typescript@5.9.3)))(jiti@2.6.1)(lightningcss@1.30.2)(tailwindcss@4.1.18)(typescript@5.9.3)(vitest@3.2.7(@types/node@20.19.27)(jiti@2.6.1)(jsdom@20.0.3)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)) '@angular-devkit/core': specifier: 20.3.21 version: 20.3.21(chokidar@4.0.3) @@ -245,6 +245,27 @@ importers: specifier: ^2.3.0 version: 2.3.2(vite@6.4.1(@types/node@20.19.27)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)) + packages/session-recording-log: + devDependencies: + '@types/node': + specifier: ^20.19.0 + version: 20.19.27 + typescript: + specifier: ~5.6.2 + version: 5.6.3 + vite: + specifier: ^6.2.1 + version: 6.4.1(@types/node@20.19.27)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1) + vite-plugin-dts: + specifier: ^4.3.0 + version: 4.5.4(@types/node@20.19.27)(rollup@4.59.0)(typescript@5.6.3)(vite@6.4.1(@types/node@20.19.27)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)) + vite-plugin-static-copy: + specifier: ^2.3.0 + version: 2.3.2(vite@6.4.1(@types/node@20.19.27)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)) + vitest: + specifier: ^3.1.1 + version: 3.2.7(@types/node@20.19.27)(jiti@2.6.1)(jsdom@20.0.3)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1) + packages/shadow-player: devDependencies: ts-node: @@ -3051,12 +3072,18 @@ packages: '@types/bonjour@3.5.13': resolution: {integrity: sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect-history-api-fallback@1.5.4': resolution: {integrity: sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==} '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -3257,6 +3284,35 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + '@volar/language-core@2.4.27': resolution: {integrity: sha512-DjmjBWZ4tJKxfNC1F6HyYERNHPYS7L7OPFyCrestykNdUZMFYzI9WTyvwPcaNaHlrEUwESHYsfEw3isInncZxQ==} @@ -3527,6 +3583,10 @@ packages: asciinema-player@3.8.2: resolution: {integrity: sha512-Lgcnj9u/H6sRpGRX1my7Azcay6llLmB/GVkCGcDbPwdTVTisS1ir8SQ9jRWRvjlLUjpSJkN0euruvy3sLRM8tw==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -3667,6 +3727,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + cacache@20.0.4: resolution: {integrity: sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==} engines: {node: ^20.17.0 || >=22.9.0} @@ -3694,6 +3758,10 @@ packages: caniuse-lite@1.0.30001760: resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -3709,6 +3777,10 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -3963,6 +4035,10 @@ packages: babel-plugin-macros: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -4124,6 +4200,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} @@ -4229,6 +4308,9 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -4263,6 +4345,10 @@ packages: resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} engines: {node: '>= 0.8.0'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + expect@29.7.0: resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -4990,6 +5076,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@3.14.2: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true @@ -5232,6 +5321,9 @@ packages: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@11.2.4: resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} engines: {node: 20 || >=22} @@ -5698,6 +5790,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -6169,6 +6265,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -6264,6 +6363,9 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@1.5.0: resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} engines: {node: '>= 0.6'} @@ -6272,6 +6374,9 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stdin-discarder@0.2.2: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} @@ -6322,6 +6427,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -6387,6 +6495,12 @@ packages: thunky@1.1.0: resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.14: resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} engines: {node: '>=12.0.0'} @@ -6395,6 +6509,18 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -6651,6 +6777,11 @@ packages: videojs-vtt.js@0.15.5: resolution: {integrity: sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite-plugin-bundle-css@0.1.1: resolution: {integrity: sha512-CGiZlMym2bkvbe6zbMcoQh7ZVZd+vkdiWc/vsVT61czQWn8w+ysI6zOHqsKbyXfpCBtzmUHFSdZo5krXncjEcQ==} engines: {node: '>=16'} @@ -6789,6 +6920,34 @@ packages: yaml: optional: true + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} @@ -6901,6 +7060,11 @@ packages: engines: {node: ^20.17.0 || >=22.9.0} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wildcard@2.0.1: resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==} @@ -7108,13 +7272,13 @@ snapshots: transitivePeerDependencies: - chokidar - '@angular-devkit/build-angular@20.3.21(@angular/compiler-cli@20.3.18(@angular/compiler@20.3.18)(typescript@5.9.3))(@angular/compiler@20.3.18)(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@20.3.18(@angular/animations@20.3.18(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@20.3.18(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0)))(@types/node@20.19.27)(chokidar@4.0.3)(jest-environment-jsdom@29.7.0)(jest@29.7.0(@types/node@20.19.27)(ts-node@10.9.2(@types/node@20.19.27)(typescript@5.9.3)))(jiti@2.6.1)(lightningcss@1.30.2)(tailwindcss@4.1.18)(typescript@5.9.3)': + '@angular-devkit/build-angular@20.3.21(@angular/compiler-cli@20.3.18(@angular/compiler@20.3.18)(typescript@5.9.3))(@angular/compiler@20.3.18)(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@20.3.18(@angular/animations@20.3.18(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@20.3.18(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0)))(@types/node@20.19.27)(chokidar@4.0.3)(jest-environment-jsdom@29.7.0)(jest@29.7.0(@types/node@20.19.27)(ts-node@10.9.2(@types/node@20.19.27)(typescript@5.9.3)))(jiti@2.6.1)(lightningcss@1.30.2)(tailwindcss@4.1.18)(typescript@5.9.3)(vitest@3.2.7(@types/node@20.19.27)(jiti@2.6.1)(jsdom@20.0.3)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1))': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2003.21(chokidar@4.0.3) '@angular-devkit/build-webpack': 0.2003.21(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.105.0))(webpack@5.105.0(esbuild@0.25.9)) '@angular-devkit/core': 20.3.21(chokidar@4.0.3) - '@angular/build': 20.3.21(@angular/compiler-cli@20.3.18(@angular/compiler@20.3.18)(typescript@5.9.3))(@angular/compiler@20.3.18)(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@20.3.18(@angular/animations@20.3.18(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@20.3.18(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0)))(@types/node@20.19.27)(chokidar@4.0.3)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(postcss@8.5.6)(tailwindcss@4.1.18)(terser@5.43.1)(tslib@2.8.1)(typescript@5.9.3) + '@angular/build': 20.3.21(@angular/compiler-cli@20.3.18(@angular/compiler@20.3.18)(typescript@5.9.3))(@angular/compiler@20.3.18)(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@20.3.18(@angular/animations@20.3.18(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@20.3.18(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0)))(@types/node@20.19.27)(chokidar@4.0.3)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(postcss@8.5.6)(tailwindcss@4.1.18)(terser@5.43.1)(tslib@2.8.1)(typescript@5.9.3)(vitest@3.2.7(@types/node@20.19.27)(jiti@2.6.1)(jsdom@20.0.3)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)) '@angular/compiler-cli': 20.3.18(@angular/compiler@20.3.18)(typescript@5.9.3) '@babel/core': 7.28.3 '@babel/generator': 7.28.3 @@ -7231,7 +7395,7 @@ snapshots: '@angular/core': 20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0) tslib: 2.6.1 - '@angular/build@20.3.21(@angular/compiler-cli@20.3.18(@angular/compiler@20.3.18)(typescript@5.9.3))(@angular/compiler@20.3.18)(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@20.3.18(@angular/animations@20.3.18(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@20.3.18(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0)))(@types/node@20.19.27)(chokidar@4.0.3)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(postcss@8.5.6)(tailwindcss@4.1.18)(terser@5.43.1)(tslib@2.8.1)(typescript@5.9.3)': + '@angular/build@20.3.21(@angular/compiler-cli@20.3.18(@angular/compiler@20.3.18)(typescript@5.9.3))(@angular/compiler@20.3.18)(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0))(@angular/platform-browser@20.3.18(@angular/animations@20.3.18(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0)))(@angular/common@20.3.18(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0))(rxjs@7.8.2))(@angular/core@20.3.18(@angular/compiler@20.3.18)(rxjs@7.8.2)(zone.js@0.15.0)))(@types/node@20.19.27)(chokidar@4.0.3)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(postcss@8.5.6)(tailwindcss@4.1.18)(terser@5.43.1)(tslib@2.8.1)(typescript@5.9.3)(vitest@3.2.7(@types/node@20.19.27)(jiti@2.6.1)(jsdom@20.0.3)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1))': dependencies: '@ampproject/remapping': 2.3.0 '@angular-devkit/architect': 0.2003.21(chokidar@4.0.3) @@ -7270,6 +7434,7 @@ snapshots: lmdb: 3.4.2 postcss: 8.5.6 tailwindcss: 4.1.18 + vitest: 3.2.7(@types/node@20.19.27)(jiti@2.6.1)(jsdom@20.0.3)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1) transitivePeerDependencies: - '@types/node' - chokidar @@ -9788,6 +9953,11 @@ snapshots: dependencies: '@types/node': 20.19.27 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/connect-history-api-fallback@1.5.4': dependencies: '@types/express-serve-static-core': 4.19.7 @@ -9797,14 +9967,16 @@ snapshots: dependencies: '@types/node': 20.19.27 + '@types/deep-eql@4.0.2': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/eslint@9.6.1': dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/json-schema': 7.0.15 '@types/estree@1.0.8': {} @@ -10062,6 +10234,48 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@6.4.1(@types/node@20.19.27)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.1(@types/node@20.19.27)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + '@volar/language-core@2.4.27': dependencies: '@volar/source-map': 2.4.27 @@ -10395,6 +10609,8 @@ snapshots: '@babel/runtime': 7.28.4 solid-js: 1.9.10 + assertion-error@2.0.1: {} + asynckit@0.4.0: {} autoprefixer@10.4.21(postcss@8.5.6): @@ -10603,6 +10819,8 @@ snapshots: bytes@3.1.2: {} + cac@6.7.14: {} + cacache@20.0.4: dependencies: '@npmcli/fs': 5.0.0 @@ -10634,6 +10852,14 @@ snapshots: caniuse-lite@1.0.30001760: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -10645,6 +10871,8 @@ snapshots: chardet@2.1.1: {} + check-error@2.1.3: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -10905,6 +11133,8 @@ snapshots: dedent@1.7.0: {} + deep-eql@5.0.2: {} + deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -11024,6 +11254,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-module-lexer@2.0.0: {} es-object-atoms@1.1.1: @@ -11216,6 +11448,10 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} etag@1.8.1: {} @@ -11246,6 +11482,8 @@ snapshots: exit@0.1.2: {} + expect-type@1.4.0: {} + expect@29.7.0: dependencies: '@jest/expect-utils': 29.7.0 @@ -12260,6 +12498,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@3.14.2: dependencies: argparse: 1.0.10 @@ -12504,6 +12744,8 @@ snapshots: strip-ansi: 7.1.2 wrap-ansi: 9.0.2 + loupe@3.2.1: {} + lru-cache@11.2.4: {} lru-cache@5.1.1: @@ -13015,6 +13257,8 @@ snapshots: pathe@2.0.3: {} + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -13546,6 +13790,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -13664,10 +13910,14 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + stackback@0.0.2: {} + statuses@1.5.0: {} statuses@2.0.2: {} + std-env@3.10.0: {} + stdin-discarder@0.2.2: {} streamsearch@1.1.0: {} @@ -13713,6 +13963,10 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -13775,6 +14029,10 @@ snapshots: thunky@1.1.0: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.14: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -13785,6 +14043,12 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + tmpl@1.0.5: {} to-regex-range@5.0.1: @@ -14043,6 +14307,27 @@ snapshots: dependencies: global: 4.4.0 + vite-node@3.2.4(@types/node@20.19.27)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.1(@types/node@20.19.27)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-plugin-bundle-css@0.1.1(rollup@4.59.0)(vite@6.4.1(@types/node@20.19.27)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.59.0) @@ -14050,6 +14335,25 @@ snapshots: rollup: 4.59.0 vite: 6.4.1(@types/node@20.19.27)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1) + vite-plugin-dts@4.5.4(@types/node@20.19.27)(rollup@4.59.0)(typescript@5.6.3)(vite@6.4.1(@types/node@20.19.27)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)): + dependencies: + '@microsoft/api-extractor': 7.55.2(@types/node@20.19.27) + '@rollup/pluginutils': 5.3.0(rollup@4.59.0) + '@volar/typescript': 2.4.27 + '@vue/language-core': 2.2.0(typescript@5.6.3) + compare-versions: 6.1.1 + debug: 4.4.3 + kolorist: 1.8.0 + local-pkg: 1.1.2 + magic-string: 0.30.21 + typescript: 5.6.3 + optionalDependencies: + vite: 6.4.1(@types/node@20.19.27)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1) + transitivePeerDependencies: + - '@types/node' + - rollup + - supports-color + vite-plugin-dts@4.5.4(@types/node@20.19.27)(rollup@4.59.0)(typescript@5.7.3)(vite@6.4.1(@types/node@20.19.27)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)): dependencies: '@microsoft/api-extractor': 7.55.2(@types/node@20.19.27) @@ -14119,7 +14423,7 @@ snapshots: dependencies: esbuild: 0.21.5 postcss: 8.5.6 - rollup: 4.53.5 + rollup: 4.59.0 optionalDependencies: '@types/node': 22.19.3 fsevents: 2.3.3 @@ -14179,6 +14483,48 @@ snapshots: sass: 1.90.0 terser: 5.43.1 + vitest@3.2.7(@types/node@20.19.27)(jiti@2.6.1)(jsdom@20.0.3)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@6.4.1(@types/node@20.19.27)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.4.1(@types/node@20.19.27)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1) + vite-node: 3.2.4(@types/node@20.19.27)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.27 + jsdom: 20.0.3 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vscode-uri@3.1.0: {} w3c-xmlserializer@4.0.0: @@ -14275,7 +14621,7 @@ snapshots: webpack@5.105.0(esbuild@0.25.9): dependencies: '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 '@types/json-schema': 7.0.15 '@webassemblyjs/ast': 1.14.1 '@webassemblyjs/wasm-edit': 1.14.1 @@ -14331,6 +14677,11 @@ snapshots: dependencies: isexe: 4.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wildcard@2.0.1: {} word-wrap@1.2.5: {}