├── .gitignore ├── .prettierrc ├── README.md ├── app ├── components │ └── Textarea.tsx ├── entry.client.tsx ├── entry.server.tsx ├── hooks │ └── useSocketListen.ts ├── root.tsx ├── routes │ ├── $room.tsx │ └── index.tsx ├── styles.css ├── theme.ts ├── ws-client.ts └── ws-context.ts ├── package-lock.json ├── package.json ├── playwright.config.ts ├── public └── favicon.ico ├── remix.config.js ├── remix.env.d.ts ├── server └── index.js ├── tests ├── main.spec.ts └── utils.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /server/build 5 | /public/build 6 | .env -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": true, 4 | "embeddedLanguageFormatting": "auto", 5 | "htmlWhitespaceSensitivity": "css", 6 | "insertPragma": false, 7 | "jsxBracketSameLine": false, 8 | "jsxSingleQuote": false, 9 | "printWidth": 80, 10 | "proseWrap": "preserve", 11 | "quoteProps": "as-needed", 12 | "requirePragma": false, 13 | "semi": false, 14 | "singleQuote": true, 15 | "tabWidth": 2, 16 | "trailingComma": "es5", 17 | "useTabs": false, 18 | "vueIndentScriptAndStyle": false 19 | } 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is just a simple google docs thing, me playing around with Remix and sockets. 2 | 3 | TODO: from socket.io to yjs sockets, support CRDT. 4 | 5 | # Installation 6 | 7 | After forking and cloning, run `npm install`, then you need two environment variables: 8 | 9 | - `BASE_URL_DEV`: set to `http://localhost:3000/` 10 | 11 | - `PORT`: set to `3000` 12 | 13 | ## Development 14 | 15 | You'll need to run two terminals (or bring in a process manager like concurrently/pm2-dev if you like): 16 | 17 | Start the Remix development asset server 18 | 19 | ```sh 20 | npm run dev 21 | ``` 22 | 23 | In a new tab start your express app: 24 | 25 | ```sh 26 | npm run start:dev 27 | ``` 28 | 29 | This starts your app in development mode, which will purge the server require cache when Remix rebuilds assets so you don't need a process manager restarting the express server. 30 | 31 | # Tests 32 | 33 | To run the tests: `npm run test`. 34 | 35 | # Demo 36 | 37 | https://user-images.githubusercontent.com/49603590/149983975-0139acfb-689b-4f23-8a59-447a3f145d81.mp4 38 | -------------------------------------------------------------------------------- /app/components/Textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { colors } from '~/theme' 3 | 4 | export const Textarea: React.FC<{ 5 | text: string 6 | handleChange: (event: React.ChangeEvent) => void 7 | }> = ({ text, handleChange }) => ( 8 | <> 9 | 22 | 39 | 40 | ) 41 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { hydrate } from 'react-dom' 2 | import { RemixBrowser } from 'remix' 3 | 4 | hydrate(, document) 5 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import { renderToString } from 'react-dom/server' 2 | import { RemixServer } from 'remix' 3 | import type { EntryContext } from 'remix' 4 | 5 | export default function handleRequest( 6 | request: Request, 7 | responseStatusCode: number, 8 | responseHeaders: Headers, 9 | remixContext: EntryContext 10 | ) { 11 | const markup = renderToString( 12 | 13 | ) 14 | 15 | responseHeaders.set('Content-Type', 'text/html') 16 | 17 | return new Response('' + markup, { 18 | status: responseStatusCode, 19 | headers: responseHeaders, 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /app/hooks/useSocketListen.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Socket } from 'socket.io-client' 3 | import { DefaultEventsMap } from 'socket.io/dist/typed-events' 4 | 5 | type Props = { 6 | event: 'receive-client' 7 | socket: Socket | undefined 8 | setData: React.Dispatch> 9 | } 10 | 11 | export const useSocketListen = ({ 12 | socket, 13 | setData, 14 | event, 15 | }: Props) => { 16 | React.useEffect(() => { 17 | if (!socket) return 18 | 19 | socket.on(event, (data) => { 20 | setData(data) 21 | }) 22 | }, [socket]) 23 | } 24 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Links, 3 | LinksFunction, 4 | LiveReload, 5 | Meta, 6 | Outlet, 7 | Scripts, 8 | ScrollRestoration, 9 | useLoaderData, 10 | } from 'remix' 11 | import React from 'react' 12 | import type { MetaFunction } from 'remix' 13 | import { connect } from './ws-client' 14 | import { Socket } from 'socket.io-client' 15 | import { wsContext } from './ws-context' 16 | import { DefaultEventsMap } from 'socket.io/dist/typed-events' 17 | import styles from './styles.css' 18 | 19 | declare global { 20 | interface Window { 21 | ENV: { BASE_URL_DEV: string | undefined } 22 | } 23 | } 24 | 25 | export const meta: MetaFunction = () => { 26 | return { title: 'Simple Google Docs' } 27 | } 28 | 29 | export const links: LinksFunction = () => { 30 | return [{ rel: 'stylesheet', href: styles }] 31 | } 32 | 33 | export default function App() { 34 | const [socket, setSocket] = 35 | React.useState>() 36 | 37 | React.useEffect(() => { 38 | const connection = connect() 39 | setSocket(connection) 40 | return () => { 41 | connection.close() 42 | } 43 | }, []) 44 | 45 | return ( 46 | 47 | 48 | 49 | 50 | 51 | ) 52 | } 53 | 54 | type LoaderData = { 55 | ENV: { BASE_URL_DEV: string | undefined } 56 | } 57 | 58 | export function loader(): LoaderData { 59 | return { 60 | ENV: { 61 | BASE_URL_DEV: process.env.BASE_URL_DEV, 62 | }, 63 | } 64 | } 65 | 66 | function Document({ 67 | children, 68 | title, 69 | }: { 70 | children: React.ReactNode 71 | title?: string 72 | }) { 73 | const data = useLoaderData() 74 | return ( 75 | 76 | 77 | 78 | 79 | {title ? {title} : null} 80 | 81 | 82 | 83 | 84 |