Created
December 21, 2023 20:53
-
-
Save tmcw/47f98394bbfebb896879b04b0824ebc3 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
encodeBase64, | |
decodeBase64, | |
} from "https://deno.land/std@0.210.0/encoding/base64.ts"; | |
import process from "node:process"; | |
import SuperJSON from "npm:superjson@2.1.0"; | |
SuperJSON.registerCustom<Function, string>( | |
{ | |
isApplicable: (v: any): v is Function => typeof v === "function", | |
serialize: (_v: Function) => "", | |
deserialize: () => { | |
// This will cause typeOutput to detect a function | |
// and trigger the right behavior in ValEditor. | |
return Symbol.for("function") as unknown as Function; | |
}, | |
}, | |
"function" | |
); | |
/** | |
* Given an object of environment variables, create a stub | |
* that simulates the same interface as Deno.env | |
*/ | |
export function createDenoEnvStub( | |
input: Record<string, string> | |
): typeof Deno.env { | |
return { | |
get(key: string) { | |
return input[key]; | |
}, | |
has(key: string) { | |
return input[key] !== undefined; | |
}, | |
toObject() { | |
return { ...input }; | |
}, | |
set(_key: string, _value: string) { | |
// Stub | |
}, | |
delete(_key: string) { | |
// Stub | |
}, | |
}; | |
} | |
/** | |
* Given a Response object, serialize it. | |
* Note: if you try this twice on the same Response, it'll | |
* crash! Streams, like resp.arrayBuffer(), can only | |
* be consumed once. | |
*/ | |
export async function serializeResponse(resp: Response) { | |
return { | |
headers: [...resp.headers.entries()], | |
body: resp.body ? encodeBase64(await resp.arrayBuffer()) : null, | |
status: resp.status, | |
statusText: resp.statusText, | |
}; | |
} | |
/** | |
* Express API mocks ---------------------------------------------------------- | |
*/ | |
export class RequestLike { | |
#options: SerializedExpressRequest; | |
constructor(options: SerializedExpressRequest) { | |
this.#options = options; | |
} | |
// https://expressjs.com/en/api.html#req.method | |
get method() { | |
return this.#options.method; | |
} | |
// https://expressjs.com/en/api.html#req.query | |
get query() { | |
return this.#options.query; | |
} | |
get body() { | |
return this.#options.body; | |
} | |
/** | |
* Stubs | |
*/ | |
// https://expressjs.com/en/api.html#req.baseUrl | |
get baseUrl() { | |
return "/"; | |
} | |
// https://expressjs.com/en/api.html#req.params | |
get params() { | |
return {}; | |
} | |
// https://expressjs.com/en/api.html#req.secure | |
get secure() { | |
return true; | |
} | |
// https://expressjs.com/en/api.html#req.subdomains | |
get subdomains() { | |
return []; | |
} | |
// https://expressjs.com/en/api.html#req.fresh | |
get fresh() { | |
return true; | |
} | |
// https://expressjs.com/en/api.html#req.protocol | |
get protocol() { | |
return "https"; | |
} | |
// https://expressjs.com/en/api.html#req.path | |
get path() { | |
return this.#options.path; | |
} | |
// https://expressjs.com/en/4x/api.html#req.originalUrl | |
get originalUrl() { | |
return this.#options.originalUrl; | |
} | |
acceptsCharsets() { | |
return true; | |
} | |
acceptsEncodings() { | |
return true; | |
} | |
acceptsLanguages() { | |
return true; | |
} | |
accepts() { | |
return true; | |
} | |
get(name: string) { | |
return this.#options.headers[String(name).toLowerCase()]; | |
} | |
} | |
interface DoneMessage { | |
type: "done"; | |
wallTime: number; | |
} | |
interface ErrorMessage { | |
type: "error"; | |
value: unknown; | |
} | |
interface ReadyMessage { | |
type: "ready"; | |
} | |
interface ReturnMessage { | |
type: "return"; | |
value: unknown; | |
} | |
interface ExportsMessage { | |
type: "exports"; | |
value: unknown; | |
} | |
interface ExpressResponseMessage { | |
type: "expressresponse"; | |
name: string; | |
args: unknown[]; | |
} | |
type Message = | |
| DoneMessage | |
| ErrorMessage | |
| ReadyMessage | |
| ReturnMessage | |
| ExportsMessage | |
| ExpressResponseMessage; | |
export function send(message: Message) { | |
// We used to have a much smaller limit here. | |
// This one is just to ensure folks aren't sending crazy amount of data | |
if (JSON.stringify(message).length > 10_000_000) { | |
message = { | |
type: "error", | |
value: { | |
name: "WS_PAYLOAD_TOO_LARGE: " + message.type, | |
message: `The ${message.type} is too large to process`, | |
}, | |
}; | |
} | |
(self as any).postMessage(message); | |
} | |
export class ResponseLike { | |
#stub(name: string, args: unknown[]) { | |
send({ | |
type: "expressresponse", | |
name, | |
args, | |
}); | |
return this; | |
} | |
json(...args: unknown[]) { | |
return this.#stub("json", args); | |
} | |
jsonp(...args: unknown[]) { | |
return this.#stub("jsonp", args); | |
} | |
status(...args: unknown[]) { | |
return this.#stub("status", args); | |
} | |
send(...args: unknown[]) { | |
return this.#stub("send", args); | |
} | |
type(...args: unknown[]) { | |
return this.#stub("type", args); | |
} | |
get(...args: unknown[]) { | |
return this.#stub("get", args); | |
} | |
redirect(...args: unknown[]) { | |
return this.#stub("redirect", args); | |
} | |
end(...args: unknown[]) { | |
return this.#stub("end", args); | |
} | |
set(...args: unknown[]) { | |
return this.#stub("set", args); | |
} | |
} | |
/** | |
* Deserialization ------------------------------------------------------------ | |
*/ | |
const TAG = "@@valtown-type"; | |
function getDeserializationTag(arg: unknown): string | undefined { | |
if ( | |
arg && | |
typeof arg === "object" && | |
TAG in arg && | |
typeof arg[TAG] === "string" | |
) { | |
return arg[TAG]; | |
} | |
} | |
type SerializedRequest = { | |
[TAG]: "request"; | |
url: string; | |
method: string; | |
headers: [string, string][]; | |
body?: string; | |
}; | |
type SerializedExpressRequest = { | |
[TAG]: "express-request"; | |
method: string; | |
protocol: string; | |
hostname: string; | |
xhr: boolean; | |
body: string; | |
query: Record<string, string>; | |
headers: Record<string, string>; | |
path: string; | |
originalUrl: string; | |
}; | |
type SerializedExpressResponse = { | |
[TAG]: "express-response"; | |
}; | |
function deserializeRequest(arg: SerializedRequest) { | |
return new Request(arg.url, { | |
method: arg.method, | |
headers: arg.headers, | |
...(arg.body ? { body: decodeBase64(arg.body) } : {}), | |
}); | |
} | |
function deserializeExpressRequest(arg: SerializedExpressRequest) { | |
return new RequestLike(arg); | |
} | |
function deserializeExpressResponse(_arg: SerializedExpressResponse) { | |
return new ResponseLike(); | |
} | |
export function deserializeCustom(arg: unknown) { | |
// Sniff for custom types that can't make it through | |
// structuredClone and deserialize them into custom | |
// objects. | |
switch (getDeserializationTag(arg)) { | |
case "request": { | |
return deserializeRequest(arg as SerializedRequest); | |
} | |
case "express-request": { | |
return deserializeExpressRequest(arg as SerializedExpressRequest); | |
} | |
case "express-response": { | |
return deserializeExpressResponse(arg as SerializedExpressResponse); | |
} | |
default: { | |
return arg; | |
} | |
} | |
} | |
const WALL_TIME_START = Date.now(); | |
function getMainExport( | |
mod: any | |
): { ok: true; value: any } | { ok: false; error: Error } { | |
if ("default" in mod) { | |
return { ok: true, value: mod.default }; | |
} | |
// If the val has exactly one named export, we run that. | |
const exports = Object.keys(mod); | |
if (exports.length > 1) { | |
const error = new Error( | |
`Vals require a default export, or exactly one named export. This val exports: ${exports.join( | |
", " | |
)}` | |
); | |
error.name = "ImportValError"; | |
return { ok: false, error }; | |
} else if (exports.length === 0) { | |
const error = new Error( | |
"Vals require a default export, or exactly one named export. This val has none." | |
); | |
error.name = "ImportValError"; | |
return { ok: false, error }; | |
} | |
return { ok: true, value: mod[exports[0]] }; | |
} | |
/** | |
* Send a message to the host. | |
*/ | |
self.addEventListener("message", async (msg: any) => { | |
if (msg.data.entrypoint) { | |
try { | |
// Override the environment | |
process.env = { ...msg.data.env }; | |
Object.defineProperty(Deno, "env", { | |
value: createDenoEnvStub({ ...msg.data.env }), | |
}); | |
const mod = await import(msg.data.entrypoint); | |
if (Array.isArray(msg.data.args)) { | |
const exp = getMainExport(mod); | |
if (!exp.ok) { | |
throw exp.error; | |
} | |
if (typeof exp.value === "function") { | |
let value = await exp.value(...msg.data.args.map(deserializeCustom)); | |
if (value instanceof Response) { | |
value = await serializeResponse(value); | |
} | |
send({ type: "return", value: SuperJSON.serialize(value) }); | |
} else { | |
send({ type: "return", value: SuperJSON.serialize(await exp.value) }); | |
} | |
} else { | |
// Await every exported value | |
const awaitedMod = Object.fromEntries( | |
await Promise.all( | |
Object.entries(mod).map(async ([key, v]) => { | |
const resolved = await v; | |
if (resolved instanceof Response) { | |
return [key, await serializeResponse(resolved)]; | |
} | |
return [key, resolved]; | |
}) | |
) | |
); | |
send({ type: "exports", value: SuperJSON.serialize(awaitedMod) }); | |
const output = getMainExport(awaitedMod); | |
send({ | |
type: "return", | |
value: SuperJSON.serialize(output.ok ? output.value : undefined), | |
}); | |
} | |
// Communicate the result to the parent. | |
} catch (e) { | |
send({ type: "error", value: e }); | |
} | |
send({ | |
type: "done", | |
wallTime: Date.now() - WALL_TIME_START, | |
}); | |
} | |
}); | |
send({ type: "ready" }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment