Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions packages/phoenix-event-display/src/event-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import {
buildEventSummaries,
type EventSummary,
} from './helpers/event-summary';
import {
SessionManager,
type SessionManagerHost,
} from './managers/session-manager';

declare global {
/**
Expand Down Expand Up @@ -47,6 +51,14 @@ export class EventDisplay {
private onDisplayedEventChange: ((nowDisplayingEvent: any) => void)[] = [];
/** Generic event bus for integration with external frameworks. */
private eventBus: Map<string, Set<(data: any) => void>> = new Map();
/** Wildcard subscribers fired on every emit (recorders, external bridges). */
private eventBusWildcard: Set<(eventName: string, data: any) => void> =
new Set();
/** Session recorder/player coordinator for #883 (lazy initialized). */
private sessionManager: SessionManager | null = null;
/** Stored keydown handler for session recording shortcut (Shift+Ctrl+R). */
private sessionRecordKeydownHandler: ((e: KeyboardEvent) => void) | null =
null;
/** Three manager for three.js operations. */
private graphicsLibrary: ThreeManager;
/** Info logger for storing event display logs. */
Expand Down Expand Up @@ -129,10 +141,18 @@ export class EventDisplay {
document.removeEventListener('keydown', this.eventNavKeydownHandler);
this.eventNavKeydownHandler = null;
}
// Clean up session recording keyboard handler
if (this.sessionRecordKeydownHandler) {
document.removeEventListener('keydown', this.sessionRecordKeydownHandler);
this.sessionRecordKeydownHandler = null;
}
// Stop any active session recording or playback
this.sessionManager?.cleanup();
// Clear accumulated callbacks
this.onEventsChange = [];
this.onDisplayedEventChange = [];
this.eventBus.clear();
this.eventBusWildcard.clear();
// Reset singletons for clean view transition
this.loadingManager?.reset();
this.stateManager?.resetForViewTransition();
Expand Down Expand Up @@ -170,6 +190,7 @@ export class EventDisplay {

/**
* Emit a named event on the integration event bus.
* Named subscribers fire first, then wildcard subscribers.
* @param eventName The event name to emit.
* @param data Data to pass to listeners.
*/
Expand All @@ -178,6 +199,84 @@ export class EventDisplay {
if (listeners) {
listeners.forEach((cb) => cb(data));
}
if (this.eventBusWildcard.size > 0) {
this.eventBusWildcard.forEach((cb) => cb(eventName, data));
}
}

/**
* Subscribe to every event-bus emission, regardless of name.
* Used by the session recorder (#883) and external bridges that want a
* single hook into all integration events.
* @param callback Invoked with (eventName, data) on every emit.
* @returns Unsubscribe function to remove the listener.
*/
public onAny(callback: (eventName: string, data: any) => void): () => void {
this.eventBusWildcard.add(callback);
return () => {
this.eventBusWildcard.delete(callback);
};
}

/**
* Get the SessionManager (#883) for recording and replaying exploration.
* Lazily instantiated so the manager is only created when the feature is used.
* @returns The SessionManager singleton for this EventDisplay.
*/
public getSessionManager(): SessionManager {
if (!this.sessionManager) {
this.sessionManager = new SessionManager(this.buildSessionHost());
}
return this.sessionManager;
}

/**
* Build the host adapter that bridges SessionManager to the live
* EventDisplay (event bus, state snapshot, camera apply).
*/
private buildSessionHost(): SessionManagerHost {
return {
onAny: (cb) => this.onAny(cb),
emit: (name, data) => this.emit(name, data),
getStateSnapshot: () => {
try {
return this.getStateManager()?.getStateAsJSON() ?? {};
} catch {
return {};
}
},
applyStateSnapshot: (state) => {
try {
this.getStateManager()?.loadStateFromJSON(state);
} catch {
// ignore: replay still proceeds even if state apply fails
}
},
getCameraSample: () => {
const stateManager = this.getStateManager();
const activeCamera = stateManager?.activeCamera;
const controls = this.graphicsLibrary
?.getControlsManager?.()
?.getMainControls?.();
if (!activeCamera || !controls) return null;
const pos = activeCamera.position;
const target = controls.target;
return {
pos: [pos.x, pos.y, pos.z],
target: [target.x, target.y, target.z],
};
},
applyCamera: (pos, target) => {
const activeCamera = this.getStateManager()?.activeCamera;
const controls = this.graphicsLibrary
?.getControlsManager?.()
?.getMainControls?.();
if (!activeCamera || !controls) return;
activeCamera.position.set(pos[0], pos[1], pos[2]);
controls.target.set(target[0], target[1], target[2]);
controls.update();
},
};
}

/**
Expand Down Expand Up @@ -888,6 +987,26 @@ export class EventDisplay {
}
};
document.addEventListener('keydown', this.eventNavKeydownHandler);

// Remove previous session recording listener if exists
if (this.sessionRecordKeydownHandler) {
document.removeEventListener('keydown', this.sessionRecordKeydownHandler);
}

// Shift+Ctrl+R = toggle session recording (#883). Plain Ctrl+R is left
// alone so the browser refresh keeps working.
this.sessionRecordKeydownHandler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
const isTyping = ['input', 'textarea', 'select'].includes(
target?.tagName.toLowerCase(),
);
if (isTyping) return;
if (!e.shiftKey || !(e.ctrlKey || e.metaKey)) return;
if (e.code !== 'KeyR') return;
e.preventDefault();
this.getSessionManager().toggleRecording();
};
document.addEventListener('keydown', this.sessionRecordKeydownHandler);
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/phoenix-event-display/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,4 @@ export * from './loaders/objects/phoenix-objects';
export * from './managers/state-manager';
export * from './managers/loading-manager';
export * from './managers/url-options-manager';
export * from './managers/session-manager';
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './session-format';
export * from './session-codec';
export * from './session-recorder';
export * from './session-player';
export * from './session-manager';
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import {
MAX_DECOMPRESSED_BYTES,
SessionV1,
validateSession,
} from './session-format';

/**
* Encode a session as a URL-safe base64 string of deflate-compressed JSON.
* Mirrors the share-link-dialog state encoding so the decoder can use the
* existing DecompressionStream('deflate') plumbing.
* @param session The session payload to encode.
* @returns Base64 string suitable for `?replay=` URLs.
*/
export async function encodeSessionToBase64(
session: SessionV1,
): Promise<string> {
const json = JSON.stringify(session);
const input = new TextEncoder().encode(json);
const compressed = await pipeThroughStream(
input,
new CompressionStream('deflate'),
);
let binary = '';
for (let i = 0; i < compressed.length; i++) {
binary += String.fromCharCode(compressed[i]);
}
return btoa(binary);
}

/**
* Pipe a Uint8Array through a transform stream and collect the output.
* Avoids Blob.stream() and Response, both of which jsdom lacks.
* @param input Source bytes.
* @param stream Transform stream (compression or decompression).
* @returns Concatenated output bytes.
*/
async function pipeThroughStream(
input: Uint8Array,
stream: CompressionStream | DecompressionStream,
): Promise<Uint8Array> {
const writer = stream.writable.getWriter();
// Write+close concurrently with reading. Awaiting write/close before reading
// deadlocks on larger inputs: the transform's output buffer fills and write()
// blocks on backpressure waiting for a reader that has not started yet.
const writeDone = (async () => {
await writer.write(input);
await writer.close();
})();
const reader = stream.readable.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
chunks.push(value);
total += value.byteLength;
}
// Surface any write/close error now that the readable side has drained.
await writeDone;
const out = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
out.set(chunk, offset);
offset += chunk.byteLength;
}
return out;
}

/**
* Decode a base64 deflate-compressed session payload, validate it, and
* cap the decompressed output to defend against compression bombs.
* @param base64 Base64 input from a URL parameter or share link.
* @returns Validated SessionV1.
* @throws Error when the payload is malformed, oversized, or invalid.
*/
export async function decodeSessionFromBase64(
base64: string,
): Promise<SessionV1> {
if (typeof base64 !== 'string' || base64.length === 0) {
throw new Error('Session payload is empty.');
}

let binary: string;
try {
binary = atob(base64);
} catch {
throw new Error('Session payload is not valid base64.');
}

const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);

const ds = new DecompressionStream('deflate');
const writer = ds.writable.getWriter();
writer.write(bytes).catch(() => {});
writer.close().catch(() => {});
const reader = ds.readable.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
let chunk: ReadableStreamReadResult<Uint8Array>;
try {
chunk = await reader.read();
} catch (err) {
throw new Error('Session payload is not valid deflate-compressed data.');
}
if (chunk.done) break;
total += chunk.value.byteLength;
if (total > MAX_DECOMPRESSED_BYTES) {
await reader.cancel().catch(() => {});
throw new Error('Session payload exceeds decompressed size limit.');
}
chunks.push(chunk.value);
}

const decompressed = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
decompressed.set(chunk, offset);
offset += chunk.byteLength;
}
const text = new TextDecoder().decode(decompressed);

let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
throw new Error('Session payload is not valid JSON after decompression.');
}

const session = validateSession(parsed);
if (!session) {
throw new Error('Session payload failed schema validation.');
}
return session;
}

/**
* Serialize a session to a downloadable JSON Blob (compact, uncompressed) for
* sharing as a `.phnxreplay` file. Compact (not pretty-printed) keeps the file
* small while staying valid JSON that the `?session=` loader can re-read.
* @param session The session payload to serialize.
* @returns Blob with MIME type application/json.
*/
export function encodeSessionToBlob(session: SessionV1): Blob {
const json = JSON.stringify(session);
return new Blob([json], { type: 'application/json' });
}

/**
* Parse a raw JSON `.phnxreplay` file payload and validate it.
* @param text JSON text from a file upload.
* @returns Validated SessionV1.
* @throws Error when the payload is malformed or invalid.
*/
export function decodeSessionFromJson(text: string): SessionV1 {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
throw new Error('File is not valid JSON.');
}
const session = validateSession(parsed);
if (!session) {
throw new Error('File failed session schema validation.');
}
return session;
}
Loading
Loading