@velajs/cloudflare 1.28.0 → 1.30.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.
- package/CHANGELOG.md +113 -0
- package/README.md +190 -69
- package/dist/_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorateParam.js +8 -0
- package/dist/cloudflare-application.js +309 -0
- package/dist/cloudflare-application.js.map +1 -0
- package/dist/cloudflare-factory.js +109 -0
- package/dist/cloudflare-factory.js.map +1 -0
- package/dist/decorators/queue-consumer.js +39 -0
- package/dist/decorators/queue-consumer.js.map +1 -0
- package/dist/durable-objects.d.ts +11 -11
- package/dist/durable-objects.js +2 -512
- package/dist/environment.js +19 -0
- package/dist/environment.js.map +1 -0
- package/dist/index.d.ts +146 -111
- package/dist/index.js +21 -1130
- package/dist/nonce/durable-object-nonce.store.js +33 -0
- package/dist/nonce/durable-object-nonce.store.js.map +1 -0
- package/dist/nonce/nonce-validation.js +18 -0
- package/dist/nonce/nonce-validation.js.map +1 -0
- package/dist/nonce/nonce.durable-object.js +54 -0
- package/dist/nonce/nonce.durable-object.js.map +1 -0
- package/dist/{nonce.durable-object-DffHMRnU.d.ts → nonce.durable-object-Ci9AGssY.d.ts} +9 -7
- package/dist/queue/cloudflare-queues.js +215 -0
- package/dist/queue/cloudflare-queues.js.map +1 -0
- package/dist/queues.d.ts +48 -0
- package/dist/queues.js +2 -0
- package/dist/rate-limit/cloudflare-rate-limit.store.js +40 -0
- package/dist/rate-limit/cloudflare-rate-limit.store.js.map +1 -0
- package/dist/schedule-diagnostics.js +51 -0
- package/dist/schedule-diagnostics.js.map +1 -0
- package/dist/scheduled-event.js +68 -0
- package/dist/scheduled-event.js.map +1 -0
- package/dist/services/flagship-flag.driver.js +52 -0
- package/dist/services/flagship-flag.driver.js.map +1 -0
- package/dist/services/kv-cache.store.js +82 -0
- package/dist/services/kv-cache.store.js.map +1 -0
- package/dist/services/kv-flag.driver.js +62 -0
- package/dist/services/kv-flag.driver.js.map +1 -0
- package/dist/storage/r2-storage.driver.js +67 -0
- package/dist/storage/r2-storage.driver.js.map +1 -0
- package/dist/storage/storage-key-claim.js +74 -0
- package/dist/storage/storage-key-claim.js.map +1 -0
- package/dist/storage/storage-manager.service.js +38 -0
- package/dist/storage/storage-manager.service.js.map +1 -0
- package/dist/storage/storage.controller.js +64 -0
- package/dist/storage/storage.controller.js.map +1 -0
- package/dist/storage/storage.module.js +25 -0
- package/dist/storage/storage.module.js.map +1 -0
- package/dist/storage/storage.service.js +64 -0
- package/dist/storage/storage.service.js.map +1 -0
- package/dist/storage/storage.tokens.js +7 -0
- package/dist/storage/storage.tokens.js.map +1 -0
- package/dist/vela-env-DFvyoNT3.d.ts +9 -0
- package/dist/websocket/broadcast.js +32 -0
- package/dist/websocket/broadcast.js.map +1 -0
- package/dist/websocket/cf-room-registry.js +137 -0
- package/dist/websocket/cf-room-registry.js.map +1 -0
- package/dist/websocket/cf-ws-client.js +94 -0
- package/dist/websocket/cf-ws-client.js.map +1 -0
- package/dist/websocket/cloudflare-websocket.module.js +25 -0
- package/dist/websocket/cloudflare-websocket.module.js.map +1 -0
- package/dist/websocket/do-bootstrap.js +56 -0
- package/dist/websocket/do-bootstrap.js.map +1 -0
- package/dist/websocket/do-live.js +184 -0
- package/dist/websocket/do-live.js.map +1 -0
- package/dist/websocket/do-pitr.js +81 -0
- package/dist/websocket/do-pitr.js.map +1 -0
- package/dist/websocket/do-state.js +7 -0
- package/dist/websocket/do-state.js.map +1 -0
- package/dist/websocket/do-websocket-host.js +169 -0
- package/dist/websocket/do-websocket-host.js.map +1 -0
- package/dist/websocket/room-id.js +27 -0
- package/dist/websocket/room-id.js.map +1 -0
- package/dist/websocket/websocket-routing.js +133 -0
- package/dist/websocket/websocket-routing.js.map +1 -0
- package/dist/websocket/websocket.durable-object.js +138 -0
- package/dist/websocket/websocket.durable-object.js.map +1 -0
- package/dist/websocket/ws-attachment.js +79 -0
- package/dist/websocket/ws-attachment.js.map +1 -0
- package/dist/websocket/ws-server-holder.js +38 -0
- package/dist/websocket/ws-server-holder.js.map +1 -0
- package/package.json +14 -11
- package/dist/durable-objects.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/nonce-validation-CeVihShV.js +0 -517
- package/dist/nonce-validation-CeVihShV.js.map +0 -1
- package/dist/queue.d.ts +0 -32
- package/dist/queue.js +0 -78
- package/dist/queue.js.map +0 -1
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { InjectionToken, Scope, defineProvider } from "@velajs/vela";
|
|
2
|
+
import { SCHEDULE_INVOCATION_SEED } from "@velajs/vela/module-kit";
|
|
3
|
+
//#region src/scheduled-event.ts
|
|
4
|
+
/**
|
|
5
|
+
* Request-scoped Cloudflare view of the scheduled trigger, seeded into each
|
|
6
|
+
* `@Cron` job's invocation scope by the Cloudflare adapter. Inject it where a
|
|
7
|
+
* job needs platform controls such as `noRetry()`; the job's argument stays
|
|
8
|
+
* the portable `ScheduleInvocation`. It resolves only inside a scheduled
|
|
9
|
+
* invocation; resolving it anywhere else throws. A cron job fired on demand
|
|
10
|
+
* (Studio's run-now) receives a synthetic event: `cron` is the job's
|
|
11
|
+
* expression and `noRetry()` does nothing.
|
|
12
|
+
*
|
|
13
|
+
* The token provides itself as request-scoped in every container, so a class
|
|
14
|
+
* that injects it is request-scoped wherever the graph boots (a Worker, a
|
|
15
|
+
* Durable Object, the CLI or a testing module) and is built only for an
|
|
16
|
+
* invocation, never at bootstrap.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* @Injectable({ scope: Scope.REQUEST })
|
|
21
|
+
* class Reports {
|
|
22
|
+
* constructor(@Inject(CLOUDFLARE_SCHEDULED_EVENT) private readonly trigger: CloudflareScheduledEvent) {}
|
|
23
|
+
*
|
|
24
|
+
* @Cron('0 3 * * *', { dialect: 'cloudflare' })
|
|
25
|
+
* async nightly(tick: CronInvocation) {
|
|
26
|
+
* if (!(await this.upstreamAvailable(tick.signal))) this.trigger.noRetry();
|
|
27
|
+
* }
|
|
28
|
+
* }
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
const CLOUDFLARE_SCHEDULED_EVENT = new InjectionToken("@velajs/cloudflare:scheduled-event", {
|
|
32
|
+
scope: Scope.REQUEST,
|
|
33
|
+
factory: () => {
|
|
34
|
+
throw new Error("CLOUDFLARE_SCHEDULED_EVENT can only be resolved inside a scheduled invocation: the Cloudflare adapter seeds it into each @Cron job scope for a cron trigger.");
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
/** @internal Freeze the injected view; `noRetry` keeps the native receiver. */
|
|
38
|
+
function cloudflareScheduledEvent(event, scheduledTime) {
|
|
39
|
+
return Object.freeze({
|
|
40
|
+
cron: event.cron,
|
|
41
|
+
scheduledTime,
|
|
42
|
+
noRetry: () => {
|
|
43
|
+
event.noRetry?.();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Seeds a cron job fired outside a trigger (Studio's run-now) the way a
|
|
49
|
+
* trigger would: a synthetic event whose `cron` is the invocation's
|
|
50
|
+
* expression, with its `scheduledTime` and a `noRetry()` that does nothing.
|
|
51
|
+
* Interval jobs never run from Workers triggers, so they get no event.
|
|
52
|
+
*/
|
|
53
|
+
const seedScheduledEvent = (scope, invocation) => {
|
|
54
|
+
if (invocation.kind !== "cron") return;
|
|
55
|
+
scope.setRequestInstance(CLOUDFLARE_SCHEDULED_EVENT, cloudflareScheduledEvent({ cron: invocation.expression }, invocation.scheduledTime));
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* @internal Provide `SCHEDULE_INVOCATION_SEED`, so jobs fired on demand get a
|
|
59
|
+
* synthetic event. Each trigger seeds the real event into its jobs' scopes.
|
|
60
|
+
*/
|
|
61
|
+
function registerScheduledEventSeed(container) {
|
|
62
|
+
container.register(defineProvider(SCHEDULE_INVOCATION_SEED, { useValue: seedScheduledEvent }));
|
|
63
|
+
container.markGlobalToken(SCHEDULE_INVOCATION_SEED);
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
66
|
+
export { CLOUDFLARE_SCHEDULED_EVENT, cloudflareScheduledEvent, registerScheduledEventSeed };
|
|
67
|
+
|
|
68
|
+
//# sourceMappingURL=scheduled-event.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scheduled-event.js","names":[],"sources":["../src/scheduled-event.ts"],"sourcesContent":["import { InjectionToken, Scope, defineProvider } from '@velajs/vela';\nimport { SCHEDULE_INVOCATION_SEED } from '@velajs/vela/module-kit';\nimport type { ScheduleInvocationSeed, Container } from '@velajs/vela/module-kit';\n\n/**\n * The event a Worker's `scheduled()` export receives. The native\n * `ScheduledController` satisfies it; direct calls (tests, scripts) may omit\n * `scheduledTime`, which then defaults to the current time, and `noRetry`.\n */\nexport interface ScheduledEvent {\n readonly cron: string;\n readonly scheduledTime?: number;\n noRetry?(): void;\n}\n\n/** The Cloudflare trigger behind the current scheduled invocation. */\nexport interface CloudflareScheduledEvent {\n /** The trigger's exact cron string; it equals the invocation's `expression`. */\n readonly cron: string;\n /** The platform's scheduled time in Unix milliseconds. */\n readonly scheduledTime: number;\n /**\n * Ask Cloudflare not to retry this trigger if the invocation fails. Already\n * bound to the native controller, so it can be passed around or destructured.\n * A no-op for direct calls that supplied no controller.\n */\n readonly noRetry: () => void;\n}\n\n/**\n * Request-scoped Cloudflare view of the scheduled trigger, seeded into each\n * `@Cron` job's invocation scope by the Cloudflare adapter. Inject it where a\n * job needs platform controls such as `noRetry()`; the job's argument stays\n * the portable `ScheduleInvocation`. It resolves only inside a scheduled\n * invocation; resolving it anywhere else throws. A cron job fired on demand\n * (Studio's run-now) receives a synthetic event: `cron` is the job's\n * expression and `noRetry()` does nothing.\n *\n * The token provides itself as request-scoped in every container, so a class\n * that injects it is request-scoped wherever the graph boots (a Worker, a\n * Durable Object, the CLI or a testing module) and is built only for an\n * invocation, never at bootstrap.\n *\n * @example\n * ```ts\n * @Injectable({ scope: Scope.REQUEST })\n * class Reports {\n * constructor(@Inject(CLOUDFLARE_SCHEDULED_EVENT) private readonly trigger: CloudflareScheduledEvent) {}\n *\n * @Cron('0 3 * * *', { dialect: 'cloudflare' })\n * async nightly(tick: CronInvocation) {\n * if (!(await this.upstreamAvailable(tick.signal))) this.trigger.noRetry();\n * }\n * }\n * ```\n */\nexport const CLOUDFLARE_SCHEDULED_EVENT = new InjectionToken<CloudflareScheduledEvent>(\n '@velajs/cloudflare:scheduled-event',\n {\n scope: Scope.REQUEST,\n factory: () => {\n throw new Error(\n 'CLOUDFLARE_SCHEDULED_EVENT can only be resolved inside a scheduled invocation: ' +\n 'the Cloudflare adapter seeds it into each @Cron job scope for a cron trigger.',\n );\n },\n },\n);\n\n/** @internal Freeze the injected view; `noRetry` keeps the native receiver. */\nexport function cloudflareScheduledEvent(\n event: ScheduledEvent,\n scheduledTime: number,\n): CloudflareScheduledEvent {\n return Object.freeze({\n cron: event.cron,\n scheduledTime,\n noRetry: () => {\n event.noRetry?.();\n },\n });\n}\n\n/**\n * Seeds a cron job fired outside a trigger (Studio's run-now) the way a\n * trigger would: a synthetic event whose `cron` is the invocation's\n * expression, with its `scheduledTime` and a `noRetry()` that does nothing.\n * Interval jobs never run from Workers triggers, so they get no event.\n */\nconst seedScheduledEvent: ScheduleInvocationSeed = (scope, invocation) => {\n if (invocation.kind !== 'cron') return;\n scope.setRequestInstance(\n CLOUDFLARE_SCHEDULED_EVENT,\n cloudflareScheduledEvent({ cron: invocation.expression }, invocation.scheduledTime),\n );\n};\n\n/**\n * @internal Provide `SCHEDULE_INVOCATION_SEED`, so jobs fired on demand get a\n * synthetic event. Each trigger seeds the real event into its jobs' scopes.\n */\nexport function registerScheduledEventSeed(container: Container): void {\n container.register(defineProvider(SCHEDULE_INVOCATION_SEED, { useValue: seedScheduledEvent }));\n container.markGlobalToken(SCHEDULE_INVOCATION_SEED);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwDA,MAAa,6BAA6B,IAAI,eAC5C,sCACA;CACE,OAAO,MAAM;CACb,eAAe;EACb,MAAM,IAAI,MACR,8JAEF;CACF;AACF,CACF;;AAGA,SAAgB,yBACd,OACA,eAC0B;CAC1B,OAAO,OAAO,OAAO;EACnB,MAAM,MAAM;EACZ;EACA,eAAe;GACb,MAAM,UAAU;EAClB;CACF,CAAC;AACH;;;;;;;AAQA,MAAM,sBAA8C,OAAO,eAAe;CACxE,IAAI,WAAW,SAAS,QAAQ;CAChC,MAAM,mBACJ,4BACA,yBAAyB,EAAE,MAAM,WAAW,WAAW,GAAG,WAAW,aAAa,CACpF;AACF;;;;;AAMA,SAAgB,2BAA2B,WAA4B;CACrE,UAAU,SAAS,eAAe,0BAA0B,EAAE,UAAU,mBAAmB,CAAC,CAAC;CAC7F,UAAU,gBAAgB,wBAAwB;AACpD"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
//#region src/services/flagship-flag.driver.ts
|
|
2
|
+
/**
|
|
3
|
+
* {@link FeatureFlagDriver} backed by a Cloudflare Flagship binding.
|
|
4
|
+
*
|
|
5
|
+
* A thin, honest wrapper: each contract method maps 1:1 onto the binding's
|
|
6
|
+
* corresponding value method, forwarding the caller's `fallback` (the binding's
|
|
7
|
+
* `defaultValue`) and evaluation context. The binding resolves the fallback on
|
|
8
|
+
* evaluation errors; anything the binding *rejects* with (e.g. a `remote: true`
|
|
9
|
+
* dev-proxy tunnel dropping) propagates — `@velajs/feature-flags`'s service owns
|
|
10
|
+
* the never-throw guarantee.
|
|
11
|
+
*
|
|
12
|
+
* Build the driver inside a provider factory with the native environment:
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* FeatureFlagsModule.forRootAsync({
|
|
16
|
+
* inject: [ENV],
|
|
17
|
+
* useFactory: (env: WorkerEnv) => ({ drivers: [flagshipFlagDriver(env.FLAGS)] }),
|
|
18
|
+
* });
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* Evaluation ergonomics (the 1:1 binding-method mapping) are ported from the
|
|
22
|
+
* Stratal feature-flags service (MIT, © Temitayo Fadojutimi), reshaped as a
|
|
23
|
+
* bare driver.
|
|
24
|
+
*/
|
|
25
|
+
var FlagshipFlagDriver = class {
|
|
26
|
+
name;
|
|
27
|
+
resolve;
|
|
28
|
+
constructor(binding, options = {}) {
|
|
29
|
+
this.resolve = typeof binding === "function" ? binding : () => binding;
|
|
30
|
+
this.name = options.name ?? "flagship";
|
|
31
|
+
}
|
|
32
|
+
getBoolean(key, fallback, ctx) {
|
|
33
|
+
return this.resolve().getBooleanValue(key, fallback, ctx);
|
|
34
|
+
}
|
|
35
|
+
getString(key, fallback, ctx) {
|
|
36
|
+
return this.resolve().getStringValue(key, fallback, ctx);
|
|
37
|
+
}
|
|
38
|
+
getNumber(key, fallback, ctx) {
|
|
39
|
+
return this.resolve().getNumberValue(key, fallback, ctx);
|
|
40
|
+
}
|
|
41
|
+
getObject(key, fallback, ctx) {
|
|
42
|
+
return this.resolve().getObjectValue(key, fallback, ctx);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
/** Convenience factory for {@link FlagshipFlagDriver}. */
|
|
46
|
+
function flagshipFlagDriver(binding, options) {
|
|
47
|
+
return new FlagshipFlagDriver(binding, options);
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
export { FlagshipFlagDriver, flagshipFlagDriver };
|
|
51
|
+
|
|
52
|
+
//# sourceMappingURL=flagship-flag.driver.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"flagship-flag.driver.js","names":[],"sources":["../../src/services/flagship-flag.driver.ts"],"sourcesContent":["import type { FeatureFlagDriver, FlagContext } from '@velajs/feature-flags';\n\n/**\n * The subset of a Cloudflare **Flagship** binding this driver evaluates against\n * — the four typed value methods. The binding itself returns the supplied\n * `defaultValue` on evaluation errors; transport-level failures reject and are\n * left to propagate (never-throw is the service layer's job, not the driver's).\n *\n * @see https://developers.cloudflare.com/flagship/binding/\n */\nexport interface FlagshipBinding {\n getBooleanValue(key: string, defaultValue: boolean, context?: FlagContext): Promise<boolean>;\n getStringValue(key: string, defaultValue: string, context?: FlagContext): Promise<string>;\n getNumberValue(key: string, defaultValue: number, context?: FlagContext): Promise<number>;\n getObjectValue(key: string, defaultValue: object, context?: FlagContext): Promise<unknown>;\n}\n\nexport interface FlagshipFlagDriverOptions {\n /** Driver name used for `use(name)` / default-driver selection. Default `\"flagship\"`. */\n name?: string;\n}\n\n/**\n * {@link FeatureFlagDriver} backed by a Cloudflare Flagship binding.\n *\n * A thin, honest wrapper: each contract method maps 1:1 onto the binding's\n * corresponding value method, forwarding the caller's `fallback` (the binding's\n * `defaultValue`) and evaluation context. The binding resolves the fallback on\n * evaluation errors; anything the binding *rejects* with (e.g. a `remote: true`\n * dev-proxy tunnel dropping) propagates — `@velajs/feature-flags`'s service owns\n * the never-throw guarantee.\n *\n * Build the driver inside a provider factory with the native environment:\n *\n * ```ts\n * FeatureFlagsModule.forRootAsync({\n * inject: [ENV],\n * useFactory: (env: WorkerEnv) => ({ drivers: [flagshipFlagDriver(env.FLAGS)] }),\n * });\n * ```\n *\n * Evaluation ergonomics (the 1:1 binding-method mapping) are ported from the\n * Stratal feature-flags service (MIT, © Temitayo Fadojutimi), reshaped as a\n * bare driver.\n */\nexport class FlagshipFlagDriver implements FeatureFlagDriver {\n readonly name: string;\n private readonly resolve: () => FlagshipBinding;\n\n constructor(\n binding: FlagshipBinding | (() => FlagshipBinding),\n options: FlagshipFlagDriverOptions = {},\n ) {\n this.resolve = typeof binding === 'function' ? binding : () => binding;\n this.name = options.name ?? 'flagship';\n }\n\n getBoolean(key: string, fallback: boolean, ctx?: FlagContext): Promise<boolean> {\n return this.resolve().getBooleanValue(key, fallback, ctx);\n }\n\n getString(key: string, fallback: string, ctx?: FlagContext): Promise<string> {\n return this.resolve().getStringValue(key, fallback, ctx);\n }\n\n getNumber(key: string, fallback: number, ctx?: FlagContext): Promise<number> {\n return this.resolve().getNumberValue(key, fallback, ctx);\n }\n\n getObject(key: string, fallback: object, ctx?: FlagContext): Promise<unknown> {\n return this.resolve().getObjectValue(key, fallback, ctx);\n }\n}\n\n/** Convenience factory for {@link FlagshipFlagDriver}. */\nexport function flagshipFlagDriver(\n binding: FlagshipBinding | (() => FlagshipBinding),\n options?: FlagshipFlagDriverOptions,\n): FlagshipFlagDriver {\n return new FlagshipFlagDriver(binding, options);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AA6CA,IAAa,qBAAb,MAA6D;CAC3D;CACA;CAEA,YACE,SACA,UAAqC,CAAC,GACtC;EACA,KAAK,UAAU,OAAO,YAAY,aAAa,gBAAgB;EAC/D,KAAK,OAAO,QAAQ,QAAQ;CAC9B;CAEA,WAAW,KAAa,UAAmB,KAAqC;EAC9E,OAAO,KAAK,QAAQ,CAAC,CAAC,gBAAgB,KAAK,UAAU,GAAG;CAC1D;CAEA,UAAU,KAAa,UAAkB,KAAoC;EAC3E,OAAO,KAAK,QAAQ,CAAC,CAAC,eAAe,KAAK,UAAU,GAAG;CACzD;CAEA,UAAU,KAAa,UAAkB,KAAoC;EAC3E,OAAO,KAAK,QAAQ,CAAC,CAAC,eAAe,KAAK,UAAU,GAAG;CACzD;CAEA,UAAU,KAAa,UAAkB,KAAqC;EAC5E,OAAO,KAAK,QAAQ,CAAC,CAAC,eAAe,KAAK,UAAU,GAAG;CACzD;AACF;;AAGA,SAAgB,mBACd,SACA,SACoB;CACpB,OAAO,IAAI,mBAAmB,SAAS,OAAO;AAChD"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
//#region src/services/kv-cache.store.ts
|
|
2
|
+
/**
|
|
3
|
+
* Native KV JSON value store. Metadata retains logical expiry even when KV's
|
|
4
|
+
* physical retention rounds up to its 60-second minimum. Legacy values without
|
|
5
|
+
* metadata remain readable, but cannot safely backfill another tier.
|
|
6
|
+
*/
|
|
7
|
+
var KVCacheStore = class {
|
|
8
|
+
ns;
|
|
9
|
+
constructor(ns) {
|
|
10
|
+
this.ns = ns;
|
|
11
|
+
}
|
|
12
|
+
async get(key) {
|
|
13
|
+
return (await this.getEntry(key))?.value;
|
|
14
|
+
}
|
|
15
|
+
async getEntry(key) {
|
|
16
|
+
const { value, metadata } = await this.ns.getWithMetadata(key, "json");
|
|
17
|
+
if (value === null) return void 0;
|
|
18
|
+
if (typeof metadata === "object" && metadata !== null && "velaCacheExpiresAt" in metadata) {
|
|
19
|
+
const expiresAt = metadata.velaCacheExpiresAt;
|
|
20
|
+
if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt <= Date.now()) return void 0;
|
|
21
|
+
return {
|
|
22
|
+
value,
|
|
23
|
+
expiresAt
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
return { value };
|
|
27
|
+
}
|
|
28
|
+
async set(key, value, ttl) {
|
|
29
|
+
if (ttl !== void 0 && (!Number.isFinite(ttl) || ttl < 0)) throw new TypeError("Cache TTL must be finite and nonnegative.");
|
|
30
|
+
if (ttl !== void 0) return this.setEntry(key, {
|
|
31
|
+
value,
|
|
32
|
+
expiresAt: Date.now() + ttl * 1e3
|
|
33
|
+
});
|
|
34
|
+
await this.ns.put(key, JSON.stringify(value));
|
|
35
|
+
}
|
|
36
|
+
async setEntry(key, entry) {
|
|
37
|
+
if (!Number.isFinite(entry.expiresAt)) throw new TypeError("Cache expiry must be finite.");
|
|
38
|
+
const remaining = (entry.expiresAt - Date.now()) / 1e3;
|
|
39
|
+
if (remaining <= 0) return this.del(key);
|
|
40
|
+
await this.ns.put(key, JSON.stringify(entry.value), {
|
|
41
|
+
expirationTtl: Math.max(60, Math.ceil(remaining)),
|
|
42
|
+
metadata: { velaCacheExpiresAt: entry.expiresAt }
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
async del(key) {
|
|
46
|
+
await this.ns.delete(key);
|
|
47
|
+
}
|
|
48
|
+
/** Namespace-wide, best effort. Use a dedicated value namespace; never use for scoped invalidation. */
|
|
49
|
+
async clear() {
|
|
50
|
+
let cursor;
|
|
51
|
+
do {
|
|
52
|
+
const list = await this.ns.list(cursor ? { cursor } : void 0);
|
|
53
|
+
await Promise.all(list.keys.map((entry) => this.ns.delete(entry.name)));
|
|
54
|
+
cursor = list.list_complete ? void 0 : list.cursor;
|
|
55
|
+
} while (cursor);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Optional, eventually consistent generation store. Use a dedicated KV namespace
|
|
60
|
+
* without TTL/lifecycle cleanup. Never delete/reset generations while entries can
|
|
61
|
+
* survive. Concurrent writes and cached/negative reads prevent strong invalidation;
|
|
62
|
+
* this is unsuitable for strict read-after-write or authorization revocation.
|
|
63
|
+
*/
|
|
64
|
+
var KVCacheInvalidationStore = class {
|
|
65
|
+
ns;
|
|
66
|
+
constructor(ns) {
|
|
67
|
+
this.ns = ns;
|
|
68
|
+
}
|
|
69
|
+
async getVersion(key) {
|
|
70
|
+
const value = await this.ns.get(key, "json");
|
|
71
|
+
if (value === null) return "initial";
|
|
72
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 2048) throw new TypeError("Invalid cache generation.");
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
async invalidate(key) {
|
|
76
|
+
await this.ns.put(key, JSON.stringify(crypto.randomUUID()));
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
//#endregion
|
|
80
|
+
export { KVCacheInvalidationStore, KVCacheStore };
|
|
81
|
+
|
|
82
|
+
//# sourceMappingURL=kv-cache.store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"kv-cache.store.js","names":[],"sources":["../../src/services/kv-cache.store.ts"],"sourcesContent":["import type {\n AsyncCacheStore,\n CacheEntryReader,\n CacheEntryWriter,\n CacheEntry,\n CacheInvalidationStore,\n} from '@velajs/vela/cache';\n\n/**\n * Native KV JSON value store. Metadata retains logical expiry even when KV's\n * physical retention rounds up to its 60-second minimum. Legacy values without\n * metadata remain readable, but cannot safely backfill another tier.\n */\nexport class KVCacheStore implements AsyncCacheStore, CacheEntryReader, CacheEntryWriter {\n constructor(private readonly ns: KVNamespace) {}\n\n async get(key: string): Promise<unknown> {\n return (await this.getEntry(key))?.value;\n }\n\n async getEntry(key: string): Promise<{ value: unknown; expiresAt?: number } | undefined> {\n const { value, metadata } = await this.ns.getWithMetadata<unknown, unknown>(key, 'json');\n if (value === null) return undefined;\n if (typeof metadata === 'object' && metadata !== null && 'velaCacheExpiresAt' in metadata) {\n const expiresAt = metadata.velaCacheExpiresAt;\n if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt) || expiresAt <= Date.now())\n return undefined;\n return { value, expiresAt };\n }\n return { value };\n }\n\n async set(key: string, value: unknown, ttl?: number): Promise<void> {\n if (ttl !== undefined && (!Number.isFinite(ttl) || ttl < 0))\n throw new TypeError('Cache TTL must be finite and nonnegative.');\n if (ttl !== undefined) return this.setEntry(key, { value, expiresAt: Date.now() + ttl * 1000 });\n await this.ns.put(key, JSON.stringify(value));\n }\n\n async setEntry(key: string, entry: CacheEntry): Promise<void> {\n if (!Number.isFinite(entry.expiresAt)) throw new TypeError('Cache expiry must be finite.');\n const remaining = (entry.expiresAt - Date.now()) / 1000;\n if (remaining <= 0) return this.del(key);\n await this.ns.put(key, JSON.stringify(entry.value), {\n expirationTtl: Math.max(60, Math.ceil(remaining)),\n metadata: { velaCacheExpiresAt: entry.expiresAt },\n });\n }\n\n async del(key: string): Promise<void> {\n await this.ns.delete(key);\n }\n\n /** Namespace-wide, best effort. Use a dedicated value namespace; never use for scoped invalidation. */\n async clear(): Promise<void> {\n let cursor: string | undefined;\n do {\n const list = await this.ns.list(cursor ? { cursor } : undefined);\n await Promise.all(list.keys.map((entry) => this.ns.delete(entry.name)));\n cursor = list.list_complete ? undefined : list.cursor;\n } while (cursor);\n }\n}\n\n/**\n * Optional, eventually consistent generation store. Use a dedicated KV namespace\n * without TTL/lifecycle cleanup. Never delete/reset generations while entries can\n * survive. Concurrent writes and cached/negative reads prevent strong invalidation;\n * this is unsuitable for strict read-after-write or authorization revocation.\n */\nexport class KVCacheInvalidationStore implements CacheInvalidationStore {\n constructor(private readonly ns: KVNamespace) {}\n async getVersion(key: string): Promise<string> {\n const value: unknown = await this.ns.get(key, 'json');\n if (value === null) return 'initial';\n if (typeof value !== 'string' || value.length === 0 || value.length > 2048)\n throw new TypeError('Invalid cache generation.');\n return value;\n }\n async invalidate(key: string): Promise<void> {\n await this.ns.put(key, JSON.stringify(crypto.randomUUID()));\n }\n}\n"],"mappings":";;;;;;AAaA,IAAa,eAAb,MAAyF;CAC1D;CAA7B,YAAY,IAAkC;EAAjB,KAAA,KAAA;CAAkB;CAE/C,MAAM,IAAI,KAA+B;EACvC,QAAQ,MAAM,KAAK,SAAS,GAAG,EAAA,EAAI;CACrC;CAEA,MAAM,SAAS,KAA0E;EACvF,MAAM,EAAE,OAAO,aAAa,MAAM,KAAK,GAAG,gBAAkC,KAAK,MAAM;EACvF,IAAI,UAAU,MAAM,OAAO,KAAA;EAC3B,IAAI,OAAO,aAAa,YAAY,aAAa,QAAQ,wBAAwB,UAAU;GACzF,MAAM,YAAY,SAAS;GAC3B,IAAI,OAAO,cAAc,YAAY,CAAC,OAAO,SAAS,SAAS,KAAK,aAAa,KAAK,IAAI,GACxF,OAAO,KAAA;GACT,OAAO;IAAE;IAAO;GAAU;EAC5B;EACA,OAAO,EAAE,MAAM;CACjB;CAEA,MAAM,IAAI,KAAa,OAAgB,KAA6B;EAClE,IAAI,QAAQ,KAAA,MAAc,CAAC,OAAO,SAAS,GAAG,KAAK,MAAM,IACvD,MAAM,IAAI,UAAU,2CAA2C;EACjE,IAAI,QAAQ,KAAA,GAAW,OAAO,KAAK,SAAS,KAAK;GAAE;GAAO,WAAW,KAAK,IAAI,IAAI,MAAM;EAAK,CAAC;EAC9F,MAAM,KAAK,GAAG,IAAI,KAAK,KAAK,UAAU,KAAK,CAAC;CAC9C;CAEA,MAAM,SAAS,KAAa,OAAkC;EAC5D,IAAI,CAAC,OAAO,SAAS,MAAM,SAAS,GAAG,MAAM,IAAI,UAAU,8BAA8B;EACzF,MAAM,aAAa,MAAM,YAAY,KAAK,IAAI,KAAK;EACnD,IAAI,aAAa,GAAG,OAAO,KAAK,IAAI,GAAG;EACvC,MAAM,KAAK,GAAG,IAAI,KAAK,KAAK,UAAU,MAAM,KAAK,GAAG;GAClD,eAAe,KAAK,IAAI,IAAI,KAAK,KAAK,SAAS,CAAC;GAChD,UAAU,EAAE,oBAAoB,MAAM,UAAU;EAClD,CAAC;CACH;CAEA,MAAM,IAAI,KAA4B;EACpC,MAAM,KAAK,GAAG,OAAO,GAAG;CAC1B;;CAGA,MAAM,QAAuB;EAC3B,IAAI;EACJ,GAAG;GACD,MAAM,OAAO,MAAM,KAAK,GAAG,KAAK,SAAS,EAAE,OAAO,IAAI,KAAA,CAAS;GAC/D,MAAM,QAAQ,IAAI,KAAK,KAAK,KAAK,UAAU,KAAK,GAAG,OAAO,MAAM,IAAI,CAAC,CAAC;GACtE,SAAS,KAAK,gBAAgB,KAAA,IAAY,KAAK;EACjD,SAAS;CACX;AACF;;;;;;;AAQA,IAAa,2BAAb,MAAwE;CACzC;CAA7B,YAAY,IAAkC;EAAjB,KAAA,KAAA;CAAkB;CAC/C,MAAM,WAAW,KAA8B;EAC7C,MAAM,QAAiB,MAAM,KAAK,GAAG,IAAI,KAAK,MAAM;EACpD,IAAI,UAAU,MAAM,OAAO;EAC3B,IAAI,OAAO,UAAU,YAAY,MAAM,WAAW,KAAK,MAAM,SAAS,MACpE,MAAM,IAAI,UAAU,2BAA2B;EACjD,OAAO;CACT;CACA,MAAM,WAAW,KAA4B;EAC3C,MAAM,KAAK,GAAG,IAAI,KAAK,KAAK,UAAU,OAAO,WAAW,CAAC,CAAC;CAC5D;AACF"}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
//#region src/services/kv-flag.driver.ts
|
|
2
|
+
/**
|
|
3
|
+
* Cloudflare KV-backed {@link FeatureFlagDriver}. Flags are stored as JSON
|
|
4
|
+
* values under an optional key prefix and read with `get(key, 'json')`. Reads
|
|
5
|
+
* are type-checked against the requested type: a missing key or a value of the
|
|
6
|
+
* wrong JSON type returns the caller's `fallback`. KV has no targeting, so the
|
|
7
|
+
* evaluation context is ignored.
|
|
8
|
+
*
|
|
9
|
+
* The driver stays honest — it does **not** swallow errors. A KV failure (or a
|
|
10
|
+
* `SyntaxError` from a malformed stored value) propagates; the never-throw
|
|
11
|
+
* guarantee lives in `@velajs/feature-flags`'s service layer.
|
|
12
|
+
*
|
|
13
|
+
* Placed like {@link KVCacheStore}: construct it in a wiring factory over a
|
|
14
|
+
* resolved {@link KVNamespace}.
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* FeatureFlagsModule.forRootAsync({
|
|
18
|
+
* inject: [ENV],
|
|
19
|
+
* useFactory: (env: WorkerEnv) => ({ drivers: [new KvFlagDriver(env.CACHE, { prefix: 'flag:' })] }),
|
|
20
|
+
* });
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
var KvFlagDriver = class {
|
|
24
|
+
ns;
|
|
25
|
+
name;
|
|
26
|
+
prefix;
|
|
27
|
+
constructor(ns, options = {}) {
|
|
28
|
+
this.ns = ns;
|
|
29
|
+
this.name = options.name ?? "kv";
|
|
30
|
+
this.prefix = options.prefix ?? "";
|
|
31
|
+
}
|
|
32
|
+
getBoolean(key, fallback, _ctx) {
|
|
33
|
+
return this.read(key, fallback, (v) => typeof v === "boolean");
|
|
34
|
+
}
|
|
35
|
+
getString(key, fallback, _ctx) {
|
|
36
|
+
return this.read(key, fallback, (v) => typeof v === "string");
|
|
37
|
+
}
|
|
38
|
+
getNumber(key, fallback, _ctx) {
|
|
39
|
+
return this.read(key, fallback, (v) => typeof v === "number");
|
|
40
|
+
}
|
|
41
|
+
getObject(key, fallback, _ctx) {
|
|
42
|
+
return this.read(key, fallback, (v) => typeof v === "object" && v !== null);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Reads and JSON-parses the (prefixed) key, returning the parsed value only
|
|
46
|
+
* when `matches` accepts its type; otherwise the caller's fallback. A missing
|
|
47
|
+
* key reads as `null` → fallback. Read/parse errors are left to propagate.
|
|
48
|
+
*/
|
|
49
|
+
async read(key, fallback, matches) {
|
|
50
|
+
const value = await this.ns.get(this.prefix + key, "json");
|
|
51
|
+
if (value === null || value === void 0) return fallback;
|
|
52
|
+
return matches(value) ? value : fallback;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
/** Convenience factory for {@link KvFlagDriver}. */
|
|
56
|
+
function kvFlagDriver(kv, options) {
|
|
57
|
+
return new KvFlagDriver(kv, options);
|
|
58
|
+
}
|
|
59
|
+
//#endregion
|
|
60
|
+
export { KvFlagDriver, kvFlagDriver };
|
|
61
|
+
|
|
62
|
+
//# sourceMappingURL=kv-flag.driver.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"kv-flag.driver.js","names":[],"sources":["../../src/services/kv-flag.driver.ts"],"sourcesContent":["import type { FeatureFlagDriver, FlagContext } from '@velajs/feature-flags';\n\nexport interface KvFlagDriverOptions {\n /** Driver name used for `use(name)` / default-driver selection. Default `\"kv\"`. */\n name?: string;\n /** Prefix prepended to every flag key before the KV read. Default `\"\"` (none). */\n prefix?: string;\n}\n\n/**\n * Cloudflare KV-backed {@link FeatureFlagDriver}. Flags are stored as JSON\n * values under an optional key prefix and read with `get(key, 'json')`. Reads\n * are type-checked against the requested type: a missing key or a value of the\n * wrong JSON type returns the caller's `fallback`. KV has no targeting, so the\n * evaluation context is ignored.\n *\n * The driver stays honest — it does **not** swallow errors. A KV failure (or a\n * `SyntaxError` from a malformed stored value) propagates; the never-throw\n * guarantee lives in `@velajs/feature-flags`'s service layer.\n *\n * Placed like {@link KVCacheStore}: construct it in a wiring factory over a\n * resolved {@link KVNamespace}.\n *\n * ```ts\n * FeatureFlagsModule.forRootAsync({\n * inject: [ENV],\n * useFactory: (env: WorkerEnv) => ({ drivers: [new KvFlagDriver(env.CACHE, { prefix: 'flag:' })] }),\n * });\n * ```\n */\nexport class KvFlagDriver implements FeatureFlagDriver {\n readonly name: string;\n private readonly prefix: string;\n\n constructor(\n private readonly ns: KVNamespace,\n options: KvFlagDriverOptions = {},\n ) {\n this.name = options.name ?? 'kv';\n this.prefix = options.prefix ?? '';\n }\n\n getBoolean(key: string, fallback: boolean, _ctx?: FlagContext): Promise<boolean> {\n return this.read(key, fallback, (v) => typeof v === 'boolean');\n }\n\n getString(key: string, fallback: string, _ctx?: FlagContext): Promise<string> {\n return this.read(key, fallback, (v) => typeof v === 'string');\n }\n\n getNumber(key: string, fallback: number, _ctx?: FlagContext): Promise<number> {\n return this.read(key, fallback, (v) => typeof v === 'number');\n }\n\n getObject(key: string, fallback: object, _ctx?: FlagContext): Promise<unknown> {\n return this.read(key, fallback, (v) => typeof v === 'object' && v !== null);\n }\n\n /**\n * Reads and JSON-parses the (prefixed) key, returning the parsed value only\n * when `matches` accepts its type; otherwise the caller's fallback. A missing\n * key reads as `null` → fallback. Read/parse errors are left to propagate.\n */\n private async read<T>(\n key: string,\n fallback: T,\n matches: (value: unknown) => value is T,\n ): Promise<T> {\n const value = await this.ns.get(this.prefix + key, 'json');\n if (value === null || value === undefined) return fallback;\n return matches(value) ? value : fallback;\n }\n}\n\n/** Convenience factory for {@link KvFlagDriver}. */\nexport function kvFlagDriver(kv: KVNamespace, options?: KvFlagDriverOptions): KvFlagDriver {\n return new KvFlagDriver(kv, options);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AA8BA,IAAa,eAAb,MAAuD;CAKlC;CAJnB;CACA;CAEA,YACE,IACA,UAA+B,CAAC,GAChC;EAFiB,KAAA,KAAA;EAGjB,KAAK,OAAO,QAAQ,QAAQ;EAC5B,KAAK,SAAS,QAAQ,UAAU;CAClC;CAEA,WAAW,KAAa,UAAmB,MAAsC;EAC/E,OAAO,KAAK,KAAK,KAAK,WAAW,MAAM,OAAO,MAAM,SAAS;CAC/D;CAEA,UAAU,KAAa,UAAkB,MAAqC;EAC5E,OAAO,KAAK,KAAK,KAAK,WAAW,MAAM,OAAO,MAAM,QAAQ;CAC9D;CAEA,UAAU,KAAa,UAAkB,MAAqC;EAC5E,OAAO,KAAK,KAAK,KAAK,WAAW,MAAM,OAAO,MAAM,QAAQ;CAC9D;CAEA,UAAU,KAAa,UAAkB,MAAsC;EAC7E,OAAO,KAAK,KAAK,KAAK,WAAW,MAAM,OAAO,MAAM,YAAY,MAAM,IAAI;CAC5E;;;;;;CAOA,MAAc,KACZ,KACA,UACA,SACY;EACZ,MAAM,QAAQ,MAAM,KAAK,GAAG,IAAI,KAAK,SAAS,KAAK,MAAM;EACzD,IAAI,UAAU,QAAQ,UAAU,KAAA,GAAW,OAAO;EAClD,OAAO,QAAQ,KAAK,IAAI,QAAQ;CAClC;AACF;;AAGA,SAAgB,aAAa,IAAiB,SAA6C;CACzF,OAAO,IAAI,aAAa,IAAI,OAAO;AACrC"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { encodeStorageKeyClaim } from "./storage-key-claim.js";
|
|
2
|
+
import { STORAGE_SIGNED_URL_PURPOSE, signUrl } from "@velajs/vela/security";
|
|
3
|
+
//#region src/storage/r2-storage.driver.ts
|
|
4
|
+
/** Base path of the StorageController presign-proxy route. */
|
|
5
|
+
const STORAGE_ROUTE_BASE = "/storage";
|
|
6
|
+
/** {@link StorageDriver} over a Cloudflare R2 bucket. */
|
|
7
|
+
var R2StorageDriver = class {
|
|
8
|
+
config;
|
|
9
|
+
constructor(config) {
|
|
10
|
+
this.config = config;
|
|
11
|
+
}
|
|
12
|
+
async upload(body, path, options) {
|
|
13
|
+
await this.config.bucket.put(path, body, {
|
|
14
|
+
httpMetadata: options.mimeType ? { contentType: options.mimeType } : void 0,
|
|
15
|
+
customMetadata: options.metadata
|
|
16
|
+
});
|
|
17
|
+
return {
|
|
18
|
+
path,
|
|
19
|
+
disk: this.config.disk,
|
|
20
|
+
size: options.size,
|
|
21
|
+
mimeType: options.mimeType ?? "application/octet-stream",
|
|
22
|
+
uploadedAt: /* @__PURE__ */ new Date()
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
async download(path) {
|
|
26
|
+
const obj = await this.config.bucket.get(path);
|
|
27
|
+
if (!obj) throw new Error(`Storage object not found at "${path}".`);
|
|
28
|
+
return {
|
|
29
|
+
toStream: () => obj.body,
|
|
30
|
+
toArrayBuffer: () => obj.arrayBuffer(),
|
|
31
|
+
toText: () => obj.text(),
|
|
32
|
+
contentType: obj.httpMetadata?.contentType ?? "application/octet-stream",
|
|
33
|
+
size: obj.size,
|
|
34
|
+
metadata: obj.customMetadata
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
async delete(path) {
|
|
38
|
+
await this.config.bucket.delete(path);
|
|
39
|
+
}
|
|
40
|
+
async exists(path) {
|
|
41
|
+
return await this.config.bucket.head(path) !== null;
|
|
42
|
+
}
|
|
43
|
+
async getPresignedUrl(path, method, expiresIn) {
|
|
44
|
+
if (!this.config.secret) throw new Error("A signing secret is required for presigned URLs (set APP_SECRET).");
|
|
45
|
+
if (!Number.isSafeInteger(expiresIn) || expiresIn <= 0) throw new Error(`Invalid presigned URL expiry: ${expiresIn}s (must be a positive safe integer).`);
|
|
46
|
+
const claim = encodeStorageKeyClaim(path);
|
|
47
|
+
const routePath = `${STORAGE_ROUTE_BASE}/${encodeURIComponent(this.config.disk)}`;
|
|
48
|
+
const query = new URLSearchParams({
|
|
49
|
+
key: claim,
|
|
50
|
+
method
|
|
51
|
+
});
|
|
52
|
+
return {
|
|
53
|
+
url: await signUrl(`${routePath}?${query}`, this.config.secret, {
|
|
54
|
+
expiresIn,
|
|
55
|
+
method,
|
|
56
|
+
purpose: STORAGE_SIGNED_URL_PURPOSE
|
|
57
|
+
}),
|
|
58
|
+
method,
|
|
59
|
+
expiresIn,
|
|
60
|
+
expiresAt: new Date(Date.now() + expiresIn * 1e3)
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
//#endregion
|
|
65
|
+
export { R2StorageDriver, STORAGE_ROUTE_BASE };
|
|
66
|
+
|
|
67
|
+
//# sourceMappingURL=r2-storage.driver.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"r2-storage.driver.js","names":[],"sources":["../../src/storage/r2-storage.driver.ts"],"sourcesContent":["import type {\n DownloadResult,\n PresignedUrlResult,\n PresignMethod,\n StorageBody,\n StorageDriver,\n UploadOptions,\n UploadResult,\n} from '@velajs/vela/storage';\nimport { signUrl, STORAGE_SIGNED_URL_PURPOSE } from '@velajs/vela/security';\nimport { encodeStorageKeyClaim } from './storage-key-claim';\n\n/** Base path of the StorageController presign-proxy route. */\nexport const STORAGE_ROUTE_BASE = '/storage';\n\nexport interface R2StorageDriverConfig {\n disk: string;\n bucket: R2Bucket;\n /** HMAC secret for presigned URLs (typically env.APP_SECRET). */\n secret?: string;\n}\n\n/** {@link StorageDriver} over a Cloudflare R2 bucket. */\nexport class R2StorageDriver implements StorageDriver {\n constructor(private readonly config: R2StorageDriverConfig) {}\n\n async upload(body: StorageBody, path: string, options: UploadOptions): Promise<UploadResult> {\n await this.config.bucket.put(\n path,\n body as ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null,\n {\n httpMetadata: options.mimeType ? { contentType: options.mimeType } : undefined,\n customMetadata: options.metadata,\n },\n );\n return {\n path,\n disk: this.config.disk,\n size: options.size,\n mimeType: options.mimeType ?? 'application/octet-stream',\n uploadedAt: new Date(),\n };\n }\n\n async download(path: string): Promise<DownloadResult> {\n const obj = await this.config.bucket.get(path);\n if (!obj) throw new Error(`Storage object not found at \"${path}\".`);\n return {\n toStream: () => obj.body as ReadableStream,\n toArrayBuffer: () => obj.arrayBuffer(),\n toText: () => obj.text(),\n contentType: obj.httpMetadata?.contentType ?? 'application/octet-stream',\n size: obj.size,\n metadata: obj.customMetadata,\n };\n }\n\n async delete(path: string): Promise<void> {\n await this.config.bucket.delete(path);\n }\n\n async exists(path: string): Promise<boolean> {\n return (await this.config.bucket.head(path)) !== null;\n }\n\n async getPresignedUrl(\n path: string,\n method: PresignMethod,\n expiresIn: number,\n ): Promise<PresignedUrlResult> {\n if (!this.config.secret) {\n throw new Error('A signing secret is required for presigned URLs (set APP_SECRET).');\n }\n // Defend the direct-driver path too: a non-finite/non-positive expiry would\n // make signUrl omit `expires`, yielding a never-expiring URL.\n if (!Number.isSafeInteger(expiresIn) || expiresIn <= 0) {\n throw new Error(\n `Invalid presigned URL expiry: ${expiresIn}s (must be a positive safe integer).`,\n );\n }\n // The object key is an opaque signed query claim, never a URL path. This\n // prevents WHATWG path normalization / multi-decode behavior from turning\n // an encoded key into a different storage capability.\n const claim = encodeStorageKeyClaim(path);\n const routePath = `${STORAGE_ROUTE_BASE}/${encodeURIComponent(this.config.disk)}`;\n const query = new URLSearchParams({ key: claim, method });\n const url = await signUrl(`${routePath}?${query}`, this.config.secret, {\n expiresIn,\n method,\n purpose: STORAGE_SIGNED_URL_PURPOSE,\n });\n return { url, method, expiresIn, expiresAt: new Date(Date.now() + expiresIn * 1000) };\n }\n}\n"],"mappings":";;;;AAaA,MAAa,qBAAqB;;AAUlC,IAAa,kBAAb,MAAsD;CACvB;CAA7B,YAAY,QAAgD;EAA/B,KAAA,SAAA;CAAgC;CAE7D,MAAM,OAAO,MAAmB,MAAc,SAA+C;EAC3F,MAAM,KAAK,OAAO,OAAO,IACvB,MACA,MACA;GACE,cAAc,QAAQ,WAAW,EAAE,aAAa,QAAQ,SAAS,IAAI,KAAA;GACrE,gBAAgB,QAAQ;EAC1B,CACF;EACA,OAAO;GACL;GACA,MAAM,KAAK,OAAO;GAClB,MAAM,QAAQ;GACd,UAAU,QAAQ,YAAY;GAC9B,4BAAY,IAAI,KAAK;EACvB;CACF;CAEA,MAAM,SAAS,MAAuC;EACpD,MAAM,MAAM,MAAM,KAAK,OAAO,OAAO,IAAI,IAAI;EAC7C,IAAI,CAAC,KAAK,MAAM,IAAI,MAAM,gCAAgC,KAAK,GAAG;EAClE,OAAO;GACL,gBAAgB,IAAI;GACpB,qBAAqB,IAAI,YAAY;GACrC,cAAc,IAAI,KAAK;GACvB,aAAa,IAAI,cAAc,eAAe;GAC9C,MAAM,IAAI;GACV,UAAU,IAAI;EAChB;CACF;CAEA,MAAM,OAAO,MAA6B;EACxC,MAAM,KAAK,OAAO,OAAO,OAAO,IAAI;CACtC;CAEA,MAAM,OAAO,MAAgC;EAC3C,OAAQ,MAAM,KAAK,OAAO,OAAO,KAAK,IAAI,MAAO;CACnD;CAEA,MAAM,gBACJ,MACA,QACA,WAC6B;EAC7B,IAAI,CAAC,KAAK,OAAO,QACf,MAAM,IAAI,MAAM,mEAAmE;EAIrF,IAAI,CAAC,OAAO,cAAc,SAAS,KAAK,aAAa,GACnD,MAAM,IAAI,MACR,iCAAiC,UAAU,qCAC7C;EAKF,MAAM,QAAQ,sBAAsB,IAAI;EACxC,MAAM,YAAY,GAAG,mBAAmB,GAAG,mBAAmB,KAAK,OAAO,IAAI;EAC9E,MAAM,QAAQ,IAAI,gBAAgB;GAAE,KAAK;GAAO;EAAO,CAAC;EAMxD,OAAO;GAAE,KAAA,MALS,QAAQ,GAAG,UAAU,GAAG,SAAS,KAAK,OAAO,QAAQ;IACrE;IACA;IACA,SAAS;GACX,CAAC;GACa;GAAQ;GAAW,WAAW,IAAI,KAAK,KAAK,IAAI,IAAI,YAAY,GAAI;EAAE;CACtF;AACF"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
//#region src/storage/storage-key-claim.ts
|
|
2
|
+
/** R2-compatible object-key claim bound. Keeps decode work predictably small. */
|
|
3
|
+
const MAX_STORAGE_KEY_BYTES = 1024;
|
|
4
|
+
const BASE64URL_RE = /^[A-Za-z0-9_-]+$/;
|
|
5
|
+
const ROOT_TOKEN_PATTERNS = {
|
|
6
|
+
date: "\\d{4}-\\d{2}-\\d{2}",
|
|
7
|
+
year: "\\d{4}",
|
|
8
|
+
month: "(?:0[1-9]|1[0-2])",
|
|
9
|
+
day: "(?:0[1-9]|[12]\\d|3[01])",
|
|
10
|
+
uuid: "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}"
|
|
11
|
+
};
|
|
12
|
+
/** Encode an object key as an opaque, canonical base64url query claim. */
|
|
13
|
+
function encodeStorageKeyClaim(key) {
|
|
14
|
+
const bytes = new TextEncoder().encode(key);
|
|
15
|
+
if (bytes.byteLength === 0 || bytes.byteLength > MAX_STORAGE_KEY_BYTES) throw new Error(`Storage key must be 1–${MAX_STORAGE_KEY_BYTES} UTF-8 bytes`);
|
|
16
|
+
let binary = "";
|
|
17
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
18
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
19
|
+
}
|
|
20
|
+
/** Decode exactly one canonical base64url layer. Malformed/non-UTF-8 claims return undefined. */
|
|
21
|
+
function decodeStorageKeyClaim(claim) {
|
|
22
|
+
try {
|
|
23
|
+
if (claim.length === 0 || claim.length > Math.ceil(MAX_STORAGE_KEY_BYTES * 4 / 3) || !BASE64URL_RE.test(claim) || claim.length % 4 === 1) return;
|
|
24
|
+
const base64 = claim.replace(/-/g, "+").replace(/_/g, "/");
|
|
25
|
+
const padded = base64 + "=".repeat((4 - base64.length % 4) % 4);
|
|
26
|
+
const binary = atob(padded);
|
|
27
|
+
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
|
28
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
29
|
+
if (bytes.byteLength === 0 || bytes.byteLength > MAX_STORAGE_KEY_BYTES) return void 0;
|
|
30
|
+
const decoded = new TextDecoder("utf-8", {
|
|
31
|
+
fatal: true,
|
|
32
|
+
ignoreBOM: false
|
|
33
|
+
}).decode(bytes);
|
|
34
|
+
return encodeStorageKeyClaim(decoded) === claim ? decoded : void 0;
|
|
35
|
+
} catch {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function isDotSegment(segment) {
|
|
40
|
+
let decoded = segment;
|
|
41
|
+
for (let i = 0; i < 2; i++) {
|
|
42
|
+
if (decoded === "" || decoded === "." || decoded === "..") return true;
|
|
43
|
+
try {
|
|
44
|
+
const next = decodeURIComponent(decoded);
|
|
45
|
+
if (next === decoded) break;
|
|
46
|
+
decoded = next;
|
|
47
|
+
} catch {
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return decoded === "" || decoded === "." || decoded === "..";
|
|
52
|
+
}
|
|
53
|
+
function rootSegmentPattern(segment) {
|
|
54
|
+
let pattern = "";
|
|
55
|
+
let cursor = 0;
|
|
56
|
+
for (const match of segment.matchAll(/\{(date|year|month|day|uuid)\}/g)) {
|
|
57
|
+
pattern += segment.slice(cursor, match.index).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
58
|
+
pattern += ROOT_TOKEN_PATTERNS[match[1]];
|
|
59
|
+
cursor = match.index + match[0].length;
|
|
60
|
+
}
|
|
61
|
+
pattern += segment.slice(cursor).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
62
|
+
return pattern;
|
|
63
|
+
}
|
|
64
|
+
/** Assert a canonical key is beneath a static or templated configured root. */
|
|
65
|
+
function isStorageKeyWithinRoot(key, root) {
|
|
66
|
+
const segments = (root ?? "").split(/[/\\]+/).filter((segment) => !isDotSegment(segment));
|
|
67
|
+
if (segments.length === 0) return key.length > 0;
|
|
68
|
+
const rootPattern = segments.map(rootSegmentPattern).join("/");
|
|
69
|
+
return new RegExp(`^(?:${rootPattern})(?:/|$)`).test(key);
|
|
70
|
+
}
|
|
71
|
+
//#endregion
|
|
72
|
+
export { MAX_STORAGE_KEY_BYTES, decodeStorageKeyClaim, encodeStorageKeyClaim, isStorageKeyWithinRoot };
|
|
73
|
+
|
|
74
|
+
//# sourceMappingURL=storage-key-claim.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"storage-key-claim.js","names":[],"sources":["../../src/storage/storage-key-claim.ts"],"sourcesContent":["/** R2-compatible object-key claim bound. Keeps decode work predictably small. */\nexport const MAX_STORAGE_KEY_BYTES = 1024;\n\nconst BASE64URL_RE = /^[A-Za-z0-9_-]+$/;\nconst ROOT_TOKEN_PATTERNS: Record<string, string> = {\n date: '\\\\d{4}-\\\\d{2}-\\\\d{2}',\n year: '\\\\d{4}',\n month: '(?:0[1-9]|1[0-2])',\n day: '(?:0[1-9]|[12]\\\\d|3[01])',\n uuid: '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}',\n};\n\n/** Encode an object key as an opaque, canonical base64url query claim. */\nexport function encodeStorageKeyClaim(key: string): string {\n const bytes = new TextEncoder().encode(key);\n if (bytes.byteLength === 0 || bytes.byteLength > MAX_STORAGE_KEY_BYTES) {\n throw new Error(`Storage key must be 1–${MAX_STORAGE_KEY_BYTES} UTF-8 bytes`);\n }\n let binary = '';\n for (const byte of bytes) binary += String.fromCharCode(byte);\n return btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n}\n\n/** Decode exactly one canonical base64url layer. Malformed/non-UTF-8 claims return undefined. */\nexport function decodeStorageKeyClaim(claim: string): string | undefined {\n try {\n if (\n claim.length === 0 ||\n claim.length > Math.ceil((MAX_STORAGE_KEY_BYTES * 4) / 3) ||\n !BASE64URL_RE.test(claim) ||\n claim.length % 4 === 1\n ) {\n return undefined;\n }\n const base64 = claim.replace(/-/g, '+').replace(/_/g, '/');\n const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);\n const binary = atob(padded);\n const bytes = new Uint8Array(new ArrayBuffer(binary.length));\n for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);\n if (bytes.byteLength === 0 || bytes.byteLength > MAX_STORAGE_KEY_BYTES) return undefined;\n const decoded = new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(bytes);\n return encodeStorageKeyClaim(decoded) === claim ? decoded : undefined;\n } catch {\n return undefined;\n }\n}\n\nfunction isDotSegment(segment: string): boolean {\n let decoded = segment;\n for (let i = 0; i < 2; i++) {\n if (decoded === '' || decoded === '.' || decoded === '..') return true;\n try {\n const next = decodeURIComponent(decoded);\n if (next === decoded) break;\n decoded = next;\n } catch {\n break;\n }\n }\n return decoded === '' || decoded === '.' || decoded === '..';\n}\n\nfunction rootSegmentPattern(segment: string): string {\n let pattern = '';\n let cursor = 0;\n for (const match of segment.matchAll(/\\{(date|year|month|day|uuid)\\}/g)) {\n pattern += segment.slice(cursor, match.index).replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n pattern += ROOT_TOKEN_PATTERNS[match[1]!]!;\n cursor = match.index! + match[0].length;\n }\n pattern += segment.slice(cursor).replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n return pattern;\n}\n\n/** Assert a canonical key is beneath a static or templated configured root. */\nexport function isStorageKeyWithinRoot(key: string, root: string | undefined): boolean {\n const segments = (root ?? '').split(/[/\\\\]+/).filter((segment) => !isDotSegment(segment));\n if (segments.length === 0) return key.length > 0;\n const rootPattern = segments.map(rootSegmentPattern).join('/');\n return new RegExp(`^(?:${rootPattern})(?:/|$)`).test(key);\n}\n"],"mappings":";;AACA,MAAa,wBAAwB;AAErC,MAAM,eAAe;AACrB,MAAM,sBAA8C;CAClD,MAAM;CACN,MAAM;CACN,OAAO;CACP,KAAK;CACL,MAAM;AACR;;AAGA,SAAgB,sBAAsB,KAAqB;CACzD,MAAM,QAAQ,IAAI,YAAY,CAAC,CAAC,OAAO,GAAG;CAC1C,IAAI,MAAM,eAAe,KAAK,MAAM,aAAa,uBAC/C,MAAM,IAAI,MAAM,yBAAyB,sBAAsB,aAAa;CAE9E,IAAI,SAAS;CACb,KAAK,MAAM,QAAQ,OAAO,UAAU,OAAO,aAAa,IAAI;CAC5D,OAAO,KAAK,MAAM,CAAC,CAAC,QAAQ,OAAO,GAAG,CAAC,CAAC,QAAQ,OAAO,GAAG,CAAC,CAAC,QAAQ,OAAO,EAAE;AAC/E;;AAGA,SAAgB,sBAAsB,OAAmC;CACvE,IAAI;EACF,IACE,MAAM,WAAW,KACjB,MAAM,SAAS,KAAK,KAAM,wBAAwB,IAAK,CAAC,KACxD,CAAC,aAAa,KAAK,KAAK,KACxB,MAAM,SAAS,MAAM,GAErB;EAEF,MAAM,SAAS,MAAM,QAAQ,MAAM,GAAG,CAAC,CAAC,QAAQ,MAAM,GAAG;EACzD,MAAM,SAAS,SAAS,IAAI,QAAQ,IAAK,OAAO,SAAS,KAAM,CAAC;EAChE,MAAM,SAAS,KAAK,MAAM;EAC1B,MAAM,QAAQ,IAAI,WAAW,IAAI,YAAY,OAAO,MAAM,CAAC;EAC3D,KAAK,IAAI,IAAI,GAAG,IAAI,OAAO,QAAQ,KAAK,MAAM,KAAK,OAAO,WAAW,CAAC;EACtE,IAAI,MAAM,eAAe,KAAK,MAAM,aAAa,uBAAuB,OAAO,KAAA;EAC/E,MAAM,UAAU,IAAI,YAAY,SAAS;GAAE,OAAO;GAAM,WAAW;EAAM,CAAC,CAAC,CAAC,OAAO,KAAK;EACxF,OAAO,sBAAsB,OAAO,MAAM,QAAQ,UAAU,KAAA;CAC9D,QAAQ;EACN;CACF;AACF;AAEA,SAAS,aAAa,SAA0B;CAC9C,IAAI,UAAU;CACd,KAAK,IAAI,IAAI,GAAG,IAAI,GAAG,KAAK;EAC1B,IAAI,YAAY,MAAM,YAAY,OAAO,YAAY,MAAM,OAAO;EAClE,IAAI;GACF,MAAM,OAAO,mBAAmB,OAAO;GACvC,IAAI,SAAS,SAAS;GACtB,UAAU;EACZ,QAAQ;GACN;EACF;CACF;CACA,OAAO,YAAY,MAAM,YAAY,OAAO,YAAY;AAC1D;AAEA,SAAS,mBAAmB,SAAyB;CACnD,IAAI,UAAU;CACd,IAAI,SAAS;CACb,KAAK,MAAM,SAAS,QAAQ,SAAS,iCAAiC,GAAG;EACvE,WAAW,QAAQ,MAAM,QAAQ,MAAM,KAAK,CAAC,CAAC,QAAQ,uBAAuB,MAAM;EACnF,WAAW,oBAAoB,MAAM;EACrC,SAAS,MAAM,QAAS,MAAM,EAAE,CAAC;CACnC;CACA,WAAW,QAAQ,MAAM,MAAM,CAAC,CAAC,QAAQ,uBAAuB,MAAM;CACtE,OAAO;AACT;;AAGA,SAAgB,uBAAuB,KAAa,MAAmC;CACrF,MAAM,YAAY,QAAQ,GAAA,CAAI,MAAM,QAAQ,CAAC,CAAC,QAAQ,YAAY,CAAC,aAAa,OAAO,CAAC;CACxF,IAAI,SAAS,WAAW,GAAG,OAAO,IAAI,SAAS;CAC/C,MAAM,cAAc,SAAS,IAAI,kBAAkB,CAAC,CAAC,KAAK,GAAG;CAC7D,OAAO,IAAI,OAAO,OAAO,YAAY,SAAS,CAAC,CAAC,KAAK,GAAG;AAC1D"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { STORAGE_OPTIONS } from "./storage.tokens.js";
|
|
2
|
+
import { R2StorageDriver } from "./r2-storage.driver.js";
|
|
3
|
+
import __decorateMetadata from "../_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorateMetadata.js";
|
|
4
|
+
import __decorateParam from "../_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorateParam.js";
|
|
5
|
+
import __decorate from "../_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorate.js";
|
|
6
|
+
import { Inject, Injectable } from "@velajs/vela";
|
|
7
|
+
//#region src/storage/storage-manager.service.ts
|
|
8
|
+
let StorageManagerService = class StorageManagerService {
|
|
9
|
+
options;
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.options = options;
|
|
12
|
+
}
|
|
13
|
+
hasDisk(disk) {
|
|
14
|
+
return this.options.disks.some((d) => d.disk === disk);
|
|
15
|
+
}
|
|
16
|
+
getDiskConfig(disk) {
|
|
17
|
+
const config = this.options.disks.find((d) => d.disk === disk);
|
|
18
|
+
if (!config) throw new Error(`Storage disk "${disk}" is not configured.`);
|
|
19
|
+
return config;
|
|
20
|
+
}
|
|
21
|
+
getDriver(disk) {
|
|
22
|
+
const config = this.getDiskConfig(disk);
|
|
23
|
+
return new R2StorageDriver({
|
|
24
|
+
disk,
|
|
25
|
+
bucket: config.bucket,
|
|
26
|
+
secret: this.options.secret
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
StorageManagerService = __decorate([
|
|
31
|
+
Injectable(),
|
|
32
|
+
__decorateParam(0, Inject(STORAGE_OPTIONS)),
|
|
33
|
+
__decorateMetadata("design:paramtypes", [Object])
|
|
34
|
+
], StorageManagerService);
|
|
35
|
+
//#endregion
|
|
36
|
+
export { StorageManagerService };
|
|
37
|
+
|
|
38
|
+
//# sourceMappingURL=storage-manager.service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"storage-manager.service.js","names":[],"sources":["../../src/storage/storage-manager.service.ts"],"sourcesContent":["import { Inject, Injectable } from '@velajs/vela';\nimport { R2StorageDriver } from './r2-storage.driver';\nimport { STORAGE_OPTIONS } from './storage.tokens';\nimport type { DiskConfig, StorageModuleOptions } from './storage.types';\n\n@Injectable()\nexport class StorageManagerService {\n constructor(@Inject(STORAGE_OPTIONS) private readonly options: StorageModuleOptions) {}\n\n hasDisk(disk: string): boolean {\n return this.options.disks.some((d) => d.disk === disk);\n }\n\n getDiskConfig(disk: string): DiskConfig {\n const config = this.options.disks.find((d) => d.disk === disk);\n if (!config) throw new Error(`Storage disk \"${disk}\" is not configured.`);\n return config;\n }\n\n getDriver(disk: string): R2StorageDriver {\n const config = this.getDiskConfig(disk);\n return new R2StorageDriver({ disk, bucket: config.bucket, secret: this.options.secret });\n }\n}\n"],"mappings":";;;;;;;AAMO,IAAM,wBAAN,MAAM,sBAAsB;CACqB;CAAtD,YAAY,SAAyE;EAA/B,KAAA,UAAA;CAAgC;CAEtF,QAAQ,MAAuB;EAC7B,OAAO,KAAK,QAAQ,MAAM,MAAM,MAAM,EAAE,SAAS,IAAI;CACvD;CAEA,cAAc,MAA0B;EACtC,MAAM,SAAS,KAAK,QAAQ,MAAM,MAAM,MAAM,EAAE,SAAS,IAAI;EAC7D,IAAI,CAAC,QAAQ,MAAM,IAAI,MAAM,iBAAiB,KAAK,qBAAqB;EACxE,OAAO;CACT;CAEA,UAAU,MAA+B;EACvC,MAAM,SAAS,KAAK,cAAc,IAAI;EACtC,OAAO,IAAI,gBAAgB;GAAE;GAAM,QAAQ,OAAO;GAAQ,QAAQ,KAAK,QAAQ;EAAO,CAAC;CACzF;AACF;;CAlBC,WAAW;CAEG,gBAAA,GAAA,OAAO,eAAe,CAAA"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { STORAGE_OPTIONS } from "./storage.tokens.js";
|
|
2
|
+
import { decodeStorageKeyClaim, isStorageKeyWithinRoot } from "./storage-key-claim.js";
|
|
3
|
+
import __decorateMetadata from "../_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorateMetadata.js";
|
|
4
|
+
import __decorateParam from "../_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorateParam.js";
|
|
5
|
+
import __decorate from "../_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorate.js";
|
|
6
|
+
import { StorageManagerService } from "./storage-manager.service.js";
|
|
7
|
+
import { Controller, Get, Inject, Req } from "@velajs/vela";
|
|
8
|
+
import { joinStoragePath } from "@velajs/vela/storage";
|
|
9
|
+
import { STORAGE_SIGNED_URL_PURPOSE, verifySignedUrl } from "@velajs/vela/security";
|
|
10
|
+
//#region src/storage/storage.controller.ts
|
|
11
|
+
let StorageController = class StorageController {
|
|
12
|
+
manager;
|
|
13
|
+
options;
|
|
14
|
+
constructor(manager, options) {
|
|
15
|
+
this.manager = manager;
|
|
16
|
+
this.options = options;
|
|
17
|
+
}
|
|
18
|
+
async download(c) {
|
|
19
|
+
const secret = this.options.secret;
|
|
20
|
+
if (!secret) return new Response("Storage signing is not configured", { status: 500 });
|
|
21
|
+
if (!await verifySignedUrl(c.req.url, secret, {
|
|
22
|
+
method: c.req.method,
|
|
23
|
+
purpose: STORAGE_SIGNED_URL_PURPOSE
|
|
24
|
+
})) return new Response("Invalid or expired URL", { status: 403 });
|
|
25
|
+
const url = new URL(c.req.url);
|
|
26
|
+
if (url.searchParams.get("method") !== "GET") return new Response("URL is not scoped for reads", { status: 403 });
|
|
27
|
+
const disk = c.req.param("disk");
|
|
28
|
+
if (!disk || !this.manager.hasDisk(disk)) return new Response("Unknown disk", { status: 404 });
|
|
29
|
+
const claim = url.searchParams.get("key");
|
|
30
|
+
const decoded = claim ? decodeStorageKeyClaim(claim) : void 0;
|
|
31
|
+
if (!decoded) return new Response("Malformed storage key claim", { status: 400 });
|
|
32
|
+
const fullPath = joinStoragePath(void 0, decoded);
|
|
33
|
+
if (fullPath !== decoded) return new Response("Invalid storage key", { status: 403 });
|
|
34
|
+
if (!isStorageKeyWithinRoot(fullPath, this.manager.getDiskConfig(disk).root)) return new Response("Storage key is outside the configured root", { status: 403 });
|
|
35
|
+
try {
|
|
36
|
+
const result = await this.manager.getDriver(disk).download(fullPath);
|
|
37
|
+
const filename = fullPath.split("/").at(-1) || "download";
|
|
38
|
+
return new Response(result.toStream(), { headers: {
|
|
39
|
+
"content-type": result.contentType || "application/octet-stream",
|
|
40
|
+
"content-disposition": `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
|
41
|
+
"x-content-type-options": "nosniff"
|
|
42
|
+
} });
|
|
43
|
+
} catch {
|
|
44
|
+
return new Response("Not found", { status: 404 });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
__decorate([
|
|
49
|
+
Get("/:disk"),
|
|
50
|
+
__decorateParam(0, Req()),
|
|
51
|
+
__decorateMetadata("design:type", Function),
|
|
52
|
+
__decorateMetadata("design:paramtypes", [Object]),
|
|
53
|
+
__decorateMetadata("design:returntype", Promise)
|
|
54
|
+
], StorageController.prototype, "download", null);
|
|
55
|
+
StorageController = __decorate([
|
|
56
|
+
Controller("storage"),
|
|
57
|
+
__decorateParam(0, Inject(StorageManagerService)),
|
|
58
|
+
__decorateParam(1, Inject(STORAGE_OPTIONS)),
|
|
59
|
+
__decorateMetadata("design:paramtypes", [typeof StorageManagerService === "undefined" ? Object : StorageManagerService, Object])
|
|
60
|
+
], StorageController);
|
|
61
|
+
//#endregion
|
|
62
|
+
export { StorageController };
|
|
63
|
+
|
|
64
|
+
//# sourceMappingURL=storage.controller.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"storage.controller.js","names":[],"sources":["../../src/storage/storage.controller.ts"],"sourcesContent":["import { Controller, Get, Inject, Req } from '@velajs/vela';\nimport { joinStoragePath } from '@velajs/vela/storage';\nimport { STORAGE_SIGNED_URL_PURPOSE, verifySignedUrl } from '@velajs/vela/security';\nimport type { Context } from 'hono';\nimport { STORAGE_OPTIONS } from './storage.tokens';\nimport type { StorageModuleOptions } from './storage.types';\nimport { StorageManagerService } from './storage-manager.service';\nimport { decodeStorageKeyClaim, isStorageKeyWithinRoot } from './storage-key-claim';\n\n/**\n * Presign-proxy: serves objects for HMAC-signed URLs produced by\n * `StorageService.url()`. R2 has no native presign, so a signed URL points here;\n * this route verifies the signature (+expiry) before streaming the object.\n * The FULL object key (root already applied at sign time) is carried as an\n * opaque base64url query claim. The signature is verified before the claim is\n * decoded exactly once and checked against the configured disk root.\n */\n@Controller('storage')\nexport class StorageController {\n constructor(\n @Inject(StorageManagerService) private readonly manager: StorageManagerService,\n @Inject(STORAGE_OPTIONS) private readonly options: StorageModuleOptions,\n ) {}\n\n @Get('/:disk')\n async download(@Req() c: Context): Promise<Response> {\n const secret = this.options.secret;\n if (!secret) return new Response('Storage signing is not configured', { status: 500 });\n\n // Verify the complete path/query capability before inspecting the disk or\n // decoding attacker-controlled claims.\n if (\n !(await verifySignedUrl(c.req.url, secret, {\n method: c.req.method,\n purpose: STORAGE_SIGNED_URL_PURPOSE,\n }))\n ) {\n return new Response('Invalid or expired URL', { status: 403 });\n }\n\n // Enforce the signed `method` scope: this proxy only serves reads, so a URL\n // scoped to PUT/DELETE/HEAD must NOT be honored as a GET (it would over-grant\n // read access relative to the token's intended scope). The param is part of\n // the signed payload, so it is trustworthy once the signature verifies.\n const url = new URL(c.req.url);\n if (url.searchParams.get('method') !== 'GET') {\n return new Response('URL is not scoped for reads', { status: 403 });\n }\n\n const disk = c.req.param('disk');\n if (!disk || !this.manager.hasDisk(disk)) return new Response('Unknown disk', { status: 404 });\n\n const claim = url.searchParams.get('key');\n const decoded = claim ? decodeStorageKeyClaim(claim) : undefined;\n if (!decoded) return new Response('Malformed storage key claim', { status: 400 });\n\n // Canonicalize after the one decode. Signed direct-driver callers cannot\n // smuggle dot segments or non-canonical aliases into the proxy contract.\n const fullPath = joinStoragePath(undefined, decoded);\n if (fullPath !== decoded) return new Response('Invalid storage key', { status: 403 });\n\n if (!isStorageKeyWithinRoot(fullPath, this.manager.getDiskConfig(disk).root)) {\n return new Response('Storage key is outside the configured root', { status: 403 });\n }\n\n try {\n const result = await this.manager.getDriver(disk).download(fullPath);\n const filename = fullPath.split('/').at(-1) || 'download';\n return new Response(result.toStream(), {\n headers: {\n 'content-type': result.contentType || 'application/octet-stream',\n // Objects are untrusted user content. The authenticated API origin\n // never renders them inline (especially HTML/SVG), even when the\n // stored Content-Type is attacker controlled.\n 'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,\n 'x-content-type-options': 'nosniff',\n },\n });\n } catch {\n return new Response('Not found', { status: 404 });\n }\n }\n}\n"],"mappings":";;;;;;;;;;AAkBO,IAAM,oBAAN,MAAM,kBAAkB;CAEqB;CACN;CAF5C,YACE,SACA,SACA;EAFgD,KAAA,UAAA;EACN,KAAA,UAAA;CACzC;CAEH,MACM,SAAS,GAAsC;EACnD,MAAM,SAAS,KAAK,QAAQ;EAC5B,IAAI,CAAC,QAAQ,OAAO,IAAI,SAAS,qCAAqC,EAAE,QAAQ,IAAI,CAAC;EAIrF,IACE,CAAE,MAAM,gBAAgB,EAAE,IAAI,KAAK,QAAQ;GACzC,QAAQ,EAAE,IAAI;GACd,SAAS;EACX,CAAC,GAED,OAAO,IAAI,SAAS,0BAA0B,EAAE,QAAQ,IAAI,CAAC;EAO/D,MAAM,MAAM,IAAI,IAAI,EAAE,IAAI,GAAG;EAC7B,IAAI,IAAI,aAAa,IAAI,QAAQ,MAAM,OACrC,OAAO,IAAI,SAAS,+BAA+B,EAAE,QAAQ,IAAI,CAAC;EAGpE,MAAM,OAAO,EAAE,IAAI,MAAM,MAAM;EAC/B,IAAI,CAAC,QAAQ,CAAC,KAAK,QAAQ,QAAQ,IAAI,GAAG,OAAO,IAAI,SAAS,gBAAgB,EAAE,QAAQ,IAAI,CAAC;EAE7F,MAAM,QAAQ,IAAI,aAAa,IAAI,KAAK;EACxC,MAAM,UAAU,QAAQ,sBAAsB,KAAK,IAAI,KAAA;EACvD,IAAI,CAAC,SAAS,OAAO,IAAI,SAAS,+BAA+B,EAAE,QAAQ,IAAI,CAAC;EAIhF,MAAM,WAAW,gBAAgB,KAAA,GAAW,OAAO;EACnD,IAAI,aAAa,SAAS,OAAO,IAAI,SAAS,uBAAuB,EAAE,QAAQ,IAAI,CAAC;EAEpF,IAAI,CAAC,uBAAuB,UAAU,KAAK,QAAQ,cAAc,IAAI,CAAC,CAAC,IAAI,GACzE,OAAO,IAAI,SAAS,8CAA8C,EAAE,QAAQ,IAAI,CAAC;EAGnF,IAAI;GACF,MAAM,SAAS,MAAM,KAAK,QAAQ,UAAU,IAAI,CAAC,CAAC,SAAS,QAAQ;GACnE,MAAM,WAAW,SAAS,MAAM,GAAG,CAAC,CAAC,GAAG,EAAE,KAAK;GAC/C,OAAO,IAAI,SAAS,OAAO,SAAS,GAAG,EACrC,SAAS;IACP,gBAAgB,OAAO,eAAe;IAItC,uBAAuB,gCAAgC,mBAAmB,QAAQ;IAClF,0BAA0B;GAC5B,EACF,CAAC;EACH,QAAQ;GACN,OAAO,IAAI,SAAS,aAAa,EAAE,QAAQ,IAAI,CAAC;EAClD;CACF;AACF;;CA1DG,IAAI,QAAQ;CACG,gBAAA,GAAA,IAAI,CAAA;;;;;;CARrB,WAAW,SAAS;CAGhB,gBAAA,GAAA,OAAO,qBAAqB,CAAA;CAC5B,gBAAA,GAAA,OAAO,eAAe,CAAA"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { STORAGE_OPTIONS } from "./storage.tokens.js";
|
|
2
|
+
import __decorate from "../_virtual/_@oxc-project_runtime@0.150.0/helpers/esm/decorate.js";
|
|
3
|
+
import { StorageManagerService } from "./storage-manager.service.js";
|
|
4
|
+
import { StorageController } from "./storage.controller.js";
|
|
5
|
+
import { StorageService } from "./storage.service.js";
|
|
6
|
+
import { ConfigurableModuleBuilder, Module } from "@velajs/vela";
|
|
7
|
+
//#region src/storage/storage.module.ts
|
|
8
|
+
const { ConfigurableModuleClass } = new ConfigurableModuleBuilder({
|
|
9
|
+
moduleName: "Storage",
|
|
10
|
+
optionsInjectionToken: STORAGE_OPTIONS
|
|
11
|
+
}).build();
|
|
12
|
+
let StorageModule = class StorageModule extends ConfigurableModuleClass {};
|
|
13
|
+
StorageModule = __decorate([Module({
|
|
14
|
+
providers: [StorageManagerService, StorageService],
|
|
15
|
+
controllers: [StorageController],
|
|
16
|
+
exports: [
|
|
17
|
+
StorageService,
|
|
18
|
+
StorageManagerService,
|
|
19
|
+
STORAGE_OPTIONS
|
|
20
|
+
]
|
|
21
|
+
})], StorageModule);
|
|
22
|
+
//#endregion
|
|
23
|
+
export { StorageModule };
|
|
24
|
+
|
|
25
|
+
//# sourceMappingURL=storage.module.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"storage.module.js","names":[],"sources":["../../src/storage/storage.module.ts"],"sourcesContent":["import { ConfigurableModuleBuilder, Module } from '@velajs/vela';\nimport { StorageController } from './storage.controller';\nimport { StorageManagerService } from './storage-manager.service';\nimport { StorageService } from './storage.service';\nimport { STORAGE_OPTIONS } from './storage.tokens';\nimport type { StorageModuleOptions } from './storage.types';\n\nconst { ConfigurableModuleClass } = new ConfigurableModuleBuilder<StorageModuleOptions>({\n moduleName: 'Storage',\n optionsInjectionToken: STORAGE_OPTIONS,\n}).build();\n\n@Module({\n providers: [StorageManagerService, StorageService],\n controllers: [StorageController],\n exports: [StorageService, StorageManagerService, STORAGE_OPTIONS],\n})\nexport class StorageModule extends ConfigurableModuleClass {}\n"],"mappings":";;;;;;;AAOA,MAAM,EAAE,4BAA4B,IAAI,0BAAgD;CACtF,YAAY;CACZ,uBAAuB;AACzB,CAAC,CAAC,CAAC,MAAM;AAOF,IAAM,gBAAN,MAAM,sBAAsB,wBAAwB,CAAC;AAL3D,gBAAA,WAAA,CAAA,OAAO;CACN,WAAW,CAAC,uBAAuB,cAAc;CACjD,aAAa,CAAC,iBAAiB;CAC/B,SAAS;EAAC;EAAgB;EAAuB;CAAe;AAClE,CAAC,CAAA,GAAA,aAAA"}
|