Skip to content

Commit

Permalink
Automatically hot reload Bun.serve() (#4344)
Browse files Browse the repository at this point in the history
* Automatically hot reload Bun.serve()

* Update doc

* Update example

---------

Co-authored-by: Jarred Sumner <[email protected]>
  • Loading branch information
Jarred-Sumner and Jarred-Sumner authored Aug 26, 2023
1 parent f70bb24 commit d98a93c
Show file tree
Hide file tree
Showing 11 changed files with 503 additions and 57 deletions.
38 changes: 4 additions & 34 deletions docs/runtime/hot.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,55 +102,25 @@ Traditional file watchers like `nodemon` restart the entire process, so HTTP ser
Bun provides the following simplified API for implementing HTTP servers. Refer to [API > HTTP](/docs/api/http) for full details.

```ts#server.ts
import {type Serve} from "bun";
import {serve} from "bun";

globalThis.count ??= 0;
globalThis.count++;

export default {
serve({
fetch(req: Request) {
return new Response(`Reloaded ${globalThis.count} times`);
},
port: 3000,
} satisfies Serve;
});
```

The file above is simply exporting an object with a `fetch` handler defined. When this file is executed, Bun interprets this as an HTTP server and passes the exported object into `Bun.serve`.

Unlike an explicit call to `Bun.serve`, the object-based syntax works out of the box with `bun --hot`. When you save the file, your HTTP server be reloaded with the updated code without the process being restarted. This results in seriously fast refresh speeds.
When you save the file, your HTTP server be reloaded with the updated code without the process being restarted. This results in seriously fast refresh speeds.

{% image src="https://user-images.githubusercontent.com/709451/195477632-5fd8a73e-014d-4589-9ba2-e075ad9eb040.gif" alt="Bun vs Nodemon refresh speeds" caption="Bun on the left, Nodemon on the right." /%}

For more fine-grained control, you can use the `Bun.serve` API directly and handle the server reloading manually.

```ts#server.ts
import type {Serve, Server} from "bun";

// make TypeScript happy
declare global {
var count: number;
var server: Server;
}

globalThis.count ??= 0;
globalThis.count++;

// define server parameters
const serverOptions: Serve = {
port: 3000,
fetch(req) {
return new Response(`Reloaded ${globalThis.count} times`);
}
};

if (!globalThis.server) {
globalThis.server = Bun.serve(serverOptions);
} else {
// reload server
globalThis.server.reload(serverOptions);
}
```

{% callout %}
**Note** — In a future version of Bun, support for Vite's `import.meta.hot` is planned to enable better lifecycle management for hot reloading and to align with the ecosystem.

Expand Down
4 changes: 2 additions & 2 deletions examples/bun-hot-websockets.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const styles = css`
}
`;

export default {
Bun.serve({
websocket: {
message(ws, msg) {
ws.send(styles);
Expand Down Expand Up @@ -86,4 +86,4 @@ export default {
},
);
},
};
});
24 changes: 24 additions & 0 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1820,6 +1820,21 @@ declare module "bun" {
this: Server,
request: Errorlike,
) => Response | Promise<Response> | undefined | void | Promise<undefined>;

/**
* Uniquely identify a server instance with an ID
*
* ### When bun is started with the `--hot` flag
*
* This string will be used to hot reload the server without interrupting
* pending requests or websockets. If not provided, a value will be
* generated. To disable hot reloading, set this value to `null`.
*
* ### When bun is not started with the `--hot` flag
*
* This string will currently do nothing. But in the future it could be useful for logs or metrics.
*/
id?: string | null;
}

export type AnyFunction = (..._: any[]) => any;
Expand Down Expand Up @@ -2345,6 +2360,15 @@ declare module "bun" {
*
*/
readonly development: boolean;

/**
* An identifier of the server instance
*
* When bun is started with the `--hot` flag, this ID is used to hot reload the server without interrupting pending requests or websockets.
*
* When bun is not started with the `--hot` flag, this ID is currently unused.
*/
readonly id: string;
}

/**
Expand Down
60 changes: 59 additions & 1 deletion src/bun.js/api/bun.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2481,7 +2481,7 @@ pub fn serve(
callframe: *JSC.CallFrame,
) callconv(.C) JSC.JSValue {
const arguments = callframe.arguments(2).slice();
const config: JSC.API.ServerConfig = brk: {
var config: JSC.API.ServerConfig = brk: {
var exception_ = [1]JSC.JSValueRef{null};
var exception = &exception_;

Expand All @@ -2497,6 +2497,40 @@ pub fn serve(

var exception_value: *JSC.JSValue = undefined;

if (config.allow_hot) {
if (globalObject.bunVM().hotMap()) |hot| {
if (config.id.len == 0) {
config.id = config.computeID(globalObject.allocator());
}

if (hot.getEntry(config.id)) |entry| {
switch (entry.tag()) {
@field(@TypeOf(entry.tag()), @typeName(JSC.API.HTTPServer)) => {
var server: *JSC.API.HTTPServer = entry.as(JSC.API.HTTPServer);
server.onReloadFromZig(&config, globalObject);
return server.thisObject;
},
@field(@TypeOf(entry.tag()), @typeName(JSC.API.DebugHTTPServer)) => {
var server: *JSC.API.DebugHTTPServer = entry.as(JSC.API.DebugHTTPServer);
server.onReloadFromZig(&config, globalObject);
return server.thisObject;
},
@field(@TypeOf(entry.tag()), @typeName(JSC.API.DebugHTTPSServer)) => {
var server: *JSC.API.DebugHTTPSServer = entry.as(JSC.API.DebugHTTPSServer);
server.onReloadFromZig(&config, globalObject);
return server.thisObject;
},
@field(@TypeOf(entry.tag()), @typeName(JSC.API.HTTPSServer)) => {
var server: *JSC.API.HTTPSServer = entry.as(JSC.API.HTTPSServer);
server.onReloadFromZig(&config, globalObject);
return server.thisObject;
},
else => {},
}
}
}
}

// Listen happens on the next tick!
// This is so we can return a Server object
if (config.ssl_config != null) {
Expand All @@ -2515,6 +2549,12 @@ pub fn serve(
obj.protect();

server.thisObject = obj;

if (config.allow_hot) {
if (globalObject.bunVM().hotMap()) |hot| {
hot.insert(config.id, server);
}
}
return obj;
} else {
var server = JSC.API.HTTPSServer.init(config, globalObject.ptr());
Expand All @@ -2530,6 +2570,12 @@ pub fn serve(
const obj = server.toJS(globalObject);
obj.protect();
server.thisObject = obj;

if (config.allow_hot) {
if (globalObject.bunVM().hotMap()) |hot| {
hot.insert(config.id, server);
}
}
return obj;
}
} else {
Expand All @@ -2547,6 +2593,12 @@ pub fn serve(
const obj = server.toJS(globalObject);
obj.protect();
server.thisObject = obj;

if (config.allow_hot) {
if (globalObject.bunVM().hotMap()) |hot| {
hot.insert(config.id, server);
}
}
return obj;
} else {
var server = JSC.API.HTTPServer.init(config, globalObject.ptr());
Expand All @@ -2563,6 +2615,12 @@ pub fn serve(
obj.protect();

server.thisObject = obj;

if (config.allow_hot) {
if (globalObject.bunVM().hotMap()) |hot| {
hot.insert(config.id, server);
}
}
return obj;
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/bun.js/api/server.classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ function generate(name) {
port: {
getter: "getPort",
},
id: {
getter: "getId",
cache: true,
},
pendingRequests: {
getter: "getPendingRequests",
},
Expand Down
108 changes: 88 additions & 20 deletions src/bun.js/api/server.zig
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,36 @@ pub const ServerConfig = struct {

inspector: bool = false,
reuse_port: bool = false,
id: []const u8 = "",
allow_hot: bool = true,

pub fn computeID(this: *const ServerConfig, allocator: std.mem.Allocator) []const u8 {
var arraylist = std.ArrayList(u8).init(allocator);
var writer = arraylist.writer();

writer.writeAll("[http]-") catch {};
switch (this.address) {
.tcp => {
if (this.address.tcp.hostname) |host| {
writer.print("tcp:{s}:{d}", .{
bun.sliceTo(host, 0),
this.address.tcp.port,
}) catch {};
} else {
writer.print("tcp:localhost:{d}", .{
this.address.tcp.port,
}) catch {};
}
},
.unix => {
writer.print("unix:{s}", .{
bun.sliceTo(this.address.unix, 0),
}) catch {};
},
}

return arraylist.items;
}

pub const SSLConfig = struct {
server_name: [*c]const u8 = null,
Expand Down Expand Up @@ -794,6 +824,23 @@ pub const ServerConfig = struct {
}
}

if (arg.get(global, "id")) |id| {
if (id.isUndefinedOrNull()) {
args.allow_hot = false;
} else {
const id_str = id.toSlice(
global,
bun.default_allocator,
);

if (id_str.len > 0) {
args.id = (id_str.cloneIfNeeded(bun.default_allocator) catch unreachable).slice();
} else {
args.allow_hot = false;
}
}
}

if (arg.get(global, "development")) |dev| {
args.development = dev.coerce(bool, global);
args.reuse_port = !args.development;
Expand Down Expand Up @@ -4867,26 +4914,8 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp
return JSC.jsBoolean(true);
}

pub fn onReload(
this: *ThisServer,
globalThis: *JSC.JSGlobalObject,
callframe: *JSC.CallFrame,
) callconv(.C) JSC.JSValue {
const arguments = callframe.arguments(1).slice();
if (arguments.len < 1) {
globalThis.throwNotEnoughArguments("reload", 1, 0);
return .zero;
}

var args_slice = JSC.Node.ArgumentsSlice.init(globalThis.bunVM(), arguments);
defer args_slice.deinit();
var exception_ref = [_]JSC.C.JSValueRef{null};
var exception: JSC.C.ExceptionRef = &exception_ref;
var new_config = ServerConfig.fromJS(globalThis, &args_slice, exception);
if (exception.* != null) {
globalThis.throwValue(exception_ref[0].?.value());
return .zero;
}
pub fn onReloadFromZig(this: *ThisServer, new_config: *ServerConfig, globalThis: *JSC.JSGlobalObject) void {
httplog("onReload", .{});

// only reload those two
if (this.config.onRequest != new_config.onRequest) {
Expand Down Expand Up @@ -4915,6 +4944,30 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp
this.config.websocket = ws.*;
} // we don't remove it
}
}

pub fn onReload(
this: *ThisServer,
globalThis: *JSC.JSGlobalObject,
callframe: *JSC.CallFrame,
) callconv(.C) JSC.JSValue {
const arguments = callframe.arguments(1).slice();
if (arguments.len < 1) {
globalThis.throwNotEnoughArguments("reload", 1, 0);
return .zero;
}

var args_slice = JSC.Node.ArgumentsSlice.init(globalThis.bunVM(), arguments);
defer args_slice.deinit();
var exception_ref = [_]JSC.C.JSValueRef{null};
var exception: JSC.C.ExceptionRef = &exception_ref;
var new_config = ServerConfig.fromJS(globalThis, &args_slice, exception);
if (exception.* != null) {
globalThis.throwValue(exception_ref[0].?.value());
return .zero;
}

this.onReloadFromZig(&new_config, globalThis);

return this.thisObject;
}
Expand Down Expand Up @@ -5066,6 +5119,15 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp
return JSC.JSValue.jsNumber(listener.getLocalPort());
}

pub fn getId(
this: *ThisServer,
globalThis: *JSC.JSGlobalObject,
) callconv(.C) JSC.JSValue {
var str = bun.String.create(this.config.id);
defer str.deref();
return str.toJS(globalThis);
}

pub fn getPendingRequests(
this: *ThisServer,
_: *JSC.JSGlobalObject,
Expand Down Expand Up @@ -5170,6 +5232,12 @@ pub fn NewServer(comptime NamespaceType: type, comptime ssl_enabled_: bool, comp
}

pub fn stop(this: *ThisServer, abrupt: bool) void {
if (this.config.allow_hot and this.config.id.len > 0) {
if (this.globalThis.bunVM().hotMap()) |hot| {
hot.remove(this.config.id);
}
}

this.stopListening(abrupt);
this.deinitIfWeCan();
}
Expand Down
Loading

0 comments on commit d98a93c

Please sign in to comment.