diff --git a/bun.lockb b/bun.lockb index e306713..7fefa9a 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 36cbb0a..6d5eb95 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "scripts": { "dev": "vinxi dev", "build": "vinxi build", - "start": "vinxi start" + "start": "vinxi start", + "test": "vitest" }, "author": "Dev Agrawal (http://devagr.me/)", "dependencies": { @@ -16,7 +17,10 @@ "@solid-primitives/websocket": "^1.2.2", "@solidjs/router": "^0.14.10", "@solidjs/start": "^1.0.9", + "immer": "^10.1.1", "rxjs": "^7.8.1", + "seroval": "^1.2.0", + "solid-events": "^0.0.5", "solid-icons": "^1.1.0", "solid-js": "^1.9.2", "unique-names-generator": "^4.7.1", @@ -30,6 +34,7 @@ "@babel/preset-typescript": "^7.26.0", "@vinxi/plugin-directives": "^0.4.3", "babel-plugin-transform-import-paths": "^1.0.3", - "vite-plugin-babel": "^1.2.0" + "vite-plugin-babel": "^1.2.0", + "vitest": "^3.0.4" } } \ No newline at end of file diff --git a/socket/__tests/serializer.test.ts b/socket/__tests/serializer.test.ts new file mode 100644 index 0000000..7f54a6a --- /dev/null +++ b/socket/__tests/serializer.test.ts @@ -0,0 +1,38 @@ +import { createPlugin, serialize } from "seroval"; +import { describe, expect, test } from "vitest"; + +type FunctionNode = { + __type: "ref"; + id: string; +}; +const refPlugin = (map: Map) => + createPlugin({ + tag: "seroval-plugins/socket/ref", + test(value) { + return typeof value === "function"; + }, + parse: { + sync(value, ctx) { + const id = Math.random().toString(); + map.set(id, value); + return { __type: "ref", id }; + }, + }, + deserialize(node, ctx) { + return map.get(node.id)!; + }, + serialize(node, ctx) { + return `node`; + }, + }); + +describe("serializer", () => { + test(`serializes functions`, () => { + const source = { id: 1, greet: () => "Hello" }; + const serialized = serialize(source, { + plugins: [refPlugin], + }); + console.log(serialized); + expect(true).toBe(true); + }); +}); diff --git a/socket/events/v2.ts b/socket/events/v2.ts new file mode 100644 index 0000000..a1be1f7 --- /dev/null +++ b/socket/events/v2.ts @@ -0,0 +1,325 @@ +import { assert } from "console"; +import { + createEvent, + createSubject, + createSubjectStore, + createTopic, + Emitter, + Handler, +} from "solid-events"; +import { createMemo, createRenderEffect, createSignal } from "solid-js"; +import { createStore } from "solid-js/store"; +import { Todo } from "~/components/todos"; +import { + TodoCreated, + TodoDeleted, + TodoEdited, + TodoEvent, + TodoToggled, +} from "~/lib/todos"; + +// export const createEventLog = () => { +// const [onEvent, emitEvent] = createEvent() +// const log = createSubjectStore( +// () => [], +// onEvent((e) => (log) => log.push(e)) +// ); + +// return { log, onEvent, emitEvent }; +// }; + +type LogEvent = { + seq: number; + data: E; +}; + +export const createLog = (props: { onEvent: Handler> }) => { + const [_log, setLog] = createStore<{ events: LogEvent[] }>({ events: [] }); + + props.onEvent((event) => {}); + + const log = createSubjectStore( + () => [], + props.onEvent((e) => (log) => log.push(e)) + ); + + return { log, onEvent: props.onEvent }; +}; + +export const createServerLog = (props: { onEvent: Handler }) => { + const log = createSubjectStore( + () => [], + props.onEvent((e) => (log) => log.push(e)) + ); + + return { log, onEvent: props.onEvent }; +}; + +// export const createLogProjection = ( +// init: () => T, +// ...events: Array<(prev: T) => void>> +// ) => { +// const actualEvents +// return createSubjectStore( +// init, +// ...events.map((onEvent) => +// onEvent((mutation) => { + +// return mutation; +// }) +// ) +// ); +// }; + +// const [onTodoEvent, emitTodoEvent] = createTopic(); + +// const [todoId, setTodoId] = createSignal(); +// onTodoEvent( +// () => `todos.${todoId()}`, +// (todoEvent) => {} +// ); + +const [counter, setCounter] = createSignal(0); + +const [onCounter, emitCounter] = createEvent(); + +onCounter((count) => { + if (count) { + emitCounter(count - 1); + } +}); +onCounter(setCounter); + +onCounter(console.log); + +// onCounter(setCounter); + +emitCounter(1); +flushSync(); +// assert(counter() === 10); +// assert(counter() === 0); + +const [onTodoEvent, emitTodoEvent] = createEvent(); + +const todos1 = createSubjectStore( + () => [] as Todo[], + onTodoEvent((e) => (todos) => { + if (e.type === "todo-added") { + const todo = todos.find((t) => t.id === e.id); + if (!todo) todos.push({ id: e.id, title: e.title, completed: false }); + } + if (e.type === "todo-toggled") { + const todo = todos.find((t) => t.id === e.id); + if (todo) todo.completed = !todo.completed; + } + if (e.type === "todo-deleted") { + const index = todos.findIndex((note) => note.id === e.id); + if (index !== -1) todos.splice(index, 1); + } + if (e.type === "todo-edited") { + const todo = todos.find((t) => t.id === e.id); + if (todo) todo.title = e.title; + } + }) +); + +const [onTodoAdded, emitTodoAdded] = createEvent(); +const [onTodoToggled, emitTodoToggled] = createEvent(); +const [onTodoDeleted, emitTodoDeleted] = createEvent(); +const [onTodoEdited, emitTodoEdited] = createEvent(); + +const todos2 = createSubjectStore( + () => [] as Todo[], + onTodoAdded((e) => (todos) => { + const todo = todos.find((t) => t.id === e.id); + if (!todo) todos.push({ id: e.id, title: e.title, completed: false }); + }), + onTodoToggled((e) => (todos) => { + const todo = todos.find((t) => t.id === e.id); + if (todo) todo.completed = !todo.completed; + }), + onTodoDeleted((e) => (todos) => { + const index = todos.findIndex((note) => note.id === e.id); + if (index !== -1) todos.splice(index, 1); + }), + onTodoEdited((e) => (todos) => { + const todo = todos.find((t) => t.id === e.id); + if (todo) todo.title = e.title; + }) +); + +const [onTodoEvents, emitTodoEvents] = createEvent<{ + Added: [Handler, Emitter]; + Toggled: [Handler, Emitter]; + Deleted: [Handler, Emitter]; + Edited: [Handler, Emitter]; +}>(); + +const Added = createEvent(), + Toggled = createEvent(), + Deleted = createEvent(), + Edited = createEvent(); + +emitTodoEvents({ Added, Toggled, Deleted, Edited }); + +const todos3 = createSubjectStore( + () => [] as Todo[], + onTodoEvents((events) => (todos) => { + events.Added[0]((e) => { + const todo = todos.find((t) => t.id === e.id); + if (!todo) todos.push({ id: e.id, title: e.title, completed: false }); + }); + events.Toggled[0]((e) => { + const todo = todos.find((t) => t.id === e.id); + if (todo) todo.completed = !todo.completed; + }); + events.Deleted[0]((e) => { + const index = todos.findIndex((note) => note.id === e.id); + if (index !== -1) todos.splice(index, 1); + }); + events.Edited[0]((e) => { + const todo = todos.find((t) => t.id === e.id); + if (todo) todo.title = e.title; + }); + }) +); + +const [onTodoTopic, emitTodoTopic] = createTopic<{ + Added: TodoCreated; + Toggled: TodoToggled; + Deleted: TodoDeleted; + Edited: TodoEdited; +}>(); + +const todos4 = createSubjectStore( + () => [] as Todo[], + onTodoTopic((events) => (todos) => { + events.Added((e) => { + const todo = todos.find((t) => t.id === e.id); + if (!todo) todos.push({ id: e.id, title: e.title, completed: false }); + }); + events.Toggled((e) => { + const todo = todos.find((t) => t.id === e.id); + if (todo) todo.completed = !todo.completed; + }); + events.Deleted((e) => { + const index = todos.findIndex((note) => note.id === e.id); + if (index !== -1) todos.splice(index, 1); + }); + events.Edited((e) => { + const todo = todos.find((t) => t.id === e.id); + if (todo) todo.title = e.title; + }); + }) +); + +emitTodoTopic({ Added: {} }); +emitTodoTopic({ Added: {} }); + +const todoId = () => 0; + +const firstTodo1 = createSubject( + null, + onTodoEvent((e) => (todo) => { + if (e.type === "todo-added" && e.id === todoId()) { + return { ...e, completed: false }; + } + if (todo) { + if (e.type === "todo-toggled" && e.id === todoId()) { + return { ...todo, completed: !todo.completed }; + } + if (e.type === "todo-deleted") { + if (e.id === todoId()) return null; + } + if (e.type === "todo-edited") { + if (e.id === todoId()) return { ...todo, title: e.title }; + } + } + return todo; + }) +); + +const firstTodo2 = createMemo(() => { + const id = todoId(); + + return createSubject( + null, + onTodoEvent((e) => (todo) => { + if (e.type === "todo-added" && e.id === id) { + return { ...e, completed: false }; + } + if (todo) { + if (e.type === "todo-toggled" && e.id === id) { + return { ...todo, completed: !todo.completed }; + } + if (e.type === "todo-deleted") { + if (e.id === id) return null; + } + if (e.type === "todo-edited") { + if (e.id === id) return { ...todo, title: e.title }; + } + } + return todo; + }) + ); +}); + +type TodoTopic = { + Added: Record; + Toggled: Record; + Deleted: Record; + Edited: Record; +}; +const [onTodosTopic, emitTodosTopic] = createEvent(); + +const firstTodo3 = createSubject( + null, + onTodosTopic.Added((id: number, e: TodoCreated) => (todo) => { + if (id === todoId()) return { ...e, completed: false }; + return todo; + }), + onTodosTopic.Toggled((id: number, e: TodoToggled) => (todo) => { + if (id === todoId()) return { ...todo, completed: !todo.completed }; + return todo; + }) +); + +const firstTodo5 = createSubject( + null, + onTodosTopic("Added", id, (e: TodoCreated) => (todo) => { + return { ...e, completed: false }; + }), + onTodosTopic("Toggled", id, (e: TodoToggled) => (todo) => { + return { ...todo, completed: !todo.completed }; + }) +); + +const onToggledTodos = onTodosTopic("Toggled"); + +const onToggledMyTodo1 = onToggledTodos(id); +const onToggledMyTodo2 = onTodosTopic("Toggled", id); + +const onToggledTodoIds1 = onToggledTodos((e: Record) => + Object.keys(e) +); +const onToggledTodoIds2 = onTodosTopic( + "Toggled", + (e: Record) => Object.keys(e) +); + +const onToggledMyTodoId1 = onToggledMyTodo1((e) => e.id); +const onToggledMyTodoId2 = onToggledTodos(id, (e) => e.id); +const onToggledMyTodoId3 = onTodosTopic("Toggled", id, (e) => e.id); + +emitTodosTopic("Added", 0, {}); +emitTodosTopic("Toggled", 3, {}); +emitTodosTopic("Added", { 0: {}, 1: {} }); +emitTodosTopic({ Added: { 1: {} }, Deleted: { 0: {} } }); + +const [nextTopic] = createTopic((emit) => { + onTodoAdded((todoAdded) => { + emit("Added", todoAdded.id, todoAdded); + }); +}); + +const [onLocalEvent, emitLocalEvent] = createEvent(onServerEvent); diff --git a/socket/lib/client.tsx b/socket/lib/client.tsx index a34bb10..f489c75 100644 --- a/socket/lib/client.tsx +++ b/socket/lib/client.tsx @@ -1,30 +1,22 @@ -import { from as rxFrom, Observable } from "rxjs"; +import { createLazyMemo } from "@solid-primitives/memo"; +import { createWS } from "@solid-primitives/websocket"; +import { createAsync } from "@solidjs/router"; +import { applyPatches, Patch } from "immer"; +import { fromJSON, SerovalJSON, toJSON } from "seroval"; +import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"; +import { createStore, produce } from "solid-js/store"; +import { + deserializeReactivePayload, + serializeReactivePayload, +} from "./serializer"; import { - createSeriazliedMemo, SerializedMemo, SerializedProjection, SerializedRef, - SerializedStoreAccessor, - SerializedThing, WsMessage, WsMessageDown, WsMessageUp, } from "./shared"; -import { - Accessor, - createComputed, - createEffect, - createMemo, - createSignal, - from, - getListener, - onCleanup, - untrack, -} from "solid-js"; -import { createAsync } from "@solidjs/router"; -import { createLazyMemo } from "@solid-primitives/memo"; -import { createCallback } from "@solid-primitives/rootless"; -import { createWS } from "@solid-primitives/websocket"; const protocol = window.location.protocol === "https:" ? "wss" : "ws"; const wsUrl = `${protocol}://${window.location.hostname}:${window.location.port}/_ws`; @@ -37,200 +29,164 @@ export type SimpleWs = { send(data: string): void; }; -function wsRpc(message: WsMessageUp) { +function wsRpc(message: WsMessageUp) { const ws = getWs(); const id = crypto.randomUUID() as string; - return new Promise<{ value: T; dispose: () => void }>(async (res, rej) => { - function dispose() { - ws.send( - JSON.stringify({ type: "dispose", id } satisfies WsMessage) - ); - } - - function handler(event: { data: string }) { - // console.log(`handler ${id}`, message, { data: event.data }); - const data = JSON.parse(event.data) as WsMessage>; - if (data.id === id && data.type === "value") { - res({ value: data.value, dispose }); - ws.removeEventListener("message", handler); + return new Promise<{ value: SerovalJSON; dispose: () => void }>( + async (res, rej) => { + function dispose() { + ws.send( + JSON.stringify({ + type: "dispose", + id, + } satisfies WsMessage) + ); } - } - ws.addEventListener("message", handler); - ws.send( - JSON.stringify({ ...message, id } satisfies WsMessage) - ); - }); -} - -function wsSub(message: WsMessageUp) { - const ws = getWs(); - const id = crypto.randomUUID(); - - return rxFrom( - new Observable((obs) => { - // console.log(`attaching sub handler`); function handler(event: { data: string }) { - const data = JSON.parse(event.data) as WsMessage>; - // console.log(`data`, data, id); - if (data.id === id && data.type === "value") obs.next(data.value); + // console.log(`handler ${id}`, message, { data: event.data }); + const data = JSON.parse(event.data) as WsMessage; + if (data.id === id && data.type === "value") { + res({ value: data.value, dispose }); + ws.removeEventListener("message", handler); + } } ws.addEventListener("message", handler); ws.send( JSON.stringify({ ...message, id } satisfies WsMessage) ); - - return () => { - // console.log(`detaching sub handler`); - ws.removeEventListener("message", handler); - }; - }) + } ); } -export function createRef(ref: SerializedRef) { - return (...input: any[]) => - wsRpc({ - type: "invoke", - ref, - input, - }).then(({ value }) => value); +// function wsSub(message: WsMessageUp) { +// const ws = getWs(); +// const id = crypto.randomUUID(); + +// return rxFrom( +// new Observable((obs) => { +// // console.log(`attaching sub handler`); +// function handler(event: { data: string }) { +// const data = JSON.parse(event.data) as WsMessage; +// // console.log(`data`, data, id); +// if (data.id === id && data.type === "value") obs.next(data.value); +// } + +// ws.addEventListener("message", handler); +// ws.send( +// JSON.stringify({ ...message, id } satisfies WsMessage) +// ); + +// return () => { +// // console.log(`detaching sub handler`); +// ws.removeEventListener("message", handler); +// }; +// }) +// ); +// } + +function createSocketRefConsumer(ref: SerializedRef) { + return async (...payload: I) => { + const input = toJSON(payload); + const { value } = await wsRpc({ type: "invoke", ref, input }); + return fromJSON(value); + }; } -export function createSocketMemoConsumer(ref: SerializedMemo) { - // console.log({ ref }); - const memo = createLazyMemo( - () => - from( - wsSub({ - type: "subscribe", - ref, - }) - ), - () => ref.initial - ); +function createSocketMemoConsumer(ref: SerializedMemo) { + const [signal, setSignal] = createSignal(ref.initial); - return () => { - const memoValue = memo()(); - // console.log({ memoValue }); - return memoValue; - }; + const ws = getWs(); + function handler(event: { data: string }) { + const data = JSON.parse(event.data) as WsMessage; + if (data.type === "value" && data.id === ref.id) { + setSignal(() => fromJSON(data.value)); + } + } + ws.addEventListener("message", handler); + + onCleanup(() => { + ws.removeEventListener("message", handler); + }); + + return signal; } -export function createSocketProjectionConsumer( - ref: SerializedProjection | SerializedStoreAccessor +function createSocketProjectionConsumer( + ref: SerializedProjection ) { - const nodes = [] as { path: string; accessor: Accessor }[]; - - function getNode(path: string) { - const node = nodes.find((node) => node.path === path); - if (node) return node; - const newNode = { - path, - accessor: from(wsSub({ type: "subscribe", ref, path })), - }; - nodes.push(newNode); - return newNode; + const [store, setStore] = createStore(ref.initial!); + + const ws = getWs(); + function handler(event: { data: string }) { + const data = JSON.parse(event.data) as WsMessage; + if (data.type === "value" && data.id === ref.id) { + setStore( + produce((draft) => { + applyPatches(draft, fromJSON(data.value)); + }) + ); + } } + ws.addEventListener("message", handler); - // @ts-expect-error - return new Proxy(ref.initial || {}, { - get(target, path: string) { - return getListener() - ? getNode(path).accessor() - : ((target as any)[path] as O); - }, + onCleanup(() => { + ws.removeEventListener("message", handler); }); -} -type SerializedValue = SerializedThing | Record; - -const deserializeValue = (value: SerializedValue) => { - if (value.__type === "ref") { - return createRef(value); - } else if (value.__type === "memo") { - return createSocketMemoConsumer(value); - } else if (value.__type === "projection") { - return createSocketProjectionConsumer(value); - } else { - return Object.entries(value).reduce((res, [name, value]) => { - return { - ...res, - [name]: - value.__type === "ref" - ? createRef(value) - : value.__type === "memo" - ? createSocketMemoConsumer(value) - : value.__type === "projection" - ? createSocketProjectionConsumer(value) - : value.__type === "store-accessor" - ? createSocketProjectionConsumer(value) - : value, - }; - }, {} as any); - } -}; + return store; +} -export function createEndpoint(name: string, input?: any) { +export function createEndpoint(name: string, rawInput?: any) { const inputScope = crypto.randomUUID(); - const serializedInput = - input?.type === "memo" - ? createSeriazliedMemo({ - name: `input`, - scope: inputScope, - initial: untrack(input), - }) - : input; + const { + value: input, + refs, + signals, + } = serializeReactivePayload(inputScope, rawInput); // console.log({ serializedInput }); - const scopePromise = wsRpc({ - type: "create", - name, - input: serializedInput, - }); - - if (input?.type === "memo") { - const [inputSignal, setInput] = createSignal(input()); - createComputed(() => setInput(input())); - - const onSubscribe = createCallback( - (ws: SimpleWs, data: WsMessage>) => { - createEffect(() => { - const value = inputSignal(); - // console.log(`sending input update to server`, value, input); - ws.send( - JSON.stringify({ - type: "value", - id: data.id, - value, - } satisfies WsMessage) - ); - }); - } - ); + const scopePromise = wsRpc({ type: "create", name, input }); - const ws = getWs(); - function handler(event: { data: string }) { - const data = JSON.parse(event.data) as WsMessage>; + const ws = getWs(); + signals.forEach((signal, id) => { + createEffect(() => { + ws.send(JSON.stringify({ type: "value", id, value: signal() })); + }); + }); - if (data.type === "subscribe" && data.ref.scope === inputScope) { - onSubscribe(ws, data); + async function refHandler(event: { data: string }) { + const data = JSON.parse(event.data) as WsMessage; + if (data.type === "invoke" && data.ref.scope === inputScope) { + const fn = refs.get(data.ref.id); + if (fn) { + const fnInput = fromJSON(data.input); + const arified = Array.isArray(fnInput) ? fnInput : [fnInput]; + const res = await fn(...arified); + const value = toJSON(res); + ws.send(JSON.stringify({ type: "value", id: data.id, value })); } } - ws.addEventListener("message", handler); - onCleanup(() => ws.removeEventListener("message", handler)); } + ws.addEventListener("message", refHandler); onCleanup(() => { // console.log(`cleanup endpoint`); + ws.removeEventListener("message", refHandler); scopePromise.then(({ dispose }) => dispose()); }); const scope = createAsync(() => scopePromise); const deserializedScope = createMemo( - () => scope() && deserializeValue(scope()!.value) + () => + scope() && + deserializeReactivePayload(scope()!.value, { + createSocketMemoConsumer, + createSocketRefConsumer, + createSocketProjectionConsumer, + }) ); return new Proxy((() => {}) as any, { diff --git a/socket/lib/serializer.tsx b/socket/lib/serializer.tsx new file mode 100644 index 0000000..6e23a13 --- /dev/null +++ b/socket/lib/serializer.tsx @@ -0,0 +1,128 @@ +import { enablePatches, produce as immerProduce, Patch } from "immer"; +import { createPlugin, fromJSON, SerovalJSON, toJSON } from "seroval"; +import { Accessor, createMemo } from "solid-js"; +import { + createSeriazliedMemo, + createSeriazliedProjection, + createSeriazliedRef, + SerializedMemo, + SerializedMemoClass, + SerializedProjection, + SerializedProjectionClass, + SerializedRef, + SerializedRefClass, +} from "./shared"; +enablePatches(); + +export function serializeReactivePayload(scope: string, input: any) { + const refs = new Map(); + const signals = new Map(); + + const value = toJSON(input, { + plugins: [ + createPlugin({ + tag: "seroval-plugins/socket/ref", + test: (value) => value instanceof SerializedRefClass, + parse: { + sync(value) { + const id = crypto.randomUUID(); + refs.set(id, value.handler); + return createSeriazliedRef({ scope, id }); + }, + }, + serialize: () => ``, + deserialize: () => ({} as any), + }), + createPlugin({ + tag: "seroval-plugins/socket/memo", + test: (value: any) => value instanceof SerializedMemoClass, + parse: { + sync(value) { + const id = crypto.randomUUID(); + signals.set(id, value.signal); + return createSeriazliedMemo({ scope, id }); + }, + }, + serialize: () => ``, + deserialize: () => ({} as any), + }), + createPlugin({ + tag: "seroval-plugins/socket/projection", + test: (value: any) => value instanceof SerializedProjectionClass, + parse: { + sync(store) { + const id = crypto.randomUUID(); + + const projection = createMemo( + ({ state, changes: _c }) => { + let changes = [] as Patch[]; + const s = immerProduce( + state, + store.mutation as any, + (patches) => { + changes.push(...patches); + } + ); + return { state: s, changes }; + }, + { state: store.init, changes: [] as Patch[] } + ); + + signals.set(id, () => projection().changes); + return createSeriazliedProjection({ + scope, + id, + initial: store.init, + }); + }, + }, + serialize: () => ``, + deserialize: () => ({} as any), + }), + ], + }); + + return { value, refs, signals }; +} + +export function deserializeReactivePayload( + value: SerovalJSON, + plugins: { + createSocketRefConsumer( + ref: SerializedRef + ): (...payload: I) => Promise; + createSocketMemoConsumer( + ref: SerializedMemo + ): Accessor; + createSocketProjectionConsumer( + ref: SerializedProjection + ): O; + } +) { + console.log({ value }); + return fromJSON(value, { + plugins: [ + createPlugin({ + tag: "seroval-plugins/socket/ref", + test: () => true, + parse: {}, + serialize: () => ``, + deserialize: plugins.createSocketRefConsumer, + }), + createPlugin({ + tag: "seroval-plugins/socket/memo", + test: () => true, + parse: {}, + serialize: () => ``, + deserialize: plugins.createSocketMemoConsumer, + }), + createPlugin({ + tag: "seroval-plugins/socket/projection", + test: () => true, + parse: {}, + serialize: () => ``, + deserialize: plugins.createSocketProjectionConsumer, + }), + ], + }); +} diff --git a/socket/lib/server.tsx b/socket/lib/server.tsx index 31021d5..7cacb61 100644 --- a/socket/lib/server.tsx +++ b/socket/lib/server.tsx @@ -1,28 +1,30 @@ -import { - createSeriazliedMemo, - createSeriazliedStore, - SerializedMemo, - SerializedReactiveThing, - SerializedRef, - SerializedStream, - SerializedThing, - WsMessage, - WsMessageDown, - WsMessageUp, -} from "./shared"; +import { parse as parseCookie } from "cookie-es"; +import type { Peer } from "crossws"; +import { applyPatches, Patch } from "immer"; +import { fromJSON, SerovalJSON, toJSON } from "seroval"; import { createContext, - createMemo, + createEffect, createRoot, createSignal, - observable, onCleanup, - untrack, useContext, } from "solid-js"; +import { createStore, produce } from "solid-js/store"; import { getManifest } from "vinxi/manifest"; -import type { Peer } from "crossws"; -import { parse as parseCookie } from "cookie-es"; +import { + deserializeReactivePayload, + serializeReactivePayload, +} from "./serializer"; +import { + SerializedMemo, + SerializedProjection, + SerializedRef, + SerializedStream, + WsMessage, + WsMessageDown, + WsMessageUp, +} from "./shared"; const peerCtx = createContext(); export const usePeer = () => { @@ -58,12 +60,15 @@ export type Endpoint = ( export type Endpoints = Record>; export class LiveSolidServer { - private closures = new Map void }>(); - observers = new Map(); + private closures = new Map< + string, + { refs?: Map; disposal: () => void } + >(); + observers = new Map void>(); constructor(public peer: Peer) {} - send(message: WsMessage>) { + send(message: WsMessage) { // console.log(`send`, message); this.peer.send(JSON.stringify(message)); } @@ -73,9 +78,9 @@ export class LiveSolidServer { this.create(message.id, message.name, message.input); } - if (message.type === "subscribe") { - this.subscribe(message.id, message.ref, message.path || ``); - } + // if (message.type === "subscribe") { + // this.subscribe(message.id, message.ref); + // } if (message.type === "dispose") { this.dispose(message.id); @@ -90,7 +95,7 @@ export class LiveSolidServer { } } - async create(id: string, name: string, input?: SerializedThing) { + async create(id: string, name: string, input?: SerovalJSON) { const [filepath, functionName] = name.split("#"); const module = await getManifest(import.meta.env.ROUTER_NAME).chunks[ filepath @@ -99,11 +104,16 @@ export class LiveSolidServer { if (!endpoint) throw new Error(`Endpoint ${name} not found`); - const { payload, disposal } = createRoot((disposal) => { + const { refs, disposal } = createRoot((disposal) => { const deserializedInput = - input?.__type === "memo" - ? createSocketMemoConsumer(input, this) - : input; + input && + deserializeReactivePayload(input, { + createSocketRefConsumer: (ref) => createSocketRefConsumer(ref, this), + createSocketMemoConsumer: (ref) => + createSocketMemoConsumer(ref, this), + createSocketProjectionConsumer: (ref) => + createSocketProjectionConsumer(ref, this), + }); let payload: any; peerCtx.Provider({ @@ -112,70 +122,29 @@ export class LiveSolidServer { children: () => (payload = endpoint(deserializedInput)), }); - return { payload, disposal }; - }); - - this.closures.set(id, { payload, disposal }); - - if (typeof payload === "function") { - if (payload.type === "memo") { - const value = createSeriazliedMemo({ - name, - scope: id, - initial: untrack(payload), - }); - this.send({ value, id, type: "value" }); - } else { - const value = createSeriazliedRef({ - name, - scope: id, - }); - this.send({ value, id, type: "value" }); - } - } else { - const value = Object.entries(payload).reduce((res, [name, value]) => { - return { - ...res, - [name]: - typeof value === "function" - ? // @ts-expect-error - value.type === "memo" - ? createSeriazliedMemo({ - name, - scope: id, - initial: untrack(() => value()), - }) - : // @ts-expect-error - value.type === "store-accessor" - ? createSeriazliedStore({ - name, - scope: id, - initial: untrack(() => value()), - }) - : createSeriazliedRef({ name, scope: id }) - : value, - }; - }, {} as Record); + const { refs, signals, value } = serializeReactivePayload(id, payload); this.send({ value, id, type: "value" }); - } + signals.forEach((signal, id) => { + createEffect(() => { + this.send({ value: toJSON(signal()), id, type: "value" }); + }); + }); + + return { refs, disposal }; + }); + this.closures.set(id, { refs, disposal }); } - async invoke(id: string, ref: SerializedRef, input: any[]) { - const closure = this.closures.get(ref.scope); - if (!closure) throw new Error(`Callable ${ref.scope} not found`); - const { payload } = closure; - - if (typeof payload === "function") { - const response = await payload(...input); - this.send({ id, value: response, type: "value" }); - } else { - const response = await payload[ref.name](...input); - this.send({ id, value: response, type: "value" }); - } + async invoke(id: string, ref: SerializedRef, input: SerovalJSON) { + const refFn = this.closures.get(ref.scope)!.refs!.get(ref.id)!; + const fnInput = fromJSON(input); + const arified = Array.isArray(fnInput) ? fnInput : [fnInput]; + const response = await refFn(...arified); + const value = toJSON(response); + this.send({ id, value, type: "value" }); } dispose(id: string) { - // console.log(`Disposing ${id}`); const closure = this.closures.get(id); if (closure) { closure.disposal(); @@ -183,29 +152,20 @@ export class LiveSolidServer { } } - subscribe(id: string, ref: SerializedReactiveThing, path: string) { - // console.log(`subscribe`, ref); + // subscribe(id: string, ref: SerializedReactiveThing) { + // const source = this.closures.get(ref.scope)!.refs!.get(ref.id)!; - const closure = this.closures.get(ref.scope); - if (!closure) throw new Error(`Callable ${ref.scope} not found`); - const { payload } = closure; + // const response$ = observable(() => source()); - const source = typeof payload === "function" ? payload : payload[ref.name]; + // const sub = response$.subscribe((payload) => { + // const value = toJSON(payload); + // this.send({ id, value, type: "value" }); + // }); - const response$ = observable(() => - ref.__type === "projection" - ? source[path] - : ref.__type === "store-accessor" - ? source()[path] - : source() - ); - const sub = response$.subscribe((value) => { - this.send({ id, value, type: "value" }); - }); - this.closures.set(id, { payload: sub, disposal: () => sub.unsubscribe() }); - } + // this.closures.set(id, { disposal: () => sub.unsubscribe() }); + // } - stream(stream: SerializedStream) {} + stream(stream: SerializedStream) {} cleanup() { for (const [key, closure] of this.closures.entries()) { @@ -216,62 +176,48 @@ export class LiveSolidServer { } } -function createSeriazliedRef( - opts: Omit -): SerializedRef { - return { ...opts, __type: "ref" }; -} - -export function createSocketFn( - fn: () => (i?: I) => O -): () => (i?: I) => Promise; - -export function createSocketFn( - fn: () => Record O> -): () => Record Promise>; +function createSocketRefConsumer( + ref: SerializedRef, + server: LiveSolidServer +) { + const inputSubId = crypto.randomUUID(); -export function createSocketFn( - fn: () => ((i: I) => O) | Record O> -): () => ((i: I) => Promise) | Record Promise> { - return fn as any; -} + return (...payload: I) => { + const input = toJSON(payload); -function createLazyMemo( - calc: (prev: T | undefined) => T, - value?: T -): () => T { - let isReading = false, - isStale: boolean | undefined = true; - - const [track, trigger] = createSignal(void 0, { equals: false }), - memo = createMemo( - (p) => (isReading ? calc(p) : ((isStale = !track()), p)), - value as T, - { equals: false } - ); + server.send({ type: "invoke", id: inputSubId, ref, input }); - return (): T => { - isReading = true; - if (isStale) isStale = trigger(); - const v = memo(); - isReading = false; - return v; + return new Promise((res) => { + server.observers.set(inputSubId, (value) => { + res(fromJSON(value)); + server.observers.delete(inputSubId); + }); + }); }; } -export function createSocketMemoConsumer( +function createSocketMemoConsumer( ref: SerializedMemo, server: LiveSolidServer ) { - const inputSubId = crypto.randomUUID(); + const [signal, setSignal] = createSignal(ref.initial); + server.observers.set(ref.id, (value) => setSignal(() => fromJSON(value))); + onCleanup(() => server.observers.delete(ref.id)); + return signal; +} - const memo = createLazyMemo(() => { - const [get, set] = createSignal(ref.initial!); - server.observers.set(inputSubId, set); - server.send({ type: "subscribe", id: inputSubId, ref }); - onCleanup(() => server.observers.delete(inputSubId)); - return get; +function createSocketProjectionConsumer( + ref: SerializedProjection, + server: LiveSolidServer +) { + const [store, setStore] = createStore(ref.initial!); + server.observers.set(ref.id, (patches) => { + setStore( + produce((draft) => { + applyPatches(draft, fromJSON(patches)); + }) + ); }); - - return () => memo()(); + onCleanup(() => server.observers.delete(ref.id)); + return store; } diff --git a/socket/lib/shared.tsx b/socket/lib/shared.tsx index 9a48391..e128e78 100644 --- a/socket/lib/shared.tsx +++ b/socket/lib/shared.tsx @@ -1,95 +1,97 @@ -import { createComputed, $PROXY } from "solid-js"; -import { createStore, produce } from "solid-js/store"; +import { SerovalJSON } from "seroval"; +import { $TRACK, $PROXY } from "solid-js"; +import { enablePatches } from "immer"; +enablePatches(); export type WsMessage = T & { id: string }; -export type WsMessageUp = +export type WsMessageUp = + // | { + // type: "subscribe"; + // ref: SerializedReactiveThing; + // } | { - type: "create"; - name: string; - input?: I; + type: "invoke"; + ref: SerializedRef; + input: SerovalJSON; } | { - type: "subscribe"; - ref: SerializedMemo; - path?: undefined; + type: "value"; + value: SerovalJSON; } | { - type: "subscribe"; - ref: SerializedProjection | SerializedStoreAccessor; - path: string; + type: "create"; + name: string; + input: SerovalJSON; } | { type: "dispose"; - } + }; + +export type WsMessageDown = + // | { + // type: "subscribe"; + // ref: SerializedReactiveThing; + // } | { type: "invoke"; ref: SerializedRef; - input?: I; + input: SerovalJSON; } | { type: "value"; - value: I; - }; - -export type WsMessageDown = - | { - type: "value"; - value: T; - } - | { - type: "subscribe"; - ref: SerializedMemo; - } - | { - type: "subscribe"; - ref: SerializedProjection; - path: string; + value: SerovalJSON; }; export type SerializedRef = { __type: "ref"; - name: string; + id: string; scope: string; }; +export class SerializedRefClass { + constructor(public handler: Function) {} +} export type SerializedMemo = { __type: "memo"; - name: string; + id: string; scope: string; initial?: O; }; +export class SerializedMemoClass { + constructor(public signal: Function) {} +} + export type SerializedProjection = { __type: "projection"; - name: string; + id: string; scope: string; initial?: O; }; -export type SerializedStoreAccessor = { - __type: "store-accessor"; - name: string; - scope: string; - initial?: O; -}; +export class SerializedProjectionClass { + constructor(public init: any, public mutation: Function) {} +} export type SerializedReactiveThing = | SerializedMemo - | SerializedProjection - | SerializedStoreAccessor; + | SerializedProjection; -export type SerializedThing = - | SerializedRef - | SerializedReactiveThing; +export type SerializedThing = SerializedRef | SerializedReactiveThing; -export type SerializedStream = { +export type SerializedStream = { __type: "stream"; - name: string; + id: string; scope: string; - value: O; }; +export function createSeriazliedRef( + opts: Omit +): SerializedRef { + return { ...opts, __type: "ref" }; +} + export function createSeriazliedMemo( opts: Omit ): SerializedMemo { @@ -102,34 +104,20 @@ export function createSeriazliedProjection( return { ...opts, __type: "projection" }; } -export function createSeriazliedStore( - opts: Omit -): SerializedStoreAccessor { - return { ...opts, __type: "store-accessor" }; +export function createSocketRef(source: F): F { + // @ts-expect-error + return new SerializedRefClass(source); } export function createSocketMemo(source: () => T): () => T | undefined { // @ts-expect-error - source.type = "memo"; - return source; + return new SerializedMemoClass(source); } -export function createSocketProjection( - storeOrMutation: (draft: T) => void, +export function createSocketProjection( + mutation: (draft: T) => void, init?: T ): T | undefined { // @ts-expect-error - const [store, setStore] = createStore(init || {}); - createComputed(() => setStore(produce(storeOrMutation))); - // @ts-expect-error - store.type = "projection"; - return store; -} - -export function createSocketStore( - storeAccessor: () => T -): T | undefined { - // @ts-expect-error - storeAccessor.type = "store-accessor"; - return storeAccessor as any; + return new SerializedProjectionClass(init, mutation); } diff --git a/socket/renderer/index.ts b/socket/renderer/index.ts new file mode 100644 index 0000000..d666e0b --- /dev/null +++ b/socket/renderer/index.ts @@ -0,0 +1,6 @@ +export type Node = + | string + | { + type: "div" | "p"; + children: Node[]; + }; diff --git a/src/components/todos.tsx b/src/components/todos.tsx index 8ed3567..09cf8d8 100644 --- a/src/components/todos.tsx +++ b/src/components/todos.tsx @@ -1,9 +1,9 @@ -import { createSignal, createMemo, Show, For } from "solid-js"; +import { createMemo, createSignal, For, Show } from "solid-js"; import { useServerTodos } from "~/lib/todos"; import { createClientEventLog, - createEventProjection, createEventComputed, + createEventProjection, } from "../../socket/events"; import { createSocketMemo } from "../../socket/lib/shared"; import { CompleteIcon, IncompleteIcon } from "./icons"; diff --git a/src/lib/counter.ts b/src/lib/counter.ts index 3310993..bbf8671 100644 --- a/src/lib/counter.ts +++ b/src/lib/counter.ts @@ -1,14 +1,17 @@ "use socket"; -import { createSocketMemo } from "../../socket/lib/shared"; -import { createPersistedSignal } from "../../socket/persisted"; -import { storage } from "./db"; - -const [count, setCount] = createPersistedSignal(storage, `count`, 0); +import { createSignal } from "solid-js"; +import { createSocketMemo, createSocketRef } from "../../socket/lib/shared"; export const useCounter = () => { + const [count, setCount] = createSignal(0); + const increment = () => setCount(count() + 1); const decrement = () => setCount(count() - 1); - return { count: createSocketMemo(count), increment, decrement }; + return { + count: createSocketMemo(count), + increment: createSocketRef(increment), + decrement: createSocketRef(decrement), + }; }; diff --git a/src/routes/index.tsx b/src/routes/index.tsx index a4a99aa..ac51c62 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -5,12 +5,17 @@ import { Invites } from "~/components/invites"; import { PresenceHost } from "~/components/presence"; import { TodoApp, TodosFilter } from "~/components/todos"; import { getUserId } from "~/lib/auth"; +import { useCounter } from "~/lib/counter"; export default function TodoAppPage(props: RouteSectionProps) { const userId = createAsync(() => getUserId()); + const counter = useCounter(); return ( <> +