The server module of this site is Kotlin/JS targeting a Cloudflare Worker. Wire encodes a proto, the Worker hands the bytes back as a Response with content-type: application/x-protobuf, and the Wasm client decodes it. Simple wire contract, weird runtime shape.
The shape of a Worker
Cloudflare Workers run as ES modules with a default { fetch } export. Kotlin/JS emits a library, not an entry point, so I have a tiny shim:
// server/worker-entry.mjs
import { fetch } from "./build/dist/js/productionLibrary/evan-site-server.mjs";
export default { fetch };
wrangler.toml points main at the shim. The Kotlin side exports a top-level fetch(request, env, ctx) and Wire does the rest. That part is boring once it works.
The symptom
Every proto endpoint returned a 500. wrangler tail showed the Worker throwing inside the response constructor — something about an invalid typed-array length. The healthz route (plain text) was fine. Anything that went through protoResponse died.
The code
ProtoResponse.kt had a ByteArray.toJsByteArray() extension that allocates a Uint8Array of the same size and copies bytes across:
private fun ByteArray.toJsByteArray(): dynamic {
val u8 = js("new Uint8Array(this.length)")
for (i in indices) u8[i] = this[i]
return u8
}
Looks fine. Reads fine. Reviews fine. Doesn't work.
Why it doesn't work
js(...) in Kotlin/JS is not an interpolation. It is a literal-substitution escape hatch — the string is dropped into the generated JavaScript verbatim. The Kotlin compiler does not rewrite identifiers inside it, does not rebind this, does not know length was supposed to mean the receiver's size.
So at runtime, this inside new Uint8Array(this.length) resolves to whatever the JS call site happens to bind. In the emitted code that's the enclosing module/global object, not the Kotlin ByteArray receiver. globalThis.length is undefined. new Uint8Array(undefined) coerces to new Uint8Array(0). Then the loop writes past index 0 into a zero-length buffer, the runtime throws, the Worker returns 500, and you stare at a stack trace that doesn't say any of that.
If you wanted the receiver, you'd have to spell it out — and even then, the receiver inside js(...) is the Kotlin object as JS sees it, not a ByteArray with a .length property. There is no clean reading of this code that does what it looks like it does.
The fix
Stop trying to be clever inside the string. Pull the size out into a Kotlin parameter and let the compiler hand it to js(...) as a normal binding:
private fun newUint8Array(size: Int): dynamic = js("new Uint8Array(size)")
private fun ByteArray.toJsByteArray(): dynamic {
val u8 = newUint8Array(size)
for (i in indices) u8[i] = this[i]
return u8
}
size inside the js(...) literal is now a parameter name the Kotlin compiler controls. It gets emitted as a real JS variable bound to the Int we passed in. this.size in the outer function is just normal Kotlin — ByteArray.size, no JS involved. Loop runs, bytes copy, Response constructs, Worker returns 200.
Whole diff was three lines. Took longer to find than to fix.
Lesson
js(...) is a footgun the same way inline assembly is a footgun: it bypasses the type system on purpose, and anything inside the string is the compiler's blind spot. Two rules I'm keeping:
-
Never reference
thisinsidejs(...). If you need the receiver, take it as a parameter. -
Anything more than one token belongs in a typed
externaldeclaration, not an inline string.
The reason this bug was hard to see is that the Kotlin reads correctly. The reason it was easy to fix is that once you stop trusting the string, the shape of the answer is obvious.