Current section
Files
Jump to
Current section
Files
src/smol.ffi.mjs
import { createReadStream } from "node:fs";
import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Readable } from "node:stream";
import {
Result$Ok,
Result$Error,
Result$isOk,
Result$Ok$0,
BitArray$BitArray,
BitArray$BitArray$data,
} from "./gleam.mjs";
import { to_list } from "../gleam_javascript/gleam/javascript/array.mjs";
import {
// FileError
FileError$IsDir,
FileError$NoEntry,
FileError$NoAccess,
FileError$UnknownFileError,
// Runtime
Runtime$Bun,
Runtime$isDeno,
Runtime$isBun,
Runtime$isNode,
Runtime$Deno,
Runtime$Node,
// Step
Step$Send,
Step$isSend,
Step$Send$sending,
Step$Send$state,
Step$Step,
Step$isStep,
Step$Step$state,
Step$Close,
Step$isClose,
// WebsocketMessage
WebsocketMessage$Text,
WebsocketMessage$isText,
WebsocketMessage$Text$text,
WebsocketMessage$Binary,
WebsocketMessage$isBinary,
WebsocketMessage$Binary$bytes,
FormData$FormData,
UploadedFile$UploadedFile,
File$File,
} from "./smol.mjs";
import { Option$None } from "../gleam_stdlib/gleam/option.mjs";
import { from_unix_seconds } from "../gleam_time/gleam/time/timestamp.mjs";
let runtimeResult;
export function detect_runtime() {
if (runtimeResult) return runtimeResult;
if (typeof globalThis.Bun !== "undefined") {
runtimeResult = Result$Ok(Runtime$Bun());
} else if (typeof globalThis.Deno !== "undefined") {
runtimeResult = Result$Ok(Runtime$Deno());
} else if (globalThis.process?.versions?.node) {
// TODO: Cloudflare Workers with nodejs_compat are detected here as Node.
runtimeResult = Result$Ok(Runtime$Node());
} else {
runtimeResult = Result$Error(undefined);
}
return runtimeResult;
}
export function error_handler(message, error) {
console.error("smol: " + message, error?.stack ?? error);
}
export function rescue(on_error, inner_handler) {
return inner_handler().catch(on_error);
}
// -- RUNTIME-SPECIFIC ---------------------------------------------------------
let runtimeModulePromise;
function importRuntimeModule() {
if (runtimeModulePromise) return runtimeModulePromise;
const runtime = detect_runtime();
if (!Result$isOk(runtime)) {
runtimeModulePromise = Promise.resolve(undefined);
return runtimeModulePromise;
}
const rt = Result$Ok$0(runtime);
if (Runtime$isBun(rt)) {
runtimeModulePromise = import("./bun.ffi.mjs");
} else if (Runtime$isDeno(rt)) {
runtimeModulePromise = import("./deno.ffi.mjs");
} else if (Runtime$isNode(rt)) {
runtimeModulePromise = import("./node.ffi.mjs");
} else {
runtimeModulePromise = Promise.resolve(undefined);
}
return runtimeModulePromise;
}
export async function from_file(path, offset, limit) {
try {
const fileInfo = await stat(path);
if (fileInfo.isDirectory()) {
return Result$Error(FileError$IsDir());
}
const options = { start: offset };
if (limit > 0) {
options.end = offset + limit - 1;
}
const body = Readable.toWeb(createReadStream(path, options));
const mtime = from_unix_seconds(Math.floor(fileInfo.mtimeMs / 1000));
return Result$Ok(File$File(body, fileInfo.size, mtime, Option$None()));
} catch (e) {
if (e.code === "ENOENT") {
return Result$Error(FileError$NoEntry());
} else if (e.code === "EACCES" || e.code === "EPERM") {
return Result$Error(FileError$NoAccess());
} else if (e.code === "EISDIR") {
return Result$Error(FileError$IsDir());
} else {
return Result$Error(FileError$UnknownFileError());
}
}
}
export function http_date_from_unix(seconds) {
return new Date(seconds * 1000).toUTCString();
}
export function parse_http_date(date) {
const milliseconds = Date.parse(date);
if (Number.isNaN(milliseconds)) {
return Result$Error(undefined);
}
return Result$Ok(Math.floor(milliseconds / 1000));
}
export async function start(builder) {
const runtime = await importRuntimeModule();
if (!runtime) {
return Result$Error(undefined);
}
return await runtime.start(builder);
}
// -- BODY ---------------------------------------------------------------------
const bodyTooLarge = Symbol("smolBodyTooLarge");
const isCloudflareWorkers =
globalThis.navigator?.userAgent === "Cloudflare-Workers";
export async function read(request, max_body_limit) {
try {
const chunks = [];
for await (const chunk of limitBody(request.body, max_body_limit)) {
chunks.push(BitArray$BitArray(chunk));
}
return Result$Ok(to_list(chunks));
} catch (e) {
return Result$Error(undefined);
}
}
export async function read_form_data(
request,
max_body_limit,
max_file_limit,
next,
on_error,
) {
let uploadDir;
try {
const nativeFormData = await new Response(
limitBody(request.body, max_body_limit),
{ headers: request.headers },
).formData();
const values = [];
const files = [];
for (const [name, value] of nativeFormData.entries()) {
if (typeof value === "string") {
values.push([name, value]);
} else if (value instanceof Blob) {
if (value.size > max_file_limit) throw bodyTooLarge;
uploadDir ??= await mkdtemp(join(tmpdir(), "smol-upload-"));
const path = join(uploadDir, `upload-${files.length}`);
await writeUploadedBlob(path, value);
files.push([name, UploadedFile$UploadedFile(value.name ?? "", path)]);
}
}
return await next(FormData$FormData(to_list(values), to_list(files)));
} catch (error) {
return await on_error(error === bodyTooLarge ? 413 : 422);
} finally {
if (uploadDir) {
await rm(uploadDir, { recursive: true, force: true }).catch((_) => {});
}
}
}
async function writeUploadedBlob(path, value) {
if (isCloudflareWorkers) {
await writeFile(path, await value.bytes());
} else {
await writeFile(path, value.stream());
}
}
function limitBody(body, max_body_limit) {
let totalSize = 0;
return body.pipeThrough(
new TransformStream({
transform(chunk, controller) {
totalSize += chunk.byteLength;
if (totalSize > max_body_limit) {
throw bodyTooLarge;
}
controller.enqueue(chunk);
},
}),
);
}
export function bit_array_list_to_string(chunks) {
try {
const decoder = new TextDecoder("utf-8", { fatal: true });
let text = "";
for (const chunk of chunks) {
text += decoder.decode(BitArray$BitArray$data(chunk), { stream: true });
}
return Result$Ok(text + decoder.decode());
} catch (_error) {
return Result$Error(undefined);
}
}
export function from_bit_array(bytes) {
return new ReadableStream({
start(controller) {
const data = BitArray$BitArray$data(bytes);
controller.enqueue(
new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
);
controller.close();
},
});
}
export async function cancel_stream(stream) {
try {
if (!stream.locked) {
await stream.cancel();
}
} catch (_) {
// The replacement response should still be sent if the discarded body has
// already been read or closed.
}
}
export function from_unfold(state, unfold) {
return new ReadableStream({
async pull(controller) {
try {
while (true) {
const step = await unfold(state);
if (Step$isSend(step)) {
const bytes = Step$Send$sending(step);
const data = BitArray$BitArray$data(bytes);
controller.enqueue(
new Uint8Array(data.buffer, data.byteOffset, data.byteLength),
);
state = Step$Send$state(step);
return;
} else if (Step$isStep(step)) {
state = Step$Step$state(step);
continue;
} else if (Step$isClose(step)) {
controller.close();
return;
}
}
} catch (e) {
controller.error(e);
}
},
});
}
// -- WEBSOCKETS ---------------------------------------------------------------
export const upgrade = Symbol("smol-websocket");
export function from_upgrade(init, update, on_open, on_message, on_close) {
const stream = new ReadableStream();
const runtime = new WebsocketRuntime({
init,
update,
on_message,
on_close,
on_open,
});
stream[upgrade] = runtime;
return stream;
}
export class WebsocketRuntime {
#options;
#state;
#queue = [];
#isRunning = false;
#isClosed = true;
send = null;
close = null;
constructor(options) {
this.#options = options;
this.#state = options.init;
this.onOpen = this.onOpen.bind(this);
this.onMessage = this.onMessage.bind(this);
this.onClose = this.onClose.bind(this);
this.dispatch = this.dispatch.bind(this);
}
dispatch(message) {
this.#receive(message);
}
onOpen() {
this.#isClosed = false;
const appMsg = this.#options.on_open(this.dispatch);
this.#receive(appMsg);
}
onMessage(message) {
const smolMsg =
typeof message === "string"
? WebsocketMessage$Text(message)
: WebsocketMessage$Binary(
BitArray$BitArray(
message instanceof Uint8Array ? message : new Uint8Array(message),
),
);
const appMsg = this.#options.on_message(smolMsg);
this.#receive(appMsg);
}
onClose() {
const appMsg = this.#options.on_close;
this.#receive(appMsg);
queueMicrotask(() => (this.#isClosed = true));
}
#close() {
this.close?.();
}
#send(message) {
if (WebsocketMessage$isText(message)) {
this.send?.(WebsocketMessage$Text$text(message));
} else if (WebsocketMessage$isBinary(message)) {
const bytes = WebsocketMessage$Binary$bytes(message);
this.send?.(BitArray$BitArray$data(bytes));
}
}
#receive(msg) {
this.#queue.push(msg);
if (!this.#isRunning && !this.#isClosed) {
this.#run();
}
}
async #run() {
this.#isRunning = true;
try {
let msg;
while ((msg = this.#queue.shift())) {
const result = await this.#options.update(this.#state, msg);
if (Step$isSend(result)) {
this.#state = Step$Send$state(result);
this.#send(Step$Send$sending(result));
} else if (Step$isStep(result)) {
this.#state = Step$Step$state(result);
} else if (Step$isClose(result)) {
this.#close();
}
}
} finally {
this.#isRunning = false;
}
}
}