Set up project structure, source files, and architecture docs
- Copy core source from alkhub_ts/packages/core/pubsub/ with import path fixups (typed_event_target.ts → types.ts, .ts → .js extensions) - Make PubSubPublishArgsByKey exported (was private type, needed by barrel) - Add package.json with sub-path exports and optional peer deps (ioredis) - Add tsup.config.ts with multi-entry + splitting for tree-shaking - Add tsconfig.json, vitest.config.ts, .gitignore - Add AGENTS.md with project conventions and adapter checklist - Add architecture docs following taskgraph/alkhub pattern: docs/architecture/README.md, api-surface.md, event-targets.md, iroh-transport.md, build-distribution.md - Add ADRs: 001-graphql-yoga-fork, 002-tree-shake-pattern - Copy migration research doc to docs/research/migration.md - Dual-license MIT OR Apache-2.0 (matching taskgraph)
This commit is contained in:
108
src/create_pubsub.ts
Normal file
108
src/create_pubsub.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Adapted from @graphql-yoga/subscription
|
||||
* Original source: https://github.com/graphql-hive/graphql-yoga
|
||||
* License: MIT
|
||||
*
|
||||
* Copyright (c) 2024 The Guild, GraphQL Yoga Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
import { Repeater } from "@repeaterjs/repeater";
|
||||
import type { TypedEventTarget, TypedEvent } from "./types.js";
|
||||
|
||||
export type PubSubPublishArgsByKey = {
|
||||
[key: string]: [] | [unknown] | [number | string, unknown];
|
||||
};
|
||||
|
||||
export type PubSubEvent<
|
||||
TPubSubPublishArgsByKey extends PubSubPublishArgsByKey,
|
||||
TKey extends Extract<keyof TPubSubPublishArgsByKey, string>,
|
||||
> = TypedEvent<
|
||||
TKey,
|
||||
TPubSubPublishArgsByKey[TKey][1] extends undefined
|
||||
? TPubSubPublishArgsByKey[TKey][0]
|
||||
: TPubSubPublishArgsByKey[TKey][1]
|
||||
>;
|
||||
|
||||
export type PubSubEventTarget<TPubSubPublishArgsByKey extends PubSubPublishArgsByKey> =
|
||||
TypedEventTarget<
|
||||
PubSubEvent<TPubSubPublishArgsByKey, Extract<keyof TPubSubPublishArgsByKey, string>>
|
||||
>;
|
||||
|
||||
export type PubSubConfig<TPubSubPublishArgsByKey extends PubSubPublishArgsByKey> = {
|
||||
eventTarget?: PubSubEventTarget<TPubSubPublishArgsByKey>;
|
||||
};
|
||||
|
||||
export type PubSub<TPubSubPublishArgsByKey extends PubSubPublishArgsByKey> = {
|
||||
publish<TKey extends Extract<keyof TPubSubPublishArgsByKey, string>>(
|
||||
routingKey: TKey,
|
||||
...args: TPubSubPublishArgsByKey[TKey]
|
||||
): void;
|
||||
subscribe<TKey extends Extract<keyof TPubSubPublishArgsByKey, string>>(
|
||||
...[routingKey, id]: TPubSubPublishArgsByKey[TKey][1] extends undefined
|
||||
? [TKey]
|
||||
: [TKey, TPubSubPublishArgsByKey[TKey][0]]
|
||||
): Repeater<unknown>;
|
||||
};
|
||||
|
||||
export function createPubSub<TPubSubPublishArgsByKey extends PubSubPublishArgsByKey>(
|
||||
config?: PubSubConfig<TPubSubPublishArgsByKey>,
|
||||
): PubSub<TPubSubPublishArgsByKey> {
|
||||
const target =
|
||||
config?.eventTarget ?? (new EventTarget() as PubSubEventTarget<TPubSubPublishArgsByKey>);
|
||||
|
||||
return {
|
||||
publish<TKey extends Extract<keyof TPubSubPublishArgsByKey, string>>(
|
||||
routingKey: TKey,
|
||||
...args: TPubSubPublishArgsByKey[TKey]
|
||||
) {
|
||||
const payload = args[1] ?? args[0] ?? null;
|
||||
const topic = args[1] === undefined ? routingKey : `${routingKey}:${args[0] as number}`;
|
||||
|
||||
const event = new CustomEvent(topic, { detail: payload }) as PubSubEvent<
|
||||
TPubSubPublishArgsByKey,
|
||||
TKey
|
||||
>;
|
||||
target.dispatchEvent(event);
|
||||
},
|
||||
subscribe<TKey extends Extract<keyof TPubSubPublishArgsByKey, string>>(
|
||||
...[routingKey, id]: TPubSubPublishArgsByKey[TKey][1] extends undefined
|
||||
? [TKey]
|
||||
: [TKey, TPubSubPublishArgsByKey[TKey][0]]
|
||||
): Repeater<unknown> {
|
||||
const topic: TKey = (id === undefined ? routingKey : `${routingKey}:${id as number}`) as TKey;
|
||||
|
||||
return new Repeater(function subscriptionRepeater(
|
||||
next: (value: unknown) => Promise<void>,
|
||||
stop: Promise<void>,
|
||||
) {
|
||||
function pubsubEventListener(event: CustomEvent) {
|
||||
next(event.detail);
|
||||
}
|
||||
|
||||
stop.then(function subscriptionRepeaterStopHandler() {
|
||||
target.removeEventListener(topic, pubsubEventListener as EventListener);
|
||||
});
|
||||
|
||||
target.addEventListener(topic, pubsubEventListener as EventListener, undefined);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
117
src/event-target-redis.ts
Normal file
117
src/event-target-redis.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Adapted from @graphql-yoga/redis-event-target
|
||||
* Original source: https://github.com/graphql-hive/graphql-yoga
|
||||
* License: MIT
|
||||
*
|
||||
* Copyright (c) 2024 The Guild, GraphQL Yoga Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*
|
||||
* Changes from original:
|
||||
* - Uses native CustomEvent instead of @whatwg-node/events ponyfill (Deno has CustomEvent)
|
||||
* - Uses our TypedEventTarget/TypedEvent types from types.ts
|
||||
* - Removed tslib dependency
|
||||
* - Uses ioredis types directly (already a dependency)
|
||||
*/
|
||||
|
||||
import type { Cluster, Redis } from "ioredis";
|
||||
import type { TypedEventTarget, TypedEvent } from "./types.js";
|
||||
|
||||
export type CreateRedisEventTargetArgs = {
|
||||
publishClient: Redis | Cluster;
|
||||
subscribeClient: Redis | Cluster;
|
||||
serializer?: {
|
||||
stringify: (message: unknown) => string;
|
||||
parse: (message: string) => unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export function createRedisEventTarget<TEvent extends TypedEvent>(
|
||||
args: CreateRedisEventTargetArgs,
|
||||
): TypedEventTarget<TEvent> {
|
||||
const { publishClient, subscribeClient } = args;
|
||||
|
||||
const serializer = args.serializer ?? JSON;
|
||||
|
||||
const callbacksForTopic = new Map<string, Set<EventListener>>();
|
||||
|
||||
function onMessage(channel: string, message: string) {
|
||||
const callbacks = callbacksForTopic.get(channel);
|
||||
if (callbacks === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const event = new CustomEvent(channel, {
|
||||
detail: message === "" ? null : serializer.parse(message),
|
||||
}) as TEvent;
|
||||
for (const callback of callbacks) {
|
||||
callback(event);
|
||||
}
|
||||
}
|
||||
|
||||
(subscribeClient as Redis).on("message", onMessage);
|
||||
|
||||
function addCallback(topic: string, callback: EventListener) {
|
||||
let callbacks = callbacksForTopic.get(topic);
|
||||
if (callbacks === undefined) {
|
||||
callbacks = new Set();
|
||||
callbacksForTopic.set(topic, callbacks);
|
||||
|
||||
subscribeClient.subscribe(topic);
|
||||
}
|
||||
callbacks.add(callback);
|
||||
}
|
||||
|
||||
function removeCallback(topic: string, callback: EventListener) {
|
||||
const callbacks = callbacksForTopic.get(topic);
|
||||
if (callbacks === undefined) {
|
||||
return;
|
||||
}
|
||||
callbacks.delete(callback);
|
||||
if (callbacks.size > 0) {
|
||||
return;
|
||||
}
|
||||
callbacksForTopic.delete(topic);
|
||||
subscribeClient.unsubscribe(topic);
|
||||
}
|
||||
|
||||
return {
|
||||
addEventListener(topic, callbackOrOptions: EventListenerOrEventListenerObject) {
|
||||
if (callbackOrOptions != null) {
|
||||
const callback =
|
||||
"handleEvent" in callbackOrOptions ? callbackOrOptions.handleEvent : callbackOrOptions;
|
||||
addCallback(topic, callback);
|
||||
}
|
||||
},
|
||||
dispatchEvent(event: TEvent) {
|
||||
publishClient.publish(
|
||||
event.type,
|
||||
event.detail === undefined ? "" : serializer.stringify(event.detail),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
removeEventListener(topic, callbackOrOptions: EventListenerOrEventListenerObject) {
|
||||
if (callbackOrOptions != null) {
|
||||
const callback =
|
||||
"handleEvent" in callbackOrOptions ? callbackOrOptions.handleEvent : callbackOrOptions;
|
||||
removeCallback(topic, callback);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
4
src/index.ts
Normal file
4
src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { createPubSub, type PubSub, type PubSubConfig, type PubSubEvent, type PubSubEventTarget, type PubSubPublishArgsByKey } from "./create_pubsub.js";
|
||||
export { type TypedEvent, type TypedEventTarget, type TypedEventListener, type TypedEventListenerObject, type TypedEventListenerOrEventListenerObject } from "./types.js";
|
||||
export { filter, map, pipe } from "./operators.js";
|
||||
export { Repeater } from "@repeaterjs/repeater";
|
||||
67
src/operators.ts
Normal file
67
src/operators.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Adapted from @graphql-yoga/subscription
|
||||
* Original source: https://github.com/graphql-hive/graphql-yoga
|
||||
* License: MIT
|
||||
*/
|
||||
|
||||
import { Repeater, type Stop, type Push } from "@repeaterjs/repeater";
|
||||
|
||||
export function filter<T, U extends T>(
|
||||
filterFn: (input: T) => input is U,
|
||||
): (source: AsyncIterable<T>) => Repeater<U, void, unknown>;
|
||||
export function filter<T>(
|
||||
filterFn: (input: T) => Promise<boolean> | boolean,
|
||||
): (source: AsyncIterable<T>) => Repeater<T, void, unknown>;
|
||||
export function filter(filterFn: (value: unknown) => Promise<boolean> | boolean) {
|
||||
return (source: AsyncIterable<unknown>) =>
|
||||
new Repeater(async (push: Push<unknown>, stop: Stop) => {
|
||||
const iterable = source[Symbol.asyncIterator]();
|
||||
stop.then(() => {
|
||||
iterable.return?.();
|
||||
});
|
||||
|
||||
let latest: IteratorResult<unknown>;
|
||||
while ((latest = await iterable.next()).done === false) {
|
||||
if (await filterFn(latest.value)) {
|
||||
await push(latest.value);
|
||||
}
|
||||
}
|
||||
stop();
|
||||
});
|
||||
}
|
||||
|
||||
export function map<T, O>(
|
||||
mapper: (input: T) => Promise<O> | O,
|
||||
): (source: AsyncIterable<T>) => Repeater<O, void, unknown> {
|
||||
return (source: AsyncIterable<T>) =>
|
||||
new Repeater(async (push: Push<O>, stop: Stop) => {
|
||||
const iterable = source[Symbol.asyncIterator]();
|
||||
stop.then(() => {
|
||||
iterable.return?.();
|
||||
});
|
||||
|
||||
let latest: IteratorResult<T>;
|
||||
while ((latest = await iterable.next()).done === false) {
|
||||
await push(await mapper(latest.value));
|
||||
}
|
||||
stop();
|
||||
});
|
||||
}
|
||||
|
||||
export function pipe<A>(a: A): A;
|
||||
export function pipe<A, B>(a: A, ab: (a: A) => B): B;
|
||||
export function pipe<A, B, C>(a: A, ab: (a: A) => B, bc: (b: B) => C): C;
|
||||
export function pipe<A, B, C, D>(a: A, ab: (a: A) => B, bc: (b: B) => C, cd: (c: C) => D): D;
|
||||
export function pipe<A, B, C, D, E>(
|
||||
a: A,
|
||||
ab: (a: A) => B,
|
||||
bc: (b: B) => C,
|
||||
cd: (c: C) => D,
|
||||
de: (d: D) => E,
|
||||
): E;
|
||||
export function pipe(
|
||||
a: unknown,
|
||||
...fns: ((arg: unknown) => unknown)[]
|
||||
): unknown {
|
||||
return fns.reduce((acc, fn) => fn(acc), a);
|
||||
}
|
||||
59
src/types.ts
Normal file
59
src/types.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Adapted from @graphql-yoga/typed-event-target
|
||||
* Original source: https://github.com/graphql-hive/graphql-yoga
|
||||
* License: MIT
|
||||
*
|
||||
* Copyright (c) 2024 The Guild, GraphQL Yoga Contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
export type TypedEvent<TType extends string = string, TDetail = unknown> = Omit<
|
||||
CustomEvent<TDetail>,
|
||||
"detail" | "type"
|
||||
> & {
|
||||
type: TType;
|
||||
detail: TDetail;
|
||||
};
|
||||
|
||||
export interface TypedEventListener<TEvent extends TypedEvent> {
|
||||
(evt: TEvent): void;
|
||||
}
|
||||
|
||||
export interface TypedEventListenerObject<TEvent extends TypedEvent> {
|
||||
handleEvent(object: TEvent): void;
|
||||
}
|
||||
|
||||
export type TypedEventListenerOrEventListenerObject<TEvent extends TypedEvent> =
|
||||
| TypedEventListener<TEvent>
|
||||
| TypedEventListenerObject<TEvent>;
|
||||
|
||||
export interface TypedEventTarget<TEvent extends TypedEvent> extends EventTarget {
|
||||
addEventListener<TCurrEvent extends TEvent>(
|
||||
type: TCurrEvent["type"],
|
||||
callback: TypedEventListenerOrEventListenerObject<TCurrEvent> | null,
|
||||
options?: AddEventListenerOptions | boolean,
|
||||
): void;
|
||||
dispatchEvent(event: TEvent): boolean;
|
||||
removeEventListener<TCurrEvent extends TEvent>(
|
||||
type: TCurrEvent["type"],
|
||||
callback: TypedEventListenerOrEventListenerObject<TCurrEvent> | null,
|
||||
options?: EventListenerOptions | boolean,
|
||||
): void;
|
||||
}
|
||||
Reference in New Issue
Block a user