diff --git a/packages/app-server/src/modules/notes/notes.repository.ts b/packages/app-server/src/modules/notes/notes.repository.ts index 73196f11..79f0c473 100644 --- a/packages/app-server/src/modules/notes/notes.repository.ts +++ b/packages/app-server/src/modules/notes/notes.repository.ts @@ -1,8 +1,10 @@ import type { Storage } from '../storage/storage.types'; import type { DatabaseNote, Note } from './notes.types'; import { injectArguments } from '@corentinth/chisels'; +import { isCustomError } from '../shared/errors/errors'; import { generateId } from '../shared/utils/random'; -import { createNoteNotFoundError } from './notes.errors'; +import { KV_VALUE_LENGTH_EXCEEDED_ERROR_CODE } from '../storage/factories/cloudflare-kv.storage'; +import { createNoteNotFoundError, createNotePayloadTooLargeError } from './notes.errors'; import { getNoteExpirationDate } from './notes.models'; export { createNoteRepository }; @@ -52,38 +54,46 @@ async function saveNote( isPublic: boolean; }, ): Promise<{ noteId: string }> { - const noteId = generateNoteId(); - const baseNote = { - payload, - deleteAfterReading, - encryptionAlgorithm, - serializationFormat, - isPublic, - }; - - if (!ttlInSeconds) { - await storage.setItem(noteId, baseNote); + try { + const noteId = generateNoteId(); + const baseNote = { + payload, + deleteAfterReading, + encryptionAlgorithm, + serializationFormat, + isPublic, + }; + + if (!ttlInSeconds) { + await storage.setItem(noteId, baseNote); + + return { noteId }; + } + + const { expirationDate } = getNoteExpirationDate({ ttlInSeconds, now }); + + await storage.setItem( + noteId, + { + ...baseNote, + expirationDate: expirationDate.toISOString(), + }, + { + // Some storage drivers have a different API for setting TTLs + ttl: ttlInSeconds, + // Cloudflare KV Binding - https://developers.cloudflare.com/kv/api/write-key-value-pairs/#create-expiring-keys + expirationTtl: ttlInSeconds, + }, + ); return { noteId }; - } + } catch (error) { + if (isCustomError(error) && error.code === KV_VALUE_LENGTH_EXCEEDED_ERROR_CODE) { + throw createNotePayloadTooLargeError(); + } - const { expirationDate } = getNoteExpirationDate({ ttlInSeconds, now }); - - await storage.setItem( - noteId, - { - ...baseNote, - expirationDate: expirationDate.toISOString(), - }, - { - // Some storage drivers have a different API for setting TTLs - ttl: ttlInSeconds, - // Cloudflare KV Binding - https://developers.cloudflare.com/kv/api/write-key-value-pairs/#create-expiring-keys - expirationTtl: ttlInSeconds, - }, - ); - - return { noteId }; + throw error; + } } async function getNoteById({ noteId, storage }: { noteId: string; storage: Storage }): Promise<{ note: Note }> { diff --git a/packages/app-server/src/modules/storage/factories/cloudflare-kv.storage.models.test.ts b/packages/app-server/src/modules/storage/factories/cloudflare-kv.storage.models.test.ts new file mode 100644 index 00000000..8625611d --- /dev/null +++ b/packages/app-server/src/modules/storage/factories/cloudflare-kv.storage.models.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from 'vitest'; +import { looksLikeCloudflare413Error } from './cloudflare-kv.storage.models'; + +describe('cloudflare-kv storage models', () => { + describe('looksLikeCloudflare413Error', () => { + test('a cloudflare 413 error starts with "KV PUT failed: 413 Value length of", everything else is not a cloudflare 413 error', () => { + expect(looksLikeCloudflare413Error({ + error: new Error('KV PUT failed: 413 Value length of 41943339 exceeds limit of 26214400.'), + })).to.eql(true); + + expect(looksLikeCloudflare413Error({ + error: new Error('KV PUT failed: 429 Too many requests.'), + })).to.eql(false); + + expect(looksLikeCloudflare413Error({ error: undefined })).to.eql(false); + expect(looksLikeCloudflare413Error({ error: 'foo' })).to.eql(false); + }); + }); +}); diff --git a/packages/app-server/src/modules/storage/factories/cloudflare-kv.storage.models.ts b/packages/app-server/src/modules/storage/factories/cloudflare-kv.storage.models.ts new file mode 100644 index 00000000..9a22486c --- /dev/null +++ b/packages/app-server/src/modules/storage/factories/cloudflare-kv.storage.models.ts @@ -0,0 +1,11 @@ +import { isError } from 'lodash-es'; + +export { looksLikeCloudflare413Error }; + +function looksLikeCloudflare413Error({ error }: { error: unknown }) { + if (isError(error)) { + return error?.message?.startsWith('KV PUT failed: 413 Value length of') ?? false; + } + + return false; +} diff --git a/packages/app-server/src/modules/storage/factories/cloudflare-kv.storage.ts b/packages/app-server/src/modules/storage/factories/cloudflare-kv.storage.ts index abfe179f..519b5857 100644 --- a/packages/app-server/src/modules/storage/factories/cloudflare-kv.storage.ts +++ b/packages/app-server/src/modules/storage/factories/cloudflare-kv.storage.ts @@ -1,8 +1,12 @@ import type { Driver } from 'unstorage'; +import { safely } from '@corentinth/chisels'; import { createStorage } from 'unstorage'; import cloudflareKVBindingDriver from 'unstorage/drivers/cloudflare-kv-binding'; import { createError } from '../../shared/errors/errors'; import { defineBindableStorageFactory } from '../storage.models'; +import { looksLikeCloudflare413Error } from './cloudflare-kv.storage.models'; + +export const KV_VALUE_LENGTH_EXCEEDED_ERROR_CODE = 'storage.kv.value_length_exceeds_limit'; export const createCloudflareKVStorageFactory = defineBindableStorageFactory(() => { return { @@ -28,7 +32,22 @@ export const createCloudflareKVStorageFactory = defineBindableStorageFactory(() // Current : https://github.com/unjs/unstorage/blob/v1.10.2/src/drivers/cloudflare-kv-binding.ts // Future : https://github.com/unjs/unstorage/blob/main/src/drivers/cloudflare-kv-binding.ts async setItem(key, value, options) { - return binding.put(key, value, options); + const [result, error] = await safely(binding.put(key, value, options)); + + if (looksLikeCloudflare413Error({ error })) { + throw createError({ + message: 'Value length exceeds limit', + code: KV_VALUE_LENGTH_EXCEEDED_ERROR_CODE, + statusCode: 413, + isInternal: true, + }); + } + + if (error) { + throw error; + } + + return result; }, }; diff --git a/packages/app-server/wrangler.toml b/packages/app-server/wrangler.toml index dc0028da..482b967a 100644 --- a/packages/app-server/wrangler.toml +++ b/packages/app-server/wrangler.toml @@ -5,3 +5,8 @@ pages_build_output_dir = "./dist" [[kv_namespaces]] binding = "notes" id = "b1329cb8560e49d392bed877c9ac48a2" + +[vars] +# Cloudfare KV value max size is 25Mib +# https://developers.cloudflare.com/kv/platform/limits/ +NOTES_MAX_ENCRYPTED_PAYLOAD_LENGTH = 26214400