From 6f6fbea4a3849e7520b0ee74b9f280acf2dcb426 Mon Sep 17 00:00:00 2001 From: Nick Thomas Date: Sat, 20 Apr 2024 23:29:22 -0700 Subject: [PATCH] wip --- .eslintrc | 1 + .gitignore | 1 + example/index.html | 2 +- example/index.ts | 237 ++++++++++++++++++++++++++++++++++++++++- example/party/index.ts | 32 ++++++ example/style.css | 69 ++++++++++++ package.json | 9 +- partykit.json | 6 ++ src/index.ts | 16 +-- src/server.ts | 28 +++++ tsconfig.json | 2 +- 11 files changed, 390 insertions(+), 13 deletions(-) create mode 100644 example/party/index.ts create mode 100644 example/style.css create mode 100644 partykit.json create mode 100644 src/server.ts diff --git a/.eslintrc b/.eslintrc index 183eb03..76cb07c 100644 --- a/.eslintrc +++ b/.eslintrc @@ -12,6 +12,7 @@ "@typescript-eslint" ], "rules": { + "dot-notation": "off", "@typescript-eslint/no-explicit-any": "off", "operator-linebreak": ["off"], "multiline-ternary": "off", diff --git a/.gitignore b/.gitignore index ba72a0d..2d229d6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ test/*.js dist .env public +.partykit/ diff --git a/example/index.html b/example/index.html index a9f3240..9dc8d6d 100644 --- a/example/index.html +++ b/example/index.html @@ -7,6 +7,6 @@
- + diff --git a/example/index.ts b/example/index.ts index 239a51b..b0f8ce4 100644 --- a/example/index.ts +++ b/example/index.ts @@ -1,14 +1,243 @@ import { html } from 'htm/preact' -import { render } from 'preact' +import { render, FunctionComponent } from 'preact' +import { signal, useSignal, useComputed, batch } from '@preact/signals' +import { ButtonOutline } from '@nichoth/components/htm/button-outline' +import { Identity, create as createID } from '@bicycle-codes/identity' +import { program as Program } from '@oddjs/odd' +import { Button } from '@nichoth/components/htm/button' +import Debug from '@nichoth/debug' +import { TextInput } from '@nichoth/components/htm/text-input' +import { customAlphabet } from '@nichoth/nanoid' +import { numbers } from '@nichoth/nanoid-dictionary' +import { Parent, Child, Certificate } from '../src/index.js' +import '@nichoth/components/button-outline.css' +import '@nichoth/components/text-input.css' +import '@nichoth/components/button.css' +import './style.css' +const debug = Debug() -example() +const program = await Program({ + namespace: { + name: 'link-example', + creator: 'bicycle-computing' + } +}) + +const { crypto } = program.components + +const state = { + me: signal(null), + linkState: signal<'parent'|'child'|null>(null), + code: signal(null), + certificate: signal(null) +} + +if (import.meta.env.DEV) { + // @ts-expect-error dev + window.state = state +} const PARTY_URL = (import.meta.env.DEV ? 'localhost:1999' : 'link.nichoth.partykit.dev') -function Example () { - return html`
hello
` +const Example:FunctionComponent = function Example () { + async function addChild (ev) { + ev.preventDefault() + debug('add a child device', ev) + // the parent needs to create a random ID for websocket connection + const code = customAlphabet(numbers, 6)() + + batch(() => { + state.linkState.value = 'parent' + state.code.value = '' + code + }) + + /** + * connect to our server + * `identity` will be the new ID, including the child device + */ + const identity = await Parent(state.me.value!, crypto, { + host: PARTY_URL, + code: '' + code + }) + + state.me.value = identity + } + + async function createId (ev) { + ev.preventDefault() + const deviceName = ev.target.elements.deviceName.value + const humanName = ev.target.elements.humanName.value + if (!deviceName) return + debug('device name', deviceName) + const me = await createID(crypto, { + humanName, + humanReadableDeviceName: deviceName + }) + + state.me.value = me + } + + function connectToParent (ev) { + ev.preventDefault() + state.linkState.value = 'child' + debug('connect to parent...', ev) + } + + async function joinParent ({ code, humanReadableDeviceName }) { + const { identity, certificate } = await Child(crypto, { + host: PARTY_URL, + code, + humanReadableDeviceName + }) + + batch(() => { + state.me.value = identity + state.certificate.value = certificate + }) + } + + return html`` } render(html`<${Example} />`, document.getElementById('root')!) + +/** + * The form to enter a code + * (from a new child device) + */ +function CodeForm ({ onSubmit }) { + const isValidPin = useSignal(false) + const isSpinning = useSignal(false) + const isNameValid = useSignal(false) + const isFormValid = useComputed(() => { + return isValidPin.value && isNameValid.value + }) + + // need this because `onInput` event doesnt work for cmd + delete event + function onFormKeydown (ev:KeyboardEvent) { + const key = ev.key + const { form } = ev.target as HTMLInputElement + if (!form) return + if (key !== 'Backspace' && key !== 'Delete') return + + const _isValid = form.checkValidity() + if (_isValid !== isValidPin.value) isValidPin.value = _isValid + } + + async function handleSubmit (ev:SubmitEvent) { + ev.preventDefault() + + const pin = (ev.target as HTMLFormElement).elements['pin'].value + const nameEl = (ev.target as HTMLFormElement).elements['device-name'] + const humanReadableDeviceName = nameEl.value + + onSubmit({ + code: pin, + humanReadableDeviceName + }) + } + + function pinInput (ev:InputEvent) { + const el = ev.target as HTMLInputElement + el.value = '' + el.value.slice(0, parseInt(el.getAttribute('maxlength')!)) + const max = parseInt(el.getAttribute('maxlength')!) + const min = parseInt(el.getAttribute('minlength')!) + const valid = (el.value.length >= min && el.value.length <= max) + if (valid !== isValidPin.value) isValidPin.value = valid + } + + function onNameInput (ev:InputEvent) { + const isValid = (ev.target as HTMLInputElement).checkValidity() + if (!!isValid !== isNameValid.value) isNameValid.value = !!isValid + } + + return html`
+
+ +
+ +

Enter the PIN from the parent device

+
+ +
+ + <${Button} + isSpinning=${isSpinning} + disabled=${!isFormValid.value} + type="submit" + > + Link devices + +
` +} diff --git a/example/party/index.ts b/example/party/index.ts new file mode 100644 index 0000000..17ff965 --- /dev/null +++ b/example/party/index.ts @@ -0,0 +1,32 @@ +import type * as Party from 'partykit/server' +import { onConnect, onMessage } from '../../src/server.js' + +export default class Server implements Party.Server { + existingDevice:string|undefined + + constructor (readonly room: Party.Room) { + this.room = room + } + + /** + * Could check a token here for auth + */ + static async onBeforeConnect (request:Party.Request) { + // const token = new URL(request.url).searchParams.get('token') ?? '' + // console.log('**before connection**', token) + return request + } + + /** + * Parent device must connect first + */ + onConnect (conn:Party.Connection, /* ctx:Party.ConnectionContext */) { + onConnect(this, conn) + } + + onMessage (message:string, sender:Party.Connection) { + onMessage(this, message, sender) + } +} + +Server satisfies Party.Worker diff --git a/example/style.css b/example/style.css new file mode 100644 index 0000000..0bd2c11 --- /dev/null +++ b/example/style.css @@ -0,0 +1,69 @@ +.link-demo { + display: flex; + flex-direction: column; + gap: 1rem; + margin: 1rem 0; + + & .connectors { + display: flex; + flex-direction: column; + gap: 1rem; + margin: 1rem 0; + + & button { + transition: 0.4s all; + width: 12rem; + cursor: pointer; + &:hover { + background-color: #00edff30; + } + } + } + +} + +form { + border: 1px solid; + padding: 1rem; + max-width: 30rem; +} + +pre { + border: 1px solid; + padding: 1rem; + background-color: rgb(247, 247, 247); + overflow: scroll; + line-height: 1.4rem; +} + +.the-pin { + margin-bottom: 2rem; + + & .the-code { + font-size: 4rem; + letter-spacing: 1rem; + margin-top: 2rem; + text-align: center; + } +} + +.pin-input { + margin-bottom: 1rem; +} + +#pin-input { + letter-spacing: 0.5rem; + font-family: "Courier New", Courier, monospace; + font-size: 2rem; + font-weight: bold; + /* border: 1px solid; */ + border: none; + border-bottom: 2px solid; + padding: 0.4rem; + padding-bottom: 0.2rem; + width: 11rem; + + &:hover { + outline: none; + } +} diff --git a/package.json b/package.json index 8d7e8fa..e0d3368 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "build-cjs": "esbuild src/*.ts --format=cjs --keep-names --tsconfig=tsconfig.build.json --outdir=./dist --out-extension:.js=.cjs --sourcemap=inline", "build-esm": "tsc --project tsconfig.build.json", "build": "mkdir -p ./dist && rm -rf ./dist/* && npm run build-cjs && npm run build-esm", - "start": "vite", + "start": "concurrently --kill-others \"npx partykit dev\" \"npx vite\"", "preversion": "npm run lint", "version": "auto-changelog -p --template keepachangelog --breaking-pattern 'BREAKING CHANGE:' && git add CHANGELOG.md", "postversion": "git push --follow-tags && npm publish", @@ -23,20 +23,27 @@ "dependencies": { "@bicycle-codes/identity": "^0.7.2", "@bicycle-codes/message": "^0.7.2", + "@nichoth/nanoid": "^5.0.8", + "@nichoth/nanoid-dictionary": "^5.0.2", "partysocket": "^1.0.1", "uint8arrays": "^5.0.3" }, "devDependencies": { "@bicycle-codes/tapzero": "^0.9.2", + "@nichoth/components": "^0.16.9", "@nichoth/debug": "^0.6.7", + "@oddjs/odd": "^0.37.2", "@preact/preset-vite": "^2.8.2", + "@preact/signals": "^1.2.3", "@typescript-eslint/eslint-plugin": "^7.6.0", "@typescript-eslint/parser": "^7.6.0", "auto-changelog": "^2.4.0", + "concurrently": "^8.2.2", "esbuild": "^0.20.2", "eslint": "^8.57.0", "eslint-config-standard": "^17.1.0", "htm": "^3.1.1", + "partykit": "0.0.104", "postcss-nesting": "^12.1.1", "preact": "^10.20.2", "tap-spec": "^5.0.0", diff --git a/partykit.json b/partykit.json new file mode 100644 index 0000000..ded8521 --- /dev/null +++ b/partykit.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://www.partykit.io/schema.json", + "name": "example-party", + "main": "example/party/index.ts", + "compatibilityDate": "2024-04-20" +} diff --git a/src/index.ts b/src/index.ts index 224fe9f..666f884 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,14 @@ import { PartySocket } from 'partysocket' import { toString } from 'uint8arrays' import { create as createMessage } from '@bicycle-codes/message' +import Debug from '@nichoth/debug' import { writeKeyToDid, addDevice, createDeviceName } from '@bicycle-codes/identity' import type { DID, Crypto, Identity } from '@bicycle-codes/identity' +const debug = Debug() /** * Message from the new, incoming, device @@ -47,21 +49,21 @@ export type Certificate = Awaited< * instance that includes the new device, after we get a message from the * new device. */ -export function Parent (identity:Identity, oddCrypto:Crypto, { +export async function Parent (identity:Identity, oddCrypto:Crypto, { host, code, - id, query }:{ host:string; code:string; - id?:string; query?:string; }):Promise { + const myDid = await writeKeyToDid(oddCrypto) + debug('my did', myDid) const party = new PartySocket({ host, room: code, - id, + id: myDid, query: { token: query } @@ -121,9 +123,11 @@ export async function Child (oddCrypto:Crypto, { code:string; query?:string; humanReadableDeviceName:string; -}) { +}):Promise<{ identity:Identity, certificate:Certificate }> { + const myDid = await writeKeyToDid(oddCrypto) const party = new PartySocket({ host, + id: myDid, room: code, query: { token: query }, }) @@ -157,7 +161,7 @@ export async function Child (oddCrypto:Crypto, { return reject(err) } - resolve({ newIdentity, certificate }) + resolve({ identity: newIdentity, certificate }) }) }) } diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..1019b92 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,28 @@ +import * as Party from 'partykit/server' + +export function onConnect ( + party:Party.Server & { existingDevice:string|undefined }, + conn:Party.Connection +) { + if (!party.existingDevice) { + // That means this is a new room. The first connection should be + // the parent device + party.existingDevice = conn.id // we use the DID as the id + } +} + +export function onMessage ( + party:Party.Server & { existingDevice:string|undefined, room:Party.Room }, + message:string, + sender:Party.Connection +) { + if (!party.existingDevice) { + // Should not happen. + throw new Error('Got a message before an existing device connected') + } + + party.room.broadcast( + message, + [sender.id] // don't send to sender's ID + ) +} diff --git a/tsconfig.json b/tsconfig.json index f4a9a34..b2e0a18 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,7 +26,7 @@ "declarationMap": true }, "include": [ - "example", + "example/**/*", "src/**/*", "test", "lib.es5.d.ts"