diff --git a/Cargo.lock b/Cargo.lock index 3d53b2ad..e6de0b6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5571,6 +5571,7 @@ dependencies = [ "axum-extra", "axum-test", "base64 0.22.1", + "bytes", "chrono", "cookie", "criterion", diff --git a/client/testfixture/index.ts b/client/testfixture/index.ts deleted file mode 100644 index 036a2abd..00000000 --- a/client/testfixture/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { addRoute } from "trailbase:main"; - -type Headers = { [key: string]: string }; -type Request = { - uri: string; - headers: Headers; - body: string; -}; -type Response = { - headers?: Headers; - status?: number; - body?: string; -}; - -addRoute("GET", "/test", (req: Request) : Response => { - console.log("Request", req); - return { - body: "js response", - }; -}); diff --git a/client/testfixture/scripts/index.ts b/client/testfixture/scripts/index.ts new file mode 100644 index 00000000..f1917d4c --- /dev/null +++ b/client/testfixture/scripts/index.ts @@ -0,0 +1,45 @@ +import { addRoute, parsePath, query, htmlHandler, jsonHandler, stringHandler } from "trailbase:main"; +import type { JsonRequestType, ParsedPath, StringRequestType } from "../../../trailbase-core/js/src/index.ts"; + +addRoute("GET", "/test", stringHandler(async (req: StringRequestType) => { + const uri : ParsedPath = parsePath(req.uri); + + const table = uri.query.get("table"); + if (table) { + const rows = await query(`SELECT COUNT(*) FROM ${table}`, []) + return `entries: ${rows[0][0]}`; + } + + return `test: ${req.uri}`; +})); + +addRoute("GET", "/test/:table", stringHandler(async (req: StringRequestType) => { + const table = req.params["table"]; + if (table) { + const rows = await query(`SELECT COUNT(*) FROM ${table}`, []) + return `entries: ${rows[0][0]}`; + } + + return `test: ${req.uri}`; +})); + +addRoute("GET", "/html", htmlHandler((_req: StringRequestType) => { + return ` + + +

Html Handler

+ + + `; +})); + +addRoute("GET", "/json", jsonHandler((_req: JsonRequestType) => { + return { + int: 5, + real: 4.2, + msg: "foo", + obj: { + nested: true, + } + }; +})); diff --git a/client/trailbase-dart/test/trailbase_test.dart b/client/trailbase-dart/test/trailbase_test.dart index ec04fe7a..ec19d15f 100644 --- a/client/trailbase-dart/test/trailbase_test.dart +++ b/client/trailbase-dart/test/trailbase_test.dart @@ -44,7 +44,7 @@ Future initTrailBase() async { ]); final dio = Dio(); - for (int i = 0; i < 50; ++i) { + for (int i = 0; i < 100; ++i) { try { final response = await dio.fetch( RequestOptions(path: 'http://127.0.0.1:${port}/api/healthcheck')); diff --git a/client/trailbase-dotnet/ClientTest.cs b/client/trailbase-dotnet/ClientTest.cs index 519f2bfa..a5a9c49d 100644 --- a/client/trailbase-dotnet/ClientTest.cs +++ b/client/trailbase-dotnet/ClientTest.cs @@ -53,7 +53,7 @@ public class ClientTestFixture : IDisposable { var client = new HttpClient(); Task.Run(async () => { - for (int i = 0; i < 50; ++i) { + for (int i = 0; i < 100; ++i) { try { var response = await client.GetAsync($"http://{address}/api/healthcheck"); if (response.StatusCode == System.Net.HttpStatusCode.OK) { @@ -61,7 +61,7 @@ public class ClientTestFixture : IDisposable { } } catch (Exception e) { - Console.WriteLine($"Caught exception: {e}"); + Console.WriteLine($"Caught exception: {e.Message}"); } await Task.Delay(500); diff --git a/client/trailbase-ts/tests/integration_test_runner.ts b/client/trailbase-ts/tests/integration_test_runner.ts index f5045b6d..b6506ecf 100644 --- a/client/trailbase-ts/tests/integration_test_runner.ts +++ b/client/trailbase-ts/tests/integration_test_runner.ts @@ -22,7 +22,7 @@ async function initTrailBase(): Promise<{ subprocess: Subprocess }> { const subprocess = execa`cargo run -- --data-dir ../testfixture run --dev -a 127.0.0.1:${port}`; - for (let i = 0; i < 50; ++i) { + for (let i = 0; i < 100; ++i) { if ((subprocess.exitCode ?? 0) > 0) { break; } diff --git a/trailbase-core/Cargo.toml b/trailbase-core/Cargo.toml index 6fda7664..eefb2517 100644 --- a/trailbase-core/Cargo.toml +++ b/trailbase-core/Cargo.toml @@ -19,6 +19,7 @@ axum = { version = "^0.7.5", features=["multipart"] } axum-client-ip = "0.6.0" axum-extra = { version = "^0.9.3", default-features = false, features=["protobuf"] } base64 = { version = "0.22.1", default-features = false } +bytes = "1.8.0" chrono = "^0.4.38" cookie = "0.18.1" crossbeam-channel = "0.5.13" diff --git a/trailbase-core/js/src/index.ts b/trailbase-core/js/src/index.ts index e6e32d06..89de1e1f 100644 --- a/trailbase-core/js/src/index.ts +++ b/trailbase-core/js/src/index.ts @@ -1,26 +1,555 @@ declare var rustyscript: any; declare var globalThis: any; -type Headers = { [key: string]: string }; -type Request = { +export type HeaderMapType = { [key: string]: string }; +export type PathParamsType = { [key: string]: string }; +export type RequestType = { uri: string; - headers: Headers; - body: string; + params: PathParamsType; + headers: HeaderMapType; + body?: Uint8Array; }; -type Response = { - headers?: Headers; +export type ResponseType = { + headers?: [string, string][]; status?: number; + body?: Uint8Array; +}; +export type MaybeResponse = Promise | T | undefined; +export type CallbackType = (req: RequestType) => MaybeResponse; + +/// HTTP status codes. +/// +// source: https://github.com/prettymuchbryce/http-status-codes/blob/master/src/status-codes.ts +export enum StatusCodes { + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.2.1 + /// + /// This interim response indicates that everything so far is OK and that the + /// client should continue with the request or ignore it if it is already + /// finished. + CONTINUE = 100, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.2.2 + /// + /// This code is sent in response to an Upgrade request header by the client, + /// and indicates the protocol the server is switching too. + SWITCHING_PROTOCOLS = 101, + /// Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.1 + /// + /// This code indicates that the server has received and is processing the + /// request, but no response is available yet. + PROCESSING = 102, + /// Official Documentation @ https://www.rfc-editor.org/rfc/rfc8297#page-3 + /// + /// This code indicates to the client that the server is likely to send a + /// final response with the header fields included in the informational + /// response. + EARLY_HINTS = 103, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.1 + /// + /// The request has succeeded. The meaning of a success varies depending on the HTTP method: + /// GET: The resource has been fetched and is transmitted in the message body. + /// HEAD: The entity headers are in the message body. + /// POST: The resource describing the result of the action is transmitted in the message body. + /// TRACE: The message body contains the request message as received by the server + OK = 200, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.2 + /// + /// The request has succeeded and a new resource has been created as a result + /// of it. This is typically the response sent after a PUT request. + CREATED = 201, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.3 + /// + /// The request has been received but not yet acted upon. It is + /// non-committal, meaning that there is no way in HTTP to later send an + /// asynchronous response indicating the outcome of processing the request. It + /// is intended for cases where another process or server handles the request, + /// or for batch processing. + ACCEPTED = 202, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.4 + /// + /// This response code means returned meta-information set is not exact set + /// as available from the origin server, but collected from a local or a third + /// party copy. Except this condition, 200 OK response should be preferred + /// instead of this response. + NON_AUTHORITATIVE_INFORMATION = 203, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.5 + /// + /// There is no content to send for this request, but the headers may be + /// useful. The user-agent may update its cached headers for this resource with + /// the new ones. + NO_CONTENT = 204, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.3.6 + /// + /// This response code is sent after accomplishing request to tell user agent + /// reset document view which sent this request. + RESET_CONTENT = 205, + /// Official Documentation @ https://tools.ietf.org/html/rfc7233#section-4.1 + /// + /// This response code is used because of range header sent by the client to + /// separate download into multiple streams. + PARTIAL_CONTENT = 206, + /// Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.2 + /// + /// A Multi-Status response conveys information about multiple resources in + /// situations where multiple status codes might be appropriate. + MULTI_STATUS = 207, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.4.1 + /// + /// The request has more than one possible responses. User-agent or user + /// should choose one of them. There is no standardized way to choose one of + /// the responses. + MULTIPLE_CHOICES = 300, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.4.2 + /// + /// This response code means that URI of requested resource has been changed. + /// Probably, new URI would be given in the response. + MOVED_PERMANENTLY = 301, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.4.3 + /// + /// This response code means that URI of requested resource has been changed + /// temporarily. New changes in the URI might be made in the future. Therefore, + /// this same URI should be used by the client in future requests. + MOVED_TEMPORARILY = 302, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.4.4 + /// + /// Server sent this response to directing client to get requested resource + /// to another URI with an GET request. + SEE_OTHER = 303, + /// Official Documentation @ https://tools.ietf.org/html/rfc7232#section-4.1 + /// + /// This is used for caching purposes. It is telling to client that response + /// has not been modified. So, client can continue to use same cached version + /// of response. + NOT_MODIFIED = 304, + /// @deprecated + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.4.6 + /// + /// Was defined in a previous version of the HTTP specification to indicate + /// that a requested response must be accessed by a proxy. It has been + /// deprecated due to security concerns regarding in-band configuration of a + /// proxy. + USE_PROXY = 305, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.4.7 + /// + /// Server sent this response to directing client to get requested resource + /// to another URI with same method that used prior request. This has the same + /// semantic than the 302 Found HTTP response code, with the exception that the + /// user agent must not change the HTTP method used: if a POST was used in the + /// first request, a POST must be used in the second request. + TEMPORARY_REDIRECT = 307, + /// Official Documentation @ https://tools.ietf.org/html/rfc7538#section-3 + /// + /// This means that the resource is now permanently located at another URI, + /// specified by the Location: HTTP Response header. This has the same + /// semantics as the 301 Moved Permanently HTTP response code, with the + /// exception that the user agent must not change the HTTP method used: if a + /// POST was used in the first request, a POST must be used in the second + /// request. + PERMANENT_REDIRECT = 308, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.1 + /// + /// This response means that server could not understand the request due to invalid syntax. + BAD_REQUEST = 400, + /// Official Documentation @ https://tools.ietf.org/html/rfc7235#section-3.1 + /// + /// Although the HTTP standard specifies "unauthorized", semantically this + /// response means "unauthenticated". That is, the client must authenticate + /// itself to get the requested response. + UNAUTHORIZED = 401, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.2 + /// + /// This response code is reserved for future use. Initial aim for creating + /// this code was using it for digital payment systems however this is not used + /// currently. + PAYMENT_REQUIRED = 402, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.3 + /// + /// The client does not have access rights to the content, i.e. they are + /// unauthorized, so server is rejecting to give proper response. Unlike 401, + /// the client's identity is known to the server. + FORBIDDEN = 403, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.4 + /// + /// The server can not find requested resource. In the browser, this means + /// the URL is not recognized. In an API, this can also mean that the endpoint + /// is valid but the resource itself does not exist. Servers may also send this + /// response instead of 403 to hide the existence of a resource from an + /// unauthorized client. This response code is probably the most famous one due + /// to its frequent occurence on the web. + NOT_FOUND = 404, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.5 + /// + /// The request method is known by the server but has been disabled and + /// cannot be used. For example, an API may forbid DELETE-ing a resource. The + /// two mandatory methods, GET and HEAD, must never be disabled and should not + /// return this error code. + METHOD_NOT_ALLOWED = 405, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.6 + /// + /// This response is sent when the web server, after performing server-driven + /// content negotiation, doesn't find any content following the criteria given + /// by the user agent. + NOT_ACCEPTABLE = 406, + /// Official Documentation @ https://tools.ietf.org/html/rfc7235#section-3.2 + /// + /// This is similar to 401 but authentication is needed to be done by a proxy. + PROXY_AUTHENTICATION_REQUIRED = 407, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.7 + /// + /// This response is sent on an idle connection by some servers, even without + /// any previous request by the client. It means that the server would like to + /// shut down this unused connection. This response is used much more since + /// some browsers, like Chrome, Firefox 27+, or IE9, use HTTP pre-connection + /// mechanisms to speed up surfing. Also note that some servers merely shut + /// down the connection without sending this message. + REQUEST_TIMEOUT = 408, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.8 + /// + /// This response is sent when a request conflicts with the current state of the server. + CONFLICT = 409, + /// + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.9 + /// + /// This response would be sent when the requested content has been + /// permenantly deleted from server, with no forwarding address. Clients are + /// expected to remove their caches and links to the resource. The HTTP + /// specification intends this status code to be used for "limited-time, + /// promotional services". APIs should not feel compelled to indicate resources + /// that have been deleted with this status code. + GONE = 410, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.10 + /// + /// The server rejected the request because the Content-Length header field + /// is not defined and the server requires it. + LENGTH_REQUIRED = 411, + /// Official Documentation @ https://tools.ietf.org/html/rfc7232#section-4.2 + /// + /// The client has indicated preconditions in its headers which the server + /// does not meet. + PRECONDITION_FAILED = 412, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.11 + /// + /// Request entity is larger than limits defined by server; the server might + /// close the connection or return an Retry-After header field. + REQUEST_TOO_LONG = 413, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.12 + /// + /// The URI requested by the client is longer than the server is willing to interpret. + REQUEST_URI_TOO_LONG = 414, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.13 + /// + /// The media format of the requested data is not supported by the server, so + /// the server is rejecting the request. + UNSUPPORTED_MEDIA_TYPE = 415, + /// Official Documentation @ https://tools.ietf.org/html/rfc7233#section-4.4 + /// + /// The range specified by the Range header field in the request can't be + /// fulfilled; it's possible that the range is outside the size of the target + /// URI's data. + REQUESTED_RANGE_NOT_SATISFIABLE = 416, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.5.14 + /// + /// This response code means the expectation indicated by the Expect request + /// header field can't be met by the server. + EXPECTATION_FAILED = 417, + /// Official Documentation @ https://tools.ietf.org/html/rfc2324#section-2.3.2 + /// + /// Any attempt to brew coffee with a teapot should result in the error code + /// "418 I'm a teapot". The resulting entity body MAY be short and stout. + IM_A_TEAPOT = 418, + /// Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.6 + /// + /// The 507 (Insufficient Storage) status code means the method could not be + /// performed on the resource because the server is unable to store the + /// representation needed to successfully complete the request. This condition + /// is considered to be temporary. If the request which received this status + /// code was the result of a user action, the request MUST NOT be repeated + /// until it is requested by a separate user action. + INSUFFICIENT_SPACE_ON_RESOURCE = 419, + /// @deprecated + /// Official Documentation @ https://tools.ietf.org/rfcdiff?difftype=--hwdiff&url2=draft-ietf-webdav-protocol-06.txt + /// + /// A deprecated response used by the Spring Framework when a method has failed. + METHOD_FAILURE = 420, + /// Official Documentation @ https://datatracker.ietf.org/doc/html/rfc7540#section-9.1.2 + /// + /// Defined in the specification of HTTP/2 to indicate that a server is not + /// able to produce a response for the combination of scheme and authority that + /// are included in the request URI. + MISDIRECTED_REQUEST = 421, + /// Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.3 + /// + /// The request was well-formed but was unable to be followed due to semantic errors. + UNPROCESSABLE_ENTITY = 422, + /// Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.4 + /// + /// The resource that is being accessed is locked. + LOCKED = 423, + /// Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.5 + /// + /// The request failed due to failure of a previous request. + FAILED_DEPENDENCY = 424, + /// Official Documentation @ https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.15 + /// + /// The server refuses to perform the request using the current protocol but + /// might be willing to do so after the client upgrades to a different + /// protocol. + UPGRADE_REQUIRED = 426, + /// Official Documentation @ https://tools.ietf.org/html/rfc6585#section-3 + /// + /// The origin server requires the request to be conditional. Intended to + /// prevent the 'lost update' problem, where a client GETs a resource's state, + /// modifies it, and PUTs it back to the server, when meanwhile a third party + /// has modified the state on the server, leading to a conflict. + PRECONDITION_REQUIRED = 428, + /// Official Documentation @ https://tools.ietf.org/html/rfc6585#section-4 + /// + /// The user has sent too many requests in a given amount of time ("rate limiting"). + TOO_MANY_REQUESTS = 429, + /// Official Documentation @ https://tools.ietf.org/html/rfc6585#section-5 + /// + /// The server is unwilling to process the request because its header fields + /// are too large. The request MAY be resubmitted after reducing the size of + /// the request header fields. + REQUEST_HEADER_FIELDS_TOO_LARGE = 431, + /// Official Documentation @ https://tools.ietf.org/html/rfc7725 + /// + /// The user-agent requested a resource that cannot legally be provided, such + /// as a web page censored by a government. + UNAVAILABLE_FOR_LEGAL_REASONS = 451, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.1 + /// + /// The server encountered an unexpected condition that prevented it from + /// fulfilling the request. + INTERNAL_SERVER_ERROR = 500, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.2 + /// + /// The request method is not supported by the server and cannot be handled. + /// The only methods that servers are required to support (and therefore that + /// must not return this code) are GET and HEAD. + NOT_IMPLEMENTED = 501, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.3 + /// + /// This error response means that the server, while working as a gateway to + /// get a response needed to handle the request, got an invalid response. + BAD_GATEWAY = 502, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.4 + /// + /// The server is not ready to handle the request. Common causes are a server + /// that is down for maintenance or that is overloaded. Note that together with + /// this response, a user-friendly page explaining the problem should be sent. + /// This responses should be used for temporary conditions and the Retry-After: + /// HTTP header should, if possible, contain the estimated time before the + /// recovery of the service. The webmaster must also take care about the + /// caching-related headers that are sent along with this response, as these + /// temporary condition responses should usually not be cached. + SERVICE_UNAVAILABLE = 503, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.5 + /// + /// This error response is given when the server is acting as a gateway and + /// cannot get a response in time. + GATEWAY_TIMEOUT = 504, + /// Official Documentation @ https://tools.ietf.org/html/rfc7231#section-6.6.6 + /// + /// The HTTP version used in the request is not supported by the server. + HTTP_VERSION_NOT_SUPPORTED = 505, + /// Official Documentation @ https://tools.ietf.org/html/rfc2518#section-10.6 + /// + /// The server has an internal configuration error: the chosen variant + /// resource is configured to engage in transparent content negotiation itself, + /// and is therefore not a proper end point in the negotiation process. + INSUFFICIENT_STORAGE = 507, + /// Official Documentation @ https://tools.ietf.org/html/rfc6585#section-6 + /// + /// The 511 status code indicates that the client needs to authenticate to + /// gain network access. + NETWORK_AUTHENTICATION_REQUIRED = 511, +} + +export type StringRequestType = { + uri: string; + params: PathParamsType; + headers: HeaderMapType; body?: string; }; -type CbType = (req: Request) => Response | undefined; +export type StringResponseType = { + headers?: [string, string][]; + status?: number; + body: string; +}; -const callbacks = new Map(); +export function stringHandler( + f: (req: StringRequestType) => MaybeResponse, +): CallbackType { + return async (req: RequestType): Promise => { + try { + let body = req.body; + let resp: StringResponseType | string | undefined = await f({ + uri: req.uri, + params: req.params, + headers: req.headers, + body: body && decodeFallback(body), + }); -export function addRoute(method: string, route: string, callback: CbType) { + if (resp === undefined) { + return undefined; + } + + if (typeof resp === "string") { + return { + status: StatusCodes.OK, + body: encodeFallback(resp), + }; + } + + const respBody = resp.body; + return { + headers: resp.headers, + status: resp.status, + body: respBody ? encodeFallback(respBody) : undefined, + }; + } catch (err) { + return { + status: StatusCodes.INTERNAL_SERVER_ERROR, + body: encodeFallback(`Uncaught error: ${err}`), + }; + } + }; +} + +export type HtmlResponseType = { + headers?: [string, string][]; + status?: number; + body: string; +}; + +export function htmlHandler( + f: (req: StringRequestType) => MaybeResponse, +): CallbackType { + return async (req: RequestType): Promise => { + try { + let body = req.body; + let resp: HtmlResponseType | string | undefined = await f({ + uri: req.uri, + params: req.params, + headers: req.headers, + body: body && decodeFallback(body), + }); + + if (resp === undefined) { + return undefined; + } + + if (typeof resp === "string") { + return { + headers: [["content-type", "text/html"]], + status: StatusCodes.OK, + body: encodeFallback(resp), + }; + } + + const respBody = resp.body; + return { + headers: [["content-type", "text/html"], ...(resp.headers ?? [])], + status: resp.status, + body: respBody ? encodeFallback(respBody) : undefined, + }; + } catch (err) { + return { + status: StatusCodes.INTERNAL_SERVER_ERROR, + body: encodeFallback(`Uncaught error: ${err}`), + }; + } + }; +} + +export type JsonRequestType = { + uri: string; + params: PathParamsType; + headers: HeaderMapType; + body?: object | string; +}; +export interface JsonResponseType { + headers?: [string, string][]; + status?: number; + body: object; +} + +export function jsonHandler( + f: (req: JsonRequestType) => MaybeResponse, +): CallbackType { + return async (req: RequestType): Promise => { + try { + let body = req.body; + let resp: JsonResponseType | object | undefined = await f({ + uri: req.uri, + params: req.params, + headers: req.headers, + body: body && decodeFallback(body), + }); + + if (resp === undefined) { + return undefined; + } + + if ("body" in resp) { + const r = resp as JsonResponseType; + const rBody = r.body; + return { + headers: [["content-type", "application/json"], ...(r.headers ?? [])], + status: r.status, + body: rBody ? encodeFallback(JSON.stringify(rBody)) : undefined, + }; + } + + return { + headers: [["content-type", "application/json"]], + status: StatusCodes.OK, + body: encodeFallback(JSON.stringify(resp)), + }; + } catch (err) { + return { + headers: [["content-type", "application/json"]], + status: StatusCodes.INTERNAL_SERVER_ERROR, + body: encodeFallback(`Uncaught error: ${err}`), + }; + } + }; +} + +const callbacks = new Map(); + +export function addRoute( + method: string, + route: string, + callback: CallbackType, +) { rustyscript.functions.route(method, route); callbacks.set(`${method}:${route}`, callback); + console.debug("JS: Added route:", method, route); +} - console.log("JS: Added route:", method, route); +export async function dispatch( + method: string, + route: string, + uri: string, + pathParams: [string, string][], + headers: [string, string][], + body: Uint8Array, +): Promise { + const key = `${method}:${route}`; + const cb: CallbackType | undefined = callbacks.get(key); + if (!cb) { + throw Error(`Missing callback: ${key}`); + } + + return ( + (await cb({ + uri, + params: Object.fromEntries(pathParams), + headers: Object.fromEntries(headers), + body, + })) ?? { status: StatusCodes.OK } + ); } export async function query( @@ -37,24 +566,173 @@ export async function execute( return await rustyscript.async_functions.execute(queryStr, params); } -export function dispatch( - method: string, - route: string, - uri: string, - headers: Headers, - body: string, -): Response | undefined { - const key = `${method}:${route}`; - const cb = callbacks.get(key); - if (!cb) { - throw Error(`Missing callback: ${key}`); +export type ParsedPath = { + path: string; + query: URLSearchParams; +}; + +export function parsePath(path: string): ParsedPath { + const queryIndex = path.indexOf("?"); + if (queryIndex >= 0) { + return { + path: path.slice(0, queryIndex), + query: new URLSearchParams(path.slice(queryIndex + 1)), + }; } - return cb({ - uri, - headers, - body, - }); + return { + path, + query: new URLSearchParams(), + }; +} + +/// @param {Uint8Array} bytes +/// @return {string} +/// +/// source: https://github.com/samthor/fast-text-encoding +export function decodeFallback(bytes: Uint8Array): string { + var inputIndex = 0; + + // Create a working buffer for UTF-16 code points, but don't generate one + // which is too large for small input sizes. UTF-8 to UCS-16 conversion is + // going to be at most 1:1, if all code points are ASCII. The other extreme + // is 4-byte UTF-8, which results in two UCS-16 points, but this is still 50% + // fewer entries in the output. + var pendingSize = Math.min(256 * 256, bytes.length + 1); + var pending = new Uint16Array(pendingSize); + var chunks = []; + var pendingIndex = 0; + + for (;;) { + var more = inputIndex < bytes.length; + + // If there's no more data or there'd be no room for two UTF-16 values, + // create a chunk. This isn't done at the end by simply slicing the data + // into equal sized chunks as we might hit a surrogate pair. + if (!more || pendingIndex >= pendingSize - 1) { + // nb. .apply and friends are *really slow*. Low-hanging fruit is to + // expand this to literally pass pending[0], pending[1], ... etc, but + // the output code expands pretty fast in this case. + // These extra vars get compiled out: they're just to make TS happy. + // Turns out you can pass an ArrayLike to .apply(). + var subarray = pending.subarray(0, pendingIndex); + var arraylike = subarray as unknown as number[]; + chunks.push(String.fromCharCode.apply(null, arraylike)); + + if (!more) { + return chunks.join(""); + } + + // Move the buffer forward and create another chunk. + bytes = bytes.subarray(inputIndex); + inputIndex = 0; + pendingIndex = 0; + } + + // The native TextDecoder will generate "REPLACEMENT CHARACTER" where the + // input data is invalid. Here, we blindly parse the data even if it's + // wrong: e.g., if a 3-byte sequence doesn't have two valid continuations. + + var byte1 = bytes[inputIndex++]; + if ((byte1 & 0x80) === 0) { + // 1-byte or null + pending[pendingIndex++] = byte1; + } else if ((byte1 & 0xe0) === 0xc0) { + // 2-byte + var byte2 = bytes[inputIndex++] & 0x3f; + pending[pendingIndex++] = ((byte1 & 0x1f) << 6) | byte2; + } else if ((byte1 & 0xf0) === 0xe0) { + // 3-byte + var byte2 = bytes[inputIndex++] & 0x3f; + var byte3 = bytes[inputIndex++] & 0x3f; + pending[pendingIndex++] = ((byte1 & 0x1f) << 12) | (byte2 << 6) | byte3; + } else if ((byte1 & 0xf8) === 0xf0) { + // 4-byte + var byte2 = bytes[inputIndex++] & 0x3f; + var byte3 = bytes[inputIndex++] & 0x3f; + var byte4 = bytes[inputIndex++] & 0x3f; + + // this can be > 0xffff, so possibly generate surrogates + var codepoint = + ((byte1 & 0x07) << 0x12) | (byte2 << 0x0c) | (byte3 << 0x06) | byte4; + if (codepoint > 0xffff) { + // codepoint &= ~0x10000; + codepoint -= 0x10000; + pending[pendingIndex++] = ((codepoint >>> 10) & 0x3ff) | 0xd800; + codepoint = 0xdc00 | (codepoint & 0x3ff); + } + pending[pendingIndex++] = codepoint; + } else { + // invalid initial byte + } + } +} + +/// @param {string} string +/// @return {Uint8Array} +//// +/// source: https://github.com/samthor/fast-text-encoding +export function encodeFallback(string: string): Uint8Array { + var pos = 0; + var len = string.length; + + var at = 0; // output position + var tlen = Math.max(32, len + (len >>> 1) + 7); // 1.5x size + var target = new Uint8Array((tlen >>> 3) << 3); // ... but at 8 byte offset + + while (pos < len) { + var value = string.charCodeAt(pos++); + if (value >= 0xd800 && value <= 0xdbff) { + // high surrogate + if (pos < len) { + var extra = string.charCodeAt(pos); + if ((extra & 0xfc00) === 0xdc00) { + ++pos; + value = ((value & 0x3ff) << 10) + (extra & 0x3ff) + 0x10000; + } + } + if (value >= 0xd800 && value <= 0xdbff) { + continue; // drop lone surrogate + } + } + + // expand the buffer if we couldn't write 4 bytes + if (at + 4 > target.length) { + tlen += 8; // minimum extra + tlen *= 1.0 + (pos / string.length) * 2; // take 2x the remaining + tlen = (tlen >>> 3) << 3; // 8 byte offset + + var update = new Uint8Array(tlen); + update.set(target); + target = update; + } + + if ((value & 0xffffff80) === 0) { + // 1-byte + target[at++] = value; // ASCII + continue; + } else if ((value & 0xfffff800) === 0) { + // 2-byte + target[at++] = ((value >>> 6) & 0x1f) | 0xc0; + } else if ((value & 0xffff0000) === 0) { + // 3-byte + target[at++] = ((value >>> 12) & 0x0f) | 0xe0; + target[at++] = ((value >>> 6) & 0x3f) | 0x80; + } else if ((value & 0xffe00000) === 0) { + // 4-byte + target[at++] = ((value >>> 18) & 0x07) | 0xf0; + target[at++] = ((value >>> 12) & 0x3f) | 0x80; + target[at++] = ((value >>> 6) & 0x3f) | 0x80; + } else { + continue; // out of range + } + + target[at++] = (value & 0x3f) | 0x80; + } + + // Use subarray if slice isn't supported (IE11). This will use more memory + // because the original array still exists. + return target.slice ? target.slice(0, at) : target.subarray(0, at); } globalThis.__dispatch = dispatch; diff --git a/trailbase-core/src/js/mod.rs b/trailbase-core/src/js/mod.rs index 1dd276a5..eedd341e 100644 --- a/trailbase-core/src/js/mod.rs +++ b/trailbase-core/src/js/mod.rs @@ -1,5 +1,5 @@ use axum::body::Body; -use axum::extract::Request; +use axum::extract::{RawPathParams, Request}; use axum::http::{header::CONTENT_TYPE, request::Parts, HeaderName, HeaderValue, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::Router; @@ -9,7 +9,7 @@ use rust_embed::RustEmbed; use rustyscript::{init_platform, json_args, Module, Runtime}; use serde::Deserialize; use serde_json::from_value; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::str::FromStr; use std::sync::{Arc, LazyLock}; use thiserror::Error; @@ -22,13 +22,6 @@ mod import_provider; type AnyError = Box; -#[derive(Default, Deserialize)] -struct JsResponse { - headers: Option>, - status: Option, - body: Option, -} - enum Message { Run(Box), } @@ -355,7 +348,7 @@ fn route_callback( ) -> Result<(), AnyError> { let method_uppercase = method.to_uppercase(); let route_path = route.clone(); - let handler = move |req: Request| async move { + let handler = move |params: RawPathParams, req: Request| async move { let (parts, body) = req.into_parts(); let Ok(body_bytes) = axum::body::to_bytes(body, usize::MAX).await else { @@ -370,7 +363,11 @@ fn route_callback( .. } = parts; - let headers: HashMap = headers + let path_params: Vec<(String, String)> = params + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + let headers: Vec<(String, String)> = headers .into_iter() .filter_map(|(key, value)| { if let Some(key) = key { @@ -382,20 +379,32 @@ fn route_callback( }) .collect(); + #[derive(Deserialize)] + struct JsResponse { + headers: Option>, + status: Option, + body: Option, + } + let js_response = RuntimeHandle::new() .apply(move |runtime| -> Result { - let response: JsResponse = runtime.call_function( - None, - "__dispatch", - json_args!( - method, - route_path, - uri.to_string(), - headers, - String::from_utf8_lossy(&body_bytes) - ), - )?; - return Ok(response); + let tokio_runtime = runtime.tokio_runtime(); + return tokio_runtime.block_on(async { + return runtime + .call_function_async::( + None, + "__dispatch", + json_args!( + method, + route_path, + uri.to_string(), + path_params, + headers, + body_bytes + ), + ) + .await; + }); }) .await .map_err(JsResponseError::Internal)?