├── tmp └── count ├── .data └── todos │ ├── counter │ └── data ├── src ├── global.d.ts ├── entry-client.tsx ├── lib │ ├── db.ts │ ├── counter.ts │ ├── auth.ts │ ├── presence.ts │ └── todos.ts ├── app.tsx ├── routes │ ├── [userId].tsx │ └── index.tsx ├── entry-server.tsx ├── components │ ├── icons.tsx │ ├── auth.tsx │ ├── invites.tsx │ ├── presence.tsx │ └── todos.tsx └── app.css ├── bun.lockb ├── public └── favicon.ico ├── socket ├── plugin │ ├── constants.js │ ├── server-runtime.ts │ ├── client-runtime.js │ ├── server-handler.ts │ ├── client.js │ └── server.js ├── imports │ ├── compiler │ │ ├── index.ts │ │ └── babel.ts │ ├── index.ts │ └── utils.ts ├── index.ts ├── events │ ├── socket.ts │ └── index.ts ├── persisted │ └── index.ts └── lib │ ├── shared.tsx │ ├── client.tsx │ └── server.tsx ├── app.config.ts ├── .gitignore ├── tsconfig.json ├── package.json └── README.md /tmp/count: -------------------------------------------------------------------------------- 1 | 14 -------------------------------------------------------------------------------- /.data/todos/counter: -------------------------------------------------------------------------------- 1 | 1 -------------------------------------------------------------------------------- /.data/todos/data: -------------------------------------------------------------------------------- 1 | [{"id":0,"title":"asdfsd","completed":false}] -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devagrawal09/solid-socket/HEAD/bun.lockb -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devagrawal09/solid-socket/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /socket/plugin/constants.js: -------------------------------------------------------------------------------- 1 | export const CLIENT_REFERENCES_MANIFEST = `use-socket-manifest.json`; 2 | -------------------------------------------------------------------------------- /socket/plugin/server-runtime.ts: -------------------------------------------------------------------------------- 1 | export function createServerReference(fn, id, name) { 2 | // console.log(`server runtime reference`, id, name); 3 | return fn; 4 | } 5 | -------------------------------------------------------------------------------- /src/entry-client.tsx: -------------------------------------------------------------------------------- 1 | // @refresh reload 2 | import { mount, StartClient } from "@solidjs/start/client"; 3 | 4 | mount(() => , document.getElementById("app")!); 5 | -------------------------------------------------------------------------------- /src/lib/db.ts: -------------------------------------------------------------------------------- 1 | import { createStorage } from "unstorage"; 2 | import fsDriver from "unstorage/drivers/fs"; 3 | 4 | export const storage = createStorage({ 5 | driver: fsDriver({ base: "./tmp" }), 6 | }); 7 | -------------------------------------------------------------------------------- /socket/plugin/client-runtime.js: -------------------------------------------------------------------------------- 1 | import { createEndpoint } from "../lib/client"; 2 | 3 | export function createServerReference(fn, id, name) { 4 | // console.log("createServerReference", id, name); 5 | return (input) => createEndpoint(`${id}#${name}`, input); 6 | } 7 | -------------------------------------------------------------------------------- /app.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "@solidjs/start/config"; 2 | import { client, router } from "./socket"; 3 | 4 | export default defineConfig({ 5 | ssr: false, 6 | server: { experimental: { websocket: true } }, 7 | vite: { plugins: [client()] }, 8 | }).addRouter(router); 9 | -------------------------------------------------------------------------------- /src/app.tsx: -------------------------------------------------------------------------------- 1 | import { Router } from "@solidjs/router"; 2 | import { FileRoutes } from "@solidjs/start/router"; 3 | import { Suspense } from "solid-js"; 4 | import "./app.css"; 5 | 6 | export default function App() { 7 | return ( 8 | {props.children}}> 9 | 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | dist 3 | .solid 4 | .output 5 | .vercel 6 | .netlify 7 | netlify 8 | .vinxi 9 | tmp 10 | 11 | # Environment 12 | .env 13 | .env*.local 14 | 15 | # dependencies 16 | /node_modules 17 | 18 | # IDEs and editors 19 | /.idea 20 | .project 21 | .classpath 22 | *.launch 23 | .settings/ 24 | 25 | # Temp 26 | gitignore 27 | 28 | # System Files 29 | .DS_Store 30 | Thumbs.db 31 | -------------------------------------------------------------------------------- /socket/imports/compiler/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import type babel from "@babel/core"; 3 | import { type FilterPattern } from "@rollup/pluginutils"; 4 | export { compilepImports } from "./babel"; 5 | 6 | export type ImportPluginOptions = { 7 | babel?: babel.TransformOptions; 8 | filter?: { 9 | include?: FilterPattern; 10 | exclude?: FilterPattern; 11 | }; 12 | log?: boolean; 13 | }; 14 | 15 | export * from "./babel"; 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "allowSyntheticDefaultImports": true, 7 | "esModuleInterop": true, 8 | "jsx": "preserve", 9 | "jsxImportSource": "solid-js", 10 | "allowJs": true, 11 | "strict": true, 12 | "noEmit": true, 13 | "types": ["vinxi/types/client"], 14 | "isolatedModules": true, 15 | "paths": { 16 | "~/*": ["./src/*"] 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/routes/[userId].tsx: -------------------------------------------------------------------------------- 1 | import { RouteSectionProps } from "@solidjs/router"; 2 | import { PresenceHost } from "~/components/presence"; 3 | import { TodoApp, TodosFilter } from "~/components/todos"; 4 | 5 | export default function TodoAppPage(props: RouteSectionProps) { 6 | return ( 7 | 8 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/counter.ts: -------------------------------------------------------------------------------- 1 | "use socket"; 2 | 3 | import { createSocketMemo } from "../../socket/lib/shared"; 4 | import { createPersistedSignal } from "../../socket/persisted"; 5 | import { storage } from "./db"; 6 | 7 | const [count, setCount] = createPersistedSignal(storage, `count`, 0); 8 | 9 | export const useCounter = () => { 10 | const increment = () => setCount(count() + 1); 11 | const decrement = () => setCount(count() - 1); 12 | 13 | return { count: createSocketMemo(count), increment, decrement }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/entry-server.tsx: -------------------------------------------------------------------------------- 1 | // @refresh reload 2 | import { createHandler, StartServer } from "@solidjs/start/server"; 3 | 4 | export default createHandler(() => ( 5 | ( 7 | 8 | 9 | 10 | 11 | 12 | {assets} 13 | 14 | 15 |
{children}
16 | {scripts} 17 | 18 | 19 | )} 20 | /> 21 | )); 22 | -------------------------------------------------------------------------------- /src/lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { cache, action } from "@solidjs/router"; 2 | import { getCookie, setCookie, deleteCookie } from "vinxi/http"; 3 | 4 | export const getUserId = cache(async () => { 5 | "use server"; 6 | 7 | const userId = getCookie(`userId`); 8 | 9 | return userId; 10 | }, `user`); 11 | 12 | export const login = action(async (formData: FormData) => { 13 | "use server"; 14 | 15 | const userId = formData.get("userId") as string; 16 | setCookie(`userId`, userId); 17 | 18 | return userId; 19 | }, `login`); 20 | 21 | export const logout = action(async () => { 22 | "use server"; 23 | 24 | deleteCookie(`userId`); 25 | }, `logout`); 26 | -------------------------------------------------------------------------------- /socket/index.ts: -------------------------------------------------------------------------------- 1 | import { normalize } from "vinxi/lib/path"; 2 | export { client } from "./plugin/client"; 3 | import { server } from "./plugin/server"; 4 | import { fileURLToPath } from "url"; 5 | import { importsPlugin } from "./imports"; 6 | 7 | export const router = { 8 | name: "socket-fns", 9 | type: "http", 10 | base: "/_ws", 11 | handler: "./socket/plugin/server-handler.ts", 12 | target: "server", 13 | plugins: () => [ 14 | server({ 15 | runtime: normalize( 16 | fileURLToPath( 17 | new URL("./socket/plugin/server-runtime.js", import.meta.url) 18 | ) 19 | ), 20 | }), 21 | importsPlugin(), 22 | ], 23 | }; 24 | -------------------------------------------------------------------------------- /socket/plugin/server-handler.ts: -------------------------------------------------------------------------------- 1 | import { eventHandler } from "vinxi/http"; 2 | import { LiveSolidServer } from "../lib/server"; 3 | import { WsMessage, WsMessageUp } from "../lib/shared"; 4 | 5 | const clients = new Map(); 6 | 7 | export default eventHandler({ 8 | handler() {}, 9 | websocket: { 10 | open(peer) { 11 | clients.set(peer.id, new LiveSolidServer(peer)); 12 | }, 13 | async message(peer, e) { 14 | const message = JSON.parse(e.text()) as WsMessage; 15 | const client = clients.get(peer.id); 16 | if (!client) return; 17 | client.handleMessage(message); 18 | }, 19 | async close(peer) { 20 | const client = clients.get(peer.id); 21 | if (!client) return; 22 | client.cleanup(); 23 | clients.delete(peer.id); 24 | }, 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /socket/events/socket.ts: -------------------------------------------------------------------------------- 1 | import type { Event, EventLog } from "."; 2 | 3 | export type LogId = string & { __brand?: "LogId" }; 4 | 5 | export function createServerEventLog( 6 | logKey: () => LogId | undefined, 7 | eventLogs: () => Record>, 8 | setEventLogs: (data: Record>) => void 9 | ) { 10 | async function appendEvent(event: Event, currentVersion: number) { 11 | const id = logKey(); 12 | if (!id) return; 13 | 14 | const log = eventLogs()[id]; 15 | if (!log) return setEventLogs({ ...eventLogs(), [id]: [event] }); 16 | 17 | if (log.length !== currentVersion) return; 18 | 19 | return setEventLogs({ 20 | ...eventLogs(), 21 | [id]: [...log, event], 22 | }); 23 | } 24 | 25 | return { 26 | serverEvents: () => eventLogs()[logKey()!], 27 | appendEvent, 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /src/components/icons.tsx: -------------------------------------------------------------------------------- 1 | export function IncompleteIcon() { 2 | return ( 3 | 9 | 17 | 18 | ); 19 | } 20 | 21 | export function CompleteIcon() { 22 | return ( 23 | 29 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /socket/imports/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import type { Plugin } from "vite"; 3 | import { compilepImports, type ImportPluginOptions } from "./compiler"; 4 | import { repushPlugin, getFilter } from "./utils"; 5 | 6 | export function importsPlugin(opts?: ImportPluginOptions): Plugin { 7 | const filter = getFilter(opts?.filter); 8 | const plugin: Plugin = { 9 | enforce: "pre", 10 | name: "imports", 11 | async transform(code, id) { 12 | if (!filter(id)) { 13 | return code; 14 | } 15 | if (id.endsWith(".ts") || id.endsWith(".tsx")) { 16 | return await compilepImports(code, id, opts); 17 | } 18 | return undefined; 19 | }, 20 | configResolved(config) { 21 | repushPlugin(config.plugins as Plugin[], plugin, [ 22 | "vite-server-references", 23 | "solid", 24 | "vinxi:routes", 25 | ]); 26 | }, 27 | }; 28 | return plugin; 29 | } 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "solid-socket", 3 | "type": "module", 4 | "description": "Signals meets WebSockets", 5 | "scripts": { 6 | "dev": "vinxi dev", 7 | "build": "vinxi build", 8 | "start": "vinxi start" 9 | }, 10 | "author": "Dev Agrawal (http://devagr.me/)", 11 | "dependencies": { 12 | "@kobalte/core": "^0.13.7", 13 | "@solid-primitives/memo": "^1.3.9", 14 | "@solid-primitives/mouse": "^2.0.20", 15 | "@solid-primitives/scheduled": "^1.4.4", 16 | "@solid-primitives/websocket": "^1.2.2", 17 | "@solidjs/router": "^0.14.10", 18 | "@solidjs/start": "^1.0.9", 19 | "rxjs": "^7.8.1", 20 | "solid-icons": "^1.1.0", 21 | "solid-js": "^1.9.2", 22 | "unique-names-generator": "^4.7.1", 23 | "unstorage": "1.10.2", 24 | "vinxi": "^0.4.3" 25 | }, 26 | "engines": { 27 | "node": ">=18" 28 | }, 29 | "devDependencies": { 30 | "@babel/preset-typescript": "^7.26.0", 31 | "@vinxi/plugin-directives": "^0.4.3", 32 | "babel-plugin-transform-import-paths": "^1.0.3", 33 | "vite-plugin-babel": "^1.2.0" 34 | } 35 | } -------------------------------------------------------------------------------- /socket/persisted/index.ts: -------------------------------------------------------------------------------- 1 | import { createSignal, createEffect, onCleanup, Accessor } from "solid-js"; 2 | import type { Storage, StorageValue } from "unstorage"; 3 | 4 | export function createPersistedSignal( 5 | storage: Storage, 6 | key: string 7 | ): readonly [Accessor, (v: T) => Promise]; 8 | export function createPersistedSignal( 9 | storage: Storage, 10 | key: string, 11 | init: T 12 | ): readonly [Accessor, (v: T) => Promise]; 13 | export function createPersistedSignal( 14 | storage: Storage, 15 | key: string, 16 | init?: T 17 | ) { 18 | const [value, _setValue] = createSignal(init); 19 | 20 | createEffect(() => { 21 | storage.getItem(key).then((v) => v && _setValue(() => v)); 22 | const unwatchPromise = storage.watch(async (e, k) => { 23 | if (k === key) { 24 | if (e === "update") { 25 | const v = await storage.getItem(key); 26 | v && value() !== v && _setValue(() => v); 27 | } 28 | if (e === "remove") { 29 | _setValue(() => init); 30 | } 31 | } 32 | }); 33 | 34 | onCleanup(() => unwatchPromise.then((unwatch) => unwatch())); 35 | }); 36 | 37 | const setValue = async (v: T) => { 38 | if (v === value()) return; 39 | _setValue(() => v); 40 | await storage.setItem(key, v); 41 | }; 42 | 43 | return [value, setValue] as const; 44 | } 45 | -------------------------------------------------------------------------------- /src/lib/presence.ts: -------------------------------------------------------------------------------- 1 | "use socket"; 2 | 3 | import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"; 4 | import { createSocketMemo } from "../../socket/lib/shared"; 5 | import { useCookies } from "../../socket/lib/server"; 6 | 7 | export type PresenceUser = { 8 | name: string; 9 | x: number; 10 | y: number; 11 | color: string; 12 | }; 13 | 14 | const [presenceDocs, setPresence] = createSignal< 15 | Record> 16 | >({}); 17 | 18 | export const usePresence = ( 19 | mousePos: () => { docId?: string; x: number; y: number } | undefined 20 | ) => { 21 | const { userId } = useCookies(); 22 | const color = Math.floor(Math.random() * 16777215).toString(16); 23 | 24 | createEffect(() => { 25 | const { docId = userId, x, y } = mousePos() || {}; 26 | x && 27 | y && 28 | setPresence((prev) => ({ 29 | ...prev, 30 | [docId]: { ...prev[docId], [userId]: { name: userId, x, y, color } }, 31 | })); 32 | }); 33 | 34 | onCleanup(() => { 35 | const { docId = userId } = mousePos() || {}; 36 | setPresence((prev) => { 37 | const { [userId]: _, ...rest } = prev[docId] || {}; 38 | return { ...prev, [docId]: rest }; 39 | }); 40 | }); 41 | 42 | const otherUsers = createMemo(() => { 43 | const docId = mousePos()?.docId || userId; 44 | const { [userId]: _, ...rest } = presenceDocs()[docId] || {}; 45 | return rest; 46 | }); 47 | 48 | return createSocketMemo(otherUsers); 49 | }; 50 | -------------------------------------------------------------------------------- /socket/plugin/client.js: -------------------------------------------------------------------------------- 1 | import { directives, shimExportsPlugin } from "@vinxi/plugin-directives"; 2 | import { fileURLToPath } from "url"; 3 | import { chunkify } from "vinxi/lib/chunks"; 4 | import { normalize } from "vinxi/lib/path"; 5 | 6 | import { CLIENT_REFERENCES_MANIFEST } from "./constants.js"; 7 | 8 | export function client({ 9 | runtime = normalize( 10 | fileURLToPath(new URL("./socket/plugin/client-runtime.js", import.meta.url)) 11 | ), 12 | manifest = CLIENT_REFERENCES_MANIFEST, 13 | } = {}) { 14 | const serverModules = new Set(); 15 | const clientModules = new Set(); 16 | return [ 17 | directives({ 18 | hash: chunkify, 19 | runtime, 20 | transforms: [ 21 | shimExportsPlugin({ 22 | runtime: { 23 | module: runtime, 24 | function: "createServerReference", 25 | }, 26 | onModuleFound: (mod) => { 27 | serverModules.add(mod); 28 | }, 29 | hash: chunkify, 30 | apply: (code, id, options) => { 31 | return !options.ssr; 32 | }, 33 | pragma: "use socket", 34 | }), 35 | ], 36 | onReference(type, reference) { 37 | if (type === "server") { 38 | serverModules.add(reference); 39 | } else { 40 | clientModules.add(reference); 41 | } 42 | }, 43 | }), 44 | { 45 | name: "references-manifest", 46 | generateBundle() { 47 | this.emitFile({ 48 | fileName: manifest, 49 | type: "asset", 50 | source: JSON.stringify({ 51 | server: [...serverModules], 52 | client: [...clientModules], 53 | }), 54 | }); 55 | }, 56 | }, 57 | ]; 58 | } 59 | -------------------------------------------------------------------------------- /socket/events/index.ts: -------------------------------------------------------------------------------- 1 | import { createWritableMemo } from "@solid-primitives/memo"; 2 | import { createComputed, createMemo } from "solid-js"; 3 | import { createStore, reconcile } from "solid-js/store"; 4 | 5 | export type EventId = string & { __brand?: "EventId" }; 6 | export type Event = T & { _id: EventId }; 7 | export type EventLog = Array>; 8 | 9 | export function createClientEventLog(serverLog: { 10 | serverEvents: () => EventLog | undefined; 11 | appendEvent: (event: Event, currentVersion: number) => Promise; 12 | }) { 13 | const [clientEvents, setClientEvents] = createWritableMemo>( 14 | (c = []) => [ 15 | ...c, 16 | ...(serverLog.serverEvents() || []).filter( 17 | (e) => !c.find((ce) => ce._id === e._id) 18 | ), 19 | ] 20 | ); 21 | 22 | async function appendEvent(e: E) { 23 | const _id = crypto.randomUUID() as EventId; 24 | const currentVersion = clientEvents().length; 25 | const event = { ...e, _id }; 26 | setClientEvents((b) => [...b, event]); 27 | await serverLog.appendEvent(event, currentVersion); 28 | } 29 | 30 | return { appendEvent, events: clientEvents }; 31 | } 32 | 33 | export function createEventProjection( 34 | eventLog: () => EventLog, 35 | reducer: (acc: S, e: E) => S, 36 | init: S 37 | ) { 38 | const [projection, setProjection] = createStore(structuredClone(init)); 39 | createComputed(() => 40 | setProjection(reconcile(eventLog().reduce(reducer, structuredClone(init)))) 41 | ); 42 | return projection; 43 | } 44 | 45 | export function createEventComputed( 46 | eventLog: () => EventLog, 47 | reducer: (acc: S, e: E) => S, 48 | init: S 49 | ) { 50 | return createMemo(() => eventLog().reduce(reducer, init)); 51 | } 52 | -------------------------------------------------------------------------------- /socket/imports/utils.ts: -------------------------------------------------------------------------------- 1 | import { createFilter, FilterPattern } from "@rollup/pluginutils"; 2 | import { Plugin } from "vite"; 3 | 4 | export const DEFAULT_INCLUDE = "{src,socket}/**/*.{jsx,tsx,ts,js,mjs,cjs}"; 5 | export const DEFAULT_EXCLUDE = "node_modules/**/*.{jsx,tsx,ts,js,mjs,cjs}"; 6 | 7 | export function getFileName(_filename: string): string { 8 | if (_filename.includes("?")) { 9 | // might be useful for the future 10 | const [actualId] = _filename.split("?"); 11 | return actualId; 12 | } 13 | return _filename; 14 | } 15 | 16 | export const getFilter = (f?: { 17 | include?: FilterPattern; 18 | exclude?: FilterPattern; 19 | }) => { 20 | const filter = createFilter( 21 | f?.include ?? DEFAULT_INCLUDE, 22 | f?.exclude ?? DEFAULT_EXCLUDE 23 | ); 24 | return (id: string) => { 25 | const actualName = getFileName(id); 26 | return filter(actualName); 27 | }; 28 | }; 29 | 30 | // From: https://github.com/bluwy/whyframe/blob/master/packages/jsx/src/index.js#L27-L37 31 | export function repushPlugin( 32 | plugins: Plugin[], 33 | plugin: Plugin | string, 34 | pluginNames: string[] 35 | ): void { 36 | const namesSet = new Set(pluginNames); 37 | const name = typeof plugin === "string" ? plugin : plugin.name; 38 | const currentPlugin = plugins.find((e) => e.name === name)!; 39 | let baseIndex = -1; 40 | let targetIndex = -1; 41 | for (let i = 0, len = plugins.length; i < len; i += 1) { 42 | const current = plugins[i]; 43 | if (namesSet.has(current.name) && baseIndex === -1) { 44 | baseIndex = i; 45 | } 46 | if (current.name === name) { 47 | targetIndex = i; 48 | } 49 | } 50 | if (baseIndex !== -1 && targetIndex !== -1 && baseIndex < targetIndex) { 51 | plugins.splice(targetIndex, 1); 52 | plugins.splice(baseIndex, 0, currentPlugin); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { createAsync, type RouteSectionProps } from "@solidjs/router"; 2 | import { Show } from "solid-js"; 3 | import { Login, Logout } from "~/components/auth"; 4 | import { Invites } from "~/components/invites"; 5 | import { PresenceHost } from "~/components/presence"; 6 | import { TodoApp, TodosFilter } from "~/components/todos"; 7 | import { getUserId } from "~/lib/auth"; 8 | 9 | export default function TodoAppPage(props: RouteSectionProps) { 10 | const userId = createAsync(() => getUserId()); 11 | 12 | return ( 13 | <> 14 | 18 |

Welcome to Solid Socket

19 |

20 | The easiest and most powerful way to build realtime applications 21 | on top of SolidStart. 22 |

23 | 27 | Read the docs 28 | 29 | 30 |

Demo

31 |

32 | A realtime and multiplayer todo application. Login with any 33 | username to access the todo list, and invite your friends to 34 | collaborate with you! 35 |

36 |

37 | Or if you don't have friends, you can collaborate with yourself! 38 | Just open multiple windows using different browsers or devices and 39 | login using a different username. 40 |

41 | 42 | 43 | } 44 | > 45 | 46 | 47 | 48 | 49 | 50 |
51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/components/auth.tsx: -------------------------------------------------------------------------------- 1 | import { login, logout } from "~/lib/auth"; 2 | 3 | export function Login() { 4 | return ( 5 |
14 | 31 | 49 |
50 | ); 51 | } 52 | 53 | export function Logout() { 54 | return ( 55 |
64 | 81 |
82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /src/lib/todos.ts: -------------------------------------------------------------------------------- 1 | "use socket"; 2 | 3 | import { createServerEventLog } from "../../socket/events/socket"; 4 | import { useCookies } from "../../socket/lib/server"; 5 | import { createSocketMemo } from "../../socket/lib/shared"; 6 | import { EventLog } from "../../socket/events"; 7 | import { createPersistedSignal } from "../../socket/persisted"; 8 | import { storage } from "./db"; 9 | 10 | export type TodoCreated = { 11 | type: "todo-added"; 12 | id: number; 13 | title: string; 14 | }; 15 | export type TodoToggled = { 16 | type: "todo-toggled"; 17 | id: number; 18 | }; 19 | export type TodoEdited = { 20 | type: "todo-edited"; 21 | id: number; 22 | title: string; 23 | }; 24 | export type TodoDeleted = { 25 | type: "todo-deleted"; 26 | id: number; 27 | }; 28 | export type TodoEvent = TodoCreated | TodoToggled | TodoEdited | TodoDeleted; 29 | 30 | const [todoLogs, setTodoLogs] = createPersistedSignal< 31 | Record> 32 | >(storage, `todos-logs`, {}); 33 | 34 | export const useServerTodos = (_listId?: () => string | undefined) => { 35 | const cookies = useCookies<{ userId?: string }>(); 36 | 37 | const listId = () => 38 | _listId?.() && invites()[cookies.userId!]?.includes(_listId()!) 39 | ? _listId()! 40 | : cookies.userId; 41 | 42 | const { serverEvents, appendEvent } = createServerEventLog( 43 | listId, 44 | todoLogs, 45 | setTodoLogs 46 | ); 47 | 48 | return { 49 | serverEvents: createSocketMemo(serverEvents), 50 | appendEvent, 51 | }; 52 | }; 53 | 54 | const [invites, setInvites] = createPersistedSignal>( 55 | storage, 56 | `invites`, 57 | {} 58 | ); 59 | 60 | export const useInvites = () => { 61 | const cookies = useCookies<{ userId: string }>(); 62 | 63 | const addInvite = (invite: string) => { 64 | setInvites({ 65 | ...invites(), 66 | [invite]: [...(invites()[invite] || []), cookies.userId], 67 | }); 68 | }; 69 | 70 | const removeInvite = (invite: string) => { 71 | const newInvites = { ...invites() }; 72 | newInvites[invite] = newInvites[invite].filter((i) => i !== cookies.userId); 73 | setInvites(newInvites); 74 | }; 75 | 76 | return { 77 | inviteds: createSocketMemo(() => 78 | Object.entries(invites()) 79 | .filter(([, invites]) => invites.includes(cookies.userId)) 80 | .map(([invitee]) => invitee) 81 | ), 82 | invites: createSocketMemo(() => invites()[cookies.userId] || []), 83 | addInvite, 84 | removeInvite, 85 | }; 86 | }; 87 | -------------------------------------------------------------------------------- /socket/plugin/server.js: -------------------------------------------------------------------------------- 1 | import { directives, wrapExportsPlugin } from "@vinxi/plugin-directives"; 2 | import { readFileSync } from "fs"; 3 | import { fileURLToPath } from "url"; 4 | import { chunkify } from "vinxi/lib/chunks"; 5 | import { handlerModule, join, normalize } from "vinxi/lib/path"; 6 | 7 | import { CLIENT_REFERENCES_MANIFEST } from "./constants.js"; 8 | 9 | export function serverTransform({ runtime }) { 10 | return directives({ 11 | hash: chunkify, 12 | runtime: runtime, 13 | transforms: [ 14 | wrapExportsPlugin({ 15 | runtime: { 16 | module: runtime, 17 | function: "createServerReference", 18 | }, 19 | hash: chunkify, 20 | apply: (code, id, options) => { 21 | return options.ssr; 22 | }, 23 | pragma: "use socket", 24 | }), 25 | ], 26 | onReference(type, reference) {}, 27 | }); 28 | } 29 | 30 | /** 31 | * 32 | * @returns {import('vinxi').Plugin} 33 | */ 34 | export const serverBuild = ({ client, manifest }) => { 35 | let input; 36 | return { 37 | name: "server-functions:build", 38 | enforce: "post", 39 | apply: "build", 40 | config(config, env) { 41 | // @ts-ignore 42 | const router = config.router; 43 | // @ts-ignore 44 | const app = config.app; 45 | 46 | const rscRouter = app.getRouter(client); 47 | 48 | const serverFunctionsManifest = JSON.parse( 49 | readFileSync(join(rscRouter.outDir, rscRouter.base, manifest), "utf-8") 50 | ); 51 | 52 | input = { 53 | entry: handlerModule(router), 54 | ...Object.fromEntries( 55 | serverFunctionsManifest.server.map((key) => { 56 | return [chunkify(key), key]; 57 | }) 58 | ), 59 | }; 60 | 61 | return { 62 | build: { 63 | rollupOptions: { 64 | output: { 65 | chunkFileNames: "[name].mjs", 66 | entryFileNames: "[name].mjs", 67 | }, 68 | treeshake: true, 69 | }, 70 | }, 71 | }; 72 | }, 73 | 74 | configResolved(config) { 75 | config.build.rollupOptions.input = input; 76 | }, 77 | }; 78 | }; 79 | 80 | /** 81 | * 82 | * @returns {import('vinxi').Plugin[]} 83 | */ 84 | export function server({ 85 | client = "client", 86 | manifest = CLIENT_REFERENCES_MANIFEST, 87 | runtime = normalize( 88 | fileURLToPath(new URL("./server-runtime.js", import.meta.url)) 89 | ), 90 | } = {}) { 91 | return [serverTransform({ runtime }), serverBuild({ client, manifest })]; 92 | } 93 | -------------------------------------------------------------------------------- /src/components/invites.tsx: -------------------------------------------------------------------------------- 1 | import { A } from "@solidjs/router"; 2 | import { For, Show } from "solid-js"; 3 | import { useInvites } from "~/lib/todos"; 4 | 5 | export function Invites() { 6 | const serverInvites = useInvites(); 7 | 8 | return ( 9 |
16 |

Invite someone to your list!

17 |
{ 19 | e.preventDefault(); 20 | serverInvites.addInvite( 21 | new FormData(e.currentTarget).get("invite") as string 22 | ); 23 | e.currentTarget.reset(); 24 | }} 25 | > 26 | 42 |
43 | 44 |

Invited

45 | 46 | {(invite) => ( 47 |
48 | {invite} 49 | 67 |
68 | )} 69 |
70 |
71 | 72 |

Invites

73 | 74 | {(invite) => ( 75 |
76 | {invite} 77 |
78 | )} 79 |
80 |
81 |
82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /socket/lib/shared.tsx: -------------------------------------------------------------------------------- 1 | import { createComputed, $PROXY } from "solid-js"; 2 | import { createStore, produce } from "solid-js/store"; 3 | 4 | export type WsMessage = T & { id: string }; 5 | 6 | export type WsMessageUp = 7 | | { 8 | type: "create"; 9 | name: string; 10 | input?: I; 11 | } 12 | | { 13 | type: "subscribe"; 14 | ref: SerializedMemo; 15 | path?: undefined; 16 | } 17 | | { 18 | type: "subscribe"; 19 | ref: SerializedProjection | SerializedStoreAccessor; 20 | path: string; 21 | } 22 | | { 23 | type: "dispose"; 24 | } 25 | | { 26 | type: "invoke"; 27 | ref: SerializedRef; 28 | input?: I; 29 | } 30 | | { 31 | type: "value"; 32 | value: I; 33 | }; 34 | 35 | export type WsMessageDown = 36 | | { 37 | type: "value"; 38 | value: T; 39 | } 40 | | { 41 | type: "subscribe"; 42 | ref: SerializedMemo; 43 | } 44 | | { 45 | type: "subscribe"; 46 | ref: SerializedProjection; 47 | path: string; 48 | }; 49 | 50 | export type SerializedRef = { 51 | __type: "ref"; 52 | name: string; 53 | scope: string; 54 | }; 55 | 56 | export type SerializedMemo = { 57 | __type: "memo"; 58 | name: string; 59 | scope: string; 60 | initial?: O; 61 | }; 62 | 63 | export type SerializedProjection = { 64 | __type: "projection"; 65 | name: string; 66 | scope: string; 67 | initial?: O; 68 | }; 69 | 70 | export type SerializedStoreAccessor = { 71 | __type: "store-accessor"; 72 | name: string; 73 | scope: string; 74 | initial?: O; 75 | }; 76 | 77 | export type SerializedReactiveThing = 78 | | SerializedMemo 79 | | SerializedProjection 80 | | SerializedStoreAccessor; 81 | 82 | export type SerializedThing = 83 | | SerializedRef 84 | | SerializedReactiveThing; 85 | 86 | export type SerializedStream = { 87 | __type: "stream"; 88 | name: string; 89 | scope: string; 90 | value: O; 91 | }; 92 | 93 | export function createSeriazliedMemo( 94 | opts: Omit 95 | ): SerializedMemo { 96 | return { ...opts, __type: "memo" }; 97 | } 98 | 99 | export function createSeriazliedProjection( 100 | opts: Omit 101 | ): SerializedProjection { 102 | return { ...opts, __type: "projection" }; 103 | } 104 | 105 | export function createSeriazliedStore( 106 | opts: Omit 107 | ): SerializedStoreAccessor { 108 | return { ...opts, __type: "store-accessor" }; 109 | } 110 | 111 | export function createSocketMemo(source: () => T): () => T | undefined { 112 | // @ts-expect-error 113 | source.type = "memo"; 114 | return source; 115 | } 116 | 117 | export function createSocketProjection( 118 | storeOrMutation: (draft: T) => void, 119 | init?: T 120 | ): T | undefined { 121 | // @ts-expect-error 122 | const [store, setStore] = createStore(init || {}); 123 | createComputed(() => setStore(produce(storeOrMutation))); 124 | // @ts-expect-error 125 | store.type = "projection"; 126 | return store; 127 | } 128 | 129 | export function createSocketStore( 130 | storeAccessor: () => T 131 | ): T | undefined { 132 | // @ts-expect-error 133 | storeAccessor.type = "store-accessor"; 134 | return storeAccessor as any; 135 | } 136 | -------------------------------------------------------------------------------- /socket/imports/compiler/babel.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import * as babel from "@babel/core"; 3 | import { ImportPluginOptions } from "."; 4 | 5 | const specificRootImports = [ 6 | "createMemo", 7 | "createRoot", 8 | "createSignal", 9 | "createEffect", 10 | "from", 11 | "observable", 12 | "untrack", 13 | "onCleanup", 14 | ]; 15 | 16 | const specificStoreImports = ["createStore", "produce"]; 17 | 18 | export function createTransform$(opts?: ImportPluginOptions) { 19 | return function transform$({ 20 | types: t, 21 | template: temp, 22 | }: { 23 | types: typeof babel.types; 24 | template: typeof babel.template; 25 | }): babel.PluginObj { 26 | return { 27 | visitor: { 28 | ImportDeclaration(path) { 29 | if (path.node.source.value === "solid-js") { 30 | const specificSpecifiers = path.node.specifiers.filter( 31 | (specifier) => 32 | t.isImportSpecifier(specifier) && 33 | specificRootImports.includes((specifier.imported as any).name) 34 | ); 35 | const otherSpecifiers = path.node.specifiers.filter( 36 | (specifier) => 37 | t.isImportSpecifier(specifier) && 38 | !specificRootImports.includes((specifier.imported as any).name) 39 | ); 40 | if (specificSpecifiers.length > 0) { 41 | const newImportDeclaration = t.importDeclaration( 42 | specificSpecifiers, 43 | t.stringLiteral("solid-js/dist/solid.cjs") 44 | ); 45 | path.insertAfter(newImportDeclaration); 46 | if (otherSpecifiers.length > 0) { 47 | path.node.specifiers = otherSpecifiers; 48 | } else { 49 | path.remove(); 50 | } 51 | } 52 | } else if (path.node.source.value === "solid-js/store") { 53 | const specificSpecifiers = path.node.specifiers.filter( 54 | (specifier) => 55 | t.isImportSpecifier(specifier) && 56 | specificStoreImports.includes((specifier.imported as any).name) 57 | ); 58 | const otherSpecifiers = path.node.specifiers.filter( 59 | (specifier) => 60 | t.isImportSpecifier(specifier) && 61 | !specificStoreImports.includes((specifier.imported as any).name) 62 | ); 63 | if (specificSpecifiers.length > 0) { 64 | const newImportDeclaration = t.importDeclaration( 65 | specificSpecifiers, 66 | t.stringLiteral("solid-js/store/dist/store") 67 | ); 68 | path.insertAfter(newImportDeclaration); 69 | if (otherSpecifiers.length > 0) { 70 | path.node.specifiers = otherSpecifiers; 71 | } else { 72 | path.remove(); 73 | } 74 | } 75 | } 76 | }, 77 | }, 78 | }; 79 | }; 80 | } 81 | 82 | export async function compilepImports( 83 | code: string, 84 | id: string, 85 | opts?: ImportPluginOptions 86 | ) { 87 | try { 88 | const plugins: babel.ParserOptions["plugins"] = ["typescript", "jsx"]; 89 | const transform$ = createTransform$(opts); 90 | const transformed = await babel.transformAsync(code, { 91 | presets: [["@babel/preset-typescript"], ...(opts?.babel?.presets ?? [])], 92 | parserOpts: { 93 | plugins, 94 | }, 95 | plugins: [[transform$], ...(opts?.babel?.plugins ?? [])], 96 | filename: id, 97 | }); 98 | if (transformed) { 99 | if (opts?.log) { 100 | console.log( 101 | `\n`, 102 | id, 103 | `\n`, 104 | transformed.code?.split(`\n`).splice(0, 10).join(`\n`) 105 | ); 106 | } 107 | return { 108 | code: transformed.code ?? "", 109 | map: transformed.map, 110 | }; 111 | } 112 | return null; 113 | } catch (e) { 114 | console.error("err$$", e); 115 | return null; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/components/presence.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip } from "@kobalte/core/tooltip"; 2 | import { 3 | useMousePosition, 4 | createPositionToElement, 5 | } from "@solid-primitives/mouse"; 6 | import { 7 | createComputed, 8 | For, 9 | createSignal, 10 | createEffect, 11 | ParentProps, 12 | } from "solid-js"; 13 | import { createStore, reconcile } from "solid-js/store"; 14 | import { usePresence, PresenceUser } from "~/lib/presence"; 15 | import { createSocketMemo } from "../../socket/lib/shared"; 16 | import { throttle } from "@solid-primitives/scheduled"; 17 | import { RiDevelopmentCursorLine } from "solid-icons/ri"; 18 | 19 | export function PresenceHost(props: ParentProps<{ docId?: string }>) { 20 | let ref: HTMLElement | undefined; 21 | const mousePos = createDebouncedMousePos(() => ref); 22 | const userPos = () => ({ docId: props.docId, ...mousePos() }); 23 | const users = usePresence(createSocketMemo(userPos)); 24 | const [presenceStore, setPresenceStore] = createStore([]); 25 | 26 | createComputed(() => 27 | setPresenceStore(reconcile(Object.values(users() || {}))) 28 | ); 29 | 30 | return ( 31 | <> 32 |

33 | If another user has this list open and you don't see their cursor, 34 | please ask them to refresh! This is a known bug that will be fixed soon. 35 |

36 |
37 | 38 | {(user) => { 39 | return ( 40 | 41 | 42 |
57 | {user.name 58 | .split(" ") 59 | .map((n) => n[0]) 60 | .join("")} 61 |
62 |
63 | 64 | 72 | 73 | {user.name} 74 | 75 | 76 |
77 | ); 78 | }} 79 |
80 |
81 |
82 | 83 | {(user) => { 84 | return ( 85 |
95 | 96 |
97 | ); 98 | }} 99 |
100 | {props.children} 101 |
102 | 103 | ); 104 | } 105 | 106 | function createDebouncedMousePos(ref: () => HTMLElement | undefined) { 107 | const pos = useMousePosition(); 108 | const relative = createPositionToElement(ref, () => pos); 109 | const [debouncedPos, setDebouncedPos] = createSignal<{ 110 | x: number; 111 | y: number; 112 | }>({ 113 | x: 0, 114 | y: 0, 115 | }); 116 | const trigger = throttle( 117 | (pos: { x: number; y: number }) => setDebouncedPos(pos), 118 | 10 119 | ); 120 | createEffect(() => { 121 | const { x, y } = relative; 122 | x && y && trigger({ x, y }); 123 | }); 124 | return debouncedPos; 125 | } 126 | -------------------------------------------------------------------------------- /socket/lib/client.tsx: -------------------------------------------------------------------------------- 1 | import { from as rxFrom, Observable } from "rxjs"; 2 | import { 3 | createSeriazliedMemo, 4 | SerializedMemo, 5 | SerializedProjection, 6 | SerializedRef, 7 | SerializedStoreAccessor, 8 | SerializedThing, 9 | WsMessage, 10 | WsMessageDown, 11 | WsMessageUp, 12 | } from "./shared"; 13 | import { 14 | Accessor, 15 | createComputed, 16 | createEffect, 17 | createMemo, 18 | createSignal, 19 | from, 20 | getListener, 21 | onCleanup, 22 | untrack, 23 | } from "solid-js"; 24 | import { createAsync } from "@solidjs/router"; 25 | import { createLazyMemo } from "@solid-primitives/memo"; 26 | import { createCallback } from "@solid-primitives/rootless"; 27 | import { createWS } from "@solid-primitives/websocket"; 28 | 29 | const protocol = window.location.protocol === "https:" ? "wss" : "ws"; 30 | const wsUrl = `${protocol}://${window.location.hostname}:${window.location.port}/_ws`; 31 | const getWs = createLazyMemo(() => createWS(wsUrl)); 32 | 33 | export type Listener = (ev: { data: any }) => any; 34 | export type SimpleWs = { 35 | removeEventListener(type: "message", listener: Listener): void; 36 | addEventListener(type: "message", listener: Listener): void; 37 | send(data: string): void; 38 | }; 39 | 40 | function wsRpc(message: WsMessageUp) { 41 | const ws = getWs(); 42 | const id = crypto.randomUUID() as string; 43 | 44 | return new Promise<{ value: T; dispose: () => void }>(async (res, rej) => { 45 | function dispose() { 46 | ws.send( 47 | JSON.stringify({ type: "dispose", id } satisfies WsMessage) 48 | ); 49 | } 50 | 51 | function handler(event: { data: string }) { 52 | // console.log(`handler ${id}`, message, { data: event.data }); 53 | const data = JSON.parse(event.data) as WsMessage>; 54 | if (data.id === id && data.type === "value") { 55 | res({ value: data.value, dispose }); 56 | ws.removeEventListener("message", handler); 57 | } 58 | } 59 | 60 | ws.addEventListener("message", handler); 61 | ws.send( 62 | JSON.stringify({ ...message, id } satisfies WsMessage) 63 | ); 64 | }); 65 | } 66 | 67 | function wsSub(message: WsMessageUp) { 68 | const ws = getWs(); 69 | const id = crypto.randomUUID(); 70 | 71 | return rxFrom( 72 | new Observable((obs) => { 73 | // console.log(`attaching sub handler`); 74 | function handler(event: { data: string }) { 75 | const data = JSON.parse(event.data) as WsMessage>; 76 | // console.log(`data`, data, id); 77 | if (data.id === id && data.type === "value") obs.next(data.value); 78 | } 79 | 80 | ws.addEventListener("message", handler); 81 | ws.send( 82 | JSON.stringify({ ...message, id } satisfies WsMessage) 83 | ); 84 | 85 | return () => { 86 | // console.log(`detaching sub handler`); 87 | ws.removeEventListener("message", handler); 88 | }; 89 | }) 90 | ); 91 | } 92 | 93 | export function createRef(ref: SerializedRef) { 94 | return (...input: any[]) => 95 | wsRpc({ 96 | type: "invoke", 97 | ref, 98 | input, 99 | }).then(({ value }) => value); 100 | } 101 | 102 | export function createSocketMemoConsumer(ref: SerializedMemo) { 103 | // console.log({ ref }); 104 | const memo = createLazyMemo( 105 | () => 106 | from( 107 | wsSub({ 108 | type: "subscribe", 109 | ref, 110 | }) 111 | ), 112 | () => ref.initial 113 | ); 114 | 115 | return () => { 116 | const memoValue = memo()(); 117 | // console.log({ memoValue }); 118 | return memoValue; 119 | }; 120 | } 121 | 122 | export function createSocketProjectionConsumer( 123 | ref: SerializedProjection | SerializedStoreAccessor 124 | ) { 125 | const nodes = [] as { path: string; accessor: Accessor }[]; 126 | 127 | function getNode(path: string) { 128 | const node = nodes.find((node) => node.path === path); 129 | if (node) return node; 130 | const newNode = { 131 | path, 132 | accessor: from(wsSub({ type: "subscribe", ref, path })), 133 | }; 134 | nodes.push(newNode); 135 | return newNode; 136 | } 137 | 138 | // @ts-expect-error 139 | return new Proxy(ref.initial || {}, { 140 | get(target, path: string) { 141 | return getListener() 142 | ? getNode(path).accessor() 143 | : ((target as any)[path] as O); 144 | }, 145 | }); 146 | } 147 | 148 | type SerializedValue = SerializedThing | Record; 149 | 150 | const deserializeValue = (value: SerializedValue) => { 151 | if (value.__type === "ref") { 152 | return createRef(value); 153 | } else if (value.__type === "memo") { 154 | return createSocketMemoConsumer(value); 155 | } else if (value.__type === "projection") { 156 | return createSocketProjectionConsumer(value); 157 | } else { 158 | return Object.entries(value).reduce((res, [name, value]) => { 159 | return { 160 | ...res, 161 | [name]: 162 | value.__type === "ref" 163 | ? createRef(value) 164 | : value.__type === "memo" 165 | ? createSocketMemoConsumer(value) 166 | : value.__type === "projection" 167 | ? createSocketProjectionConsumer(value) 168 | : value.__type === "store-accessor" 169 | ? createSocketProjectionConsumer(value) 170 | : value, 171 | }; 172 | }, {} as any); 173 | } 174 | }; 175 | 176 | export function createEndpoint(name: string, input?: any) { 177 | const inputScope = crypto.randomUUID(); 178 | const serializedInput = 179 | input?.type === "memo" 180 | ? createSeriazliedMemo({ 181 | name: `input`, 182 | scope: inputScope, 183 | initial: untrack(input), 184 | }) 185 | : input; 186 | // console.log({ serializedInput }); 187 | 188 | const scopePromise = wsRpc({ 189 | type: "create", 190 | name, 191 | input: serializedInput, 192 | }); 193 | 194 | if (input?.type === "memo") { 195 | const [inputSignal, setInput] = createSignal(input()); 196 | createComputed(() => setInput(input())); 197 | 198 | const onSubscribe = createCallback( 199 | (ws: SimpleWs, data: WsMessage>) => { 200 | createEffect(() => { 201 | const value = inputSignal(); 202 | // console.log(`sending input update to server`, value, input); 203 | ws.send( 204 | JSON.stringify({ 205 | type: "value", 206 | id: data.id, 207 | value, 208 | } satisfies WsMessage) 209 | ); 210 | }); 211 | } 212 | ); 213 | 214 | const ws = getWs(); 215 | function handler(event: { data: string }) { 216 | const data = JSON.parse(event.data) as WsMessage>; 217 | 218 | if (data.type === "subscribe" && data.ref.scope === inputScope) { 219 | onSubscribe(ws, data); 220 | } 221 | } 222 | ws.addEventListener("message", handler); 223 | onCleanup(() => ws.removeEventListener("message", handler)); 224 | } 225 | 226 | onCleanup(() => { 227 | // console.log(`cleanup endpoint`); 228 | scopePromise.then(({ dispose }) => dispose()); 229 | }); 230 | 231 | const scope = createAsync(() => scopePromise); 232 | const deserializedScope = createMemo( 233 | () => scope() && deserializeValue(scope()!.value) 234 | ); 235 | 236 | return new Proxy((() => {}) as any, { 237 | get(_, path) { 238 | const res = deserializedScope()?.[path]; 239 | return res || (() => {}); 240 | }, 241 | apply(_, __, args) { 242 | const res = deserializedScope()?.(...args); 243 | return res; 244 | }, 245 | }); 246 | } 247 | -------------------------------------------------------------------------------- /src/components/todos.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal, createMemo, Show, For } from "solid-js"; 2 | import { useServerTodos } from "~/lib/todos"; 3 | import { 4 | createClientEventLog, 5 | createEventProjection, 6 | createEventComputed, 7 | } from "../../socket/events"; 8 | import { createSocketMemo } from "../../socket/lib/shared"; 9 | import { CompleteIcon, IncompleteIcon } from "./icons"; 10 | 11 | export type TodosFilter = "all" | "active" | "completed" | undefined; 12 | 13 | export type Todo = { 14 | id: number; 15 | title: string; 16 | completed: boolean; 17 | }; 18 | 19 | export function TodoApp(props: { filter: TodosFilter; listId?: string }) { 20 | const filter = () => props.filter; 21 | 22 | const [editingTodoId, setEditingId] = createSignal(); 23 | 24 | const setEditing = ({ 25 | id, 26 | pending, 27 | }: { 28 | id?: number; 29 | pending?: () => boolean; 30 | }) => { 31 | if (!pending || !pending()) setEditingId(id); 32 | }; 33 | let inputRef!: HTMLInputElement; 34 | 35 | const serverTodos = useServerTodos(createSocketMemo(() => props.listId)); 36 | const { events, appendEvent } = createClientEventLog(serverTodos); 37 | const todos = createEventProjection( 38 | events, 39 | (acc, e) => { 40 | if (e.type === "todo-added") { 41 | acc.push({ id: e.id, title: e.title, completed: false }); 42 | } 43 | if (e.type === "todo-toggled") { 44 | const todo = acc.find((t) => t.id === e.id); 45 | if (todo) todo.completed = !todo.completed; 46 | } 47 | if (e.type === "todo-deleted") { 48 | const index = acc.findIndex((note) => note.id === e.id); 49 | if (index !== -1) acc.splice(index, 1); 50 | } 51 | if (e.type === "todo-edited") { 52 | const todo = acc.find((t) => t.id === e.id); 53 | if (todo) todo.title = e.title; 54 | } 55 | return acc; 56 | }, 57 | [] as Todo[] 58 | ); 59 | 60 | const filteredTodos = createMemo(() => { 61 | if (filter() === "active") return todos.filter((t) => !t.completed); 62 | if (filter() === "completed") return todos.filter((t) => t.completed); 63 | return todos; 64 | }); 65 | 66 | const remainingTodos = createEventProjection( 67 | events, 68 | (acc, e) => { 69 | if (e.type === "todo-added") { 70 | acc.push(e.id); 71 | } 72 | if (e.type === "todo-toggled") { 73 | acc.includes(e.id) ? acc.splice(acc.indexOf(e.id), 1) : acc.push(e.id); 74 | } 75 | if (e.type === "todo-deleted") { 76 | acc.includes(e.id) && acc.splice(acc.indexOf(e.id), 1); 77 | } 78 | return acc; 79 | }, 80 | [] as number[] 81 | ); 82 | 83 | const totalCount = createEventComputed( 84 | events, 85 | (acc, e) => { 86 | if (e.type === "todo-added") { 87 | acc++; 88 | } 89 | if (e.type === "todo-deleted") { 90 | acc--; 91 | } 92 | return acc; 93 | }, 94 | 0 95 | ); 96 | 97 | const toggleAll = (completed: boolean) => 98 | Promise.all( 99 | todos 100 | .filter((t) => t.completed !== completed) 101 | .map((t) => appendEvent({ type: "todo-toggled", id: t.id })) 102 | ); 103 | 104 | const clearCompleted = () => 105 | Promise.all( 106 | todos 107 | .filter((t) => t.completed) 108 | .map((t) => appendEvent({ type: "todo-deleted", id: t.id })) 109 | ); 110 | 111 | return ( 112 | <> 113 |
114 |

todos

115 |
{ 117 | e.preventDefault(); 118 | if (!inputRef.value.trim()) e.preventDefault(); 119 | setTimeout(() => (inputRef.value = "")); 120 | const title = ( 121 | new FormData(e.currentTarget).get("title") as string 122 | ).trim(); 123 | const id = todos.length + 1; 124 | 125 | if (title.length) 126 | await appendEvent({ type: "todo-added", title, id }); 127 | }} 128 | > 129 | 136 |
137 |
138 | 139 |
140 | 0}> 141 | 147 | 148 |
    149 | 150 | {(todo) => { 151 | return ( 152 |
  • 159 |
    160 | 171 | 172 | 175 |
    185 | 186 |
    { 188 | e.preventDefault(); 189 | const title = new FormData(e.currentTarget).get( 190 | "title" 191 | ) as string; 192 | appendEvent({ 193 | type: "todo-edited", 194 | id: todo.id, 195 | title, 196 | }); 197 | setEditing({}); 198 | }} 199 | > 200 | { 205 | if (todo.title !== e.currentTarget.value) { 206 | e.currentTarget.form!.requestSubmit(); 207 | } else setEditing({}); 208 | }} 209 | /> 210 |
    211 |
    212 |
  • 213 | ); 214 | }} 215 |
    216 |
217 |
218 | 219 |
220 | 221 | {remainingTodos.length}{" "} 222 | {remainingTodos.length === 1 ? " item " : " items "} left 223 | 224 | 252 | 253 | 256 | 257 |
258 | 259 | ); 260 | } 261 | -------------------------------------------------------------------------------- /socket/lib/server.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createSeriazliedMemo, 3 | createSeriazliedStore, 4 | SerializedMemo, 5 | SerializedReactiveThing, 6 | SerializedRef, 7 | SerializedStream, 8 | SerializedThing, 9 | WsMessage, 10 | WsMessageDown, 11 | WsMessageUp, 12 | } from "./shared"; 13 | import { 14 | createContext, 15 | createMemo, 16 | createRoot, 17 | createSignal, 18 | observable, 19 | onCleanup, 20 | untrack, 21 | useContext, 22 | } from "solid-js"; 23 | import { getManifest } from "vinxi/manifest"; 24 | import type { Peer } from "crossws"; 25 | import { parse as parseCookie } from "cookie-es"; 26 | 27 | const peerCtx = createContext(); 28 | export const usePeer = () => { 29 | const peer = useContext(peerCtx); 30 | if (!peer) throw new Error(`No peer context found`); 31 | return peer; 32 | }; 33 | 34 | export function useCookies>() { 35 | const peer = usePeer(); 36 | 37 | let parsedCookies: T; 38 | const getParsedCookies = () => { 39 | if (parsedCookies) return parsedCookies; 40 | // @ts-expect-error 41 | return (parsedCookies = parseCookie(peer.headers.cookie || ``) as T); 42 | }; 43 | 44 | return new Proxy({} as T, { 45 | get(_, path: string) { 46 | const cookies = getParsedCookies(); 47 | // @ts-expect-error 48 | return cookies[path]; 49 | }, 50 | }); 51 | } 52 | 53 | export type Callable = (arg: unknown) => T | Promise; 54 | 55 | export type Endpoint = ( 56 | input: I 57 | ) => Callable | Record>; 58 | export type Endpoints = Record>; 59 | 60 | export class LiveSolidServer { 61 | private closures = new Map void }>(); 62 | observers = new Map(); 63 | 64 | constructor(public peer: Peer) {} 65 | 66 | send(message: WsMessage>) { 67 | // console.log(`send`, message); 68 | this.peer.send(JSON.stringify(message)); 69 | } 70 | 71 | handleMessage(message: WsMessage) { 72 | if (message.type === "create") { 73 | this.create(message.id, message.name, message.input); 74 | } 75 | 76 | if (message.type === "subscribe") { 77 | this.subscribe(message.id, message.ref, message.path || ``); 78 | } 79 | 80 | if (message.type === "dispose") { 81 | this.dispose(message.id); 82 | } 83 | 84 | if (message.type === "invoke") { 85 | this.invoke(message.id, message.ref, message.input); 86 | } 87 | 88 | if (message.type === "value") { 89 | this.observers.get(message.id)?.(message.value); 90 | } 91 | } 92 | 93 | async create(id: string, name: string, input?: SerializedThing) { 94 | const [filepath, functionName] = name.split("#"); 95 | const module = await getManifest(import.meta.env.ROUTER_NAME).chunks[ 96 | filepath 97 | ].import(); 98 | const endpoint = module[functionName]; 99 | 100 | if (!endpoint) throw new Error(`Endpoint ${name} not found`); 101 | 102 | const { payload, disposal } = createRoot((disposal) => { 103 | const deserializedInput = 104 | input?.__type === "memo" 105 | ? createSocketMemoConsumer(input, this) 106 | : input; 107 | 108 | let payload: any; 109 | peerCtx.Provider({ 110 | value: this.peer, 111 | // @ts-expect-error 112 | children: () => (payload = endpoint(deserializedInput)), 113 | }); 114 | 115 | return { payload, disposal }; 116 | }); 117 | 118 | this.closures.set(id, { payload, disposal }); 119 | 120 | if (typeof payload === "function") { 121 | if (payload.type === "memo") { 122 | const value = createSeriazliedMemo({ 123 | name, 124 | scope: id, 125 | initial: untrack(payload), 126 | }); 127 | this.send({ value, id, type: "value" }); 128 | } else { 129 | const value = createSeriazliedRef({ 130 | name, 131 | scope: id, 132 | }); 133 | this.send({ value, id, type: "value" }); 134 | } 135 | } else { 136 | const value = Object.entries(payload).reduce((res, [name, value]) => { 137 | return { 138 | ...res, 139 | [name]: 140 | typeof value === "function" 141 | ? // @ts-expect-error 142 | value.type === "memo" 143 | ? createSeriazliedMemo({ 144 | name, 145 | scope: id, 146 | initial: untrack(() => value()), 147 | }) 148 | : // @ts-expect-error 149 | value.type === "store-accessor" 150 | ? createSeriazliedStore({ 151 | name, 152 | scope: id, 153 | initial: untrack(() => value()), 154 | }) 155 | : createSeriazliedRef({ name, scope: id }) 156 | : value, 157 | }; 158 | }, {} as Record); 159 | this.send({ value, id, type: "value" }); 160 | } 161 | } 162 | 163 | async invoke(id: string, ref: SerializedRef, input: any[]) { 164 | const closure = this.closures.get(ref.scope); 165 | if (!closure) throw new Error(`Callable ${ref.scope} not found`); 166 | const { payload } = closure; 167 | 168 | if (typeof payload === "function") { 169 | const response = await payload(...input); 170 | this.send({ id, value: response, type: "value" }); 171 | } else { 172 | const response = await payload[ref.name](...input); 173 | this.send({ id, value: response, type: "value" }); 174 | } 175 | } 176 | 177 | dispose(id: string) { 178 | // console.log(`Disposing ${id}`); 179 | const closure = this.closures.get(id); 180 | if (closure) { 181 | closure.disposal(); 182 | this.closures.delete(id); 183 | } 184 | } 185 | 186 | subscribe(id: string, ref: SerializedReactiveThing, path: string) { 187 | // console.log(`subscribe`, ref); 188 | 189 | const closure = this.closures.get(ref.scope); 190 | if (!closure) throw new Error(`Callable ${ref.scope} not found`); 191 | const { payload } = closure; 192 | 193 | const source = typeof payload === "function" ? payload : payload[ref.name]; 194 | 195 | const response$ = observable(() => 196 | ref.__type === "projection" 197 | ? source[path] 198 | : ref.__type === "store-accessor" 199 | ? source()[path] 200 | : source() 201 | ); 202 | const sub = response$.subscribe((value) => { 203 | this.send({ id, value, type: "value" }); 204 | }); 205 | this.closures.set(id, { payload: sub, disposal: () => sub.unsubscribe() }); 206 | } 207 | 208 | stream(stream: SerializedStream) {} 209 | 210 | cleanup() { 211 | for (const [key, closure] of this.closures.entries()) { 212 | // console.log(`Disposing ${key}`); 213 | closure.disposal(); 214 | this.closures.delete(key); 215 | } 216 | } 217 | } 218 | 219 | function createSeriazliedRef( 220 | opts: Omit 221 | ): SerializedRef { 222 | return { ...opts, __type: "ref" }; 223 | } 224 | 225 | export function createSocketFn( 226 | fn: () => (i?: I) => O 227 | ): () => (i?: I) => Promise; 228 | 229 | export function createSocketFn( 230 | fn: () => Record O> 231 | ): () => Record Promise>; 232 | 233 | export function createSocketFn( 234 | fn: () => ((i: I) => O) | Record O> 235 | ): () => ((i: I) => Promise) | Record Promise> { 236 | return fn as any; 237 | } 238 | 239 | function createLazyMemo( 240 | calc: (prev: T | undefined) => T, 241 | value?: T 242 | ): () => T { 243 | let isReading = false, 244 | isStale: boolean | undefined = true; 245 | 246 | const [track, trigger] = createSignal(void 0, { equals: false }), 247 | memo = createMemo( 248 | (p) => (isReading ? calc(p) : ((isStale = !track()), p)), 249 | value as T, 250 | { equals: false } 251 | ); 252 | 253 | return (): T => { 254 | isReading = true; 255 | if (isStale) isStale = trigger(); 256 | const v = memo(); 257 | isReading = false; 258 | return v; 259 | }; 260 | } 261 | 262 | export function createSocketMemoConsumer( 263 | ref: SerializedMemo, 264 | server: LiveSolidServer 265 | ) { 266 | const inputSubId = crypto.randomUUID(); 267 | 268 | const memo = createLazyMemo(() => { 269 | const [get, set] = createSignal(ref.initial!); 270 | server.observers.set(inputSubId, set); 271 | server.send({ type: "subscribe", id: inputSubId, ref }); 272 | onCleanup(() => server.observers.delete(inputSubId)); 273 | return get; 274 | }); 275 | 276 | return () => memo()(); 277 | } 278 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | button { 8 | margin: 0; 9 | padding: 0; 10 | border: 0; 11 | background: none; 12 | font-size: 100%; 13 | vertical-align: baseline; 14 | font-family: inherit; 15 | font-weight: inherit; 16 | color: inherit; 17 | -webkit-appearance: none; 18 | appearance: none; 19 | -webkit-font-smoothing: antialiased; 20 | -moz-osx-font-smoothing: grayscale; 21 | } 22 | 23 | body { 24 | font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; 25 | line-height: 1.4em; 26 | background: #f5f5f5; 27 | color: #4d4d4d; 28 | min-width: 230px; 29 | max-width: 550px; 30 | margin: 0 auto; 31 | -webkit-font-smoothing: antialiased; 32 | -moz-osx-font-smoothing: grayscale; 33 | font-weight: 300; 34 | } 35 | 36 | :focus { 37 | outline: 0; 38 | } 39 | 40 | .hidden { 41 | display: none; 42 | } 43 | 44 | .todoapp { 45 | background: #fff; 46 | margin: 130px 0 40px 0; 47 | position: relative; 48 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 49 | 0 25px 50px 0 rgba(0, 0, 0, 0.1); 50 | } 51 | 52 | .todoapp input::-webkit-input-placeholder { 53 | font-style: italic; 54 | font-weight: 300; 55 | color: #e6e6e6; 56 | } 57 | 58 | .todoapp input::-moz-placeholder { 59 | font-style: italic; 60 | font-weight: 300; 61 | color: #e6e6e6; 62 | } 63 | 64 | .todoapp input::input-placeholder { 65 | font-style: italic; 66 | font-weight: 300; 67 | color: #e6e6e6; 68 | } 69 | 70 | .todoapp h1 { 71 | position: absolute; 72 | top: -155px; 73 | width: 100%; 74 | font-size: 100px; 75 | font-weight: 100; 76 | text-align: center; 77 | color: rgba(175, 47, 47, 0.15); 78 | -webkit-text-rendering: optimizeLegibility; 79 | -moz-text-rendering: optimizeLegibility; 80 | text-rendering: optimizeLegibility; 81 | } 82 | 83 | .new-todo, 84 | .edit { 85 | position: relative; 86 | margin: 0; 87 | width: 100%; 88 | font-size: 24px; 89 | font-family: inherit; 90 | font-weight: inherit; 91 | line-height: 1.4em; 92 | border: 0; 93 | color: inherit; 94 | padding: 6px; 95 | border: 1px solid #999; 96 | box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); 97 | box-sizing: border-box; 98 | -webkit-font-smoothing: antialiased; 99 | -moz-osx-font-smoothing: grayscale; 100 | } 101 | 102 | .new-todo { 103 | padding: 16px 16px 16px 60px; 104 | border: none; 105 | background: rgba(0, 0, 0, 0.003); 106 | box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); 107 | } 108 | 109 | .main { 110 | position: relative; 111 | z-index: 2; 112 | border-top: 1px solid #e6e6e6; 113 | } 114 | 115 | .toggle-all { 116 | width: 60px; 117 | height: 34px; 118 | font-size: 22px; 119 | position: absolute; 120 | top: -52px; 121 | left: 0; 122 | -webkit-transform: rotate(90deg); 123 | transform: rotate(90deg); 124 | color: #e6e6e6; 125 | } 126 | 127 | .toggle-all.checked { 128 | color: #737373; 129 | } 130 | 131 | .todo-list { 132 | margin: 0; 133 | padding: 0; 134 | list-style: none; 135 | } 136 | 137 | .todo-list li { 138 | position: relative; 139 | font-size: 24px; 140 | border-bottom: 1px solid #ededed; 141 | } 142 | 143 | .todo-list li:last-child { 144 | border-bottom: none; 145 | } 146 | 147 | .todo-list li.editing { 148 | border-bottom: none; 149 | padding: 0; 150 | } 151 | 152 | .todo-list li.editing .edit { 153 | display: block; 154 | width: 506px; 155 | padding: 12px 16px; 156 | margin: 0 0 0 43px; 157 | } 158 | 159 | .todo-list li.editing .view { 160 | display: none; 161 | } 162 | 163 | .todo-list li button.toggle { 164 | z-index: 1; 165 | padding: 10px; 166 | position: absolute; 167 | top: 0; 168 | bottom: 0; 169 | margin: auto 0; 170 | display: flex; 171 | align-items: center; 172 | } 173 | 174 | 175 | .todo-list li label { 176 | word-break: break-all; 177 | padding: 15px 15px 15px 60px; 178 | display: block; 179 | line-height: 1.2; 180 | transition: color 0.4s; 181 | } 182 | 183 | .todo-list li.completed label { 184 | color: #d9d9d9; 185 | text-decoration: line-through; 186 | } 187 | 188 | .todo-list li.pending label { 189 | color: #c23bc9; 190 | } 191 | 192 | .todo-list li .destroy { 193 | display: none; 194 | position: absolute; 195 | top: 0; 196 | right: 10px; 197 | bottom: 0; 198 | width: 40px; 199 | height: 40px; 200 | margin: auto 0; 201 | font-size: 30px; 202 | color: #cc9a9a; 203 | margin-bottom: 11px; 204 | transition: color 0.2s ease-out; 205 | } 206 | 207 | .todo-list li .destroy:hover { 208 | color: #af5b5e; 209 | } 210 | 211 | .todo-list li .destroy:after { 212 | content: '×'; 213 | } 214 | 215 | .todo-list li:hover .destroy { 216 | display: block; 217 | } 218 | 219 | .todo-list li .edit { 220 | display: none; 221 | } 222 | 223 | .todo-list li.editing:last-child { 224 | margin-bottom: -1px; 225 | } 226 | 227 | .footer { 228 | color: #777; 229 | padding: 10px 15px; 230 | height: 20px; 231 | text-align: center; 232 | border-top: 1px solid #e6e6e6; 233 | } 234 | 235 | .footer:before { 236 | content: ''; 237 | position: absolute; 238 | right: 0; 239 | bottom: 0; 240 | left: 0; 241 | height: 50px; 242 | overflow: hidden; 243 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 244 | 0 8px 0 -3px #f6f6f6, 245 | 0 9px 1px -3px rgba(0, 0, 0, 0.2), 246 | 0 16px 0 -6px #f6f6f6, 247 | 0 17px 2px -6px rgba(0, 0, 0, 0.2); 248 | } 249 | 250 | .todo-count { 251 | float: left; 252 | text-align: left; 253 | } 254 | 255 | .todo-count strong { 256 | font-weight: 300; 257 | } 258 | 259 | .filters { 260 | margin: 0; 261 | padding: 0; 262 | list-style: none; 263 | position: absolute; 264 | right: 0; 265 | left: 0; 266 | } 267 | 268 | .filters li { 269 | display: inline; 270 | } 271 | 272 | .filters li a { 273 | color: inherit; 274 | margin: 3px; 275 | padding: 3px 7px; 276 | text-decoration: none; 277 | border: 1px solid transparent; 278 | border-radius: 3px; 279 | } 280 | 281 | .filters li a:hover { 282 | border-color: rgba(175, 47, 47, 0.1); 283 | } 284 | 285 | .filters li a.selected { 286 | border-color: rgba(175, 47, 47, 0.2); 287 | } 288 | 289 | .clear-completed, 290 | html .clear-completed:active { 291 | float: right; 292 | position: relative; 293 | line-height: 20px; 294 | text-decoration: none; 295 | cursor: pointer; 296 | } 297 | 298 | .clear-completed:hover { 299 | text-decoration: underline; 300 | } 301 | 302 | .info { 303 | margin: 65px auto 0; 304 | color: #bfbfbf; 305 | font-size: 10px; 306 | text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); 307 | text-align: center; 308 | } 309 | 310 | .info p { 311 | line-height: 1; 312 | } 313 | 314 | .info a { 315 | color: inherit; 316 | text-decoration: none; 317 | font-weight: 400; 318 | } 319 | 320 | .info a:hover { 321 | text-decoration: underline; 322 | } 323 | 324 | /* 325 | Hack to remove background from Mobile Safari. 326 | Can't use it globally since it destroys checkboxes in Firefox 327 | */ 328 | @media screen and (-webkit-min-device-pixel-ratio:0) { 329 | .toggle-all, 330 | .todo-list li .toggle { 331 | background: none; 332 | } 333 | 334 | .todo-list li .toggle { 335 | height: 40px; 336 | } 337 | } 338 | 339 | @media (max-width: 430px) { 340 | .footer { 341 | height: 50px; 342 | } 343 | 344 | .filters { 345 | bottom: 10px; 346 | } 347 | } 348 | 349 | hr { 350 | margin: 20px 0; 351 | border: 0; 352 | border-top: 1px dashed #c5c5c5; 353 | border-bottom: 1px dashed #f7f7f7; 354 | } 355 | 356 | .learn a { 357 | font-weight: normal; 358 | text-decoration: none; 359 | color: #b83f45; 360 | } 361 | 362 | .learn a:hover { 363 | text-decoration: underline; 364 | color: #787e7e; 365 | } 366 | 367 | .learn h3, 368 | .learn h4, 369 | .learn h5 { 370 | margin: 10px 0; 371 | font-weight: 500; 372 | line-height: 1.2; 373 | color: #000; 374 | } 375 | 376 | .learn h3 { 377 | font-size: 24px; 378 | } 379 | 380 | .learn h4 { 381 | font-size: 18px; 382 | } 383 | 384 | .learn h5 { 385 | margin-bottom: 0; 386 | font-size: 14px; 387 | } 388 | 389 | .learn ul { 390 | padding: 0; 391 | margin: 0 0 30px 25px; 392 | } 393 | 394 | .learn li { 395 | line-height: 20px; 396 | } 397 | 398 | .learn p { 399 | font-size: 15px; 400 | font-weight: 300; 401 | line-height: 1.3; 402 | margin-top: 0; 403 | margin-bottom: 0; 404 | } 405 | 406 | #issue-count { 407 | display: none; 408 | } 409 | 410 | .quote { 411 | border: none; 412 | margin: 20px 0 60px 0; 413 | } 414 | 415 | .quote p { 416 | font-style: italic; 417 | } 418 | 419 | .quote p:before { 420 | content: '“'; 421 | font-size: 50px; 422 | opacity: .15; 423 | position: absolute; 424 | top: -20px; 425 | left: 3px; 426 | } 427 | 428 | .quote p:after { 429 | content: '”'; 430 | font-size: 50px; 431 | opacity: .15; 432 | position: absolute; 433 | bottom: -42px; 434 | right: 3px; 435 | } 436 | 437 | .quote footer { 438 | position: absolute; 439 | bottom: -40px; 440 | right: 0; 441 | } 442 | 443 | .quote footer img { 444 | border-radius: 3px; 445 | } 446 | 447 | .quote footer a { 448 | margin-left: 5px; 449 | vertical-align: middle; 450 | } 451 | 452 | .speech-bubble { 453 | position: relative; 454 | padding: 10px; 455 | background: rgba(0, 0, 0, .04); 456 | border-radius: 5px; 457 | } 458 | 459 | .speech-bubble:after { 460 | content: ''; 461 | position: absolute; 462 | top: 100%; 463 | right: 30px; 464 | border: 13px solid transparent; 465 | border-top-color: rgba(0, 0, 0, .04); 466 | } 467 | 468 | .learn-bar > .learn { 469 | position: absolute; 470 | width: 272px; 471 | top: 8px; 472 | left: -300px; 473 | padding: 10px; 474 | border-radius: 5px; 475 | background-color: rgba(255, 255, 255, .6); 476 | transition-property: left; 477 | transition-duration: 500ms; 478 | } 479 | 480 | @media (min-width: 899px) { 481 | .learn-bar { 482 | width: auto; 483 | padding-left: 300px; 484 | } 485 | 486 | .learn-bar > .learn { 487 | left: 8px; 488 | } 489 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Solid Socket 2 | 3 | Signals meets WebSockets. 4 | 5 | Solid-Socket is an extension to SolidStart which adds realtime websocket capabilities over familiar signal based APIs. 6 | 7 | Similar to `"use server"` in SolidStart, Solid-Socket adds a `"use socket"` which gets bundled and run in a stateful server, which means you have access to global state that lives as long as its host server, and any exported function get turned into RPC/Subscription calls over websockets. 8 | 9 | Solid Socket also provides some powerful utilities that enable 10 | 11 | - Two way reactive communication 12 | - Fine grained updates using stores 13 | - Sync engine with incremental projections 14 | - Reactive persistance and server sync 15 | - Cookie-based authentication 16 | 17 | ## Demo 18 | 19 | The demo is the classic TodoMVC application but enhanced to highlight realtime capabilities of Solid-Socket. 20 | 21 | You can log in to the app using just a username, and invite other users to collaborate on your todo list. 22 | 23 | The todo list itself features realtime sync and instant optimistic updates, along with user presence indicators to show who else is currently looking at the list. 24 | 25 | Cookie based auth ensures that a user can only see their own list as well as the lists they have been invited to. 26 | 27 | The todo list and invite data is persisted in a reactive key-value store. 28 | 29 | While the demo is deployed on a single-instance Railway server, it can be easily scaled out horizontally, and the reactive KV persistance will ensure data is synced across every live server. 30 | 31 | Take it for a spin at [solid-socket-production.up.railway.app](https://solid-socket-production.up.railway.app/). 32 | 33 | ## Getting Started 34 | 35 | ``` bash 36 | git clone https://github.com/devagrawal09/solid-socket 37 | cd solid-socket 38 | npm install 39 | npm run dev 40 | ``` 41 | 42 | ## Base APIs 43 | 44 | ### `"use socket"` 45 | 46 | Use this directive on top of a file to define **socket functions**. A **socket function** is a function exported from a file marked as `"use socket"`. This file will be split into a separate bundle that runs on the server. You can create global state in a `"use socket"` file through signals or any other stateful primitive. 47 | 48 | Socket functions work like hooks, and should be called inside Solid.js components. Calling a socket function can instantiate a stateful closure on the server, which is automatically cleaned up with the calling component. 49 | 50 | ```tsx 51 | // src/lib/socket.tsx 52 | "use socket" 53 | 54 | export function useLogger() { 55 | let i = 0 56 | 57 | function logger() { 58 | console.log(`Hello World!`, i++) 59 | } 60 | 61 | return logger 62 | } 63 | 64 | // src/routes/index.tsx 65 | export default function IndexPage() { 66 | const serverLogger = useLogger() 67 | 68 | return 69 | } 70 | ``` 71 | 72 | Clicking the button will log the message on the server and increment the count for the next log. 73 | 74 | ### `createSocketMemo` 75 | 76 | A **socket memo** is a signal that can be accessed on the other side of the network. It's a serializable/transportable reactive value. **Socket memos** can be used to share a reactive value from the client to the server, and the server to the client. 77 | 78 | 79 | ```tsx 80 | // src/lib/socket.tsx 81 | "use socket" 82 | 83 | export function useCounter() { 84 | const [count, setCount] = createSignal() 85 | return { 86 | count: createSocketMemo(count), 87 | setCount 88 | } 89 | } 90 | 91 | // src/routes/index.tsx 92 | export default function Counter() { 93 | const serverCounter = useCounter() 94 | 95 | return 100 | } 101 | ``` 102 | 103 | The todos example in this repo shows how to use `createSocketMemo` to also share a signal from the client to the server. 104 | 105 | ### `createSocketStore` 106 | 107 | _Partially implemented_ 108 | 109 | Truly fine grained reactivity over the wire! 110 | A **socket store** is a nested reactive store accessible on the other side of the network. While `createSocketMemo` sends the entire value across on every update, `createSocketStore` only sends the nested values that are actually being listened to. 111 | 112 | ```tsx 113 | // src/lib/socket.tsx 114 | "use socket" 115 | 116 | export function useConfig() { 117 | const [config, setConfig] = createStore({ name: 'Sockets', location: 'AWS' }) 118 | return { 119 | config: createSocketStore(() => config), 120 | setConfig 121 | } 122 | } 123 | 124 | // src/routes/index.tsx 125 | export default function Page() { 126 | const serverConfig = useConfig() 127 | const [configKeys, setConfigKeys] = createSignal(['name']) 128 | 129 | return 130 | {key => {key()}: {serverConfig.config()[key]}} 131 | 132 | } 133 | ``` 134 | 135 | In this example, the client only renders the `name` property of the config. If the `location` property changes on the server, no updates are sent to the client. 136 | 137 | ### `createSocketProjection` 138 | 139 | _Inspired by the `createProjection` proposal for Solid 2.0_ 140 | _Partially implemented_ 141 | 142 | Similar to `createSocketStore`, but instead of passing in a pre-created store proxy object, you pass in a reactive function that mutates the current state of the proxy using `produce`. 143 | 144 | ```tsx 145 | // src/lib/socket.tsx 146 | "use socket" 147 | 148 | export function useConfig() { 149 | const [name, setName] = createSignal() 150 | const [location, setLocation] = createSignal() 151 | return { 152 | config: createSocketProjection((draft) => { 153 | draft.name = name() 154 | draft.location = location() 155 | }), 156 | setConfig 157 | } 158 | } 159 | 160 | // src/routes/index.tsx 161 | export default function Page() { 162 | const serverConfig = useConfig() 163 | const [configKeys, setConfigKeys] = createSignal(['name']) 164 | 165 | return 166 | {key => {key()}: {serverConfig.config()[key]}} 167 | 168 | } 169 | ``` 170 | 171 | ## Utilities 172 | 173 | ### `useCookies` 174 | 175 | To access session information like the user id or auth token, you can use `useCookies` inside any `"use socket"` function. Since the cookies are shared between the http and websocket servers, you only need to authenticate the user once on the http side (`"use server"`) and you can reuse the auth cookies without an additional auth layer. 176 | 177 | ```ts 178 | // src/lib/auth.ts 179 | "use server" 180 | 181 | export async function login(username: string, password: string) { 182 | // authenticate the username and password 183 | setCookie(`userId`, user.id) 184 | } 185 | 186 | // src/lib/todos.ts 187 | 188 | export const useTodos = () => { 189 | const { userId } = useCookies() 190 | // use userId to access protected data 191 | } 192 | ``` 193 | 194 | ### `createPersistedSignal` 195 | 196 | Regular signals are ephemeral and only live in the memory of the host. This has two issues in a server environment - 197 | - Servers are not always long lived and persistent, so data stored in memory can be lost 198 | - Horizontally scaled servers don't share state by default, so different users can see different states 199 | 200 | To solve these issues, you can use `createPersistedSignal`, which not only stores the data in a persisted database, but also watches for updates so that multiple servers can stay in sync. 201 | 202 | ```tsx 203 | "use socket" 204 | 205 | const storage = createStorage({ // from unstorage 206 | driver: ... // use a driver that supports watching 207 | }); 208 | 209 | const [count, setCount] = createPersistedSignal( 210 | storage, // unstorage client to use 211 | `count`, // key for this signal 212 | 0 // initial value 213 | ); 214 | 215 | ``` 216 | 217 | ### Event Log and Sync 218 | 219 | Building local first applications requires a realtime sync engine. While you can easily build a sync engine on top of the primitives provided, there's a simple, powerful, and customizable sync engine provided with solid-socket that works on top of an event log. 220 | 221 | **Server Event Log** 222 | 223 | We start by defining an event log on the server. 224 | 225 | ```ts 226 | "use socket" 227 | 228 | export type TodoCreated = { 229 | type: "todo-added"; 230 | id: number; 231 | title: string; 232 | }; 233 | export type TodoDeleted = { 234 | type: "todo-deleted"; 235 | id: number; 236 | }; 237 | export type TodoEvent = TodoCreated | TodoDeleted; 238 | 239 | const [todoLogs, setTodoLogs] = createServerLog(); 240 | 241 | export const useServerTodos = () => { 242 | const { userId } = useCookies(); 243 | const { serverEvents, appendEvent } = createServerEventLog( 244 | () => userId, 245 | todoLogs, 246 | setTodoLogs 247 | ); 248 | 249 | return { serverEvents: createSocketMemo(serverEvents), appendEvent }; 250 | }; 251 | ``` 252 | 253 | `createServerLog` creates a global map of event logs. You can think of it like a database table, where each key is associated with an ordered log of events. 254 | 255 | `createServerEventLog` provides access to a single log within the global log using the first argument. In this case, we use the `userId` as the key. It returns a signal to access all the events withing the log, and a method to append an event to the log. We can return both of these to the client. 256 | 257 | **Client Event Log** 258 | 259 | Next, we create a corresponding event log on the client, with a reference to the server log. 260 | 261 | ```ts 262 | export default function TodoApp() { 263 | 264 | const serverTodos = useServerTodos(); 265 | const { events, appendEvent } = createClientEventLog(serverTodos); 266 | 267 | ... 268 | ``` 269 | 270 | On the client, we call the `useServerTodos` function to get access to the server log, and `createClientEventLog` to create a corresponding log on the client. The client log also returns a signal to access the events, and a method to append an event to both the client and the server logs. `createClientEventLog` will ensure the client and server stay in sync. 271 | 272 | **Reducing Events into State** 273 | 274 | Finally, we can use our log of events to construct computations and projections. 275 | 276 | ```tsx 277 | ... 278 | const remainingCount = createEventComputed( 279 | events, 280 | (acc, e) => { 281 | if (e.type === "todo-added") acc++; 282 | if (e.type === "todo-toggled") acc--; 283 | if (e.type === "todo-deleted") acc--; 284 | return acc; 285 | }, 286 | 0 287 | ); 288 | 289 | const todos = createEventProjection( 290 | events, 291 | (acc, e) => { 292 | if (e.type === "todo-added") { 293 | acc.push({ id: e.id, title: e.title, completed: false }); 294 | } 295 | if (e.type === "todo-toggled") { 296 | const todo = acc.find((t) => t.id === e.id); 297 | if (todo) todo.completed = true; 298 | } 299 | if (e.type === "todo-deleted") { 300 | const index = acc.findIndex((note) => note.id === e.id); 301 | if (index !== -1) acc.splice(index, 1); 302 | } 303 | if (e.type === "todo-edited") { 304 | const todo = acc.find((t) => t.id === e.id); 305 | if (todo) todo.title = e.title; 306 | } 307 | return acc; 308 | }, 309 | [] as Todo[] 310 | ); 311 | 312 | ``` 313 | 314 | `createEventComputed` and `createEventProjection` are primitives that consume the log of events and fold over them to compute immutable and mutable values (respectively). 315 | 316 | **Incremental Updates** 317 | 318 | Right now when new events are added, the client receives the entire event log, and the computations rerun from scratch on the entire log. 319 | However, since the log of events is supposed to be an append-only log with no mutations allowed to existing events, this implementation can be incrementalized without any changes to the APIs shown above. The client-server sync can be made smarter so only necesarry updates are sent from the server, and the computations can keep track of events they have already seen so they only fold over new events. This will massively improve the efficiency of the sync engine. 320 | 321 | **Conflict Resolution** 322 | 323 | The current implementation uses a simple id and version approach to resolving conflicts for simplicity's sake. The length of the event log is considered to be it's "version", and each event is tagged with a unique id on creation. 324 | 325 | When an event is appended on the client, it sends that event to the server along with its current version. If the server is at the same version, the append is successful. If the server is ahead of the client, the event is ignored and rolled back on the client. 326 | 327 | When an updated log is received from the server, the client simply checks for any events in the update that are not already in the client log, and adds them in. This allows the client to maintain any events added optimistically while the request to append them on the server are still in flight. 328 | 329 | These conflict resolution strategies can be customized to the user's needs, and more functional strategies will likely be incorporated into the library and offered out-of-the-box over time. 330 | 331 | ### `solid-events` Integration 332 | 333 | _Work in progress_ 334 | 335 | Along with communicating in signals, the client and server can also communicate in events using the [`solid-events`](https://github.com/devagrawal09/solid-events) library. 336 | 337 | While push-based events require a slightly different mental model to program than signals, they are also cheaper since they don't need to maintain the current state on both sides of the network. Instead, they simply push an event and let the other side process it however they want. Event streams are also naturally serializable, so they don't need a special wrapper like `createSocketEvent` to pass through the network. Events can also useful to communicate domain-specific information that can be used to compute specific state changes on either side, rather than relying on diffing or fine grained store updates. 338 | 339 | ## Status 340 | 341 | This project is highly experimental and you might run into issues building with this. Please report any issues you might find and leave feedback for things you'd like to see!. 342 | 343 | ### Known Limitations 344 | - If socket functions are not called within components or reactive roots, they will never be cleaned up from the server. Only call socket functions from roots. 345 | - Socket functions can return functions, memos, or objects whose shallow properties are functions or memos. Deeply nested properties that are functions or memos won't be serialized and might throw an error instead. 346 | - The input to a socket function can either be a serializable object or a memo. It cannot be a function or an object with properties that are functions or memos. 347 | - Third party packages that use Solid's signals (such as solid-primitives) might not work yet on the server. 348 | 349 | ### Roadmap 350 | - More demos 351 | - Integration with `solid-events` 352 | - Address above limitations 353 | - Package as an npm module --------------------------------------------------------------------------------