@elysiumjs/runtime-workers 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,142 @@
1
+ # @elysiumjs/runtime-workers
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
+ - [#8](https://github.com/na2axl/elysiumjs/pull/8) [`6bc48fe`](https://github.com/na2axl/elysiumjs/commit/6bc48febd51f7090c582e73f349eaeff7261241c) Thanks [@na2axl](https://github.com/na2axl)! - `Application.build()` builds the root Elysia app without listening, for hosts that own the server (Cloudflare Workers) and for ahead-of-time compilation with `elysia/plugin/aot`. Modules are registered but boot on the first request (once, shared by concurrent requests), so building runs no `onInit` work. A boot failure fails the requests.
15
+
16
+ HTTP and websocket controllers in the server scope, and middlewares, are now built on their first request instead of when the app is built. `serve` still boots every module before listening.
17
+
18
+ `@elysiumjs/runtime-workers` gets a `check:aot` script, run in CI: it compiles a sample app with the AOT plugin for workerd, checks that the bundle holds no `new Function` or `eval`, and serves a validated route and the OpenAPI document under Node.js with code generation from strings disallowed.
19
+
20
+ - [#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 })`).
21
+
22
+ 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`.
23
+
24
+ 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.
25
+
26
+ `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.
27
+
28
+ - [#20](https://github.com/na2axl/elysiumjs/pull/20) [`1738567`](https://github.com/na2axl/elysiumjs/commit/17385674c14991f0ddb27eb6ee967afbcebcc307) Thanks [@na2axl](https://github.com/na2axl)! - Scheduled fan-out, dead letters and one consumer per queue.
29
+
30
+ - `@Scheduler.job(cron, { args })` dispatches a job on a cron with fixed arguments.
31
+ - A class that extends `Scheduler` and is marked with `@Scheduler.register({ cron, job })` dispatches one job per case its `fanOut(tick)` returns, with arguments typed against the job and a dispatch id made of the scheduler, the case key and the tick. Schedulers are services, so they inject what they read.
32
+ - `ScheduleRunner` runs the schedules on Bun and Node.js (the `heracles:schedule` command), and `Scheduler.jobsFor()` / `Scheduler.dispatch()` serve the cron triggers of Workers.
33
+ - `@Once()` of `@elysiumjs/moirai/heracles` runs a job once per dispatch id, in a unit of work with an inbox record.
34
+ - `HeraclesWorkersModule.with({ deadLetterQueue, onDead })` keeps the jobs that failed their last retry: sent to a dead letter queue with their failure, and handed to a hook.
35
+ - Cron triggers dispatch at the trigger's time.
36
+ - A named queue of a Worker has one consumer: two throw `DuplicateQueueConsumerError` when the Worker is created.
37
+
38
+ - [#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.
39
+
40
+ Breaking changes:
41
+
42
+ - `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.
43
+ - The third parameter of `InteractsWithConsole.write` is now `'stdout' | 'stderr'`.
44
+ - The WebSocket options are narrowed to `idleTimeout`, `maxPayloadLength` and `perMessageDeflate`, and the `bun` WebSocket types are replaced by `ElysiumWebSocket`.
45
+ - Redis connections are `RedisConnection`s, a port any Redis client can implement, instead of `Bun.RedisClient`.
46
+ - `styx create @scope/name` creates the project in `name/`, and refuses project and module names with upper-case letters.
47
+ - 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.
48
+
49
+ Fixes:
50
+
51
+ - 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`.
52
+ - A failed module or service `onInit` no longer breaks later requests: the boot is retried, and the module's event listeners are unwired.
53
+ - `@elysiumjs/runtime-node` serves through `node:http`, keeping the Host header authoritative.
54
+ - `styx create` validates names before creating files, ships `.gitignore`, declares the dependencies of created projects and imports their default controllers.
55
+
56
+ - [#14](https://github.com/na2axl/elysiumjs/pull/14) [`0956186`](https://github.com/na2axl/elysiumjs/commit/0956186db0ca5a83c0a6636aeb2a4c81a0033de4) Thanks [@na2axl](https://github.com/na2axl)! - The request pipeline: problem details, exception filters, interceptors, typed configuration, HTTP security options and an HTTP test client, checked on Bun, Node.js, workerd and AOT-compiled Workers.
57
+
58
+ Breaking changes:
59
+
60
+ - Error responses are RFC 9457 problem details (`application/problem+json`). Request validation errors answer 422 with an `errors` list of JSON pointers (`problems.validationStatus: 400` keeps 400). Server errors only show their message and stack in development. Thrown statuses (`c.status(...)`) are sent as they are.
61
+ - Client errors (4xx) are no longer reported to `onError`, the `elysium:error` event or the trace.
62
+ - `Runtime.serve` may return a handle with `close()`, which the application calls on shutdown.
63
+ - Request bodies are limited to 1 MiB by default (`http.bodyLimit`), also when chunked.
64
+ - The environment schema is validated when the app is built or run, with every error reported at once, instead of in the constructor.
65
+
66
+ Features:
67
+
68
+ - `DomainError`, with `status`, `type`, `title`, `detail` and extensions, and shortcuts such as `DomainError.notFound()`.
69
+ - Exception filters (`@Catch()`, `filters` on routes, controllers, modules and the app) and interceptors (`interceptors` on the same levels), with `TimingInterceptor`, `TransformInterceptor` and mnemosyne's `CacheInterceptor`.
70
+ - Typed configuration: `Secret` values, module `envSchema`s merged into the app's, `InvalidEnvironmentError`.
71
+ - `http` options: CORS, security headers, body limits and trusted proxies (`ctx.ip`). Runtimes report the client address through `clientAddress`.
72
+ - `createTestingHttpApp` and `createHttpClient` send requests to an app in memory, with `expectStatus()`.
73
+
74
+ Fixes:
75
+
76
+ - App-level `onRequest` middlewares run in the request scope.
77
+ - On Workers, the request scope of a streamed response is disposed when the body ends, not when `fetch` returns.
78
+ - `KeyvWorkersKV.clear()` stays within an operation budget (`maxOperations`).
79
+ - The Node.js bridge treats a destroyed socket as a client abort.
80
+ - Heracles performance metrics report through the Redis port (`connection` or `client`), not a Bun client.
81
+ - `styx routes:generate` names anonymous default controllers after their file; `styx create` pins `@elysiumjs/*` packages to the versions it was released with; invalid names are reported as Elysium errors.
82
+
83
+ - [#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.
84
+
85
+ Breaking:
86
+
87
+ - 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.
88
+
89
+ Added:
90
+
91
+ - `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.
92
+ - 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.
93
+ - 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`.
94
+ - `ModelResource(model, { hidden, pick, omit, extend })` (`@elysiumjs/mnemosyne`) builds a response schema from a model.
95
+ - `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 }`).
96
+ - `DrizzleRepository.list(query)` and `count(query)` run a `ParsedQuery` with keyset pagination (`@elysiumjs/mnemosyne-drizzle`), and `compileQuery()` gives its `where` and `orderBy`.
97
+ - 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.
98
+
99
+ - [#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.
100
+
101
+ Breaking:
102
+
103
+ - 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.
104
+ - 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.
105
+
106
+ Added:
107
+
108
+ - 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.
109
+ - 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.
110
+ - `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.
111
+ - `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.
112
+ - 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.
113
+ - `@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.
114
+ - 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.
115
+ - `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`).
116
+ - `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.
117
+
118
+ - [#12](https://github.com/na2axl/elysiumjs/pull/12) [`98a16aa`](https://github.com/na2axl/elysiumjs/commit/98a16aae889d46f7a9fb5573188f4cbe3629dd3b) Thanks [@na2axl](https://github.com/na2axl)! - `createWorker(app)` from `@elysiumjs/runtime-workers` builds an application and returns the handlers a Cloudflare Worker exports: `fetch` serves the routes, `queue` hands each batch to the service methods marked `@Workers.queue(name)`, and `scheduled` calls the methods marked `@Workers.cron(expression)`. Each invocation runs with its own env and execution context: `Workers.env()`, `Workers.context()` and `Workers.binding(name)` read them, and `WorkersModule` provides them, and the bindings it lists, as request-scoped services injected with `@Workers.inject(name)`. `KeyvWorkersKV` stores mnemosyne caches in a KV namespace. `bun run check:wrangler` runs a sample Worker in `wrangler dev`.
119
+
120
+ `MnemosyneModule` takes other Keyv stores in `cache.stores`, read with `Cache.store(name)`.
121
+
122
+ `styx build --target workers` (Hephaestus `hephaestus:build --target workers`, `WorkersBuilder`) bundles a Worker with its routes compiled ahead of time and refuses a bundle that generates code at runtime.
123
+
124
+ In core, `Application.boot()` initialises the modules once, for hosts that handle more than requests; `Kernel.serviceClasses(node)` lists the service classes of a module; console output falls back to `console` where the host has no output streams; and a singleton depending on a request-scoped service throws `ScopeMismatchError` even when it is built outside a request.
125
+
126
+ ### Patch Changes
127
+
128
+ - [#7](https://github.com/na2axl/elysiumjs/pull/7) [`e9c3d98`](https://github.com/na2axl/elysiumjs/commit/e9c3d98dd2714b69100215f7c8a80046da463148) Thanks [@na2axl](https://github.com/na2axl)! - Elysium runs on Elysia 2 (`elysia@^2.0.0-rc.1`), with TypeBox 1 (`typebox`), `@elysia/openapi` and `@elysia/opentelemetry`.
129
+
130
+ Breaking:
131
+
132
+ - `TSchema` and `Static` are re-exported from `typebox` instead of `@sinclair/typebox`; `Value` comes from `typebox/value` and `Value.Errors()` returns an array.
133
+ - `Application.onError()` receives a `ServerError` (exported from core). Elysia now names its errors in kebab case; the default error response keeps the upper snake case names (`VALIDATION`, `NOT_FOUND`, `UNKNOWN` for other errors).
134
+ - `AppHttpContext` is a plain interface with `decorator`, `store` and `derive` records (Elysia 2 renamed `resolve` to `derive`).
135
+ - Websocket controllers install Elysia's `websocket()` capability themselves.
136
+
137
+ A module or controller `onRequest` middleware that answers now stops the request: Elysia 2 ignores values returned from a transform hook, so the response is thrown as a status and sent as is, without going through `onError`.
138
+
139
+ `mnemosyne` builds date columns with `t.Codec` (TypeBox 1 dropped `Transform`). `argus` traces every route of the app it is installed on and ends each span once, keeping the error status when the request failed.
140
+
141
+ - 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)]:
142
+ - @elysiumjs/core@0.11.0
package/README.md CHANGED
@@ -1,3 +1,135 @@
1
- # Temporary Holding Version
1
+ # @elysiumjs/runtime-workers
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
+ Cloudflare Workers runtime and adapter for [Elysium.js](https://github.com/na2axl/elysiumjs).
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ bun add @elysiumjs/runtime-workers
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```ts
14
+ import { Application } from '@elysiumjs/core';
15
+ import { runtime } from '@elysiumjs/runtime-workers';
16
+
17
+ @Application.register({ runtime, modules: [] })
18
+ class App extends Application {}
19
+ ```
20
+
21
+ The package exports `runtime`; rename it at import if you like: `import { runtime as workersRuntime } from '@elysiumjs/runtime-workers'`.
22
+
23
+ Environment variables come from `process.env`, which workerd fills from the Worker's vars and secrets with the `nodejs_compat` flag; use `createRuntime({ env })` to pass others. The host owns the server and the isolate: `serve` throws (export the app's `fetch` from the Worker), and exit and signals do nothing. `bun run check:runtime` checks that the bundle never touches the `Bun` global.
24
+
25
+ ## The Worker entry
26
+
27
+ `createWorker()` builds the app and returns the handlers a Worker module exports: `fetch` serves the routes, `queue` hands each batch to its consumers, and `scheduled` runs the cron handlers. It also returns the root Elysia `app`; export it as `app` so that the AOT plugin below compiles the routes:
28
+
29
+ ```ts
30
+ import { createWorker } from '@elysiumjs/runtime-workers';
31
+
32
+ import { App } from './app';
33
+
34
+ const worker = await createWorker(new App());
35
+
36
+ export const app = worker.app;
37
+
38
+ export default worker;
39
+ ```
40
+
41
+ Modules boot on the first invocation, whatever its kind, so building runs no `onInit` work.
42
+
43
+ workerd binds every I/O object (a stream, a request or response body, a socket) to the request that created it. A singleton's `onInit` runs during the first invocation, so it may do I/O and keep what it read, but it must not keep the I/O object itself: a later invocation that uses it fails with "Cannot perform I/O on behalf of a different request". Open such objects per invocation, in request-scoped services.
44
+
45
+ A singleton whose `onInit` calls its own app, through a service binding or a fetch to itself, deadlocks: the app holds every request until its boot has finished, and the boot waits for that response.
46
+
47
+ ### Bindings
48
+
49
+ Each invocation runs with its own env and execution context. Read them with `Workers.env()`, `Workers.context()` and `Workers.binding(name)`, or inject them: `WorkersModule` provides the env (`workers.env`), the context (`workers.context`) and each binding it lists as global request-scoped services.
50
+
51
+ ```ts
52
+ import { Service, ServiceScope } from '@elysiumjs/core';
53
+ import { Workers, WorkersModule } from '@elysiumjs/runtime-workers';
54
+
55
+ @Service.register({ scope: ServiceScope.REQUEST })
56
+ export class JobQueue {
57
+ public constructor(@Workers.inject('JOBS') private readonly queue: Queue<Job>) {}
58
+
59
+ public send(job: Job) {
60
+ return this.queue.send(job);
61
+ }
62
+ }
63
+
64
+ @Application.register({
65
+ runtime,
66
+ modules: [WorkersModule.with({ bindings: ['JOBS'] }), JobsModule]
67
+ })
68
+ export class App extends Application {}
69
+ ```
70
+
71
+ A binding belongs to the invocation, so only request-scoped services, factories and request-scoped controllers take it: a singleton that depends on one throws `ScopeMismatchError`. Outside an invocation, reading a binding throws `OutsideInvocationError`.
72
+
73
+ ### Queues and cron triggers
74
+
75
+ Mark methods of module services with `@Workers.queue(name)` or `@Workers.cron(expression)`. Each handler runs in its module, in a request scope of its own. A queue handler receives the batch, and the whole batch is retried when it throws. A cron handler receives the scheduled controller; the expression must match the one in the wrangler config. `*` matches every queue or cron. A batch or event no handler matches throws `NoWorkersHandlerError`. A named queue has one consumer, so that each message is acked or retried by one decision: two handlers of the same queue throw `DuplicateQueueConsumerError` when the Worker is created. `*` handlers, such as observers, are not counted.
76
+
77
+ ```ts
78
+ @Service.register()
79
+ export class JobConsumer {
80
+ @Workers.queue('jobs')
81
+ public async handle(batch: MessageBatch<Job>) {
82
+ for (const message of batch.messages) {
83
+ await this.run(message.body);
84
+ message.ack();
85
+ }
86
+ }
87
+
88
+ @Workers.cron('*/5 * * * *')
89
+ public async sweep(controller: ScheduledController) {}
90
+ }
91
+ ```
92
+
93
+ With `@Workers.queue(name, { each: true })`, the handler receives one message at a time: a message it throws on is retried and the others are acked. Without `each`, the handler keeps batch semantics: it acks or retries messages itself, and the whole batch is retried when it throws.
94
+
95
+ Several handlers can match one queue. A message retried for one of them is delivered again to every matching handler, including those that already handled it, so queue handlers must be idempotent.
96
+
97
+ The handler types (`WorkersMessageBatch`, `WorkersScheduledController`, `WorkersKVNamespace`, ...) are structural, so the types from `@cloudflare/workers-types` or `wrangler types` fit them.
98
+
99
+ ### KV cache
100
+
101
+ `KeyvWorkersKV` is a Keyv store over a KV namespace, for the caches of `@elysiumjs/mnemosyne`. It reads the binding from the current invocation on every call. KV is eventually consistent and expires entries after at least 60 seconds, so shorter TTLs are rounded up.
102
+
103
+ ```ts
104
+ MnemosyneModule.with({
105
+ cache: { redis: false, stores: { kv: () => new KeyvWorkersKV({ binding: 'CACHE' }) } }
106
+ });
107
+
108
+ await Cache.store('kv').set('greeting', 'hello', 120_000);
109
+ ```
110
+
111
+ `clear()` lists the keys of the namespace and deletes them, and each listed page and each deleted key is a subrequest. It stops at `maxOperations` (900 by default, under the 1000 subrequests of the paid plan; set 45 on the free plan), logs how many keys are left, and the next `clear()` goes on with them. `delete()` reads the key first, because the Keyv contract returns whether it existed; on eventually consistent KV that answer is best effort.
112
+
113
+ ## Compiling ahead of time
114
+
115
+ Workers forbid generating code at runtime (`new Function`), which Elysia does for validators unless the app is compiled ahead of time. With `@elysiumjs/hephaestus` installed, `styx build --target workers` bundles `src/worker.ts` this way into `dist/worker.js`. Without it, bundle the entry with Elysia's AOT plugin, which evaluates it at build time:
116
+
117
+ ```ts
118
+ import { aot } from 'elysia/plugin/aot/bun';
119
+
120
+ await Bun.build({
121
+ entrypoints: ['src/worker.ts'],
122
+ outdir: 'dist',
123
+ target: 'browser',
124
+ format: 'esm',
125
+ conditions: ['workerd', 'worker', 'browser'],
126
+ external: ['node:*', 'cloudflare:*'],
127
+ plugins: [aot('src/worker.ts', { target: 'workerd' })]
128
+ });
129
+ ```
130
+
131
+ `bun run check:aot` compiles a sample app this way, checks that the bundle has no runtime code generation, and serves a validated route under Node.js with code generation from strings disallowed. `bun run check:wrangler` compiles the sample Worker in `scripts/worker` and runs it in `wrangler dev`: a route sends to a queue, the consumer and a cron handler write to the KV cache, and routes read it back.
132
+
133
+ ## License
134
+
135
+ Apache-2.0
@@ -0,0 +1,37 @@
1
+ import { ElysiumError } from '@elysiumjs/core';
2
+ /**
3
+ * Thrown when a binding is read outside a Worker invocation.
4
+ * @author Axel Nana <axel.nana@workbud.com>
5
+ */
6
+ export declare class OutsideInvocationError extends ElysiumError {
7
+ readonly what: string;
8
+ constructor(what: string);
9
+ }
10
+ /**
11
+ * Thrown when the Worker has no binding with the requested name.
12
+ * @author Axel Nana <axel.nana@workbud.com>
13
+ */
14
+ export declare class BindingNotFoundError extends ElysiumError {
15
+ readonly binding: string;
16
+ constructor(binding: string);
17
+ }
18
+ /**
19
+ * Thrown when a queue batch or a scheduled event reaches the Worker and no service handles it.
20
+ * @author Axel Nana <axel.nana@workbud.com>
21
+ */
22
+ export declare class NoWorkersHandlerError extends ElysiumError {
23
+ readonly kind: 'queue' | 'cron';
24
+ readonly key: string;
25
+ constructor(kind: 'queue' | 'cron', key: string);
26
+ }
27
+ /**
28
+ * Thrown when the Worker is created and two service methods consume the same queue. A queue has
29
+ * one consumer, so that every message is acked or retried by one decision; `*` handlers are not
30
+ * counted.
31
+ * @author Axel Nana <axel.nana@workbud.com>
32
+ */
33
+ export declare class DuplicateQueueConsumerError extends ElysiumError {
34
+ readonly queue: string;
35
+ readonly consumers: string[];
36
+ constructor(queue: string, consumers: string[]);
37
+ }
@@ -0,0 +1,9 @@
1
+ export { BindingNotFoundError, DuplicateQueueConsumerError, NoWorkersHandlerError, OutsideInvocationError } from './errors';
2
+ export { type Invocation, runInvocation } from './invocation';
3
+ export { KeyvWorkersKV, type KeyvWorkersKVOptions } from './kv-store';
4
+ export { type WorkersModuleConfig, WorkersModule } from './module';
5
+ export { WorkersRateLimitStore, type WorkersRateLimitStoreOptions } from './rate-limit';
6
+ export { createRuntime, runtime, type WorkersRuntimeOptions } from './runtime';
7
+ export * from './types';
8
+ export { createWorker } from './worker';
9
+ export { type WorkersHandlerMeta, Workers, WORKERS_CONTEXT_SERVICE, WORKERS_ENV_SERVICE } from './workers';
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ 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{ElysiumError}from"@elysiumjs/core";class OutsideInvocationError extends ElysiumError{what;constructor(what){super(`${what} is only available while the Worker handles a request, a queue batch or a scheduled event.`);this.what=what}}class BindingNotFoundError extends ElysiumError{binding;constructor(binding){super(`The Worker has no binding named "${binding}". Declare it in the wrangler config.`);this.binding=binding}}class NoWorkersHandlerError extends ElysiumError{kind;key;constructor(kind,key){super(kind==="queue"?`No service consumes the queue "${key}". Add @Workers.queue('${key}') to a method of a module service.`:`No service handles the cron "${key}". Add @Workers.cron('${key}') to a method of a module service.`);this.kind=kind;this.key=key}}class DuplicateQueueConsumerError extends ElysiumError{queue;consumers;constructor(queue,consumers){super(`The queue "${queue}" has more than one consumer: ${consumers.join(", ")}. Keep one.`);this.queue=queue;this.consumers=consumers}}import{AsyncLocalStorage}from"node:async_hooks";var storage=new AsyncLocalStorage,runInvocation=(invocation,fn)=>storage.run(invocation,fn),currentInvocation=()=>storage.getStore();import{getLogger}from"@elysiumjs/core";import{Metadata,Service}from"@elysiumjs/core";var WORKERS_ENV_SERVICE="workers.env",WORKERS_CONTEXT_SERVICE="workers.context",HANDLERS=Symbol("workers:handlers"),addHandler=(kind,key,each)=>(target,method)=>{if(typeof target==="function")throw TypeError(`@Workers.${kind}() goes on an instance method, not on a static one.`);let cls=target.constructor,handlers=Metadata.getOwn(HANDLERS,cls)??[];Metadata.define(HANDLERS,[...handlers,{kind,key,method,...each?{each}:{}}],cls)},Workers;((Workers)=>(Workers.env=()=>{let invocation=currentInvocation();if(!invocation)throw new OutsideInvocationError("The Worker env");return invocation.env},Workers.context=()=>{let invocation=currentInvocation();if(!invocation)throw new OutsideInvocationError("The execution context");return invocation.ctx},Workers.binding=(name)=>{let value=Workers.env()[name];if(value===void 0)throw new BindingNotFoundError(name);return value},Workers.bindingService=(name)=>`workers.binding.${name}`,Workers.inject=(name)=>Service.inject(Workers.bindingService(name)),Workers.queue=(queue,options)=>addHandler("queue",queue,options?.each),Workers.cron=(cron)=>addHandler("cron",cron),Workers.handlersOf=(cls)=>Metadata.get(HANDLERS,cls)??[]))(Workers||={});var MIN_TTL_SECONDS=60,KV_DELETE_CHUNK=50,DEFAULT_MAX_OPERATIONS=900;class KeyvWorkersKV{namespace;#binding;#separator;#maxOperations;#listeners=new Map;constructor(options){this.#binding=options.binding,this.namespace=options.namespace,this.#separator=options.keyPrefixSeparator??"::",this.#maxOperations=Math.max(2,options.maxOperations??DEFAULT_MAX_OPERATIONS)}get opts(){return{dialect:"workers-kv",binding:this.#binding,namespace:this.namespace,keyPrefixSeparator:this.#separator}}on(event,listener){return this.#listeners.set(event,[...this.#listeners.get(event)??[],listener]),this}async get(key){let value=await this.#kv().get(this.#key(key),"text");return value===null?void 0:value}async getMany(keys){return Promise.all(keys.map((key)=>this.get(key)))}async has(key){return await this.get(key)!==void 0}async set(key,value,ttl){let options=ttl?{expirationTtl:Math.max(MIN_TTL_SECONDS,Math.ceil(ttl/1000))}:void 0;await this.#kv().put(this.#key(key),String(value),options)}async setMany(entries){await Promise.all(entries.map(({key,value,ttl})=>this.set(key,value,ttl)))}async delete(key){let existed=await this.has(key);return await this.#kv().delete(this.#key(key)),existed}async deleteMany(keys){return(await Promise.all(keys.map((key)=>this.delete(key)))).some(Boolean)}async clear(){let kv=this.#kv(),prefix=this.namespace?`${this.namespace}${this.#separator}`:void 0,budget=this.#maxOperations,names=[],cursor;do{let page=await kv.list({prefix,cursor});budget--,names.push(...page.keys.map(({name})=>name)),cursor=page.list_complete?void 0:page.cursor}while(cursor&&budget>names.length+1);let deleting=names.slice(0,Math.max(0,budget));for(let i=0;i<deleting.length;i+=KV_DELETE_CHUNK)await Promise.all(deleting.slice(i,i+KV_DELETE_CHUNK).map((name)=>kv.delete(name)));let left=names.length-deleting.length;if(left>0||cursor)getLogger().warn("KV clear() stopped at its operation budget; call it again for the rest",{binding:this.#binding,namespace:this.namespace,deleted:deleting.length,remaining:cursor?`${left} listed, more not listed`:left,maxOperations:this.#maxOperations})}#kv(){return Workers.binding(this.#binding)}#key(key){return this.namespace?`${this.namespace}${this.#separator}${key}`:key}}import{Module,ServiceScope}from"@elysiumjs/core";class WorkersModule extends Module.configurable({defaults:{bindings:[]}}){}WorkersModule=__legacyDecorateClassTS([Module.register({services:(config)=>{let perInvocation=(name,read)=>({name,global:!0,scope:ServiceScope.REQUEST,factory:read});return[perInvocation(WORKERS_ENV_SERVICE,()=>Workers.env()),perInvocation(WORKERS_CONTEXT_SERVICE,()=>Workers.context()),...config.bindings.map((name)=>perInvocation(Workers.bindingService(name),()=>Workers.binding(name)))]}})],WorkersModule);class WorkersRateLimitStore{#binding;constructor(options){this.#binding=options.binding}async hit(key){let{success}=await Workers.binding(this.#binding).limit({key});return{limited:!success}}}import{RuntimeNotSupportedError}from"@elysiumjs/core";var CONSOLE_METHODS={trace:"debug",debug:"debug",info:"info",warn:"warn",error:"error",fatal:"error"},createRuntime=(options={})=>({name:"workers",env:()=>options.env??(typeof process>"u"?{}:process.env),sleep:(ms)=>new Promise((resolve)=>setTimeout(resolve,ms)),serve(){throw new RuntimeNotSupportedError("Application.serve","workers","Export the app's fetch handler from the Worker instead.")},exit(){},argv:()=>[],write(stream,text,level){let line=text.endsWith(`
2
+ `)?text.slice(0,-1):text;console[level?CONSOLE_METHODS[level]:stream==="stderr"?"error":"log"](line)},terminal:()=>{return},clientAddress:(request)=>request.headers.get("cf-connecting-ip")??void 0,onShutdown(){return()=>{}}}),runtime=createRuntime();import{getLogger as getLogger2,ModuleContext,Service as Service2}from"@elysiumjs/core";var collectHandlers=(app)=>app.kernel.graph.nodes.flatMap((node)=>app.kernel.serviceClasses(node).flatMap((cls)=>Workers.handlersOf(cls).map((meta)=>({...meta,node,cls})))),assertOneConsumerPerQueue=(handlers)=>{let consumers=new Map;for(let handler of handlers){if(handler.kind!=="queue"||handler.key==="*")continue;let names=consumers.get(handler.key)??[],name=`${handler.cls.name}.${String(handler.method)}`;if(!names.includes(name))names.push(name);consumers.set(handler.key,names)}for(let[queue,names]of consumers)if(names.length>1)throw new DuplicateQueueConsumerError(queue,names)},disposeAfterBody=(request,response)=>{let ctx=Workers.context(),source=response.body;if(!source||response.status===101)return ctx.waitUntil(Service2.disposeRequestScope(request)),response;let ended;ctx.waitUntil(new Promise((resolve)=>ended=resolve).then(()=>Service2.disposeRequestScope(request)));let reader=source.getReader(),body=new ReadableStream({async pull(controller){try{let{done,value}=await reader.read();if(done)controller.close(),ended();else controller.enqueue(value)}catch(error){controller.error(error),ended()}},async cancel(reason){ended(),await reader.cancel(reason)}});return new Response(body,response)},runHandler=(handler,argument)=>ModuleContext.run(handler.node,()=>Service2.runInRequestScope(async()=>{await(await Service2.resolve(handler.cls))[handler.method](argument)})),dispatch=async(handlers,kind,key,argument)=>{let matching=handlers.filter((h)=>h.kind===kind&&(h.key===key||h.key==="*"));if(matching.length===0)throw new NoWorkersHandlerError(kind,key);for(let handler of matching)await runHandler(handler,argument)},settleable=(message,settled)=>({get id(){return message.id},get timestamp(){return message.timestamp},get body(){return message.body},get attempts(){return message.attempts},ack:()=>{settled.add(message),message.ack()},retry:(options)=>{settled.add(message),message.retry(options)}}),dispatchQueue=async(handlers,batch)=>{let matching=handlers.filter((h)=>h.kind==="queue"&&(h.key===batch.queue||h.key==="*"));if(matching.length===0)throw new NoWorkersHandlerError("queue",batch.queue);let failed=new Set,settled=new Set,log=(handler,error)=>getLogger2().error("Queue handler failed",{queue:batch.queue,handler:`${handler.cls.name}.${String(handler.method)}`,error});for(let handler of matching)if(handler.each)for(let[index,message]of batch.messages.entries())try{await runHandler(handler,settleable(message,settled))}catch(error){failed.add(index),log(handler,error)}else try{await runHandler(handler,batch)}catch(error){batch.messages.forEach((_,index)=>failed.add(index)),log(handler,error)}for(let[index,message]of batch.messages.entries()){if(settled.has(message))continue;if(failed.has(index))message.retry();else message.ack()}},createWorker=async(app)=>{let elysia=await app.build(),handlers=collectHandlers(app);return assertOneConsumerPerQueue(handlers),{app:elysia,fetch:(request,env,ctx)=>runInvocation({env,ctx},async()=>{let response;try{return response=await elysia.fetch(request),disposeAfterBody(request,response)}finally{if(!response)Workers.context().waitUntil(Service2.disposeRequestScope(request))}}),queue:(batch,env,ctx)=>runInvocation({env,ctx},async()=>{await app.boot(),await dispatchQueue(handlers,batch)}),scheduled:(controller,env,ctx)=>runInvocation({env,ctx},async()=>{await app.boot(),await dispatch(handlers,"cron",controller.cron,controller)})}};export{BindingNotFoundError,DuplicateQueueConsumerError,KeyvWorkersKV,NoWorkersHandlerError,OutsideInvocationError,WORKERS_CONTEXT_SERVICE,WORKERS_ENV_SERVICE,Workers,WorkersModule,WorkersRateLimitStore,createRuntime,createWorker,runInvocation,runtime};
@@ -0,0 +1,19 @@
1
+ import type { WorkersExecutionContext } from './types';
2
+ /**
3
+ * What a Worker invocation (a request, a queue batch or a scheduled event) was called with.
4
+ * @author Axel Nana <axel.nana@workbud.com>
5
+ */
6
+ export type Invocation = {
7
+ /** The Worker's bindings, vars and secrets. */
8
+ readonly env: Record<string, unknown>;
9
+ /** The execution context of the invocation. */
10
+ readonly ctx: WorkersExecutionContext;
11
+ };
12
+ /**
13
+ * Runs `fn` as part of an invocation, so that its bindings resolve inside it.
14
+ * @param invocation The env and context of the invocation.
15
+ * @param fn The work to run.
16
+ */
17
+ export declare const runInvocation: <T>(invocation: Invocation, fn: () => T) => T;
18
+ /** The current invocation, if any. */
19
+ export declare const currentInvocation: () => Invocation | undefined;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Options of {@link KeyvWorkersKV}.
3
+ * @author Axel Nana <axel.nana@workbud.com>
4
+ */
5
+ export type KeyvWorkersKVOptions = {
6
+ /** The name of the KV namespace binding. */
7
+ binding: string;
8
+ /** The namespace prefixed to every key. Keyv sets it. */
9
+ namespace?: string;
10
+ /** The separator between the namespace and the key. Defaults to `::`. */
11
+ keyPrefixSeparator?: string;
12
+ /**
13
+ * The most KV operations (one per listed page, one per deleted key) one `clear()` makes, so
14
+ * that it stays within the subrequest limit of an invocation. Defaults to 900, under the 1000
15
+ * of the paid plan; set 45 on the free plan. Keys left over are logged, and the next
16
+ * `clear()` goes on with them.
17
+ */
18
+ maxOperations?: number;
19
+ };
20
+ /**
21
+ * A Keyv store over Workers KV, for the caches of `@elysiumjs/mnemosyne`.
22
+ *
23
+ * The KV namespace is read from the current invocation on every call, so the store holds no
24
+ * binding between invocations. KV is eventually consistent, and entries expire after at least
25
+ * 60 seconds: shorter TTLs are rounded up.
26
+ *
27
+ * @example
28
+ * ```ts
29
+ * MnemosyneModule.with({
30
+ * cache: { redis: false, stores: { kv: () => new KeyvWorkersKV({ binding: 'CACHE' }) } }
31
+ * });
32
+ * ```
33
+ * @author Axel Nana <axel.nana@workbud.com>
34
+ */
35
+ export declare class KeyvWorkersKV {
36
+ #private;
37
+ namespace: string | undefined;
38
+ constructor(options: KeyvWorkersKVOptions);
39
+ get opts(): {
40
+ dialect: string;
41
+ binding: string;
42
+ namespace: string | undefined;
43
+ keyPrefixSeparator: string;
44
+ };
45
+ on(event: string, listener: (...args: any[]) => void): this;
46
+ get<Value>(key: string): Promise<Value | undefined>;
47
+ getMany<Value>(keys: string[]): Promise<Array<Value | undefined>>;
48
+ has(key: string): Promise<boolean>;
49
+ set(key: string, value: unknown, ttl?: number): Promise<void>;
50
+ setMany(entries: Array<{
51
+ key: string;
52
+ value: unknown;
53
+ ttl?: number;
54
+ }>): Promise<void>;
55
+ /**
56
+ * Deletes a key. It reads first, because the Keyv contract returns whether the key existed;
57
+ * on eventually consistent KV this answer is best-effort.
58
+ */
59
+ delete(key: string): Promise<boolean>;
60
+ deleteMany(keys: string[]): Promise<boolean>;
61
+ /**
62
+ * Deletes every key of the namespace, or every key of the KV namespace without one, within the
63
+ * `maxOperations` budget. When the budget runs out, the keys left are logged as a warning and
64
+ * stay for the next `clear()`.
65
+ */
66
+ clear(): Promise<void>;
67
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Configuration of the {@link WorkersModule}.
3
+ * @author Axel Nana <axel.nana@workbud.com>
4
+ */
5
+ export type WorkersModuleConfig = {
6
+ /** The bindings to provide as services, injected with `@Workers.inject(name)`. */
7
+ bindings: string[];
8
+ };
9
+ declare const WorkersModule_base: (abstract new () => {
10
+ readonly config: WorkersModuleConfig;
11
+ onInit(): Promise<void> | void;
12
+ onDestroy(): Promise<void> | void;
13
+ onServe(_app: import("elysia").Elysia<any>): Promise<void> | void;
14
+ }) & {
15
+ readonly config: string;
16
+ with(config: Partial<WorkersModuleConfig> | (() => Partial<WorkersModuleConfig> | Promise<Partial<WorkersModuleConfig>>)): import("@elysiumjs/core").ConfiguredModule<WorkersModuleConfig>;
17
+ resolveConfig(input?: Partial<WorkersModuleConfig> | undefined): WorkersModuleConfig;
18
+ register<TConfig_1 = any>(props?: import("@elysiumjs/core").ModuleProps<TConfig_1>): (target: import("@elysiumjs/core").ModuleClass) => void;
19
+ configurable<TConfig extends object>(options?: {
20
+ defaults?: Partial<TConfig>;
21
+ required?: (keyof TConfig)[];
22
+ }): any;
23
+ };
24
+ /**
25
+ * Provides the Worker's env (`workers.env`), the execution context (`workers.context`) and
26
+ * each listed binding (`workers.binding.<name>`) as global request-scoped services: they hold
27
+ * the values of the invocation being handled, never values kept across invocations.
28
+ * @author Axel Nana <axel.nana@workbud.com>
29
+ */
30
+ export declare class WorkersModule extends WorkersModule_base {
31
+ }
32
+ export {};
@@ -0,0 +1,35 @@
1
+ import type { RateLimitStore } from '@elysiumjs/core';
2
+ /**
3
+ * Options of {@link WorkersRateLimitStore}.
4
+ * @author Axel Nana <axel.nana@workbud.com>
5
+ */
6
+ export type WorkersRateLimitStoreOptions = {
7
+ /** The name of the rate limiting binding, as declared in the wrangler config. */
8
+ binding: string;
9
+ };
10
+ /**
11
+ * Uses a Cloudflare Workers rate limiting binding (`ratelimits` in the wrangler config) as the
12
+ * store of a limit. The binding decides whether a hit is over the limit; its limit and period are
13
+ * set in the wrangler config, so set the same `max` and `window` on the limit, which the
14
+ * `RateLimit-Limit`, `RateLimit-Policy` and `Retry-After` headers report. The binding gives no
15
+ * count, so responses carry no `RateLimit-Reset`. Cloudflare counts per location, so a limit is
16
+ * approximate. The binding is read from the current invocation on every hit.
17
+ *
18
+ * @example
19
+ * ```jsonc
20
+ * // wrangler.jsonc
21
+ * { "ratelimits": [{ "name": "OTP_LIMITER", "namespace_id": "1001", "simple": { "limit": 3, "period": 60 } }] }
22
+ * ```
23
+ *
24
+ * ```ts
25
+ * @RateLimit({ window: 60_000, max: 3, store: new WorkersRateLimitStore({ binding: 'OTP_LIMITER' }) })
26
+ * ```
27
+ * @author Axel Nana <axel.nana@workbud.com>
28
+ */
29
+ export declare class WorkersRateLimitStore implements RateLimitStore {
30
+ #private;
31
+ constructor(options: WorkersRateLimitStoreOptions);
32
+ hit(key: string): Promise<{
33
+ limited: boolean;
34
+ }>;
35
+ }
@@ -0,0 +1,25 @@
1
+ import type { Runtime } from '@elysiumjs/core';
2
+ /**
3
+ * Options of {@link createRuntime}.
4
+ * @author Axel Nana <axel.nana@workbud.com>
5
+ */
6
+ export type WorkersRuntimeOptions = {
7
+ /**
8
+ * The environment variables to expose. Defaults to `process.env`, which workerd fills from
9
+ * the Worker's vars and secrets with the `nodejs_compat` flag.
10
+ */
11
+ env?: Record<string, string | undefined>;
12
+ };
13
+ /**
14
+ * Creates the Elysium runtime for Cloudflare Workers. The host owns the server and the isolate's
15
+ * lifetime: export the app's `fetch` from the Worker instead of serving, and nothing exits or
16
+ * receives signals.
17
+ * @param options The runtime options.
18
+ * @author Axel Nana <axel.nana@workbud.com>
19
+ */
20
+ export declare const createRuntime: (options?: WorkersRuntimeOptions) => Runtime;
21
+ /**
22
+ * The Workers runtime reading `process.env`.
23
+ * @author Axel Nana <axel.nana@workbud.com>
24
+ */
25
+ export declare const runtime: Runtime;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The parts of the Cloudflare Workers API the adapter uses. They are structural, so the types
3
+ * from `@cloudflare/workers-types` (or `wrangler types`) fit them.
4
+ * @module
5
+ */
6
+ /** The `ctx` argument of a Worker handler. */
7
+ export interface WorkersExecutionContext {
8
+ waitUntil(promise: Promise<unknown>): void;
9
+ passThroughOnException(): void;
10
+ }
11
+ /** A message of a queue batch. */
12
+ export interface WorkersQueueMessage<Body = unknown> {
13
+ readonly id: string;
14
+ readonly timestamp: Date;
15
+ readonly body: Body;
16
+ readonly attempts: number;
17
+ ack(): void;
18
+ retry(options?: {
19
+ delaySeconds?: number;
20
+ }): void;
21
+ }
22
+ /** The batch a queue consumer receives. */
23
+ export interface WorkersMessageBatch<Body = unknown> {
24
+ readonly queue: string;
25
+ readonly messages: readonly WorkersQueueMessage<Body>[];
26
+ ackAll(): void;
27
+ retryAll(options?: {
28
+ delaySeconds?: number;
29
+ }): void;
30
+ }
31
+ /** The controller of a scheduled (cron) event. */
32
+ export interface WorkersScheduledController {
33
+ readonly scheduledTime: number;
34
+ readonly cron: string;
35
+ noRetry(): void;
36
+ }
37
+ /** A Workers KV namespace binding. */
38
+ export interface WorkersKVNamespace {
39
+ get(key: string, type: 'text'): Promise<string | null>;
40
+ put(key: string, value: string, options?: {
41
+ expirationTtl?: number;
42
+ }): Promise<void>;
43
+ delete(key: string): Promise<void>;
44
+ list(options?: {
45
+ prefix?: string;
46
+ cursor?: string;
47
+ limit?: number;
48
+ }): Promise<{
49
+ keys: {
50
+ name: string;
51
+ }[];
52
+ list_complete: boolean;
53
+ cursor?: string;
54
+ }>;
55
+ }
56
+ /** A rate limiting binding. */
57
+ export interface WorkersRateLimitBinding {
58
+ limit(options: {
59
+ key: string;
60
+ }): Promise<{
61
+ success: boolean;
62
+ }>;
63
+ }
64
+ /** The handlers a Worker module exports. */
65
+ export interface WorkersHandler<Env = Record<string, unknown>> {
66
+ fetch(request: Request, env: Env, ctx: WorkersExecutionContext): Promise<Response>;
67
+ queue(batch: WorkersMessageBatch, env: Env, ctx: WorkersExecutionContext): Promise<void>;
68
+ scheduled(controller: WorkersScheduledController, env: Env, ctx: WorkersExecutionContext): Promise<void>;
69
+ }
@@ -0,0 +1,31 @@
1
+ import type { Application, Elysia } from '@elysiumjs/core';
2
+ import type { WorkersHandler } from './types';
3
+ /**
4
+ * Builds an application and returns the handlers a Cloudflare Worker module exports.
5
+ *
6
+ * - `fetch` serves the app's routes.
7
+ * - `queue` hands each batch to the service method marked `@Workers.queue(name)`, and to those
8
+ * marked `@Workers.queue('*')`, then acks or retries each message on its own. A named queue has
9
+ * one consumer: two are refused when the Worker is created.
10
+ * - `scheduled` calls the service methods marked `@Workers.cron(expression)`.
11
+ *
12
+ * Each invocation runs with its own env and execution context, read with `Workers.env()`,
13
+ * `Workers.binding(name)` or the services of `WorkersModule`. Modules boot on the first
14
+ * invocation, whatever its kind.
15
+ *
16
+ * The result also holds the root Elysia `app`. Export it as `app` from the Worker entry so that
17
+ * `elysia/plugin/aot` compiles the routes at build time.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * const worker = await createWorker(new App());
22
+ * export const app = worker.app;
23
+ * export default worker;
24
+ * ```
25
+ * @param app The application to serve.
26
+ * @returns The `fetch`, `queue` and `scheduled` handlers, and the root Elysia app.
27
+ * @author Axel Nana <axel.nana@workbud.com>
28
+ */
29
+ export declare const createWorker: <Env = Record<string, unknown>>(app: Application) => Promise<WorkersHandler<Env> & {
30
+ readonly app: Elysia<any>;
31
+ }>;
@@ -0,0 +1,78 @@
1
+ import type { WorkersExecutionContext } from './types';
2
+ /** Name of the request-scoped service holding the Worker's env. */
3
+ export declare const WORKERS_ENV_SERVICE = "workers.env";
4
+ /** Name of the request-scoped service holding the invocation's execution context. */
5
+ export declare const WORKERS_CONTEXT_SERVICE = "workers.context";
6
+ /**
7
+ * A method that handles queue batches or scheduled events.
8
+ * @author Axel Nana <axel.nana@workbud.com>
9
+ */
10
+ export type WorkersHandlerMeta = {
11
+ /** What the method handles. */
12
+ readonly kind: 'queue' | 'cron';
13
+ /** The queue name or the cron expression; `*` matches every queue or cron. */
14
+ readonly key: string;
15
+ /** The method name. */
16
+ readonly method: PropertyKey;
17
+ /** Queue handlers only: whether the method receives one message at a time. */
18
+ readonly each?: boolean;
19
+ };
20
+ /**
21
+ * Options of `@Workers.queue`.
22
+ * @author Axel Nana <axel.nana@workbud.com>
23
+ */
24
+ export type WorkersQueueOptions = {
25
+ /** Receive one message at a time instead of the batch, so failures retry only that message. */
26
+ readonly each?: boolean;
27
+ };
28
+ export declare namespace Workers {
29
+ /**
30
+ * The Worker's env (bindings, vars and secrets) for the current invocation.
31
+ * @throws {OutsideInvocationError} Outside a request, a queue batch or a scheduled event.
32
+ */
33
+ const env: <Env = Record<string, unknown>>() => Env;
34
+ /**
35
+ * The execution context of the current invocation, for `waitUntil`.
36
+ * @throws {OutsideInvocationError} Outside a request, a queue batch or a scheduled event.
37
+ */
38
+ const context: () => WorkersExecutionContext;
39
+ /**
40
+ * A binding of the current invocation.
41
+ * @param name The binding name, as declared in the wrangler config.
42
+ * @throws {OutsideInvocationError} Outside a request, a queue batch or a scheduled event.
43
+ * @throws {BindingNotFoundError} When the Worker has no such binding.
44
+ */
45
+ const binding: <T = unknown>(name: string) => T;
46
+ /**
47
+ * The name of the service holding a binding, registered by `WorkersModule`.
48
+ * @param name The binding name.
49
+ */
50
+ const bindingService: (name: string) => string;
51
+ /**
52
+ * Injects a binding listed in `WorkersModule.with({ bindings })`. The binding is
53
+ * request-scoped, so only request-scoped services, factories and request-scoped
54
+ * controllers can take it.
55
+ * @param name The binding name.
56
+ */
57
+ const inject: (name: string) => ParameterDecorator;
58
+ /**
59
+ * Marks a method of a module service as the consumer of a queue. It receives the batch;
60
+ * when it throws, every message is retried. With `{ each: true }` it receives one message
61
+ * at a time, and only the messages it throws on are retried. After all handlers ran, the
62
+ * messages that no handler failed on are acked.
63
+ * @param queue The queue name, or `*` for every queue.
64
+ * @param options Set `each` to handle messages one by one.
65
+ */
66
+ const queue: (queue: string, options?: WorkersQueueOptions) => MethodDecorator;
67
+ /**
68
+ * Marks a method of a module service as the handler of a cron trigger. It receives the
69
+ * scheduled controller.
70
+ * @param cron The cron expression, exactly as in the wrangler config, or `*` for every cron.
71
+ */
72
+ const cron: (cron: string) => MethodDecorator;
73
+ /**
74
+ * The queue and cron handlers declared on a class.
75
+ * @param cls The class.
76
+ */
77
+ const handlersOf: (cls: object) => readonly WorkersHandlerMeta[];
78
+ }
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "@elysiumjs/runtime-workers",
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": "Cloudflare Workers runtime and adapter 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/runtime-workers"
15
+ },
16
+ "homepage": "https://github.com/na2axl/elysiumjs/tree/main/@elysiumjs/runtime-workers#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 browser --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
+ "check:runtime": "bun scripts/check-runtime.ts",
31
+ "check:aot": "bun scripts/check-aot.ts",
32
+ "check:wrangler": "bun scripts/check-wrangler.ts"
33
+ },
34
+ "keywords": [
35
+ "elysium",
36
+ "framework",
37
+ "typescript",
38
+ "runtime",
39
+ "cloudflare",
40
+ "workers"
41
+ ],
42
+ "type": "module",
43
+ "module": "dist/index.js",
44
+ "types": "dist/index.d.ts",
45
+ "files": [
46
+ "dist",
47
+ "CHANGELOG.md"
48
+ ],
49
+ "sideEffects": false,
50
+ "devDependencies": {
51
+ "@elysiumjs/core": "0.12.0",
52
+ "elysia": "^2.0.0-rc.1",
53
+ "wrangler": "4.158.0",
54
+ "@elysiumjs/mnemosyne": "0.5.1"
55
+ },
56
+ "peerDependencies": {
57
+ "@elysiumjs/core": "^0.12.0"
58
+ }
59
+ }