From aa2f43af944c0f2a90759a6003da6feb60029ac0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Filho?= Date: Tue, 13 Jan 2026 16:09:35 -0300 Subject: [PATCH] feat: add KV storage polyfill for local development - Add Azion.KV polyfill mapping in azion-local-polyfills - Implement KVContext class with file system-based KV storage simulation - Add support for get, getMultiple, put, and delete operations - Implement metadata handling with expiration and expirationTtl support - Add support for multiple return types (text, json, arrayBuffer, stream) - Export KVContext in polyfills index for local development usage --- .../src/helpers/azion-local-polyfills.ts | 1 + .../src/polyfills/azion/kv/context/index.js | 3 + .../polyfills/azion/kv/context/kv.context.js | 149 ++++++++++++++++++ .../bundler/src/polyfills/azion/kv/index.js | 4 + .../src/polyfills/azion/kv/kv.polyfills.js | 83 ++++++++++ packages/bundler/src/polyfills/index.d.ts | 1 + packages/bundler/src/polyfills/index.js | 2 + 7 files changed, 243 insertions(+) create mode 100644 packages/bundler/src/polyfills/azion/kv/context/index.js create mode 100644 packages/bundler/src/polyfills/azion/kv/context/kv.context.js create mode 100644 packages/bundler/src/polyfills/azion/kv/index.js create mode 100644 packages/bundler/src/polyfills/azion/kv/kv.polyfills.js diff --git a/packages/bundler/src/helpers/azion-local-polyfills.ts b/packages/bundler/src/helpers/azion-local-polyfills.ts index d5dd149c..ab0f05b3 100644 --- a/packages/bundler/src/helpers/azion-local-polyfills.ts +++ b/packages/bundler/src/helpers/azion-local-polyfills.ts @@ -14,5 +14,6 @@ export default { ['Azion.env', `${externalPolyfillsPath}/env-vars/env-vars.polyfills.js`], ['Azion.networkList', `${externalPolyfillsPath}/network-list/network-list.polyfills.js`], ['Azion.Storage', `${externalPolyfillsPath}/storage/storage.polyfills.js`], + ['Azion.KV', `${externalPolyfillsPath}/kv/kv.polyfills.js`], ]), }; diff --git a/packages/bundler/src/polyfills/azion/kv/context/index.js b/packages/bundler/src/polyfills/azion/kv/context/index.js new file mode 100644 index 00000000..e1968ea6 --- /dev/null +++ b/packages/bundler/src/polyfills/azion/kv/context/index.js @@ -0,0 +1,3 @@ +import KVContext from './kv.context.js'; + +export default { KVContext }; diff --git a/packages/bundler/src/polyfills/azion/kv/context/kv.context.js b/packages/bundler/src/polyfills/azion/kv/context/kv.context.js new file mode 100644 index 00000000..b85e2315 --- /dev/null +++ b/packages/bundler/src/polyfills/azion/kv/context/kv.context.js @@ -0,0 +1,149 @@ +import fs from 'fs'; +import path from 'path'; +import { Readable } from 'stream'; + +// KVContext Simulate KV Storage API with file system + +class KVContext { + #pathKV; + #metadataPrefix; + + constructor(kvName, pathKV) { + this.kvName = kvName; + this.#pathKV = pathKV || `.edge/kv/${kvName}`; + this.#metadataPrefix = `.metadata-${kvName}.json`; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async get(key, returnType = 'text', options = {}) { + try { + const item = await fs.promises.readFile(`${this.#pathKV}/${key}`); + const metadata = await KVContext.getMetadata(this.#pathKV, key, this.#metadataPrefix); + + if (metadata?.expiration) { + const now = Math.floor(Date.now() / 1000); + if (now >= metadata.expiration) { + await this.delete(key); + return { value: null, metadata: null }; + } + } + + return { + value: await this.#convertValue(item, returnType), + metadata: metadata?.metadata || null, + }; + } catch { + return { value: null, metadata: null }; + } + } + + async getMultiple(keys, returnType = 'text', options = {}) { + const results = new Map(); + + for (const key of keys) { + const result = await this.get(key, returnType, options); + results.set(key, result); + } + + return results; + } + + async put(key, value, options = {}) { + const prefix = path.dirname(key); + await fs.promises.mkdir(`${this.#pathKV}/${prefix}`, { + recursive: true, + }); + + let fileContent; + if (value instanceof ReadableStream) { + const reader = value.getReader(); + const chunks = []; + while (true) { + const { done, value: chunk } = await reader.read(); + if (done) break; + chunks.push(chunk); + } + fileContent = Buffer.concat(chunks); + } else if (value instanceof ArrayBuffer) { + fileContent = Buffer.from(value); + } else if (typeof value === 'object') { + fileContent = JSON.stringify(value); + } else { + fileContent = String(value); + } + + await fs.promises.writeFile(`${this.#pathKV}/${key}`, fileContent); + await KVContext.putMetadata(this.#pathKV, key, options, this.#metadataPrefix); + + if (options.expirationTtl) { + setTimeout(async () => { + try { + await this.delete(key); + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-empty + } catch (error) {} + }, options.expirationTtl * 1000); + } else if (options.expiration) { + const now = Math.floor(Date.now() / 1000); + const ttl = options.expiration - now; + if (ttl > 0) { + setTimeout(async () => { + try { + await this.delete(key); + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-empty + } catch (error) {} + }, ttl * 1000); + } + } + } + + async delete(key) { + try { + await fs.promises.rm(`${this.#pathKV}/${key}`); + try { + await fs.promises.rm(`${this.#pathKV}/${key}${this.#metadataPrefix}`); + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-empty + } catch (error) {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-empty + } catch (error) {} + } + + async #convertValue(buffer, returnType) { + switch (returnType) { + case 'json': + return JSON.parse(buffer.toString()); + case 'arrayBuffer': + return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); + case 'stream': + return Readable.from(buffer); + case 'text': + default: + return buffer.toString(); + } + } + + static async putMetadata(pathKV, key, options, metadataPrefix) { + const bodyMetadata = { + metadata: options?.metadata || null, + expiration: options?.expiration || null, + expirationTtl: options?.expirationTtl || null, + }; + await fs.promises.writeFile(`${pathKV}/${key}${metadataPrefix}`, JSON.stringify(bodyMetadata, undefined, 2)); + return bodyMetadata; + } + + static async getMetadata(pathKV, key, metadataPrefix) { + try { + let data = await fs.promises.readFile(`${pathKV}/${key}${metadataPrefix}`); + data = JSON.parse(data.toString()); + return { + metadata: data?.metadata || null, + expiration: data?.expiration || null, + expirationTtl: data?.expirationTtl || null, + }; + } catch { + return { metadata: null }; + } + } +} + +export default KVContext; diff --git a/packages/bundler/src/polyfills/azion/kv/index.js b/packages/bundler/src/polyfills/azion/kv/index.js new file mode 100644 index 00000000..a28caee3 --- /dev/null +++ b/packages/bundler/src/polyfills/azion/kv/index.js @@ -0,0 +1,4 @@ +import KVContext from './context/kv.context.js'; +import KV from './kv.polyfills.js'; + +export { KV, KVContext }; diff --git a/packages/bundler/src/polyfills/azion/kv/kv.polyfills.js b/packages/bundler/src/polyfills/azion/kv/kv.polyfills.js new file mode 100644 index 00000000..c5a6090e --- /dev/null +++ b/packages/bundler/src/polyfills/azion/kv/kv.polyfills.js @@ -0,0 +1,83 @@ +/* eslint-disable */ + +/** + * KV Storage API Polyfill + * This polyfill is referenced in #build/bundlers/polyfills/polyfills-manager.js + * + * @example + * + * const kv = new KV("mystore"); + * const value = await kv.get("mykey"); + */ + +class KV { + #kvName; + + constructor(kvName) { + if (typeof kvName !== 'string') { + throw new Error('kvName must be a string'); + } + this.#kvName = kvName; + } + + static async open(name) { + if (typeof name !== 'string') { + throw new Error('name must be a string'); + } + return new KV(name); + } + + async get(key, returnType, options) { + if (Array.isArray(key)) { + return this.#getMultiple(key, returnType, options); + } + + const actualReturnType = options?.type || returnType || 'text'; + const result = await new KV_CONTEXT(this.#kvName).get(key, actualReturnType, options); + return result.value; + } + + async getWithMetadata(key, returnType, options) { + if (Array.isArray(key)) { + return this.#getMultipleWithMetadata(key, returnType, options); + } + + const actualReturnType = options?.type || returnType || 'text'; + return await new KV_CONTEXT(this.#kvName).get(key, actualReturnType, options); + } + + async #getMultiple(keys, returnType, options) { + const actualReturnType = options?.type || returnType || 'text'; + const results = await new KV_CONTEXT(this.#kvName).getMultiple(keys, actualReturnType, options); + + const valueMap = new Map(); + for (const [key, result] of results.entries()) { + valueMap.set(key, result.value); + } + return valueMap; + } + + async #getMultipleWithMetadata(keys, returnType, options) { + const actualReturnType = options?.type || returnType || 'text'; + return await new KV_CONTEXT(this.#kvName).getMultiple(keys, actualReturnType, options); + } + + async put(key, value, options) { + if (typeof key !== 'string') { + throw new Error('key must be a string'); + } + return await new KV_CONTEXT(this.#kvName).put(key, value, options || {}); + } + + async delete(key) { + if (typeof key !== 'string') { + throw new Error('key must be a string'); + } + return await new KV_CONTEXT(this.#kvName).delete(key); + } +} + +export default KV; + +globalThis.Azion = globalThis.Azion || {}; +globalThis.Azion.KV = KV; diff --git a/packages/bundler/src/polyfills/index.d.ts b/packages/bundler/src/polyfills/index.d.ts index 7b43519e..7c0b0159 100644 --- a/packages/bundler/src/polyfills/index.d.ts +++ b/packages/bundler/src/polyfills/index.d.ts @@ -14,4 +14,5 @@ declare module 'azion/bundler/polyfills' { export const streamContext: any; export const cryptoContext: any; export const promisesContext: any; + export const KVContext: any; } diff --git a/packages/bundler/src/polyfills/index.js b/packages/bundler/src/polyfills/index.js index 26131e71..1ff4ef23 100644 --- a/packages/bundler/src/polyfills/index.js +++ b/packages/bundler/src/polyfills/index.js @@ -3,6 +3,7 @@ import EnvVarsContext from './azion/env-vars/index.js'; import FetchEventContext from './azion/fetch-event/index.js'; import fetchContext from './azion/fetch/index.js'; import FirewallEventContext from './azion/firewall-event/index.js'; +import { KVContext } from './azion/kv/index.js'; import NetworkListContext from './azion/network-list/index.js'; import { StorageContext } from './azion/storage/index.js'; import cryptoContext from './crypto/index.js'; @@ -20,6 +21,7 @@ export { FetchEventContext, FirewallEventContext, fsContext, + KVContext, NetworkListContext, promisesContext, StorageContext,