diff --git a/.changeset/recovered-rclone-batch-upload.md b/.changeset/recovered-rclone-batch-upload.md new file mode 100644 index 000000000..a23739d56 --- /dev/null +++ b/.changeset/recovered-rclone-batch-upload.md @@ -0,0 +1,39 @@ +--- +"@opennextjs/cloudflare": minor +--- + +feature: add opt-in batch upload via `rclone` for fast R2 cache population. + +**Key Changes:** + +1. **Optional `rclone` Upload**: Install the optional `rclone.js` peer dependency and pass `--rclone` to opt in to `rclone` based batch uploads. + + - `R2_ACCESS_KEY_ID` + - `R2_SECRET_ACCESS_KEY` + - `CF_ACCOUNT_ID` + +2. **Explicit Opt-in**: The existing worker-based population path remains the default. `rclone` is only loaded when `--rclone` is used for a remote cache. + +3. **Clear Errors**: The CLI reports missing credentials or a missing `rclone.js` installation when the option is used. + +**Usage:** + +Install `rclone.js`, then add the secrets in a `.env`/`.dev.vars` file in your project root: + +```bash +pnpm add rclone.js +pnpm approve-builds # select rclone.js +pnpm rebuild rclone.js +R2_ACCESS_KEY_ID=your_key +R2_SECRET_ACCESS_KEY=your_secret +CF_ACCOUNT_ID=your_account + +opennextjs-cloudflare deploy --rclone +``` + +You can also set the environment variables for CI builds. + +**Notes:** + +- You can follow documentation for creating API tokens with appropriate permissions for R2 access. +- `rclone` may not be supported on all platforms. diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 42d9d8357..bf96fb500 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -70,6 +70,7 @@ "@types/mock-fs": "catalog:", "@types/node": "catalog:", "@types/picomatch": "^4.0.0", + "@types/rclone.js": "^0.6.1", "@types/yargs": "catalog:", "diff": "^8.0.2", "esbuild": "catalog:", @@ -81,6 +82,7 @@ "mock-fs": "catalog:", "next": "catalog:", "picomatch": "^4.0.2", + "rclone.js": "^0.6.6", "rimraf": "catalog:", "typescript": "catalog:", "typescript-eslint": "catalog:", @@ -88,6 +90,12 @@ }, "peerDependencies": { "next": ">=15.5.18 <16 || >=16.2.6", + "rclone.js": "^0.6.6", "wrangler": "catalog:" + }, + "peerDependenciesMeta": { + "rclone.js": { + "optional": true + } } } diff --git a/packages/cloudflare/src/api/cloudflare-context.ts b/packages/cloudflare/src/api/cloudflare-context.ts index cfde3167b..df1694024 100644 --- a/packages/cloudflare/src/api/cloudflare-context.ts +++ b/packages/cloudflare/src/api/cloudflare-context.ts @@ -83,8 +83,13 @@ declare global { CF_PREVIEW_DOMAIN?: string; // Should have the `Workers Scripts:Read` permission CF_WORKERS_SCRIPTS_API_TOKEN?: string; - // Cloudflare account id - needed for skew protection + + // Cloudflare account id - needed for skew protection and R2 batch population CF_ACCOUNT_ID?: string; + + // R2 S3 API credentials used by `rclone` + R2_ACCESS_KEY_ID?: string; + R2_SECRET_ACCESS_KEY?: string; } } diff --git a/packages/cloudflare/src/cli/commands/deploy.ts b/packages/cloudflare/src/cli/commands/deploy.ts index b8a8f6ccc..0362f5e17 100644 --- a/packages/cloudflare/src/cli/commands/deploy.ts +++ b/packages/cloudflare/src/cli/commands/deploy.ts @@ -20,7 +20,9 @@ import { * * @param args */ -export async function deployCommand(args: WithWranglerArgs<{ cacheChunkSize?: number }>): Promise { +export async function deployCommand( + args: WithWranglerArgs<{ cacheChunkSize?: number; rclone: boolean }> +): Promise { printHeaders("deploy"); const { config } = await retrieveCompiledConfig(); @@ -45,6 +47,7 @@ export async function deployCommand(args: WithWranglerArgs<{ cacheChunkSize?: nu environment: args.env, wranglerConfigPath: args.wranglerConfigPath, cacheChunkSize: args.cacheChunkSize, + useRclone: args.rclone, shouldUsePreviewId: false, }, envVars diff --git a/packages/cloudflare/src/cli/commands/populate-cache.spec.ts b/packages/cloudflare/src/cli/commands/populate-cache.spec.ts index cf85bbe29..9ecf082a2 100644 --- a/packages/cloudflare/src/cli/commands/populate-cache.spec.ts +++ b/packages/cloudflare/src/cli/commands/populate-cache.spec.ts @@ -1,8 +1,10 @@ +import { EventEmitter } from "node:events"; import { mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; import type { BuildOptions } from "@opennextjs/aws/build/helper.js"; import mockFs from "mock-fs"; +import rclone from "rclone.js"; import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest"; import type { Unstable_Config as WranglerConfig } from "wrangler"; import { unstable_startWorker } from "wrangler"; @@ -12,6 +14,7 @@ import r2IncrementalCache from "../../api/overrides/incremental-cache/r2-increme import { ensureR2Bucket } from "../utils/ensure-r2-bucket.js"; import { getCacheAssets, + loadRclone, MAX_REQUEST_RETRIES, MAX_RETRY_DELAY_MS, populateCache, @@ -104,6 +107,18 @@ vi.mock("./utils/helpers.js", () => ({ })); vi.mock("../utils/ensure-r2-bucket.js"); +vi.mock("rclone.js", () => ({ + default: { + copy: vi.fn(() => { + const child = new EventEmitter(); + queueMicrotask(() => child.emit("close", 0, null)); + return child; + }), + promises: { + version: vi.fn(async () => ""), + }, + }, +})); vi.mock("wrangler"); describe("populateCache", () => { @@ -224,6 +239,153 @@ describe("populateCache", () => { } ); + test("remote with rclone - uploads staged cache entries without starting a worker", async () => { + setupMockFileSystem(); + + await populateCache( + buildOptions, + config, + wranglerConfig, + { target: "remote", shouldUsePreviewId: false, useRclone: true, cacheChunkSize: 20 }, + { + R2_ACCESS_KEY_ID: "access-key", + R2_SECRET_ACCESS_KEY: "secret-key", + CF_ACCOUNT_ID: "account-id", + } as WorkerEnvVar + ); + + expect(rclone.copy).toHaveBeenCalledWith( + expect.any(String), + "r2:test-bucket", + expect.objectContaining({ + progress: true, + transfers: 20, + checkers: 10, + env: expect.objectContaining({ RCLONE_CONFIG: expect.any(String) }), + stdio: "inherit", + }) + ); + expect(unstable_startWorker).not.toHaveBeenCalled(); + expect(ensureR2Bucket).not.toHaveBeenCalled(); + }); + + test("remote with rclone - uses the default transfer concurrency", async () => { + setupMockFileSystem(); + + await populateCache( + buildOptions, + config, + wranglerConfig, + { target: "remote", shouldUsePreviewId: false, useRclone: true }, + { + R2_ACCESS_KEY_ID: "access-key", + R2_SECRET_ACCESS_KEY: "secret-key", + CF_ACCOUNT_ID: "account-id", + } as WorkerEnvVar + ); + + expect(rclone.copy).toHaveBeenCalledWith( + expect.any(String), + "r2:test-bucket", + expect.objectContaining({ transfers: 25, checkers: 12 }) + ); + }); + + test("remote with rclone - rejects a failed rclone process", async () => { + setupMockFileSystem(); + vi.mocked(rclone.copy).mockImplementationOnce(() => { + const child = new EventEmitter(); + queueMicrotask(() => child.emit("close", 7, null)); + return child as ReturnType; + }); + + await expect( + populateCache( + buildOptions, + config, + wranglerConfig, + { target: "remote", shouldUsePreviewId: false, useRclone: true }, + { + R2_ACCESS_KEY_ID: "access-key", + R2_SECRET_ACCESS_KEY: "secret-key", + CF_ACCOUNT_ID: "account-id", + } as WorkerEnvVar + ) + ).rejects.toThrow("rclone exited with code 7"); + }); + + test("local with rclone - rejects the unsupported option", async () => { + setupMockFileSystem(); + + await expect( + populateCache( + buildOptions, + config, + wranglerConfig, + { target: "local", shouldUsePreviewId: false, useRclone: true }, + envVars + ) + ).rejects.toThrow("The `--rclone` option can only be used when populating a remote R2 cache."); + }); + + test("remote with rclone - rejects missing R2 credentials", async () => { + setupMockFileSystem(); + + await expect( + populateCache( + buildOptions, + config, + wranglerConfig, + { target: "remote", shouldUsePreviewId: false, useRclone: true }, + envVars + ) + ).rejects.toThrow( + "R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, and CF_ACCOUNT_ID must be provided to use `rclone`" + ); + }); + + test("remote with rclone - rejects cache prefixes that escape the staging directory", async () => { + setupMockFileSystem(); + + await expect( + populateCache( + buildOptions, + config, + wranglerConfig, + { target: "remote", shouldUsePreviewId: false, useRclone: true }, + { + R2_ACCESS_KEY_ID: "access-key", + R2_SECRET_ACCESS_KEY: "secret-key", + CF_ACCOUNT_ID: "account-id", + NEXT_INC_CACHE_R2_PREFIX: "../escaped", + } as WorkerEnvVar + ) + ).rejects.toThrow("Cannot stage R2 cache key outside the temporary directory"); + }); + + test("remote with rclone - explains how to install the rclone executable with pnpm", async () => { + setupMockFileSystem(); + vi.mocked(rclone.promises.version).mockRejectedValueOnce( + Object.assign(new Error("spawn rclone ENOENT"), { code: "ENOENT" }) + ); + + await expect( + populateCache( + buildOptions, + config, + wranglerConfig, + { target: "remote", shouldUsePreviewId: false, useRclone: true }, + { + R2_ACCESS_KEY_ID: "access-key", + R2_SECRET_ACCESS_KEY: "secret-key", + CF_ACCOUNT_ID: "account-id", + } as WorkerEnvVar + ) + ).rejects.toThrow( + "pnpm users must allow its install script with `pnpm approve-builds`, select `rclone.js`, then run `pnpm rebuild rclone.js`" + ); + }); + test("remote - exits when bucket provisioning fails", async () => { setupMockFileSystem(); vi.mocked(ensureR2Bucket).mockResolvedValueOnce({ @@ -465,3 +627,19 @@ describe("populateCache", () => { }); }); }); + +describe("loadRclone", () => { + test("reports how to install the optional peer dependency", async () => { + const missingModuleError = Object.assign(new Error("Cannot find package 'rclone.js'"), { + code: "ERR_MODULE_NOT_FOUND", + }); + + await expect( + loadRclone(async () => { + throw missingModuleError; + }) + ).rejects.toThrow( + "The `--rclone` option requires the optional `rclone.js` peer dependency. Install it in your project before using this option." + ); + }); +}); diff --git a/packages/cloudflare/src/cli/commands/populate-cache.ts b/packages/cloudflare/src/cli/commands/populate-cache.ts index b6291bd16..60c781a12 100644 --- a/packages/cloudflare/src/cli/commands/populate-cache.ts +++ b/packages/cloudflare/src/cli/commands/populate-cache.ts @@ -72,7 +72,7 @@ export const BACKOFF_FACTOR = (MAX_RETRY_DELAY_MS / BASE_RETRY_DELAY_MS) ** (1 / */ async function populateCacheCommand( target: "local" | "remote", - args: WithWranglerArgs<{ cacheChunkSize?: number }> + args: WithWranglerArgs<{ cacheChunkSize?: number; rclone: boolean }> ) { printHeaders(`populate cache - ${target}`); @@ -97,6 +97,7 @@ async function populateCacheCommand( environment: args.env, wranglerConfigPath: args.wranglerConfigPath, cacheChunkSize: args.cacheChunkSize, + useRclone: args.rclone, shouldUsePreviewId: false, }, envVars @@ -218,11 +219,15 @@ export type PopulateCacheOptions = { /** * Number of concurrent requests when populating the cache. * For KV this is the chunk size passed to `wrangler kv bulk put`. - * For R2 this is the number of concurrent requests to the local worker. + * For R2 this is the number of concurrent requests to the local worker or rclone transfers. * * @default 25 */ cacheChunkSize?: number; + /** + * Whether to use `rclone` instead of the worker-based R2 cache population path. + */ + useRclone?: boolean; /** * Instructs Wrangler to use the preview namespace or ID defined in the Wrangler config for the remote target. */ @@ -269,6 +274,21 @@ async function populateR2IncrementalCache( return; } + if (populateCacheOptions.useRclone) { + if (populateCacheOptions.target !== "remote") { + throw new Error("The `--rclone` option can only be used when populating a remote R2 cache."); + } + + await populateR2IncrementalCacheWithRclone( + bucketName, + prefix, + assets, + envVars, + populateCacheOptions.cacheChunkSize + ); + return; + } + const currentDir = path.dirname(fileURLToPath(import.meta.url)); const handlerPath = path.join(currentDir, "../workers/r2-cache.js"); const isRemote = populateCacheOptions.target === "remote"; @@ -330,6 +350,167 @@ async function populateR2IncrementalCache( logger.info(`Successfully populated cache with ${assets.length} entries`); } +async function populateR2IncrementalCacheWithRclone( + bucketName: string, + prefix: string | undefined, + assets: CacheAsset[], + envVars: WorkerEnvVar, + cacheChunkSize?: number +) { + const accessKey = envVars.R2_ACCESS_KEY_ID; + const secretKey = envVars.R2_SECRET_ACCESS_KEY; + const accountId = envVars.CF_ACCOUNT_ID; + + if (!accessKey || !secretKey || !accountId) { + throw new Error( + "R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, and CF_ACCOUNT_ID must be provided to use `rclone`" + ); + } + + const rclone = await loadRclone(); + + logger.info("\nPopulating remote R2 incremental cache using `rclone`..."); + + const configDir = await fsp.mkdtemp(path.join(os.tmpdir(), "rclone-config-")); + const configPath = path.join(configDir, "rclone.conf"); + const stagingDir = await fsp.mkdtemp(path.join(os.tmpdir(), "r2-staging-")); + const transfers = Math.max(1, cacheChunkSize ?? 25); + const checkers = Math.max(1, Math.floor(transfers / 2)); + + try { + await fsp.writeFile( + configPath, + `[r2]\ntype = s3\nprovider = Cloudflare\naccess_key_id = ${accessKey}\nsecret_access_key = ${secretKey}\nendpoint = https://${accountId}.r2.cloudflarestorage.com\nacl = private\n`, + { mode: 0o600 } + ); + const rcloneEnv = { + ...process.env, + RCLONE_CONFIG: configPath, + }; + await ensureRcloneExecutable(rclone, rcloneEnv); + + await stageCacheAssets(assets, stagingDir, prefix, transfers); + + await runRcloneCopy(rclone, stagingDir, `r2:${bucketName}`, { + progress: true, + transfers, + checkers, + env: rcloneEnv, + stdio: "inherit", + }); + } finally { + await Promise.allSettled([ + fsp.rm(stagingDir, { recursive: true, force: true }), + fsp.rm(configDir, { recursive: true, force: true }), + ]); + } + + logger.info(`Successfully populated cache with ${assets.length} entries`); +} + +async function runRcloneCopy( + rclone: typeof import("rclone.js"), + source: string, + destination: string, + options: Record +) { + await new Promise((resolve, reject) => { + const child = rclone.copy(source, destination, options); + + child.once("error", reject); + child.once("close", (code, signal) => { + if (code === 0) { + resolve(); + return; + } + + reject( + new Error( + `rclone exited ${signal ? `after receiving signal ${signal}` : `with code ${code ?? "unknown"}`}` + ) + ); + }); + }); +} + +async function stageCacheAssets( + assets: CacheAsset[], + stagingDir: string, + prefix: string | undefined, + maxConcurrency: number +) { + const pending = new Set>(); + + for (const asset of assets) { + const task = stageCacheAsset(asset, stagingDir, prefix).finally(() => pending.delete(task)); + pending.add(task); + + if (pending.size >= maxConcurrency) { + await Promise.race(pending); + } + } + + await Promise.all(pending); +} + +async function stageCacheAsset(asset: CacheAsset, stagingDir: string, prefix: string | undefined) { + const cacheKey = computeCacheKey(asset.key, { + prefix, + buildId: asset.buildId, + cacheType: asset.isFetch ? "fetch" : "cache", + }); + const destination = path.resolve(stagingDir, cacheKey); + const relativeDestination = path.relative(stagingDir, destination); + + if (relativeDestination.startsWith(`..${path.sep}`) || path.isAbsolute(relativeDestination)) { + throw new Error(`Cannot stage R2 cache key outside the temporary directory: ${JSON.stringify(cacheKey)}`); + } + + await fsp.mkdir(path.dirname(destination), { recursive: true }); + + try { + await fsp.link(asset.fullPath, destination); + } catch (error) { + if (!(error instanceof Error) || !("code" in error) || error.code !== "EXDEV") { + throw error; + } + + await fsp.copyFile(asset.fullPath, destination); + } +} + +async function ensureRcloneExecutable(rclone: typeof import("rclone.js"), env: NodeJS.ProcessEnv) { + try { + await rclone.promises.version({ env }); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + throw new Error( + "The `rclone.js` executable is unavailable. pnpm users must allow its install script with `pnpm approve-builds`, select `rclone.js`, then run `pnpm rebuild rclone.js`." + ); + } + + throw error; + } +} + +export async function loadRclone( + importRclone: () => Promise<{ default: typeof import("rclone.js") }> = () => import("rclone.js") +) { + try { + return (await importRclone()).default; + } catch (error) { + if ( + error instanceof Error && + ("code" in error ? error.code === "ERR_MODULE_NOT_FOUND" : error.message.includes("rclone.js")) + ) { + throw new Error( + "The `--rclone` option requires the optional `rclone.js` peer dependency. Install it in your project before using this option." + ); + } + throw error; + } +} + /** * Sends cache entries to the R2 worker, one entry per request. * @@ -655,8 +836,14 @@ export function addPopulateCacheCommand(y: T) { } export function withPopulateCacheOptions(args: T) { - return withWranglerOptions(args).options("cacheChunkSize", { - type: "number", - desc: "Number of entries per chunk when populating the cache", - }); + return withWranglerOptions(args) + .options("cacheChunkSize", { + type: "number", + desc: "Number of concurrent cache entries to process", + }) + .options("rclone", { + type: "boolean", + default: false, + desc: "Use rclone to populate a remote R2 incremental cache", + }); } diff --git a/packages/cloudflare/src/cli/commands/preview.ts b/packages/cloudflare/src/cli/commands/preview.ts index a26af25df..83a45f648 100644 --- a/packages/cloudflare/src/cli/commands/preview.ts +++ b/packages/cloudflare/src/cli/commands/preview.ts @@ -19,7 +19,7 @@ import { * @param args */ export async function previewCommand( - args: WithWranglerArgs<{ cacheChunkSize?: number; remote: boolean }> + args: WithWranglerArgs<{ cacheChunkSize?: number; remote: boolean; rclone: boolean }> ): Promise { printHeaders("preview"); @@ -44,6 +44,7 @@ export async function previewCommand( environment: args.env, wranglerConfigPath: args.wranglerConfigPath, cacheChunkSize: args.cacheChunkSize, + useRclone: args.rclone, shouldUsePreviewId: args.remote, }, envVars diff --git a/packages/cloudflare/src/cli/commands/upload.ts b/packages/cloudflare/src/cli/commands/upload.ts index 095d0e76e..b09f066e5 100644 --- a/packages/cloudflare/src/cli/commands/upload.ts +++ b/packages/cloudflare/src/cli/commands/upload.ts @@ -20,7 +20,9 @@ import { * * @param args */ -export async function uploadCommand(args: WithWranglerArgs<{ cacheChunkSize?: number }>): Promise { +export async function uploadCommand( + args: WithWranglerArgs<{ cacheChunkSize?: number; rclone: boolean }> +): Promise { printHeaders("upload"); const { config } = await retrieveCompiledConfig(); @@ -47,6 +49,7 @@ export async function uploadCommand(args: WithWranglerArgs<{ cacheChunkSize?: nu environment: args.env, wranglerConfigPath: args.wranglerConfigPath, cacheChunkSize: args.cacheChunkSize, + useRclone: args.rclone, shouldUsePreviewId: false, }, envVars diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60cb6ad00..78f29f69d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1126,6 +1126,9 @@ importers: '@types/picomatch': specifier: ^4.0.0 version: 4.0.0 + '@types/rclone.js': + specifier: ^0.6.1 + version: 0.6.3 '@types/yargs': specifier: 'catalog:' version: 17.0.33 @@ -1159,6 +1162,9 @@ importers: picomatch: specifier: ^4.0.2 version: 4.0.2 + rclone.js: + specifier: ^0.6.6 + version: 0.6.6 rimraf: specifier: 'catalog:' version: 6.0.1 @@ -4557,6 +4563,9 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/rclone.js@0.6.3': + resolution: {integrity: sha512-BssKAAVRY//fxGKso8SatyOwiD7X0toDofNnVxZlIXmN7UHrn2UBTxldNAjgUvWA91qJyeEPfKmeJpZVhLugXg==} + '@types/react-dom@19.0.6': resolution: {integrity: sha512-lo6MuY+rFr8kIiFnr+7TzO+Av0wUPcEcepiPV4epGP0eTQpkDfp9czudg73isV8UxKauCUNlL1N8fXhcnx4iBw==} peerDependencies: @@ -4845,6 +4854,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + adm-zip@0.5.17: + resolution: {integrity: sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==} + engines: {node: '>=12.0'} + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -8389,6 +8402,13 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + rclone.js@0.6.6: + resolution: {integrity: sha512-Dxh34cab/fNjFq5SSm0fYLNkGzG2cQSBy782UW9WwxJCEiVO4cGXkvaXcNlgv817dK8K8PuQ+NHUqSAMMhWujQ==} + engines: {node: '>=12'} + cpu: [arm, arm64, mips, mipsel, x32, x64] + os: [darwin, freebsd, linux, openbsd, sunos, win32] + hasBin: true + react-dom@19.0.6: resolution: {integrity: sha512-0dbe9G8x3ewsAkbetkL7qEqQ0zQNbQWV7il9kVD0R2haq3DT/3/3ZZqRH8QeMqMejTCFOvl5n8pbwKGiKvZhFQ==} peerDependencies: @@ -13792,7 +13812,7 @@ snapshots: '@types/body-parser@1.19.5': dependencies: '@types/connect': 3.4.38 - '@types/node': 20.14.10 + '@types/node': 22.12.0 '@types/caseless@0.12.5': optional: true @@ -13804,7 +13824,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 20.14.10 + '@types/node': 22.12.0 '@types/debug@4.1.12': dependencies: @@ -13818,7 +13838,7 @@ snapshots: '@types/express-serve-static-core@4.19.6': dependencies: - '@types/node': 20.14.10 + '@types/node': 22.12.0 '@types/qs': 6.9.18 '@types/range-parser': 1.2.7 '@types/send': 0.17.4 @@ -13843,7 +13863,7 @@ snapshots: '@types/jsonwebtoken@9.0.8': dependencies: '@types/ms': 0.7.34 - '@types/node': 20.14.10 + '@types/node': 22.12.0 '@types/long@4.0.2': optional: true @@ -13893,6 +13913,10 @@ snapshots: '@types/range-parser@1.2.7': {} + '@types/rclone.js@0.6.3': + dependencies: + '@types/node': 22.12.0 + '@types/react-dom@19.0.6(@types/react@19.0.6)': dependencies: '@types/react': 19.0.6 @@ -13912,7 +13936,7 @@ snapshots: '@types/request@2.48.12': dependencies: '@types/caseless': 0.12.5 - '@types/node': 20.14.10 + '@types/node': 22.12.0 '@types/tough-cookie': 4.0.5 form-data: 2.5.2 optional: true @@ -13920,12 +13944,12 @@ snapshots: '@types/send@0.17.4': dependencies: '@types/mime': 1.3.5 - '@types/node': 20.14.10 + '@types/node': 22.12.0 '@types/serve-static@1.15.7': dependencies: '@types/http-errors': 2.0.4 - '@types/node': 20.14.10 + '@types/node': 22.12.0 '@types/send': 0.17.4 '@types/tough-cookie@4.0.5': @@ -13935,7 +13959,7 @@ snapshots: '@types/ws@8.5.14': dependencies: - '@types/node': 20.14.10 + '@types/node': 22.12.0 '@types/yargs-parser@21.0.3': {} @@ -14559,6 +14583,8 @@ snapshots: acorn@8.15.0: {} + adm-zip@0.5.17: {} + agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -18986,7 +19012,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 20.14.10 + '@types/node': 22.12.0 long: 5.2.4 proxy-addr@2.0.7: @@ -19060,6 +19086,11 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + rclone.js@0.6.6: + dependencies: + adm-zip: 0.5.17 + mri: 1.2.0 + react-dom@19.0.6(react@19.0.6): dependencies: react: 19.0.6