Skip to content

Commit

Permalink
Inject Replay client instance (via context) so no React components im…
Browse files Browse the repository at this point in the history
…port the protocol directly
  • Loading branch information
bvaughn committed May 31, 2022
1 parent 8d81ddb commit ad91149
Show file tree
Hide file tree
Showing 7 changed files with 113 additions and 161 deletions.
31 changes: 8 additions & 23 deletions packages/bvaughn-architecture-demo/components/Initializer.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
// This file is not really part of the architectural demo.
// It's just a bootstrap for things like auth that I didn't want to spend time actually implementing.

import { client, initSocket } from "protocol/socket";
import { ThreadFront } from "protocol/thread";
import { ReactNode, useEffect, useRef, useState } from "react";
import { ReactNode, useContext, useEffect, useRef, useState } from "react";

import { SessionContext } from "../src/contexts";

const DISPATCH_URL = "wss://dispatch.replay.io";
import { ReplayClientContext, SessionContext } from "../src/contexts";

// HACK Hack around the fact that the initSocket() function is side effectful
// and writes to an "app" global on the window object.
Expand All @@ -20,6 +16,7 @@ if (typeof window !== "undefined") {
type ContextType = { duration: number; endPoint: string; recordingId: string; sessionId: string };

export default function Initializer({ children }: { children: ReactNode }) {
const client = useContext(ReplayClientContext);
const [context, setContext] = useState<ContextType | null>(null);
const didInitializeRef = useRef<boolean>(false);

Expand All @@ -28,8 +25,6 @@ export default function Initializer({ children }: { children: ReactNode }) {
// We only need to initialize them once.
if (!didInitializeRef.current) {
const asyncInitialize = async () => {
initSocket(DISPATCH_URL);

// Read some of the hard-coded values from query params.
// (This is just a prototype; no sense building a full authentication flow.)
const url = new URL(window.location.href);
Expand All @@ -39,21 +34,11 @@ export default function Initializer({ children }: { children: ReactNode }) {
throw Error(`Must specify "recordingId" parameter.`);
}

// Authenticate
if (accessToken) {
await client.Authentication.setAccessToken({ accessToken });
}
const sessionId = await client.initialize(recordingId, accessToken);
const endpoint = await client.getSessionEndpoint(sessionId);

// Create session
const { sessionId } = await client.Recording.createSession({ recordingId });
const { endpoint } = await client.Session.getEndpoint({}, sessionId);

// Pre-load sources for ValueFront usage later.
ThreadFront.setSessionId(sessionId);
// @ts-expect-error `sourceMapURL` doesn't exist?
await ThreadFront.findSources(({ sourceId, url, sourceMapURL }) => {
// Ignore
});
// The demo doesn't use these directly, but the client throws if they aren't loaded.
await client.findSources();

setContext({
duration: endpoint.time,
Expand All @@ -67,7 +52,7 @@ export default function Initializer({ children }: { children: ReactNode }) {
}

didInitializeRef.current = true;
}, []);
}, [client]);

if (context === null) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ function MessageRenderer({ message }: { message: Message }) {

// TODO This logic should be moved out of the view.
// It should probably be backed by its own Suspense cache which can just-in-time load ValueFronts, e.g. loadIfNecessary()
// Do messages have some sort of stable ID that we could use for a Suspense cache key? Maybe pauseId?
const valueFronts = useMemo<ValueFront[]>(() => {
if (message.argumentValues == null) {
return [];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Message } from "@replayio/protocol";
import { useContext } from "react";

import { ConsoleFiltersContext, FocusContext, SessionContext } from "../../src/contexts";
import { ConsoleFiltersContext, FocusContext, ReplayClientContext } from "../../src/contexts";
import useFilteredMessages from "../../src/hooks/useFilteredMessages";
import { getMessages } from "../../src/suspense/MessagesCache";
import { getClosestPointForTime } from "../../src/suspense/PointsCache";
Expand All @@ -17,7 +17,7 @@ import styles from "./MessagesList.module.css";
// Note that the props passed from the parent component would more likely be exposed through Context in a real app.
// We're passing them as props in this case since the parent and child are right beside each other in the tree.
export default function MessagesList() {
const { sessionId } = useContext(SessionContext);
const replayClient = useContext(ReplayClientContext);

const { filterByText, levelFlags } = useContext(ConsoleFiltersContext);
const { range, isTransitionPending: isFocusTransitionPending } = useContext(FocusContext);
Expand All @@ -26,8 +26,8 @@ export default function MessagesList() {
if (range !== null) {
const [startTime, endTime] = range;

const startPoint = getClosestPointForTime(startTime, sessionId);
const endPoint = getClosestPointForTime(endTime, sessionId);
const startPoint = getClosestPointForTime(replayClient, startTime);
const endPoint = getClosestPointForTime(replayClient, endTime);

focusMode = {
begin: {
Expand All @@ -41,7 +41,7 @@ export default function MessagesList() {
};
}

const { countAfter, countBefore, didOverflow, messages } = getMessages(sessionId, focusMode);
const { countAfter, countBefore, didOverflow, messages } = getMessages(replayClient, focusMode);

// Memoized selector that joins log points and messages and filters by criteria (e.g. type)
// Note that we are intentionally not storing derived values like this in state.
Expand Down
162 changes: 73 additions & 89 deletions packages/bvaughn-architecture-demo/src/ReplayClient.ts
Original file line number Diff line number Diff line change
@@ -1,117 +1,101 @@
import { ExecutionPoint, Message, SessionId, TimeStampedPointRange } from "@replayio/protocol";
import { client } from "protocol/socket";
import {
ExecutionPoint,
Message,
SessionId,
TimeStampedPoint,
TimeStampedPointRange,
} from "@replayio/protocol";
import { client, initSocket } from "protocol/socket";
import type { ThreadFront } from "protocol/thread";

import { isRangeSubset } from "./utils/time";

// TODO How should the client handle concurrent requests?
// Should we force serialization?
// Should we cancel in-flight requests and start new ones?

export default class ReplayClient {
private _focusRange: TimeStampedPointRange | null = null;
private _messageClient: MessageClient;
private _sessionId: SessionId | null = null;
private _threadFront: typeof ThreadFront;

// TODO Pass config of some sort?
constructor(threadFront: typeof ThreadFront) {
this._messageClient = new MessageClient(threadFront);
}
constructor(dispatchURL: string, threadFront: typeof ThreadFront) {
this._threadFront = threadFront;

get focusRange(): TimeStampedPointRange | null {
return this._focusRange;
if (typeof window !== "undefined") {
initSocket(dispatchURL);
}
}

get messages(): Message[] {
return this._messageClient.filteredMessages;
getSessionIdThrows(): SessionId {
const sessionId = this._sessionId;
if (sessionId === null) {
throw Error("Invalid session");
}
return sessionId;
}

// Narrow focus window for all Replay data.
async setFocus(focusRange: TimeStampedPointRange | null): Promise<void> {
this._focusRange = focusRange;
async initialize(recordingId: string, accessToken: string | null): Promise<SessionId> {
if (accessToken != null) {
await client.Authentication.setAccessToken({ accessToken });
}

// Update all data; fetch from the backend or filter in memory, as needed.
await Promise.all([this._messageClient.setFocus(focusRange)]);
}
}
const { sessionId } = await client.Recording.createSession({ recordingId });

class MessageClient {
private _filteredMessages: Message[] = [];
private _lastFetchDidOverflow: boolean = false;
private _lastFetchFocusRange: TimeStampedPointRange | null = null;
private _numFilteredAfterFocusRange: number = 0;
private _numFilteredBeforeFocusRange: number = 0;
private _unfilteredMessages: Message[] = [];
private _sessionEndpoint: ExecutionPoint | null = null;
private _threadFront: typeof ThreadFront;
this._sessionId = sessionId;
this._threadFront.setSessionId(sessionId);

constructor(threadFront: typeof ThreadFront) {
this._threadFront = threadFront;
return sessionId;
}

get didOverflow(): boolean {
return this._lastFetchDidOverflow;
}
async findMessages(focusRange: TimeStampedPointRange | null): Promise<{
messages: Message[];
overflow: boolean;
}> {
const sessionId = this.getSessionIdThrows();

get filteredMessages(): Message[] {
return this._filteredMessages;
}
if (focusRange !== null) {
const response = await client.Console.findMessagesInRange(
{ range: { begin: focusRange.begin.point, end: focusRange.end.point } },
sessionId
);

get numFilteredAfterFocusRange(): number {
return this._numFilteredAfterFocusRange;
return {
messages: response.messages,
overflow: response.overflow == true,
};
} else {
const messages: Message[] = [];

// TOOD This won't work if there are every overlapping requests.
client.Console.addNewMessageListener(({ message }) => {
messages.push(message);
});

const response = await client.Console.findMessages({}, sessionId);

client.Console.removeNewMessageListener();

return {
messages,
overflow: response.overflow == true,
};
}
}

get numFilteredBeforeFocusRange(): number {
return this._numFilteredBeforeFocusRange;
async findSources() {
await this._threadFront.findSources(() => {
// The demo doesn't use these directly, but the client throws if they aren't loaded.
});
}

// Apply the new focus window to console logs.
//
// In many cases, this will be a synchronous in-memory operation.
// In some cases, this will require re-fetching from the backend.
async setFocus(focusRange: TimeStampedPointRange | null): Promise<void> {
if (
this._unfilteredMessages === null ||
this._lastFetchDidOverflow ||
!isRangeSubset(this._lastFetchFocusRange, focusRange)
) {
const sessionId = this._threadFront.sessionId as SessionId;

if (!this._sessionEndpoint) {
this._sessionEndpoint = (await client.Session.getEndpoint({}, sessionId)).endpoint.point;
}

const begin = focusRange ? focusRange.begin.point : "0";
const end = focusRange ? focusRange.end.point : this._sessionEndpoint!;
const { messages, overflow } = await client.Console.findMessagesInRange(
{ range: { begin, end } },
sessionId
);
async getPointNearTime(time: number): Promise<TimeStampedPoint> {
const sessionId = this.getSessionIdThrows();
const { point } = await client.Session.getPointNearTime({ time: time }, sessionId);

this._lastFetchDidOverflow = overflow === true;
this._unfilteredMessages = messages;
}
return point;
}

// Filter in-memory values.
if (focusRange === null) {
this._filteredMessages = this._unfilteredMessages;
} else {
const begin = focusRange.begin.time;
const end = focusRange.end.time;

this._numFilteredAfterFocusRange = 0;
this._numFilteredBeforeFocusRange = 0;
this._filteredMessages = this._unfilteredMessages.filter(message => {
const time = message.point.time;
if (time < begin) {
this._numFilteredBeforeFocusRange++;
return false;
} else if (time > end) {
this._numFilteredAfterFocusRange++;
return false;
} else {
return true;
}
});
}
async getSessionEndpoint(sessionId: SessionId): Promise<TimeStampedPoint> {
const { endpoint } = await client.Session.getEndpoint({}, sessionId);

return endpoint;
}
}
11 changes: 11 additions & 0 deletions packages/bvaughn-architecture-demo/src/contexts.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { ExecutionPoint } from "@replayio/protocol";
import { ThreadFront } from "protocol/thread";
import { createContext } from "react";
import ReplayClient from "./ReplayClient";

import { Range } from "./types";

Expand Down Expand Up @@ -46,3 +48,12 @@ export type SessionContextType = {
sessionId: string;
};
export const SessionContext = createContext<SessionContextType>(null as any);

export type ReplayClientContextType = ReplayClient;

// By default, this context wires the app up to use real Replay backend APIs.
// We can leverage this when writing tests (or UI demos) by injecting a stub client.
const DISPATCH_URL = "wss://dispatch.replay.io";
export const ReplayClientContext = createContext<ReplayClientContextType>(
new ReplayClient(DISPATCH_URL, ThreadFront)
);
Loading

0 comments on commit ad91149

Please sign in to comment.