From a3f1bc263c68869134ba3be43f7d31ffdb367fc7 Mon Sep 17 00:00:00 2001 From: Donnie Flood Date: Sun, 10 Apr 2022 20:15:25 -0600 Subject: [PATCH] so more refactoring --- src/server/adaptor/http.ts | 2 +- src/server/adaptor/jsonSerDe.ts | 15 + src/server/live/liveView.ts | 2 +- src/server/pubsub/PubSub.ts | 8 +- src/server/pubsub/SingleProcessPubSub.ts | 6 +- src/server/socket/index.ts | 2 +- ...live_socket.test.ts => liveSocket.test.ts} | 61 +- .../socket/{live_socket.ts => liveSocket.ts} | 37 +- src/server/socket/liveViewManager.test.ts | 355 +++-------- src/server/socket/liveViewManager.ts | 602 ++++++++++-------- src/server/socket/types.ts | 2 +- src/server/socket/util.ts | 13 +- 12 files changed, 511 insertions(+), 594 deletions(-) create mode 100644 src/server/adaptor/jsonSerDe.ts rename src/server/socket/{live_socket.test.ts => liveSocket.test.ts} (78%) rename src/server/socket/{live_socket.ts => liveSocket.ts} (83%) diff --git a/src/server/adaptor/http.ts b/src/server/adaptor/http.ts index db57c5f0..682b1e40 100644 --- a/src/server/adaptor/http.ts +++ b/src/server/adaptor/http.ts @@ -1,6 +1,6 @@ import { AnyLiveContext, HttpLiveComponentSocket, LiveComponent, LiveView, LiveViewTemplate } from "../live"; import { SessionData } from "../session"; -import { HttpLiveViewSocket } from "../socket/live_socket"; +import { HttpLiveViewSocket } from "../socket/liveSocket"; import { html, safe } from "../templates"; import { PageTitleDefaults } from "../templates/helpers/page_title"; diff --git a/src/server/adaptor/jsonSerDe.ts b/src/server/adaptor/jsonSerDe.ts new file mode 100644 index 00000000..00731617 --- /dev/null +++ b/src/server/adaptor/jsonSerDe.ts @@ -0,0 +1,15 @@ +import { SerDe } from "./http"; + +/** + * A SerDe (serializer/deserializer) that uses JSON.stringify and JSON.parse. + * WARNING: this is not secure so should only be used for testing. + */ +export class JsonSerDe implements SerDe { + serialize(obj: T): Promise { + return Promise.resolve(JSON.stringify(obj)); + } + + deserialize(data: string): Promise { + return Promise.resolve(JSON.parse(data) as T); + } +} diff --git a/src/server/live/liveView.ts b/src/server/live/liveView.ts index 6386e37d..5633073e 100644 --- a/src/server/live/liveView.ts +++ b/src/server/live/liveView.ts @@ -1,6 +1,6 @@ import { LiveComponent, LiveViewTemplate } from "."; import { SessionData } from "../session"; -import { LiveViewSocket } from "../socket/live_socket"; +import { LiveViewSocket } from "../socket/liveSocket"; export interface LiveContext { [key: string]: any; diff --git a/src/server/pubsub/PubSub.ts b/src/server/pubsub/PubSub.ts index 71ad9a72..86eb34da 100644 --- a/src/server/pubsub/PubSub.ts +++ b/src/server/pubsub/PubSub.ts @@ -1,12 +1,14 @@ export type SubscriberFunction = (data: T) => void; +export type SubscriberId = string; + export interface Subscriber { - subscribe(topic: string, subscriber: SubscriberFunction): Promise; - unsubscribe(topic: string, subscriberId: string): Promise; + subscribe(topic: string, subscriber: SubscriberFunction): Promise; + unsubscribe(topic: string, subscriberId: SubscriberId): Promise; } export interface Publisher { - broadcast(topic: string, data: T): Promise; + broadcast(topic: string, data: T): Promise; } export interface PubSub extends Subscriber, Publisher {} diff --git a/src/server/pubsub/SingleProcessPubSub.ts b/src/server/pubsub/SingleProcessPubSub.ts index 7ebd81bd..8c1ba277 100644 --- a/src/server/pubsub/SingleProcessPubSub.ts +++ b/src/server/pubsub/SingleProcessPubSub.ts @@ -10,11 +10,11 @@ import { Publisher, Subscriber, SubscriberFunction } from "./pubSub"; */ const eventEmitter = new EventEmitter(); // use this singleton for all pubSub events -export class SingleProcessPubSub implements Subscriber, Publisher { +export class SingleProcessPubSub implements Subscriber, Publisher { private subscribers: Record> = {}; public async subscribe(topic: string, subscriber: SubscriberFunction): Promise { - await eventEmitter.on(topic, subscriber); + await eventEmitter.addListener(topic, subscriber); // store connection id for unsubscribe and return for caller const subId = crypto.randomBytes(10).toString("hex"); this.subscribers[subId] = subscriber; @@ -28,7 +28,7 @@ export class SingleProcessPubSub implements Subscriber, Publisher { public async unsubscribe(topic: string, subscriberId: string) { // get subscriber function from id const subscriber = this.subscribers[subscriberId]; - await eventEmitter.off(topic, subscriber); + await eventEmitter.removeListener(topic, subscriber); // remove subscriber from subscribers delete this.subscribers[subscriberId]; } diff --git a/src/server/socket/index.ts b/src/server/socket/index.ts index 37549a1f..b7b97157 100644 --- a/src/server/socket/index.ts +++ b/src/server/socket/index.ts @@ -1,3 +1,3 @@ export * from "./liveViewManager"; -export * from "./live_socket"; +export * from "./liveSocket"; export * from "./wsMessageRouter"; diff --git a/src/server/socket/live_socket.test.ts b/src/server/socket/liveSocket.test.ts similarity index 78% rename from src/server/socket/live_socket.test.ts rename to src/server/socket/liveSocket.test.ts index 28689914..d47f29bf 100644 --- a/src/server/socket/live_socket.test.ts +++ b/src/server/socket/liveSocket.test.ts @@ -1,18 +1,11 @@ -import { SessionData } from "express-session"; import { html } from ".."; -import { - BaseLiveView, - LiveView, - LiveViewContext, - LiveViewMountParams, - LiveViewTemplate, - StringPropertyValues, -} from "../component"; -import { HttpLiveViewSocket, LiveViewSocket, WsLiveViewSocket } from "./live_socket"; +import { BaseLiveView, LiveView, LiveViewMountParams, LiveViewTemplate } from "../live"; +import { SessionData } from "../session"; +import { HttpLiveViewSocket, LiveViewSocket, WsLiveViewSocket } from "./liveSocket"; describe("test LiveViewSocket", () => { let socket; - let component: LiveView; + let component: LiveView; let pageTitleCallback: jest.Mock; let pushEventCallback = jest.fn(); let pushRedirectCallback = jest.fn(); @@ -32,7 +25,7 @@ describe("test LiveViewSocket", () => { repeatCallback = jest.fn(); sendCallback = jest.fn(); subscribeCallback = jest.fn(); - socket = new WsLiveViewSocket( + socket = new WsLiveViewSocket( "id", pageTitleCallback, pushEventCallback, @@ -54,21 +47,21 @@ describe("test LiveViewSocket", () => { it("http default handleParams does NOT change context", async () => { const socket = new HttpLiveViewSocket("id"); component.mount({ _csrf_token: "csrf", _mounts: -1 }, {}, socket); - await component.handleParams({ foo: "baz" }, "", socket); + await component.handleParams(new URL("http://example.com/?foo=baz"), socket); expect(socket.context.foo).toEqual("bar"); }); it("http render returns context view", async () => { const socket = new HttpLiveViewSocket("id"); component.mount({ _csrf_token: "csrf", _mounts: -1 }, {}, socket); - await component.handleParams({ foo: "baz" }, "", socket); + await component.handleParams(new URL("http://example.com/?foo=baz"), socket); expect(socket.context.foo).toEqual("bar"); const view = await component.render(socket.context, { csrfToken: "csrf", live_component: jest.fn() }); expect(view.toString()).toEqual("
bar
"); }); it("ws mount returns context", async () => { - const socket = new WsLiveViewSocket( + const socket = new WsLiveViewSocket( "id", pageTitleCallback, pushEventCallback, @@ -85,7 +78,7 @@ describe("test LiveViewSocket", () => { it("calls all callbacks", async () => { component = new TestLVPushAndSend(); - const socket = new WsLiveViewSocket( + const socket = new WsLiveViewSocket( "id", pageTitleCallback, pushEventCallback, @@ -109,7 +102,7 @@ describe("test LiveViewSocket", () => { }); it("tempAssign works to clear assigns", () => { - const socket = new WsLiveViewSocket( + const socket = new WsLiveViewSocket( "id", pageTitleCallback, pushEventCallback, @@ -134,17 +127,17 @@ describe("test LiveViewSocket", () => { c.mount({ _csrf_token: "csrf", _mounts: -1 }, {}, socket); expect(socket.redirect).toEqual({ to: "/new/path?param=mount", replace: false }); expect(socket.context.redirectedIn).toEqual("mount"); - c.handleParams({}, "", socket); + c.handleParams(new URL("http://example.com"), socket); expect(socket.redirect).toEqual({ to: "/new/path?param=handleParams", replace: true }); expect(socket.context.redirectedIn).toEqual("handleParams"); }); }); -interface TestLVContext extends LiveViewContext { +interface TestLVContext { foo: string; } -class TestLiveView extends BaseLiveView { +class TestLiveView extends BaseLiveView { mount(params: LiveViewMountParams, session: Partial, socket: LiveViewSocket) { socket.assign({ foo: "bar" }); } @@ -154,23 +147,23 @@ class TestLiveView extends BaseLiveView { } } -interface TestLVPushAndSendContext extends LiveViewContext { +interface TestLVPushAndSendContext { foo: string; } -class TestLVPushAndSend extends BaseLiveView { +class TestLVPushAndSend extends BaseLiveView { mount(params: LiveViewMountParams, session: Partial, socket: LiveViewSocket) { socket.pageTitle("new page title"); - socket.pushEvent("event", { data: "blah" }); + socket.pushEvent({ type: "event", data: "blah" }); socket.pushPatch("path"); - socket.pushPatch("path", { param: 1 }); - socket.pushPatch("path", { param: 1 }, true); + socket.pushPatch("path", new URLSearchParams({ param: String(1) })); + socket.pushPatch("path", new URLSearchParams({ param: String(1) }), true); socket.pushRedirect("/new/path"); - socket.pushRedirect("/new/path", { param: 1 }); - socket.pushRedirect("/new/path", { param: 1 }, true); + socket.pushRedirect("/new/path", new URLSearchParams({ param: String(1) })); + socket.pushRedirect("/new/path", new URLSearchParams({ param: String(1) }), true); socket.putFlash("info", "Helpful message"); socket.repeat(() => {}, 1000); - socket.send("my_event"); + socket.send({ type: "my_event" }); socket.subscribe("topic"); } @@ -179,26 +172,26 @@ class TestLVPushAndSend extends BaseLiveView { } } -interface TestRedirectingContext extends LiveViewContext { +interface TestRedirectingContext { redirectedIn: "mount" | "handleParams"; } -class TestRedirectingLiveView extends BaseLiveView { +class TestRedirectingLiveView extends BaseLiveView { mount(params: LiveViewMountParams, session: Partial, socket: LiveViewSocket) { if (!socket.context.redirectedIn) { socket.assign({ redirectedIn: "mount" }); - socket.pushRedirect("/new/path", { param: "mount" }, false); + socket.pushRedirect("/new/path", new URLSearchParams({ param: "mount" }), false); } } - handleParams(params: StringPropertyValues<{}>, url: string, socket: LiveViewSocket): void { + handleParams(url: URL, socket: LiveViewSocket): void { if (socket.context.redirectedIn === "mount") { socket.assign({ redirectedIn: "handleParams" }); - socket.pushRedirect("/new/path", { param: "handleParams" }, true); + socket.pushRedirect("/new/path", new URLSearchParams({ param: "handleParams" }), true); } } render(ctx: TestRedirectingContext): LiveViewTemplate { - return html`
${ctx.foo}
`; + return html`
${ctx.redirectedIn}
`; } } diff --git a/src/server/socket/live_socket.ts b/src/server/socket/liveSocket.ts similarity index 83% rename from src/server/socket/live_socket.ts rename to src/server/socket/liveSocket.ts index 03a975f3..cd639e62 100644 --- a/src/server/socket/live_socket.ts +++ b/src/server/socket/liveSocket.ts @@ -60,7 +60,7 @@ export interface LiveViewSocket { * @param replaceHistory whether to replace the current history entry or push a new one (defaults to false) */ // pushPatch(path: string, params: Record): void; - pushPatch(path: string, params?: Record, replaceHistory?: boolean): void; + pushPatch(path: string, params?: URLSearchParams, replaceHistory?: boolean): void; /** * Shutdowns the current `LiveView` and load another `LiveView` in its place without reloading the @@ -71,7 +71,7 @@ export interface LiveViewSocket { * @param params the query params to update the path with * @param replaceHistory whether to replace the current history entry or push a new one (defaults to false) */ - pushRedirect(path: string, params?: Record, replaceHistory?: boolean): void; + pushRedirect(path: string, params?: URLSearchParams, replaceHistory?: boolean): void; /** * Add flash to the socket for a given key and value. * @param key @@ -132,10 +132,10 @@ abstract class BaseLiveViewSocket pushEvent(pushEvent: AnyLivePushEvent) { // no-op } - pushPatch(path: string, params?: Record, replaceHistory?: boolean) { + pushPatch(path: string, params?: URLSearchParams, replaceHistory?: boolean) { // no-op } - pushRedirect(path: string, params?: Record, replaceHistory?: boolean) { + pushRedirect(path: string, params?: URLSearchParams, replaceHistory?: boolean) { // no-op } putFlash(key: string, value: string) { @@ -176,17 +176,8 @@ export class HttpLiveViewSocket extends BaseLiveViewSocket { return this._redirect; } - pushRedirect(path: string, params?: Record, replaceHistory?: boolean): void { - let stringParams: string | undefined; - const urlParams = new URLSearchParams(); - if (params && Object.keys(params).length > 0) { - for (const [key, value] of Object.entries(params)) { - urlParams.set(key, String(value)); - } - stringParams = urlParams.toString(); - } - - const to = stringParams ? `${path}?${stringParams}` : path; + pushRedirect(path: string, params?: URLSearchParams, replaceHistory?: boolean): void { + const to = params ? `${path}?${params}` : path; this._redirect = { to, replace: replaceHistory || false, @@ -207,12 +198,8 @@ export class WsLiveViewSocket extends BaseLiveViewSocket { // callbacks to the ComponentManager private pageTitleCallback: (newPageTitle: string) => void; private pushEventCallback: (pushEvent: AnyLivePushEvent) => void; - private pushPatchCallback: (path: string, params?: Record, replaceHistory?: boolean) => void; - private pushRedirectCallback: ( - path: string, - params?: Record, - replaceHistory?: boolean - ) => void; + private pushPatchCallback: (path: string, params?: URLSearchParams, replaceHistory?: boolean) => void; + private pushRedirectCallback: (path: string, params?: URLSearchParams, replaceHistory?: boolean) => void; private putFlashCallback: (key: string, value: string) => void; private repeatCallback: (fn: () => void, intervalMillis: number) => void; private sendCallback: (info: AnyLiveInfo) => void; @@ -222,8 +209,8 @@ export class WsLiveViewSocket extends BaseLiveViewSocket { id: string, pageTitleCallback: (newPageTitle: string) => void, pushEventCallback: (pushEvent: AnyLivePushEvent) => void, - pushPatchCallback: (path: string, params?: Record, replaceHistory?: boolean) => void, - pushRedirectCallback: (path: string, params?: Record, replaceHistory?: boolean) => void, + pushPatchCallback: (path: string, params?: URLSearchParams, replaceHistory?: boolean) => void, + pushRedirectCallback: (path: string, params?: URLSearchParams, replaceHistory?: boolean) => void, putFlashCallback: (key: string, value: string) => void, repeatCallback: (fn: () => void, intervalMillis: number) => void, sendCallback: (info: AnyLiveInfo) => void, @@ -249,10 +236,10 @@ export class WsLiveViewSocket extends BaseLiveViewSocket { pushEvent(pushEvent: AnyLivePushEvent) { this.pushEventCallback(pushEvent); } - pushPatch(path: string, params?: Record, replaceHistory: boolean = false) { + pushPatch(path: string, params?: URLSearchParams, replaceHistory: boolean = false) { this.pushPatchCallback(path, params, replaceHistory); } - pushRedirect(path: string, params?: Record, replaceHistory: boolean = false) { + pushRedirect(path: string, params?: URLSearchParams, replaceHistory: boolean = false) { this.pushRedirectCallback(path, params, replaceHistory); } repeat(fn: () => void, intervalMillis: number) { diff --git a/src/server/socket/liveViewManager.test.ts b/src/server/socket/liveViewManager.test.ts index f2eaef2e..46193f0a 100644 --- a/src/server/socket/liveViewManager.test.ts +++ b/src/server/socket/liveViewManager.test.ts @@ -1,25 +1,19 @@ -import { SessionData } from "express-session"; import { mock } from "jest-mock-extended"; -import jwt from "jsonwebtoken"; import { nanoid } from "nanoid"; -import { WebSocket } from "ws"; import { BaseLiveComponent, BaseLiveView, html, HtmlSafeString, LiveComponentSocket, - LiveViewExternalEventListener, - LiveViewInternalEventListener, LiveViewMeta, LiveViewMountParams, LiveViewSocket, LiveViewTemplate, - StringPropertyValues, } from ".."; -import { LiveViewContext } from "../component"; -import { PubSub } from "../pubsub/SingleProcessPubSub"; -import { areContextsValueEqual, isEventHandler, isInfoHandler } from "./liveViewManager"; +import { SingleProcessPubSub } from "../pubsub"; +import { JsonSerDe } from "../adaptor/jsonSerDe"; +import { LiveViewManager } from "./liveViewManager"; import { PhxBlurPayload, PhxClickPayload, @@ -34,29 +28,39 @@ import { PhxKeyUpPayload, PhxLivePatchIncoming, } from "./types"; +import { AnyLiveContext, AnyLiveEvent, AnyLiveInfo } from "../live"; +import { SessionData } from "../session"; +import { WsAdaptor } from "../adaptor"; describe("test component manager", () => { let cmLiveView: LiveViewManager; let cmLiveViewAndLiveComponent: LiveViewManager; let liveViewConnectionId: string; let liveViewAndLiveComponentConnectionId: string; - let ws: WebSocket; + let ws: WsAdaptor; beforeEach(() => { liveViewConnectionId = nanoid(); liveViewAndLiveComponentConnectionId = nanoid(); - ws = mock(); - cmLiveView = new LiveViewManager(new TestLiveViewComponent(), "my signing secret", liveViewConnectionId, ws); + ws = mock(); + cmLiveView = new LiveViewManager( + new TestLiveViewComponent(), + liveViewConnectionId, + ws, + new JsonSerDe(), + new SingleProcessPubSub() + ); cmLiveViewAndLiveComponent = new LiveViewManager( new TestLiveViewAndLiveComponent(), - "my signing secret", liveViewAndLiveComponentConnectionId, - ws + ws, + new JsonSerDe(), + new SingleProcessPubSub() ); }); afterEach(() => { - cmLiveView.shutdown(); - cmLiveViewAndLiveComponent.shutdown(); + (cmLiveView as any).shutdown(); + (cmLiveViewAndLiveComponent as any).shutdown(); }); it("handle join works for liveView", async () => { @@ -97,14 +101,6 @@ describe("test component manager", () => { expect(ws.send).toHaveBeenCalledTimes(0); }); - it("can determine if component implements handleEvent", () => { - expect(isEventHandler(new TestLiveViewComponent())).toBe(true); - expect(isInfoHandler(new TestLiveViewComponent())).toBe(true); - - expect(isEventHandler(new NotEventHandlerNorInfoHandlerLiveViewComponent())).toBe(false); - expect(isInfoHandler(new NotEventHandlerNorInfoHandlerLiveViewComponent())).toBe(false); - }); - it("handleSubscription unknown message fails", async () => { const phx_unknown = [null, null, "unknown", "unknown", {}]; // @ts-ignore - ignore type error for test @@ -210,25 +206,6 @@ describe("test component manager", () => { expect(ws.send).toHaveBeenCalledTimes(3); }); - it("onEvent valid click event but not eventHandler", async () => { - const c = new NotEventHandlerNorInfoHandlerLiveViewComponent(); - const cm = new LiveViewManager(c, "my signing secret", liveViewConnectionId, ws); - const phx_click: PhxIncomingMessage = [ - "4", - "6", - "lv:phx-AAAAAAAA", - "event", - { - type: "click", - event: "eventName", - value: { value: "eventValue" }, - }, - ]; - await cm.handleSubscriptions({ type: "event", message: phx_click }); - await cm.onEvent(phx_click); - expect(ws.send).toHaveBeenCalledTimes(0); - }); - it("onEvent valid click event", async () => { const phx_click: PhxIncomingMessage = [ "4", @@ -407,20 +384,9 @@ describe("test component manager", () => { expect(ws.send).toHaveBeenCalledTimes(3); }); - it("test repeat / shutdown", (done) => { - let count = 0; - cmLiveView.repeat(() => { - count++; - }, 100); - setTimeout(() => { - expect(count).toBe(2); - done(); - }, 250); - }); - it("sendInternal with handleInfo", async () => { const sic = new SendInternalTestLiveViewComponent(); - const cm = new LiveViewManager(sic, "my signing secret", liveViewConnectionId, ws); + const cm = new LiveViewManager(sic, liveViewConnectionId, ws, new JsonSerDe(), new SingleProcessPubSub()); const phx_click: PhxIncomingMessage = [ "4", "6", @@ -437,45 +403,15 @@ describe("test component manager", () => { expect(ws.send).toHaveBeenCalledTimes(3); }); - it("sendInternal with no handleInfo", async () => { - const sic = new SendInternalNoHandleInfoLiveViewComponent(); - const cm = new LiveViewManager(sic, "my signing secret", liveViewConnectionId, ws); - const phx_click: PhxIncomingMessage = [ - "4", - "6", - "lv:phx-AAAAAAAA", - "event", - { - type: "click", - event: "eventName", - value: { value: "eventValue" }, - }, - ]; - await cm.handleJoin(newPhxJoin("my csrf token", "my signing secret", { url: "http://localhost:4444/test" })); - await cm.handleSubscriptions({ type: "event", message: phx_click }); - expect(ws.send).toHaveBeenCalledTimes(2); - }); - it("send phxReply on unknown socket error", async () => { const tc = new TestLiveViewComponent(); - const ws = mock(); - ws.send.mockImplementation( - ( - data: any, - options: { - mask?: boolean; - binary?: boolean; - compress?: boolean; - fin?: boolean; - }, - cb?: (err?: Error) => void - ) => { - if (cb) { - cb(new Error("unknown error")); - } + const ws = mock(); + ws.send.mockImplementation((message: string, errorHandler?: (err?: Error) => void) => { + if (errorHandler) { + errorHandler(new Error("unknown error")); } - ); - const cm = new LiveViewManager(tc, "my signing secret", liveViewConnectionId, ws); + }); + const cm = new LiveViewManager(tc, liveViewConnectionId, ws, new JsonSerDe(), new SingleProcessPubSub()); const phx_click: PhxIncomingMessage = [ "4", @@ -493,7 +429,7 @@ describe("test component manager", () => { it("a component that sets page title", async () => { const c = new SetsPageTitleComponent(); - const cm = new LiveViewManager(c, "my signing secret", liveViewConnectionId, ws); + const cm = new LiveViewManager(c, liveViewConnectionId, ws, new JsonSerDe(), new SingleProcessPubSub()); const spyMaybeAddPageTitleToParts = jest.spyOn(cm as any, "maybeAddPageTitleToParts"); await cm.handleJoin(newPhxJoin("my csrf token", "my signing secret", { url: "http://localhost:4444/test" })); @@ -506,30 +442,31 @@ describe("test component manager", () => { jest.useFakeTimers(); const c = new Repeat50msTestLiveViewComponent(); const spyHandleInfo = jest.spyOn(c as any, "handleInfo"); - const cm = new LiveViewManager(c, "my signing secret", liveViewConnectionId, ws); + const cm = new LiveViewManager(c, liveViewConnectionId, ws, new JsonSerDe(), new SingleProcessPubSub()); await cm.handleJoin(newPhxJoin("my csrf token", "my signing secret", { url: "http://localhost:4444/test" })); setTimeout(async () => { // get private socket context - expect((cm["socket"] as LiveViewSocket).context).toHaveProperty("count", 2); - cm.shutdown(); + expect((cm["socket"] as LiveViewSocket).context).toHaveProperty("count", 2); + (cm as any).shutdown(); }, 125); jest.runAllTimers(); }); it("component that subscribes and received message", async () => { const c = new SubscribeTestLiveViewComponent(); - const cm = new LiveViewManager(c, "my signing secret", liveViewConnectionId, ws); + const pubSub = new SingleProcessPubSub(); + const cm = new LiveViewManager(c, liveViewConnectionId, ws, new JsonSerDe(), pubSub); await cm.handleJoin(newPhxJoin("my csrf token", "my signing secret", { url: "http://localhost:4444/test" })); - await PubSub.broadcast("testTopic", { test: "test" }); - expect((cm["socket"] as LiveViewSocket).context).toHaveProperty("testReceived", 1); - cm.shutdown(); + await pubSub.broadcast("testTopic", { test: "test" }); + expect((cm["socket"] as LiveViewSocket).context).toHaveProperty("testReceived", 1); + (cm as any).shutdown(); }); it("component that pushPatches", async () => { const c = new PushPatchingTestComponent(); - const cm = new LiveViewManager(c, "my signing secret", liveViewConnectionId, ws); + const cm = new LiveViewManager(c, liveViewConnectionId, ws, new JsonSerDe(), new SingleProcessPubSub()); const spyCm = jest.spyOn(cm as any, "onPushPatch"); await cm.handleJoin(newPhxJoin("my csrf token", "my signing secret", { url: "http://localhost:4444/test" })); @@ -548,12 +485,12 @@ describe("test component manager", () => { await cm.onEvent(phx_click); await cm.onEvent(phx_click); expect(spyCm).toHaveBeenCalledTimes(3); - cm.shutdown(); + (cm as any).shutdown(); }); it("component that pushRedirects", async () => { const c = new PushRedirectingTestComponent(); - const cm = new LiveViewManager(c, "my signing secret", liveViewConnectionId, ws); + const cm = new LiveViewManager(c, liveViewConnectionId, ws, new JsonSerDe(), new SingleProcessPubSub()); const spyCm = jest.spyOn(cm as any, "onPushRedirect"); await cm.handleJoin(newPhxJoin("my csrf token", "my signing secret", { url: "http://localhost:4444/test" })); @@ -572,12 +509,12 @@ describe("test component manager", () => { await cm.onEvent(phx_click); await cm.onEvent(phx_click); expect(spyCm).toHaveBeenCalledTimes(3); - cm.shutdown(); + (cm as any).shutdown(); }); it("component that pushEvents", async () => { const c = new PushEventTestComponent(); - const cm = new LiveViewManager(c, "my signing secret", liveViewConnectionId, ws); + const cm = new LiveViewManager(c, liveViewConnectionId, ws, new JsonSerDe(), new SingleProcessPubSub()); const spyCm = jest.spyOn(cm as any, "onPushEvent"); await cm.handleJoin(newPhxJoin("my csrf token", "my signing secret", { url: "http://localhost:4444/test" })); @@ -594,12 +531,12 @@ describe("test component manager", () => { ]; await cm.onEvent(phx_click); expect(spyCm).toHaveBeenCalledTimes(1); - cm.shutdown(); + (cm as any).shutdown(); }); it("component that putFlash", async () => { const c = new PutFlashComponent(); - const cm = new LiveViewManager(c, "my signing secret", liveViewConnectionId, ws); + const cm = new LiveViewManager(c, liveViewConnectionId, ws, new JsonSerDe(), new SingleProcessPubSub()); const spyPutFlash = jest.spyOn(cm as any, "putFlash"); await cm.handleJoin(newPhxJoin("my csrf token", "my signing secret", { url: "http://localhost:4444/test" })); @@ -616,13 +553,13 @@ describe("test component manager", () => { ]; await cm.onEvent(phx_click); expect(spyPutFlash).toHaveBeenCalledTimes(1); - cm.shutdown(); + (cm as any).shutdown(); }); it("default live view meta", async () => { const c = new PushPatchingTestComponent(); const spyHandleParams = jest.spyOn(c as any, "handleParams"); - const cm = new LiveViewManager(c, "my signing secret", liveViewConnectionId, ws); + const cm = new LiveViewManager(c, liveViewConnectionId, ws, new JsonSerDe(), new SingleProcessPubSub()); await cm.handleJoin(newPhxJoin("my csrf token", "my signing secret", { url: "http://localhost:4444/test" })); const phx_click: PhxIncomingMessage = [ @@ -638,25 +575,22 @@ describe("test component manager", () => { ]; await cm.onEvent(phx_click); expect(spyHandleParams).toHaveBeenCalledTimes(2); - expect((cm["socket"] as LiveViewSocket).context).toHaveProperty("pushed", 2); - cm.shutdown(); + expect((cm["socket"] as LiveViewSocket).context).toHaveProperty("pushed", 2); + (cm as any).shutdown(); }); it("test liveViewRootTemplate", async () => { const c = new TestLiveViewComponent(); const liveViewRootTemplate = (session: SessionData, inner_content: HtmlSafeString) => html`
${session.csrfToken} ${inner_content}
`; - const cm = new LiveViewManager(c, "my signing secret", liveViewConnectionId, ws, liveViewRootTemplate); + const cm = new LiveViewManager(c, liveViewConnectionId, ws, new JsonSerDe(), new SingleProcessPubSub()); await cm.handleJoin(newPhxJoin("my csrf token", "my signing secret", { url: "http://localhost:4444/test" })); // use inline shapshot to see liveViewRootTemplate rendered expect(ws.send).toMatchInlineSnapshot(` [MockFunction] { "calls": Array [ Array [ - "[\\"4\\",\\"4\\",\\"lv:phx-AAAAAAAA\\",\\"phx_reply\\",{\\"response\\":{\\"rendered\\":{\\"0\\":\\"my csrf token\\",\\"1\\":{\\"s\\":[\\"
test
\\"]},\\"s\\":[\\"
\\",\\" \\",\\"
\\"]}},\\"status\\":\\"ok\\"}]", - Object { - "binary": false, - }, + "[\\"4\\",\\"4\\",\\"lv:phx-AAAAAAAA\\",\\"phx_reply\\",{\\"response\\":{\\"rendered\\":{\\"s\\":[\\"
test
\\"]}},\\"status\\":\\"ok\\"}]", [Function], ], ], @@ -668,22 +602,11 @@ describe("test component manager", () => { ], } `); - cm.shutdown(); - }); - - it("areContextsValueEqual test", () => { - // @ts-ignore - expect(areContextsValueEqual(undefined, undefined)).toBeFalsy(); + (cm as any).shutdown(); }); }); -interface TestLiveViewComponentContext extends LiveViewContext {} -class TestLiveViewComponent - extends BaseLiveView - implements - LiveViewExternalEventListener, - LiveViewInternalEventListener -{ +class TestLiveViewComponent extends BaseLiveView { private newPageTitle?: string; constructor(newPageTitle?: string) { @@ -691,15 +614,7 @@ class TestLiveViewComponent this.newPageTitle = newPageTitle; } - handleInfo(event: "internalEvent", socket: LiveViewSocket): void | Promise { - // no op but expected for test - } - - handleEvent( - event: "eventName", - params: StringPropertyValues, - socket: LiveViewSocket - ) { + handleEvent(event: AnyLiveEvent, socket: LiveViewSocket) { if (this.newPageTitle) { socket.pageTitle(this.newPageTitle); } @@ -710,32 +625,29 @@ class TestLiveViewComponent } } -interface SendInternalContext extends LiveViewContext { +interface SendInternalContext { handleEventCount: number; handleInfoCount: number; } -class SendInternalTestLiveViewComponent - extends BaseLiveView - implements - LiveViewExternalEventListener, - LiveViewInternalEventListener -{ - mount(params: LiveViewMountParams, session: Partial, socket: LiveViewSocket<{}>) { +type SendInternalInfo = { type: "internal" }; + +class SendInternalTestLiveViewComponent extends BaseLiveView { + mount(params: LiveViewMountParams, session: Partial, socket: LiveViewSocket) { socket.assign({ handleEventCount: 0, handleInfoCount: 0, }); } - handleEvent(event: "eventName", params: StringPropertyValues, socket: LiveViewSocket) { - socket.send("eventName"); + handleEvent(event: AnyLiveEvent, socket: LiveViewSocket) { + socket.send({ type: "internal" }); socket.assign({ handleEventCount: socket.context.handleEventCount + 1, handleInfoCount: socket.context.handleInfoCount, }); } - handleInfo(event: "eventName", socket: LiveViewSocket) { + handleInfo(info: SendInternalInfo, socket: LiveViewSocket) { socket.assign({ handleEventCount: socket.context.handleEventCount, handleInfoCount: socket.context.handleInfoCount + 1, @@ -748,75 +660,32 @@ class SendInternalTestLiveViewComponent } } -interface SendInternalNoHandleInfoContext extends LiveViewContext { - handleEventCount: number; - handleInfoCount: number; -} -class SendInternalNoHandleInfoLiveViewComponent - extends BaseLiveView - implements LiveViewExternalEventListener -{ - mount(params: LiveViewMountParams, session: Partial, socket: LiveViewSocket<{}>) { - socket.assign({ - handleEventCount: 0, - handleInfoCount: 0, - }); - } - - handleEvent( - event: "eventName", - params: StringPropertyValues, - socket: LiveViewSocket - ) { - socket.send("eventName"); - socket.assign({ - handleEventCount: socket.context.handleEventCount + 1, - handleInfoCount: socket.context.handleInfoCount, - }); - } - - render(context: SendInternalNoHandleInfoContext) { - const { handleEventCount, handleInfoCount } = context; - return html`
${handleEventCount},${handleInfoCount}
`; - } -} - -class NotEventHandlerNorInfoHandlerLiveViewComponent extends BaseLiveView { - render() { - return html`
test
`; - } -} - -interface RepeatCtx extends LiveViewContext { +interface RepeatCtx { count: number; } -class Repeat50msTestLiveViewComponent - extends BaseLiveView - implements LiveViewInternalEventListener -{ +type RepeatInfo = { type: "add" }; +class Repeat50msTestLiveViewComponent extends BaseLiveView { mount(params: LiveViewMountParams, session: Partial, socket: LiveViewSocket) { socket.repeat(() => { - socket.send("add"); + socket.send({ type: "add" }); }, 50); socket.assign({ count: 0 }); } - handleInfo(event: "add", socket: LiveViewSocket) { + handleInfo(info: RepeatInfo, socket: LiveViewSocket) { socket.assign({ count: socket.context.count + 1 }); } - render() { - return html`
test
`; + render(context: RepeatCtx, meta: LiveViewMeta) { + return html`
test:${context.count}
`; } } -interface SubscribeCtx extends LiveViewContext { +interface SubscribeCtx { testReceived: number; } -class SubscribeTestLiveViewComponent - extends BaseLiveView - implements LiveViewInternalEventListener -{ +type SubscribeInfo = { type: "testTopicReceived" }; +class SubscribeTestLiveViewComponent extends BaseLiveView { mount(params: LiveViewMountParams, session: Partial, socket: LiveViewSocket) { socket.subscribe("testTopic"); socket.assign({ @@ -824,7 +693,7 @@ class SubscribeTestLiveViewComponent }); } - handleInfo(event: "testTopicReceived", socket: LiveViewSocket) { + handleInfo(info: SubscribeInfo, socket: LiveViewSocket) { socket.assign({ testReceived: socket.context.testReceived + 1, }); @@ -835,33 +704,30 @@ class SubscribeTestLiveViewComponent } } -interface PushPatchCtx extends LiveViewContext { +interface PushPatchCtx { pushed: number; } -class PushPatchingTestComponent - extends BaseLiveView - implements LiveViewExternalEventListener -{ +class PushPatchingTestComponent extends BaseLiveView { mount(params: LiveViewMountParams, session: Partial, socket: LiveViewSocket) { socket.assign({ pushed: 0, }); } - handleParams(params: StringPropertyValues<{ go?: string }>, url: string, socket: LiveViewSocket) { + handleParams(url: URL, socket: LiveViewSocket) { let pushed = Number(socket.context.pushed); socket.assign({ pushed: pushed + 1, }); } - handleEvent(event: "push", params: StringPropertyValues<{}>, socket: LiveViewSocket) { + handleEvent(event: AnyLiveEvent, socket: LiveViewSocket) { if (socket.context.pushed === 0) { socket.pushPatch("pushed"); } else if (socket.context.pushed === 1) { - socket.pushPatch("pushed", { go: "dog" }); + socket.pushPatch("pushed", new URLSearchParams({ go: "dog" })); } else { - socket.pushPatch("pushed", { go: "dog" }, true); + socket.pushPatch("pushed", new URLSearchParams({ go: "dog" }), true); } } @@ -870,33 +736,30 @@ class PushPatchingTestComponent } } -interface PushRedirectCtx extends LiveViewContext { +interface PushRedirectCtx { pushed: number; } -class PushRedirectingTestComponent - extends BaseLiveView - implements LiveViewExternalEventListener -{ +class PushRedirectingTestComponent extends BaseLiveView { mount(params: LiveViewMountParams, session: Partial, socket: LiveViewSocket) { socket.assign({ pushed: 0, }); } - handleParams(params: StringPropertyValues<{ go?: string }>, url: string, socket: LiveViewSocket) { + handleParams(url: URL, socket: LiveViewSocket) { let pushed = Number(socket.context.pushed); socket.assign({ pushed: pushed + 1, }); } - handleEvent(event: "push", params: StringPropertyValues<{}>, socket: LiveViewSocket) { + handleEvent(event: AnyLiveEvent, socket: LiveViewSocket) { if (socket.context.pushed === 0) { socket.pushRedirect("pushed"); } else if (socket.context.pushed === 1) { - socket.pushRedirect("pushed", { go: "dog" }); + socket.pushRedirect("pushed", new URLSearchParams({ go: "dog" })); } else { - socket.pushRedirect("pushed", { go: "dog" }, true); + socket.pushRedirect("pushed", new URLSearchParams({ go: "dog" }), true); } } @@ -905,22 +768,19 @@ class PushRedirectingTestComponent } } -interface PushEventCtx extends LiveViewContext { +interface PushEventCtx { pushed: number; } -class PushEventTestComponent - extends BaseLiveView - implements LiveViewExternalEventListener -{ +class PushEventTestComponent extends BaseLiveView { mount(params: LiveViewMountParams, session: Partial, socket: LiveViewSocket) { socket.assign({ pushed: 0, }); } - handleParams(params: StringPropertyValues<{ go?: string }>, url: string, socket: LiveViewSocket) { + handleParams(url: URL, socket: LiveViewSocket) { let pushed = Number(socket.context.pushed); - if (params.go === "dog") { + if (url.searchParams.get("go") === "dog") { // only increment if passed params.go is dog pushed += 1; socket.assign({ @@ -929,8 +789,8 @@ class PushEventTestComponent } } - handleEvent(event: "push", params: StringPropertyValues<{}>, socket: LiveViewSocket) { - socket.pushEvent("pushed", { go: "dog" }); + handleEvent(event: AnyLiveEvent, socket: LiveViewSocket) { + socket.pushEvent({ type: "pushed", go: "dog" }); } render() { @@ -938,20 +798,17 @@ class PushEventTestComponent } } -interface PutFlashContext extends LiveViewContext { +interface PutFlashContext { called: number; } -class PutFlashComponent - extends BaseLiveView - implements LiveViewExternalEventListener -{ +class PutFlashComponent extends BaseLiveView { mount(params: LiveViewMountParams, session: Partial, socket: LiveViewSocket) { socket.assign({ called: 0, }); } - handleEvent(event: "something", params: StringPropertyValues<{}>, socket: LiveViewSocket) { + handleEvent(event: AnyLiveEvent, socket: LiveViewSocket) { socket.putFlash("info", "flash test"); } @@ -969,15 +826,14 @@ interface NewPhxJoinOptions { } const newPhxJoin = (csrfToken: string, signingSecret: string, options: NewPhxJoinOptions): PhxJoinIncoming => { const session: Partial = { - csrfToken, + _csrf_token: csrfToken, }; const params: LiveViewMountParams = { _csrf_token: options.paramCsrfOverride ?? csrfToken, _mounts: 0, }; const url = options.url ?? options.redirect; - const jwtSession = jwt.sign(JSON.stringify(session), options.signingSecretOverride ?? signingSecret); - const jwtStatic = jwt.sign(JSON.stringify([]), options.signingSecretOverride ?? signingSecret); + const jwtSession = JSON.stringify(session); return [ "4", "4", @@ -987,12 +843,12 @@ const newPhxJoin = (csrfToken: string, signingSecret: string, options: NewPhxJoi url, params, session: jwtSession, - static: jwtStatic, + static: "", }, ]; }; -class SetsPageTitleComponent extends BaseLiveView { +class SetsPageTitleComponent extends BaseLiveView { mount(params: LiveViewMountParams, session: Partial, socket: LiveViewSocket<{}>) { socket.pageTitle("new page title"); } @@ -1001,14 +857,11 @@ class SetsPageTitleComponent extends BaseLiveView { } } -interface TestLVAndLCContext extends LiveViewContext { +interface TestLVAndLCContext { called: number; } -class TestLiveViewAndLiveComponent - extends BaseLiveView - implements LiveViewInternalEventListener -{ +class TestLiveViewAndLiveComponent extends BaseLiveView { mount(params: LiveViewMountParams, session: Partial, socket: LiveViewSocket) { socket.assign({ called: 0 }); } @@ -1017,17 +870,17 @@ class TestLiveViewAndLiveComponent const { called } = ctx; const { live_component } = meta; return html` -
${await live_component(new TestLiveComponent(), { id: 1, subcalled: called })}
+
${await live_component(new TestLiveComponent(), { id: 1, foo: "called" })}
${await live_component(new TestLiveComponent(), { foo: "bar" })}
`; } - handleInfo(event: "test", socket: LiveViewSocket) { + handleInfo(info: AnyLiveInfo, socket: LiveViewSocket) { socket.assign({ called: socket.context.called + 1 }); } } -interface TestLVContext extends LiveViewContext { +interface TestLVContext { foo: string; } class TestLiveComponent extends BaseLiveComponent { @@ -1035,9 +888,9 @@ class TestLiveComponent extends BaseLiveComponent { return html`
${ctx.foo}
`; } - handleEvent(event: "test", params: StringPropertyValues, socket: LiveComponentSocket) { - socket.send("test"); - socket.pushEvent("test", { foo: "bar" }); + handleEvent(event: AnyLiveEvent, socket: LiveComponentSocket) { + socket.send({ type: "test" }); + socket.pushEvent({ type: "test", foo: "bar" }); socket.assign({ foo: "bar" }); } } diff --git a/src/server/socket/liveViewManager.ts b/src/server/socket/liveViewManager.ts index 2d423283..73aee09e 100644 --- a/src/server/socket/liveViewManager.ts +++ b/src/server/socket/liveViewManager.ts @@ -1,9 +1,12 @@ import { SerDe } from "../adaptor"; import { WsAdaptor } from "../adaptor/websocket"; import { + AnyLiveContext, AnyLiveEvent, + AnyLiveInfo, AnyLivePushEvent, LiveComponent, + LiveContext, LiveView, LiveViewMeta, LiveViewTemplate, @@ -14,7 +17,7 @@ import { PubSub } from "../pubsub"; import { SessionData } from "../session"; import { HtmlSafeString, Parts, safe } from "../templates"; import { deepDiff } from "../templates/diff"; -import { WsLiveViewSocket } from "./live_socket"; +import { WsLiveViewSocket } from "./liveSocket"; import { PhxBlurPayload, PhxClickPayload, @@ -31,9 +34,19 @@ import { PhxMessage, PhxOutgoingLivePatchPush, PhxOutgoingMessage, + PhxProtocol, } from "./types"; import { newHeartbeatReply, newPhxReply } from "./util"; +/** + * Add structuredClone type until makes it to latest @types/node + * See: https://github.com/DefinitelyTyped/DefinitelyTyped/pull/59434/files + */ +declare function structuredClone( + value: T, + transfer?: { transfer: ReadonlyArray } +): T; + /** * Data kept for each `LiveComponent` instance. */ @@ -91,7 +104,6 @@ export class LiveViewManager { private csrfToken?: string; private _events: AnyLivePushEvent[] = []; - private eventAdded: boolean = false; private _pageTitle: string | undefined; private pageTitleChanged: boolean = false; @@ -114,21 +126,30 @@ export class LiveViewManager { this.pubSub = pubSub; this.liveViewRootTemplate = liveViewRootTemplate; - // subscribe to events on connectionId which should just be - // heartbeat messages - const subId = this.pubSub.subscribe(connectionId, (data: PhxMessage) => this.handleSubscriptions(data)); + // subscribe to events for a given connectionId which should only be heartbeat messages + const subId = this.pubSub.subscribe(connectionId, this.handleSubscriptions); // save subscription id for unsubscribing on shutdown this.subscriptionIds[connectionId] = subId; } + /** + * The `phx_join` event is the initial connection between the client and the server and initializes the + * `LiveView`, sets up subscriptions for additional events, and otherwise prepares the `LiveView` for + * future client interactions. + * @param message a `PhxJoinIncoming` message + */ async handleJoin(message: PhxJoinIncoming) { try { - const [joinRef, messageRef, topic, event, payload] = message; + const payload = message[PhxProtocol.payload]; + const topic = message[PhxProtocol.topic]; + + // figure out if we are using url or redirect for join URL const { url: urlString, redirect: redirectString } = payload; - const joinUrl = urlString || redirectString; + if (urlString === undefined && redirectString === undefined) { + throw new Error("Join message must have either a url or redirect property"); + } // checked one of these was defined in MessageRouter - const url = new URL(joinUrl!); - + const url = new URL((urlString || redirectString)!); // save base for possible pushPatch base for URL this.urlBase = `${url.protocol}//${url.host}`; @@ -137,89 +158,94 @@ export class LiveViewManager { // set component manager csfr token this.csrfToken = payloadParams._csrf_token; - try { - this.session = await this.serDe.deserialize(payloadSession); - this.session.flash = new Flash(Object.entries(this.session.flash || {})); - - // compare sesison csrfToken with csrfToken from payload - if (this.session._csrf_token !== this.csrfToken) { - // if session csrfToken does not match payload csrfToken, reject join - console.error("Rejecting join due to mismatched csrfTokens", this.session._csrf_token, this.csrfToken); - return; - } - } catch (e) { - console.error("Error decoding session", e); + // attempt to deserialize session + this.session = await this.serDe.deserialize(payloadSession); + this.session.flash = new Flash(Object.entries(this.session.flash || {})); + // if session csrfToken does not match payload csrfToken, reject join + if (this.session._csrf_token !== this.csrfToken) { + console.error("Rejecting join due to mismatched csrfTokens", this.session._csrf_token, this.csrfToken); return; } + // otherwise set the joinId as the phx topic this.joinId = topic; - // subscribe to events on the socketId which includes - // events, live_patch, and phx_leave messages - const subId = this.pubSub.subscribe(this.joinId, (data: PhxMessage) => - this.handleSubscriptions(data) - ); + // subscribe to events on the joinId which includes events, live_patch, and phx_leave messages + const subId = this.pubSub.subscribe(this.joinId, this.handleSubscriptions); // again save subscription id for unsubscribing this.subscriptionIds[this.joinId] = subId; - // create the liveViewSocket now + // run initial lifecycle steps for the liveview: mount => handleParams this.socket = this.newLiveViewSocket(); - - // initial lifecycle steps mount => handleParams => render await this.liveView.mount(payloadParams, this.session, this.socket); await this.liveView.handleParams(url, this.socket); - let view = await this.liveView.render(this.socket.context, this.defaultLiveViewMeta()); - // wrap in root template if there is one + // now the socket context had a chance to be updated, we run the render steps + // step 1: render the `LiveView` + let view = await this.liveView.render(this.socket.context, this.defaultLiveViewMeta()); + // step 2: if provided, wrap the rendered `LiveView` inside the root template view = await this.maybeWrapInRootTemplate(view); - - // add `LiveComponent` to the render tree + // step 3: add any `LiveComponent` renderings to the parts tree let rendered = this.maybeAddLiveComponentsToParts(view.partsTree()); - - // change the page title if it has been set + // step 4: if set, add the page title to the parts tree rendered = this.maybeAddPageTitleToParts(rendered); + // step 5: if added, add events to the parts tree rendered = this.maybeAddEventsToParts(rendered); - // send full view parts (statics & dynaimcs back) + // reply to the join message with the rendered parts tree const replyPayload = { response: { rendered, }, status: "ok", }; - this.sendPhxReply(newPhxReply(message, replyPayload)); - // remove temp data + // remove temp data from the context this.socket.updateContextWithTempAssigns(); } catch (e) { console.error("Error handling join", e); } } + /** + * Every event other than `phx_join` that is received over the connected WebSocket are passed into this + * method and then dispatched the appropriate handler based on the message type. + * @param phxMessage + */ public async handleSubscriptions(phxMessage: PhxMessage) { // console.log("handleSubscriptions", this.connectionId, this.joinId, phxMessage.type); - const { type } = phxMessage; - if (type === "heartbeat") { - this.onHeartbeat(phxMessage.message); - } else if (type === "event") { - await this.onEvent(phxMessage.message); - } else if (type === "live_patch") { - await this.onLivePatch(phxMessage.message); - } else if (type === "phx_leave") { - this.onPhxLeave(phxMessage.message); - } else { - console.error( - "Unknown message type", - type, - phxMessage, - " on connectionId:", - this.connectionId, - " socketId:", - this.joinId - ); + try { + const { type } = phxMessage; + switch (type) { + case "heartbeat": + this.onHeartbeat(phxMessage.message); + break; + case "event": + await this.onEvent(phxMessage.message); + break; + case "live_patch": + await this.onLivePatch(phxMessage.message); + break; + case "phx_leave": + await this.onPhxLeave(phxMessage.message); + break; + default: + console.error( + `Unknown message type:"${type}", message:"${JSON.stringify(phxMessage)}" on connectionId:"${ + this.connectionId + }" and joinId:"${this.joinId}"` + ); + } + } catch (e) { + console.error("Error handling subscription", e); } } + /** + * Any message of type `event` is passed into this method and then handled based on the payload details of + * the message including: click, form, key, blur/focus, and hook events. + * @param message a `PhxEventIncoming` message with a different payload depending on the event type + */ public async onEvent( message: PhxIncomingMessage< | PhxClickPayload @@ -231,125 +257,179 @@ export class LiveViewManager { | PhxHookPayload > ) { - const [joinRef, messageRef, topic, _, payload] = message; - const { type, event, cid } = payload; - - // click and form events have different value in their payload - // TODO - handle uploads - let value: Record; - switch (type) { - case "click": - case "keyup": - case "keydown": - case "blur": - case "focus": - case "hook": - value = payload.value; - break; - case "form": - // @ts-ignore - URLSearchParams has an entries method but not typed - value = Object.fromEntries(new URLSearchParams(payload.value)); - // TODO - check value for _csrf_token here from phx_submit and validate against session csrf? - // TODO - check for _target variable from phx_change here and remove it from value? - break; - default: - console.error("Unknown event type", type); - return; - } - const anEvent: AnyLiveEvent = { - type: event, - ...value, - }; - - // determine if event is for `LiveComponent` - if (cid !== undefined) { - // console.log("LiveComponent event", type, cid, event, value); - // find stateful component data by cid - const statefulComponent = Object.values(this.statefulLiveComponents).find((c) => c.cid === cid); - if (statefulComponent) { - const { componentClass, context: oldContext, parts: oldParts, compoundId } = statefulComponent; - // call event handler on stateful component instance - const liveComponent = this.statefuleLiveComponentInstances[componentClass]; - if (liveComponent) { - // socker for this live component instance - // @ts-ignore - const lcSocket = this.newLiveComponentSocket(structuredClone(oldContext)); - - // run handleEvent and render then update context for cid - await liveComponent.handleEvent(anEvent, lcSocket); - - // TODO optimization - if contexts are the same, don't re-render - const newView = await liveComponent.render(lcSocket.context, { myself: cid }); - - // - const newParts = deepDiff(oldParts, newView.partsTree()); - const changed = Object.keys(newParts).length > 0; - // store state for subsequent loads - this.statefulLiveComponents[compoundId] = { - ...statefulComponent, - context: lcSocket.context, - parts: newView.partsTree(), - changed, - }; - - let diff: Parts = { - c: { - // use cid to identify component to update - [`${cid}`]: newParts, - }, - }; - - diff = this.maybeAddEventsToParts(diff); - - // send message to re-render - const replyPayload = { - response: { - diff, - }, - status: "ok", - }; - - this.sendPhxReply(newPhxReply(message, replyPayload)); + try { + const payload = message[PhxProtocol.payload]; + const { type, event, cid } = payload; + + // TODO - handle uploads + let value: Record; + switch (type) { + case "click": + case "keyup": + case "keydown": + case "blur": + case "focus": + case "hook": + value = payload.value; + break; + case "form": + // parse payload into form data + value = Object.fromEntries(new URLSearchParams(payload.value)); + // ensure _csrf_token is set and same as session csrf token + if (value.hasOwnProperty("_csrf_token")) { + if (value._csrf_token !== this.csrfToken) { + console.error( + `Rejecting form submission due to mismatched csrfTokens. expected:"${this.csrfToken}", got:"${value._csrf_token}"` + ); + return; + } + } else { + console.error(`Rejecting form event due to missing _csrf_token value`); + return; + } + // TODO - check for _target variable from phx_change here and remove it from value? + break; + default: + console.error("Unknown event type", type); + return; + } + + // package the event into a `LiveEvent` type + const eventObj: AnyLiveEvent = { + type: event, + ...value, + }; + + // if the payload has a cid, then this event's target is a `LiveComponent` + if (cid !== undefined) { + // handleLiveComponentEvent() + // console.log("LiveComponent event", type, cid, event, value); + // find stateful component data by cid + const statefulComponent = Object.values(this.statefulLiveComponents).find((c) => c.cid === cid); + if (statefulComponent) { + const { componentClass, context: oldContext, parts: oldParts, compoundId } = statefulComponent; + // call event handler on stateful component instance + const liveComponent = this.statefuleLiveComponentInstances[componentClass]; + if (liveComponent) { + // socker for this live component instance + const lcSocket = this.newLiveComponentSocket(structuredClone(oldContext) as LiveContext); + + // run handleEvent and render then update context for cid + await liveComponent.handleEvent(eventObj, lcSocket); + + // TODO optimization - if contexts are the same, don't re-render + const newView = await liveComponent.render(lcSocket.context, { myself: cid }); + + // + const newParts = deepDiff(oldParts, newView.partsTree()); + const changed = Object.keys(newParts).length > 0; + // store state for subsequent loads + this.statefulLiveComponents[compoundId] = { + ...statefulComponent, + context: lcSocket.context, + parts: newView.partsTree(), + changed, + }; + + let diff: Parts = { + c: { + // use cid to identify component to update + [`${cid}`]: newParts, + }, + }; + + diff = this.maybeAddEventsToParts(diff); + + // send message to re-render + const replyPayload = { + response: { + diff, + }, + status: "ok", + }; + + this.sendPhxReply(newPhxReply(message, replyPayload)); + } else { + // not sure how we'd get here but just in case - ignore test coverage though + /* istanbul ignore next */ + console.error("Could not find stateful component instance for", componentClass); + return; + } } else { - // not sure how we'd get here but just in case - ignore test coverage though - /* istanbul ignore next */ - console.error("Could not find stateful component instance for", componentClass); + console.error("Could not find stateful component for", cid); + return; } - } else { - console.error("Could not find stateful component for", cid); } + // event is not for LiveComponent rather it is for LiveView + else { + // console.log("LiveView event", type, event, value); + // copy previous context + const previousContext = structuredClone(this.socket.context); + + // check again because event could be a lv:clear-flash + await this.liveView.handleEvent(eventObj, this.socket); + + // skip ctxEqual for now + // const ctxEqual = areConte xtsValueEqual(previousContext, this.socket.context); + let diff: Parts = {}; + + // only calc diff if contexts have changed + // if (!ctxEqual || event === "lv:clear-flash") { + // get old render tree and new render tree for diffing + // const oldView = await this.liveView.render(previousContext, this.defaultLiveViewMeta()); + let view = await this.liveView.render(this.socket.context, this.defaultLiveViewMeta()); + + // wrap in root template if there is one + view = await this.maybeWrapInRootTemplate(view); + diff = view.partsTree(); + // diff = deepDiff(oldView.partsTree(), view.partsTree()); + // } + + diff = this.maybeAddPageTitleToParts(diff); + diff = this.maybeAddEventsToParts(diff); + + const replyPayload = { + response: { + diff, + }, + status: "ok", + }; + + this.sendPhxReply(newPhxReply(message, replyPayload)); + + // remove temp data + this.socket.updateContextWithTempAssigns(); + } + } catch (e) { + console.error("Error handling event", e); } - // event is not for LiveComponent rather it is for LiveView - else { - // console.log("LiveView event", type, event, value); - // copy previous context - // @ts-ignore - const previousContext = structuredClone(this.socket.context); + } - // check again because event could be a lv:clear-flash - // if (isEventHandler(this.liveView)) { - // @ts-ignore - already checked if handleEvent is defined - // await this.liveView.handleEvent(event, value, this.socket); - // } - await this.liveView.handleEvent(anEvent, this.socket); + /** + * Handle's `live_patch` message from clients which denote change to the `LiveView`'s path parameters + * and kicks off a re-render after calling `handleParams`. + * @param message a `PhxLivePatchIncoming` message + */ + public async onLivePatch(message: PhxLivePatchIncoming) { + try { + const payload = message[PhxProtocol.payload]; - // skip ctxEqual for now - // const ctxEqual = areConte xtsValueEqual(previousContext, this.socket.context); - let diff: Parts = {}; + const { url: urlString } = payload; + const url = new URL(urlString); + + const previousContext = structuredClone(this.socket.context); + await this.liveView.handleParams(url, this.socket); - // only calc diff if contexts have changed - // if (!ctxEqual || event === "lv:clear-flash") { // get old render tree and new render tree for diffing - // const oldView = await this.liveView.render(previousContext, this.defaultLiveViewMeta()); + // const oldView = await this.component.render(previousContext, this.defaultLiveViewMeta()); let view = await this.liveView.render(this.socket.context, this.defaultLiveViewMeta()); // wrap in root template if there is one view = await this.maybeWrapInRootTemplate(view); - diff = view.partsTree(); - // diff = deepDiff(oldView.partsTree(), view.partsTree()); - // } - diff = this.maybeAddPageTitleToParts(diff); + // TODO - why is the diff causing live_patch to fail?? + // const diff = deepDiff(oldView.partsTree(), view.partsTree()); + let diff = this.maybeAddPageTitleToParts(view.partsTree(false)); diff = this.maybeAddEventsToParts(diff); const replyPayload = { @@ -363,52 +443,32 @@ export class LiveViewManager { // remove temp data this.socket.updateContextWithTempAssigns(); + } catch (e) { + console.error("Error handling live_patch", e); } } - public async onLivePatch(message: PhxLivePatchIncoming) { - const [joinRef, messageRef, topic, event, payload] = message; - - const { url: urlString } = payload; - const url = new URL(urlString); - - const previousContext = this.socket.context; - await this.liveView.handleParams(url, this.socket); - - // get old render tree and new render tree for diffing - // const oldView = await this.component.render(previousContext, this.defaultLiveViewMeta()); - let view = await this.liveView.render(this.socket.context, this.defaultLiveViewMeta()); - - // wrap in root template if there is one - view = await this.maybeWrapInRootTemplate(view); - - // TODO - why is the diff causing live_patch to fail?? - // const diff = deepDiff(oldView.partsTree(), view.partsTree()); - let diff = this.maybeAddPageTitleToParts(view.partsTree(false)); - diff = this.maybeAddEventsToParts(diff); - - const replyPayload = { - response: { - diff, - }, - status: "ok", - }; - - this.sendPhxReply(newPhxReply(message, replyPayload)); - - // remove temp data - this.socket.updateContextWithTempAssigns(); - } - + /** + * Responds to `heartbeat` message from clients by sending a `heartbeat` message back. + * @param message + */ public onHeartbeat(message: PhxHeartbeatIncoming) { // TODO - monitor lastHeartbeat and shutdown if it's been too long? this.sendPhxReply(newHeartbeatReply(message)); } + /** + * Handles `phx_leave` messages from clients which are sent when the client is leaves the `LiveView` + * that is currently being rendered by navigating to a different `LiveView` or closing the browser. + * @param message + */ public async onPhxLeave(message: PhxIncomingMessage<{}>) { await this.shutdown(); } + /** + * Clean up any resources used by the `LiveView` and `LiveComponent` instances. + */ private async shutdown() { try { // unsubscribe from PubSubs @@ -428,70 +488,89 @@ export class LiveViewManager { this.intervals.push(setInterval(fn, intervalMillis)); } - private async onPushPatch(path: string, params?: Record, replaceHistory: boolean = false) { + /** + * Callback from `LiveSocket`s passed into `LiveView` and `LiveComponent` lifecycle methods (i.e. mount, handleParams, + * handleEvent, handleInfo, update, etc) that enables a `LiveView` or `LiveComponent` to update the browser's + * path and query string params. + * @param path the path to patch + * @param params the URLSearchParams to that will drive the new path query string params + * @param replaceHistory whether to replace the current browser history entry or not + */ + private async onPushPatch(path: string, params?: URLSearchParams, replaceHistory: boolean = false) { this.onPushNavigation("live_patch", path, params, replaceHistory); } - private async onPushRedirect( - path: string, - params?: Record, - replaceHistory: boolean = false - ) { + /** + * Callback from `LiveSocket`s passed into `LiveView` and `LiveComponent` lifecycle methods (i.e. mount, handleParams, + * handleEvent, handleInfo, update, etc) that enables a `LiveView` or `LiveComponent` to redirect the browser to a + * new path and query string params. + * @param path the path to redirect to + * @param params the URLSearchParams to that will be added to the redirect + * @param replaceHistory whether to replace the current browser history entry or not + */ + private async onPushRedirect(path: string, params?: URLSearchParams, replaceHistory: boolean = false) { this.onPushNavigation("live_redirect", path, params, replaceHistory); } + /** + * Common logic that handles both `live_patch` and `live_redirect` messages from clients. + * @param navEvent the type of navigation event to handle: either `live_patch` or `live_redirect` + * @param path the path to patch or to be redirected to + * @param params the URLSearchParams to that will be added to the path + * @param replaceHistory whether to replace the current browser history entry or not + */ private async onPushNavigation( navEvent: "live_redirect" | "live_patch", path: string, - params?: Record, + params?: URLSearchParams, replaceHistory: boolean = false ) { - // make params into query string - let stringParams: string | undefined; - const urlParams = new URLSearchParams(); - if (params && Object.keys(params).length > 0) { - for (const [key, value] of Object.entries(params)) { - urlParams.set(key, String(value)); - } - stringParams = urlParams.toString(); - } + try { + // construct the outgoing message + const to = params ? `${path}?${params}` : path; + const kind = replaceHistory ? "replace" : "push"; + const message: PhxOutgoingLivePatchPush = [ + null, // no join reference + null, // no message reference + this.joinId, + navEvent, + { kind, to }, + ]; - const to = stringParams ? `${path}?${stringParams}` : path; - const kind = replaceHistory ? "replace" : "push"; - const message: PhxOutgoingLivePatchPush = [ - null, // no join reference - null, // no message reference - this.joinId, - navEvent, - { kind, to }, - ]; + // to is relative so need to provide the urlBase determined on initial join + const url = new URL(to, this.urlBase); - // to is relative so need to provide the urlBase determined on initial join - const url = new URL(to, this.urlBase); - await this.liveView.handleParams(url, this.socket); + // let the `LiveView` udpate its context based on the new url + await this.liveView.handleParams(url, this.socket); - this.sendPhxReply(message); + // send the message + this.sendPhxReply(message); - // remove temp data - this.socket.updateContextWithTempAssigns(); + // remove temp data + this.socket.updateContextWithTempAssigns(); + } catch (e) { + console.error(`Error handling ${navEvent}`, e); + } } + /** + * Queues `AnyLivePushEvent` messages to be sent to the client on the subsequent `sendPhxReply` call. + * @param pushEvent + */ private async onPushEvent(pushEvent: AnyLivePushEvent) { - // queue event for sending + // queue event this._events.push(pushEvent); - this.eventAdded = true; } - private putFlash(key: string, value: string) { - this.session.flash.set(key, value); - } - - private async sendInternal(event: any): Promise { + /** + * Handles sending `LiveInfo` events back to the `LiveView`'s `handleInfo` method. + * @param info the `LiveInfo` event to dispatch to the `LiveView` + */ + private async sendInternal(info: AnyLiveInfo): Promise { // console.log("sendInternal", event, this.socketId); const previousContext = this.socket.context; - // @ts-ignore - already checked if handleInfo is defined - this.liveView.handleInfo(event, this.socket); + this.liveView.handleInfo(info, this.socket); const ctxEqual = false; //areContextsValueEqual(previousContext, this.socket.context); let diff: Parts = {}; @@ -532,6 +611,10 @@ export class LiveViewManager { } } + private putFlash(key: string, value: string) { + this.session.flash.set(key, value); + } + private async maybeWrapInRootTemplate(view: HtmlSafeString) { if (this.liveViewRootTemplate) { return await this.liveViewRootTemplate(this.session, safe(view)); @@ -551,15 +634,14 @@ export class LiveViewManager { } private maybeAddEventsToParts(parts: Parts) { - if (this.eventAdded) { - this.eventAdded = false; // reset - const e = [ - ...this._events.map((event) => { - const { type, ...values } = event; - return [type, values]; - }), - ]; + if (this._events.length > 0) { + const events = structuredClone(this._events); this._events = []; // reset + // map events to tuples of [type, values] + const e = events.map((event) => { + const { type, ...values } = event; + return [type, values]; + }); return { ...parts, e, @@ -575,7 +657,6 @@ export class LiveViewManager { console.error(`Shutting down topic:${reply[2]}. For component:${this.liveView}. Error: ${err}`); } }); - // this.ws.send(JSON.stringify(reply), { binary: false }, (err?: Error) => this.handleError(reply, err)); } /** @@ -594,9 +675,9 @@ export class LiveViewManager { * @param liveComponent * @param params */ - private async liveComponentProcessor( - liveComponent: LiveComponent, - params: Partial = {} as Context + private async liveComponentProcessor( + liveComponent: LiveComponent, + params: Partial = {} as TContext ): Promise { // console.log("liveComponentProcessor", liveComponent, params); // TODO - determine how to collect all the live components of the same type @@ -615,7 +696,7 @@ export class LiveViewManager { } // setup variables - let context: Partial = { ...params }; + let context = structuredClone(params); let newView: LiveViewTemplate; // determine if component is stateful or stateless @@ -639,7 +720,7 @@ export class LiveViewManager { myself = Object.keys(this.statefulLiveComponents).length + 1; // setup socket - const lcSocket = this.newLiveComponentSocket({ ...context } as Context); + const lcSocket = this.newLiveComponentSocket(structuredClone(context) as TContext); // first load lifecycle mount => update => render await liveComponent.mount(lcSocket); @@ -663,8 +744,7 @@ export class LiveViewManager { myself = cid; // setup socket - // @ts-ignore - const lcSocket = this.newLiveComponentSocket(structuredClone(oldContext) as Context); + const lcSocket = this.newLiveComponentSocket(structuredClone(oldContext) as TContext); // subsequent loads lifecycle update => render (no mount) await liveComponent.update(lcSocket); @@ -693,7 +773,7 @@ export class LiveViewManager { // 4. render // setup socket - const lcSocket = this.newLiveComponentSocket({ ...context } as Context); + const lcSocket = this.newLiveComponentSocket(structuredClone(context) as TContext); // skipping preload for now... see comment above // first load lifecycle mount => update => render @@ -757,7 +837,9 @@ export class LiveViewManager { (fn, intervalMillis) => this.repeat(fn, intervalMillis), (info) => this.sendInternal(info), (topic: string) => { - const subId = this.pubSub.subscribe(topic, (event: unknown) => this.sendInternal(event)); + const subId = this.pubSub.subscribe(topic, (info: AnyLiveInfo) => { + this.sendInternal(info); + }); this.subscriptionIds[topic] = subId; } ); @@ -772,13 +854,3 @@ export class LiveViewManager { ); } } - -// export function areContextsValueEqual(context1: LiveComponentContext, context2: LiveComponentContext): boolean { -// if (!!context1 && !!context2) { -// const c1 = fromJS(context1); -// const c2 = fromJS(context2); -// return c1.equals(c2); -// } else { -// return false; -// } -// } diff --git a/src/server/socket/types.ts b/src/server/socket/types.ts index 5702e8a8..90bbd0d8 100644 --- a/src/server/socket/types.ts +++ b/src/server/socket/types.ts @@ -1,6 +1,6 @@ import { LiveViewMountParams } from ".."; -export enum PhxSocketProtocolNames { +export enum PhxProtocol { joinRef = 0, messageRef, topic, diff --git a/src/server/socket/util.ts b/src/server/socket/util.ts index 265818a0..c1c2f09e 100644 --- a/src/server/socket/util.ts +++ b/src/server/socket/util.ts @@ -1,19 +1,14 @@ -import { PhxIncomingMessage, PhxReply, PhxSocketProtocolNames } from "./types"; +import { PhxIncomingMessage, PhxReply, PhxProtocol } from "./types"; export const newPhxReply = (from: PhxIncomingMessage, payload: any): PhxReply => { - return [ - from[PhxSocketProtocolNames.joinRef], - from[PhxSocketProtocolNames.messageRef], - from[PhxSocketProtocolNames.topic], - "phx_reply", - payload, - ]; + const [joinRef, messageRef, topic, ...rest] = from; + return [joinRef, messageRef, topic, "phx_reply", payload]; }; export const newHeartbeatReply = (incoming: PhxIncomingMessage<{}>): PhxReply => { return [ null, - incoming[PhxSocketProtocolNames.messageRef], + incoming[PhxProtocol.messageRef], "phoenix", "phx_reply", {