npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@velajs/cloudflare

v1.31.0

Published

Cloudflare Workers integration for Vela framework

Readme

@velajs/cloudflare

Nest-style modules, dependency injection, controllers, queue consumers, cron triggers, and live WebSockets on Cloudflare Workers. HTTP routing uses Hono. Bindings use the platform's native types.

Native environment and application lifetime

The Worker's native environment is the framework ENV from @velajs/vela. createCloudflareWorker seeds it for each environment before any provider is constructed, so the Worker entry only exports. Inject it wherever bindings or secrets are needed, including async provider factories (inject: [ENV]).

Types come from Wrangler. Run wrangler types --include-runtime=false (runtime types stay with @cloudflare/workers-types) and include the generated worker-configuration.d.ts in your tsconfig. It declares Cloudflare.Env from the bindings and variables in your Wrangler file and the secret names in .dev.vars; this package extends VelaEnv with it, so ENV, forRootAsync factories and registerAs factories are typed without a hand-written interface. Regenerate it whenever the Wrangler file changes.

import { Controller, Get, InjectEnv, Module, type VelaEnv } from '@velajs/vela';
import { createCloudflareWorker } from '@velajs/cloudflare';

@Controller('/status')
class StatusController {
  // CACHE is a KVNamespace in worker-configuration.d.ts.
  constructor(@InjectEnv() private readonly env: VelaEnv) {}

  @Get()
  async status() {
    return { message: await this.env.CACHE.get('status') };
  }
}

@Module({ controllers: [StatusController] })
class AppModule {}

export default createCloudflareWorker(AppModule);

The worker exposes fetch, queue, and scheduled, and carries a descriptor under the symbol key CLOUDFLARE_WORKER (Symbol.for('vela.cloudflare.worker')), which the platform ignores and which an entry adding handlers keeps (export default { ...worker, email }): the root module, the options, and createOptions(env)/createApplication(env), which build the application exactly as the Worker does, short of the configure(app, env) hook, which receives the Workers application. @velajs/cli loads the Worker entry and builds its application from it, so a project needs no vela.config, and @velajs/cloudflare/testing builds test applications from the same options. Its first event builds an application with that event's environment. Concurrent events for the same environment object share construction. Different environment objects receive separate applications, including separate providers, lifecycle state, and live drivers. A failed construction is evicted and the next event retries.

The root is static: a module class, or a DynamicModule such as AppModule.forRoot(...), declared once at module scope. createCloudflareWorker, createCloudflareApp and VelaWebSocketDurableObject all take the same root. When module configuration needs bindings, read them where each application is built, from its own ENV:

import { ENV, Module } from '@velajs/vela';

@Module({
  imports: [
    DatabaseModule.forRootAsync({
      inject: [ENV],
      useFactory: (env) => ({ database: env.DB }),
    }),
  ],
})
class AppModule {}

forRootAsync factories, useFactory providers, @InjectEnv() constructors and queue driver factories such as cloudflareQueues() run for each application, so nothing built from one environment is shared with another. Because the root never changes, building another application or Durable Object instance declares no new classes in the isolate. See the complete API starter for D1, Better Auth, CRUD, the generated Hono client, live updates, and Studio inspection in one application.

The application cache uses weak object keys, so the cache itself does not keep a replaced environment alive. Build secret-bearing values in forRootAsync factories that inject ENV rather than capturing them in module options. Providers with request scope still rebuild per HTTP request or queue/cron dispatch. Do not retain request objects or authentication state in singleton providers.

Request middleware belongs in modules: a consumer middleware class resolves through dependency injection, so it can inject ENV like any provider.

import {
  InjectEnv,
  Injectable,
  Module,
  type MiddlewareConsumer,
  type NestMiddleware,
  type NestModule,
  type VelaContext,
  type VelaEnv,
} from '@velajs/vela';

@Injectable()
class ServiceHeader implements NestMiddleware {
  constructor(@InjectEnv() private readonly env: VelaEnv) {}
  async use(context: VelaContext, next: () => Promise<void>) {
    context.header('x-service', this.env.SERVICE_NAME);
    await next();
  }
}

@Module({ providers: [ServiceHeader] })
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(ServiceHeader).forRoutes('*');
  }
}

createCloudflareWorker(AppModule, { configure }) finishes each application's HTTP surface, for example with extra Hono routes, once per environment. It runs after the application is built and before any event reaches it, including concurrent cold events; a throw fails that construction and the next event retries. It must be synchronous and do no I/O (a returned promise fails the construction):

export default createCloudflareWorker(AppModule, {
  configure(app, env) {
    app.getHonoApp().get('/version', (c) => c.text(env.SERVICE_VERSION));
  },
});

Enable CORS as in Nest, with the cors option or app.enableCors() inside configure; Hono's cors middleware then answers preflights ahead of every route and guard:

export default createCloudflareWorker(AppModule, { cors: { origin: ['https://app.example.com'] } });

For explicit construction inside a platform event:

const app = await createCloudflareApp(AppModule, { env, globalPrefix: '/api' });
const bindings = app.get(ENV); // VelaEnv
return app.fetch(request, env, executionContext);

env is registered as ENV before provider factories and lifecycle hooks. Referencing a binding is safe during construction; platform I/O must still happen inside a Workers event or Durable Object context. An explicitly built application rejects requests or events carrying another environment object, including calls through the underlying Hono app. Internal ctx.run reentry retains the application's environment.

cloudflareAdapter({ env }) provides the same bootstrap and request contract when composing VelaFactory.create directly. createCloudflareWorker and createCloudflareApp accept adapters: RuntimeAdapter[], composed after the Cloudflare adapter for each application, so the Worker entry needs no hand-written per-environment cache for them.

Because ENV carries every binding, variable and secret, framework features read their secrets from it without extra wiring: a string URL_SIGNING_SECRET signs URLs and invocations when no explicit secret is configured, and Studio reads VELA_STUDIO_TOKEN and its VELA_STUDIO_*_EDITABLE flags. Set them with wrangler secret put. Values come from outside the program, so validate each value your own code reads before relying on it.

Module-based queues, cron and RPC

QueueModule from @velajs/vela/queue is the Workers queue API. Configure the driver once in the root module and register each queue where it is used:

import { Injectable, Module } from '@velajs/vela';
import {
  InjectQueue,
  Process,
  Processor,
  QueueModule,
  defineQueueJob,
  type QueueClient,
  type QueueJob,
} from '@velajs/vela/queue';
import { cloudflareQueues } from '@velajs/cloudflare/queues';
import { z } from 'zod';

const welcome = defineQueueJob('welcome', z.object({ userId: z.string() }));

@Injectable()
class Signup {
  constructor(@InjectQueue('email') private readonly email: QueueClient) {}
  invite(userId: string) {
    return this.email.add(welcome, { userId });
  }
}

@Processor('email')
class EmailProcessor {
  @Process(welcome)
  send(job: QueueJob<{ userId: string }>) {}
}

@Module({
  imports: [QueueModule.registerQueue({ name: 'email', binding: 'EMAIL_QUEUE' })],
  providers: [Signup, EmailProcessor],
})
class EmailModule {}

@Module({ imports: [QueueModule.forRoot({ driver: cloudflareQueues() }), EmailModule] })
class AppModule {}

binding names a Wrangler queues.producers[].binding. The driver reads it from the application's ENV when a job is added and awaits the native send. addBulk uses sendBatch, split into calls of at most 100 messages and an estimated 256 KB, and rejects a job estimated over 128 KB before sending anything. A partial failure rejects with a QueueBatchError whose accepted lists the job ids already sent.

The Worker's queue() handler gives each batch to the @QueueConsumer handlers of its physical queue. Batches no @QueueConsumer claims go to QueueModule, which routes every job by its logical queue, so several registered queues may share one physical queue. Each job runs through the module's dispatch policy, including signed dispatch and its global guards. A message is acknowledged after its processors succeed; a message that is not a job envelope, belongs to an unregistered queue, or fails stays unacknowledged, so Cloudflare retries it and then dead-letters it. registerQueue({ name, consumer: 'email-production' }) pins the queue to that physical queue: its jobs are accepted only from it, and it carries only the queues pinned to it. A physical queue cannot be both a @QueueConsumer queue and a pinned consumer. A @QueueConsumer owns its physical queue and must not carry jobs of registered queues: those reach their @Processor only if the raw handler dispatches them itself, so the adapter warns once when it sees them. Registered queues are delivered by cloudflareQueues(). dispatchQueueJob is for tests and for transports other than Cloudflare Queues; it applies the module's dispatch policy, signed dispatch included.

Use ScheduleModule.forRoot() and @Cron() for native scheduled work. The queue guide and module guide cover producer-only and consumer-only Workers, RPC modules and deployment checks. @QueueConsumer remains available for raw batches.

Managed queue and cron work

Each matching @QueueConsumer handler receives its batch and environment, plus a context whose waitUntil(promise) delegates to the platform and retains that handler's DI scope until the promise settles. Class/method guards, interceptors and filters resolve asynchronously from the handler's declaring module. The execution context exposes that same child via getContainer() and its owner via getModuleId(); REQUEST_CONTEXT remains HTTP-only.

A @Cron job receives only its CronInvocation, with no environment or context argument, and runs no guards, interceptors or filters: the adapter warns once (fails bootstrap in diagnostics: 'throw') when a job declares @UseGuards, @UseInterceptors or @UseFilters, and a job that declares guards is refused on every trigger instead of running unguarded. Use signed ScheduleModule dispatch to run a job through a route's request pipeline, and inject ENV, CLOUDFLARE_SCHEDULED_EVENT and EXECUTION_LIFETIME for what the native handler arguments used to carry.

In both, inject EXECUTION_LIFETIME from @velajs/vela to schedule deferred callbacks with lifetime.defer(work) or register already-started work with lifetime.waitUntil(promise). The handler, managed work and asynchronous provider disposal finish before queue/cron dispatch returns. Unclaimed failures reject for the platform to observe; they are not silently converted into success. When several handlers match, every handler settles before a single failure or AggregateError is returned. Background failure does not undo completed writes; handlers still need the idempotency appropriate to their delivery semantics.

See execution scopes for lifetime ownership, stream boundaries, cancellation and optional transport integration.

Typed provider factories

Bindings retain their full native API and generic parameters. There are no binding-name wrappers to initialize or cast.

import { defineProvider, ENV, InjectionToken, Module } from '@velajs/vela';

const TASK_QUEUE = new InjectionToken<Queue<{ taskId: string }>>('task queue');

@Module({
  providers: [defineProvider(TASK_QUEUE, {
    inject: [ENV],
    useFactory: (env) => env.JOBS,
  })],
  exports: [TASK_QUEUE],
})
class JobsModule {}

A useFactory strategy declares its dependencies with inject; a factory without parameters may omit it. This also applies to lazyProvider and forRootAsync factory options.

Use native env.DB, env.CACHE, env.FILES, env.JOBS, env.AI, env.VECTORIZE, or env.HYPERDRIVE directly. Inject ENV in constructors (@InjectEnv()) and factories (inject: [ENV]); it works the same in HTTP, queue, cron and Durable Object code.

Queues and cron

import { InjectEnv, Injectable, type VelaEnv } from '@velajs/vela';
import { Cron, type CronInvocation } from '@velajs/vela/schedule';
import { QueueConsumer } from '@velajs/cloudflare';

@Injectable()
class Jobs {
  constructor(@InjectEnv() private readonly env: VelaEnv) {}

  // Declare the same string under Wrangler `triggers.crons`.
  @Cron('0 * * * *', { dialect: 'cloudflare' })
  async refresh(tick: CronInvocation) {
    await this.env.CACHE.put('last-refresh', new Date(tick.scheduledTime).toISOString());
  }

  @QueueConsumer('jobs')
  async consume(batch: MessageBatch<unknown>) {
    for (const message of batch.messages) {
      // Validate message.body before interpreting its application shape.
    }
  }
}

A cron trigger runs every core @Cron() job whose expression is exactly the trigger string. Jobs receive only their CronInvocation, as on Node, in a fresh request scope and without guards, interceptors or filters; inject CLOUDFLARE_SCHEDULED_EVENT for the trigger's bound noRetry() and EXECUTION_LIFETIME for background work. A job run outside a trigger (Studio's run-now) receives a synthetic event whose noRetry() does nothing. Signed ScheduleModule dispatch runs the signed route with its global guards. Queue consumers use fresh request scopes and their declared guards, interceptors, and filters. Unclaimed errors propagate to the platform for retry. Cold queue and cron events have the same native bindings and live invalidation capabilities as HTTP. See scheduling.

WebSockets, live queries, and Durable Objects

Import the core modules; the Cloudflare adapter wires them to Durable Objects. Use the native Durable Object entrypoint only in your Worker entry file:

import { Module } from '@velajs/vela';
import { LiveModule } from '@velajs/vela/live';
import { WebSocketModule } from '@velajs/vela/websocket';
import { createCloudflareWorker } from '@velajs/cloudflare';
import { VelaWebSocketDurableObject } from '@velajs/cloudflare/durable-objects';

@Module({
  imports: [WebSocketModule.forRoot(), LiveModule.forRoot()],
  // Your @WebSocketGateway({ binding: 'ROOMS', ... }) and @LiveResolver classes.
  providers: [],
})
class RoomModule {}

export class Room extends VelaWebSocketDurableObject(RoomModule) {}
export default createCloudflareWorker(RoomModule);

cloudflareAdapter registers the platform as the global WS_TRANSPORT and LIVE_PLATFORM tokens before modules load; it never replaces module providers. In the Worker, WebSocketModule serves an upgrade route for each gateway that names a binding and forwards the authenticated upgrade to that room's Durable Object. The Worker keeps no sockets, so a gateway's @WebSocketServer() refuses pushes there; Gateways from @velajs/vela/websocket (gateways.of(ChatGateway).to(room).emit(event, data)) calls the broadcast RPC of the gateway + room Durable Object, read by the gateway's binding from ENV. Inside a Durable Object, a Gateways push to its own room reaches its sockets and a push to another room goes to that room's object. LiveModule sends invalidations to the room Durable Object of the single binding-backed gateway, reading the namespace from ENV when first needed; with several, the first invalidation reports the ambiguity, and driver: () => durableObjectLive({ gatewayPath }) (or { binding }) chooses. A gateway without roomParam has one room Durable Object, named by its path, so its invalidations and inspections go there whatever room they name. Inside the Durable Object, the gateway server broadcasts to its hibernatable sockets, invalidations apply locally, and the cursor log is a DoCursorLog in the object's SQLite storage (in memory when the class is not SQLite-backed). LiveInspector reads a named room through the same gateway binding with the object's inspectLive RPC, which Studio's livePanel({ rooms }) uses. The same RoomModule serves the Worker, every Durable Object and a node host. Without WebSocketModule, the Worker mounts no upgrade route, and the adapter reports each binding-backed gateway through the diagnostics policy.

Declare gateways with @WebSocketGateway({ path, roomParam, binding, ... }) and configure origins and upgrade authentication for your application. authenticator names an UpgradeAuthenticator class that the Worker resolves once per application from the module declaring the gateway, and allowedOrigins may read the environment: (env) => [env.APP_ORIGIN]. BetterAuthUpgradeAuthenticator (@velajs/better-auth) and CloudflareAccessUpgradeAuthenticator (@velajs/cloudflare-access/vela) are ready-made authenticators. A gateway without an authenticator refuses every upgrade. Authentication finishes before the Durable Object id is derived. Upgrade routing consumes the core trusted request identity, checks conflicts with the upgrade credential, and forwards issuer, subject, tenant, and expiry to the DO. Client-supplied internal identity headers are stripped before authorization.

Declare each live query once with defineLiveQuery({ name, args, result }) from @velajs/vela/live. Both schemas accept a parser object such as a Zod schema. Use @LiveQuery(definition, { tags: ['todos'] }) on the resolver and share the definition with its client. Restored hibernation arguments and final query results use the same validation boundary.

Each application constructs its own driver and cursor log. Workers send invalidations through the gateway's DO namespace; the DO uses its own live engine and SQLite cursor log. Configure the class in Wrangler new_sqlite_classes to retain cursor/epoch state across hibernation. Hibernated subscriptions are restored on wake; the shared live protocol handles resume or snapshot fallback.

The DO class preserves its RPC types, so DurableObjectNamespace<Room> exposes invalidate, broadcast, inspectLive, and PITR methods without assertions. liveInvalidateToRoom and the PITR helpers remain available from the root package. VelaNonceDurableObject is exported from /durable-objects; its durableObjectNonceStore factory remains on the root entrypoint.

The root package contains no runtime cloudflare:workers import and can be loaded by Node tooling. Native classes belong to /durable-objects.

Bindings by name

Module options name a binding instead of holding it. kv, r2, d1, queue, durableObject and rateLimit each take { binding }, the name declared in the Wrangler configuration, and read nothing when declared: calling the reference with an application's ENV returns the typed native binding, or fails naming the binding and the Wrangler key that declares it (ENV.UPLOADS is not set: declare the R2 bucket binding 'UPLOADS' under r2_buckets …). The drivers and stores below are built on them, so one static module graph serves every environment.

Rate limiting

ThrottlerModule from @velajs/vela/throttler counts through Workers Rate Limiting bindings named in rateLimitStore:

import { ThrottlerModule } from '@velajs/vela/throttler';
import { rateLimitStore } from '@velajs/cloudflare';

ThrottlerModule.forRoot({
  throttlers: [
    { name: 'burst', ttl: 10_000, limit: 20 },
    { name: 'sustained', ttl: 60_000, limit: 100 },
  ],
  storage: rateLimitStore({ binding: { burst: 'BURST_LIMITER', sustained: 'API_LIMITER' } }),
});

rateLimitStore({ binding: 'API_LIMITER' }) serves every throttler from one binding. Each binding's simple.limit and simple.period in the Wrangler ratelimits block must equal its throttler's limit and ttl (10 or 60 seconds): the platform enforces them, so a @Throttle() override that changes them fails at bootstrap, and one binding serves only throttlers that share a limit and ttl. The store checks the declared throttlers at bootstrap: another period, different values on one binding, or a throttler the per-name map leaves out fails the application before any binding is charged. The platform exposes no counters, so responses carry no X-RateLimit-Remaining, and no reset time, so X-RateLimit-Reset and Retry-After report the configured period.

Workers Rate Limiting counts per Cloudflare location, and its counters are eventually consistent: limits are approximate, not a global or exact quota. A client spread across locations can exceed them. For strict limits such as login attempts per account, implement a ThrottlerStore that counts in a Durable Object.

R2 storage and caches

StorageModule from @velajs/storage is the one file-storage module. Its native R2 driver comes from the @velajs/cloudflare/storage subpath:

import { StorageModule } from '@velajs/storage';
import { r2Storage } from '@velajs/cloudflare/storage';

StorageModule.forRoot({ driver: r2Storage({ binding: 'UPLOADS' }) });

The bucket is read from each application's ENV on its first storage operation. The native binding cannot presign; use publicBaseUrl, the storage HTTP controller with http: { download: 'proxy' }, or the R2 HTTP/hybrid drivers for provider-signed URLs.

CacheModule from @velajs/vela/cache takes its KV stores by binding name:

import { CacheModule } from '@velajs/vela/cache';
import { kvCache, kvCacheInvalidation } from '@velajs/cloudflare';

CacheModule.forRoot({
  namespace: 'catalog-v1',
  scope: trustedCacheScope,
  store: kvCache({ binding: 'CACHE' }),
  invalidation: kvCacheInvalidation({ binding: 'CACHE_GENERATIONS' }),
});

Each operation reads the namespace from the application's ENV. A binding that is not declared fails the operation with an error naming it and kv_namespaces; the cache treats that as a miss and sends the error to the application's error reporter (edge 'cache').

KVCacheStore and KVCacheInvalidationStore take a namespace or a function returning one, for composition such as store: (env) => new TieredCacheStore([new MemoryCacheStore(), new KVCacheStore(env.CACHE)]). Construct KvFlagDriver with a native namespace: new KvFlagDriver(env.CACHE). Cache reads and object-valued flag reads return unknown; validate them with an application parser (cache.scope(scope).getParsed(key, parser)).

Testing Worker handlers

@velajs/cloudflare/testing runs inside the Workers Vitest pool (@cloudflare/vitest-plugin) and needs @velajs/testing. createTestingWorker(AppModule, { env?, overrides?, ...workerOptions }) builds the module as createCloudflareWorker(AppModule, workerOptions) does (its adapters, then its configure(app, env) hook), through Test.createTestingModule(), and drives the Worker's handlers:

import { env } from 'cloudflare:workers';
import { createTestingWorker, queueJob } from '@velajs/cloudflare/testing';
import { expect, it } from 'vitest';

it('processes a created todo', async () => {
  const worker = await createTestingWorker(AppModule, {
    env,
    overrides: (module) =>
      module
        .overrideModule(NotificationsModule)
        .useModule(SilentNotifications)
        .useMocker((token) => (token === Notifier ? fakeNotifier : undefined)),
  });
  try {
    const created = await worker.fetch('/todos', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ title: 'Write tests' }),
    });
    expect(created.ok).toBe(true);

    const result = await worker.queue('todo-events', [
      queueJob('todo-events', todoCreated, { id: '1', title: 'Write tests' }),
    ]);
    expect(result.outcome).toBe('ok');
    expect(result.explicitAcks).toHaveLength(1);

    await worker.scheduled('0 3 * * *');
  } finally {
    await worker.close();
  }
});
  • env defaults to the pool's env from cloudflare:workers; every event carries it, so the Cloudflare adapter accepts the requests.
  • overrides receives the @velajs/testing builder: overrideProvider(), overrideGuard() and the other enhancer overrides, overrideModule().useModule() and useMocker(). worker.module is the compiled TestingModule; its fetch() and http client send the same env as c.env.
  • fetch(input, init?) resolves a path against http://localhost.
  • queue(physicalQueue, messages) delivers one batch built with cloudflare:test's createMessageBatch() and returns its getQueueResult() plus outcome ('exception', with error, when the handler rejected: Cloudflare would retry the unacknowledged messages). queueJob(queue, jobOrName, data, { id?, attempts? }) builds the envelope QueueClient.add() sends.
  • scheduled(cron, { scheduledTime? }) fires a cron trigger with createScheduledController(), and rejects when no @Cron job declares cron.
  • close() cancels response bodies a test never read, waits for background work (waitUntil) and closes the application.

The subpath is not part of any Worker bundle: import it from tests only.

Development

From the repository root:

pnpm --filter @velajs/cloudflare test
pnpm --filter @velajs/cloudflare test:workers
pnpm --filter @velajs/cloudflare typecheck

The Workers suite uses real KV, D1, R2, WebSockets, SQLite Durable Objects, and cold event dispatch. See the security guide for trusted identity, URL signing, and WebSocket boundaries.

KV caches

kvCache works directly as CacheModule's store or, through KVCacheStore, as a tier beneath TieredCacheStore. The adapter retains absolute logical expiry in KV metadata; KV's minimum physical retention does not extend the requested TTL. For tags and scoped invalidation, configure kvCacheInvalidation with a separate dedicated KV namespace without TTLs or lifecycle cleanup. It is eventually consistent, including concurrent writes and cached negative reads, and does not promise globally strong invalidation. See the caching guide.