@velajs/cloudflare 1.5.0 → 1.6.0

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +8 -8
  3. package/dist/cloudflare-application.d.ts +4 -0
  4. package/dist/cloudflare-application.js +6 -0
  5. package/dist/cloudflare-factory.js +9 -7
  6. package/dist/index.d.ts +7 -0
  7. package/dist/index.js +7 -0
  8. package/dist/modules/create-binding-module.js +19 -22
  9. package/dist/services/kv-cache.store.d.ts +19 -0
  10. package/dist/services/kv-cache.store.js +58 -0
  11. package/dist/storage/index.d.ts +7 -0
  12. package/dist/storage/index.js +6 -0
  13. package/dist/storage/r2-storage.driver.d.ts +19 -0
  14. package/dist/storage/r2-storage.driver.js +61 -0
  15. package/dist/storage/storage-manager.service.d.ts +16 -0
  16. package/dist/storage/storage-manager.service.js +56 -0
  17. package/dist/storage/storage.controller.d.ts +16 -0
  18. package/dist/storage/storage.controller.js +88 -0
  19. package/dist/storage/storage.module.d.ts +7 -0
  20. package/dist/storage/storage.module.js +37 -0
  21. package/dist/storage/storage.service.d.ts +20 -0
  22. package/dist/storage/storage.service.js +78 -0
  23. package/dist/storage/storage.tokens.d.ts +3 -0
  24. package/dist/storage/storage.tokens.js +2 -0
  25. package/dist/storage/storage.types.d.ts +17 -0
  26. package/dist/storage/storage.types.js +1 -0
  27. package/dist/websocket/broadcast.d.ts +15 -0
  28. package/dist/websocket/broadcast.js +26 -0
  29. package/dist/websocket/cf-room-registry.d.ts +23 -0
  30. package/dist/websocket/cf-room-registry.js +65 -0
  31. package/dist/websocket/cf-ws-client.d.ts +28 -0
  32. package/dist/websocket/cf-ws-client.js +83 -0
  33. package/dist/websocket/cloudflare-websocket.module.d.ts +11 -0
  34. package/dist/websocket/cloudflare-websocket.module.js +27 -0
  35. package/dist/websocket/do-bootstrap.d.ts +19 -0
  36. package/dist/websocket/do-bootstrap.js +43 -0
  37. package/dist/websocket/do-state.d.ts +25 -0
  38. package/dist/websocket/do-state.js +4 -0
  39. package/dist/websocket/do-websocket-host.d.ts +27 -0
  40. package/dist/websocket/do-websocket-host.js +67 -0
  41. package/dist/websocket/index.d.ts +13 -0
  42. package/dist/websocket/index.js +13 -0
  43. package/dist/websocket/room-id.d.ts +6 -0
  44. package/dist/websocket/room-id.js +12 -0
  45. package/dist/websocket/websocket-routing.d.ts +14 -0
  46. package/dist/websocket/websocket-routing.js +45 -0
  47. package/dist/websocket/websocket.durable-object.d.ts +15 -0
  48. package/dist/websocket/websocket.durable-object.js +72 -0
  49. package/dist/websocket/ws-server-holder.d.ts +16 -0
  50. package/dist/websocket/ws-server-holder.js +34 -0
  51. package/package.json +8 -17
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.0 (2026-07-01)
4
+
5
+ ### Added
6
+
7
+ - **Multi-disk `StorageModule` over R2** (`StorageService.put/get/delete/exists/url`, per-disk templated roots, bucket-by-name via `EnvService`, `R2StorageDriver`) + a signature-gated `StorageController` presign-proxy.
8
+ - **`KVCacheStore`** implementing vela's `AsyncCacheStore` — pair with `TieredCacheStore` for a memory→KV cache.
9
+ - WebSocket transport for vela's WebSocket gateways (Durable Object backed).
10
+
11
+ ### Fixed
12
+
13
+ - **Multiple same-type bindings** (e.g. two `KVModule.forRoot` with different bindings) now all initialize — `collectBindingRefs` enumerates every binding ref across module buckets instead of resolving each token once.
14
+
3
15
  ## 0.2.0 (2026-04-28)
4
16
 
5
17
  ### Breaking changes
package/README.md CHANGED
@@ -34,7 +34,7 @@ class HyperdriveService { readonly binding: Hyperdrive; }
34
34
 
35
35
  ```ts
36
36
  import { Controller, Get, Module, Injectable, Param } from '@velajs/vela';
37
- import { CloudflareFactory, KVModule, KVService, D1Module, D1Service } from '@velajs/cloudflare';
37
+ import { createCloudflareApp, KVModule, KVService, D1Module, D1Service } from '@velajs/cloudflare';
38
38
 
39
39
  @Injectable()
40
40
  class UserService {
@@ -73,7 +73,7 @@ class UserController {
73
73
  })
74
74
  class AppModule {}
75
75
 
76
- export default await CloudflareFactory.create(AppModule);
76
+ export default await createCloudflareApp(AppModule);
77
77
  ```
78
78
 
79
79
  The `binding` string matches the binding name in your `wrangler.toml`:
@@ -327,14 +327,14 @@ class EmailWorker {
327
327
  }
328
328
  ```
329
329
 
330
- ## CloudflareFactory
330
+ ## createCloudflareApp
331
331
 
332
- Use `CloudflareFactory.create()` instead of `VelaFactory.create()` for Cloudflare apps. It sets up a one-time middleware that captures `c.env` on the first request and initializes all binding modules.
332
+ Use `createCloudflareApp()` instead of `VelaFactory.create()` for Cloudflare apps. It sets up a one-time middleware that captures `c.env` on the first request and initializes all binding modules.
333
333
 
334
334
  ```ts
335
- import { CloudflareFactory } from '@velajs/cloudflare';
335
+ import { createCloudflareApp } from '@velajs/cloudflare';
336
336
 
337
- const app = await CloudflareFactory.create(AppModule);
337
+ const app = await createCloudflareApp(AppModule);
338
338
  export default app;
339
339
  ```
340
340
 
@@ -343,7 +343,7 @@ export default app;
343
343
  To use scheduled triggers and queue consumers, export the handlers explicitly:
344
344
 
345
345
  ```ts
346
- const app = await CloudflareFactory.create(AppModule);
346
+ const app = await createCloudflareApp(AppModule);
347
347
 
348
348
  export default {
349
349
  fetch: app.fetch,
@@ -371,7 +371,7 @@ const rawHD = hdService.binding; // Hyperdrive
371
371
 
372
372
  Cloudflare Workers only provide bindings (`env.DB`, `env.MY_KV`, etc.) at request time via the `env` parameter. They are stable across requests within an isolate.
373
373
 
374
- `CloudflareFactory` handles this by:
374
+ `createCloudflareApp` handles this by:
375
375
 
376
376
  1. Each `XModule.forRoot()` creates a `BindingRef` (mutable holder) and registers it in the DI container
377
377
  2. Services are constructed at boot time with the `BindingRef` — no binding access yet
@@ -1,5 +1,6 @@
1
1
  import type { Hono } from 'hono';
2
2
  import { type VelaApplication } from '@velajs/vela';
3
+ import { type WsGatewayRoute } from './websocket/websocket-routing';
3
4
  import type { CloudflareEnv } from './types';
4
5
  /**
5
6
  * Options accepted by {@link CloudflareApplication.mountOpenApi}.
@@ -42,6 +43,7 @@ export declare class CloudflareApplication {
42
43
  private app;
43
44
  private scheduledHandlers;
44
45
  private queueConsumers;
46
+ private wsGatewayRoutes;
45
47
  constructor(app: VelaApplication);
46
48
  get fetch(): Hono['fetch'];
47
49
  getHonoApp(): Hono;
@@ -81,6 +83,8 @@ export declare class CloudflareApplication {
81
83
  mountOpenApi(options: MountOpenApiOptions): this;
82
84
  /** @internal — scans instances for @Scheduled, @Cron, and @QueueConsumer metadata */
83
85
  scanInstances(instances: unknown[]): void;
86
+ /** @internal — upgrade routes discovered from `@WebSocketGateway({ path, binding })`. */
87
+ getWsGatewayRoutes(): WsGatewayRoute[];
84
88
  /**
85
89
  * Handle Cloudflare scheduled (cron) events.
86
90
  * Matches the event's cron expression to `@Scheduled()` and vela `@Cron()` handlers.
@@ -1,6 +1,7 @@
1
1
  import { CRON_METADATA, getMetadata } from "@velajs/vela";
2
2
  import { getScheduledMetadata } from "./decorators/scheduled.js";
3
3
  import { getQueueConsumerMetadata } from "./decorators/queue-consumer.js";
4
+ import { collectWsGatewayRoutes } from "./websocket/websocket-routing.js";
4
5
  function invoke(instance, methodName, args) {
5
6
  const method = instance[methodName];
6
7
  if (typeof method !== 'function') {
@@ -40,6 +41,7 @@ function invoke(instance, methodName, args) {
40
41
  app;
41
42
  scheduledHandlers = [];
42
43
  queueConsumers = [];
44
+ wsGatewayRoutes = [];
43
45
  constructor(app){
44
46
  this.app = app;
45
47
  }
@@ -112,8 +114,12 @@ function invoke(instance, methodName, args) {
112
114
  queueName: meta.queueName
113
115
  });
114
116
  }
117
+ this.wsGatewayRoutes.push(...collectWsGatewayRoutes(instance));
115
118
  }
116
119
  }
120
+ /** @internal — upgrade routes discovered from `@WebSocketGateway({ path, binding })`. */ getWsGatewayRoutes() {
121
+ return this.wsGatewayRoutes;
122
+ }
117
123
  /**
118
124
  * Handle Cloudflare scheduled (cron) events.
119
125
  * Matches the event's cron expression to `@Scheduled()` and vela `@Cron()` handlers.
@@ -2,19 +2,18 @@ import { VelaFactory } from "@velajs/vela";
2
2
  import { BindingRef } from "./binding-ref.js";
3
3
  import { CloudflareApplication } from "./cloudflare-application.js";
4
4
  import { EnvRef } from "./env-ref.js";
5
+ import { registerWebSocketRoutes } from "./websocket/websocket-routing.js";
5
6
  // Walks every provider registered in the container and pulls out the
6
7
  // BindingRef instances. Replaces the old module-level `bindingsRegistry`
7
8
  // global so two CloudflareApplication instances in one process don't
8
9
  // share state.
9
10
  function collectBindingRefs(container) {
11
+ // Enumerate per-instance useValue providers across ALL module buckets. Two
12
+ // same-type binding modules (e.g. KVModule.forRoot for CACHE and SESSIONS)
13
+ // share one token but live in distinct buckets — resolving the token would
14
+ // return only the first, leaving the others uninitialized.
10
15
  const refs = [];
11
- for (const token of container.getTokens()){
12
- let value;
13
- try {
14
- value = container.resolve(token);
15
- } catch {
16
- continue;
17
- }
16
+ for (const value of container.getUseValues()){
18
17
  if (value instanceof BindingRef) refs.push(value);
19
18
  }
20
19
  return refs;
@@ -76,5 +75,8 @@ function collectBindingRefs(container) {
76
75
  refs = collectBindingRefs(velaApp.getContainer());
77
76
  const cfApp = new CloudflareApplication(velaApp);
78
77
  cfApp.scanInstances(velaApp.getInstances());
78
+ // Register WebSocket upgrade routes for any @WebSocketGateway({ path, binding }).
79
+ // Each forwards the upgrade to the room's Durable Object (which owns the socket).
80
+ registerWebSocketRoutes(cfApp.getHonoApp(), cfApp.getWsGatewayRoutes());
79
81
  return cfApp;
80
82
  }
package/dist/index.d.ts CHANGED
@@ -11,7 +11,10 @@ export { AIModule } from './modules/ai.module';
11
11
  export { VectorizeModule } from './modules/vectorize.module';
12
12
  export { HyperdriveModule } from './modules/hyperdrive.module';
13
13
  export { EnvModule } from './modules/env.module';
14
+ export { StorageModule, StorageService, StorageManagerService, StorageController, R2StorageDriver, STORAGE_OPTIONS, } from './storage/index';
15
+ export type { StorageModuleOptions, DiskConfig, PresignedUrlConfig } from './storage/index';
14
16
  export { KVService } from './services/kv.service';
17
+ export { KVCacheStore } from './services/kv-cache.store';
15
18
  export { D1Service } from './services/d1.service';
16
19
  export { R2Service } from './services/r2.service';
17
20
  export { QueueService } from './services/queue.service';
@@ -23,6 +26,10 @@ export { EnvService } from './services/env.service';
23
26
  export { Env } from './decorators/env';
24
27
  export { Scheduled } from './decorators/scheduled';
25
28
  export { QueueConsumer } from './decorators/queue-consumer';
29
+ export { VelaWebSocketDurableObject, CloudflareWebSocketModule, broadcastToRoom, } from './websocket/index';
30
+ export type { WsGatewayRoute } from './websocket/index';
31
+ export { WebSocketGateway, SubscribeMessage, MessageBody, ConnectedSocket, WebSocketServer, WsException, } from '@velajs/vela/websocket';
32
+ export type { WsClient, WsServer, WsResponse, WsMessage, OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect, } from '@velajs/vela/websocket';
26
33
  export type { CloudflareEnv, ScheduledRegistration, QueueRegistration } from './types';
27
34
  export type { ScheduledMetadata } from './decorators/scheduled';
28
35
  export type { QueueConsumerMetadata } from './decorators/queue-consumer';
package/dist/index.js CHANGED
@@ -11,8 +11,11 @@ export { AIModule } from "./modules/ai.module.js";
11
11
  export { VectorizeModule } from "./modules/vectorize.module.js";
12
12
  export { HyperdriveModule } from "./modules/hyperdrive.module.js";
13
13
  export { EnvModule } from "./modules/env.module.js";
14
+ // Storage (multi-disk over R2 + presign proxy)
15
+ export { StorageModule, StorageService, StorageManagerService, StorageController, R2StorageDriver, STORAGE_OPTIONS } from "./storage/index.js";
14
16
  // Services
15
17
  export { KVService } from "./services/kv.service.js";
18
+ export { KVCacheStore } from "./services/kv-cache.store.js";
16
19
  export { D1Service } from "./services/d1.service.js";
17
20
  export { R2Service } from "./services/r2.service.js";
18
21
  export { QueueService } from "./services/queue.service.js";
@@ -25,3 +28,7 @@ export { EnvService } from "./services/env.service.js";
25
28
  export { Env } from "./decorators/env.js";
26
29
  export { Scheduled } from "./decorators/scheduled.js";
27
30
  export { QueueConsumer } from "./decorators/queue-consumer.js";
31
+ // WebSocket (Durable Object transport for the Vela WebSocketModule)
32
+ export { VelaWebSocketDurableObject, CloudflareWebSocketModule, broadcastToRoom } from "./websocket/index.js";
33
+ // Re-export the core gateway API so a Cloudflare app can import it from one place.
34
+ export { WebSocketGateway, SubscribeMessage, MessageBody, ConnectedSocket, WebSocketServer, WsException } from "@velajs/vela/websocket";
@@ -1,7 +1,8 @@
1
+ import { defineConfigurableModule } from "@velajs/vela";
1
2
  import { BindingRef } from "../binding-ref.js";
2
3
  // Single factory for every Cloudflare binding module (KV, D1, R2, ...).
3
- // Each binding module is just a bag of (token, service); this generates
4
- // the canonical forRoot() shape for them.
4
+ // Each binding module is just a bag of (token, service); this generates the
5
+ // canonical forRoot() shape via vela's `defineConfigurableModule` engine.
5
6
  //
6
7
  // Identity model (post vela audit #2):
7
8
  // - One real module class is declared per binding TYPE (KV, D1, R2, ...).
@@ -22,24 +23,20 @@ export function createBindingModule(opts) {
22
23
  [className]: class {
23
24
  }
24
25
  }[className];
25
- return {
26
- forRoot ({ binding }) {
27
- const ref = new BindingRef(binding);
28
- return {
29
- module: moduleClass,
30
- key: binding,
31
- providers: [
32
- {
33
- provide: opts.bindingRefToken,
34
- useValue: ref
35
- },
36
- opts.serviceClass
37
- ],
38
- exports: [
39
- opts.serviceClass,
40
- opts.bindingRefToken
41
- ]
42
- };
43
- }
44
- };
26
+ return defineConfigurableModule({
27
+ module: moduleClass,
28
+ methodName: 'forRoot',
29
+ keyFrom: ({ binding })=>binding,
30
+ providers: ({ binding })=>[
31
+ {
32
+ provide: opts.bindingRefToken,
33
+ useValue: new BindingRef(binding)
34
+ },
35
+ opts.serviceClass
36
+ ],
37
+ exports: [
38
+ opts.serviceClass,
39
+ opts.bindingRefToken
40
+ ]
41
+ });
45
42
  }
@@ -0,0 +1,19 @@
1
+ import { type AsyncCacheStore } from '@velajs/vela';
2
+ import { KVService } from './kv.service';
3
+ /**
4
+ * Cloudflare KV-backed {@link CacheStore}. Values are JSON-encoded. Intended as
5
+ * the slow tier under a `TieredCacheStore` (memory L1 → KV L2), but usable
6
+ * standalone as `CacheModule.forRootAsync({ inject: [KVService], useFactory: (kv) => ({ store: new KVCacheStore(kv) }) })`.
7
+ *
8
+ * Note: Cloudflare KV requires `expirationTtl >= 60s`, so sub-minute TTLs are
9
+ * clamped up. Keep short TTLs on the memory tier; use KV for longer-lived entries.
10
+ */
11
+ export declare class KVCacheStore implements AsyncCacheStore {
12
+ private readonly kv;
13
+ constructor(kv: KVService);
14
+ private get ns();
15
+ get<T = unknown>(key: string): Promise<T | undefined>;
16
+ set<T = unknown>(key: string, value: T, ttl?: number): Promise<void>;
17
+ del(key: string): Promise<void>;
18
+ clear(): Promise<void>;
19
+ }
@@ -0,0 +1,58 @@
1
+ function _ts_decorate(decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ 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;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ }
7
+ function _ts_metadata(k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ }
10
+ function _ts_param(paramIndex, decorator) {
11
+ return function(target, key) {
12
+ decorator(target, key, paramIndex);
13
+ };
14
+ }
15
+ import { Inject, Injectable } from "@velajs/vela";
16
+ import { KVService } from "./kv.service.js";
17
+ export class KVCacheStore {
18
+ kv;
19
+ constructor(kv){
20
+ this.kv = kv;
21
+ }
22
+ get ns() {
23
+ return this.kv.namespace;
24
+ }
25
+ async get(key) {
26
+ const value = await this.ns.get(key, 'json');
27
+ return value === null ? undefined : value;
28
+ }
29
+ async set(key, value, ttl) {
30
+ // KV enforces a 60s minimum expirationTtl; clamp up. Omit for no-TTL.
31
+ const options = ttl !== undefined ? {
32
+ expirationTtl: Math.max(60, Math.floor(ttl))
33
+ } : undefined;
34
+ await this.ns.put(key, JSON.stringify(value), options);
35
+ }
36
+ async del(key) {
37
+ await this.ns.delete(key);
38
+ }
39
+ async clear() {
40
+ // KV has no native clear — page through and delete. Best-effort.
41
+ let cursor;
42
+ do {
43
+ const list = await this.ns.list(cursor ? {
44
+ cursor
45
+ } : undefined);
46
+ await Promise.all(list.keys.map((entry)=>this.ns.delete(entry.name)));
47
+ cursor = list.list_complete ? undefined : list.cursor;
48
+ }while (cursor)
49
+ }
50
+ }
51
+ KVCacheStore = _ts_decorate([
52
+ Injectable(),
53
+ _ts_param(0, Inject(KVService)),
54
+ _ts_metadata("design:type", Function),
55
+ _ts_metadata("design:paramtypes", [
56
+ typeof KVService === "undefined" ? Object : KVService
57
+ ])
58
+ ], KVCacheStore);
@@ -0,0 +1,7 @@
1
+ export { StorageModule } from './storage.module';
2
+ export { StorageService } from './storage.service';
3
+ export { StorageManagerService } from './storage-manager.service';
4
+ export { StorageController } from './storage.controller';
5
+ export { R2StorageDriver } from './r2-storage.driver';
6
+ export { STORAGE_OPTIONS } from './storage.tokens';
7
+ export type { StorageModuleOptions, DiskConfig, PresignedUrlConfig } from './storage.types';
@@ -0,0 +1,6 @@
1
+ export { StorageModule } from "./storage.module.js";
2
+ export { StorageService } from "./storage.service.js";
3
+ export { StorageManagerService } from "./storage-manager.service.js";
4
+ export { StorageController } from "./storage.controller.js";
5
+ export { R2StorageDriver } from "./r2-storage.driver.js";
6
+ export { STORAGE_OPTIONS } from "./storage.tokens.js";
@@ -0,0 +1,19 @@
1
+ import { type DownloadResult, type PresignedUrlResult, type PresignMethod, type StorageBody, type StorageDriver, type UploadOptions, type UploadResult } from '@velajs/vela/storage';
2
+ /** Base path of the StorageController presign-proxy route. */
3
+ export declare const STORAGE_ROUTE_BASE = "/storage";
4
+ export interface R2StorageDriverConfig {
5
+ disk: string;
6
+ bucket: R2Bucket;
7
+ /** HMAC secret for presigned URLs (typically env.APP_SECRET). */
8
+ secret?: string;
9
+ }
10
+ /** {@link StorageDriver} over a Cloudflare R2 bucket. */
11
+ export declare class R2StorageDriver implements StorageDriver {
12
+ private readonly config;
13
+ constructor(config: R2StorageDriverConfig);
14
+ upload(body: StorageBody, path: string, options: UploadOptions): Promise<UploadResult>;
15
+ download(path: string): Promise<DownloadResult>;
16
+ delete(path: string): Promise<void>;
17
+ exists(path: string): Promise<boolean>;
18
+ getPresignedUrl(path: string, method: PresignMethod, expiresIn: number): Promise<PresignedUrlResult>;
19
+ }
@@ -0,0 +1,61 @@
1
+ import { signUrl } from "@velajs/vela/storage";
2
+ /** Base path of the StorageController presign-proxy route. */ export const STORAGE_ROUTE_BASE = '/storage';
3
+ /** {@link StorageDriver} over a Cloudflare R2 bucket. */ export class R2StorageDriver {
4
+ config;
5
+ constructor(config){
6
+ this.config = config;
7
+ }
8
+ async upload(body, path, options) {
9
+ await this.config.bucket.put(path, body, {
10
+ httpMetadata: options.mimeType ? {
11
+ contentType: options.mimeType
12
+ } : undefined,
13
+ customMetadata: options.metadata
14
+ });
15
+ return {
16
+ path,
17
+ disk: this.config.disk,
18
+ size: options.size,
19
+ mimeType: options.mimeType ?? 'application/octet-stream',
20
+ uploadedAt: new Date()
21
+ };
22
+ }
23
+ async download(path) {
24
+ const obj = await this.config.bucket.get(path);
25
+ if (!obj) throw new Error(`Storage object not found at "${path}".`);
26
+ return {
27
+ toStream: ()=>obj.body,
28
+ toArrayBuffer: ()=>obj.arrayBuffer(),
29
+ toText: ()=>obj.text(),
30
+ contentType: obj.httpMetadata?.contentType ?? 'application/octet-stream',
31
+ size: obj.size,
32
+ metadata: obj.customMetadata
33
+ };
34
+ }
35
+ async delete(path) {
36
+ await this.config.bucket.delete(path);
37
+ }
38
+ async exists(path) {
39
+ return await this.config.bucket.head(path) !== null;
40
+ }
41
+ async getPresignedUrl(path, method, expiresIn) {
42
+ if (!this.config.secret) {
43
+ throw new Error('A signing secret is required for presigned URLs (set APP_SECRET).');
44
+ }
45
+ // Defend the direct-driver path too: a non-finite/non-positive expiry would
46
+ // make signUrl omit `expires`, yielding a never-expiring URL.
47
+ if (!Number.isFinite(expiresIn) || expiresIn <= 0) {
48
+ throw new Error(`Invalid presigned URL expiry: ${expiresIn}s (must be a positive number).`);
49
+ }
50
+ const routePath = `${STORAGE_ROUTE_BASE}/${this.config.disk}/${path}`;
51
+ const url = await signUrl(`${routePath}?method=${method}`, this.config.secret, {
52
+ expiresIn
53
+ });
54
+ return {
55
+ url,
56
+ method,
57
+ expiresIn,
58
+ expiresAt: new Date(Date.now() + expiresIn * 1000)
59
+ };
60
+ }
61
+ }
@@ -0,0 +1,16 @@
1
+ import { EnvService } from '../services/env.service';
2
+ import { R2StorageDriver } from './r2-storage.driver';
3
+ import type { DiskConfig, StorageModuleOptions } from './storage.types';
4
+ /**
5
+ * Resolves R2 buckets by binding NAME (via {@link EnvService}) so multiple disks
6
+ * coexist without registering N R2Modules. Drivers are created per call — cheap,
7
+ * and avoids caching a per-request env value on this singleton.
8
+ */
9
+ export declare class StorageManagerService {
10
+ private readonly options;
11
+ private readonly env;
12
+ constructor(options: StorageModuleOptions, env: EnvService);
13
+ hasDisk(disk: string): boolean;
14
+ getDiskConfig(disk: string): DiskConfig;
15
+ getDriver(disk: string): R2StorageDriver;
16
+ }
@@ -0,0 +1,56 @@
1
+ function _ts_decorate(decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ 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;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ }
7
+ function _ts_metadata(k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ }
10
+ function _ts_param(paramIndex, decorator) {
11
+ return function(target, key) {
12
+ decorator(target, key, paramIndex);
13
+ };
14
+ }
15
+ import { Inject, Injectable } from "@velajs/vela";
16
+ import { EnvService } from "../services/env.service.js";
17
+ import { R2StorageDriver } from "./r2-storage.driver.js";
18
+ import { STORAGE_OPTIONS } from "./storage.tokens.js";
19
+ export class StorageManagerService {
20
+ options;
21
+ env;
22
+ constructor(options, env){
23
+ this.options = options;
24
+ this.env = env;
25
+ }
26
+ hasDisk(disk) {
27
+ return this.options.disks.some((d)=>d.disk === disk);
28
+ }
29
+ getDiskConfig(disk) {
30
+ const config = this.options.disks.find((d)=>d.disk === disk);
31
+ if (!config) throw new Error(`Storage disk "${disk}" is not configured.`);
32
+ return config;
33
+ }
34
+ getDriver(disk) {
35
+ const config = this.getDiskConfig(disk);
36
+ const bucket = this.env.get(config.binding);
37
+ if (!bucket) {
38
+ throw new Error(`R2 binding "${config.binding}" for disk "${disk}" was not found in env.`);
39
+ }
40
+ return new R2StorageDriver({
41
+ disk,
42
+ bucket,
43
+ secret: this.env.get('APP_SECRET')
44
+ });
45
+ }
46
+ }
47
+ StorageManagerService = _ts_decorate([
48
+ Injectable(),
49
+ _ts_param(0, Inject(STORAGE_OPTIONS)),
50
+ _ts_param(1, Inject(EnvService)),
51
+ _ts_metadata("design:type", Function),
52
+ _ts_metadata("design:paramtypes", [
53
+ typeof StorageModuleOptions === "undefined" ? Object : StorageModuleOptions,
54
+ typeof EnvService === "undefined" ? Object : EnvService
55
+ ])
56
+ ], StorageManagerService);
@@ -0,0 +1,16 @@
1
+ import type { Context } from 'hono';
2
+ import { EnvService } from '../services/env.service';
3
+ import { StorageManagerService } from './storage-manager.service';
4
+ /**
5
+ * Presign-proxy: serves objects for HMAC-signed URLs produced by
6
+ * `StorageService.url()`. R2 has no native presign, so a signed URL points here;
7
+ * this route verifies the signature (+expiry) before streaming the object.
8
+ * The wildcard is the FULL object path (root already applied at sign time), so
9
+ * it is passed straight to the driver.
10
+ */
11
+ export declare class StorageController {
12
+ private readonly manager;
13
+ private readonly env;
14
+ constructor(manager: StorageManagerService, env: EnvService);
15
+ download(c: Context): Promise<Response>;
16
+ }
@@ -0,0 +1,88 @@
1
+ function _ts_decorate(decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ 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;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ }
7
+ function _ts_metadata(k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ }
10
+ function _ts_param(paramIndex, decorator) {
11
+ return function(target, key) {
12
+ decorator(target, key, paramIndex);
13
+ };
14
+ }
15
+ import { Controller, Get, Inject, Req } from "@velajs/vela";
16
+ import { verifySignedUrl } from "@velajs/vela/storage";
17
+ import { EnvService } from "../services/env.service.js";
18
+ import { StorageManagerService } from "./storage-manager.service.js";
19
+ export class StorageController {
20
+ manager;
21
+ env;
22
+ constructor(manager, env){
23
+ this.manager = manager;
24
+ this.env = env;
25
+ }
26
+ async download(c) {
27
+ const secret = this.env.get('APP_SECRET');
28
+ if (!secret) return new Response('Storage signing is not configured', {
29
+ status: 500
30
+ });
31
+ const disk = c.req.param('disk');
32
+ if (!disk || !this.manager.hasDisk(disk)) return new Response('Unknown disk', {
33
+ status: 404
34
+ });
35
+ if (!await verifySignedUrl(c.req.url, secret)) {
36
+ return new Response('Invalid or expired URL', {
37
+ status: 403
38
+ });
39
+ }
40
+ // Enforce the signed `method` scope: this proxy only serves reads, so a URL
41
+ // scoped to PUT/DELETE/HEAD must NOT be honored as a GET (it would over-grant
42
+ // read access relative to the token's intended scope). The param is part of
43
+ // the signed payload, so it is trustworthy once the signature verifies.
44
+ const url = new URL(c.req.url);
45
+ if ((url.searchParams.get('method') ?? 'GET') !== 'GET') {
46
+ return new Response('URL is not scoped for reads', {
47
+ status: 403
48
+ });
49
+ }
50
+ // The object key is everything after `/storage/:disk/` (Hono doesn't expose
51
+ // the `*` wildcard via param()). The key already includes the disk root.
52
+ const { pathname } = url;
53
+ const prefix = `/storage/${disk}/`;
54
+ const idx = pathname.indexOf(prefix);
55
+ const fullPath = idx >= 0 ? decodeURIComponent(pathname.slice(idx + prefix.length)) : '';
56
+ try {
57
+ const result = await this.manager.getDriver(disk).download(fullPath);
58
+ return new Response(result.toStream(), {
59
+ headers: {
60
+ 'content-type': result.contentType
61
+ }
62
+ });
63
+ } catch {
64
+ return new Response('Not found', {
65
+ status: 404
66
+ });
67
+ }
68
+ }
69
+ }
70
+ _ts_decorate([
71
+ Get('/:disk/*'),
72
+ _ts_param(0, Req()),
73
+ _ts_metadata("design:type", Function),
74
+ _ts_metadata("design:paramtypes", [
75
+ typeof Context === "undefined" ? Object : Context
76
+ ]),
77
+ _ts_metadata("design:returntype", Promise)
78
+ ], StorageController.prototype, "download", null);
79
+ StorageController = _ts_decorate([
80
+ Controller('storage'),
81
+ _ts_param(0, Inject(StorageManagerService)),
82
+ _ts_param(1, Inject(EnvService)),
83
+ _ts_metadata("design:type", Function),
84
+ _ts_metadata("design:paramtypes", [
85
+ typeof StorageManagerService === "undefined" ? Object : StorageManagerService,
86
+ typeof EnvService === "undefined" ? Object : EnvService
87
+ ])
88
+ ], StorageController);
@@ -0,0 +1,7 @@
1
+ import type { StorageModuleOptions } from './storage.types';
2
+ declare const ConfigurableModuleClass: import("@velajs/vela").ConfigurableModuleClassType<StorageModuleOptions, "forRoot", "create", {
3
+ isGlobal?: boolean;
4
+ }>;
5
+ export declare class StorageModule extends ConfigurableModuleClass {
6
+ }
7
+ export {};