diff options
Diffstat (limited to 'src/bun.js/api/router.zig')
-rw-r--r-- | src/bun.js/api/router.zig | 541 |
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(); + } +} |