diff --git a/.changeset/blob-eve-extension.md b/.changeset/blob-eve-extension.md new file mode 100644 index 000000000..4891a2f75 --- /dev/null +++ b/.changeset/blob-eve-extension.md @@ -0,0 +1,11 @@ +--- +"@vercel/blob": minor +--- + +Add `@vercel/blob/eve` Eve extension with tools for listing, reading, uploading, deleting, copying, renaming, and creating folders in a Blob store. + +The destructive tools `del` and `rename` are gated on human approval (`approval: always()`) and pause the run until a person responds. Consumers can relax this with a directory mount override. + +`zod` is now an optional peer dependency (`^4`) rather than a runtime dependency, alongside the existing optional `eve` peer. Both must be installed to use the `@vercel/blob/eve` subpath; neither is needed by the core SDK. + +`CreateFolderCommandOptions` is widened to include the `storeId` and `oidcToken` auth options, so `createFolder` now declares the same Vercel OIDC authentication options as the other Blob commands. These options were already forwarded at runtime; this is a type-only, backward-compatible change. diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 5c1aabfd1..ece3b5eb8 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -68,9 +68,13 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v5 + # Pinned ahead of `.node-version` (lts/iron) on purpose: publint validates + # the published artifact, and building `@vercel/blob`'s `./eve` extension + # entry points requires Node >=24. On older versions that build is skipped, + # which would leave publint checking exports that were never emitted. - uses: actions/setup-node@v6 with: - node-version-file: ".node-version" + node-version: 24 cache: "pnpm" - name: Install dependencies diff --git a/packages/blob/README.md b/packages/blob/README.md index e2ce51810..f2ed4b479 100644 --- a/packages/blob/README.md +++ b/packages/blob/README.md @@ -31,6 +31,75 @@ We have examples on the vercel.com documentation, there are two ways to upload f 1. [Server uploads](https://vercel.com/docs/vercel-blob/server-upload): This is the most common way to upload files. The file is first sent to your server and then to Vercel Blob. It's straightforward to implement, but you are limited to the request body your server can handle. Which in case of a Vercel-hosted website is 4.5 MB. **This means you can't upload files larger than 4.5 MB on Vercel when using this method.** 2. [Client uploads](https://vercel.com/docs/vercel-blob/client-upload): This is a more advanced solution for when you need to upload larger files. The file is securely sent directly from the client (a browser for example) to Vercel Blob. This requires a bit more work to implement, but it allows you to upload files up to 5 TB. +## Eve extension + +Mount Vercel Blob tools in an [Eve](https://eve.dev) agent. + +`eve` and `zod` are optional peer dependencies: they are only needed for the +`@vercel/blob/eve` subpath, and the consuming agent supplies both. + +```sh +pnpm add @vercel/blob eve zod +``` + +```ts +// agent/extensions/blob.ts +import blob from '@vercel/blob/eve'; + +export default blob({ + token: process.env.BLOB_READ_WRITE_TOKEN, +}); +``` + +The extension declares a config schema, so its default export is a mount +factory that must be called — even when every field is left out. Pass an empty +object to let the tools fall back to `BLOB_READ_WRITE_TOKEN`, `BLOB_STORE_ID`, +and `VERCEL_OIDC_TOKEN` from the environment: + +```ts +// agent/extensions/blob.ts +import blob from '@vercel/blob/eve'; + +export default blob({}); +``` + +The bare `export { default } from '@vercel/blob/eve'` form only works for +extensions that declare no config, and will not type-check here. + +Mounted tools include `list`, `head`, `get`, `put`, `del`, `copy`, `rename`, and `create_folder`. + +### Approval for destructive tools + +`del` and `rename` ship with `approval: always()`, so each call pauses the run +and waits for a person before it executes. To relax that, use a directory mount +and override the tool in a same-named slot: + +``` +agent/extensions/blob/ + extension.ts + tools/del.ts +``` + +```ts +// agent/extensions/blob/extension.ts +import blob from '@vercel/blob/eve'; + +export default blob({}); +``` + +```ts +// agent/extensions/blob/tools/del.ts +import { del } from '@vercel/blob/eve/tools'; +import { defineTool } from 'eve/tools'; +import { never } from 'eve/tools/approval'; + +export default defineTool({ ...del, approval: never() }); +``` + +The directory name is still the mount namespace, so the tool stays `blob__del`. +See [Eve extensions](https://eve.dev/docs/extensions) for mount namespaces and overrides, +and [human-in-the-loop](https://eve.dev/docs/human-in-the-loop) for the approval helpers. + ## Releasing Make sure to include a changeset in your PR. You can do this by running: diff --git a/packages/blob/eve/__tests__/get.node.test.ts b/packages/blob/eve/__tests__/get.node.test.ts new file mode 100644 index 000000000..35fe09e19 --- /dev/null +++ b/packages/blob/eve/__tests__/get.node.test.ts @@ -0,0 +1,243 @@ +/** + * Branching in the `get` tool: what gets inlined into the model's context, + * what gets omitted, and how misses / conditional requests are reported. + */ + +import type { Interceptable, MockAgent } from 'undici'; +import { + BLOB_STORE_BASE_URL, + getTool, + setupMockAgent, + toolContext, +} from './harness'; + +const LAST_MODIFIED = 'Thu, 04 May 2023 15:12:07 GMT'; +const MAX_INLINE_TEXT_BYTES = 64 * 1024; + +type GetOutput = Awaited> & + Record; + +describe('get tool', () => { + let mockAgent: MockAgent; + let storeClient: Interceptable; + + beforeEach(() => { + mockAgent = setupMockAgent(); + storeClient = mockAgent.get(BLOB_STORE_BASE_URL); + }); + + function replyWith( + body: string, + contentType: string, + { size = body.length }: { size?: number } = {}, + ): void { + storeClient + .intercept({ path: '/foo.txt', method: 'GET' }) + .reply(200, body, { + headers: { + 'content-type': contentType, + 'content-length': String(size), + 'last-modified': LAST_MODIFIED, + etag: '"abc123"', + 'cache-control': 'public, max-age=31536000', + 'content-disposition': 'inline; filename="foo.txt"', + }, + }); + } + + async function run( + input: Partial[0]> = {}, + ): Promise { + return (await getTool.execute( + { + urlOrPathname: `${BLOB_STORE_BASE_URL}/foo.txt`, + access: 'public', + ...input, + }, + toolContext(), + )) as GetOutput; + } + + it('inlines small text payloads', async () => { + replyWith('hello world', 'text/plain'); + + const result = await run(); + + expect(result.text).toBe('hello world'); + expect(result.inlineTextOmitted).toBeUndefined(); + expect(result.statusCode).toBe(200); + expect(result.contentType).toBe('text/plain'); + expect(result.size).toBe(11); + expect(result.url).toBe(`${BLOB_STORE_BASE_URL}/foo.txt`); + expect(result.downloadUrl).toBe( + `${BLOB_STORE_BASE_URL}/foo.txt?download=1`, + ); + expect(result.pathname).toBe('foo.txt'); + expect(result.etag).toBe('"abc123"'); + // eve tool output has to be JSON-serialisable, so this must be a string. + expect(typeof result.uploadedAt).toBe('string'); + expect(result.uploadedAt).toBe(new Date(LAST_MODIFIED).toISOString()); + }); + + it('inlines small JSON payloads', async () => { + replyWith('{"a":1}', 'application/json'); + + const result = await run(); + + expect(result.text).toBe('{"a":1}'); + expect(result.inlineTextOmitted).toBeUndefined(); + }); + + // A content-type header may carry parameters and is case-insensitive, so the + // media type has to be matched on its own rather than compared verbatim. + it.each([ + ['application/json; charset=utf-8', '{"a":1}'], + ['application/json;charset=UTF-8', '{"a":1}'], + ['text/plain; charset=utf-8', 'hello'], + ['Application/JSON', '{"a":1}'], + ['TEXT/PLAIN', 'hello'], + ['application/ld+json', '{"@id":"x"}'], + ])('inlines %s', async (contentType, body) => { + replyWith(body, contentType); + + const result = await run(); + + expect(result.text).toBe(body); + expect(result.inlineTextOmitted).toBeUndefined(); + }); + + it.each([ + ['image/png'], + ['application/octet-stream'], + ['application/json-seq'], + ])('still omits %s', async (contentType) => { + replyWith('not inlined', contentType); + + const result = await run(); + + expect(result.text).toBeUndefined(); + expect(result.inlineTextOmitted).toBe(true); + }); + + it('inlines a text payload sitting exactly on the 64 KiB ceiling', async () => { + const body = 'a'.repeat(MAX_INLINE_TEXT_BYTES); + replyWith(body, 'text/plain'); + + const result = await run(); + + expect(result.size).toBe(MAX_INLINE_TEXT_BYTES); + expect(result.text).toBe(body); + expect(result.inlineTextOmitted).toBeUndefined(); + }); + + /** Replies without a `content-length` header, as a chunked response would. */ + function replyWithoutContentLength(body: string, contentType: string): void { + storeClient + .intercept({ path: '/foo.txt', method: 'GET' }) + .reply(200, body, { + headers: { + 'content-type': contentType, + 'last-modified': LAST_MODIFIED, + etag: '"abc123"', + 'cache-control': 'public, max-age=31536000', + 'content-disposition': 'inline; filename="foo.txt"', + }, + }); + } + + // Regression: `blob.size` is populated from `content-length`, which `get()` + // reports as 0 when the header is absent. Gating inlining on that value alone + // let a body of any size straight into the model's context. + it('omits an oversized body that arrives without content-length', async () => { + replyWithoutContentLength('a'.repeat(200 * 1024), 'text/plain'); + + const result = await run(); + + expect(result.size).toBe(0); + expect(result.text).toBeUndefined(); + expect(result.inlineTextOmitted).toBe(true); + }); + + it('still inlines a small body that arrives without content-length', async () => { + replyWithoutContentLength('hello world', 'text/plain'); + + const result = await run(); + + expect(result.size).toBe(0); + expect(result.text).toBe('hello world'); + expect(result.inlineTextOmitted).toBeUndefined(); + }); + + it('counts multi-byte characters as bytes, not code units', async () => { + // 'é' is 2 bytes in UTF-8, so this is ~128 KiB on the wire while + // `String.length` reports only 64 KiB worth of code units. + replyWithoutContentLength('é'.repeat(MAX_INLINE_TEXT_BYTES), 'text/plain'); + + const result = await run(); + + expect(result.text).toBeUndefined(); + expect(result.inlineTextOmitted).toBe(true); + }); + + it('omits text one byte over the 64 KiB ceiling', async () => { + const body = 'a'.repeat(MAX_INLINE_TEXT_BYTES + 1); + replyWith(body, 'text/plain'); + + const result = await run(); + + expect(result.size).toBe(MAX_INLINE_TEXT_BYTES + 1); + expect(result.text).toBeUndefined(); + expect(result.inlineTextOmitted).toBe(true); + // Metadata still comes back so the model can hand off the URL. + expect(result.url).toBe(`${BLOB_STORE_BASE_URL}/foo.txt`); + expect(result.contentType).toBe('text/plain'); + }); + + it('omits text for a non-inlineable content type even when small', async () => { + replyWith('‰PNG fake bytes', 'image/png'); + + const result = await run(); + + expect(result.text).toBeUndefined(); + expect(result.inlineTextOmitted).toBe(true); + expect(result.contentType).toBe('image/png'); + expect(result.size).toBeLessThan(MAX_INLINE_TEXT_BYTES); + }); + + it('reports a miss as found: false rather than throwing', async () => { + storeClient.intercept({ path: '/foo.txt', method: 'GET' }).reply(404, ''); + + await expect(run()).resolves.toEqual({ found: false }); + }); + + it('reports a 304 as notModified with the etag and no body', async () => { + storeClient.intercept({ path: '/foo.txt', method: 'GET' }).reply(304, '', { + headers: { + etag: '"abc123"', + 'last-modified': LAST_MODIFIED, + }, + }); + + const result = await run({ ifNoneMatch: '"abc123"' }); + + expect(result).toEqual({ + statusCode: 304, + notModified: true, + etag: '"abc123"', + }); + }); + + it('sends the If-None-Match header supplied by the model', async () => { + let sentHeaders: Record = {}; + storeClient + .intercept({ path: '/foo.txt', method: 'GET' }) + .reply(200, (req) => { + sentHeaders = req.headers as Record; + return 'hello'; + }); + + await run({ ifNoneMatch: '"etag-from-model"' }); + + expect(sentHeaders['If-None-Match']).toBe('"etag-from-model"'); + }); +}); diff --git a/packages/blob/eve/__tests__/harness.ts b/packages/blob/eve/__tests__/harness.ts new file mode 100644 index 000000000..bd3868e7c --- /dev/null +++ b/packages/blob/eve/__tests__/harness.ts @@ -0,0 +1,181 @@ +/** + * Test harness for the `@vercel/blob/eve` extension. + * + * The extension source is authored for the eve bundler: it uses explicit `.js` + * specifiers on relative imports and pulls the eve runtime in from an + * ESM-only package. Jest's CJS pipeline can do neither out of the box, and the + * package's `jest.config.cjs` is shared with `src/`, so both gaps are bridged + * here instead: + * + * 1. `.js` specifiers are re-pointed at the real TypeScript sources with + * virtual mocks. Nothing is stubbed -- the factories return the genuine + * modules, so tests exercise the real Blob SDK over mocked HTTP. + * 2. The three `eve/*` entry points the extension imports are replaced with + * faithful ports of eve 0.27's runtime behaviour (see each factory). + * + * `jest.mock` is hoisted above the imports below by ts-jest, so importing this + * module is enough to register everything. Tests should import tools *from + * here* rather than reaching into `../tools/*` directly, which keeps the mocks + * guaranteed to be registered first. + */ + +import type { ToolContext } from 'eve/tools'; +import { MockAgent, setGlobalDispatcher } from 'undici'; + +// --- eve runtime shims ------------------------------------------------------ + +// Mirrors `defineTool`: returns the definition object untouched (the real one +// only stamps non-enumerable brand symbols onto it). +jest.mock('eve/tools', () => ({ + defineTool: (definition: unknown) => definition, +})); + +// Mirrors `eve/tools/approval`: each helper returns the decision callback the +// runtime invokes before a tool call. +jest.mock('eve/tools/approval', () => ({ + always: () => () => 'user-approval', + never: () => () => 'not-applicable', + once: + () => + ({ + approvedTools, + toolName, + }: { + approvedTools: Set; + toolName: string; + }) => + approvedTools.has(toolName) ? 'not-applicable' : 'user-approval', +})); + +// Mirrors `defineExtension`'s unmounted path: with no mount call, `config` +// validates `{}` against the declared schema and returns the parsed value. +// Every field on the Blob extension's schema is optional, so this yields `{}` +// and the SDK falls back to `BLOB_READ_WRITE_TOKEN` from the environment. +jest.mock('eve/extension', () => ({ + defineExtension: (definition: { config?: unknown } | undefined) => { + const schema = definition?.config as + | { + '~standard': { + validate: ( + value: unknown, + ) => + | { value?: unknown; issues?: readonly { message: string }[] } + | Promise; + }; + } + | undefined; + + return { + schema, + get config(): Record { + if (schema === undefined) return {}; + + const result = schema['~standard'].validate({}); + if (result instanceof Promise) { + throw new Error( + 'Extension config must validate synchronously; the config schema uses async validation, which is not supported at mount.', + ); + } + if (result.issues !== undefined) { + throw new Error( + `Invalid extension config: ${result.issues.map((issue) => issue.message).join('; ')}`, + ); + } + + return result.value as Record; + }, + }; + }, +})); + +// --- `.js` specifier bridges ------------------------------------------------ + +jest.mock('../extension.js', () => require('../extension'), { virtual: true }); +jest.mock('../lib/options.js', () => require('../lib/options'), { + virtual: true, +}); +jest.mock('../../src/api.js', () => require('../../src/api'), { + virtual: true, +}); +jest.mock('../../src/copy.js', () => require('../../src/copy'), { + virtual: true, +}); +jest.mock( + '../../src/create-folder.js', + () => require('../../src/create-folder'), + { + virtual: true, + }, +); +jest.mock('../../src/del.js', () => require('../../src/del'), { + virtual: true, +}); +jest.mock('../../src/get.js', () => require('../../src/get'), { + virtual: true, +}); +jest.mock('../../src/head.js', () => require('../../src/head'), { + virtual: true, +}); +jest.mock('../../src/index.js', () => require('../../src/index'), { + virtual: true, +}); +jest.mock('../../src/list.js', () => require('../../src/list'), { + virtual: true, +}); +jest.mock('../../src/rename.js', () => require('../../src/rename'), { + virtual: true, +}); + +// --- tools under test ------------------------------------------------------- + +export { default as copyTool } from '../tools/copy'; +export { default as createFolderTool } from '../tools/create_folder'; +export { default as delTool } from '../tools/del'; +export { default as getTool } from '../tools/get'; +export { default as headTool } from '../tools/head'; +export { default as listTool } from '../tools/list'; +export { default as putTool } from '../tools/put'; +export { default as renameTool } from '../tools/rename'; + +// --- shared test utilities -------------------------------------------------- + +/** The API host every non-`get` command talks to. */ +export const BLOB_API_URL_AGENT = 'https://vercel.com'; + +/** + * Store host used for direct `get` downloads. Deliberately lowercase: undici + * normalises request hostnames, so a mixed-case origin would never match. + */ +export const BLOB_STORE_BASE_URL = + 'https://storeid.public.blob.vercel-storage.com'; + +export const READ_WRITE_TOKEN = + 'vercel_blob_rw_12345fakeStoreId_30FakeRandomCharacters12345678'; + +/** + * `execute` only ever reads `ctx.abortSignal`, but `ToolContext` also requires + * `session`, `callId`, `toolName`, `getSandbox` and `getSkill`. Rather than + * fabricating those, cast a minimal object -- once, here. + */ +export function toolContext(abortSignal?: AbortSignal): ToolContext { + return { + abortSignal: abortSignal ?? new AbortController().signal, + } as unknown as ToolContext; +} + +/** + * Installs a fresh undici `MockAgent` as the global dispatcher and resets the + * Blob environment variables, matching `src/index.node.test.ts`. + */ +export function setupMockAgent(): MockAgent { + delete process.env.BLOB_STORE_ID; + delete process.env.VERCEL_OIDC_TOKEN; + process.env.BLOB_READ_WRITE_TOKEN = READ_WRITE_TOKEN; + process.env.VERCEL_BLOB_RETRIES = '0'; + + const mockAgent = new MockAgent(); + mockAgent.disableNetConnect(); + setGlobalDispatcher(mockAgent); + + return mockAgent; +} diff --git a/packages/blob/eve/__tests__/schema.node.test.ts b/packages/blob/eve/__tests__/schema.node.test.ts new file mode 100644 index 000000000..136d185c9 --- /dev/null +++ b/packages/blob/eve/__tests__/schema.node.test.ts @@ -0,0 +1,161 @@ +/** + * Input-schema guards and approval wiring. These are pure-validation tests -- + * no HTTP is involved, and nothing here should ever reach the network. + */ + +import { + copyTool, + createFolderTool, + delTool, + getTool, + headTool, + listTool, + putTool, + renameTool, +} from './harness'; + +/** `approval` is optional on a tool definition, so read it structurally. */ +function approvalOf(tool: unknown): unknown { + return (tool as { approval?: unknown }).approval; +} + +interface ParseIssue { + path: PropertyKey[]; + message: string; +} + +interface ParseResult { + success: boolean; + data?: unknown; + error?: { issues: ParseIssue[] }; +} + +/** + * `inputSchema` is surfaced by eve's types as a standard-schema value, which + * hides zod's `safeParse`. The runtime object is the zod schema the tool + * declared, so reach through the standard-schema facade to call it. + */ +function safeParse(schema: unknown, input: unknown): ParseResult { + return (schema as { safeParse: (value: unknown) => ParseResult }).safeParse( + input, + ); +} + +const BLOB_URL = 'https://storeid.public.blob.vercel-storage.com/foo.txt'; +const OTHER_BLOB_URL = 'https://storeid.public.blob.vercel-storage.com/bar.txt'; + +describe('del inputSchema', () => { + it('rejects ifMatch combined with more than one target', () => { + const result = safeParse(delTool.inputSchema, { + urlOrPathname: [BLOB_URL, OTHER_BLOB_URL], + ifMatch: '"abc123"', + }); + + expect(result.success).toBe(false); + expect(result.error?.issues).toHaveLength(1); + expect(result.error?.issues[0]?.path).toEqual(['ifMatch']); + expect(result.error?.issues[0]?.message).toBe( + 'ifMatch can only be used when deleting a single blob. Pass exactly one urlOrPathname, or omit ifMatch.', + ); + }); + + it('accepts ifMatch with exactly one target', () => { + const result = safeParse(delTool.inputSchema, { + urlOrPathname: [BLOB_URL], + ifMatch: '"abc123"', + }); + + expect(result.success).toBe(true); + expect(result.data).toEqual({ + urlOrPathname: [BLOB_URL], + ifMatch: '"abc123"', + }); + }); + + it('accepts many targets when ifMatch is omitted', () => { + const result = safeParse(delTool.inputSchema, { + urlOrPathname: [BLOB_URL, OTHER_BLOB_URL, 'baz.txt'], + }); + + expect(result.success).toBe(true); + }); + + it('still rejects an empty target list', () => { + expect(safeParse(delTool.inputSchema, { urlOrPathname: [] }).success).toBe( + false, + ); + }); +}); + +describe('put inputSchema', () => { + const base = { + pathname: 'foo.txt', + body: 'hello', + access: 'public' as const, + }; + + it('rejects ifMatch combined with allowOverwrite: false', () => { + const result = safeParse(putTool.inputSchema, { + ...base, + ifMatch: '"abc123"', + allowOverwrite: false, + }); + + expect(result.success).toBe(false); + expect(result.error?.issues).toHaveLength(1); + expect(result.error?.issues[0]?.path).toEqual(['allowOverwrite']); + expect(result.error?.issues[0]?.message).toBe( + 'ifMatch performs a conditional overwrite, so it cannot be combined with allowOverwrite: false. Omit allowOverwrite, or set it to true.', + ); + }); + + it('accepts ifMatch with allowOverwrite: true', () => { + expect( + safeParse(putTool.inputSchema, { + ...base, + ifMatch: '"abc123"', + allowOverwrite: true, + }).success, + ).toBe(true); + }); + + it('accepts ifMatch when allowOverwrite is omitted', () => { + expect( + safeParse(putTool.inputSchema, { ...base, ifMatch: '"abc123"' }).success, + ).toBe(true); + }); + + it('accepts allowOverwrite: false when ifMatch is omitted', () => { + expect( + safeParse(putTool.inputSchema, { ...base, allowOverwrite: false }) + .success, + ).toBe(true); + }); +}); + +describe('approval wiring', () => { + // Destructive tools must ask. This is a deliberate product decision, so pin + // it: widening or dropping it should not slip through unnoticed. + it.each([ + ['del', delTool], + ['rename', renameTool], + ])('%s requires user approval', (_name, tool) => { + const approval = approvalOf(tool); + + expect(approval).toBeDefined(); + expect(typeof approval).toBe('function'); + // `always()` from eve/tools/approval resolves to 'user-approval'. + expect((approval as (ctx: unknown) => unknown)({})).toBe('user-approval'); + }); + + it.each([ + ['put', putTool], + ['copy', copyTool], + ['get', getTool], + ['head', headTool], + ['list', listTool], + ['create_folder', createFolderTool], + ])('%s does not require approval', (_name, tool) => { + expect(approvalOf(tool)).toBeUndefined(); + }); +}); diff --git a/packages/blob/eve/__tests__/tools.node.test.ts b/packages/blob/eve/__tests__/tools.node.test.ts new file mode 100644 index 000000000..f7cdcc8f4 --- /dev/null +++ b/packages/blob/eve/__tests__/tools.node.test.ts @@ -0,0 +1,329 @@ +/** + * `head` error handling, `list` shaping, and the abort-signal forwarding that + * lets a cancelled turn cancel the in-flight Blob request. + */ + +import type { Interceptable, MockAgent } from 'undici'; +import { BlobRequestAbortedError } from '../../src/api'; +import { + BLOB_API_URL_AGENT, + BLOB_STORE_BASE_URL, + copyTool, + createFolderTool, + delTool, + getTool, + headTool, + listTool, + putTool, + renameTool, + setupMockAgent, + toolContext, +} from './harness'; + +const UPLOADED_AT = '2023-05-04T15:12:07.818Z'; + +const mockedFileMeta = { + url: `${BLOB_STORE_BASE_URL}/foo-id.txt`, + downloadUrl: `${BLOB_STORE_BASE_URL}/foo-id.txt?download=1`, + size: 12345, + uploadedAt: UPLOADED_AT, + pathname: 'foo.txt', + contentType: 'text/plain', + contentDisposition: 'attachment; filename="foo.txt"', + etag: '"abc123"', +}; + +describe('head tool', () => { + let mockAgent: MockAgent; + let apiClient: Interceptable; + + beforeEach(() => { + mockAgent = setupMockAgent(); + apiClient = mockAgent.get(BLOB_API_URL_AGENT); + }); + + async function run(): Promise> { + return (await headTool.execute( + { urlOrPathname: `${BLOB_STORE_BASE_URL}/foo-id.txt` }, + toolContext(), + )) as Record; + } + + it('returns metadata with uploadedAt as an ISO string', async () => { + apiClient + .intercept({ path: () => true, method: 'GET' }) + .reply(200, mockedFileMeta); + + const result = await run(); + + expect(result).toEqual({ + url: mockedFileMeta.url, + downloadUrl: mockedFileMeta.downloadUrl, + pathname: 'foo.txt', + size: 12345, + contentType: 'text/plain', + contentDisposition: 'attachment; filename="foo.txt"', + cacheControl: undefined, + uploadedAt: UPLOADED_AT, + etag: '"abc123"', + }); + expect(typeof result.uploadedAt).toBe('string'); + }); + + it('reports a missing blob as found: false', async () => { + apiClient + .intercept({ path: () => true, method: 'GET' }) + .reply(404, { error: { code: 'not_found', message: 'Not found' } }); + + await expect(run()).resolves.toEqual({ found: false }); + }); + + // The `catch` in head must only swallow BlobNotFoundError. If it ever widens + // to a bare `return { found: false }`, an auth failure would be reported to + // the model as "the blob does not exist". + it('still throws on a non-404 failure', async () => { + apiClient + .intercept({ path: () => true, method: 'GET' }) + .reply(403, { error: { code: 'forbidden' } }); + + await expect(run()).rejects.toThrow( + 'Vercel Blob: Access denied, please provide a valid token for this resource.', + ); + }); + + it('still throws on a server error', async () => { + apiClient + .intercept({ path: () => true, method: 'GET' }) + .reply(500, 'Invalid token'); + + await expect(run()).rejects.toThrow( + 'Vercel Blob: Unknown error, please visit https://vercel.com/help.', + ); + }); +}); + +describe('list tool', () => { + let mockAgent: MockAgent; + let apiClient: Interceptable; + + beforeEach(() => { + mockAgent = setupMockAgent(); + apiClient = mockAgent.get(BLOB_API_URL_AGENT); + }); + + const listedBlob = { + url: mockedFileMeta.url, + downloadUrl: mockedFileMeta.downloadUrl, + pathname: mockedFileMeta.pathname, + size: mockedFileMeta.size, + uploadedAt: UPLOADED_AT, + etag: mockedFileMeta.etag, + }; + + async function run( + input: Record = {}, + ): Promise> { + return (await listTool.execute( + input as Parameters[0], + toolContext(), + )) as Record; + } + + it('surfaces folders in folded mode', async () => { + let path: string | null = null; + apiClient + .intercept({ path: () => true, method: 'GET' }) + .reply(200, (req) => { + path = req.path; + return { + blobs: [listedBlob], + folders: ['foo/', 'bar/'], + hasMore: false, + }; + }); + + const result = await run({ mode: 'folded' }); + + expect(result.folders).toEqual(['foo/', 'bar/']); + expect(path).toBe('/api/blob?mode=folded'); + }); + + it('leaves folders undefined in expanded mode', async () => { + apiClient.intercept({ path: () => true, method: 'GET' }).reply(200, { + blobs: [listedBlob], + cursor: 'cursor-123', + hasMore: true, + }); + + const result = await run({ mode: 'expanded' }); + + expect(result.folders).toBeUndefined(); + expect(result.cursor).toBe('cursor-123'); + expect(result.hasMore).toBe(true); + }); + + // eve tool output must be JSON-serialisable; the SDK hands back a Date. + it('converts uploadedAt to an ISO string', async () => { + apiClient + .intercept({ path: () => true, method: 'GET' }) + .reply(200, { blobs: [listedBlob], hasMore: false }); + + const result = await run(); + const blobs = result.blobs as Record[]; + + expect(blobs).toHaveLength(1); + expect(blobs[0]?.uploadedAt).toBe(UPLOADED_AT); + expect(typeof blobs[0]?.uploadedAt).toBe('string'); + expect(blobs[0]?.uploadedAt).not.toBeInstanceOf(Date); + expect(blobs[0]).toEqual({ + url: listedBlob.url, + downloadUrl: listedBlob.downloadUrl, + pathname: 'foo.txt', + size: 12345, + uploadedAt: UPLOADED_AT, + etag: '"abc123"', + }); + }); +}); + +/** + * The point of threading `ctx.abortSignal` through `blobCommandOptions` is that + * cancelling a turn cancels the HTTP request. Each case below registers an + * interceptor that *would* succeed, so dropping the signal turns these from + * rejections into resolutions. + */ +describe('abortSignal forwarding', () => { + let mockAgent: MockAgent; + let apiClient: Interceptable; + + beforeEach(() => { + mockAgent = setupMockAgent(); + apiClient = mockAgent.get(BLOB_API_URL_AGENT); + }); + + type ToolRun = (ctx: ReturnType) => Promise; + + const apiBackedTools: [string, ToolRun][] = [ + [ + 'put', + async (ctx) => + putTool.execute( + { pathname: 'foo.txt', body: 'hello', access: 'public' }, + ctx, + ), + ], + [ + 'copy', + async (ctx) => + copyTool.execute( + { + fromUrlOrPathname: 'foo.txt', + toPathname: 'bar.txt', + access: 'public', + }, + ctx, + ), + ], + [ + 'rename', + async (ctx) => + renameTool.execute( + { + fromUrlOrPathname: 'foo.txt', + toPathname: 'bar.txt', + access: 'public', + }, + ctx, + ), + ], + [ + 'del', + async (ctx) => + delTool.execute( + { urlOrPathname: [`${BLOB_STORE_BASE_URL}/foo.txt`] }, + ctx, + ), + ], + [ + 'head', + async (ctx) => headTool.execute({ urlOrPathname: 'foo.txt' }, ctx), + ], + ['list', async (ctx) => listTool.execute({}, ctx)], + [ + 'create_folder', + async (ctx) => createFolderTool.execute({ pathname: 'reports/' }, ctx), + ], + ]; + + it.each( + apiBackedTools, + )('%s aborts its request when the turn is cancelled', async (_name, run) => { + apiClient + .intercept({ path: () => true, method: () => true }) + .reply(200, mockedFileMeta) + .delay(500) + .persist(); + + const controller = new AbortController(); + const promise = run(toolContext(controller.signal)); + controller.abort(); + + await expect(promise).rejects.toThrow(BlobRequestAbortedError); + }); + + it('list resolves normally when the turn is not cancelled', async () => { + apiClient + .intercept({ path: () => true, method: 'GET' }) + .reply(200, { blobs: [], hasMore: false }); + + await expect( + Promise.resolve(listTool.execute({}, toolContext())), + ).resolves.toMatchObject({ hasMore: false }); + }); + + // `get` streams straight from the store host rather than going through + // src/api.ts, so it surfaces the raw fetch abort instead of BlobRequestAbortedError. + it('get aborts its download when the turn is cancelled', async () => { + const storeClient = mockAgent.get(BLOB_STORE_BASE_URL); + storeClient + .intercept({ path: '/foo.txt', method: 'GET' }) + .reply(200, 'hello', { headers: { 'content-type': 'text/plain' } }) + .delay(500) + .persist(); + + const controller = new AbortController(); + const promise = Promise.resolve( + getTool.execute( + { + urlOrPathname: `${BLOB_STORE_BASE_URL}/foo.txt`, + access: 'public', + }, + toolContext(controller.signal), + ), + ); + controller.abort(); + + await expect(promise).rejects.toThrow(/abort/i); + }); + + it('get resolves normally when the turn is not cancelled', async () => { + const storeClient = mockAgent.get(BLOB_STORE_BASE_URL); + storeClient + .intercept({ path: '/foo.txt', method: 'GET' }) + .reply(200, 'hello', { + headers: { 'content-type': 'text/plain', 'content-length': '5' }, + }); + + await expect( + Promise.resolve( + getTool.execute( + { + urlOrPathname: `${BLOB_STORE_BASE_URL}/foo.txt`, + access: 'public', + }, + toolContext(), + ), + ), + ).resolves.toMatchObject({ text: 'hello' }); + }); +}); diff --git a/packages/blob/eve/extension.ts b/packages/blob/eve/extension.ts new file mode 100644 index 000000000..b6820ff29 --- /dev/null +++ b/packages/blob/eve/extension.ts @@ -0,0 +1,19 @@ +import { defineExtension } from 'eve/extension'; +import { z } from 'zod'; + +export default defineExtension({ + config: z.object({ + token: z + .string() + .optional() + .describe( + 'Blob read-write token. Defaults to BLOB_READ_WRITE_TOKEN when omitted.', + ), + storeId: z + .string() + .optional() + .describe( + 'Blob store id for Vercel OIDC auth. Defaults to BLOB_STORE_ID when omitted.', + ), + }), +}); diff --git a/packages/blob/eve/instructions.md b/packages/blob/eve/instructions.md new file mode 100644 index 000000000..bbe5e7564 --- /dev/null +++ b/packages/blob/eve/instructions.md @@ -0,0 +1,12 @@ +# Vercel Blob + +Use these tools to manage files in a Vercel Blob store: + +- Prefer `list` with a `prefix` to explore folders before uploading or deleting. +- Use `head` to read metadata without downloading content. +- Use `get` for small text or JSON blobs; large or binary files return URLs only. +- `put` uploads UTF-8 text. For binary uploads, use the Blob SDK or client uploads in application code. +- `del` accepts blob URLs or pathnames. Confirm the target before deleting production assets. +- `del` and `rename` pause for human approval, so they will not complete silently. +- `copy` and `rename` require `access` (`public` or `private`) like `put`. For `create_folder` it is optional and defaults to `public`. +- An existing blob's access level is in its URL hostname: `.public.blob.vercel-storage.com` or `.private.blob.vercel-storage.com`. Read it from there instead of guessing the `access` argument for `get`. diff --git a/packages/blob/eve/lib/options.ts b/packages/blob/eve/lib/options.ts new file mode 100644 index 000000000..b14a638d8 --- /dev/null +++ b/packages/blob/eve/lib/options.ts @@ -0,0 +1,19 @@ +import type { ToolContext } from 'eve/tools'; + +import type { BlobCommandOptions } from '../../src/helpers.js'; + +import extension from '../extension.js'; + +/** + * Store credentials from the extension config plus the turn's abort signal, so + * that cancelling a turn also cancels the in-flight Blob HTTP request. + */ +export function blobCommandOptions(ctx: ToolContext): BlobCommandOptions { + const { token, storeId } = extension.config; + + return { + abortSignal: ctx.abortSignal, + ...(token !== undefined ? { token } : {}), + ...(storeId !== undefined ? { storeId } : {}), + }; +} diff --git a/packages/blob/eve/tools/copy.ts b/packages/blob/eve/tools/copy.ts new file mode 100644 index 000000000..57a7702d2 --- /dev/null +++ b/packages/blob/eve/tools/copy.ts @@ -0,0 +1,57 @@ +import { defineTool } from 'eve/tools'; +import { z } from 'zod'; + +import { copy } from '../../src/copy.js'; +import { blobCommandOptions } from '../lib/options.js'; + +export default defineTool({ + description: 'Copy a blob to a new pathname in the same store.', + inputSchema: z.object({ + fromUrlOrPathname: z.string().describe('Source blob URL or pathname.'), + toPathname: z + .string() + .describe('Destination pathname, including file extension.'), + access: z.enum(['public', 'private']).describe('Access for the new blob.'), + addRandomSuffix: z + .boolean() + .optional() + .describe( + 'Append a random suffix to the destination pathname to avoid collisions.', + ), + allowOverwrite: z + .boolean() + .optional() + .describe( + 'Allow replacing an existing blob at the destination pathname.', + ), + contentType: z + .string() + .optional() + .describe( + 'Media type for the copied blob. Defaults from the destination pathname extension.', + ), + cacheControlMaxAge: z + .number() + .int() + .positive() + .optional() + .describe('Cache lifetime in seconds for the copied blob.'), + }), + async execute(input, ctx) { + const { fromUrlOrPathname, toPathname, access, ...options } = input; + const result = await copy(fromUrlOrPathname, toPathname, { + access, + ...options, + ...blobCommandOptions(ctx), + }); + + return { + url: result.url, + downloadUrl: result.downloadUrl, + pathname: result.pathname, + contentType: result.contentType, + contentDisposition: result.contentDisposition, + etag: result.etag, + }; + }, +}); diff --git a/packages/blob/eve/tools/create_folder.ts b/packages/blob/eve/tools/create_folder.ts new file mode 100644 index 000000000..77dd2785a --- /dev/null +++ b/packages/blob/eve/tools/create_folder.ts @@ -0,0 +1,30 @@ +import { defineTool } from 'eve/tools'; +import { z } from 'zod'; + +import { createFolder } from '../../src/create-folder.js'; +import { blobCommandOptions } from '../lib/options.js'; + +export default defineTool({ + description: + 'Create a folder marker in the store. Folder pathnames end with a trailing slash.', + inputSchema: z.object({ + pathname: z + .string() + .describe('Folder pathname, for example reports/ or reports/2026/.'), + access: z + .enum(['public', 'private']) + .optional() + .describe('Folder access. Defaults to public.'), + }), + async execute({ pathname, access }, ctx) { + const result = await createFolder(pathname, { + ...blobCommandOptions(ctx), + ...(access !== undefined ? { access } : {}), + }); + + return { + pathname: result.pathname, + url: result.url, + }; + }, +}); diff --git a/packages/blob/eve/tools/del.ts b/packages/blob/eve/tools/del.ts new file mode 100644 index 000000000..89d2c1cbf --- /dev/null +++ b/packages/blob/eve/tools/del.ts @@ -0,0 +1,41 @@ +import { defineTool } from 'eve/tools'; +import { always } from 'eve/tools/approval'; +import { z } from 'zod'; + +import { del } from '../../src/del.js'; +import { blobCommandOptions } from '../lib/options.js'; + +export default defineTool({ + description: 'Delete one or more blobs from the store by URL or pathname.', + inputSchema: z + .object({ + urlOrPathname: z + .array(z.string()) + .min(1) + .describe('Blob URLs or pathnames to delete.'), + ifMatch: z + .string() + .optional() + .describe( + 'Delete only when the blob etag matches. Only valid for a single target.', + ), + }) + .refine( + (input) => + input.ifMatch === undefined || input.urlOrPathname.length === 1, + { + error: + 'ifMatch can only be used when deleting a single blob. Pass exactly one urlOrPathname, or omit ifMatch.', + path: ['ifMatch'], + }, + ), + approval: always(), + async execute({ urlOrPathname, ifMatch }, ctx) { + await del(urlOrPathname, { + ...blobCommandOptions(ctx), + ...(ifMatch !== undefined ? { ifMatch } : {}), + }); + + return { deleted: urlOrPathname }; + }, +}); diff --git a/packages/blob/eve/tools/get.ts b/packages/blob/eve/tools/get.ts new file mode 100644 index 000000000..bc38625e7 --- /dev/null +++ b/packages/blob/eve/tools/get.ts @@ -0,0 +1,147 @@ +import { defineTool } from 'eve/tools'; +import { z } from 'zod'; + +import { get } from '../../src/get.js'; +import { blobCommandOptions } from '../lib/options.js'; + +// Inlined text is spent straight out of the model's context window, so keep the +// ceiling small: 64 KiB is roughly 16k tokens. +const MAX_INLINE_TEXT_BYTES = 64 * 1024; + +/** + * Reads `stream` as UTF-8, or returns `null` when the body exceeds `maxBytes`. + * + * The size reported on the blob comes from `content-length`, which `get()` + * records as `0` when the header is absent (chunked or streamed responses, more + * likely on `useCache: false` origin reads). Gating only on that value would let + * a body of any size through, so the real byte count is enforced here while + * reading. Reading incrementally also avoids buffering an unbounded body into + * memory just to reject it. + */ +async function readTextWithinBudget( + stream: ReadableStream, + maxBytes: number, +): Promise { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + total += value.byteLength; + + if (total > maxBytes) { + await reader.cancel(); + return null; + } + + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const merged = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.byteLength; + } + + return new TextDecoder().decode(merged); +} + +export default defineTool({ + description: + 'Download a blob by URL or pathname. Returns inline text for text or JSON payloads up to 64 KiB; otherwise returns metadata and URLs only.', + inputSchema: z.object({ + urlOrPathname: z.string().describe('Blob URL or pathname to download.'), + access: z.enum(['public', 'private']).describe('Blob access level.'), + useCache: z + .boolean() + .optional() + .describe('When false, bypass CDN cache and read from origin.'), + ifNoneMatch: z + .string() + .optional() + .describe('Skip download when the blob etag still matches.'), + }), + async execute({ urlOrPathname, access, useCache, ifNoneMatch }, ctx) { + const result = await get(urlOrPathname, { + ...blobCommandOptions(ctx), + access, + ...(useCache !== undefined ? { useCache } : {}), + ...(ifNoneMatch !== undefined ? { ifNoneMatch } : {}), + }); + + if (!result) { + return { found: false }; + } + + if (result.statusCode === 304) { + return { + statusCode: 304, + notModified: true, + etag: result.blob.etag, + }; + } + + const { stream, blob } = result; + const base = { + statusCode: result.statusCode, + url: blob.url, + downloadUrl: blob.downloadUrl, + pathname: blob.pathname, + contentDisposition: blob.contentDisposition, + cacheControl: blob.cacheControl, + uploadedAt: blob.uploadedAt.toISOString(), + etag: blob.etag, + contentType: blob.contentType, + size: blob.size, + }; + + // A content-type header carries optional parameters and is case-insensitive + // (`application/json; charset=utf-8`, `Text/Plain`), so match on the bare + // media type rather than the raw header value. The `+json` suffix covers + // structured JSON types such as `application/ld+json`. + const mediaType = blob.contentType.split(';')[0].trim().toLowerCase(); + + const isInlineableType = + mediaType.startsWith('text/') || + mediaType === 'application/json' || + mediaType.endsWith('+json'); + + // Fast path: when content-length already reports an oversized body, skip + // the download entirely. Cancel the stream so the connection is released. + if (!isInlineableType || blob.size > MAX_INLINE_TEXT_BYTES) { + await stream.cancel().catch(() => undefined); + + return { + ...base, + text: undefined, + inlineTextOmitted: true, + }; + } + + const text = await readTextWithinBudget(stream, MAX_INLINE_TEXT_BYTES); + + if (text === null) { + return { + ...base, + text: undefined, + inlineTextOmitted: true, + }; + } + + return { + ...base, + text, + }; + }, +}); diff --git a/packages/blob/eve/tools/head.ts b/packages/blob/eve/tools/head.ts new file mode 100644 index 000000000..d35848d1b --- /dev/null +++ b/packages/blob/eve/tools/head.ts @@ -0,0 +1,38 @@ +import { defineTool } from 'eve/tools'; +import { z } from 'zod'; + +import { BlobNotFoundError } from '../../src/api.js'; +import { head } from '../../src/head.js'; +import { blobCommandOptions } from '../lib/options.js'; + +export default defineTool({ + description: + 'Fetch metadata for a single blob by URL or pathname without downloading its contents. Returns found: false when the blob does not exist.', + inputSchema: z.object({ + urlOrPathname: z.string().describe('Blob URL or pathname to inspect.'), + }), + async execute({ urlOrPathname }, ctx) { + try { + const result = await head(urlOrPathname, blobCommandOptions(ctx)); + + return { + url: result.url, + downloadUrl: result.downloadUrl, + pathname: result.pathname, + size: result.size, + contentType: result.contentType, + contentDisposition: result.contentDisposition, + cacheControl: result.cacheControl, + uploadedAt: result.uploadedAt.toISOString(), + etag: result.etag, + }; + } catch (error) { + // Mirror get: a missing blob is a recoverable result, not an error turn. + if (error instanceof BlobNotFoundError) { + return { found: false }; + } + + throw error; + } + }, +}); diff --git a/packages/blob/eve/tools/list.ts b/packages/blob/eve/tools/list.ts new file mode 100644 index 000000000..0b9f43c57 --- /dev/null +++ b/packages/blob/eve/tools/list.ts @@ -0,0 +1,56 @@ +import { defineTool } from 'eve/tools'; +import { z } from 'zod'; + +import { list } from '../../src/list.js'; +import { blobCommandOptions } from '../lib/options.js'; + +export default defineTool({ + description: + 'List blobs in the Vercel Blob store. Returns blob metadata, pagination cursor, and optional folder names in folded mode.', + inputSchema: z.object({ + limit: z + .number() + .int() + .positive() + .max(1000) + .optional() + .describe('Maximum blobs to return. Defaults to 1000.'), + prefix: z + .string() + .optional() + .describe('Only include blobs whose pathname starts with this prefix.'), + cursor: z + .string() + .optional() + .describe('Pagination cursor from a previous list response.'), + mode: z + .enum(['expanded', 'folded']) + .optional() + .describe( + 'expanded lists every blob; folded groups blobs inside folders.', + ), + }), + async execute(input, ctx) { + const result = await list({ + ...input, + ...blobCommandOptions(ctx), + }); + + return { + blobs: result.blobs.map((blob) => ({ + url: blob.url, + downloadUrl: blob.downloadUrl, + pathname: blob.pathname, + size: blob.size, + uploadedAt: blob.uploadedAt.toISOString(), + etag: blob.etag, + })), + cursor: result.cursor, + hasMore: result.hasMore, + folders: + 'folders' in result && result.folders !== undefined + ? result.folders + : undefined, + }; + }, +}); diff --git a/packages/blob/eve/tools/put.ts b/packages/blob/eve/tools/put.ts new file mode 100644 index 000000000..4a7b854c9 --- /dev/null +++ b/packages/blob/eve/tools/put.ts @@ -0,0 +1,74 @@ +import { defineTool } from 'eve/tools'; +import { z } from 'zod'; + +// `src/put.ts` only exports the `createPutMethod` factory, not a ready-made +// `put`, so this deliberately uses the package entry point rather than +// re-deriving the method and duplicating its `allowedOptions` list. +import { put } from '../../src/index.js'; +import { blobCommandOptions } from '../lib/options.js'; + +export default defineTool({ + description: + 'Upload UTF-8 text to Vercel Blob. For binary or large files, use the Blob SDK or client uploads.', + inputSchema: z + .object({ + pathname: z + .string() + .describe('Destination pathname, including file extension.'), + body: z.string().describe('UTF-8 text content to upload.'), + access: z.enum(['public', 'private']).describe('Blob access level.'), + contentType: z + .string() + .optional() + .describe('Media type. Defaults from the pathname extension.'), + addRandomSuffix: z + .boolean() + .optional() + .describe('Append a random suffix to avoid pathname collisions.'), + allowOverwrite: z + .boolean() + .optional() + .describe('Allow replacing an existing blob at the same pathname.'), + cacheControlMaxAge: z + .number() + .int() + .positive() + .optional() + .describe( + 'Cache lifetime in seconds for the uploaded blob. Minimum 60. Defaults to one month.', + ), + ifMatch: z + .string() + .optional() + .describe( + 'Overwrite only when the existing blob etag matches. Implies allowOverwrite.', + ), + }) + // `createPutHeaders` throws on this combination, so reject it at validation + // time where the model gets an actionable message instead. + .refine( + (input) => input.ifMatch === undefined || input.allowOverwrite !== false, + { + error: + 'ifMatch performs a conditional overwrite, so it cannot be combined with allowOverwrite: false. Omit allowOverwrite, or set it to true.', + path: ['allowOverwrite'], + }, + ), + async execute(input, ctx) { + const { pathname, body, access, ...options } = input; + const result = await put(pathname, body, { + access, + ...options, + ...blobCommandOptions(ctx), + }); + + return { + url: result.url, + downloadUrl: result.downloadUrl, + pathname: result.pathname, + contentType: result.contentType, + contentDisposition: result.contentDisposition, + etag: result.etag, + }; + }, +}); diff --git a/packages/blob/eve/tools/rename.ts b/packages/blob/eve/tools/rename.ts new file mode 100644 index 000000000..02dfc444c --- /dev/null +++ b/packages/blob/eve/tools/rename.ts @@ -0,0 +1,62 @@ +import { defineTool } from 'eve/tools'; +import { always } from 'eve/tools/approval'; +import { z } from 'zod'; + +import { rename } from '../../src/rename.js'; +import { blobCommandOptions } from '../lib/options.js'; + +export default defineTool({ + description: + 'Rename or move a blob to a new pathname. Copies to the destination then deletes the source.', + inputSchema: z.object({ + fromUrlOrPathname: z.string().describe('Source blob URL or pathname.'), + toPathname: z + .string() + .describe('Destination pathname, including file extension.'), + access: z + .enum(['public', 'private']) + .describe('Access for the renamed blob.'), + addRandomSuffix: z + .boolean() + .optional() + .describe( + 'Append a random suffix to the destination pathname to avoid collisions.', + ), + allowOverwrite: z + .boolean() + .optional() + .describe( + 'Allow replacing an existing blob at the destination pathname.', + ), + contentType: z + .string() + .optional() + .describe( + 'Media type for the renamed blob. Defaults from the destination pathname extension.', + ), + cacheControlMaxAge: z + .number() + .int() + .positive() + .optional() + .describe('Cache lifetime in seconds for the renamed blob.'), + }), + approval: always(), + async execute(input, ctx) { + const { fromUrlOrPathname, toPathname, access, ...options } = input; + const result = await rename(fromUrlOrPathname, toPathname, { + access, + ...options, + ...blobCommandOptions(ctx), + }); + + return { + url: result.url, + downloadUrl: result.downloadUrl, + pathname: result.pathname, + contentType: result.contentType, + contentDisposition: result.contentDisposition, + etag: result.etag, + }; + }, +}); diff --git a/packages/blob/package.json b/packages/blob/package.json index 664524291..1ffa6b1e4 100644 --- a/packages/blob/package.json +++ b/packages/blob/package.json @@ -22,6 +22,14 @@ "./client": { "import": "./dist/client.js", "require": "./dist/client.cjs" + }, + "./eve": { + "types": "./dist/eve/index.d.ts", + "default": "./dist/eve/index.mjs" + }, + "./eve/tools": { + "types": "./dist/eve/tools/index.d.ts", + "default": "./dist/eve/tools/index.mjs" } }, "main": "./dist/index.cjs", @@ -35,6 +43,12 @@ "*": { "client": [ "dist/client.d.ts" + ], + "eve": [ + "dist/eve/index.d.ts" + ], + "eve/tools": [ + "dist/eve/tools/index.d.ts" ] } }, @@ -42,11 +56,12 @@ "dist" ], "scripts": { - "build": "tsup && pnpm run copy-shims", + "build": "tsup && pnpm run copy-shims && pnpm run build:eve", + "build:eve": "node scripts/restore-package-exports.mjs", "copy-shims": "cp src/*-browser.js dist/", "dev": "pnpm run copy-shims && tsup --watch --clean=false", "check": "biome check", - "prepublishOnly": "pnpm run build", + "prepublishOnly": "tsup && pnpm run copy-shims && node scripts/restore-package-exports.mjs --require-eve", "publint": "npx publint", "test": "pnpm run test:node && pnpm run test:edge && pnpm run test:browser", "test:browser": "jest --env jsdom .browser.test.ts --setupFilesAfterEnv ./jest/setup.js", @@ -62,17 +77,37 @@ "throttleit": "^2.1.0", "undici": "^6.23.0" }, + "peerDependencies": { + "eve": "*", + "zod": "^4" + }, + "peerDependenciesMeta": { + "eve": { + "optional": true + }, + "zod": { + "optional": true + } + }, "devDependencies": { "@edge-runtime/jest-environment": "4.0.0", "@edge-runtime/types": "4.0.0", "@types/async-retry": "1.4.9", "@types/jest": "30.0.0", "@types/node": "24.12.2", + "eve": "0.27.1", "jest": "30.3.0", "jest-environment-jsdom": "30.3.0", "ts-jest": "29.4.9", "tsconfig": "workspace:*", - "tsup": "8.5.1" + "tsup": "8.5.1", + "zod": "4.4.3" + }, + "eve": { + "extension": { + "source": "./eve", + "dist": "./dist/eve/extension" + } }, "engines": { "node": ">=20.0.0" diff --git a/packages/blob/scripts/restore-package-exports.mjs b/packages/blob/scripts/restore-package-exports.mjs new file mode 100644 index 000000000..62af9ff55 --- /dev/null +++ b/packages/blob/scripts/restore-package-exports.mjs @@ -0,0 +1,231 @@ +/** + * Runs `eve extension build` and repairs the damage it does to package.json. + * + * `eve extension build` rewrites the `exports` field in place, assuming it owns + * the whole package: it repoints `.` at the extension entry (`./dist/eve/...`) + * and injects a top-level `./tools` entry. For `@vercel/blob` that is wrong -- + * `.` and `./client` are the real package entry points, and the extension is + * published under the `./eve` namespace instead. + * + * This script therefore: + * 1. snapshots package.json, + * 2. runs `eve extension build`, + * 3. merges eve's generated entries back under the `./eve` prefix while + * restoring every entry the package itself declares, + * 4. restores the snapshot verbatim if anything goes wrong. + * + * The single source of truth for the package's own exports is package.json + * itself -- nothing here is hardcoded, so adding a new export only ever + * requires editing package.json. + */ + +import { spawn } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const packageDir = join(dirname(fileURLToPath(import.meta.url)), '..'); +const packageJsonPath = join(packageDir, 'package.json'); + +/** + * Minimum Node version the eve CLI supports. The core SDK supports Node >=20 + * (see `engines`), and CI exercises 20.x and 22.x, so the extension build is + * skipped rather than failing the whole build on those versions. `publint` and + * `prepublishOnly` both run on >=24, so the published artifact always contains + * the extension -- see `assertEveBuildable` below. + */ +const MIN_EVE_NODE_MAJOR = 24; + +const nodeMajor = Number(process.versions.node.split('.')[0]); + +if (nodeMajor < MIN_EVE_NODE_MAJOR) { + // `--require-eve` is passed by `prepublishOnly`: a publish must never produce + // a tarball whose `./eve` exports point at files that were never built. + if (process.argv.includes('--require-eve')) { + console.error( + `[restore-package-exports] eve extension build requires Node >=${MIN_EVE_NODE_MAJOR}, but this is ${process.version}.`, + ); + console.error( + '[restore-package-exports] Refusing to publish @vercel/blob without the ./eve entry points.', + ); + process.exit(1); + } + + console.log( + `[restore-package-exports] skipping eve extension build: requires Node >=${MIN_EVE_NODE_MAJOR}, running ${process.version}. The core dist is unaffected.`, + ); + process.exit(0); +} + +/** Namespace the extension is published under. */ +const EVE_PREFIX = './eve'; +/** Any export target below this directory is considered eve-generated. */ +const EVE_DIST_PREFIX = './dist/eve/'; + +/** + * Maps a key from eve's generated `exports` onto the subpath we publish it as. + * `.` -> `./eve`, `./tools` -> `./eve/tools`, and so on for anything eve adds + * in the future. + */ +function toPublishedSubpath(key) { + return key === '.' ? EVE_PREFIX : `${EVE_PREFIX}${key.slice(1)}`; +} + +/** True for subpaths we already publish the extension under (`./eve`, `./eve/...`). */ +function isPublishedEveSubpath(key) { + return key === EVE_PREFIX || key.startsWith(`${EVE_PREFIX}/`); +} + +/** True when every target in an export entry points into `./dist/eve/`. */ +function isEveGenerated(entry) { + const targets = []; + const collect = (value) => { + if (typeof value === 'string') { + targets.push(value); + } else if (value && typeof value === 'object') { + for (const nested of Object.values(value)) collect(nested); + } + }; + collect(entry); + return ( + targets.length > 0 && + targets.every((target) => target.startsWith(EVE_DIST_PREFIX)) + ); +} + +/** + * Rebuilds the `exports` map: the package's own entries win, and eve's + * generated entries are re-homed under `./eve`. + * + * @param original `exports` as committed in package.json (source of truth). + * @param built `exports` as left behind by `eve extension build`. + */ +function mergeExports(original, built) { + // Everything the package declares that eve did not generate. This drops the + // clobbered `.` (repointed at ./dist/eve) and keeps `.`/`./client` from the + // snapshot below. + const own = Object.fromEntries( + Object.entries(original).filter(([, entry]) => !isEveGenerated(entry)), + ); + + // Eve emits its entry points at the package root (`.`, `./tools`, ...), so + // re-home them under `./eve`. Keys already in that namespace are ones a + // previous run of this script wrote and eve passed through untouched -- + // skipping them keeps us from nesting `./eve/eve` on every build. + const generated = Object.fromEntries( + Object.entries(built) + .filter( + ([key, entry]) => isEveGenerated(entry) && !isPublishedEveSubpath(key), + ) + .map(([key, entry]) => [toPublishedSubpath(key), entry]), + ); + + // Preserve the committed key order, then append anything newly generated. + const merged = {}; + for (const key of Object.keys(original)) { + if (key in own) merged[key] = own[key]; + else if (key in generated) merged[key] = generated[key]; + } + for (const [key, entry] of Object.entries(generated)) { + if (!(key in merged)) merged[key] = entry; + } + return merged; +} + +function serialize(packageJson) { + return `${JSON.stringify(packageJson, null, 2)}\n`; +} + +function runEveBuild() { + return new Promise((resolve, reject) => { + const child = spawn('eve', ['extension', 'build'], { + cwd: packageDir, + stdio: 'inherit', + shell: process.platform === 'win32', + }); + child.on('error', reject); + child.on('close', (code, signal) => { + if (signal) reject(new Error(`eve extension build killed by ${signal}`)); + else resolve(code ?? 0); + }); + }); +} + +const snapshot = await readFile(packageJsonPath, 'utf8'); +const original = JSON.parse(snapshot); + +// The snapshot is our only record of the package's own entry points, so refuse +// to build on top of a package.json that eve already clobbered -- otherwise we +// would treat the damage as the source of truth and bake it in. +{ + const exportsField = original.exports ?? {}; + const damage = []; + if (exportsField['.'] && isEveGenerated(exportsField['.'])) { + damage.push('the "." export points into ./dist/eve'); + } + if (exportsField['./tools']) { + damage.push('a top-level "./tools" export is present'); + } + if (damage.length > 0) { + console.error( + `[restore-package-exports] package.json looks like the output of a previous failed build (${damage.join('; ')}).`, + ); + console.error( + '[restore-package-exports] Restore it first (git checkout -- package.json), then re-run the build.', + ); + process.exit(1); + } +} + +/** Puts package.json back exactly as we found it. */ +async function restoreSnapshot() { + const current = await readFile(packageJsonPath, 'utf8').catch(() => null); + if (current !== snapshot) await writeFile(packageJsonPath, snapshot); +} + +// If we are interrupted while eve owns package.json, still put it back. +let interrupted = false; +for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { + process.on(signal, () => { + if (interrupted) return; + interrupted = true; + try { + // Synchronous on purpose: async work is not guaranteed to run here. + writeFileSync(packageJsonPath, snapshot); + } catch { + /* best effort */ + } + process.exit(1); + }); +} + +let exitCode = 0; +try { + exitCode = await runEveBuild(); + if (exitCode !== 0) { + throw new Error(`eve extension build exited with code ${exitCode}`); + } + + const built = JSON.parse(await readFile(packageJsonPath, 'utf8')); + const merged = mergeExports(original.exports ?? {}, built.exports ?? {}); + + if (!Object.keys(merged).some((key) => key.startsWith(EVE_PREFIX))) { + throw new Error( + 'eve extension build produced no ./dist/eve exports; refusing to write a package.json without the extension entry points', + ); + } + + // Rebase on the snapshot so any *other* field eve touched is reverted too. + // Always write: the file on disk is eve's clobbered version at this point, + // so "unchanged versus the snapshot" still means "needs writing back". + await writeFile(packageJsonPath, serialize({ ...original, exports: merged })); +} catch (error) { + await restoreSnapshot(); + console.error( + `[restore-package-exports] ${error instanceof Error ? error.message : error}`, + ); + console.error('[restore-package-exports] package.json restored unchanged.'); + process.exit(exitCode || 1); +} diff --git a/packages/blob/src/create-folder.ts b/packages/blob/src/create-folder.ts index 7c4187542..5d48097a1 100644 --- a/packages/blob/src/create-folder.ts +++ b/packages/blob/src/create-folder.ts @@ -1,15 +1,11 @@ import { requestApi } from './api'; -import type { BlobAccessType, CommonCreateBlobOptions } from './helpers'; -import { BlobError } from './helpers'; +import type { BlobAccessType, BlobCommandOptions } from './helpers'; import { type PutBlobApiResponse, putOptionHeaderMap } from './put-helpers'; -export type CreateFolderCommandOptions = Pick< - CommonCreateBlobOptions, - 'token' | 'abortSignal' -> & { +export interface CreateFolderCommandOptions extends BlobCommandOptions { /** @defaultValue 'public' — kept for backward compatibility */ access?: BlobAccessType; -}; +} export interface CreateFolderResult { pathname: string; @@ -21,7 +17,12 @@ export interface CreateFolderResult { * * Use the resulting `url` to delete the folder, just like you would delete a blob. * @param pathname - Can be user1/ or user1/avatars/ - * @param options - Additional options including required `access` ('public' or 'private') and optional `token` + * @param options - Configuration options including: + * - access - (Optional) Must be 'public' or 'private'. Determines the access level of the folder. Defaults to 'public' for backward compatibility. + * - token - (Optional) Read-write token when not using Vercel OIDC token for authentication, or set `BLOB_READ_WRITE_TOKEN`. + * - oidcToken - (Optional) Vercel OIDC token for authentication with `storeId` (or `BLOB_STORE_ID`); overrides `VERCEL_OIDC_TOKEN`. + * - storeId - (Optional) Store id when using Vercel OIDC token for authentication; overrides `BLOB_STORE_ID`. + * - abortSignal - (Optional) AbortSignal to cancel the operation. */ // access defaults to 'public' for backward compatibility with callers // that don't pass options (pre-private-storage API) diff --git a/packages/blob/tsconfig.json b/packages/blob/tsconfig.json index 226905bae..60967dd85 100644 --- a/packages/blob/tsconfig.json +++ b/packages/blob/tsconfig.json @@ -1,4 +1,8 @@ { "extends": "tsconfig/base.json", - "include": ["src"] + "compilerOptions": { + "moduleResolution": "bundler", + "module": "ESNext" + }, + "include": ["src", "eve"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96d58854b..ceeb2b7ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: '@types/node': specifier: 24.12.2 version: 24.12.2 + eve: + specifier: 0.27.1 + version: 0.27.1(@opentelemetry/api@1.7.0)(@vercel/blob@2.6.1)(ai@7.0.37(zod@4.4.3))(chokidar@4.0.3)(dotenv@17.2.3) jest: specifier: 30.3.0 version: 30.3.0(@types/node@24.12.2)(ts-node@10.9.2(@types/node@24.12.2)(typescript@5.9.3)) @@ -87,6 +90,9 @@ importers: tsup: specifier: 8.5.1 version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.3)(yaml@2.8.2) + zod: + specifier: 4.4.3 + version: 4.4.3 packages/edge-config: dependencies: @@ -269,6 +275,22 @@ importers: packages: + '@ai-sdk/gateway@4.0.28': + resolution: {integrity: sha512-ee9TsNO3mkgHDWmTmJ8Fvltr6PFh52zhrV2+FaKJY3F3iu7kWZi5tCRJCR1HVhhlpj6lHniUb28nLQdPHkA/1Q==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@5.0.12': + resolution: {integrity: sha512-bbhlOgHeYwrIGheLkM6fhS8hVger8uFPmcOLg+kxc9EFh7y30XYorWhthlYAgpadO3SJhFZrIcEknN7qEqEVvA==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@4.0.3': + resolution: {integrity: sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw==} + engines: {node: '>=22'} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -605,15 +627,24 @@ packages: resolution: {integrity: sha512-NKBGBSIKUG584qrS1tyxVpX/AKJKQw5HgjYEnPLC0QsTw79JrGn+qUr8CXFb955Iy7GUdiiUv1rJ6JBGvaKb6w==} engines: {node: '>=18'} + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/runtime@1.8.1': resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.27.2': resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} @@ -1086,6 +1117,12 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@next/env@16.1.4': resolution: {integrity: sha512-gkrXnZyxPUy0Gg6SrPQPccbNVLSP3vmW8LU5dwEttEEC1RwDivk8w4O+sZIjFvPrSICXyhQDCG+y3VmjlJf+9A==} @@ -1212,6 +1249,9 @@ packages: resolution: {integrity: sha512-AdY5wvN0P2vXBi3b29hxZgSFvdhdxPB9+f0B6s//P9Q8nibRWeA3cHm8UmLpio9ABigkVHJ5NMPk+Mz8VCCyrw==} engines: {node: '>=8.0.0'} + '@oxc-project/types@0.140.0': + resolution: {integrity: sha512-h5LUOzGArYemnW1NMz/DuuQhBi96J6JL2Bk8zE4kvqxB5Sg3jxmCiH4uyOWHDkiKSt5vWlG4FIwCR/DbstcNRQ==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1229,6 +1269,104 @@ packages: resolution: {integrity: sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ==} engines: {node: '>=18'} + '@rolldown/binding-android-arm64@1.2.0': + resolution: {integrity: sha512-9yB1l95IrJuNGDFdOYe79vdApdz6WWBCObE+rQ2LUliYUlcyFwSYIb2xb5/Ifw7dAtMy2ZqNyd8QTSOc7duAKw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.0': + resolution: {integrity: sha512-pexNaW9ACLUOaBITOpU6qVu4VrsOFIjTv6bzgu0YUATo4eUJx0V605PxwZfndpPOn0ilqGqvGQ0M8UW0IE24jg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.0': + resolution: {integrity: sha512-NqKYaq0355ZmNMG4QGpxtEDxsc7tGDhjhCm4PpE0cwnBW+5Il95LJyq414niEiaKLVjnVHBEjSo1wngKxJNiFw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.0': + resolution: {integrity: sha512-3vPoHzh6eBTz9IbB0/qZdSr0Qeks2echn+I4cHu2joV74VriPDdldswksEDzrl1mBB+oPRi+67+3Ib59paxIPQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.0': + resolution: {integrity: sha512-E6NNefZ1bUVmKJq2tJkf45J4Zyczj7qm9rUT7NY+Xo2474Y13qWAwc2tvBt0BAVbmtXR1llkxXg0Ou1jbDf2SQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.0': + resolution: {integrity: sha512-D+TgkdgM1vu+7/Fpf8+v0ARW+RXEP9Ccazgm8zQ4JFFd9Q7SrYQ2TakU5S5ihazQDgpKyAgZDOcIFsvoHmTZ8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.0': + resolution: {integrity: sha512-wUqdwJBbAv0APN87GecstdMUtLjjNTs0hBALpxETD73mccFxdmt/XeizXDtN5RAlBwNKmI+Tg+blect2G+8IeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.0': + resolution: {integrity: sha512-9DtF35qR9/NrfhM4oxLplCzVVjE+KKm8Pjemi0i/sdhAWkUasjmSo8WTTubNJClhSHCfyk2yeyoXDQEDPtDAAw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.0': + resolution: {integrity: sha512-RzuHrBh8X8Hntd2N4VR02QGEciq/9JhcZoTpR/Cee6otRrlILGCf3cg2ygHuih+ZebUnWmMrDX6ITI85btO6rQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.0': + resolution: {integrity: sha512-MK7L0018jjh1jR3mh21G2j1zAVcpscJBlPo2z19pRjv2XOYGRhaV4LyiD8HO6nCDdZln9IFgCMIV5yt4E3klGQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.0': + resolution: {integrity: sha512-gyrxLQ9NfGb/9LoVnC4kb9miUghw1mghnkfYvNHSnVIXriabnfgGPUP4RLcJm87q3KgYz4FYUG8IDiWUT+CpSw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.0': + resolution: {integrity: sha512-/6VFMQGRmrhP77KXDC+StIxGzcNp5JOIyYtw0CQ8gPlzhpiIRucYfoM5FaFamHd5BJYIdH86yfP46l1p3WdrFA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.2.0': + resolution: {integrity: sha512-rwdbUL465kisF24WEJLvP3JrEG6E5GRuIHt5wpMwHGERtHe4Wm2CIvtf5gTBgr2tGOHKh5NdKEAFS2VkOPE91g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.2.0': + resolution: {integrity: sha512-+5suHwRiKGmhwyUaNT8a5QbrBvLFh2DbO910TEmGRH1aSxwrCezodvGQnulv4uiWEIv1Kq4ypRsJ5+O+ry1DiA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.0': + resolution: {integrity: sha512-WfFv6/qGufotqBSBzBYwgpCkJBk8Nj7697LL9vTz/XWc67e0r3oewu8iMRwQj3AUL45GVD7wVsPjCsAAtW66Wg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rollup/rollup-android-arm-eabi@4.56.0': resolution: {integrity: sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==} cpu: [arm] @@ -1389,6 +1527,9 @@ packages: '@sinonjs/fake-timers@15.1.1': resolution: {integrity: sha512-cO5W33JgAPbOh07tvZjUOJ7oWhtaqGHiZw+11DPbyqh2kHTBc3eF/CjJDeQ4205RLQsX6rxCuYOroFQwl7JDRw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -1534,6 +1675,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/async-retry@1.4.9': resolution: {integrity: sha512-s1ciZQJzRh3708X/m3vPExr5KJlzlZJvXsKpbtE2luqNcbROr64qU+3KpJsYHqWMeaxI839OvXf9PrUSw1Xtyg==} @@ -1712,6 +1856,10 @@ packages: cpu: [x64] os: [win32] + '@vercel/blob@2.6.1': + resolution: {integrity: sha512-KTJytw85j1XQBxjN5d6UXI7fIWNQe1jotn4nWN+0hePqLs+Qi1B3jHdQcSKFGF0m2rsy9uhPT6GOXMtHe3qNzg==} + engines: {node: '>=20.0.0'} + '@vercel/cli-config@0.2.0': resolution: {integrity: sha512-fJRRRB7734BDuXZ89yBEaA2ncYhH7bWX30mk04W80J6VAfQc+4iB8lyzAdaGpFV3/vNlkt9VZt+/uoQoWX6UsQ==} @@ -1719,10 +1867,17 @@ packages: resolution: {integrity: sha512-LMRMEai3Z+BODyxGcU9+KiWrS/UElNiOLKiNRfGNt2Vu3NTEmXgFeXG9wBfocAnTe5yJCX/DY6k3k7S/LkPp/g==} engines: {node: '>= 18'} + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + '@vercel/oidc@3.6.1': resolution: {integrity: sha512-8ipTFoiX3WBRrvXLjSrmgAiwtMDQk3EgSxe8N7v2rXBz39NBIIyoGXeVbJRoBcP8WEuVnvjvIQsggbGU7ZKrMw==} engines: {node: '>= 20'} + '@workflow/serde@4.1.0': + resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==} + acorn-walk@8.2.0: resolution: {integrity: sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==} engines: {node: '>=0.4.0'} @@ -1741,6 +1896,12 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ai@7.0.37: + resolution: {integrity: sha512-stF+SEQJgKY3Qfe3FwNzqUrehHviOp2l7LemoI8YMOa0Zk5PxKsRNgUk3cHXz0RAue9RRyCAis2fz6LSCP6EKw==} + engines: {node: '>=22'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -2014,6 +2175,14 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crossws@0.4.10: + resolution: {integrity: sha512-pz3oubH/dt12KjqsUB0IuXW4nwRDQ583iDsP4555Cpdqx0NoU7pGlWBcayyFI8f/l/idRpgjMEfwuOxSWJYlIA==} + peerDependencies: + srvx: '>=0.11.5' + peerDependenciesMeta: + srvx: + optional: true + cssstyle@4.6.0: resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} engines: {node: '>=18'} @@ -2025,6 +2194,29 @@ packages: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} + db0@0.3.4: + resolution: {integrity: sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw==} + peerDependencies: + '@electric-sql/pglite': '*' + '@libsql/client': '*' + better-sqlite3: '*' + drizzle-orm: '*' + mysql2: '*' + sqlite3: '*' + peerDependenciesMeta: + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + better-sqlite3: + optional: true + drizzle-orm: + optional: true + mysql2: + optional: true + sqlite3: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2124,6 +2316,24 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + env-runner@0.1.16: + resolution: {integrity: sha512-2LRJM4P2KLX6J83QZZrMqvgCDt/D5ea7wPcI3yYiy5cG/9rX5QwdwZFx0D7ktWnjdRyZxYjttGGorb5nFqb1CA==} + hasBin: true + peerDependencies: + '@netlify/runtime': ^4.1.23 + '@vercel/queue': '>=0.2.0' + miniflare: ^4.20260515.0 + wrangler: ^4.0.0 + peerDependenciesMeta: + '@netlify/runtime': + optional: true + '@vercel/queue': + optional: true + miniflare: + optional: true + wrangler: + optional: true + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -2165,9 +2375,33 @@ packages: engines: {node: '>=4'} hasBin: true + eve@0.27.1: + resolution: {integrity: sha512-KNyW1PBgUPqOx3+tNqztN1tCucCMlk3fI78erabch1iHXv4MM9/SvHRNyKlpukohNpJz2TNM7u+/Eak1gdRAaQ==} + engines: {node: '>=24'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.0.0 + ai: ^7.0.34 + braintrust: ^3.0.0 + just-bash: ^3.0.0 + microsandbox: ^0.5.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + braintrust: + optional: true + just-bash: + optional: true + microsandbox: + optional: true + eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -2180,6 +2414,9 @@ packages: resolution: {integrity: sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + exsolve@1.1.0: + resolution: {integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==} + extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} @@ -2321,6 +2558,16 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + h3@2.0.1-rc.22: + resolution: {integrity: sha512-Esv0DMIuPkCTSWCA0vO73vcTqwzH1wjSrAO1TXNu/K3up1sZHa9EKMapbmxCDYBeymC3fVTk4qxp7ogQWQ+KgA==} + engines: {node: '>=20.11.1'} + hasBin: true + peerDependencies: + crossws: ^0.4.1 + peerDependenciesMeta: + crossws: + optional: true + handlebars@4.7.9: resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} engines: {node: '>=0.4.7'} @@ -2345,6 +2592,9 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hookable@6.1.1: + resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} + html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -2367,6 +2617,9 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + httpxy@0.5.5: + resolution: {integrity: sha512-uDjmnPyp1q4Sgzf3w+J/Fc6UqcCEj0x4Wjp7OqK5dGhNeDgpyrAmnS6ey8QWrX3SWDon2DMKf9sBa5X9+CVyMA==} + human-id@4.1.1: resolution: {integrity: sha512-3gKm/gCSUipeLsRYZbbdA1BD83lBoWUkZ7G9VFrhWPAU76KwYo5KR8V28bpoPm/ygy0x5/GCbpRQdY7VLYCoIg==} hasBin: true @@ -2681,6 +2934,9 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stable-stringify@1.3.0: resolution: {integrity: sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==} engines: {node: '>= 0.4'} @@ -2958,6 +3214,40 @@ packages: sass: optional: true + nf3@0.3.22: + resolution: {integrity: sha512-NOcqlHu22X1rUakd0SrqllP8kb3LWFmSAi1svTxaKxz+yB604xlaOmUfI3oO+RkODvaocKDDy8bgthnZL8hrkw==} + + nitro@3.0.260610-beta: + resolution: {integrity: sha512-KPb4L5yaF/Rx/xoGMpgHRJvZhbhGiqbRKOwwPLCH9jKTKTsEUHLjnJas85AeCzaswqa8Wi52eQBtRsODC4PS0Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@vercel/queue': ^0.3.0 + dotenv: '*' + giget: '*' + jiti: ^2.7.0 + rollup: ^4.61.1 + vite: ^7 || ^8 + xml2js: ^0.6.2 + zephyr-agent: ^0.2.0 + peerDependenciesMeta: + '@vercel/queue': + optional: true + dotenv: + optional: true + giget: + optional: true + jiti: + optional: true + rollup: + optional: true + vite: + optional: true + xml2js: + optional: true + zephyr-agent: + optional: true + node-domexception@2.0.2: resolution: {integrity: sha512-Qf9vHK9c5MGgUXj8SnucCIS4oEPuUstjRaMplLGeZpbWMfNV1rvEcXuwoXfN51dUfD1b4muPHPQtCx/5Dj/QAA==} engines: {node: '>=16'} @@ -3005,6 +3295,15 @@ packages: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} + ocache@0.1.5: + resolution: {integrity: sha512-kNNnkkVQup/QDvmTz8Q84wc2ntiyoVHDxa6eHWKt5qdGAmFRBIxy83rxgCYEjW0x06UJ9E3P6VgM2yY4rOBH4w==} + + ofetch@2.0.0-alpha.3: + resolution: {integrity: sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -3250,11 +3549,19 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rolldown@1.2.0: + resolution: {integrity: sha512-u7tgm5l4Yw1iTqUL4EcYOAt7fFvCgQMLeidrnD4GALlC6aOznCjezYajgxeyKw27u0Q5N7fwgCzjVyPIWzwuBA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rollup@4.56.0: resolution: {integrity: sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rou3@0.8.1: + resolution: {integrity: sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA==} + rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} @@ -3336,6 +3643,11 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + srvx@0.11.22: + resolution: {integrity: sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ==} + engines: {node: '>=20.16.0'} + hasBin: true + stack-utils@2.0.6: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} @@ -3601,6 +3913,9 @@ packages: resolution: {integrity: sha512-Heho1hJD81YChi+uS2RkSjcVO+EQLmLSyUlHyp7Y/wFbxQaGb4WXVKD073JytrjXJVkSZVzoE2MCSOKugFGtOQ==} engines: {node: '>=20.18.1'} + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -3608,6 +3923,80 @@ packages: unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + unstorage@2.0.0-alpha.7: + resolution: {integrity: sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog==} + peerDependencies: + '@azure/app-configuration': ^1.11.0 + '@azure/cosmos': ^4.9.1 + '@azure/data-tables': ^13.3.2 + '@azure/identity': ^4.13.0 + '@azure/keyvault-secrets': ^4.10.0 + '@azure/storage-blob': ^12.31.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.13.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.36.2 + '@vercel/blob': '>=0.27.3' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1.0.1 + aws4fetch: ^1.0.20 + chokidar: ^4 || ^5 + db0: '>=0.3.4' + idb-keyval: ^6.2.2 + ioredis: ^5.9.3 + lru-cache: ^11.2.6 + mongodb: ^6 || ^7 + ofetch: '*' + uploadthing: ^7.7.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + chokidar: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + lru-cache: + optional: true + mongodb: + optional: true + ofetch: + optional: true + uploadthing: + optional: true + update-browserslist-db@1.2.2: resolution: {integrity: sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==} hasBin: true @@ -3741,8 +4130,30 @@ packages: zod@4.1.11: resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: + '@ai-sdk/gateway@4.0.28(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 4.0.3 + '@ai-sdk/provider-utils': 5.0.12(zod@4.4.3) + '@vercel/oidc': 3.2.0 + zod: 4.4.3 + + '@ai-sdk/provider-utils@5.0.12(zod@4.4.3)': + dependencies: + '@ai-sdk/provider': 4.0.3 + '@standard-schema/spec': 1.1.0 + '@workflow/serde': 4.1.0 + eventsource-parser: 3.1.0 + zod: 4.4.3 + + '@ai-sdk/provider@4.0.3': + dependencies: + json-schema: 0.4.0 + '@alloc/quick-lru@5.2.0': {} '@asamuzakjp/css-color@3.2.0': @@ -4166,12 +4577,23 @@ snapshots: dependencies: '@edge-runtime/primitives': 6.0.0 + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 @@ -4182,6 +4604,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.27.2': optional: true @@ -4651,6 +5078,13 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + '@next/env@16.1.4': {} '@next/env@16.2.4': {} @@ -4717,6 +5151,8 @@ snapshots: '@opentelemetry/api@1.7.0': {} + '@oxc-project/types@0.140.0': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -4728,6 +5164,57 @@ snapshots: '@publint/pack@0.1.4': {} + '@rolldown/binding-android-arm64@1.2.0': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.0': + optional: true + + '@rolldown/binding-darwin-x64@1.2.0': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.0': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.0': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.0': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.0': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.0': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.0': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.0': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.0': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.0': + optional: true + + '@rolldown/binding-wasm32-wasi@1.2.0': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.0': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.0': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@rollup/rollup-android-arm-eabi@4.56.0': optional: true @@ -4823,6 +5310,8 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -4932,6 +5421,11 @@ snapshots: tslib: 2.8.1 optional: true + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + '@types/async-retry@1.4.9': dependencies: '@types/retry': 0.12.5 @@ -5077,6 +5571,16 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@vercel/blob@2.6.1': + dependencies: + '@vercel/oidc': 3.6.1 + async-retry: 1.3.3 + is-buffer: 2.0.5 + is-node-process: 1.2.0 + throttleit: 2.1.0 + undici: 6.23.0 + optional: true + '@vercel/cli-config@0.2.0': dependencies: xdg-app-paths: 5.5.1 @@ -5086,12 +5590,16 @@ snapshots: dependencies: execa: 5.1.1 + '@vercel/oidc@3.2.0': {} + '@vercel/oidc@3.6.1': dependencies: '@vercel/cli-config': 0.2.0 '@vercel/cli-exec': 0.1.1 jose: 5.10.0 + '@workflow/serde@4.1.0': {} + acorn-walk@8.2.0: {} acorn@8.11.3: {} @@ -5100,6 +5608,13 @@ snapshots: agent-base@7.1.4: {} + ai@7.0.37(zod@4.4.3): + dependencies: + '@ai-sdk/gateway': 4.0.28(zod@4.4.3) + '@ai-sdk/provider': 4.0.3 + '@ai-sdk/provider-utils': 5.0.12(zod@4.4.3) + zod: 4.4.3 + ansi-colors@4.1.3: {} ansi-escapes@4.3.2: @@ -5375,6 +5890,10 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crossws@0.4.10(srvx@0.11.22): + optionalDependencies: + srvx: 0.11.22 + cssstyle@4.6.0: dependencies: '@asamuzakjp/css-color': 3.2.0 @@ -5387,6 +5906,8 @@ snapshots: whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 + db0@0.3.4: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -5455,6 +5976,13 @@ snapshots: entities@6.0.1: {} + env-runner@0.1.16: + dependencies: + crossws: 0.4.10(srvx@0.11.22) + exsolve: 1.1.0 + httpxy: 0.5.5 + srvx: 0.11.22 + environment@1.1.0: {} error-ex@1.3.2: @@ -5511,8 +6039,56 @@ snapshots: esprima@4.0.1: {} + eve@0.27.1(@opentelemetry/api@1.7.0)(@vercel/blob@2.6.1)(ai@7.0.37(zod@4.4.3))(chokidar@4.0.3)(dotenv@17.2.3): + dependencies: + ai: 7.0.37(zod@4.4.3) + nitro: 3.0.260610-beta(@vercel/blob@2.6.1)(chokidar@4.0.3)(dotenv@17.2.3) + optionalDependencies: + '@opentelemetry/api': 1.7.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@netlify/runtime' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - '@vercel/queue' + - aws4fetch + - better-sqlite3 + - chokidar + - dotenv + - drizzle-orm + - giget + - idb-keyval + - ioredis + - jiti + - lru-cache + - miniflare + - mongodb + - mysql2 + - rollup + - sqlite3 + - uploadthing + - vite + - wrangler + - xml2js + - zephyr-agent + eventemitter3@5.0.1: {} + eventsource-parser@3.1.0: {} + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -5536,6 +6112,8 @@ snapshots: jest-mock: 30.3.0 jest-util: 30.3.0 + exsolve@1.1.0: {} + extendable-error@0.1.7: {} fast-glob@3.3.3: @@ -5697,6 +6275,13 @@ snapshots: graceful-fs@4.2.11: {} + h3@2.0.1-rc.22(crossws@0.4.10(srvx@0.11.22)): + dependencies: + rou3: 0.8.1 + srvx: 0.11.22 + optionalDependencies: + crossws: 0.4.10(srvx@0.11.22) + handlebars@4.7.9: dependencies: minimist: 1.2.8 @@ -5722,6 +6307,8 @@ snapshots: dependencies: function-bind: 1.1.2 + hookable@6.1.1: {} + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 @@ -5749,6 +6336,8 @@ snapshots: transitivePeerDependencies: - supports-color + httpxy@0.5.5: {} + human-id@4.1.1: {} human-signals@2.1.0: {} @@ -6289,6 +6878,8 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema@0.4.0: {} + json-stable-stringify@1.3.0: dependencies: call-bind: 1.0.8 @@ -6536,6 +7127,58 @@ snapshots: - '@babel/core' - babel-plugin-macros + nf3@0.3.22: {} + + nitro@3.0.260610-beta(@vercel/blob@2.6.1)(chokidar@4.0.3)(dotenv@17.2.3): + dependencies: + consola: 3.4.2 + crossws: 0.4.10(srvx@0.11.22) + db0: 0.3.4 + env-runner: 0.1.16 + h3: 2.0.1-rc.22(crossws@0.4.10(srvx@0.11.22)) + hookable: 6.1.1 + nf3: 0.3.22 + ocache: 0.1.5 + ofetch: 2.0.0-alpha.3 + ohash: 2.0.11 + rolldown: 1.2.0 + srvx: 0.11.22 + unenv: 2.0.0-rc.24 + unstorage: 2.0.0-alpha.7(@vercel/blob@2.6.1)(chokidar@4.0.3)(db0@0.3.4)(ofetch@2.0.0-alpha.3) + optionalDependencies: + dotenv: 17.2.3 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@electric-sql/pglite' + - '@libsql/client' + - '@netlify/blobs' + - '@netlify/runtime' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - better-sqlite3 + - chokidar + - drizzle-orm + - idb-keyval + - ioredis + - lru-cache + - miniflare + - mongodb + - mysql2 + - sqlite3 + - uploadthing + - wrangler + node-domexception@2.0.2: {} node-fetch@2.6.7: @@ -6563,6 +7206,14 @@ snapshots: object-keys@1.1.1: {} + ocache@0.1.5: + dependencies: + ohash: 2.0.11 + + ofetch@2.0.0-alpha.3: {} + + ohash@2.0.11: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -6763,6 +7414,27 @@ snapshots: rfdc@1.4.1: {} + rolldown@1.2.0: + dependencies: + '@oxc-project/types': 0.140.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.0 + '@rolldown/binding-darwin-arm64': 1.2.0 + '@rolldown/binding-darwin-x64': 1.2.0 + '@rolldown/binding-freebsd-x64': 1.2.0 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.0 + '@rolldown/binding-linux-arm64-gnu': 1.2.0 + '@rolldown/binding-linux-arm64-musl': 1.2.0 + '@rolldown/binding-linux-ppc64-gnu': 1.2.0 + '@rolldown/binding-linux-s390x-gnu': 1.2.0 + '@rolldown/binding-linux-x64-gnu': 1.2.0 + '@rolldown/binding-linux-x64-musl': 1.2.0 + '@rolldown/binding-openharmony-arm64': 1.2.0 + '@rolldown/binding-wasm32-wasi': 1.2.0 + '@rolldown/binding-win32-arm64-msvc': 1.2.0 + '@rolldown/binding-win32-x64-msvc': 1.2.0 + rollup@4.56.0: dependencies: '@types/estree': 1.0.8 @@ -6794,6 +7466,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.56.0 fsevents: 2.3.3 + rou3@0.8.1: {} + rrweb-cssom@0.8.0: {} run-parallel@1.2.0: @@ -6892,6 +7566,8 @@ snapshots: sprintf-js@1.0.3: {} + srvx@0.11.22: {} + stack-utils@2.0.6: dependencies: escape-string-regexp: 2.0.0 @@ -7154,6 +7830,10 @@ snapshots: undici@7.19.0: {} + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + universalify@0.1.2: {} unrs-resolver@1.11.1: @@ -7180,6 +7860,13 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + unstorage@2.0.0-alpha.7(@vercel/blob@2.6.1)(chokidar@4.0.3)(db0@0.3.4)(ofetch@2.0.0-alpha.3): + optionalDependencies: + '@vercel/blob': 2.6.1 + chokidar: 4.0.3 + db0: 0.3.4 + ofetch: 2.0.0-alpha.3 + update-browserslist-db@1.2.2(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -7297,3 +7984,5 @@ snapshots: yocto-queue@0.1.0: {} zod@4.1.11: {} + + zod@4.4.3: {}