diff --git a/connection.ts b/connection.ts index ba7c527f..c6e33fb3 100644 --- a/connection.ts +++ b/connection.ts @@ -1,9 +1,15 @@ import type { Backoff } from "./backoff.ts"; import type { ConnectionEventMap } from "./events.ts"; import type { ErrorReplyError } from "./errors.ts"; +import type { + DefaultPubSubMessageType, + PubSubMessageType, + RedisSubscription, +} from "./subscription.ts"; import type { TypedEventTarget } from "./internal/typed_event_target.ts"; import type { kUnstableCreateProtocol, + kUnstableCreateSubscription, kUnstablePipeline, kUnstableProtover, kUnstableReadReply, @@ -40,6 +46,12 @@ export interface Connection extends TypedEventTarget { args?: Array, options?: SendCommandOptions, ): Promise; + /** + * @private + */ + [kUnstableCreateSubscription]< + TMessage extends PubSubMessageType = DefaultPubSubMessageType, + >(): RedisSubscription; /** * @private */ diff --git a/default_client.ts b/default_client.ts index f42ed995..46fc17eb 100644 --- a/default_client.ts +++ b/default_client.ts @@ -6,7 +6,7 @@ import type { SubscribeCommand, } from "./subscription.ts"; import type { Connection, SendCommandOptions } from "./connection.ts"; -import { DefaultRedisSubscription } from "./default_subscription.ts"; +import { kUnstableCreateSubscription } from "./internal/symbols.ts"; import type { RedisReply, RedisValue } from "./protocol/shared/types.ts"; export function createDefaultClient(connection: Connection): Client { @@ -37,9 +37,9 @@ class DefaultClient implements Client { command: SubscribeCommand, ...channelsOrPatterns: Array ): Promise> { - const subscription = new DefaultRedisSubscription( - this.connection, - ); + const subscription = this.connection[kUnstableCreateSubscription]< + TMessage + >(); switch (command) { case "SUBSCRIBE": await subscription.subscribe(...channelsOrPatterns); diff --git a/default_connection.ts b/default_connection.ts index 6be083ee..770c9972 100644 --- a/default_connection.ts +++ b/default_connection.ts @@ -5,14 +5,21 @@ import type { RedisConnectionOptions, SendCommandOptions, } from "./connection.ts"; +import { DefaultRedisSubscription } from "./default_subscription.ts"; import { ErrorReplyError, InvalidStateError, isRetriableError, } from "./errors.ts"; import type { ConnectionEventMap } from "./events.ts"; +import type { + DefaultPubSubMessageType, + PubSubMessageType, + RedisSubscription, +} from "./subscription.ts"; import { kUnstableCreateProtocol, + kUnstableCreateSubscription, kUnstablePipeline, kUnstableProtover, kUnstableReadReply, @@ -27,7 +34,11 @@ import { import { kEmptyRedisArgs } from "./protocol/shared/command.ts"; import type { Command, Protocol } from "./protocol/shared/protocol.ts"; import { Protocol as DenoStreamsProtocol } from "./protocol/deno_streams/mod.ts"; -import type { RedisReply, RedisValue } from "./protocol/shared/types.ts"; +import type { + Protover, + RedisReply, + RedisValue, +} from "./protocol/shared/types.ts"; import { delay } from "./deps/std/async.ts"; export function createRedisConnection( @@ -58,6 +69,8 @@ class RedisConnection private commandQueue: PendingCommand[] = []; #conn!: Deno.Conn; #protocol!: Protocol; + /** @default {2} */ + #protover?: Protover; #eventTarget = createTypedEventTarget(); #connectingPromise?: PromiseWithResolvers; @@ -191,6 +204,12 @@ class RedisConnection ); } + [kUnstableCreateSubscription]< + TMessage extends PubSubMessageType = DefaultPubSubMessageType, + >(): RedisSubscription { + return new DefaultRedisSubscription(this); + } + [kUnstableReadReply](returnsUint8Arrays?: boolean): Promise { return this.#protocol.readReply(returnsUint8Arrays); } @@ -292,9 +311,11 @@ class RedisConnection await this.authenticate(this.options.username, this.options.password); } if (this.options[kUnstableProtover] != null) { - await this.#sendCommandImmediately("HELLO", [ - this.options[kUnstableProtover], - ]); + const protover = this.options[kUnstableProtover]; + await this.#sendCommandImmediately("HELLO", [protover]); + if (protover !== 2) { + this.#protover = protover; + } } if (this.options.db) { await this.selectDb(this.options.db); diff --git a/internal/symbols.ts b/internal/symbols.ts index 6954325e..b7e9b019 100644 --- a/internal/symbols.ts +++ b/internal/symbols.ts @@ -27,3 +27,10 @@ export const kUnstableProtover = Symbol("deno-redis.protover"); * @private */ export const kUnstableStartReadLoop = Symbol("deno-redis.startReadLoop"); + +/** + * @private + */ +export const kUnstableCreateSubscription = Symbol( + "deno-redis.createSubscription", +); diff --git a/pool/client.ts b/pool/client.ts index 35a7b511..e0b30388 100644 --- a/pool/client.ts +++ b/pool/client.ts @@ -9,6 +9,7 @@ import type { } from "../subscription.ts"; import { createDefaultClient } from "../default_client.ts"; import { + kUnstableCreateSubscription, kUnstablePipeline, kUnstableReadReply, kUnstableStartReadLoop, @@ -100,6 +101,7 @@ function createPoolConnection( "addEventListener", "removeEventListener", Symbol.dispose, + kUnstableCreateSubscription, kUnstableReadReply, kUnstableWriteCommand, kUnstablePipeline, diff --git a/protocol/deno_streams/mod.ts b/protocol/deno_streams/mod.ts index 79d14195..e45ccdbd 100644 --- a/protocol/deno_streams/mod.ts +++ b/protocol/deno_streams/mod.ts @@ -1,10 +1,15 @@ import { BufReader, BufWriter } from "../../deps/std/io.ts"; -import { readReply } from "./reply.ts"; +import { readOrEmitReply, readReply } from "./reply.ts"; import { sendCommand, sendCommands, writeCommand } from "./command.ts"; import type { Command, Protocol as BaseProtocol } from "../shared/protocol.ts"; -import type { RedisReply, RedisValue } from "../shared/types.ts"; +import type { + ProtocolEvents, + RedisReply, + RedisValue, +} from "../shared/types.ts"; import type { ErrorReplyError } from "../../errors.ts"; +import type { TypedEventTarget } from "../../internal/typed_event_target.ts"; export class Protocol implements BaseProtocol { #reader: BufReader; @@ -33,6 +38,13 @@ export class Protocol implements BaseProtocol { return readReply(this.#reader, returnsUint8Arrays); } + readOrEmitReply( + eventTarget: TypedEventTarget, + returnsUint8Arrays?: boolean, + ): Promise { + return readOrEmitReply(this.#reader, eventTarget, returnsUint8Arrays); + } + async writeCommand(command: Command): Promise { await writeCommand(this.#writer, command.command, command.args); await this.#writer.flush(); diff --git a/protocol/deno_streams/reply.ts b/protocol/deno_streams/reply.ts index 64a8b2b4..406f5461 100644 --- a/protocol/deno_streams/reply.ts +++ b/protocol/deno_streams/reply.ts @@ -1,5 +1,6 @@ import type { BufReader } from "../../deps/std/io.ts"; import type * as types from "../shared/types.ts"; +import type { ProtocolEvents } from "../shared/types.ts"; import { ArrayReplyCode, AttributeReplyCode, @@ -19,17 +20,32 @@ import { } from "../shared/reply.ts"; import { EOFError, ErrorReplyError, InvalidStateError } from "../../errors.ts"; import { decoder } from "../../internal/encoding.ts"; +import type { TypedEventTarget } from "../../internal/typed_event_target.ts"; +import { dispatchEvent } from "../../internal/typed_event_target.ts"; -export async function readReply( +export async function readOrEmitReply( reader: BufReader, + eventTarget: TypedEventTarget, returnUint8Arrays?: boolean, ): Promise { - const res = await reader.peek(1); - if (res == null) { - throw new EOFError(); + const code = await peekReplyCode(reader); + if (code === ErrorReplyCode) { + await readErrorReplyOrFail(reader); } - const code = res[0]; + if (code === PushReplyCode) { + const reply = await readPushReply(reader, returnUint8Arrays); + dispatchEvent(eventTarget, "push", reply ?? []); + return readOrEmitReply(reader, eventTarget, returnUint8Arrays); + } + return readReply(reader, returnUint8Arrays); +} + +export async function readReply( + reader: BufReader, + returnUint8Arrays?: boolean, +): Promise { + const code = await peekReplyCode(reader); if (code === ErrorReplyCode) { await readErrorReplyOrFail(reader); } @@ -73,6 +89,15 @@ export async function readReply( } } +async function peekReplyCode(reader: BufReader): Promise { + const res = await reader.peek(1); + if (res == null) { + throw new EOFError(); + } + const code = res[0]; + return code; +} + async function readIntegerReply( reader: BufReader, ): Promise { diff --git a/protocol/shared/protocol.ts b/protocol/shared/protocol.ts index 810cdf34..f8ec1c8e 100644 --- a/protocol/shared/protocol.ts +++ b/protocol/shared/protocol.ts @@ -1,5 +1,7 @@ import type { RedisReply, RedisValue } from "./types.ts"; import type { ErrorReplyError } from "../../errors.ts"; +import type { TypedEventTarget } from "../../internal/typed_event_target.ts"; +import type { ProtocolEvents } from "./types.ts"; export interface Command { command: string; @@ -14,6 +16,10 @@ export interface Protocol { returnsUint8Arrays?: boolean, ): Promise; readReply(returnsUint8Array?: boolean): Promise; + readOrEmitReply( + eventTarget: TypedEventTarget, + returnsUint8Array?: boolean, + ): Promise; writeCommand(command: Command): Promise; pipeline( commands: Array, diff --git a/protocol/shared/types.ts b/protocol/shared/types.ts index ef47abfb..f70b1889 100644 --- a/protocol/shared/types.ts +++ b/protocol/shared/types.ts @@ -50,3 +50,19 @@ export type RawOrError = Raw | ErrorReplyError; export const okReply = "OK"; export type Protover = 2 | 3; + +export type ProtocolEvents = { + push: Array; +}; + +/** + * TODO: This is a dirty hack to distinguish push replies from regular array replies. + */ +class PushReply extends Array {} + +export function newPushReply(size: number): Array { + return new PushReply(size); +} +export function isPushReply(reply: RedisReply): reply is Array { + return reply instanceof PushReply; +} diff --git a/protocol/shared/types_test.ts b/protocol/shared/types_test.ts new file mode 100644 index 00000000..640fe08a --- /dev/null +++ b/protocol/shared/types_test.ts @@ -0,0 +1,38 @@ +import type { RedisReply } from "./types.ts"; +import { isPushReply, newPushReply } from "./types.ts"; +import { assertEquals } from "../../deps/std/assert.ts"; + +Deno.test({ + name: "isPushReply", + permissions: "none", + fn: async (t) => { + await t.step("returns false for a regular array", () => { + const given: Array = []; + const expected = false; + const actual = isPushReply(given); + assertEquals(actual, expected); + }); + + await t.step("returns true for an array created by newPushReply", () => { + const given = newPushReply(0); + const expected = true; + const actual = isPushReply(given); + assertEquals(actual, expected); + }); + }, +}); + +Deno.test({ + name: "newPushReply", + permissions: "none", + fn: async (t) => { + await t.step("works just like a regular array", () => { + const subject = newPushReply(2); + assertEquals(subject.length, 2); + subject[0] = 1; + subject[1] = 2; + subject.push(3); + assertEquals([...subject], [1, 2, 3]); + }); + }, +}); diff --git a/protocol/web_streams/mod.ts b/protocol/web_streams/mod.ts index 9fe1379b..4f2db65e 100644 --- a/protocol/web_streams/mod.ts +++ b/protocol/web_streams/mod.ts @@ -1,9 +1,14 @@ import { sendCommand, sendCommands, writeCommand } from "./command.ts"; -import { readReply } from "./reply.ts"; +import { readOrEmitReply, readReply } from "./reply.ts"; import type { Command, Protocol as BaseProtocol } from "../shared/protocol.ts"; -import type { RedisReply, RedisValue } from "../shared/types.ts"; +import type { + ProtocolEvents, + RedisReply, + RedisValue, +} from "../shared/types.ts"; import type { ErrorReplyError } from "../../errors.ts"; import { BufferedReadableStream } from "../../internal/buffered_readable_stream.ts"; +import type { TypedEventTarget } from "../../internal/typed_event_target.ts"; export class Protocol implements BaseProtocol { #readable: BufferedReadableStream; @@ -30,6 +35,13 @@ export class Protocol implements BaseProtocol { return readReply(this.#readable, returnsUint8Arrays); } + readOrEmitReply( + eventTarget: TypedEventTarget, + returnsUint8Arrays?: boolean, + ): Promise { + return readOrEmitReply(this.#readable, eventTarget, returnsUint8Arrays); + } + writeCommand(command: Command): Promise { return writeCommand(this.#writable, command.command, command.args); } diff --git a/protocol/web_streams/reply.ts b/protocol/web_streams/reply.ts index 94e62e02..4fe3b31d 100644 --- a/protocol/web_streams/reply.ts +++ b/protocol/web_streams/reply.ts @@ -1,4 +1,5 @@ import type * as types from "../shared/types.ts"; +import type { ProtocolEvents } from "../shared/types.ts"; import { ArrayReplyCode, AttributeReplyCode, @@ -19,12 +20,38 @@ import { import { ErrorReplyError, NotImplementedError } from "../../errors.ts"; import { decoder } from "../../internal/encoding.ts"; import type { BufferedReadableStream } from "../../internal/buffered_readable_stream.ts"; +import type { TypedEventTarget } from "../../internal/typed_event_target.ts"; +import { dispatchEvent } from "../../internal/typed_event_target.ts"; + +export async function readOrEmitReply( + readable: BufferedReadableStream, + eventTarget: TypedEventTarget, + returnUint8Arrays?: boolean, +): Promise { + const line = await readable.readLine(); + const code = line[0]; + if (code === PushReplyCode) { + const reply = await parseArrayLikeReply(line, readable, returnUint8Arrays); + dispatchEvent(eventTarget, "push", reply ?? []); + return readOrEmitReply(readable, eventTarget, returnUint8Arrays); + } else { + return parseLine(line, readable, returnUint8Arrays); + } +} export async function readReply( readable: BufferedReadableStream, returnUint8Arrays?: boolean, -) { +): Promise { const line = await readable.readLine(); + return parseLine(line, readable, returnUint8Arrays); +} + +async function parseLine( + line: Uint8Array, + readable: BufferedReadableStream, + returnUint8Arrays?: boolean, +): Promise { const code = line[0]; switch (code) { case ErrorReplyCode: { @@ -56,16 +83,7 @@ export async function readReply( } case ArrayReplyCode: case PushReplyCode: { - const size = Number.parseInt(decoder.decode(line.slice(1))); - if (size === -1) { - // `-1` indicates a null array - return null; - } - const array: Array = []; - for (let i = 0; i < size; i++) { - array.push(await readReply(readable, returnUint8Arrays)); - } - return array; + return parseArrayLikeReply(line, readable, returnUint8Arrays); } case MapReplyCode: { // NOTE: We treat a map type as an array to keep backward compatibility. @@ -130,3 +148,20 @@ export async function readReply( ); } } + +async function parseArrayLikeReply( + line: Uint8Array, + readable: BufferedReadableStream, + returnUint8Arrays?: boolean, +): Promise | null> { + const size = Number.parseInt(decoder.decode(line.slice(1))); + if (size === -1) { + // `-1` indicates a null array + return null; + } + const array: Array = []; + for (let i = 0; i < size; i++) { + array.push(await readReply(readable, returnUint8Arrays)); + } + return array; +}