diff --git a/.changeset/wet-lemons-tap.md b/.changeset/wet-lemons-tap.md new file mode 100644 index 0000000..da10f0c --- /dev/null +++ b/.changeset/wet-lemons-tap.md @@ -0,0 +1,5 @@ +--- +'cf-bindings-proxy': minor +--- + +Support for the Cache API, as well as support for transforming Request / Response / URL objects. diff --git a/README.md b/README.md index 0514c81..470bb5c 100644 --- a/README.md +++ b/README.md @@ -116,3 +116,9 @@ Note: Functionality and bindings not listed below may still work but have not be - [x] delete - [ ] createMultipartUpload (needs more tests) - [ ] resumeMultipartUpload (needs more tests) + +#### Cache API + +- [x] put +- [x] match +- [x] delete diff --git a/package.json b/package.json index 9260abd..aa8faac 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "prettier:format": "prettier --ignore-unknown --ignore-path=.gitignore --write .", "tsc": "tsc --noEmit", "test": "vitest run", - "test:kill": "rm -rf .wrangler; sudo kill -9 `sudo lsof -i :8799 -t`", + "test:kill": "rm -rf .wrangler; pkill workerd", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "alter-version": "node ./scripts/alter-version.js", diff --git a/src/cli/template/_worker.ts b/src/cli/template/_worker.ts index 339ce6a..d439357 100644 --- a/src/cli/template/_worker.ts +++ b/src/cli/template/_worker.ts @@ -1,3 +1,4 @@ +import type { CacheStorage } from '@cloudflare/workers-types'; import type { BindingRequest, BindingResponse, PropertyCall } from '../../proxy'; import type { FunctionInfo, TransformRule } from '../../transform'; import { prepareDataForProxy, transformData } from '../../transform'; @@ -39,11 +40,28 @@ export default { try { // eslint-disable-next-line @typescript-eslint/naming-convention - const { __original_call, __bindingId, __calls } = await request.json(); + const { __original_call, __proxyType, __bindingId, __calls } = + await request.json(); - const callee = __original_call - ? await reduceCalls(env[__original_call.__bindingId], __original_call.__calls) - : env[__bindingId]; + const baseId = __original_call ? __original_call.__bindingId : __bindingId; + + let base; + switch (__proxyType) { + case 'caches': { + const asCacheStorage = caches as unknown as CacheStorage; + base = baseId === 'default' ? asCacheStorage.default : await asCacheStorage.open(baseId); + break; + } + case 'binding': { + base = env[baseId]; + break; + } + default: { + throw new Error('Unknown proxy type'); + } + } + + const callee = __original_call ? await reduceCalls(base, __original_call.__calls) : base; const rawData = await reduceCalls(callee, __calls); const resp: BindingResponse = { success: true, data: rawData, functions: {} }; @@ -53,7 +71,12 @@ export default { resp.transform = transformedResp.transform; resp.data = transformedResp.data; - if (rawData && typeof rawData === 'object' && !Array.isArray(rawData)) { + if ( + rawData && + typeof rawData === 'object' && + !Array.isArray(rawData) && + ![Response, Request, URL].find((t) => rawData instanceof t) + ) { // resp.arrayBuffer() => Promise if ('arrayBuffer' in rawData && typeof rawData.arrayBuffer === 'function') { const buffer = await rawData.arrayBuffer(); diff --git a/src/index.ts b/src/index.ts index f9d979c..1130167 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,19 @@ +import type { Cache, CacheStorage } from '@cloudflare/workers-types'; import { createBindingProxy } from './proxy'; +/** + * Whether the bindings proxy is enabled and currently active. + * + * The proxy is enabled by default in development mode, but can be disabled by setting + * `DISABLE_BINDINGS_PROXY` to `true`. + * + * Alternatively, it can be enabled in other environments by setting `ENABLE_BINDINGS_PROXY` to + * `true`. + * */ +export const isProxyEnabled = () => + process?.env?.ENABLE_BINDINGS_PROXY || + (!process?.env?.DISABLE_BINDINGS_PROXY && process?.env?.NODE_ENV === 'development'); + /** * Interfaces with a binding from the environment. * @@ -19,14 +33,11 @@ import { createBindingProxy } from './proxy'; * @returns Binding value. */ export const binding = (id: string, opts?: BindingOpts): T => { - if ( - process?.env?.ENABLE_BINDINGS_PROXY || - (!process?.env?.DISABLE_BINDINGS_PROXY && process?.env?.NODE_ENV === 'development') - ) { + if (isProxyEnabled()) { return new Proxy( {}, { - get: (_, prop) => createBindingProxy(id)[prop as keyof T], + get: (_, prop) => createBindingProxy(id, { proxyType: 'binding' })[prop as keyof T], }, ) as T; } @@ -34,6 +45,49 @@ export const binding = (id: string, opts?: BindingOpts): T => { return (opts?.fallback ?? process?.env)?.[id] as T; }; +type DeriveCacheReturnType = T extends 'default' | undefined ? Cache : Promise; + +/** + * Interfaces with the Cloudflare Cache API. + * + * By default, the `default` cache is used, however, a custom cache can be provided by passing a + * cache name as the first argument. + * + * @example + * ```ts + * const value = await cacheApi().put(..., ...); + * ``` + * + * @example + * ```ts + * const value = await cacheApi('custom').put(..., ...); + * ``` + * + * @param cacheName Name of the cache to open, or `undefined` to open the default cache. + * @returns Cache instance. + */ +export const cacheApi = ( + cacheName?: T, +): DeriveCacheReturnType => { + if (isProxyEnabled()) { + return new Proxy( + {}, + { + get: (_, prop: keyof Cache) => + createBindingProxy(cacheName ?? 'default', { proxyType: 'caches' })[prop], + }, + ) as DeriveCacheReturnType; + } + + const cachesInstance = caches as unknown as CacheStorage; + + return ( + cacheName === 'default' || cacheName === undefined + ? cachesInstance.default + : cachesInstance.open(cacheName) + ) as DeriveCacheReturnType; +}; + type BindingOpts = { fallback: Record; }; diff --git a/src/proxy.ts b/src/proxy.ts index b1cb8a7..fbbc92f 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -13,7 +13,7 @@ export type BindingResponse = /** * Prepares the binding request to be sent to the proxy. * - * @param bindingRequest + * @param bindingRequest The binding request to prepare. */ const prepareBindingRequest = async (bindingRequest: BindingRequest): Promise => { return { @@ -116,8 +116,11 @@ export type PropertyCall( + proxyType: ProxyType, bindingId: string, originalProxy: BindingRequest, data: T, @@ -166,7 +170,10 @@ const createResponseProxy = ( } // eslint-disable-next-line @typescript-eslint/no-use-before-define - const newProxy = createBindingProxy(bindingId, true); + const newProxy = createBindingProxy(bindingId, { + notChainable: true, + proxyType, + }); newProxy.__original_call = originalProxy; @@ -194,6 +201,11 @@ const shouldChainUntil = (prop: string): string[] => { return []; }; +const buildDefaultBindingRequest = (__proxyType: ProxyType, __bindingId: string) => + ({ __proxyType, __bindingId, __calls: [], __chainUntil: [] } as BindingRequest); + +type CreateBindingOpts = { notChainable?: boolean; proxyType?: ProxyType }; + /** * Creates a proxy object for the binding. * @@ -201,14 +213,18 @@ const shouldChainUntil = (prop: string): string[] => { * @param notChainable Whether or not the proxy should be chainable. * @returns A proxy object. */ -export const createBindingProxy = (bindingId: string, notChainable = false): T => { - return new Proxy({ __bindingId: bindingId, __calls: [], __chainUntil: [] } as BindingRequest, { +export const createBindingProxy = ( + bindingId: string, + { notChainable = false, proxyType = 'binding' }: CreateBindingOpts = {}, +): T => { + return new Proxy(buildDefaultBindingRequest(proxyType, bindingId), { get(target, prop: string) { // internal properties if (typeof prop === 'string' && prop.startsWith('__')) return target[prop as keyof BindingRequest]; // ignore toJSON calls if (prop === 'toJSON') return undefined; + // if the current proxy is not chainable, ignore calls if (notChainable) return undefined; // ignore then calls if there are no calls yet if (target.__calls.length === 0 && prop === 'then') return undefined; @@ -221,7 +237,7 @@ export const createBindingProxy = (bindingId: string, notChainable = false): // if we haven't reached the point where we should stop chaining, return a new proxy if (target.__chainUntil.length && !target.__chainUntil.includes(prop)) { - const newProxy = createBindingProxy(bindingId); + const newProxy = createBindingProxy(bindingId, { proxyType }); newProxy.__chainUntil = target.__chainUntil; newProxy.__calls = target.__calls; @@ -237,15 +253,16 @@ export const createBindingProxy = (bindingId: string, notChainable = false): const data = await fetchData(target); - if (typeof data !== 'object' || !data) { - return data; - } - - if (Array.isArray(data)) { + if ( + typeof data !== 'object' || + !data || + Array.isArray(data) || + [URL, Request, Response].find((t) => data instanceof t) + ) { return data; } - return createResponseProxy(bindingId, target, data); + return createResponseProxy(proxyType, bindingId, target, data); }; }, }) as T; diff --git a/src/transform.ts b/src/transform.ts index 37d530b..eefcf2c 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -1,25 +1,41 @@ import type { PropertyCall } from './proxy'; -export type TransformDataType = 'buffer' | 'blob' | 'stream' | 'base64' | 'text' | 'json'; -export type TransformRawType = ArrayBuffer | Blob | string | NonNullable; +export type TransformDataType = + | 'buffer' + | 'blob' + | 'stream' + | 'base64' + | 'text' + | 'json' + | 'url' + | 'request' + | 'response'; + +export type TransformRawType = + | ArrayBuffer + | Blob + | string + | NonNullable + | URL + | Request + | Response; type ParseTransformFrom = T extends 'buffer' ? Extract - : T extends 'blob' - ? Extract - : T extends 'stream' + : T extends 'blob' | 'stream' ? Extract : T extends 'base64' ? Extract + : T extends 'text' + ? Extract + : T extends 'url' | 'request' | 'response' + ? Extract : never; export type TransformRule< From extends TransformDataType = TransformDataType, To extends ParseTransformFrom = ParseTransformFrom, -> = { - from: From; - to: To; -}; +> = { from: From; to: To }; export type ParseType = T extends 'buffer' ? ArrayBuffer @@ -31,6 +47,12 @@ export type ParseType = T extends 'buffer' ? string : T extends 'json' ? NonNullable + : T extends 'url' + ? URL + : T extends 'request' + ? Request + : T extends 'response' + ? Response : never; export type Functions = 'arrayBuffer' | 'blob' | 'json' | 'text' | 'body'; @@ -42,6 +64,19 @@ export type FunctionInfo< asAccessor?: boolean; }; +type DeserializedRequest = { + url: string; + method: string; + headers: [string, string][]; + body: string; +}; +type DeserializedResponse = { + status: number; + statusText: string; + headers: [string, string][]; + body: string; +}; + /** * Transforms data from one format to another. * @@ -118,6 +153,74 @@ export const transformData = async < writer.close(); return readable as ParseType; } + + if (transform.to === 'url') { + return new URL(data as string) as ParseType; + } + break; + } + case 'url': { + if (transform.to === 'text') { + return (data as URL).toString() as ParseType; + } + break; + } + case 'request': { + if (transform.to === 'text') { + const asReq = data as Request; + return JSON.stringify({ + url: asReq.url, + method: asReq.method, + headers: [...asReq.headers.entries()], + body: await transformData(await asReq.arrayBuffer(), { + from: 'buffer', + to: 'base64', + }), + } satisfies DeserializedRequest) as ParseType; + } + break; + } + case 'response': { + if (transform.to === 'text') { + const asResp = data as Response; + return JSON.stringify({ + status: asResp.status, + statusText: asResp.statusText, + headers: [...asResp.headers.entries()], + body: await transformData(await asResp.arrayBuffer(), { from: 'buffer', to: 'base64' }), + } satisfies DeserializedResponse) as ParseType; + } + break; + } + case 'text': { + if (transform.to === 'url') { + return new URL(data as string) as ParseType; + } + + if (transform.to === 'request') { + const deserialized = JSON.parse(data as string) as DeserializedRequest; + return new Request(deserialized.url, { + method: deserialized.method, + headers: Object.fromEntries(deserialized.headers), + body: deserialized.body + ? await transformData(deserialized.body, { from: 'base64', to: 'buffer' }) + : undefined, + }) as ParseType; + } + + if (transform.to === 'response') { + const deserialized = JSON.parse(data as string) as DeserializedResponse; + return new Response( + deserialized.body + ? await transformData(deserialized.body, { from: 'base64', to: 'buffer' }) + : undefined, + { + status: deserialized.status, + statusText: deserialized.statusText, + headers: Object.fromEntries(deserialized.headers), + }, + ) as ParseType; + } break; } default: @@ -151,6 +254,27 @@ export const prepareDataForProxy = async ( }; } + if (rawData instanceof URL) { + return { + transform: { from: 'text', to: 'url' }, + data: await transformData(rawData, { from: 'url', to: 'text' }), + }; + } + + if (rawData instanceof Request) { + return { + transform: { from: 'text', to: 'request' }, + data: await transformData(rawData, { from: 'request', to: 'text' }), + }; + } + + if (rawData instanceof Response) { + return { + transform: { from: 'text', to: 'response' }, + data: await transformData(rawData, { from: 'response', to: 'text' }), + }; + } + // NOTE: We can't use `instanceof` here as the value may not strictly be an instance of `ReadableStream`. if ( rawData !== null && diff --git a/tests/proxy.spec.ts b/tests/proxy.spec.ts index 17725fe..911940f 100644 --- a/tests/proxy.spec.ts +++ b/tests/proxy.spec.ts @@ -1,11 +1,14 @@ import { resolve } from 'path'; +import type { Response as CfResponse, Request as CfRequest } from '@cloudflare/workers-types'; import type { ColumnType, Generated } from 'kysely'; import { Kysely } from 'kysely'; import { D1Dialect } from 'kysely-d1'; import { afterAll, beforeAll, expect, suite, test } from 'vitest'; import type { UnstableDevWorker } from 'wrangler'; import { unstable_dev } from 'wrangler'; -import { binding } from '../src'; +import { binding, cacheApi } from '../src'; + +type MaybePromise = Promise | T; suite('bindings', () => { let worker: UnstableDevWorker; @@ -459,4 +462,45 @@ suite('bindings', () => { expect(kv).toBeDefined(); }); }); + + suite('cache api', () => { + const buildUrl = (path: string) => new URL(path, 'http://localhost.local'); + const buildReq = (url: string | URL) => new Request(url) as unknown as CfRequest; + const buildRes = (body: string) => + new Response(body, { + status: 200, + headers: new Headers({ 'cache-control': 'max-age=31536000' }), + }) as unknown as CfResponse; + const parseRes = async (res: MaybePromise) => (await res)?.text(); + + test('default cache -> put/match/delete', async () => { + const firstKey = buildUrl('first-key'); + await cacheApi('default').put(firstKey, buildRes('first-value')); + + const firstValue = await parseRes(cacheApi('default').match(firstKey)); + expect(firstValue).toEqual('first-value'); + + const isDeleted = await cacheApi().delete(firstKey); + expect(isDeleted).toEqual(true); + + const firstValueAfterDelete = await parseRes(cacheApi().match(firstKey)); + expect(firstValueAfterDelete).toEqual(undefined); + }); + + test('custom cache -> put/match/delete', async () => { + const defaultCache = await cacheApi('custom'); + + const firstKey = buildReq(buildUrl('first-key')); + await defaultCache.put(firstKey, buildRes('first-value')); + + const firstValue = await parseRes(defaultCache.match(firstKey)); + expect(firstValue).toEqual('first-value'); + + const isDeleted = await defaultCache.delete(firstKey); + expect(isDeleted).toEqual(true); + + const firstValueAfterDelete = await parseRes(defaultCache.match(firstKey)); + expect(firstValueAfterDelete).toEqual(undefined); + }); + }); });