From 5519500f7a0c699314f29342cb6f40d3494ebe5f Mon Sep 17 00:00:00 2001 From: Uchio Kondo Date: Thu, 20 Aug 2026 21:14:21 +0900 Subject: [PATCH 1/5] feat: configure Cloudflare HTTP request size --- uzumibi-on-cloudflare-spike/package.json | 21 ++-- .../scripts/build-wasm.mjs | 85 ++++++++++++++++ uzumibi-on-cloudflare-spike/src/index.js | 94 ++---------------- .../src/index.vanilla.js | 96 ++---------------- .../src/request-buffer.js | 99 +++++++++++++++++++ .../test/request-buffer.spec.js | 64 ++++++++++++ uzumibi-on-cloudflare-spike/wasm-app/build.rs | 18 +++- .../wasm-app/src/lib.rs | 12 +++ 8 files changed, 303 insertions(+), 186 deletions(-) create mode 100644 uzumibi-on-cloudflare-spike/scripts/build-wasm.mjs create mode 100644 uzumibi-on-cloudflare-spike/src/request-buffer.js create mode 100644 uzumibi-on-cloudflare-spike/test/request-buffer.spec.js diff --git a/uzumibi-on-cloudflare-spike/package.json b/uzumibi-on-cloudflare-spike/package.json index 6dcb288..eba594b 100644 --- a/uzumibi-on-cloudflare-spike/package.json +++ b/uzumibi-on-cloudflare-spike/package.json @@ -2,15 +2,18 @@ "name": "uzumibi-on-cloudflare-spike", "version": "0.0.0", "private": true, + "uzumibi": { + "httpMaxBytes": 65536 + }, "scripts": { - "deploy": "npm run build:wasm:asyncify && wrangler deploy", - "dev": "npm run build:wasm:asyncify && wrangler dev", - "dev:vanilla": "npm run build:wasm:vanilla && wrangler dev --config wrangler.vanilla.jsonc", - "build:wasm:vanilla": "cargo build --package uzumibi-on-cloudflare-spike --target wasm32-unknown-unknown --release --no-default-features && cp -v -f ../target/wasm32-unknown-unknown/release/uzumibi_on_cloudflare_spike.wasm src/", - "build:wasm:asyncify": "cargo build --package uzumibi-on-cloudflare-spike --target wasm32-unknown-unknown --release --features enable-external && wasm-opt --enable-bulk-memory --enable-nontrapping-float-to-int --asyncify -O2 ../target/wasm32-unknown-unknown/release/uzumibi_on_cloudflare_spike.wasm -o ./src/uzumibi_on_cloudflare_spike.wasm", - "build:wasm:queue": "cargo build --package uzumibi-on-cloudflare-spike --target wasm32-unknown-unknown --release --features queue && wasm-opt --enable-bulk-memory --enable-nontrapping-float-to-int --asyncify -O2 ../target/wasm32-unknown-unknown/release/uzumibi_on_cloudflare_spike.wasm -o ./src/uzumibi_on_cloudflare_spike_queue.wasm", - "dev:queue": "npm run build:wasm:queue && wrangler dev --config wrangler.queue.jsonc", - "deploy:queue": "npm run build:wasm:queue && wrangler deploy --config wrangler.queue.jsonc", + "deploy": "pnpm run build:wasm:asyncify && wrangler deploy", + "dev": "pnpm run build:wasm:asyncify && wrangler dev", + "dev:vanilla": "pnpm run build:wasm:vanilla && wrangler dev --config wrangler.vanilla.jsonc", + "build:wasm:vanilla": "node scripts/build-wasm.mjs vanilla", + "build:wasm:asyncify": "node scripts/build-wasm.mjs asyncify", + "build:wasm:queue": "node scripts/build-wasm.mjs queue", + "dev:queue": "pnpm run build:wasm:queue && wrangler dev --config wrangler.queue.jsonc", + "deploy:queue": "pnpm run build:wasm:queue && wrangler deploy --config wrangler.queue.jsonc", "start": "wrangler dev", "test": "vitest" }, @@ -20,4 +23,4 @@ "vitest": "~3.2.0", "wrangler": "^4.54.0" } -} \ No newline at end of file +} diff --git a/uzumibi-on-cloudflare-spike/scripts/build-wasm.mjs b/uzumibi-on-cloudflare-spike/scripts/build-wasm.mjs new file mode 100644 index 0000000..a864227 --- /dev/null +++ b/uzumibi-on-cloudflare-spike/scripts/build-wasm.mjs @@ -0,0 +1,85 @@ +import { copyFileSync, readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const projectDir = join(dirname(fileURLToPath(import.meta.url)), ".."); +const packageJson = JSON.parse( + readFileSync(join(projectDir, "package.json"), "utf8"), +); + +const mode = process.argv[2] ?? "asyncify"; +const supportedModes = new Set(["vanilla", "asyncify", "queue"]); +if (!supportedModes.has(mode)) { + throw new Error(`Unknown build mode: ${mode}`); +} + +const cliOption = process.argv.find((arg) => + arg.startsWith("--http-max-bytes="), +); +const cliOptionIndex = process.argv.indexOf("--http-max-bytes"); +const cliValue = cliOption?.split("=", 2)[1] + ?? (cliOptionIndex >= 0 ? process.argv[cliOptionIndex + 1] : undefined); +const configuredValue = cliValue + ?? process.env.UZUMIBI_HTTP_MAX_BYTES + ?? packageJson.uzumibi?.httpMaxBytes + ?? 65536; +const httpMaxBytes = Number(configuredValue); + +if (!Number.isSafeInteger(httpMaxBytes) || httpMaxBytes <= 0 || httpMaxBytes > 0x7fffffff) { + throw new Error( + `Invalid HTTP maximum size: ${configuredValue}. ` + + "Expected an integer between 1 and 2147483647.", + ); +} + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: projectDir, + env: { + ...process.env, + UZUMIBI_HTTP_MAX_BYTES: String(httpMaxBytes), + }, + stdio: "inherit", + }); + if (result.error) throw result.error; + if (result.status !== 0) process.exit(result.status ?? 1); +} + +const cargoArgs = [ + "build", + "--package", "uzumibi-on-cloudflare-spike", + "--target", "wasm32-unknown-unknown", + "--release", +]; + +if (mode === "vanilla") { + cargoArgs.push("--no-default-features"); +} else { + cargoArgs.push("--features", mode === "queue" ? "queue" : "enable-external"); +} + +console.log(`Building ${mode} Wasm with HTTP maximum ${httpMaxBytes} bytes`); +run("cargo", cargoArgs); + +const source = join( + projectDir, + "../target/wasm32-unknown-unknown/release/uzumibi_on_cloudflare_spike.wasm", +); +const outputName = mode === "queue" + ? "uzumibi_on_cloudflare_spike_queue.wasm" + : "uzumibi_on_cloudflare_spike.wasm"; +const output = join(projectDir, "src", outputName); + +if (mode === "vanilla") { + copyFileSync(source, output); +} else { + run("wasm-opt", [ + "--enable-bulk-memory", + "--enable-nontrapping-float-to-int", + "--asyncify", + "-O2", + source, + "-o", output, + ]); +} diff --git a/uzumibi-on-cloudflare-spike/src/index.js b/uzumibi-on-cloudflare-spike/src/index.js index 388a946..53b56b2 100644 --- a/uzumibi-on-cloudflare-spike/src/index.js +++ b/uzumibi-on-cloudflare-spike/src/index.js @@ -1,6 +1,7 @@ import { DurableObject } from "cloudflare:workers"; import { instantiate } from "asyncify-wasm"; import mod from "./uzumibi_on_cloudflare_spike.wasm"; +import { RequestTooLargeError, writeRequestToWasm } from "./request-buffer.js"; const wasmModule = mod; @@ -25,8 +26,6 @@ export default { return new Response(null, { status: 404 }); } - const query = new URL(request.url).searchParams; - const kv = env.UZUMIBI_KV ?? null; // Durable Object stub retained for Uzumibi::LegacyKV. @@ -229,92 +228,13 @@ export default { const instance = await instantiate(wasmModule, importObject); const exports = instance.exports; - const reqResult = await exports.uzumibi_initialize_request(65536); - const reqOffset = Number(reqResult & 0xFFFFFFFFn); - if (reqOffset === 0) { - const errOffset = Number((reqResult >> 32n) & 0xFFFFFFFFn); - const buffer = new Uint8Array(exports.memory.buffer, errOffset); - let errStr = ""; - for (let i = 0; buffer[i] !== 0; i++) { - errStr += String.fromCharCode(buffer[i]); - } - throw new Error(`Failed to initialize request: ${errStr}`); - } - const requestBuffer = new Uint8Array(exports.memory.buffer, reqOffset, 65536); - - let pos = 0; - const dataView = new DataView(exports.memory.buffer, reqOffset); - - const method = encoder.encode(request.method); - requestBuffer.fill(0, pos, pos + 6); - requestBuffer.set(method.slice(0, 6), pos); - pos += 6; - - // Path size (u16 little-endian) - const pathBytes = encoder.encode(path); - dataView.setUint16(pos, pathBytes.length, true); - pos += 2; - - // Path - requestBuffer.set(pathBytes, pos); - pos += pathBytes.length; - - // Query string size (u16 little-endian) - const queryString = query.toString(); - const queryBytes = encoder.encode(queryString); - dataView.setUint16(pos, queryBytes.length, true); - pos += 2; - - // Query string - requestBuffer.set(queryBytes, pos); - pos += queryBytes.length; - - // Headers - const headers = []; - request.headers.forEach((value, key) => { - if (key.toLowerCase() !== 'cf-connecting-ip' && - key.toLowerCase() !== 'cf-ray' && - !key.toLowerCase().startsWith('x-')) { - headers.push({ key, value }); + try { + await writeRequestToWasm(exports, request); + } catch (error) { + if (error instanceof RequestTooLargeError) { + return new Response(error.message, { status: 413 }); } - }); - - // Headers count (u16 little-endian) - dataView.setUint16(pos, headers.length, true); - pos += 2; - - // Each header - for (const header of headers) { - // Header key size (u16 little-endian) - const keyBytes = encoder.encode(header.key); - dataView.setUint16(pos, keyBytes.length, true); - pos += 2; - - // Header key - requestBuffer.set(keyBytes, pos); - pos += keyBytes.length; - - // Header value size (u16 little-endian) - const valueBytes = encoder.encode(header.value); - dataView.setUint16(pos, valueBytes.length, true); - pos += 2; - - // Header value - requestBuffer.set(valueBytes, pos); - pos += valueBytes.length; - } - - // Request body size (u32 little-endian) - const bodyBytes = request.body ? new Uint8Array(await request.arrayBuffer()) : new Uint8Array(0); - dataView.setUint32(pos, bodyBytes.length, true); - pos += 4; - - // Request body - requestBuffer.set(bodyBytes, pos); - pos += bodyBytes.length; - - if (pos > 65536) { - throw new Error("Request data exceeds allocated buffer size"); + throw error; } const resResult = await exports.uzumibi_start_request(); diff --git a/uzumibi-on-cloudflare-spike/src/index.vanilla.js b/uzumibi-on-cloudflare-spike/src/index.vanilla.js index e61e534..1d2a492 100644 --- a/uzumibi-on-cloudflare-spike/src/index.vanilla.js +++ b/uzumibi-on-cloudflare-spike/src/index.vanilla.js @@ -1,4 +1,5 @@ import mod from "./uzumibi_on_cloudflare_spike.wasm"; +import { RequestTooLargeError, writeRequestToWasm } from "./request-buffer.js"; const importObject = { env: { @@ -19,101 +20,18 @@ const exports = instance.exports; export default { async fetch(request, env, ctx) { - const reqResult = exports.uzumibi_initialize_request(65536); - const reqOffset = Number(reqResult & 0xFFFFFFFFn); - if (reqOffset === 0) { - const errOffset = Number((reqResult >> 32n) & 0xFFFFFFFFn); - const decoder = new TextDecoder(); - let errStr = ""; - const buffer = new Uint8Array(exports.memory.buffer, errOffset); - for (let i = 0; buffer[i] !== 0; i++) { - errStr += String.fromCharCode(buffer[i]); - } - throw new Error(`Failed to initialize request: ${errStr}`); - } - const requestBuffer = new Uint8Array(exports.memory.buffer, reqOffset, 65536); const path = new URL(request.url).pathname; if (path === "/favicon.ico") { return new Response(null, { status: 404 }); } - const query = new URL(request.url).searchParams; - - let pos = 0; - const encoder = new TextEncoder(); - const dataView = new DataView(exports.memory.buffer, reqOffset); - - const method = encoder.encode(request.method); - requestBuffer.fill(0, pos, pos + 6); - requestBuffer.set(method.slice(0, 6), pos); - pos += 6; - - // Path size (u16 little-endian) - const pathBytes = encoder.encode(path); - dataView.setUint16(pos, pathBytes.length, true); - pos += 2; - - // Path - requestBuffer.set(pathBytes, pos); - pos += pathBytes.length; - - // Query string size (u16 little-endian) - const queryString = query.toString(); - const queryBytes = encoder.encode(queryString); - dataView.setUint16(pos, queryBytes.length, true); - pos += 2; - - // Query string - requestBuffer.set(queryBytes, pos); - pos += queryBytes.length; - - // Headers - const headers = []; - request.headers.forEach((value, key) => { - // 一般的なヘッダーのみ含める(必要に応じて調整) - if (key.toLowerCase() !== 'cf-connecting-ip' && - key.toLowerCase() !== 'cf-ray' && - !key.toLowerCase().startsWith('x-')) { - headers.push({ key, value }); + try { + await writeRequestToWasm(exports, request); + } catch (error) { + if (error instanceof RequestTooLargeError) { + return new Response(error.message, { status: 413 }); } - }); - - // Headers count (u16 little-endian) - dataView.setUint16(pos, headers.length, true); - pos += 2; - - // Each header - for (const header of headers) { - // Header key size (u16 little-endian) - const keyBytes = encoder.encode(header.key); - dataView.setUint16(pos, keyBytes.length, true); - pos += 2; - - // Header key - requestBuffer.set(keyBytes, pos); - pos += keyBytes.length; - - // Header value size (u16 little-endian) - const valueBytes = encoder.encode(header.value); - dataView.setUint16(pos, valueBytes.length, true); - pos += 2; - - // Header value - requestBuffer.set(valueBytes, pos); - pos += valueBytes.length; - } - - // Request body size (u32 little-endian) - const bodyBytes = request.body ? new Uint8Array(await request.arrayBuffer()) : new Uint8Array(0); - dataView.setUint32(pos, bodyBytes.length, true); - pos += 4; - - // Request body - requestBuffer.set(bodyBytes, pos); - pos += bodyBytes.length; - - if (pos > 65536) { - throw new Error("Request data exceeds allocated buffer size"); + throw error; } const resResult = exports.uzumibi_start_request(); diff --git a/uzumibi-on-cloudflare-spike/src/request-buffer.js b/uzumibi-on-cloudflare-spike/src/request-buffer.js new file mode 100644 index 0000000..a17d2b8 --- /dev/null +++ b/uzumibi-on-cloudflare-spike/src/request-buffer.js @@ -0,0 +1,99 @@ +export class RequestTooLargeError extends Error { + constructor(actualBytes, maxBytes) { + super(`Encoded request size ${actualBytes} exceeds the configured maximum of ${maxBytes} bytes`); + this.name = "RequestTooLargeError"; + this.actualBytes = actualBytes; + this.maxBytes = maxBytes; + } +} + +function readError(exports, result, operation) { + const errorOffset = Number((result >> 32n) & 0xFFFFFFFFn); + const buffer = new Uint8Array(exports.memory.buffer, errorOffset); + let message = ""; + for (let i = 0; buffer[i] !== 0; i++) { + message += String.fromCharCode(buffer[i]); + } + return new Error(`Failed to ${operation}: ${message}`); +} + +export async function writeRequestToWasm(exports, request) { + const encoder = new TextEncoder(); + const url = new URL(request.url); + const methodBytes = encoder.encode(request.method); + const pathBytes = encoder.encode(url.pathname); + const queryBytes = encoder.encode(url.searchParams.toString()); + const bodyBytes = request.body + ? new Uint8Array(await request.arrayBuffer()) + : new Uint8Array(0); + + const headers = []; + request.headers.forEach((value, key) => { + if (key.toLowerCase() !== "cf-connecting-ip" + && key.toLowerCase() !== "cf-ray" + && !key.toLowerCase().startsWith("x-")) { + headers.push({ + key: encoder.encode(key), + value: encoder.encode(value), + }); + } + }); + + const headersSize = headers.reduce( + (size, header) => size + 2 + header.key.length + 2 + header.value.length, + 0, + ); + const requiredSize = 6 + + 2 + pathBytes.length + + 2 + queryBytes.length + + 2 + headersSize + + 4 + bodyBytes.length; + const maxBytes = Number(await exports.uzumibi_http_max_bytes()); + + if (requiredSize > maxBytes) { + throw new RequestTooLargeError(requiredSize, maxBytes); + } + + const result = await exports.uzumibi_initialize_request(requiredSize); + const offset = Number(result & 0xFFFFFFFFn); + if (offset === 0) { + throw readError(exports, result, "initialize request"); + } + + const requestBuffer = new Uint8Array(exports.memory.buffer, offset, requiredSize); + const dataView = new DataView(exports.memory.buffer, offset, requiredSize); + let pos = 0; + + requestBuffer.fill(0, pos, pos + 6); + requestBuffer.set(methodBytes.slice(0, 6), pos); + pos += 6; + + dataView.setUint16(pos, pathBytes.length, true); + pos += 2; + requestBuffer.set(pathBytes, pos); + pos += pathBytes.length; + + dataView.setUint16(pos, queryBytes.length, true); + pos += 2; + requestBuffer.set(queryBytes, pos); + pos += queryBytes.length; + + dataView.setUint16(pos, headers.length, true); + pos += 2; + for (const header of headers) { + dataView.setUint16(pos, header.key.length, true); + pos += 2; + requestBuffer.set(header.key, pos); + pos += header.key.length; + dataView.setUint16(pos, header.value.length, true); + pos += 2; + requestBuffer.set(header.value, pos); + pos += header.value.length; + } + + dataView.setUint32(pos, bodyBytes.length, true); + pos += 4; + requestBuffer.set(bodyBytes, pos); + + return requiredSize; +} diff --git a/uzumibi-on-cloudflare-spike/test/request-buffer.spec.js b/uzumibi-on-cloudflare-spike/test/request-buffer.spec.js new file mode 100644 index 0000000..b9fd12a --- /dev/null +++ b/uzumibi-on-cloudflare-spike/test/request-buffer.spec.js @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + RequestTooLargeError, + writeRequestToWasm, +} from "../src/request-buffer.js"; + +function createExports(maxBytes) { + const memory = new WebAssembly.Memory({ initial: 4 }); + let allocatedSize = 0; + + return { + exports: { + memory, + uzumibi_http_max_bytes: () => maxBytes, + uzumibi_initialize_request: (size) => { + allocatedSize = size; + return 1024n; + }, + }, + allocatedSize: () => allocatedSize, + }; +} + +describe("Cloudflare request buffer", () => { + it("allocates the encoded request size instead of the configured maximum", async () => { + const wasm = createExports(131072); + const request = new Request("https://example.com/items?q=uzumibi", { + method: "POST", + headers: { "content-type": "text/plain" }, + body: "hello", + }); + + const encodedSize = await writeRequestToWasm(wasm.exports, request); + + expect(wasm.allocatedSize()).toBe(encodedSize); + expect(encodedSize).toBeLessThan(131072); + }); + + it("accepts a request larger than the old 64 KiB limit", async () => { + const wasm = createExports(131072); + const request = new Request("https://example.com/upload", { + method: "POST", + body: new Uint8Array(70000), + }); + + const encodedSize = await writeRequestToWasm(wasm.exports, request); + + expect(encodedSize).toBeGreaterThan(65536); + expect(wasm.allocatedSize()).toBe(encodedSize); + }); + + it("rejects a request that exceeds the configured maximum", async () => { + const wasm = createExports(65536); + const request = new Request("https://example.com/upload", { + method: "POST", + body: new Uint8Array(70000), + }); + + await expect(writeRequestToWasm(wasm.exports, request)).rejects.toBeInstanceOf( + RequestTooLargeError, + ); + expect(wasm.allocatedSize()).toBe(0); + }); +}); diff --git a/uzumibi-on-cloudflare-spike/wasm-app/build.rs b/uzumibi-on-cloudflare-spike/wasm-app/build.rs index f94e82f..2176067 100644 --- a/uzumibi-on-cloudflare-spike/wasm-app/build.rs +++ b/uzumibi-on-cloudflare-spike/wasm-app/build.rs @@ -1,9 +1,25 @@ -use std::path::Path; +use std::{fs, path::Path}; extern crate mruby_compiler2_sys; fn main() { let out_dir = std::env::var("OUT_DIR").unwrap(); + let http_max_bytes = std::env::var("UZUMIBI_HTTP_MAX_BYTES") + .unwrap_or_else(|_| "65536".to_string()) + .parse::() + .expect("UZUMIBI_HTTP_MAX_BYTES must be a positive 32-bit integer"); + assert!( + http_max_bytes > 0 && http_max_bytes <= i32::MAX as u32, + "UZUMIBI_HTTP_MAX_BYTES must be between 1 and {}", + i32::MAX + ); + fs::write( + Path::new(&out_dir).join("uzumibi_config.rs"), + format!("pub const HTTP_MAX_BYTES: u32 = {http_max_bytes};\n"), + ) + .expect("failed to write Uzumibi build configuration"); + println!("cargo:rerun-if-env-changed=UZUMIBI_HTTP_MAX_BYTES"); + let mrb_path = Path::new(&out_dir).join("app.mrb"); let code = include_str!("../lib/app.rb"); println!("cargo:rerun-if-changed=../lib/app.rb"); diff --git a/uzumibi-on-cloudflare-spike/wasm-app/src/lib.rs b/uzumibi-on-cloudflare-spike/wasm-app/src/lib.rs index 0317321..b9f9305 100644 --- a/uzumibi-on-cloudflare-spike/wasm-app/src/lib.rs +++ b/uzumibi-on-cloudflare-spike/wasm-app/src/lib.rs @@ -14,6 +14,8 @@ use mrubyedge::{ }, }; +include!(concat!(env!("OUT_DIR"), "/uzumibi_config.rs")); + #[cfg(not(feature = "queue"))] static MRB: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/app.mrb")); #[cfg(feature = "queue")] @@ -61,6 +63,11 @@ fn assume_init_vm() -> Result<&'static mut VM, mrubyedge::Error> { } fn do_uzumibi_initialize_request(size: i32) -> Result<*mut u8, mrubyedge::Error> { + if size <= 0 || size as u32 > HTTP_MAX_BYTES { + return Err(mrubyedge::Error::RuntimeError(format!( + "Request size {size} exceeds the configured maximum of {HTTP_MAX_BYTES} bytes" + ))); + } let vm = assume_init_vm()?; let size = RObject::integer(size as i64).to_refcount_assigned(); let app = vm @@ -71,6 +78,11 @@ fn do_uzumibi_initialize_request(size: i32) -> Result<*mut u8, mrubyedge::Error> ret.as_ref().try_into() } +#[unsafe(export_name = "uzumibi_http_max_bytes")] +extern "C" fn uzumibi_http_max_bytes() -> u32 { + HTTP_MAX_BYTES +} + fn do_uzumibi_start_request() -> Result<*mut u8, mrubyedge::Error> { uzumibi_cloudflare_ext::debug_console_log_internal("uzumibi_start_request called"); let vm = assume_init_vm()?; From c1aef911dc1e2e5256e9e438055a78e2fe0264c0 Mon Sep 17 00:00:00 2001 From: Uchio Kondo Date: Thu, 20 Aug 2026 21:28:08 +0900 Subject: [PATCH 2/5] feat: configure Cloudflare template request size --- .../__features__/enable-external/package.json | 11 ++- .../__features__/enable-external/src/index.js | 94 ++---------------- .../enable-external/wasm-app/src/lib.rs | 12 +++ .../__features__/queue/package.json | 11 ++- .../__features__/queue/wasm-app/build.rs | 18 +++- .../__features__/queue/wasm-app/src/lib.rs | 12 +++ uzumibi-cli/templates/cloudflare/package.json | 10 +- .../cloudflare/scripts/build-wasm.mjs | 85 ++++++++++++++++ uzumibi-cli/templates/cloudflare/src/index.js | 96 ++---------------- .../cloudflare/src/request-buffer.js | 99 +++++++++++++++++++ .../cloudflare/test/request-buffer.spec.js | 64 ++++++++++++ .../templates/cloudflare/wasm-app/build.rs | 18 +++- .../templates/cloudflare/wasm-app/src/lib.rs | 12 +++ uzumibi-cli/tests/runn/new_cloudflare.yml | 6 ++ 14 files changed, 359 insertions(+), 189 deletions(-) create mode 100644 uzumibi-cli/templates/cloudflare/scripts/build-wasm.mjs create mode 100644 uzumibi-cli/templates/cloudflare/src/request-buffer.js create mode 100644 uzumibi-cli/templates/cloudflare/test/request-buffer.spec.js diff --git a/uzumibi-cli/templates/cloudflare/__features__/enable-external/package.json b/uzumibi-cli/templates/cloudflare/__features__/enable-external/package.json index c04421a..3e09d6d 100644 --- a/uzumibi-cli/templates/cloudflare/__features__/enable-external/package.json +++ b/uzumibi-cli/templates/cloudflare/__features__/enable-external/package.json @@ -2,10 +2,13 @@ "name": "$$PROJECT_NAME$$", "version": "0.0.0", "private": true, + "uzumibi": { + "httpMaxBytes": 65536 + }, "scripts": { - "deploy": "npm run build:wasm:asyncify && wrangler deploy", - "dev": "npm run build:wasm:asyncify && wrangler dev", - "build:wasm:asyncify": "cargo build --package $$PROJECT_NAME$$ --target wasm32-unknown-unknown --release --features enable-external && wasm-opt --enable-bulk-memory --enable-nontrapping-float-to-int --asyncify -O2 target/wasm32-unknown-unknown/release/$$PROJECT_NAME_UNDERSCORE$$.wasm -o ./src/$$PROJECT_NAME_UNDERSCORE$$.wasm", + "deploy": "pnpm run build:wasm:asyncify && wrangler deploy", + "dev": "pnpm run build:wasm:asyncify && wrangler dev", + "build:wasm:asyncify": "node scripts/build-wasm.mjs asyncify", "start": "wrangler dev", "test": "vitest" }, @@ -15,4 +18,4 @@ "vitest": "~3.2.0", "wrangler": "^4.54.0" } -} \ No newline at end of file +} diff --git a/uzumibi-cli/templates/cloudflare/__features__/enable-external/src/index.js b/uzumibi-cli/templates/cloudflare/__features__/enable-external/src/index.js index 537c981..87096a4 100644 --- a/uzumibi-cli/templates/cloudflare/__features__/enable-external/src/index.js +++ b/uzumibi-cli/templates/cloudflare/__features__/enable-external/src/index.js @@ -1,6 +1,7 @@ import { DurableObject } from "cloudflare:workers"; import { instantiate } from "asyncify-wasm"; import mod from "./$$PROJECT_NAME_UNDERSCORE$$.wasm"; +import { RequestTooLargeError, writeRequestToWasm } from "./request-buffer.js"; const wasmModule = mod; @@ -25,8 +26,6 @@ export default { return new Response(null, { status: 404 }); } - const query = new URL(request.url).searchParams; - const kv = env.UZUMIBI_KV ?? null; // Durable Object stub retained for Uzumibi::LegacyKV. @@ -229,92 +228,13 @@ export default { const instance = await instantiate(wasmModule, importObject); const exports = instance.exports; - const reqResult = await exports.uzumibi_initialize_request(65536); - const reqOffset = Number(reqResult & 0xFFFFFFFFn); - if (reqOffset === 0) { - const errOffset = Number((reqResult >> 32n) & 0xFFFFFFFFn); - const buffer = new Uint8Array(exports.memory.buffer, errOffset); - let errStr = ""; - for (let i = 0; buffer[i] !== 0; i++) { - errStr += String.fromCharCode(buffer[i]); - } - throw new Error(`Failed to initialize request: ${errStr}`); - } - const requestBuffer = new Uint8Array(exports.memory.buffer, reqOffset, 65536); - - let pos = 0; - const dataView = new DataView(exports.memory.buffer, reqOffset); - - const method = encoder.encode(request.method); - requestBuffer.fill(0, pos, pos + 6); - requestBuffer.set(method.slice(0, 6), pos); - pos += 6; - - // Path size (u16 little-endian) - const pathBytes = encoder.encode(path); - dataView.setUint16(pos, pathBytes.length, true); - pos += 2; - - // Path - requestBuffer.set(pathBytes, pos); - pos += pathBytes.length; - - // Query string size (u16 little-endian) - const queryString = query.toString(); - const queryBytes = encoder.encode(queryString); - dataView.setUint16(pos, queryBytes.length, true); - pos += 2; - - // Query string - requestBuffer.set(queryBytes, pos); - pos += queryBytes.length; - - // Headers - const headers = []; - request.headers.forEach((value, key) => { - if (key.toLowerCase() !== 'cf-connecting-ip' && - key.toLowerCase() !== 'cf-ray' && - !key.toLowerCase().startsWith('x-')) { - headers.push({ key, value }); + try { + await writeRequestToWasm(exports, request); + } catch (error) { + if (error instanceof RequestTooLargeError) { + return new Response(error.message, { status: 413 }); } - }); - - // Headers count (u16 little-endian) - dataView.setUint16(pos, headers.length, true); - pos += 2; - - // Each header - for (const header of headers) { - // Header key size (u16 little-endian) - const keyBytes = encoder.encode(header.key); - dataView.setUint16(pos, keyBytes.length, true); - pos += 2; - - // Header key - requestBuffer.set(keyBytes, pos); - pos += keyBytes.length; - - // Header value size (u16 little-endian) - const valueBytes = encoder.encode(header.value); - dataView.setUint16(pos, valueBytes.length, true); - pos += 2; - - // Header value - requestBuffer.set(valueBytes, pos); - pos += valueBytes.length; - } - - // Request body size (u32 little-endian) - const bodyBytes = request.body ? new Uint8Array(await request.arrayBuffer()) : new Uint8Array(0); - dataView.setUint32(pos, bodyBytes.length, true); - pos += 4; - - // Request body - requestBuffer.set(bodyBytes, pos); - pos += bodyBytes.length; - - if (pos > 65536) { - throw new Error("Request data exceeds allocated buffer size"); + throw error; } const resResult = await exports.uzumibi_start_request(); diff --git a/uzumibi-cli/templates/cloudflare/__features__/enable-external/wasm-app/src/lib.rs b/uzumibi-cli/templates/cloudflare/__features__/enable-external/wasm-app/src/lib.rs index 9a667f1..ce85618 100644 --- a/uzumibi-cli/templates/cloudflare/__features__/enable-external/wasm-app/src/lib.rs +++ b/uzumibi-cli/templates/cloudflare/__features__/enable-external/wasm-app/src/lib.rs @@ -14,6 +14,8 @@ use mrubyedge::{ }, }; +include!(concat!(env!("OUT_DIR"), "/uzumibi_config.rs")); + static MRB: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/app.mrb")); static mut MRUBY_VM: MaybeUninit = MaybeUninit::uninit(); @@ -58,6 +60,11 @@ fn assume_init_vm() -> Result<&'static mut VM, mrubyedge::Error> { } fn do_uzumibi_initialize_request(size: i32) -> Result<*mut u8, mrubyedge::Error> { + if size <= 0 || size as u32 > HTTP_MAX_BYTES { + return Err(mrubyedge::Error::RuntimeError(format!( + "Request size {size} exceeds the configured maximum of {HTTP_MAX_BYTES} bytes" + ))); + } let vm = assume_init_vm()?; let size = RObject::integer(size as i64).to_refcount_assigned(); let app = vm @@ -68,6 +75,11 @@ fn do_uzumibi_initialize_request(size: i32) -> Result<*mut u8, mrubyedge::Error> ret.as_ref().try_into() } +#[unsafe(export_name = "uzumibi_http_max_bytes")] +extern "C" fn uzumibi_http_max_bytes() -> u32 { + HTTP_MAX_BYTES +} + fn do_uzumibi_start_request() -> Result<*mut u8, mrubyedge::Error> { uzumibi_cloudflare_ext::debug_console_log_internal("uzumibi_start_request called"); let vm = assume_init_vm()?; diff --git a/uzumibi-cli/templates/cloudflare/__features__/queue/package.json b/uzumibi-cli/templates/cloudflare/__features__/queue/package.json index 3304c76..f7a66e9 100644 --- a/uzumibi-cli/templates/cloudflare/__features__/queue/package.json +++ b/uzumibi-cli/templates/cloudflare/__features__/queue/package.json @@ -2,10 +2,13 @@ "name": "$$PROJECT_NAME$$", "version": "0.0.0", "private": true, + "uzumibi": { + "httpMaxBytes": 65536 + }, "scripts": { - "deploy": "npm run build:wasm:asyncify && wrangler deploy", - "dev": "npm run build:wasm:asyncify && wrangler dev", - "build:wasm:asyncify": "cargo build --package $$PROJECT_NAME$$ --target wasm32-unknown-unknown --release --features queue && wasm-opt --enable-bulk-memory --enable-nontrapping-float-to-int --asyncify -O2 target/wasm32-unknown-unknown/release/$$PROJECT_NAME_UNDERSCORE$$.wasm -o ./src/$$PROJECT_NAME_UNDERSCORE$$_queue.wasm", + "deploy": "pnpm run build:wasm:queue && wrangler deploy", + "dev": "pnpm run build:wasm:queue && wrangler dev", + "build:wasm:queue": "node scripts/build-wasm.mjs queue", "start": "wrangler dev", "test": "vitest" }, @@ -15,4 +18,4 @@ "vitest": "~3.2.0", "wrangler": "^4.54.0" } -} \ No newline at end of file +} diff --git a/uzumibi-cli/templates/cloudflare/__features__/queue/wasm-app/build.rs b/uzumibi-cli/templates/cloudflare/__features__/queue/wasm-app/build.rs index 47fbb9b..5f5ffbe 100644 --- a/uzumibi-cli/templates/cloudflare/__features__/queue/wasm-app/build.rs +++ b/uzumibi-cli/templates/cloudflare/__features__/queue/wasm-app/build.rs @@ -1,9 +1,25 @@ -use std::path::Path; +use std::{fs, path::Path}; extern crate mruby_compiler2_sys; fn main() { let out_dir = std::env::var("OUT_DIR").unwrap(); + let http_max_bytes = std::env::var("UZUMIBI_HTTP_MAX_BYTES") + .unwrap_or_else(|_| "65536".to_string()) + .parse::() + .expect("UZUMIBI_HTTP_MAX_BYTES must be a positive 32-bit integer"); + assert!( + http_max_bytes > 0 && http_max_bytes <= i32::MAX as u32, + "UZUMIBI_HTTP_MAX_BYTES must be between 1 and {}", + i32::MAX + ); + fs::write( + Path::new(&out_dir).join("uzumibi_config.rs"), + format!("pub const HTTP_MAX_BYTES: u32 = {http_max_bytes};\n"), + ) + .expect("failed to write Uzumibi build configuration"); + println!("cargo:rerun-if-env-changed=UZUMIBI_HTTP_MAX_BYTES"); + let mrb_path = Path::new(&out_dir).join("consumer.mrb"); let code = include_str!("../lib/consumer.rb"); println!("cargo:rerun-if-changed=../lib/consumer.rb"); diff --git a/uzumibi-cli/templates/cloudflare/__features__/queue/wasm-app/src/lib.rs b/uzumibi-cli/templates/cloudflare/__features__/queue/wasm-app/src/lib.rs index 0317321..b9f9305 100644 --- a/uzumibi-cli/templates/cloudflare/__features__/queue/wasm-app/src/lib.rs +++ b/uzumibi-cli/templates/cloudflare/__features__/queue/wasm-app/src/lib.rs @@ -14,6 +14,8 @@ use mrubyedge::{ }, }; +include!(concat!(env!("OUT_DIR"), "/uzumibi_config.rs")); + #[cfg(not(feature = "queue"))] static MRB: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/app.mrb")); #[cfg(feature = "queue")] @@ -61,6 +63,11 @@ fn assume_init_vm() -> Result<&'static mut VM, mrubyedge::Error> { } fn do_uzumibi_initialize_request(size: i32) -> Result<*mut u8, mrubyedge::Error> { + if size <= 0 || size as u32 > HTTP_MAX_BYTES { + return Err(mrubyedge::Error::RuntimeError(format!( + "Request size {size} exceeds the configured maximum of {HTTP_MAX_BYTES} bytes" + ))); + } let vm = assume_init_vm()?; let size = RObject::integer(size as i64).to_refcount_assigned(); let app = vm @@ -71,6 +78,11 @@ fn do_uzumibi_initialize_request(size: i32) -> Result<*mut u8, mrubyedge::Error> ret.as_ref().try_into() } +#[unsafe(export_name = "uzumibi_http_max_bytes")] +extern "C" fn uzumibi_http_max_bytes() -> u32 { + HTTP_MAX_BYTES +} + fn do_uzumibi_start_request() -> Result<*mut u8, mrubyedge::Error> { uzumibi_cloudflare_ext::debug_console_log_internal("uzumibi_start_request called"); let vm = assume_init_vm()?; diff --git a/uzumibi-cli/templates/cloudflare/package.json b/uzumibi-cli/templates/cloudflare/package.json index 08e5fdd..bea0376 100644 --- a/uzumibi-cli/templates/cloudflare/package.json +++ b/uzumibi-cli/templates/cloudflare/package.json @@ -2,9 +2,13 @@ "name": "$$PROJECT_NAME$$", "version": "0.0.0", "private": true, + "uzumibi": { + "httpMaxBytes": 65536 + }, "scripts": { - "deploy": "wrangler deploy", - "dev": "cargo build --package $$PROJECT_NAME$$ --target wasm32-unknown-unknown --release && cp -v -f target/wasm32-unknown-unknown/release/$$PROJECT_NAME_UNDERSCORE$$.wasm src/ && wrangler dev", + "deploy": "pnpm run build:wasm:vanilla && wrangler deploy", + "dev": "pnpm run build:wasm:vanilla && wrangler dev", + "build:wasm:vanilla": "node scripts/build-wasm.mjs vanilla", "start": "wrangler dev", "test": "vitest" }, @@ -13,4 +17,4 @@ "vitest": "~3.2.0", "wrangler": "^4.54.0" } -} \ No newline at end of file +} diff --git a/uzumibi-cli/templates/cloudflare/scripts/build-wasm.mjs b/uzumibi-cli/templates/cloudflare/scripts/build-wasm.mjs new file mode 100644 index 0000000..423d57e --- /dev/null +++ b/uzumibi-cli/templates/cloudflare/scripts/build-wasm.mjs @@ -0,0 +1,85 @@ +import { copyFileSync, readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const projectDir = join(dirname(fileURLToPath(import.meta.url)), ".."); +const packageJson = JSON.parse( + readFileSync(join(projectDir, "package.json"), "utf8"), +); + +const mode = process.argv[2] ?? "asyncify"; +const supportedModes = new Set(["vanilla", "asyncify", "queue"]); +if (!supportedModes.has(mode)) { + throw new Error(`Unknown build mode: ${mode}`); +} + +const cliOption = process.argv.find((arg) => + arg.startsWith("--http-max-bytes="), +); +const cliOptionIndex = process.argv.indexOf("--http-max-bytes"); +const cliValue = cliOption?.split("=", 2)[1] + ?? (cliOptionIndex >= 0 ? process.argv[cliOptionIndex + 1] : undefined); +const configuredValue = cliValue + ?? process.env.UZUMIBI_HTTP_MAX_BYTES + ?? packageJson.uzumibi?.httpMaxBytes + ?? 65536; +const httpMaxBytes = Number(configuredValue); + +if (!Number.isSafeInteger(httpMaxBytes) || httpMaxBytes <= 0 || httpMaxBytes > 0x7fffffff) { + throw new Error( + `Invalid HTTP maximum size: ${configuredValue}. ` + + "Expected an integer between 1 and 2147483647.", + ); +} + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: projectDir, + env: { + ...process.env, + UZUMIBI_HTTP_MAX_BYTES: String(httpMaxBytes), + }, + stdio: "inherit", + }); + if (result.error) throw result.error; + if (result.status !== 0) process.exit(result.status ?? 1); +} + +const cargoArgs = [ + "build", + "--package", "$$PROJECT_NAME$$", + "--target", "wasm32-unknown-unknown", + "--release", +]; + +if (mode === "vanilla") { + cargoArgs.push("--no-default-features"); +} else { + cargoArgs.push("--features", mode === "queue" ? "queue" : "enable-external"); +} + +console.log(`Building ${mode} Wasm with HTTP maximum ${httpMaxBytes} bytes`); +run("cargo", cargoArgs); + +const source = join( + projectDir, + "target/wasm32-unknown-unknown/release/$$PROJECT_NAME_UNDERSCORE$$.wasm", +); +const outputName = mode === "queue" + ? "$$PROJECT_NAME_UNDERSCORE$$_queue.wasm" + : "$$PROJECT_NAME_UNDERSCORE$$.wasm"; +const output = join(projectDir, "src", outputName); + +if (mode === "vanilla") { + copyFileSync(source, output); +} else { + run("wasm-opt", [ + "--enable-bulk-memory", + "--enable-nontrapping-float-to-int", + "--asyncify", + "-O2", + source, + "-o", output, + ]); +} diff --git a/uzumibi-cli/templates/cloudflare/src/index.js b/uzumibi-cli/templates/cloudflare/src/index.js index 943618f..0c9457b 100644 --- a/uzumibi-cli/templates/cloudflare/src/index.js +++ b/uzumibi-cli/templates/cloudflare/src/index.js @@ -1,4 +1,5 @@ import mod from "./$$PROJECT_NAME_UNDERSCORE$$.wasm"; +import { RequestTooLargeError, writeRequestToWasm } from "./request-buffer.js"; const importObject = { env: { @@ -19,101 +20,18 @@ const exports = instance.exports; export default { async fetch(request, env, ctx) { - const reqResult = exports.uzumibi_initialize_request(65536); - const reqOffset = Number(reqResult & 0xFFFFFFFFn); - if (reqOffset === 0) { - const errOffset = Number((reqResult >> 32n) & 0xFFFFFFFFn); - const decoder = new TextDecoder(); - let errStr = ""; - const buffer = new Uint8Array(exports.memory.buffer, errOffset); - for (let i = 0; buffer[i] !== 0; i++) { - errStr += String.fromCharCode(buffer[i]); - } - throw new Error(`Failed to initialize request: ${errStr}`); - } - const requestBuffer = new Uint8Array(exports.memory.buffer, reqOffset, 65536); const path = new URL(request.url).pathname; if (path === "/favicon.ico") { return new Response(null, { status: 404 }); } - const query = new URL(request.url).searchParams; - - let pos = 0; - const encoder = new TextEncoder(); - const dataView = new DataView(exports.memory.buffer, reqOffset); - - const method = encoder.encode(request.method); - requestBuffer.fill(0, pos, pos + 6); - requestBuffer.set(method.slice(0, 6), pos); - pos += 6; - - // Path size (u16 little-endian) - const pathBytes = encoder.encode(path); - dataView.setUint16(pos, pathBytes.length, true); - pos += 2; - - // Path - requestBuffer.set(pathBytes, pos); - pos += pathBytes.length; - - // Query string size (u16 little-endian) - const queryString = query.toString(); - const queryBytes = encoder.encode(queryString); - dataView.setUint16(pos, queryBytes.length, true); - pos += 2; - - // Query string - requestBuffer.set(queryBytes, pos); - pos += queryBytes.length; - - // Headers - const headers = []; - request.headers.forEach((value, key) => { - // 一般的なヘッダーのみ含める(必要に応じて調整) - if (key.toLowerCase() !== 'cf-connecting-ip' && - key.toLowerCase() !== 'cf-ray' && - !key.toLowerCase().startsWith('x-')) { - headers.push({ key, value }); + try { + await writeRequestToWasm(exports, request); + } catch (error) { + if (error instanceof RequestTooLargeError) { + return new Response(error.message, { status: 413 }); } - }); - - // Headers count (u16 little-endian) - dataView.setUint16(pos, headers.length, true); - pos += 2; - - // Each header - for (const header of headers) { - // Header key size (u16 little-endian) - const keyBytes = encoder.encode(header.key); - dataView.setUint16(pos, keyBytes.length, true); - pos += 2; - - // Header key - requestBuffer.set(keyBytes, pos); - pos += keyBytes.length; - - // Header value size (u16 little-endian) - const valueBytes = encoder.encode(header.value); - dataView.setUint16(pos, valueBytes.length, true); - pos += 2; - - // Header value - requestBuffer.set(valueBytes, pos); - pos += valueBytes.length; - } - - // Request body size (u32 little-endian) - const bodyBytes = request.body ? new Uint8Array(await request.arrayBuffer()) : new Uint8Array(0); - dataView.setUint32(pos, bodyBytes.length, true); - pos += 4; - - // Request body - requestBuffer.set(bodyBytes, pos); - pos += bodyBytes.length; - - if (pos > 65536) { - throw new Error("Request data exceeds allocated buffer size"); + throw error; } const resResult = exports.uzumibi_start_request(); diff --git a/uzumibi-cli/templates/cloudflare/src/request-buffer.js b/uzumibi-cli/templates/cloudflare/src/request-buffer.js new file mode 100644 index 0000000..a17d2b8 --- /dev/null +++ b/uzumibi-cli/templates/cloudflare/src/request-buffer.js @@ -0,0 +1,99 @@ +export class RequestTooLargeError extends Error { + constructor(actualBytes, maxBytes) { + super(`Encoded request size ${actualBytes} exceeds the configured maximum of ${maxBytes} bytes`); + this.name = "RequestTooLargeError"; + this.actualBytes = actualBytes; + this.maxBytes = maxBytes; + } +} + +function readError(exports, result, operation) { + const errorOffset = Number((result >> 32n) & 0xFFFFFFFFn); + const buffer = new Uint8Array(exports.memory.buffer, errorOffset); + let message = ""; + for (let i = 0; buffer[i] !== 0; i++) { + message += String.fromCharCode(buffer[i]); + } + return new Error(`Failed to ${operation}: ${message}`); +} + +export async function writeRequestToWasm(exports, request) { + const encoder = new TextEncoder(); + const url = new URL(request.url); + const methodBytes = encoder.encode(request.method); + const pathBytes = encoder.encode(url.pathname); + const queryBytes = encoder.encode(url.searchParams.toString()); + const bodyBytes = request.body + ? new Uint8Array(await request.arrayBuffer()) + : new Uint8Array(0); + + const headers = []; + request.headers.forEach((value, key) => { + if (key.toLowerCase() !== "cf-connecting-ip" + && key.toLowerCase() !== "cf-ray" + && !key.toLowerCase().startsWith("x-")) { + headers.push({ + key: encoder.encode(key), + value: encoder.encode(value), + }); + } + }); + + const headersSize = headers.reduce( + (size, header) => size + 2 + header.key.length + 2 + header.value.length, + 0, + ); + const requiredSize = 6 + + 2 + pathBytes.length + + 2 + queryBytes.length + + 2 + headersSize + + 4 + bodyBytes.length; + const maxBytes = Number(await exports.uzumibi_http_max_bytes()); + + if (requiredSize > maxBytes) { + throw new RequestTooLargeError(requiredSize, maxBytes); + } + + const result = await exports.uzumibi_initialize_request(requiredSize); + const offset = Number(result & 0xFFFFFFFFn); + if (offset === 0) { + throw readError(exports, result, "initialize request"); + } + + const requestBuffer = new Uint8Array(exports.memory.buffer, offset, requiredSize); + const dataView = new DataView(exports.memory.buffer, offset, requiredSize); + let pos = 0; + + requestBuffer.fill(0, pos, pos + 6); + requestBuffer.set(methodBytes.slice(0, 6), pos); + pos += 6; + + dataView.setUint16(pos, pathBytes.length, true); + pos += 2; + requestBuffer.set(pathBytes, pos); + pos += pathBytes.length; + + dataView.setUint16(pos, queryBytes.length, true); + pos += 2; + requestBuffer.set(queryBytes, pos); + pos += queryBytes.length; + + dataView.setUint16(pos, headers.length, true); + pos += 2; + for (const header of headers) { + dataView.setUint16(pos, header.key.length, true); + pos += 2; + requestBuffer.set(header.key, pos); + pos += header.key.length; + dataView.setUint16(pos, header.value.length, true); + pos += 2; + requestBuffer.set(header.value, pos); + pos += header.value.length; + } + + dataView.setUint32(pos, bodyBytes.length, true); + pos += 4; + requestBuffer.set(bodyBytes, pos); + + return requiredSize; +} diff --git a/uzumibi-cli/templates/cloudflare/test/request-buffer.spec.js b/uzumibi-cli/templates/cloudflare/test/request-buffer.spec.js new file mode 100644 index 0000000..b9fd12a --- /dev/null +++ b/uzumibi-cli/templates/cloudflare/test/request-buffer.spec.js @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + RequestTooLargeError, + writeRequestToWasm, +} from "../src/request-buffer.js"; + +function createExports(maxBytes) { + const memory = new WebAssembly.Memory({ initial: 4 }); + let allocatedSize = 0; + + return { + exports: { + memory, + uzumibi_http_max_bytes: () => maxBytes, + uzumibi_initialize_request: (size) => { + allocatedSize = size; + return 1024n; + }, + }, + allocatedSize: () => allocatedSize, + }; +} + +describe("Cloudflare request buffer", () => { + it("allocates the encoded request size instead of the configured maximum", async () => { + const wasm = createExports(131072); + const request = new Request("https://example.com/items?q=uzumibi", { + method: "POST", + headers: { "content-type": "text/plain" }, + body: "hello", + }); + + const encodedSize = await writeRequestToWasm(wasm.exports, request); + + expect(wasm.allocatedSize()).toBe(encodedSize); + expect(encodedSize).toBeLessThan(131072); + }); + + it("accepts a request larger than the old 64 KiB limit", async () => { + const wasm = createExports(131072); + const request = new Request("https://example.com/upload", { + method: "POST", + body: new Uint8Array(70000), + }); + + const encodedSize = await writeRequestToWasm(wasm.exports, request); + + expect(encodedSize).toBeGreaterThan(65536); + expect(wasm.allocatedSize()).toBe(encodedSize); + }); + + it("rejects a request that exceeds the configured maximum", async () => { + const wasm = createExports(65536); + const request = new Request("https://example.com/upload", { + method: "POST", + body: new Uint8Array(70000), + }); + + await expect(writeRequestToWasm(wasm.exports, request)).rejects.toBeInstanceOf( + RequestTooLargeError, + ); + expect(wasm.allocatedSize()).toBe(0); + }); +}); diff --git a/uzumibi-cli/templates/cloudflare/wasm-app/build.rs b/uzumibi-cli/templates/cloudflare/wasm-app/build.rs index a35f9a7..3ce57eb 100644 --- a/uzumibi-cli/templates/cloudflare/wasm-app/build.rs +++ b/uzumibi-cli/templates/cloudflare/wasm-app/build.rs @@ -1,9 +1,25 @@ -use std::path::Path; +use std::{fs, path::Path}; extern crate mruby_compiler2_sys; fn main() { let out_dir = std::env::var("OUT_DIR").unwrap(); + let http_max_bytes = std::env::var("UZUMIBI_HTTP_MAX_BYTES") + .unwrap_or_else(|_| "65536".to_string()) + .parse::() + .expect("UZUMIBI_HTTP_MAX_BYTES must be a positive 32-bit integer"); + assert!( + http_max_bytes > 0 && http_max_bytes <= i32::MAX as u32, + "UZUMIBI_HTTP_MAX_BYTES must be between 1 and {}", + i32::MAX + ); + fs::write( + Path::new(&out_dir).join("uzumibi_config.rs"), + format!("pub const HTTP_MAX_BYTES: u32 = {http_max_bytes};\n"), + ) + .expect("failed to write Uzumibi build configuration"); + println!("cargo:rerun-if-env-changed=UZUMIBI_HTTP_MAX_BYTES"); + let mrb_path = Path::new(&out_dir).join("app.mrb"); let code = include_str!("../lib/app.rb"); println!("cargo:rerun-if-changed=../lib/app.rb"); diff --git a/uzumibi-cli/templates/cloudflare/wasm-app/src/lib.rs b/uzumibi-cli/templates/cloudflare/wasm-app/src/lib.rs index 9a667f1..ce85618 100644 --- a/uzumibi-cli/templates/cloudflare/wasm-app/src/lib.rs +++ b/uzumibi-cli/templates/cloudflare/wasm-app/src/lib.rs @@ -14,6 +14,8 @@ use mrubyedge::{ }, }; +include!(concat!(env!("OUT_DIR"), "/uzumibi_config.rs")); + static MRB: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/app.mrb")); static mut MRUBY_VM: MaybeUninit = MaybeUninit::uninit(); @@ -58,6 +60,11 @@ fn assume_init_vm() -> Result<&'static mut VM, mrubyedge::Error> { } fn do_uzumibi_initialize_request(size: i32) -> Result<*mut u8, mrubyedge::Error> { + if size <= 0 || size as u32 > HTTP_MAX_BYTES { + return Err(mrubyedge::Error::RuntimeError(format!( + "Request size {size} exceeds the configured maximum of {HTTP_MAX_BYTES} bytes" + ))); + } let vm = assume_init_vm()?; let size = RObject::integer(size as i64).to_refcount_assigned(); let app = vm @@ -68,6 +75,11 @@ fn do_uzumibi_initialize_request(size: i32) -> Result<*mut u8, mrubyedge::Error> ret.as_ref().try_into() } +#[unsafe(export_name = "uzumibi_http_max_bytes")] +extern "C" fn uzumibi_http_max_bytes() -> u32 { + HTTP_MAX_BYTES +} + fn do_uzumibi_start_request() -> Result<*mut u8, mrubyedge::Error> { uzumibi_cloudflare_ext::debug_console_log_internal("uzumibi_start_request called"); let vm = assume_init_vm()?; diff --git a/uzumibi-cli/tests/runn/new_cloudflare.yml b/uzumibi-cli/tests/runn/new_cloudflare.yml index faa354b..49552cb 100644 --- a/uzumibi-cli/tests/runn/new_cloudflare.yml +++ b/uzumibi-cli/tests/runn/new_cloudflare.yml @@ -54,6 +54,12 @@ steps: command: test -f {{ vars.tmpdir }}/{{ vars.project_name }}/wrangler.jsonc test: current.exit_code == 0 + check_http_max_bytes: + desc: Check configurable HTTP request size files + exec: + command: cd {{ vars.tmpdir }}/{{ vars.project_name }} && node -e 'const p = require("./package.json"); if (p.uzumibi.httpMaxBytes !== 65536) process.exit(1)' && test -f scripts/build-wasm.mjs && test -f src/request-buffer.js + test: current.exit_code == 0 + check_assets_index_html: desc: Check public/assets/index.html exists and contains expected content exec: From 72cf1741b3477491563e75d1503a478d15fb5764 Mon Sep 17 00:00:00 2001 From: Uchio Kondo Date: Thu, 20 Aug 2026 21:33:26 +0900 Subject: [PATCH 3/5] test: add Cloudflare Queue smoke scenario --- .../tests/runn/new_cloudflare_queue.yml | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 uzumibi-cli/tests/runn/new_cloudflare_queue.yml diff --git a/uzumibi-cli/tests/runn/new_cloudflare_queue.yml b/uzumibi-cli/tests/runn/new_cloudflare_queue.yml new file mode 100644 index 0000000..32a7781 --- /dev/null +++ b/uzumibi-cli/tests/runn/new_cloudflare_queue.yml @@ -0,0 +1,86 @@ +desc: Test uzumibi new command with Cloudflare Queue template +runners: + req: http://127.0.0.1:8787 +vars: + binary: ${UZUMIBI_TEST_BINARY:-../target/release/uzumibi} + tmpdir: ${UZUMIBI_TEST_TMPDIR:-../tmp} + project_name: ${UZUMIBI_PROJECT_NAME:-test-cloudflare-queue-project} +steps: + build: + desc: Build release binary + exec: + command: cargo build --release --quiet + test: current.exit_code == 0 + + setup_tmpdir: + desc: Setup temp directory + exec: + command: mkdir -p {{ vars.tmpdir }} + test: current.exit_code == 0 + + cleanup_before: + desc: Clean up existing test directory + exec: + command: rm -rf {{ vars.tmpdir }}/{{ vars.project_name }} + test: current.exit_code == 0 + + create_project: + desc: Create new Cloudflare Queue project + exec: + command: cd {{ vars.tmpdir }} && {{ vars.binary }} new -t cloudflare --features queue {{ vars.project_name }} + test: | + current.exit_code == 0 && + current.stdout contains 'Successfully created project' + + check_queue_files: + desc: Check Queue-specific files and configuration + exec: + command: cd {{ vars.tmpdir }}/{{ vars.project_name }} && test -f lib/consumer.rb && grep -q '"consumers"' wrangler.jsonc && node -e 'const p = require("./package.json"); if (p.uzumibi.httpMaxBytes !== 65536 || !p.scripts["build:wasm:queue"]) process.exit(1)' + test: current.exit_code == 0 + + pnpm_install: + desc: Install Node.js dependencies + exec: + command: cd {{ vars.tmpdir }}/{{ vars.project_name }} && pnpm install + test: current.exit_code == 0 + + build_queue_wasm: + desc: Build and asyncify the Queue WASM app + exec: + command: cd {{ vars.tmpdir }}/{{ vars.project_name }} && pnpm run build:wasm:queue + test: current.exit_code == 0 + + start_server: + desc: Start Queue Worker dev server in background + exec: + command: cd {{ vars.tmpdir }}/{{ vars.project_name }} && pnpm start + background: true + + wait_for_server: + desc: Wait for Queue Worker to start + exec: + command: for i in $(seq 1 300); do curl -s -o /dev/null http://127.0.0.1:8787 && exit 0; sleep 0.1; done; exit 1 + test: current.exit_code == 0 + + test_http_endpoint: + desc: Confirm Queue Worker rejects ordinary HTTP requests + req: + "/": + get: + body: null + test: | + current.res.status == 400 && + current.res.rawBody contains 'queue processing' + + kill_server: + desc: Kill dev server + exec: + command: kill -INT $(ps -ef | grep 'wrangler.js de[v]' | awk '{print $2}') || true + test: current.exit_code == 0 + + cleanup_after: + desc: Clean up test directory + exec: + command: rm -rf {{ vars.tmpdir }}/{{ vars.project_name }} + dump: steps + test: current.exit_code == 0 From e40e6be9ee675af4fad6caea99fd827b7bb91ae9 Mon Sep 17 00:00:00 2001 From: Uchio Kondo Date: Thu, 20 Aug 2026 21:40:02 +0900 Subject: [PATCH 4/5] Bump uzumibi-cli to 0.8.1 --- Cargo.lock | 2 +- uzumibi-cli/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 016f457..0ec1726 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2896,7 +2896,7 @@ dependencies = [ [[package]] name = "uzumibi-cli" -version = "0.8.0" +version = "0.8.1" dependencies = [ "clap", "dialoguer", diff --git a/uzumibi-cli/Cargo.toml b/uzumibi-cli/Cargo.toml index 07a4f8c..09ddf0e 100644 --- a/uzumibi-cli/Cargo.toml +++ b/uzumibi-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uzumibi-cli" -version = "0.8.0" +version = "0.8.1" edition = "2024" authors = ["Uchio Kondo "] description = "Uzumibi CLI tool to generate serverless mruby/edge apps" From 53dafcf399409317036642f096db1159c92b74a4 Mon Sep 17 00:00:00 2001 From: Uchio Kondo Date: Thu, 20 Aug 2026 21:43:24 +0900 Subject: [PATCH 5/5] Add Cloudflare Queue CI smoke test --- .github/workflows/runn-cli-tests.yml | 48 ++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/.github/workflows/runn-cli-tests.yml b/.github/workflows/runn-cli-tests.yml index 250a60c..53252be 100644 --- a/.github/workflows/runn-cli-tests.yml +++ b/.github/workflows/runn-cli-tests.yml @@ -148,6 +148,54 @@ jobs: UZUMIBI_PROJECT_NAME: test-cloudflare-project-${{ github.run_id }} run: runn run new_cloudflare.yml --verbose + cloudflare-queue-test: + name: Cloudflare Queue template test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install wasm32 target + run: rustup target add wasm32-unknown-unknown + + - name: Cache cargo registry and build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-cloudflare-queue-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-cloudflare-queue- + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Install wasm-opt + run: | + curl -L https://github.com/WebAssembly/binaryen/releases/download/version_120/binaryen-version_120-x86_64-linux.tar.gz | tar xz + sudo cp binaryen-version_120/bin/wasm-opt /usr/local/bin/ + + - name: Install runn + uses: k1LoW/gh-setup@v1 + with: + repo: k1LoW/runn + bin-match: runn + + - name: Run Cloudflare Queue smoke test + working-directory: uzumibi-cli/tests/runn + env: + RUNN_SCOPES: "read:parent,run:exec" + UZUMIBI_TEST_BINARY: ${{ github.workspace }}/target/release/uzumibi + UZUMIBI_TEST_TMPDIR: ${{ runner.temp }} + UZUMIBI_PROJECT_NAME: test-cloudflare-queue-project-${{ github.run_id }} + run: runn run new_cloudflare_queue.yml --verbose + cloudrun-test: name: Cloud Run template test runs-on: ubuntu-latest