@elysiumjs/redis 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,78 @@
1
+ # @elysiumjs/redis
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [[`651a755`](https://github.com/na2axl/elysiumjs/commit/651a7557173ce2c6155bcfa8d90cf388ac419821)]:
8
+ - @elysiumjs/core@0.12.0
9
+
10
+ ## 0.1.0
11
+
12
+ ### Minor Changes
13
+
14
+ - [#5](https://github.com/na2axl/elysiumjs/pull/5) [`557a3ca`](https://github.com/na2axl/elysiumjs/commit/557a3cab7af8053a8bab9944e492e5022d91aa82) Thanks [@na2axl](https://github.com/na2axl)! - Core runs outside Bun. Environment variables, timers, the HTTP server, process exit and shutdown signals now come from a `Runtime` set with the new `runtime` option of `@Application.register()` or with `Runtime.use()`. Runtimes ship as packages, each exporting `runtime`: `@elysiumjs/runtime-bun`, `@elysiumjs/runtime-node` and `@elysiumjs/runtime-workers` (which also exports `createRuntime({ env })`).
15
+
16
+ Breaking: an application needs a runtime (add `runtime` from `@elysiumjs/runtime-bun`), and `Redis` and `RedisModule` moved from core to the new `@elysiumjs/redis` package, which also accepts a `client` factory for runtimes without `Bun.RedisClient`.
17
+
18
+ Console colours are plain ANSI codes instead of `Bun.color`, `node:readline` is loaded only when a prompt runs, and core's dist is built for the `node` target so `node:` imports keep their prefix. `check:runtime` scripts in core and runtime-workers fail when their bundles touch the `Bun` global.
19
+
20
+ `create-elysium` gets a runtime step: it installs the chosen runtime package and sets it in the generated app. Bun is selectable today; Node.js and Cloudflare Workers are listed as coming later.
21
+
22
+ - [#13](https://github.com/na2axl/elysiumjs/pull/13) [`1ea417e`](https://github.com/na2axl/elysiumjs/commit/1ea417e3583250bc2f975d795f398b7ff736115d) Thanks [@na2axl](https://github.com/na2axl)! - Hardening of the runtimes, the container and the CLI, checked by a conformance suite that runs on Bun, Node.js and workerd.
23
+
24
+ Breaking changes:
25
+
26
+ - `Runtime` gains the required `argv()`, `write(stream, text)` and `terminal()` members: core reaches the host's arguments, output streams and terminal only through the runtime. Custom runtimes must implement them.
27
+ - The third parameter of `InteractsWithConsole.write` is now `'stdout' | 'stderr'`.
28
+ - The WebSocket options are narrowed to `idleTimeout`, `maxPayloadLength` and `perMessageDeflate`, and the `bun` WebSocket types are replaced by `ElysiumWebSocket`.
29
+ - Redis connections are `RedisConnection`s, a port any Redis client can implement, instead of `Bun.RedisClient`.
30
+ - `styx create @scope/name` creates the project in `name/`, and refuses project and module names with upper-case letters.
31
+ - Queue handlers no longer reject the batch: after the handlers ran, each message is acked or retried on its own. `@Workers.queue(name, { each: true })` receives one message at a time, so a failure retries only that message.
32
+
33
+ Fixes:
34
+
35
+ - Request-scoped services are destroyed when the request ends, also on failure, and an async `onInit` is awaited. A second dispose of a request scope waits for the one in progress, so a Worker's `waitUntil` covers an async `onDestroy`.
36
+ - A failed module or service `onInit` no longer breaks later requests: the boot is retried, and the module's event listeners are unwired.
37
+ - `@elysiumjs/runtime-node` serves through `node:http`, keeping the Host header authoritative.
38
+ - `styx create` validates names before creating files, ships `.gitignore`, declares the dependencies of created projects and imports their default controllers.
39
+
40
+ - [#15](https://github.com/na2axl/elysiumjs/pull/15) [`fbcc7da`](https://github.com/na2axl/elysiumjs/commit/fbcc7dad127db29da42e7a4a5c2124966731cd88) Thanks [@na2axl](https://github.com/na2axl)! - Resources, queries, units of work and rate limits.
41
+
42
+ Breaking:
43
+
44
+ - A route with a `response` schema serializes its handler result through it: fields the schema does not declare are dropped, a `Date` becomes an ISO string (`YYYY-MM-DD` for `format: 'date'`), and an object with `toJSON()` becomes what it returns. Set `serialize: false` on a route to send the result as it is.
45
+
46
+ Added:
47
+
48
+ - `Resource.hidden(schema)` marks a field that never reaches responses nor the OpenAPI document. `Resource.serialize()` and `Resource.project()` are exported for use outside routes.
49
+ - Sparse fieldsets: `?fields=id,title` keeps only those fields of an object, of each item of an array, or of each item of a `data` array. An unknown or hidden field answers a 400 problem.
50
+ - Rate limits with `@RateLimit({ window, max, key?, store?, name? })` on routes and controllers (`@RateLimit(false)` turns one off), and a default limit and store in the app's `rateLimit` option, with fixed windows. Responses carry `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` and `RateLimit-Policy`; a client over the limit gets a 429 problem with `Retry-After`. Counts are kept by a `RateLimitStore`: `MemoryRateLimitStore` (core), `RedisRateLimitStore` (`@elysiumjs/redis`) or `WorkersRateLimitStore` over a Cloudflare rate limiting binding (`@elysiumjs/runtime-workers`), which only decides whether a hit is over the limit, so its responses carry no `RateLimit-Reset`.
51
+ - `ModelResource(model, { hidden, pick, omit, extend })` (`@elysiumjs/mnemosyne`) builds a response schema from a model.
52
+ - `Query.define(model, spec)` declares the filters (`filter[field][op]=value`), sorts, page size and cursor a list accepts; `Query.schema()` gives its query schema, `Query.parse()` turns a request query into a `ParsedQuery` and answers a 400 problem with a pointer per mistake, and `Query.page()` / `Query.pageSchema()` build cursor pages (`{ data, nextCursor }`).
53
+ - `DrizzleRepository.list(query)` and `count(query)` run a `ParsedQuery` with keyset pagination (`@elysiumjs/mnemosyne-drizzle`), and `compileQuery()` gives its `where` and `orderBy`.
54
+ - Units of work: `UnitOfWork.run()` and the `@Transactional()` method decorator run work in one transaction, and `UnitOfWork.afterCommit()` / `afterRollback()` defer work, such as sending a message, until the outermost transaction has ended. `transactional` routes open a unit of work too.
55
+
56
+ - [#16](https://github.com/na2axl/elysiumjs/pull/16) [`ac96e55`](https://github.com/na2axl/elysiumjs/commit/ac96e55874b0ffe350b287c4278cd85afecb7576) Thanks [@na2axl](https://github.com/na2axl)! - Redis, databases, logs, jobs and builds on Bun, Node.js and Cloudflare Workers.
57
+
58
+ Breaking:
59
+
60
+ - The level shortcuts of `InteractsWithConsole` (`info`, `success`, `warning`, `error` and `debug`) write a tag (`[I]`, `[S]`, `[W]`, `[E]` and `[D]`, in color) before the message, which `InteractsWithConsole.useTags()` changes. `trace()` writes to stderr.
61
+ - Outside Bun, a Drizzle connection needs a `driver`. Without one, the `connection` and `client` props are a Bun SQL connection, which is only available on Bun; elsewhere the connection throws `MissingDriverError`, which names the drivers.
62
+
63
+ Added:
64
+
65
+ - Redis adapters (`@elysiumjs/redis`): `ioredis(create)` plugs an ioredis client into the Redis port on Node.js, and `upstash({ token, url?, fetch? })` talks to Upstash over its REST API, for Workers (no pub/sub). `bunRedis` explains which adapter to use when it runs outside Bun.
66
+ - Drizzle drivers (`@elysiumjs/mnemosyne-drizzle`): a `DrizzleDriver` port set with the `driver` prop of a connection, and drivers on subpaths so that only the chosen database client is imported: `/bun-sql` (`bunSql()`), `/postgres-js` (`postgresJs()`), `/node-postgres` (`nodePostgres()`) and `/hyperdrive` (`hyperdrive({ binding, driver })`, a client per Worker invocation over a Cloudflare Hyperdrive binding). `DrizzleModule` closes connections through their driver, and `migration:run` runs on any driver.
67
+ - `InteractsWithConsole.write()` and `Runtime.write()` take the level of the line (`OutputLevel`), which the level shortcuts pass. On Cloudflare Workers, a line is written with the console method of its level, so Workers Logs keep it.
68
+ - `JsonConsoleLogger` (`@elysiumjs/hermes`): JSON lines through the console of the runtime (warnings and errors on stderr), with levels, error serialization and `child()`. `HermesModule.with({ transport: 'console' | 'pino' })` picks it, and it is the default on Workers. `RequestLogInterceptor` writes one structured line per request: method, route, status, duration, client address and request id.
69
+ - Scheduled jobs (`@elysiumjs/heracles`): `@Schedule(cron, { timezone, args })` on a job class, a five-field cron parser (`Cron`, with lists, ranges, steps and names), and a `Scheduler` that dispatches the jobs on time on Bun and Node.js, taking a Redis lock per tick when a Redis connection is set. The `heracles:schedule` command runs it.
70
+ - `@elysiumjs/heracles-workers`: `CloudflareQueueTransport` sends jobs to a Cloudflare Queue (dispatch delays become `delaySeconds`), and `HeraclesWorkersModule` consumes them in the Worker's `queue` handler (ack on success, retry with a delay up to the job's retries) and dispatches the `@Schedule()` jobs whose expression is the cron of a trigger.
71
+ - Queue handlers (`@elysiumjs/runtime-workers`) can settle a message themselves with `ack()` or `retry({ delaySeconds })`; the messages they leave are acked when no handler failed on them.
72
+ - `styx build --target node` (`@elysiumjs/hephaestus`) bundles the app into one ES module for Node.js, with decorator metadata emitted at build time, run with `node dist/server.js serve`. `styx build --target workers` runs from the CLI, resolves packages to their browser entry, and leaves Node.js modules imported by bare names to workerd (`nodejs_compat`).
73
+ - `styx create` (`@elysiumjs/styx`) takes `--runtime bun|node|workers`, `--database`, `--cache` and `--package-manager bun|npm|pnpm`. Each runtime gets its entry, tsconfig types, scripts and drivers: postgres.js and ioredis on Node.js; Hyperdrive, Upstash, console logs and a `wrangler.jsonc` on Workers.
74
+
75
+ ### Patch Changes
76
+
77
+ - Updated dependencies [[`6bc48fe`](https://github.com/na2axl/elysiumjs/commit/6bc48febd51f7090c582e73f349eaeff7261241c), [`e9c3d98`](https://github.com/na2axl/elysiumjs/commit/e9c3d98dd2714b69100215f7c8a80046da463148), [`557a3ca`](https://github.com/na2axl/elysiumjs/commit/557a3cab7af8053a8bab9944e492e5022d91aa82), [`ccc3f5b`](https://github.com/na2axl/elysiumjs/commit/ccc3f5bb904d1bf52d8c72ebf7ea8d10f617a8c4), [`6789879`](https://github.com/na2axl/elysiumjs/commit/678987976ecfd46b534b65276e0de3f448977562), [`87299fd`](https://github.com/na2axl/elysiumjs/commit/87299fd9da82d1a408ced1891c0c3d42991444e5), [`bee66bd`](https://github.com/na2axl/elysiumjs/commit/bee66bdc874b8f23fea356f7d3adf7555a48ca64), [`0057b67`](https://github.com/na2axl/elysiumjs/commit/0057b67df0e3abecab8a9accff6b55a6a64c21df), [`632d77b`](https://github.com/na2axl/elysiumjs/commit/632d77b561fc43ade605fb69b06ac1b9b9a2ffc4), [`1ea417e`](https://github.com/na2axl/elysiumjs/commit/1ea417e3583250bc2f975d795f398b7ff736115d), [`0956186`](https://github.com/na2axl/elysiumjs/commit/0956186db0ca5a83c0a6636aeb2a4c81a0033de4), [`fbcc7da`](https://github.com/na2axl/elysiumjs/commit/fbcc7dad127db29da42e7a4a5c2124966731cd88), [`ac96e55`](https://github.com/na2axl/elysiumjs/commit/ac96e55874b0ffe350b287c4278cd85afecb7576), [`98a16aa`](https://github.com/na2axl/elysiumjs/commit/98a16aae889d46f7a9fb5573188f4cbe3629dd3b)]:
78
+ - @elysiumjs/core@0.11.0
package/README.md CHANGED
@@ -1,3 +1,36 @@
1
- # Temporary Holding Version
1
+ # @elysiumjs/redis
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Redis connections for [Elysium.js](https://github.com/na2axl/elysiumjs), as global services named `redis.connection.{name}`.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ bun add @elysiumjs/redis
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```ts
14
+ import { Application } from '@elysiumjs/core';
15
+ import { Redis, RedisModule } from '@elysiumjs/redis';
16
+ import { runtime as bunRuntime } from '@elysiumjs/runtime-bun';
17
+
18
+ @Application.register({
19
+ runtime: bunRuntime,
20
+ modules: [
21
+ RedisModule.with({
22
+ default: 'cache',
23
+ connections: { cache: { url: process.env.REDIS_URL! } }
24
+ })
25
+ ]
26
+ })
27
+ class App extends Application {}
28
+
29
+ const client = Redis.getConnection('cache');
30
+ ```
31
+
32
+ Clients are created on first use with `Bun.RedisClient`. Pass `client: (url, options) => ...` in the module config to use another client.
33
+
34
+ ## License
35
+
36
+ Apache-2.0
@@ -0,0 +1,6 @@
1
+ import type { RedisClientFactory } from '../connection';
2
+ /**
3
+ * Creates a connection with Bun's built-in Redis client, the default client of `RedisModule`.
4
+ * @author Axel Nana <axel.nana@workbud.com>
5
+ */
6
+ export declare const bunRedis: RedisClientFactory;
@@ -0,0 +1,37 @@
1
+ import type { RedisClientFactory } from '../connection';
2
+ /**
3
+ * The parts of an ioredis client the adapter uses. An `ioredis` `Redis` instance fits it.
4
+ * @author Axel Nana <axel.nana@workbud.com>
5
+ */
6
+ export interface IoredisLike {
7
+ readonly status: string;
8
+ call(command: string, ...args: string[]): Promise<unknown>;
9
+ get(key: string): Promise<string | null>;
10
+ set(key: string, value: string): Promise<unknown>;
11
+ del(...keys: string[]): Promise<number>;
12
+ expire(key: string, seconds: number): Promise<number>;
13
+ subscribe(...channels: string[]): Promise<unknown>;
14
+ unsubscribe(...channels: string[]): Promise<unknown>;
15
+ on(event: string, listener: (...args: any[]) => void): unknown;
16
+ off(event: string, listener: (...args: any[]) => void): unknown;
17
+ duplicate(): IoredisLike;
18
+ connect(): Promise<void>;
19
+ quit(): Promise<unknown>;
20
+ }
21
+ /**
22
+ * Plugs an ioredis client into the Redis port, for Node.js. Pass a function that creates the
23
+ * client; the module passes each connection's URL and options to it. Create it with
24
+ * `lazyConnect: true` so that it connects on `connect()`.
25
+ * @author Axel Nana <axel.nana@workbud.com>
26
+ * @param create Creates an ioredis client.
27
+ * @example
28
+ * ```ts
29
+ * import IORedis from 'ioredis';
30
+ *
31
+ * RedisModule.with({
32
+ * client: ioredis((url, options) => new IORedis(url, { ...options, lazyConnect: true })),
33
+ * connections: { default: { url: process.env.REDIS_URL! } }
34
+ * });
35
+ * ```
36
+ */
37
+ export declare const ioredis: (create: (url: string, options: Record<string, unknown>) => IoredisLike) => RedisClientFactory;
@@ -0,0 +1,27 @@
1
+ import type { RedisClientFactory } from '../connection';
2
+ /**
3
+ * Options of {@link upstash}.
4
+ * @author Axel Nana <axel.nana@workbud.com>
5
+ */
6
+ export type UpstashOptions = {
7
+ /** The REST URL of the database. Defaults to the connection URL. */
8
+ url?: string;
9
+ /** The REST token of the database. */
10
+ token: string;
11
+ /** The `fetch` to send commands with. Defaults to the global one. */
12
+ fetch?: typeof fetch;
13
+ };
14
+ /**
15
+ * Plugs Upstash Redis into the Redis port over its REST API, for Cloudflare Workers and other
16
+ * runtimes without TCP sockets. Every command is one HTTP request; pub/sub is not supported.
17
+ * @author Axel Nana <axel.nana@workbud.com>
18
+ * @param options The REST token, and the REST URL when the connection URL is not it.
19
+ * @example
20
+ * ```ts
21
+ * RedisModule.with({
22
+ * client: upstash({ token: env.UPSTASH_REDIS_REST_TOKEN }),
23
+ * connections: { default: { url: env.UPSTASH_REDIS_REST_URL } }
24
+ * });
25
+ * ```
26
+ */
27
+ export declare const upstash: (options: UpstashOptions) => RedisClientFactory;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The surface of a Redis client that the Elysium packages rely on. Any Redis client can be
3
+ * plugged in by adapting it to this interface; everything else goes through {@link send}.
4
+ * @author Axel Nana <axel.nana@workbud.com>
5
+ */
6
+ export interface RedisConnection {
7
+ /** Whether the connection is currently established. */
8
+ readonly connected: boolean;
9
+ /** Called when the connection is established. */
10
+ onconnect?: () => void;
11
+ /** Called when the connection is closed. */
12
+ onclose?: (error: unknown) => void;
13
+ /**
14
+ * Sends a raw command.
15
+ * @param command The command name, e.g. `ZADD`.
16
+ * @param args The arguments of the command.
17
+ */
18
+ send(command: string, args: string[]): Promise<unknown>;
19
+ /** Gets the value of a key, or `null` if it does not exist. */
20
+ get(key: string): Promise<string | null>;
21
+ /** Sets the value of a key. Use `send('SET', [...])` for options such as `PX` or `NX`. */
22
+ set(key: string, value: string): Promise<unknown>;
23
+ /** Deletes keys, resolving to the number of keys deleted. */
24
+ del(...keys: string[]): Promise<number>;
25
+ /** Sets the time to live of a key, resolving to `1` if it was set. */
26
+ expire(key: string, seconds: number): Promise<number>;
27
+ /** Subscribes to a pub/sub channel. */
28
+ subscribe(channel: string, listener: (message: string, channel: string) => void): Promise<unknown>;
29
+ /** Unsubscribes from a channel, or from all channels when none is given. */
30
+ unsubscribe(channel?: string): Promise<unknown>;
31
+ /** Creates a new, unconnected connection with the same configuration. */
32
+ duplicate(): Promise<RedisConnection>;
33
+ /** Establishes the connection. */
34
+ connect(): Promise<void>;
35
+ /** Closes the connection. */
36
+ close(): Promise<void> | void;
37
+ }
38
+ /**
39
+ * Creates the connection to a Redis server.
40
+ * @author Axel Nana <axel.nana@workbud.com>
41
+ */
42
+ export type RedisClientFactory = (url: string, options: Record<string, unknown>) => RedisConnection;
@@ -0,0 +1,8 @@
1
+ import { ElysiumError } from '@elysiumjs/core';
2
+ /**
3
+ * Raised when a Redis connection cannot run a command, such as pub/sub over HTTP.
4
+ * @author Axel Nana <axel.nana@workbud.com>
5
+ */
6
+ export declare class RedisFeatureNotSupportedError extends ElysiumError {
7
+ constructor(adapter: string, feature: string);
8
+ }
@@ -0,0 +1,7 @@
1
+ export { type RedisClientFactory, type RedisConnection } from './connection';
2
+ export { type RedisConnectionProps, type RedisModuleConfig, Redis, RedisModule } from './redis';
3
+ export { bunRedis } from './adapters/bun';
4
+ export { type IoredisLike, ioredis } from './adapters/ioredis';
5
+ export { upstash, type UpstashOptions } from './adapters/upstash';
6
+ export { RedisFeatureNotSupportedError } from './errors';
7
+ export { RedisRateLimitStore } from './rate-limit';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ // @bun
2
+ var __legacyDecorateClassTS=function(decorators,target,key,desc){var c=arguments.length,r=c<3?target:desc===null?desc=Object.getOwnPropertyDescriptor(target,key):desc,d;if(typeof Reflect==="object"&&typeof Reflect.decorate==="function")r=Reflect.decorate(decorators,target,key,desc);else for(var i=decorators.length-1;i>=0;i--)if(d=decorators[i])r=(c<3?d(r):c>3?d(target,key,r):d(target,key))||r;return c>3&&r&&Object.defineProperty(target,key,r),r};import{Module,needsDefaultAlias,runAll,Service,ServiceNotFoundError}from"@elysiumjs/core";import{omit}from"radash";import{ElysiumError}from"@elysiumjs/core";class BunRedisConnection{#client;constructor(client){this.#client=client}get connected(){return this.#client.connected}set onconnect(handler){this.#client.onconnect=handler??null}set onclose(handler){this.#client.onclose=handler??null}send(command,args){return this.#client.send(command,args)}get(key){return this.#client.get(key)}set(key,value){return this.#client.set(key,value)}del(...keys){return this.#client.del(...keys)}expire(key,seconds){return this.#client.expire(key,seconds)}subscribe(channel,listener){return this.#client.subscribe(channel,listener)}unsubscribe(channel){return channel===void 0?this.#client.unsubscribe():this.#client.unsubscribe(channel)}async duplicate(){return new BunRedisConnection(await this.#client.duplicate())}connect(){return this.#client.connect()}close(){this.#client.close()}}var bunRedis=(url,options)=>{if(typeof Bun>"u")throw new ElysiumError("The default Redis client only runs on Bun: pass `client: ioredis(...)` on Node.js, or `client: upstash(...)` on Cloudflare Workers.");return new BunRedisConnection(new Bun.RedisClient(url,options))};var Redis;((Redis)=>(Redis.getConnectionName=(name)=>`redis.connection.${name}`,Redis.getConnection=(name="default")=>{let client=Service.get(Redis.getConnectionName(name));if(client===null)throw new ServiceNotFoundError(Redis.getConnectionName(name));return client},Redis.connectionExists=(name)=>Service.exists(Redis.getConnectionName(name)),Redis.getDefaultConnection=()=>Redis.getConnection("default")))(Redis||={});class RedisModule extends Module.configurable({defaults:{default:"default"},required:["connections"]}){async onDestroy(){let names=Object.keys(this.config.connections).map(Redis.getConnectionName);await runAll(names.filter((name)=>Service.isConstructed(name)).map((name)=>()=>Service.get(name)?.close()))}}RedisModule=__legacyDecorateClassTS([Module.register({services:(config)=>{let alias=needsDefaultAlias("RedisModule",config);return[...Object.entries(config.connections).map(([name,props])=>({name:Redis.getConnectionName(name),global:!0,factory:()=>(config.client??bunRedis)(props.url,omit(props,["url"]))})),...alias?[{name:Redis.getConnectionName("default"),global:!0,factory:(_config,get)=>get(Redis.getConnectionName(config.default))}]:[]]}})],RedisModule);class IoredisConnection{#client;#listeners=new Map;#onconnect;#onclose;constructor(client){this.#client=client,client.on("ready",()=>this.#onconnect?.()),client.on("end",()=>this.#onclose?.(void 0)),client.on("message",(channel,message)=>this.#listeners.get(channel)?.(message,channel))}get connected(){return this.#client.status==="ready"}set onconnect(handler){this.#onconnect=handler}set onclose(handler){this.#onclose=handler}send(command,args){return this.#client.call(command,...args)}get(key){return this.#client.get(key)}set(key,value){return this.#client.set(key,value)}del(...keys){return this.#client.del(...keys)}expire(key,seconds){return this.#client.expire(key,seconds)}async subscribe(channel,listener){return this.#listeners.set(channel,listener),this.#client.subscribe(channel)}async unsubscribe(channel){if(channel===void 0)return this.#listeners.clear(),this.#client.unsubscribe();return this.#listeners.delete(channel),this.#client.unsubscribe(channel)}async duplicate(){return new IoredisConnection(this.#client.duplicate())}async connect(){if(this.#client.status==="ready"||this.#client.status==="connecting")return;await this.#client.connect()}async close(){await this.#client.quit()}}var ioredis=(create)=>(url,options)=>new IoredisConnection(create(url,options));import{ElysiumError as ElysiumError3}from"@elysiumjs/core";import{ElysiumError as ElysiumError2}from"@elysiumjs/core";class RedisFeatureNotSupportedError extends ElysiumError2{constructor(adapter,feature){super(`The ${adapter} Redis connection does not support ${feature}.`)}}class UpstashConnection{#url;#token;#fetch;constructor(url,token,fetcher){this.#url=url.replace(/\/+$/,""),this.#token=token,this.#fetch=fetcher}connected=!0;onconnect;onclose;async send(command,args){let response=await this.#fetch(this.#url,{method:"POST",headers:{authorization:`Bearer ${this.#token}`,"content-type":"application/json"},body:JSON.stringify([command,...args])}),body=await response.json().catch(()=>({}));if(!response.ok||body.error!==void 0)throw new ElysiumError3(`Upstash answered ${response.status} to ${command}: ${body.error??"no error message"}`);return body.result??null}async get(key){return await this.send("GET",[key])}set(key,value){return this.send("SET",[key,value])}async del(...keys){return Number(await this.send("DEL",keys))}async expire(key,seconds){return Number(await this.send("EXPIRE",[key,String(seconds)]))}subscribe(){return Promise.reject(new RedisFeatureNotSupportedError("Upstash REST","pub/sub"))}unsubscribe(){return Promise.reject(new RedisFeatureNotSupportedError("Upstash REST","pub/sub"))}async duplicate(){return new UpstashConnection(this.#url,this.#token,this.#fetch)}async connect(){this.onconnect?.()}close(){this.onclose?.(void 0)}}var upstash=(options)=>(url)=>{let restUrl=options.url??url;if(!restUrl||!options.token)throw new ElysiumError3("The Upstash Redis connection needs a REST URL and a REST token. Set the connection URL (or the url option) and the token option.");return new UpstashConnection(restUrl,options.token,options.fetch??fetch)};class RedisRateLimitStore{#connection;#prefix;constructor(options={}){this.#connection=options.connection??"default",this.#prefix=options.prefix??""}async hit(key,windowMs){let redis=typeof this.#connection==="string"?Redis.getConnection(this.#connection):this.#connection,name=`${this.#prefix}${key}`,count=Number(await redis.send("INCR",[name]));if(count===1)await redis.send("PEXPIRE",[name,String(windowMs)]);let ttl=Number(await redis.send("PTTL",[name]));if(ttl<0)await redis.send("PEXPIRE",[name,String(windowMs)]),ttl=windowMs;return{count,resetAt:Date.now()+ttl}}}export{Redis,RedisFeatureNotSupportedError,RedisModule,RedisRateLimitStore,bunRedis,ioredis,upstash};
@@ -0,0 +1,26 @@
1
+ import type { RateLimitStore } from '@elysiumjs/core';
2
+ import type { RedisConnection } from './connection';
3
+ /**
4
+ * Keeps rate limit counts in Redis, shared by every process of the app. A count is an `INCR`ed
5
+ * key that expires with its window.
6
+ * @author Axel Nana <axel.nana@workbud.com>
7
+ * @example
8
+ * ```ts
9
+ * @Application.register({ rateLimit: { store: new RedisRateLimitStore() } })
10
+ * ```
11
+ */
12
+ export declare class RedisRateLimitStore implements RateLimitStore {
13
+ #private;
14
+ /**
15
+ * @param options The `RedisModule` connection (its name, `default` by default) or a connection,
16
+ * and a prefix for the keys.
17
+ */
18
+ constructor(options?: {
19
+ connection?: string | RedisConnection;
20
+ prefix?: string;
21
+ });
22
+ hit(key: string, windowMs: number): Promise<{
23
+ count: number;
24
+ resetAt: number;
25
+ }>;
26
+ }
@@ -0,0 +1,78 @@
1
+ import type { RedisClientFactory, RedisConnection } from './connection';
2
+ /**
3
+ * Properties used to create a new redis connection.
4
+ * @author Axel Nana <axel.nana@workbud.com>
5
+ */
6
+ export type RedisConnectionProps = {
7
+ url: string;
8
+ } & Record<string, unknown>;
9
+ /**
10
+ * Configuration of the {@link RedisModule}.
11
+ * @author Axel Nana <axel.nana@workbud.com>
12
+ */
13
+ export type RedisModuleConfig = {
14
+ /** The name of the connection that `default` resolves to. */
15
+ default: string;
16
+ /**
17
+ * Creates the client of a connection. Defaults to {@link bunRedis}; pass another factory
18
+ * to plug in any other Redis client.
19
+ */
20
+ client?: RedisClientFactory;
21
+ /** The connections to declare, by name. */
22
+ connections: Record<string, RedisConnectionProps>;
23
+ };
24
+ export declare namespace Redis {
25
+ /**
26
+ * Creates a service name for a redis connection.
27
+ * @author Axel Nana <axel.nana@workbud.com>
28
+ * @param name The name of the connection.
29
+ * @returns A service name for the connection.
30
+ */
31
+ const getConnectionName: (name: string) => string;
32
+ /**
33
+ * Retrieves the client for a redis connection declared by the {@link RedisModule}.
34
+ * @author Axel Nana <axel.nana@workbud.com>
35
+ * @param name The name of the connection.
36
+ * @returns The {@link RedisConnection} for the connection with the given name.
37
+ * @throws {ServiceNotFoundError} If no connection with that name is declared.
38
+ */
39
+ const getConnection: (name?: string) => RedisConnection;
40
+ /**
41
+ * Checks if a redis connection with the given name exists.
42
+ * @author Axel Nana <axel.nana@workbud.com>
43
+ * @param name The name of the connection to check.
44
+ * @returns `true` if the connection exists, `false` otherwise.
45
+ */
46
+ const connectionExists: (name: string) => boolean;
47
+ /**
48
+ * Retrieves the default redis connection.
49
+ * @author Axel Nana <axel.nana@workbud.com>
50
+ * @returns The default redis connection.
51
+ */
52
+ const getDefaultConnection: () => RedisConnection;
53
+ }
54
+ declare const RedisModule_base: (abstract new () => {
55
+ readonly config: RedisModuleConfig;
56
+ onInit(): Promise<void> | void;
57
+ onDestroy(): Promise<void> | void;
58
+ onServe(_app: import("elysia").Elysia<any>): Promise<void> | void;
59
+ }) & {
60
+ readonly config: string;
61
+ with(config: Partial<RedisModuleConfig> | (() => Partial<RedisModuleConfig> | Promise<Partial<RedisModuleConfig>>)): import("@elysiumjs/core").ConfiguredModule<RedisModuleConfig>;
62
+ resolveConfig(input?: Partial<RedisModuleConfig> | undefined): RedisModuleConfig;
63
+ register<TConfig_1 = any>(props?: import("@elysiumjs/core").ModuleProps<TConfig_1>): (target: import("@elysiumjs/core").ModuleClass) => void;
64
+ configurable<TConfig extends object>(options?: {
65
+ defaults?: Partial<TConfig>;
66
+ required?: (keyof TConfig)[];
67
+ }): any;
68
+ };
69
+ /**
70
+ * Declares redis connections as global services named `redis.connection.{name}`, plus
71
+ * `redis.connection.default` aliasing the configured default. Clients are created on first use.
72
+ * @author Axel Nana <axel.nana@workbud.com>
73
+ */
74
+ export declare class RedisModule extends RedisModule_base {
75
+ /** Closes the clients that were created. */
76
+ onDestroy(): Promise<void>;
77
+ }
78
+ export {};
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "@elysiumjs/redis",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "license": "Apache-2.0",
5
+ "description": "Redis connections for the Elysium.js framework.",
6
+ "private": false,
7
+ "author": {
8
+ "name": "Axel Nana",
9
+ "email": "axel.nana@workbud.com"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/na2axl/elysiumjs.git",
14
+ "directory": "@elysiumjs/redis"
15
+ },
16
+ "homepage": "https://github.com/na2axl/elysiumjs/tree/main/@elysiumjs/redis#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/na2axl/elysiumjs/issues"
19
+ },
20
+ "scripts": {
21
+ "format": "prettier --write \"./src/**/*.ts\"",
22
+ "clean": "rimraf dist",
23
+ "build:js": "bun build ./src/index.ts --outdir ./dist --target bun --packages external --root ./src --minify-syntax --minify-whitespace",
24
+ "build:ts": "tsc",
25
+ "build": "bun run clean && bun run build:js && bun run build:ts",
26
+ "typecheck": "tsc --noEmit",
27
+ "prepublishOnly": "bun run build",
28
+ "test": "bun test",
29
+ "test:coverage": "bun test --coverage"
30
+ },
31
+ "keywords": [
32
+ "elysium",
33
+ "framework",
34
+ "typescript",
35
+ "redis"
36
+ ],
37
+ "type": "module",
38
+ "module": "dist/index.js",
39
+ "types": "dist/index.d.ts",
40
+ "files": [
41
+ "dist",
42
+ "CHANGELOG.md"
43
+ ],
44
+ "sideEffects": false,
45
+ "devDependencies": {
46
+ "@elysiumjs/core": "0.12.0"
47
+ },
48
+ "peerDependencies": {
49
+ "@elysiumjs/core": "^0.12.0"
50
+ },
51
+ "dependencies": {
52
+ "radash": "^12.1.1"
53
+ }
54
+ }