From 5fc83888cad9259c60d27c2cba3428a6f5989ee9 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Thu, 23 Mar 2023 11:04:33 +0000 Subject: [PATCH 1/6] feat: init typed channels experiment --- package.json | 4 +++ src/utils/typed-channels/index.ts | 42 +++++++++++++++++++++++++++++++ utils/typed-channels/package.json | 5 ++++ 3 files changed, 51 insertions(+) create mode 100644 src/utils/typed-channels/index.ts create mode 100644 utils/typed-channels/package.json diff --git a/package.json b/package.json index 9dcb427e..c5c821af 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,10 @@ "./utils/zod": { "import": "./dist/utils/zod/index.js", "require": "./dist/utils/zod/index.cjs" + }, + "./utils/typed-channels": { + "import": "./dist/utils/typed-channels/index.js", + "require": "./dist/utils/typed-channels/index.cjs" } }, "repository": "https://github.com/hopinc/js.git", diff --git a/src/utils/typed-channels/index.ts b/src/utils/typed-channels/index.ts new file mode 100644 index 00000000..e0b33f24 --- /dev/null +++ b/src/utils/typed-channels/index.ts @@ -0,0 +1,42 @@ +import type {z} from 'zod'; +import type {Hop} from '../../hop.ts'; + +export type AnyEventsDefinition = Record; + +export function createTypedChannelsEmitter< + T extends AnyEventsDefinition = AnyEventsDefinition, +>( + hop: Hop, + schemas?: { + [Key in keyof T]: z.Schema; + }, +) { + return { + selectChannel: (channel: string) => ({ + publish: async ( + event: Extract, + data: T[K], + ) => { + const parsed = (await schemas?.[event].parseAsync(data)) ?? data; + + await hop.channels.publishMessage(channel, event, parsed); + }, + }), + }; +} + +declare const hop: Hop; + +type PublishableEvents = { + CREATE_MESSAGE: { + content: string; + }; +}; + +const client = createTypedChannelsEmitter(hop); + +const messages = client.selectChannel('messages'); + +await messages.publish('CREATE_MESSAGE', { + content: '', +}); diff --git a/utils/typed-channels/package.json b/utils/typed-channels/package.json new file mode 100644 index 00000000..00eb032e --- /dev/null +++ b/utils/typed-channels/package.json @@ -0,0 +1,5 @@ +{ + "main": "../../dist/utils/typed-channels/index.js", + "module": "../../dist/utils/typed-channels/index.mjs", + "types": "../../dist/utils/typed-channels/index.d.ts" +} From 97890fb9500ca7a296fba48d015ae085cac87975 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Thu, 23 Mar 2023 11:13:48 +0000 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=91=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/typed-channels/README.md | 47 ++++++++++++++++++++++++++++++ src/utils/typed-channels/index.ts | 16 ---------- 2 files changed, 47 insertions(+), 16 deletions(-) create mode 100644 src/utils/typed-channels/README.md diff --git a/src/utils/typed-channels/README.md b/src/utils/typed-channels/README.md new file mode 100644 index 00000000..76a80e6b --- /dev/null +++ b/src/utils/typed-channels/README.md @@ -0,0 +1,47 @@ +# typed-channels + +Typed channels is a small experiment that will (probably) ship eventually. It's a small wrapper to make it easier to work with channels and TypeScript. You can share a single object of all possible events between your backend and client, so that you'll get compile-time errors if you try to send an event that doesn't exist . + +## Example + +```ts +// Create or import your Hop instance +const hop = new Hop(auth); + +// Define a type of all events that +// can be published +type PublishableEvents = { + CREATE_MESSAGE: { + id: string; + content: string; + }; + + DELETE_MESSAGE: { + id: string; + }; + + UPDATE_MESSAGE: { + id: string; + content: string; + }; +}; + +const emitter = createTypedChannelsEmitter(hop); + +const messages = emitter.selectChannel('messages'); + +await messages.publish('CREATE_MESSAGE', { + id: Math.random().toString(36).substr(2, 9), + content: 'Hello World', +}); + +// This will throw a compile-time error +await messages.publish('INVALID_EVENT', { + troll: 'lololol', + epic: 'fail', + + // if you see this then congrats for digging through + // the commit history of this repo 👍👍👍 + google_search_bar: 'epic club penguin fail 2019 working method 100% legit', +}); +``` diff --git a/src/utils/typed-channels/index.ts b/src/utils/typed-channels/index.ts index e0b33f24..a861fab4 100644 --- a/src/utils/typed-channels/index.ts +++ b/src/utils/typed-channels/index.ts @@ -24,19 +24,3 @@ export function createTypedChannelsEmitter< }), }; } - -declare const hop: Hop; - -type PublishableEvents = { - CREATE_MESSAGE: { - content: string; - }; -}; - -const client = createTypedChannelsEmitter(hop); - -const messages = client.selectChannel('messages'); - -await messages.publish('CREATE_MESSAGE', { - content: '', -}); From d13a809ef522fa3944ee808f9b1ef1d7397d1282 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Thu, 23 Mar 2023 11:38:05 +0000 Subject: [PATCH 3/6] api changes/experimenting --- src/index.ts | 1 + src/sdks/channels.ts | 14 ++-- src/utils/typed-channels/index.ts | 130 +++++++++++++++++++++++++----- 3 files changed, 117 insertions(+), 28 deletions(-) diff --git a/src/index.ts b/src/index.ts index 0002ebaf..8d3bfb66 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,3 +15,4 @@ export { type BuildEnvironment, } from './rest/types/ignite.ts'; export * from './util/index.ts'; +export * as sdks from './sdks/index.ts'; diff --git a/src/sdks/channels.ts b/src/sdks/channels.ts index 97208c2b..26a80a8d 100644 --- a/src/sdks/channels.ts +++ b/src/sdks/channels.ts @@ -6,7 +6,7 @@ import {sdk} from './create.ts'; * New state to set to a channel, or a callback function that will produce the new state * @public */ -export type SetStateAction = +export type ChannelSetStateAction = | T | ((oldState: T) => T | Promise); @@ -17,13 +17,13 @@ export type SetStateAction = export const channels = sdk(client => { const Channels = create().methods({ async setState( - state: SetStateAction, + state: ChannelSetStateAction, ) { await updateState(this.id, state, 'set'); }, async patchState( - state: SetStateAction, + state: ChannelSetStateAction, ) { await updateState(this.id, state, 'patch'); }, @@ -43,7 +43,7 @@ export const channels = sdk(client => { async function updateState( channelId: API.Channels.Channel['id'], - newState: SetStateAction, + newState: ChannelSetStateAction, mode: 'patch' | 'set', ) { let state: API.Channels.AnyStateObject; @@ -138,7 +138,7 @@ export const channels = sdk(client => { async subscribeTokens( channel: API.Channels.Channel | API.Channels.Channel['id'], - tokens: Id<'leap_token'>[] | Set>, + tokens: Iterable>, ) { const promises: Array> = []; @@ -165,7 +165,7 @@ export const channels = sdk(client => { T extends API.Channels.AnyStateObject = API.Channels.AnyStateObject, >( channel: API.Channels.Channel | API.Channels.Channel['id'], - state: SetStateAction, + state: ChannelSetStateAction, ) { const id = typeof channel === 'object' ? channel.id : channel; return updateState(id, state, 'set'); @@ -173,7 +173,7 @@ export const channels = sdk(client => { async patchState( channel: API.Channels.Channel | API.Channels.Channel['id'], - state: SetStateAction, + state: ChannelSetStateAction, ) { const id = typeof channel === 'object' ? channel.id : channel; return updateState(id, state, 'patch'); diff --git a/src/utils/typed-channels/index.ts b/src/utils/typed-channels/index.ts index a861fab4..08e02ab9 100644 --- a/src/utils/typed-channels/index.ts +++ b/src/utils/typed-channels/index.ts @@ -1,26 +1,114 @@ -import type {z} from 'zod'; -import type {Hop} from '../../hop.ts'; +import {z} from 'zod'; +import type {AnyStateObject, Id, Hop, sdks} from '../../index.ts'; export type AnyEventsDefinition = Record; -export function createTypedChannelsEmitter< - T extends AnyEventsDefinition = AnyEventsDefinition, ->( - hop: Hop, - schemas?: { +export type StateClientDefinition = { + schema: + | z.ZodObject<{ + [Key in keyof State]: z.Schema; + }> + | undefined; +}; + +export type EventsClientDefinition = { + schemas: + | { + [Key in keyof Events]: z.Schema; + } + | undefined; +}; + +export namespace typedChannelsClient { + export function create< + State extends AnyStateObject, + Events extends AnyEventsDefinition, + >( + hop: Hop, + stateDefinition: StateClientDefinition, + eventsDefinition: EventsClientDefinition, + ) { + return { + selectChannel: (channel: string) => ({ + publish: async ( + event: Extract, + data: Events[K], + ) => { + const parsed = + (await eventsDefinition.schemas?.[event].parseAsync(data)) ?? data; + + return hop.channels.publishMessage(channel, event, parsed); + }, + + delete: async () => { + return hop.channels.delete(channel); + }, + + subscribeTokens: async (tokens: Iterable>) => { + return hop.channels.subscribeTokens(channel, tokens); + }, + + setState: async (state: sdks.ChannelSetStateAction) => { + if (stateDefinition.schema) { + const parsed = await stateDefinition.schema.parseAsync(state); + return hop.channels.setState(channel, parsed); + } + + return hop.channels.setState(channel, state); + }, + + patchState: async (state: sdks.ChannelSetStateAction) => { + if (stateDefinition.schema) { + const parsed = await stateDefinition.schema + .partial() + .parseAsync(state); + + return hop.channels.setState(channel, parsed); + } + + return hop.channels.patchState(channel, state); + }, + }), + }; + } + + export function state( + schema?: z.ZodObject<{ + [Key in keyof T]: z.Schema; + }>, + ): StateClientDefinition { + return { + schema, + }; + } + + export function events< + T extends AnyEventsDefinition = AnyEventsDefinition, + >(schemas?: { [Key in keyof T]: z.Schema; - }, -) { - return { - selectChannel: (channel: string) => ({ - publish: async ( - event: Extract, - data: T[K], - ) => { - const parsed = (await schemas?.[event].parseAsync(data)) ?? data; - - await hop.channels.publishMessage(channel, event, parsed); - }, - }), - }; + }) { + return {schemas}; + } } + +declare const hop: Hop; + +const client = typedChannelsClient.create( + hop, + typedChannelsClient.state<{name: string}>(), + typedChannelsClient.events<{ + CREATE_MESSAGE: { + content: string; + }; + }>({ + CREATE_MESSAGE: z.object({ + content: z.string(), + }), + }), +); + +const messages = client.selectChannel('messages'); + +await messages.publish('CREATE_MESSAGE', { + content: 'Hello world', +}); From 28cb54897bcb5903ba454084300111dd223c391d Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Thu, 23 Mar 2023 11:41:18 +0000 Subject: [PATCH 4/6] readme --- src/utils/typed-channels/README.md | 57 ++++++++++++++++-------------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/src/utils/typed-channels/README.md b/src/utils/typed-channels/README.md index 76a80e6b..ec5c1bc7 100644 --- a/src/utils/typed-channels/README.md +++ b/src/utils/typed-channels/README.md @@ -2,33 +2,43 @@ Typed channels is a small experiment that will (probably) ship eventually. It's a small wrapper to make it easier to work with channels and TypeScript. You can share a single object of all possible events between your backend and client, so that you'll get compile-time errors if you try to send an event that doesn't exist . +> **Info** +> Types can be defined in two ways here. Either with Zod schemas, or with TypeScript types. The Zod schemas are recommended, as you'll still have compile time safety, but you'll also get runtime validation. If you're using TypeScript types, you'll only get compile-time safety. + ## Example ```ts // Create or import your Hop instance const hop = new Hop(auth); -// Define a type of all events that -// can be published -type PublishableEvents = { - CREATE_MESSAGE: { - id: string; - content: string; - }; - - DELETE_MESSAGE: { - id: string; - }; - - UPDATE_MESSAGE: { - id: string; - content: string; - }; -}; - -const emitter = createTypedChannelsEmitter(hop); - -const messages = emitter.selectChannel('messages'); +// With types... +const client = typedChannelsClient.create( + hop, + typedChannelsClient.state<{name: string}>(), + typedChannelsClient.events<{ + CREATE_MESSAGE: { + id: string; + content: string; + } + }>(), +); + + +// Or, with Zod Schemas (recommended) +const client = typedChannelsClient.create( + hop, + typedChannelsClient.state( + z.object({ + name: z.string() + }) + ), + typedChannelsClient.events({ + CREATE_MESSAGE: z.object({ + id: z.string() + content: z.string(), + }), + }), +); await messages.publish('CREATE_MESSAGE', { id: Math.random().toString(36).substr(2, 9), @@ -37,11 +47,6 @@ await messages.publish('CREATE_MESSAGE', { // This will throw a compile-time error await messages.publish('INVALID_EVENT', { - troll: 'lololol', epic: 'fail', - - // if you see this then congrats for digging through - // the commit history of this repo 👍👍👍 - google_search_bar: 'epic club penguin fail 2019 working method 100% legit', }); ``` From 3330617e0f8922d732b01b45ec1e54b926a729ea Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Thu, 23 Mar 2023 11:47:33 +0000 Subject: [PATCH 5/6] note? info? --- src/utils/typed-channels/README.md | 2 +- src/utils/typed-channels/index.ts | 22 ---------------------- 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/src/utils/typed-channels/README.md b/src/utils/typed-channels/README.md index ec5c1bc7..42c3c4a9 100644 --- a/src/utils/typed-channels/README.md +++ b/src/utils/typed-channels/README.md @@ -2,7 +2,7 @@ Typed channels is a small experiment that will (probably) ship eventually. It's a small wrapper to make it easier to work with channels and TypeScript. You can share a single object of all possible events between your backend and client, so that you'll get compile-time errors if you try to send an event that doesn't exist . -> **Info** +> **Note** > Types can be defined in two ways here. Either with Zod schemas, or with TypeScript types. The Zod schemas are recommended, as you'll still have compile time safety, but you'll also get runtime validation. If you're using TypeScript types, you'll only get compile-time safety. ## Example diff --git a/src/utils/typed-channels/index.ts b/src/utils/typed-channels/index.ts index 08e02ab9..3a4fc25d 100644 --- a/src/utils/typed-channels/index.ts +++ b/src/utils/typed-channels/index.ts @@ -90,25 +90,3 @@ export namespace typedChannelsClient { return {schemas}; } } - -declare const hop: Hop; - -const client = typedChannelsClient.create( - hop, - typedChannelsClient.state<{name: string}>(), - typedChannelsClient.events<{ - CREATE_MESSAGE: { - content: string; - }; - }>({ - CREATE_MESSAGE: z.object({ - content: z.string(), - }), - }), -); - -const messages = client.selectChannel('messages'); - -await messages.publish('CREATE_MESSAGE', { - content: 'Hello world', -}); From 31bb3ccadf8dc73af9b74ba8dffa9ec4afc2e657 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Thu, 23 Mar 2023 11:53:47 +0000 Subject: [PATCH 6/6] ok --- src/utils/typed-channels/index.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/utils/typed-channels/index.ts b/src/utils/typed-channels/index.ts index 3a4fc25d..f6ac1668 100644 --- a/src/utils/typed-channels/index.ts +++ b/src/utils/typed-channels/index.ts @@ -19,7 +19,7 @@ export type EventsClientDefinition = { | undefined; }; -export namespace typedChannelsClient { +export namespace unstable_typedChannelsClient { export function create< State extends AnyStateObject, Events extends AnyEventsDefinition, @@ -34,10 +34,15 @@ export namespace typedChannelsClient { event: Extract, data: Events[K], ) => { - const parsed = - (await eventsDefinition.schemas?.[event].parseAsync(data)) ?? data; + if (eventsDefinition.schemas?.[event]) { + const parsed = await eventsDefinition.schemas[event].parseAsync( + data, + ); - return hop.channels.publishMessage(channel, event, parsed); + return hop.channels.publishMessage(channel, event, parsed); + } + + return hop.channels.publishMessage(channel, event, data); }, delete: async () => { @@ -51,6 +56,7 @@ export namespace typedChannelsClient { setState: async (state: sdks.ChannelSetStateAction) => { if (stateDefinition.schema) { const parsed = await stateDefinition.schema.parseAsync(state); + return hop.channels.setState(channel, parsed); }