Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

typed channels experiment #109

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ export {
type BuildEnvironment,
} from './rest/types/ignite.ts';
export * from './util/index.ts';
export * as sdks from './sdks/index.ts';
14 changes: 7 additions & 7 deletions src/sdks/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends API.Channels.AnyStateObject> =
export type ChannelSetStateAction<T extends API.Channels.AnyStateObject> =
| T
| ((oldState: T) => T | Promise<T>);

Expand All @@ -17,13 +17,13 @@ export type SetStateAction<T extends API.Channels.AnyStateObject> =
export const channels = sdk(client => {
const Channels = create<API.Channels.Channel>().methods({
async setState<T extends API.Channels.AnyStateObject>(
state: SetStateAction<T>,
state: ChannelSetStateAction<T>,
) {
await updateState(this.id, state, 'set');
},

async patchState<T extends API.Channels.AnyStateObject>(
state: SetStateAction<T>,
state: ChannelSetStateAction<T>,
) {
await updateState(this.id, state, 'patch');
},
Expand All @@ -43,7 +43,7 @@ export const channels = sdk(client => {

async function updateState<T extends API.Channels.AnyStateObject>(
channelId: API.Channels.Channel['id'],
newState: SetStateAction<T>,
newState: ChannelSetStateAction<T>,
mode: 'patch' | 'set',
) {
let state: API.Channels.AnyStateObject;
Expand Down Expand Up @@ -138,7 +138,7 @@ export const channels = sdk(client => {

async subscribeTokens(
channel: API.Channels.Channel | API.Channels.Channel['id'],
tokens: Id<'leap_token'>[] | Set<Id<'leap_token'>>,
tokens: Iterable<Id<'leap_token'>>,
) {
const promises: Array<Promise<void>> = [];

Expand All @@ -165,15 +165,15 @@ export const channels = sdk(client => {
T extends API.Channels.AnyStateObject = API.Channels.AnyStateObject,
>(
channel: API.Channels.Channel | API.Channels.Channel['id'],
state: SetStateAction<T>,
state: ChannelSetStateAction<T>,
) {
const id = typeof channel === 'object' ? channel.id : channel;
return updateState(id, state, 'set');
},

async patchState<T extends API.Channels.AnyStateObject>(
channel: API.Channels.Channel | API.Channels.Channel['id'],
state: SetStateAction<T>,
state: ChannelSetStateAction<T>,
) {
const id = typeof channel === 'object' ? channel.id : channel;
return updateState(id, state, 'patch');
Expand Down
52 changes: 52 additions & 0 deletions src/utils/typed-channels/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# 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 .

> **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

```ts
// Create or import your Hop instance
const hop = new Hop(auth);

// 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),
content: 'Hello World',
});

// This will throw a compile-time error
await messages.publish('INVALID_EVENT', {
epic: 'fail',
});
```
98 changes: 98 additions & 0 deletions src/utils/typed-channels/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import {z} from 'zod';
import type {AnyStateObject, Id, Hop, sdks} from '../../index.ts';

export type AnyEventsDefinition = Record<string, unknown>;

export type StateClientDefinition<State> = {
schema:
| z.ZodObject<{
[Key in keyof State]: z.Schema<State[Key]>;
}>
| undefined;
};

export type EventsClientDefinition<Events> = {
schemas:
| {
[Key in keyof Events]: z.Schema<Events[Key]>;
}
| undefined;
};

export namespace unstable_typedChannelsClient {
export function create<
State extends AnyStateObject,
Events extends AnyEventsDefinition,
>(
hop: Hop,
stateDefinition: StateClientDefinition<State>,
eventsDefinition: EventsClientDefinition<Events>,
) {
return {
selectChannel: (channel: string) => ({
publish: async <K extends keyof Events>(
event: Extract<K, string>,
data: Events[K],
) => {
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, data);
},

delete: async () => {
return hop.channels.delete(channel);
},

subscribeTokens: async (tokens: Iterable<Id<'leap_token'>>) => {
return hop.channels.subscribeTokens(channel, tokens);
},

setState: async (state: sdks.ChannelSetStateAction<State>) => {
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<State>) => {
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<T extends AnyStateObject = AnyStateObject>(
schema?: z.ZodObject<{
[Key in keyof T]: z.Schema<T[Key]>;
}>,
): StateClientDefinition<T> {
return {
schema,
};
}

export function events<
T extends AnyEventsDefinition = AnyEventsDefinition,
>(schemas?: {
[Key in keyof T]: z.Schema<T[Key]>;
}) {
return {schemas};
}
}
5 changes: 5 additions & 0 deletions utils/typed-channels/package.json
Original file line number Diff line number Diff line change
@@ -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"
}