├── app ├── env.d.ts ├── entry.client.tsx ├── entry.server.tsx ├── root.tsx ├── routes │ └── _index.tsx └── components │ ├── whos-here.tsx │ └── country-code-emoji.ts ├── public └── favicon.ico ├── .gitignore ├── partykit.json ├── vite.config.ts ├── messages.d.ts ├── tsconfig.json ├── party ├── server.ts └── geo.ts ├── LICENSE ├── README.md ├── package.json └── .eslintrc.cjs /app/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/partykit/remix-vite-starter/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | .env 7 | 8 | /dist 9 | /.partykit 10 | -------------------------------------------------------------------------------- /partykit.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://www.partykit.io/schema.json", 3 | "name": "remix-vite-partykit-starter", 4 | "main": "party/server.ts", 5 | "compatibilityDate": "2024-01-22", 6 | "parties": { 7 | "geo": "party/geo.ts" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { vitePlugin as remix } from "@remix-run/dev"; 2 | import { defineConfig } from "vite"; 3 | import tsconfigPaths from "vite-tsconfig-paths"; 4 | 5 | export default defineConfig({ 6 | plugins: [remix({ ssr: false }), tsconfigPaths()], 7 | }); 8 | -------------------------------------------------------------------------------- /app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { RemixBrowser } from "@remix-run/react"; 2 | import { startTransition, StrictMode } from "react"; 3 | import { hydrateRoot } from "react-dom/client"; 4 | 5 | startTransition(() => { 6 | hydrateRoot( 7 | document, 8 | 9 | 10 | 11 | ); 12 | }); 13 | -------------------------------------------------------------------------------- /messages.d.ts: -------------------------------------------------------------------------------- 1 | // This is a shared file between the server and the client, 2 | // showing the types of messages being passed between them. 3 | 4 | // Keeping this simple, we send only one type of message 5 | // (a total count of all connections and a count of connections from each country) 6 | 7 | export type State = { 8 | total: number; 9 | from: Record; 10 | }; 11 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import type { EntryContext } from "partymix"; 2 | import { RemixServer } from "@remix-run/react"; 3 | import { renderToString } from "react-dom/server"; 4 | 5 | export default function handleRequest( 6 | request: Request, 7 | responseStatusCode: number, 8 | responseHeaders: Headers, 9 | remixContext: EntryContext 10 | ) { 11 | let html = renderToString( 12 | 13 | ); 14 | if (html.startsWith(" 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | } 26 | 27 | export function HydrateFallback() { 28 | return ( 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 |

Loading...

38 | 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /app/routes/_index.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction } from "partymix"; 2 | import WhosHere from "~/components/whos-here"; 3 | 4 | export const meta: MetaFunction = () => { 5 | return [ 6 | { title: "New Remix SPA" }, 7 | { name: "description", content: "Welcome to Remix (SPA Mode)!" }, 8 | ]; 9 | }; 10 | 11 | export default function Index() { 12 | return ( 13 |
14 |

15 | Welcome to 💿 Remix (SPA Mode)
running on 🎈 PartyKit
{" "} 16 | built on ⚡️ Vite 17 |

18 | 34 | 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 PartyKit, Inc. 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /app/components/whos-here.tsx: -------------------------------------------------------------------------------- 1 | import { usePartySocket } from "partysocket/react"; 2 | import { useState } from "react"; 3 | import type { State } from "../../messages"; 4 | import countryCodeEmoji from "./country-code-emoji"; 5 | 6 | // This is a component that will connect to the partykit backend 7 | // and display the number of connected users, and where they're from. 8 | export default function WhosHere() { 9 | const [users, setUsers] = useState(); 10 | 11 | usePartySocket({ 12 | // connect to the party defined by 'geo.ts' 13 | party: "geo", 14 | // this can be any name, we just picked 'index' 15 | room: "index", 16 | onMessage(evt) { 17 | const data = JSON.parse(evt.data) as State; 18 | setUsers(data); 19 | }, 20 | }); 21 | 22 | return !users ? ( 23 | "Connecting..." 24 | ) : ( 25 |
26 | Who's here? 27 |
28 | {users?.total} user{users?.total !== 1 ? "s" : ""} online. ( 29 | {Object.entries(users?.from || {}) 30 | .map(([from, count]) => { 31 | return `${count} from ${countryCodeEmoji(from)}`; 32 | }) 33 | .join(", ")} 34 | ) 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 💿 remix ⤫ 🎈 partykit ⤫ ⚡️ vite 2 | 3 | This template leverages [Remix SPA Mode](https://remix.run/docs/en/main/future/spa-mode) to build your app as a Single-Page Application using [Client Data](https://remix.run/docs/en/main/guides/client-data) for all of you data loads and mutations. This is then deployed on to [PartyKit](https://partykit.io), for multiplayer/real-time support. 4 | 5 | ⚠️ This is built on top of the Remix Vite template. Remix support for Vite is currently unstable and not recommended for production. 6 | 7 | 📖 See the [Remix Vite docs][remix-vite-docs] for details on supported features. 8 | 9 | ## Setup 10 | 11 | ```shellscript 12 | npx create-remix@latest --template partykit/remix-vite-starter 13 | ``` 14 | 15 | ## Development 16 | 17 | You will be running two processes during development: 18 | 19 | - The Remix development server (powered by Vite) 20 | - The PartyKit server 21 | 22 | Both are started with one command: 23 | 24 | ```shellscript 25 | npm run dev 26 | ``` 27 | 28 | If you want to check the production build, you can stop the dev server and run following commands: 29 | 30 | ```sh 31 | npm run build 32 | npm start 33 | ``` 34 | 35 | Then refresh the same URL in your browser (no live reload for production builds). 36 | 37 | ## Deployment 38 | 39 | ```sh 40 | npm run deploy 41 | ``` 42 | 43 | If you don't already have a PartyKit account, you'll be prompted to create one during the deploy process. 44 | 45 | [remix-vite-docs]: https://remix.run/docs/en/main/future/vite 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-vite-unstable-test", 3 | "private": true, 4 | "sideEffects": false, 5 | "type": "module", 6 | "scripts": { 7 | "build": "remix vite:build", 8 | "dev": "concurrently \"partykit dev --define DEVMODE=true\" \"remix vite:dev\" --kill-others-on-fail", 9 | "start": "partykit dev --define DEVMODE=false --serve ./build/client", 10 | "deploy": "npm run build && partykit deploy --define DEVMODE=false --serve ./build/client", 11 | "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .", 12 | "typecheck": "tsc" 13 | }, 14 | "dependencies": { 15 | "@remix-run/react": "^2.8.1", 16 | "partymix": "^0.0.15", 17 | "partysocket": "^1.0.1", 18 | "react": "^18.2.0", 19 | "react-dom": "^18.2.0" 20 | }, 21 | "devDependencies": { 22 | "@remix-run/dev": "^2.8.1", 23 | "@types/react": "^18.2.66", 24 | "@types/react-dom": "^18.2.22", 25 | "@typescript-eslint/eslint-plugin": "^7.2.0", 26 | "concurrently": "^8.2.2", 27 | "eslint": "^8.57.0", 28 | "eslint-config-prettier": "^9.1.0", 29 | "eslint-import-resolver-typescript": "^3.6.1", 30 | "eslint-plugin-import": "^2.29.1", 31 | "eslint-plugin-jsx-a11y": "^6.8.0", 32 | "eslint-plugin-react": "^7.34.1", 33 | "eslint-plugin-react-hooks": "^4.6.0", 34 | "partykit": "^0.0.100", 35 | "typescript": "^5.4.2", 36 | "vite": "^5.1.6", 37 | "vite-tsconfig-paths": "^4.3.2" 38 | }, 39 | "engines": { 40 | "node": ">=18.0.0" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/components/country-code-emoji.ts: -------------------------------------------------------------------------------- 1 | // Convert a country code to a flag emoji 2 | // modified from https://github.com/thekelvinliu/country-code-emoji/ 3 | 4 | // country code regex 5 | const CC_REGEX = /^[a-z]{2}$/i; 6 | 7 | // offset between uppercase ascii and regional indicator symbols 8 | const OFFSET = 127397; 9 | 10 | /** 11 | * convert country code to corresponding flag emoji 12 | */ 13 | export default function countryCodeEmoji(cc: string): string { 14 | if (!CC_REGEX.test(cc)) { 15 | // if it's not recognized as a country code, return pirate flag as fallback 16 | return `🏴‍☠️`; 17 | } 18 | 19 | const codePoints = [...cc.toUpperCase()].map( 20 | (c) => (c.codePointAt(0) ?? 0) + OFFSET 21 | ); 22 | return String.fromCodePoint(...codePoints); 23 | } 24 | 25 | /* 26 | 27 | The MIT License (MIT) 28 | 29 | Copyright (c) 2019 Kelvin Liu 30 | 31 | Permission is hereby granted, free of charge, to any person obtaining a copy 32 | of this software and associated documentation files (the "Software"), to deal 33 | in the Software without restriction, including without limitation the rights 34 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 35 | copies of the Software, and to permit persons to whom the Software is 36 | furnished to do so, subject to the following conditions: 37 | 38 | The above copyright notice and this permission notice shall be included in all 39 | copies or substantial portions of the Software. 40 | 41 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 42 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 43 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 44 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 45 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 46 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 47 | SOFTWARE. 48 | 49 | */ 50 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * This is intended to be a basic starting point for linting in your app. 3 | * It relies on recommended configs out of the box for simplicity, but you can 4 | * and should modify this configuration to best suit your team's needs. 5 | */ 6 | 7 | /** @type {import('eslint').Linter.Config} */ 8 | module.exports = { 9 | root: true, 10 | parserOptions: { 11 | ecmaVersion: "latest", 12 | sourceType: "module", 13 | ecmaFeatures: { 14 | jsx: true, 15 | }, 16 | }, 17 | env: { 18 | browser: true, 19 | commonjs: true, 20 | es6: true, 21 | }, 22 | 23 | // Base config 24 | extends: ["eslint:recommended"], 25 | 26 | overrides: [ 27 | // React 28 | { 29 | files: ["**/*.{js,jsx,ts,tsx}"], 30 | plugins: ["react", "jsx-a11y"], 31 | extends: [ 32 | "plugin:react/recommended", 33 | "plugin:react/jsx-runtime", 34 | "plugin:react-hooks/recommended", 35 | "plugin:jsx-a11y/recommended", 36 | ], 37 | settings: { 38 | react: { 39 | version: "detect", 40 | }, 41 | formComponents: ["Form"], 42 | linkComponents: [ 43 | { name: "Link", linkAttribute: "to" }, 44 | { name: "NavLink", linkAttribute: "to" }, 45 | ], 46 | "import/resolver": { 47 | typescript: {}, 48 | }, 49 | }, 50 | }, 51 | 52 | // Typescript 53 | { 54 | files: ["**/*.{ts,tsx}"], 55 | plugins: ["@typescript-eslint", "import"], 56 | parser: "@typescript-eslint/parser", 57 | settings: { 58 | "import/internal-regex": "^~/", 59 | "import/resolver": { 60 | node: { 61 | extensions: [".ts", ".tsx"], 62 | }, 63 | typescript: { 64 | alwaysTryTypes: true, 65 | }, 66 | }, 67 | }, 68 | extends: [ 69 | "plugin:@typescript-eslint/recommended", 70 | "plugin:import/recommended", 71 | "plugin:import/typescript", 72 | ], 73 | }, 74 | 75 | // Node 76 | { 77 | files: [".eslintrc.js"], 78 | env: { 79 | node: true, 80 | }, 81 | }, 82 | ], 83 | }; 84 | -------------------------------------------------------------------------------- /party/geo.ts: -------------------------------------------------------------------------------- 1 | // We use this 'party' to get and broadcast presence information 2 | // from all connected users. We'll use this to show how many people 3 | // are connected to the room, and where they're from. 4 | 5 | import type { State } from "../messages"; 6 | 7 | import type * as Party from "partykit/server"; 8 | 9 | export default class MyRemix implements Party.Server { 10 | // eslint-disable-next-line no-useless-constructor 11 | constructor(public room: Party.Room) {} 12 | 13 | // we'll store the state in memory 14 | state: State = { 15 | total: 0, 16 | from: {}, 17 | }; 18 | // let's opt in to hibernation mode, for much higher concurrency 19 | // like, 1000s of people in a room 🤯 20 | // This has tradeoffs for the developer, like needing to hydrate/rehydrate 21 | // state on start, so be careful! 22 | static options = { 23 | hibernate: true, 24 | }; 25 | 26 | // This is called every time a new room is made 27 | // since we're using hibernation mode, we should 28 | // "rehydrate" this.state here from all connections 29 | onStart(): void | Promise { 30 | for (const connection of this.room.getConnections<{ from: string }>()) { 31 | const from = connection.state!.from; 32 | this.state = { 33 | total: this.state.total + 1, 34 | from: { 35 | ...this.state.from, 36 | [from]: (this.state.from[from] ?? 0) + 1, 37 | }, 38 | }; 39 | } 40 | } 41 | 42 | // This is called every time a new connection is made 43 | async onConnect( 44 | connection: Party.Connection<{ from: string }>, 45 | ctx: Party.ConnectionContext 46 | ): Promise { 47 | // Let's read the country from the request context 48 | const from = (ctx.request.cf?.country ?? "unknown") as string; 49 | // and update our state 50 | this.state = { 51 | total: this.state.total + 1, 52 | from: { 53 | ...this.state.from, 54 | [from]: (this.state.from[from] ?? 0) + 1, 55 | }, 56 | }; 57 | // let's also store where we're from on the connection 58 | // so we can hydrate state on start, as well as reference it on close 59 | connection.setState({ from }); 60 | // finally, let's broadcast the new state to all connections 61 | this.room.broadcast(JSON.stringify(this.state)); 62 | } 63 | 64 | // This is called every time a connection is closed 65 | async onClose(connection: Party.Connection<{ from: string }>): Promise { 66 | // let's update our state 67 | // first let's read the country from the connection state 68 | const from = connection.state!.from; 69 | // and update our state 70 | this.state = { 71 | total: this.state.total - 1, 72 | from: { 73 | ...this.state.from, 74 | [from]: (this.state.from[from] ?? 0) - 1, 75 | }, 76 | }; 77 | // finally, let's broadcast the new state to all connections 78 | this.room.broadcast(JSON.stringify(this.state)); 79 | } 80 | 81 | // This is called when a connection has an error 82 | async onError( 83 | connection: Party.Connection<{ from: string }>, 84 | err: Error 85 | ): Promise { 86 | // let's log the error 87 | console.error(err); 88 | // and close the connection 89 | await this.onClose(connection); 90 | } 91 | } 92 | 93 | MyRemix satisfies Party.Worker; 94 | --------------------------------------------------------------------------------