Loading...
38 | ();
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 |
--------------------------------------------------------------------------------