aboutsummaryrefslogtreecommitdiff
path: root/src/bun.js/api/router.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/bun.js/api/router.zig')
-rw-r--r--src/bun.js/api/router.zig541
1 files changed, 541 insertions, 0 deletions
diff --git a/src/bun.js/api/router.zig b/src/bun.js/api/router.zig
new file mode 100644
index 000000000..847e7a756
--- /dev/null
+++ b/src/bun.js/api/router.zig
@@ -0,0 +1,541 @@
+const std = @import("std");
+const Api = @import("../../api/schema.zig").Api;
+const FilesystemRouter = @import("../../router.zig");
+const http = @import("../../http.zig");
+const JavaScript = @import("../javascript.zig");
+const QueryStringMap = @import("../../url.zig").QueryStringMap;
+const CombinedScanner = @import("../../url.zig").CombinedScanner;
+const bun = @import("../../global.zig");
+const string = bun.string;
+const JSC = @import("../../jsc.zig");
+const js = JSC.C;
+const WebCore = @import("../webcore/response.zig");
+const Router = @This();
+const Bundler = @import("../../bundler.zig");
+const VirtualMachine = JavaScript.VirtualMachine;
+const ScriptSrcStream = std.io.FixedBufferStream([]u8);
+const ZigString = JSC.ZigString;
+const Fs = @import("../../fs.zig");
+const Base = @import("../base.zig");
+const getAllocator = Base.getAllocator;
+const JSObject = JSC.JSObject;
+const JSError = Base.JSError;
+const JSValue = JSC.JSValue;
+const JSGlobalObject = JSC.JSGlobalObject;
+const strings = @import("strings");
+const NewClass = Base.NewClass;
+const To = Base.To;
+const Request = WebCore.Request;
+const d = Base.d;
+const FetchEvent = WebCore.FetchEvent;
+const URLPath = @import("../../http/url_path.zig");
+const URL = @import("../../url.zig").URL;
+route: *const FilesystemRouter.Match,
+route_holder: FilesystemRouter.Match = undefined,
+needs_deinit: bool = false,
+query_string_map: ?QueryStringMap = null,
+param_map: ?QueryStringMap = null,
+params_list_holder: FilesystemRouter.Param.List = .{},
+
+pub fn importRoute(
+ this: *Router,
+ ctx: js.JSContextRef,
+ _: js.JSObjectRef,
+ _: js.JSObjectRef,
+ _: []const js.JSValueRef,
+ _: js.ExceptionRef,
+) js.JSObjectRef {
+ const prom = JSC.JSModuleLoader.loadAndEvaluateModule(ctx.ptr(), &ZigString.init(this.route.file_path));
+
+ VirtualMachine.vm.tick();
+
+ return prom.result(ctx.ptr().vm()).asRef();
+}
+
+pub fn match(
+ _: void,
+ ctx: js.JSContextRef,
+ _: js.JSObjectRef,
+ _: js.JSObjectRef,
+ arguments: []const js.JSValueRef,
+ exception: js.ExceptionRef,
+) js.JSObjectRef {
+ if (arguments.len == 0) {
+ JSError(getAllocator(ctx), "Expected string, FetchEvent, or Request but there were no arguments", .{}, ctx, exception);
+ return null;
+ }
+
+ const arg: JSC.JSValue = brk: {
+ if (FetchEvent.Class.isLoaded()) {
+ if (JSValue.as(JSValue.fromRef(arguments[0]), FetchEvent)) |fetch_event| {
+ if (fetch_event.request_context != null) {
+ return matchFetchEvent(ctx, fetch_event, exception);
+ }
+
+ // When disconencted, we still have a copy of the request data in here
+ break :brk JSC.JSValue.fromRef(fetch_event.getRequest(ctx, null, null, null));
+ }
+ }
+ break :brk JSC.JSValue.fromRef(arguments[0]);
+ };
+
+ var router = JavaScript.VirtualMachine.vm.bundler.router orelse {
+ JSError(getAllocator(ctx), "Bun.match needs a framework configured with routes", .{}, ctx, exception);
+ return null;
+ };
+
+ var path_: ?ZigString.Slice = null;
+ var pathname: string = "";
+ defer {
+ if (path_) |path| {
+ path.deinit();
+ }
+ }
+
+ if (arg.isString()) {
+ var path_string = arg.getZigString(ctx.ptr());
+ path_ = path_string.toSlice(bun.default_allocator);
+ var url = URL.parse(path_.?.slice());
+ pathname = url.pathname;
+ } else if (arg.as(Request)) |req| {
+ var path_string = req.url;
+ path_ = path_string.toSlice(bun.default_allocator);
+ var url = URL.parse(path_.?.slice());
+ pathname = url.pathname;
+ }
+
+ if (path_ == null) {
+ JSError(getAllocator(ctx), "Expected string, FetchEvent, or Request", .{}, ctx, exception);
+ return null;
+ }
+
+ const url_path = URLPath.parse(path_.?.slice()) catch {
+ JSError(getAllocator(ctx), "Could not parse URL path", .{}, ctx, exception);
+ return null;
+ };
+
+ var match_params_fallback = std.heap.stackFallback(1024, bun.default_allocator);
+ var match_params_allocator = match_params_fallback.get();
+ var match_params = FilesystemRouter.Param.List{};
+ match_params.ensureTotalCapacity(match_params_allocator, 16) catch unreachable;
+ var prev_allocator = router.routes.allocator;
+ router.routes.allocator = match_params_allocator;
+ defer router.routes.allocator = prev_allocator;
+ if (router.routes.matchPage("", url_path, &match_params)) |matched| {
+ var match_ = matched;
+ var params_list = match_.params.clone(bun.default_allocator) catch unreachable;
+ var instance = getAllocator(ctx).create(Router) catch unreachable;
+
+ instance.* = Router{
+ .route_holder = match_,
+ .route = undefined,
+ };
+ instance.params_list_holder = params_list;
+ instance.route = &instance.route_holder;
+ instance.route_holder.params = &instance.params_list_holder;
+
+ return Instance.make(ctx, instance);
+ }
+ // router.routes.matchPage
+
+ return JSC.JSValue.jsNull().asObjectRef();
+}
+
+fn matchRequest(
+ ctx: js.JSContextRef,
+ request: *const Request,
+ _: js.ExceptionRef,
+) js.JSObjectRef {
+ return createRouteObject(ctx, request.request_context);
+}
+
+fn matchFetchEvent(
+ ctx: js.JSContextRef,
+ fetch_event: *const FetchEvent,
+ _: js.ExceptionRef,
+) js.JSObjectRef {
+ return createRouteObject(ctx, fetch_event.request_context.?);
+}
+
+fn createRouteObject(ctx: js.JSContextRef, req: *const http.RequestContext) js.JSValueRef {
+ const route = &(req.matched_route orelse {
+ return js.JSValueMakeNull(ctx);
+ });
+
+ return createRouteObjectFromMatch(ctx, route);
+}
+
+fn createRouteObjectFromMatch(
+ ctx: js.JSContextRef,
+ route: *const FilesystemRouter.Match,
+) js.JSValueRef {
+ var router = getAllocator(ctx).create(Router) catch unreachable;
+ router.* = Router{
+ .route = route,
+ };
+
+ return Instance.make(ctx, router);
+}
+
+pub const match_type_definition = &[_]d.ts{
+ .{
+ .tsdoc = "Match a {@link https://developer.mozilla.org/en-US/docs/Web/API/FetchEvent FetchEvent} to a `Route` from the local filesystem. Returns `null` if there is no match.",
+ .args = &[_]d.ts.arg{
+ .{
+ .name = "event",
+ .@"return" = "FetchEvent",
+ },
+ },
+ .@"return" = "Route | null",
+ },
+ .{
+ .tsdoc = "Match a `pathname` to a `Route` from the local filesystem. Returns `null` if there is no match.",
+ .args = &[_]d.ts.arg{
+ .{
+ .name = "pathname",
+ .@"return" = "string",
+ },
+ },
+ .@"return" = "Route | null",
+ },
+ .{
+ .tsdoc = "Match a {@link https://developer.mozilla.org/en-US/docs/Web/API/Request Request} to a `Route` from the local filesystem. Returns `null` if there is no match.",
+ .args = &[_]d.ts.arg{
+ .{
+ .name = "request",
+ .@"return" = "Request",
+ },
+ },
+ .@"return" = "Route | null",
+ },
+};
+
+pub const Instance = NewClass(
+ Router,
+ .{
+ .name = "Route",
+ .read_only = true,
+ .ts = .{
+ .class = d.ts.class{
+ .tsdoc =
+ \\Route matched from the filesystem.
+ ,
+ },
+ },
+ },
+ .{
+ .finalize = finalize,
+ .import = .{
+ .rfn = importRoute,
+ .ts = d.ts{
+ .@"return" = "Object",
+ .tsdoc =
+ \\Synchronously load & evaluate the file corresponding to the route. Returns the exports of the route. This is similar to `await import(route.filepath)`, except it's synchronous. It is recommended to use this function instead of `import`.
+ ,
+ },
+ },
+ },
+ .{
+ .pathname = .{
+ .get = getPathname,
+ .ro = true,
+ .ts = d.ts{
+ .@"return" = "string",
+ .@"tsdoc" = "URL path as appears in a web browser's address bar",
+ },
+ },
+
+ .filePath = .{
+ .get = getFilePath,
+ .ro = true,
+ .ts = d.ts{
+ .@"return" = "string",
+ .tsdoc =
+ \\Project-relative filesystem path to the route file.
+ ,
+ },
+ },
+ .scriptSrc = .{
+ .get = getScriptSrc,
+ .ro = true,
+ .ts = d.ts{
+ .@"return" = "string",
+ .tsdoc =
+ \\src attribute of the script tag that loads the route.
+ ,
+ },
+ },
+ .kind = .{
+ .get = getKind,
+ .ro = true,
+ .ts = d.ts{
+ .@"return" = "\"exact\" | \"dynamic\" | \"catch-all\" | \"optional-catch-all\"",
+ },
+ },
+ .name = .{
+ .get = getRoute,
+ .ro = true,
+ .ts = d.ts{
+ .@"return" = "string",
+ .tsdoc =
+ \\Route name
+ \\@example
+ \\`"blog/posts/[id]"`
+ \\`"blog/posts/[id]/[[...slug]]"`
+ \\`"blog"`
+ ,
+ },
+ },
+ .query = .{
+ .get = getQuery,
+ .ro = true,
+ .ts = d.ts{
+ .@"return" = "Record<string, string | string[]>",
+ .tsdoc =
+ \\Route parameters & parsed query string values as a key-value object
+ \\
+ \\@example
+ \\```js
+ \\console.assert(router.query.id === "123");
+ \\console.assert(router.pathname === "/blog/posts/123");
+ \\console.assert(router.route === "blog/posts/[id]");
+ \\```
+ ,
+ },
+ },
+ .params = .{
+ .get = getParams,
+ .ro = true,
+ .ts = d.ts{
+ .@"return" = "Record<string, string | string[]>",
+ .tsdoc =
+ \\Route parameters as a key-value object
+ \\
+ \\@example
+ \\```js
+ \\console.assert(router.query.id === "123");
+ \\console.assert(router.pathname === "/blog/posts/123");
+ \\console.assert(router.route === "blog/posts/[id]");
+ \\```
+ ,
+ },
+ },
+ },
+);
+
+pub fn getFilePath(
+ this: *Router,
+ ctx: js.JSContextRef,
+ _: js.JSObjectRef,
+ _: js.JSStringRef,
+ _: js.ExceptionRef,
+) js.JSValueRef {
+ return ZigString.init(this.route.file_path)
+ .withEncoding()
+ .toValueGC(ctx.ptr()).asRef();
+}
+
+pub fn finalize(
+ this: *Router,
+) void {
+ if (this.query_string_map) |*map| {
+ map.deinit();
+ }
+
+ if (this.needs_deinit) {
+ this.params_list_holder.deinit(bun.default_allocator);
+ this.params_list_holder = .{};
+ this.needs_deinit = false;
+ }
+
+ bun.default_allocator.destroy(this);
+}
+
+pub fn getPathname(
+ this: *Router,
+ ctx: js.JSContextRef,
+ _: js.JSObjectRef,
+ _: js.JSStringRef,
+ _: js.ExceptionRef,
+) js.JSValueRef {
+ return ZigString.init(this.route.pathname)
+ .withEncoding()
+ .toValueGC(ctx.ptr()).asRef();
+}
+
+pub fn getRoute(
+ this: *Router,
+ ctx: js.JSContextRef,
+ _: js.JSObjectRef,
+ _: js.JSStringRef,
+ _: js.ExceptionRef,
+) js.JSValueRef {
+ return ZigString.init(this.route.name)
+ .withEncoding()
+ .toValueGC(ctx.ptr()).asRef();
+}
+
+const KindEnum = struct {
+ pub const exact = "exact";
+ pub const catch_all = "catch-all";
+ pub const optional_catch_all = "optional-catch-all";
+ pub const dynamic = "dynamic";
+
+ // this is kinda stupid it should maybe just store it
+ pub fn init(name: string) ZigString {
+ if (strings.contains(name, "[[...")) {
+ return ZigString.init(optional_catch_all);
+ } else if (strings.contains(name, "[...")) {
+ return ZigString.init(catch_all);
+ } else if (strings.contains(name, "[")) {
+ return ZigString.init(dynamic);
+ } else {
+ return ZigString.init(exact);
+ }
+ }
+};
+
+pub fn getKind(
+ this: *Router,
+ ctx: js.JSContextRef,
+ _: js.JSObjectRef,
+ _: js.JSStringRef,
+ _: js.ExceptionRef,
+) js.JSValueRef {
+ return KindEnum.init(this.route.name).toValue(ctx.ptr()).asRef();
+}
+
+threadlocal var query_string_values_buf: [256]string = undefined;
+threadlocal var query_string_value_refs_buf: [256]ZigString = undefined;
+pub fn createQueryObject(ctx: js.JSContextRef, map: *QueryStringMap, _: js.ExceptionRef) callconv(.C) js.JSValueRef {
+ const QueryObjectCreator = struct {
+ query: *QueryStringMap,
+ pub fn create(this: *@This(), obj: *JSObject, global: *JSGlobalObject) void {
+ var iter = this.query.iter();
+ var str: ZigString = undefined;
+ while (iter.next(&query_string_values_buf)) |entry| {
+ str = ZigString.init(entry.name);
+
+ std.debug.assert(entry.values.len > 0);
+ if (entry.values.len > 1) {
+ var values = query_string_value_refs_buf[0..entry.values.len];
+ for (entry.values) |value, i| {
+ values[i] = ZigString.init(value);
+ }
+ obj.putRecord(global, &str, values.ptr, values.len);
+ } else {
+ query_string_value_refs_buf[0] = ZigString.init(entry.values[0]);
+
+ obj.putRecord(global, &str, &query_string_value_refs_buf, 1);
+ }
+ }
+ }
+ };
+
+ var creator = QueryObjectCreator{ .query = map };
+
+ var value = JSObject.createWithInitializer(QueryObjectCreator, &creator, ctx.ptr(), map.getNameCount());
+
+ return value.asRef();
+}
+
+pub fn getScriptSrcString(
+ comptime Writer: type,
+ writer: Writer,
+ file_path: string,
+ client_framework_enabled: bool,
+) void {
+ var entry_point_tempbuf: [bun.MAX_PATH_BYTES]u8 = undefined;
+ // We don't store the framework config including the client parts in the server
+ // instead, we just store a boolean saying whether we should generate this whenever the script is requested
+ // this is kind of bad. we should consider instead a way to inline the contents of the script.
+ if (client_framework_enabled) {
+ JSC.API.Bun.getPublicPath(
+ Bundler.ClientEntryPoint.generateEntryPointPath(
+ &entry_point_tempbuf,
+ Fs.PathName.init(file_path),
+ ),
+ VirtualMachine.vm.origin,
+ Writer,
+ writer,
+ );
+ } else {
+ JSC.API.Bun.getPublicPath(file_path, VirtualMachine.vm.origin, Writer, writer);
+ }
+}
+
+pub fn getScriptSrc(
+ this: *Router,
+ ctx: js.JSContextRef,
+ _: js.JSObjectRef,
+ _: js.JSStringRef,
+ _: js.ExceptionRef,
+) js.JSValueRef {
+ var script_src_buffer = std.ArrayList(u8).init(bun.default_allocator);
+
+ var writer = script_src_buffer.writer();
+ getScriptSrcString(@TypeOf(&writer), &writer, this.route.file_path, this.route.client_framework_enabled);
+
+ return ZigString.init(script_src_buffer.toOwnedSlice()).toExternalValue(ctx.ptr()).asObjectRef();
+}
+
+pub fn getParams(
+ this: *Router,
+ ctx: js.JSContextRef,
+ _: js.JSObjectRef,
+ _: js.JSStringRef,
+ exception: js.ExceptionRef,
+) js.JSValueRef {
+ if (this.param_map == null) {
+ if (this.route.params.len > 0) {
+ if (QueryStringMap.initWithScanner(getAllocator(ctx), CombinedScanner.init(
+ "",
+ this.route.pathnameWithoutLeadingSlash(),
+ this.route.name,
+ this.route.params,
+ ))) |map| {
+ this.param_map = map;
+ } else |_| {}
+ }
+ }
+
+ // If it's still null, there are no params
+ if (this.param_map) |*map| {
+ return createQueryObject(ctx, map, exception);
+ } else {
+ return JSValue.createEmptyObject(ctx.ptr(), 0).asRef();
+ }
+}
+
+pub fn getQuery(
+ this: *Router,
+ ctx: js.JSContextRef,
+ _: js.JSObjectRef,
+ _: js.JSStringRef,
+ exception: js.ExceptionRef,
+) js.JSValueRef {
+ if (this.query_string_map == null) {
+ if (this.route.params.len > 0) {
+ if (QueryStringMap.initWithScanner(getAllocator(ctx), CombinedScanner.init(
+ this.route.query_string,
+ this.route.pathnameWithoutLeadingSlash(),
+ this.route.name,
+
+ this.route.params,
+ ))) |map| {
+ this.query_string_map = map;
+ } else |_| {}
+ } else if (this.route.query_string.len > 0) {
+ if (QueryStringMap.init(getAllocator(ctx), this.route.query_string)) |map| {
+ this.query_string_map = map;
+ } else |_| {}
+ }
+ }
+
+ // If it's still null, the query string has no names.
+ if (this.query_string_map) |*map| {
+ return createQueryObject(ctx, map, exception);
+ } else {
+ return JSValue.createEmptyObject(ctx.ptr(), 0).asRef();
+ }
+}