Compare commits

..

5 Commits

Author SHA1 Message Date
Claude Bot
427bb66604 Add Bun.TOON API for Token-Oriented Object Notation
Implements basic infrastructure for Bun.TOON.parse() and Bun.TOON.stringify().
TOON is a compact, human-readable format designed for passing structured data
to Large Language Models with significantly reduced token usage.

This commit adds:
- Phase 1 complete: Full API wiring for Bun.TOON object
- Bun.TOON.stringify() with support for:
  - Primitives (null, boolean, number, string)
  - Proper quote detection and escaping
  - Space parameter support (number or string)
  - Circular reference detection
- Bun.TOON.parse() stub (returns SyntaxError for now)
- Test suite with passing tests for stringify primitives
- Analytics tracking for toon_parse and toon_stringify

Files added:
- src/bun.js/api/TOONObject.zig: API wrapper calling into interchange layer
- src/interchange/toon.zig: Parser and stringifier implementation
- test/js/bun/toon/toon.test.ts: Test suite

Modified files:
- src/bun.js/bindings/BunObject.cpp: Added TOON property callback
- src/bun.js/bindings/BunObject+exports.h: Export TOON getter
- src/bun.js/api/BunObject.zig: Wire up TOON lazy property
- src/bun.js/api.zig: Export TOONObject
- src/interchange.zig: Export toon module
- src/analytics.zig: Add toon_parse and toon_stringify metrics

Phase 2 remaining: Full TOON parser and advanced stringifier features
(objects, arrays, tabular format) to be implemented in follow-up PRs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 18:16:53 +00:00
Jarred Sumner
b280e8d326 Enable more sanitizers in CI (#24117)
### What does this PR do?

We were only enabling UBSAN in debug builds. This was probably a
mistake.

### How did you verify your code works?
2025-10-27 02:37:05 -07:00
Jarred Sumner
b7ae21d0bc Mark flaky test as TODO 2025-10-26 14:29:31 -07:00
robobun
a75cef5079 Add comprehensive documentation for JSRef (#24095)
## Summary

- Adds detailed documentation explaining JSRef's intended usage
- Includes a complete example showing common patterns
- Explains the three states (weak, strong, finalized) 
- Provides guidelines on when to use strong vs weak references
- References real examples from the codebase (ServerWebSocket,
UDPSocket, MySQLConnection, ValkeyClient)

## Motivation

JSRef is a critical type for managing JavaScript object references from
native code, but it lacked comprehensive documentation explaining its
usage patterns and lifecycle management. This makes it clearer how to
properly use JSRef to:

- Safely maintain references to JS objects from native code
- Control whether references prevent garbage collection
- Manage the upgrade/downgrade pattern based on object activity

## Test plan

Documentation-only change, no functional changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-26 01:28:27 -07:00
github-actions[bot]
4c00d8f016 deps: update elysia to 1.4.13 (#24085)
## What does this PR do?

Updates elysia to version 1.4.13

Compare: https://github.com/elysiajs/elysia/compare/1.4.12...1.4.13

Auto-updated by [this
workflow](https://github.com/oven-sh/bun/actions/workflows/update-vendor.yml)

Co-authored-by: Jarred-Sumner <709451+Jarred-Sumner@users.noreply.github.com>
2025-10-25 22:03:34 -07:00
20 changed files with 602 additions and 1102 deletions

View File

@@ -944,7 +944,7 @@ if(NOT WIN32)
if (NOT ABI STREQUAL "musl")
target_compile_options(${bun} PUBLIC
-fsanitize=null
-fsanitize-recover=all
-fno-sanitize-recover=all
-fsanitize=bounds
-fsanitize=return
-fsanitize=nullability-arg
@@ -999,6 +999,20 @@ if(NOT WIN32)
)
if(ENABLE_ASAN)
target_compile_options(${bun} PUBLIC
-fsanitize=null
-fno-sanitize-recover=all
-fsanitize=bounds
-fsanitize=return
-fsanitize=nullability-arg
-fsanitize=nullability-assign
-fsanitize=nullability-return
-fsanitize=returns-nonnull-attribute
-fsanitize=unreachable
)
target_link_libraries(${bun} PRIVATE
-fsanitize=null
)
target_compile_options(${bun} PUBLIC -fsanitize=address)
target_link_libraries(${bun} PUBLIC -fsanitize=address)
endif()

View File

@@ -535,41 +535,18 @@ declare module "bun" {
type BaseRouteValue = Response | false | HTMLBundle | BunFile;
/**
* Route configuration with optional WebSocket handler.
* When `websocket` is specified, an `upgrade` handler must also be provided.
* @template WebSocketData - Type of data attached to WebSocket connections
* @template Path - Route path for typed route parameters (e.g., "/user/:id")
* @template HTTPResponse - HTTP handler return type (Response or Response | undefined | void)
*/
type RouteWithWebSocket<WebSocketData, Path extends string = string, HTTPResponse = Response> =
| (Partial<Record<HTTPMethod, Handler<BunRequest<Path>, Server<WebSocketData>, HTTPResponse>>> & {
websocket: WebSocketHandler<WebSocketData>;
/**
* Upgrade handler for WebSocket connections.
* Required when `websocket` is specified.
*/
upgrade: Handler<BunRequest<Path>, Server<WebSocketData>, Response | undefined | void>;
})
| (Partial<Record<HTTPMethod, Handler<BunRequest<Path>, Server<WebSocketData>, HTTPResponse>>> & {
websocket?: never;
upgrade?: Handler<BunRequest<Path>, Server<WebSocketData>, Response | undefined | void>;
});
type Routes<WebSocketData, R extends string> = {
[Path in R]:
| BaseRouteValue
| Handler<BunRequest<Path>, Server<WebSocketData>, Response>
| Partial<Record<HTTPMethod, Handler<BunRequest<Path>, Server<WebSocketData>, Response>>>
| RouteWithWebSocket<WebSocketData, Path, Response>;
| Partial<Record<HTTPMethod, Handler<BunRequest<Path>, Server<WebSocketData>, Response>>>;
};
type RoutesWithUpgrade<WebSocketData, R extends string> = {
[Path in R]:
| BaseRouteValue
| Handler<BunRequest<Path>, Server<WebSocketData>, Response | undefined | void>
| Partial<Record<HTTPMethod, Handler<BunRequest<Path>, Server<WebSocketData>, Response | undefined | void>>>
| RouteWithWebSocket<WebSocketData, Path, Response | undefined | void>;
| Partial<Record<HTTPMethod, Handler<BunRequest<Path>, Server<WebSocketData>, Response | undefined | void>>>;
};
type FetchOrRoutes<WebSocketData, R extends string> =

View File

@@ -87,6 +87,8 @@ pub const Features = struct {
pub var yarn_migration: usize = 0;
pub var pnpm_migration: usize = 0;
pub var yaml_parse: usize = 0;
pub var toon_parse: usize = 0;
pub var toon_stringify: usize = 0;
comptime {
@export(&napi_module_register, .{ .name = "Bun__napi_module_register_count" });

View File

@@ -27,6 +27,7 @@ pub const Subprocess = @import("./api/bun/subprocess.zig");
pub const HashObject = @import("./api/HashObject.zig");
pub const UnsafeObject = @import("./api/UnsafeObject.zig");
pub const TOMLObject = @import("./api/TOMLObject.zig");
pub const TOONObject = @import("./api/TOONObject.zig");
pub const YAMLObject = @import("./api/YAMLObject.zig");
pub const Timer = @import("./api/Timer.zig");
pub const FFIObject = @import("./api/FFIObject.zig");

View File

@@ -62,6 +62,7 @@ pub const BunObject = struct {
pub const SHA512 = toJSLazyPropertyCallback(Crypto.SHA512.getter);
pub const SHA512_256 = toJSLazyPropertyCallback(Crypto.SHA512_256.getter);
pub const TOML = toJSLazyPropertyCallback(Bun.getTOMLObject);
pub const TOON = toJSLazyPropertyCallback(Bun.getTOONObject);
pub const YAML = toJSLazyPropertyCallback(Bun.getYAMLObject);
pub const Transpiler = toJSLazyPropertyCallback(Bun.getTranspilerConstructor);
pub const argv = toJSLazyPropertyCallback(Bun.getArgv);
@@ -127,6 +128,7 @@ pub const BunObject = struct {
@export(&BunObject.SHA512_256, .{ .name = lazyPropertyCallbackName("SHA512_256") });
@export(&BunObject.TOML, .{ .name = lazyPropertyCallbackName("TOML") });
@export(&BunObject.TOON, .{ .name = lazyPropertyCallbackName("TOON") });
@export(&BunObject.YAML, .{ .name = lazyPropertyCallbackName("YAML") });
@export(&BunObject.Glob, .{ .name = lazyPropertyCallbackName("Glob") });
@export(&BunObject.Transpiler, .{ .name = lazyPropertyCallbackName("Transpiler") });
@@ -1269,6 +1271,10 @@ pub fn getTOMLObject(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSVa
return TOMLObject.create(globalThis);
}
pub fn getTOONObject(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSValue {
return TOONObject.create(globalThis);
}
pub fn getYAMLObject(globalThis: *jsc.JSGlobalObject, _: *jsc.JSObject) jsc.JSValue {
return YAMLObject.create(globalThis);
}
@@ -2059,6 +2065,7 @@ const api = bun.api;
const FFIObject = bun.api.FFIObject;
const HashObject = bun.api.HashObject;
const TOMLObject = bun.api.TOMLObject;
const TOONObject = bun.api.TOONObject;
const UnsafeObject = bun.api.UnsafeObject;
const YAMLObject = bun.api.YAMLObject;
const node = bun.api.node;

View File

@@ -0,0 +1,107 @@
pub fn create(globalThis: *jsc.JSGlobalObject) jsc.JSValue {
const object = JSValue.createEmptyObject(globalThis, 2);
object.put(
globalThis,
ZigString.static("parse"),
jsc.createCallback(
globalThis,
ZigString.static("parse"),
1,
parse,
),
);
object.put(
globalThis,
ZigString.static("stringify"),
jsc.createCallback(
globalThis,
ZigString.static("stringify"),
3,
stringify,
),
);
return object;
}
pub fn parse(
globalThis: *jsc.JSGlobalObject,
callframe: *jsc.CallFrame,
) bun.JSError!jsc.JSValue {
var arena = bun.ArenaAllocator.init(globalThis.allocator());
const allocator = arena.allocator();
defer arena.deinit();
var log = logger.Log.init(default_allocator);
const arguments = callframe.arguments_old(1).slice();
if (arguments.len == 0 or arguments[0].isEmptyOrUndefinedOrNull()) {
return globalThis.throwInvalidArguments("Expected a string to parse", .{});
}
var input_slice = try arguments[0].toSlice(globalThis, bun.default_allocator);
defer input_slice.deinit();
const source = &logger.Source.initPathString("input.toon", input_slice.slice());
const parse_result = TOON.parse(source, &log, allocator) catch {
return globalThis.throwValue(try log.toJS(globalThis, default_allocator, "Failed to parse toon"));
};
// Convert parsed result to JSON
const buffer_writer = js_printer.BufferWriter.init(allocator);
var writer = js_printer.BufferPrinter.init(buffer_writer);
_ = js_printer.printJSON(
*js_printer.BufferPrinter,
&writer,
parse_result,
source,
.{
.mangled_props = null,
},
) catch {
return globalThis.throwValue(try log.toJS(globalThis, default_allocator, "Failed to print toon"));
};
const slice = writer.ctx.buffer.slice();
var out = bun.String.borrowUTF8(slice);
defer out.deref();
return out.toJSByParseJSON(globalThis);
}
pub fn stringify(
globalThis: *jsc.JSGlobalObject,
callframe: *jsc.CallFrame,
) bun.JSError!jsc.JSValue {
const value, const replacer, const space_value = callframe.argumentsAsArray(3);
value.ensureStillAlive();
if (value.isUndefined() or value.isSymbol() or value.isFunction()) {
return .js_undefined;
}
if (!replacer.isUndefinedOrNull()) {
return globalThis.throw("TOON.stringify does not support the replacer argument", .{});
}
var scope: bun.AllocationScope = .init(bun.default_allocator);
defer scope.deinit();
var stringifier = TOON.stringify(scope.allocator(), globalThis, value, space_value) catch |err| return switch (err) {
error.OutOfMemory => error.JSError,
error.JSError, error.JSTerminated => |js_err| js_err,
error.StackOverflow => globalThis.throwStackOverflow(),
};
defer stringifier.deinit();
return stringifier.toString(globalThis);
}
const bun = @import("bun");
const default_allocator = bun.default_allocator;
const js_printer = bun.js_printer;
const logger = bun.logger;
const TOON = bun.interchange.toon.TOON;
const jsc = bun.jsc;
const JSGlobalObject = jsc.JSGlobalObject;
const JSValue = jsc.JSValue;
const ZigString = jsc.ZigString;

View File

@@ -561,19 +561,10 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
/// So we have to store it.
user_routes: std.ArrayListUnmanaged(UserRoute) = .{},
/// Per-route WebSocket contexts. Index is (id - 2) where id comes from app.ws()
/// Use Shared pointers to ensure stable addresses (ServerWebSocket stores raw pointers to handlers)
/// When ref count reaches 0, WebSocketServerContext.deinit() is automatically called to unprotect JSValues
route_websocket_contexts: std.ArrayListUnmanaged(SharedWebSocketContext) = .{},
on_clienterror: jsc.Strong.Optional = .empty,
inspector_server_id: jsc.Debugger.DebuggerId = .init(0),
/// Shared pointer type for route-specific WebSocket contexts
/// .deinit = true enables automatic cleanup: when the last reference is released,
/// WebSocketServerContext.deinit() is called to unprotect JSValues
pub const SharedWebSocketContext = bun.ptr.shared.WithOptions(*WebSocketServerContext, .{ .deinit = true });
pub const doStop = host_fn.wrapInstanceMethod(ThisServer, "stopFromJS", false);
pub const dispose = host_fn.wrapInstanceMethod(ThisServer, "disposeFromJS", false);
pub const doUpgrade = host_fn.wrapInstanceMethod(ThisServer, "onUpgrade", false);
@@ -587,8 +578,6 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
id: u32,
server: *ThisServer,
route: ServerConfig.RouteDeclaration,
/// Index into route_websocket_contexts, or null if no route-specific websocket
websocket_context_index: ?u32 = null,
pub fn deinit(this: *UserRoute) void {
this.route.deinit();
@@ -748,10 +737,8 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
}
pub fn onUpgrade(this: *ThisServer, globalThis: *jsc.JSGlobalObject, object: jsc.JSValue, optional: ?JSValue) bun.JSError!JSValue {
// Check if we have either a global websocket or route-specific websockets
const has_websocket = this.config.websocket != null or this.route_websocket_contexts.items.len > 0;
if (!has_websocket) {
return globalThis.throwInvalidArguments("To enable websocket support, set the \"websocket\" object in Bun.serve({}) or in a route", .{});
if (this.config.websocket == null) {
return globalThis.throwInvalidArguments("To enable websocket support, set the \"websocket\" object in Bun.serve({})", .{});
}
if (this.flags.terminated) {
@@ -976,16 +963,6 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
}
}
// Verify we have a WebSocket handler for this upgrade
// Either route-specific or global
if (upgrader.route_websocket_context_index) |ws_idx| {
if (ws_idx >= this.route_websocket_contexts.items.len) {
return globalThis.throwInvalidArguments("Invalid WebSocket context index for this route", .{});
}
} else if (this.config.websocket == null) {
return globalThis.throwInvalidArguments("No WebSocket handler available for this route", .{});
}
// Write status, custom headers, and cookies in one place
if (fetch_headers_to_use != null or cookies_to_write != null) {
// we must write the status first so that 200 OK isn't written
@@ -1012,12 +989,7 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
upgrader.request_weakref.deref();
data_value.ensureStillAlive();
// Create ServerWebSocket with route-specific or global handler
const ws = if (upgrader.route_websocket_context_index) |ws_idx|
ServerWebSocket.initWithSharedContext(this.route_websocket_contexts.items[ws_idx], data_value, signal)
else
ServerWebSocket.init(&this.config.websocket.?.handler, data_value, signal);
const ws = ServerWebSocket.init(&this.config.websocket.?.handler, data_value, signal);
data_value.ensureStillAlive();
var sec_websocket_protocol_str = sec_websocket_protocol.toSlice(bun.default_allocator);
@@ -1637,12 +1609,6 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
}
this.user_routes.deinit(bun.default_allocator);
// Clean up route-specific WebSocket contexts
for (this.route_websocket_contexts.items) |*shared_ws| {
shared_ws.deinit(); // Decrements ref count, calls WebSocketServerContext.deinit() when count reaches 0
}
this.route_websocket_contexts.deinit(bun.default_allocator);
this.config.deinit();
this.on_clienterror.deinit();
@@ -2340,10 +2306,6 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
var should_deinit_context = false;
var prepared = server.prepareJsRequestContext(req, resp, &should_deinit_context, .no, method) orelse return;
prepared.ctx.upgrade_context = upgrade_ctx; // set the upgrade context
// Store route-specific WebSocket context index if present
prepared.ctx.route_websocket_context_index = this.websocket_context_index;
const server_request_list = js.routeListGetCached(server.jsValueAssertAlive()).?;
const response_value = bun.jsc.fromJSHostCall(server.globalThis, @src(), Bun__ServerRouteList__callRoute, .{ server.globalThis, index, prepared.request_object, server.jsValueAssertAlive(), server_request_list, &prepared.js_request, req }) catch |err| server.globalThis.takeException(err);
@@ -2352,10 +2314,8 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
pub fn onWebSocketUpgrade(this: *ThisServer, resp: *App.Response, req: *uws.Request, upgrade_ctx: *uws.SocketContext, id: usize) void {
jsc.markBinding(@src());
if (id >= 1) {
// This is actually a UserRoute if id >= 1 so it's safe to cast
// id == 1: global websocket
// id >= 2: route-specific websocket (context index stored in UserRoute)
if (id == 1) {
// This is actually a UserRoute if id is 1 so it's safe to cast
upgradeWebSocketUserRoute(@ptrCast(this), resp, req, upgrade_ctx, null);
return;
}
@@ -2521,23 +2481,7 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
for (old_user_routes.items) |*r| r.route.deinit();
old_user_routes.deinit(bun.default_allocator);
}
// Clean up old route-specific WebSocket contexts
var old_route_websocket_contexts = this.route_websocket_contexts;
defer {
// Deinit Shared pointers - this loop decrements ref counts.
// With .deinit = true, when ref count reaches 0, WebSocketServerContext.deinit()
// is called automatically to unprotect JSValues.
for (old_route_websocket_contexts.items) |*shared_ws| {
shared_ws.deinit();
}
// Free the slice container using bun.default_allocator
old_route_websocket_contexts.deinit(bun.default_allocator);
}
this.user_routes = std.ArrayListUnmanaged(UserRoute).initCapacity(bun.default_allocator, user_routes_to_build_list.items.len) catch @panic("OOM");
this.route_websocket_contexts = std.ArrayListUnmanaged(SharedWebSocketContext).initCapacity(bun.default_allocator, user_routes_to_build_list.items.len) catch @panic("OOM");
const paths_zig = bun.default_allocator.alloc(ZigString, user_routes_to_build_list.items.len) catch @panic("OOM");
defer bun.default_allocator.free(paths_zig);
const callbacks_js = bun.default_allocator.alloc(jsc.JSValue, user_routes_to_build_list.items.len) catch @panic("OOM");
@@ -2546,25 +2490,10 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
for (user_routes_to_build_list.items, paths_zig, callbacks_js, 0..) |*builder, *p_zig, *cb_js, i| {
p_zig.* = ZigString.init(builder.route.path);
cb_js.* = builder.callback.get().?;
// Store route-specific WebSocket context if present
var ws_ctx_index: ?u32 = null;
if (builder.websocket) |ws| {
ws_ctx_index = @truncate(this.route_websocket_contexts.items.len);
// Use Shared pointer to ensure stable memory address for raw pointers in ServerWebSocket
// .deinit = true enables automatic cleanup: when ref count reaches 0,
// WebSocketServerContext.deinit() is called automatically to unprotect JSValues
const shared_ws = SharedWebSocketContext.new(ws);
shared_ws.get().protect();
this.route_websocket_contexts.appendAssumeCapacity(shared_ws);
builder.websocket = null; // Mark as moved
}
this.user_routes.appendAssumeCapacity(.{
.id = @truncate(i),
.server = this,
.route = builder.route,
.websocket_context_index = ws_ctx_index,
});
builder.route = .{}; // Mark as moved
}
@@ -2580,14 +2509,6 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
websocket.handler.flags.ssl = ssl_enabled;
}
// Setup route-specific WebSocket contexts
for (this.route_websocket_contexts.items) |*shared_ws| {
const websocket = shared_ws.get();
websocket.globalObject = this.globalThis;
websocket.handler.app = app;
websocket.handler.flags.ssl = ssl_enabled;
}
// --- 3. Register compiled user routes (this.user_routes) & Track "/*" Coverage ---
var star_methods_covered_by_user = bun.http.Method.Set.initEmpty();
var has_any_user_route_for_star_path = false; // True if "/*" path appears in user_routes at all
@@ -2613,28 +2534,14 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
star_methods_covered_by_user = .initFull();
}
// Register WebSocket route - prefer route-specific context over global
if (user_route.websocket_context_index) |ws_idx| {
// Route has its own WebSocket handler
if (is_star_path) {
has_any_ws_route_for_star_path = true;
}
const ws_context = this.route_websocket_contexts.items[ws_idx].get();
app.ws(
user_route.route.path,
user_route,
2 + ws_idx, // id = 2 + index for route-specific handlers
ServerWebSocket.behavior(ThisServer, ssl_enabled, ws_context.toBehavior()),
);
} else if (this.config.websocket) |*websocket| {
// Use global WebSocket handler
if (this.config.websocket) |*websocket| {
if (is_star_path) {
has_any_ws_route_for_star_path = true;
}
app.ws(
user_route.route.path,
user_route,
1, // id 1 means is a user route with global websocket
1, // id 1 means is a user route
ServerWebSocket.behavior(ThisServer, ssl_enabled, websocket.toBehavior()),
);
}
@@ -2646,23 +2553,13 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
}
// Setup user websocket in the route if needed.
// WebSocket upgrade is a GET request, so only register for GET or ANY methods
if (method_val == .GET) {
if (user_route.websocket_context_index) |ws_idx| {
// Route has its own WebSocket handler
const ws_context = this.route_websocket_contexts.items[ws_idx].get();
if (this.config.websocket) |*websocket| {
// Websocket upgrade is a GET request
if (method_val == .GET) {
app.ws(
user_route.route.path,
user_route,
2 + ws_idx, // id = 2 + index for route-specific handlers
ServerWebSocket.behavior(ThisServer, ssl_enabled, ws_context.toBehavior()),
);
} else if (this.config.websocket) |*websocket| {
// Use global WebSocket handler
app.ws(
user_route.route.path,
user_route,
1, // id 1 means is a user route with global websocket
1, // id 1 means is a user route
ServerWebSocket.behavior(ThisServer, ssl_enabled, websocket.toBehavior()),
);
}

View File

@@ -44,8 +44,6 @@ pub fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool,
flags: NewFlags(debug_mode) = .{},
upgrade_context: ?*uws.SocketContext = null,
/// Index into server.route_websocket_contexts for route-specific WebSocket handlers
route_websocket_context_index: ?u32 = null,
/// We can only safely free once the request body promise is finalized
/// and the response is rejected

View File

@@ -581,47 +581,6 @@ pub fn fromJS(
HTTP.Method.TRACE,
};
var found = false;
var websocket_ctx: ?WebSocketServerContext = null;
var upgrade_callback: jsc.Strong.Optional = .empty;
// Check for websocket and upgrade fields
if (try value.getOwn(global, "websocket")) |ws_value| {
if (!ws_value.isUndefined()) {
websocket_ctx = try WebSocketServerContext.onCreate(global, ws_value);
}
}
if (try value.getOwn(global, "upgrade")) |upgrade_value| {
if (upgrade_value.isCallable()) {
upgrade_callback = .create(upgrade_value.withAsyncContextIfNeeded(global), global);
}
}
// Validate: if route has websocket, it must have upgrade
if (websocket_ctx != null and upgrade_callback.impl == null) {
return global.throwInvalidArguments("Route has 'websocket' but missing 'upgrade' handler. Both must be specified together.", .{});
}
// If we have an upgrade handler, add it FIRST (before other method handlers)
// This ensures app.ws() is registered before app.method(.GET), so WebSocket
// upgrade requests are handled by app.ws(), and regular GET requests yield
// and fall through to the GET handler
if (upgrade_callback.impl != null) {
if (!found) {
try validateRouteName(global, path);
}
args.user_routes_to_build.append(.{
.route = .{
.path = bun.handleOom(bun.default_allocator.dupeZ(u8, path)),
.method = .{ .specific = .GET },
},
.callback = upgrade_callback,
.websocket = websocket_ctx, // May be null (uses global)
}) catch |err| bun.handleOom(err);
found = true;
}
// Process HTTP method handlers (registered after upgrade handler)
inline for (methods) |method| {
if (try value.getOwn(global, @tagName(method))) |function| {
if (!found) {
@@ -630,15 +589,12 @@ pub fn fromJS(
found = true;
if (function.isCallable()) {
// Never attach websocket to method handlers
// WebSocket is handled separately via the upgrade callback
args.user_routes_to_build.append(.{
.route = .{
.path = bun.handleOom(bun.default_allocator.dupeZ(u8, path)),
.method = .{ .specific = method },
},
.callback = .create(function.withAsyncContextIfNeeded(global), global),
.websocket = null,
}) catch |err| bun.handleOom(err);
} else if (try AnyRoute.fromJS(global, path, function, init_ctx)) |html_route| {
var method_set = bun.http.Method.Set.initEmpty();
@@ -1114,14 +1070,10 @@ pub fn fromJS(
const UserRouteBuilder = struct {
route: ServerConfig.RouteDeclaration,
callback: jsc.Strong.Optional = .empty,
websocket: ?WebSocketServerContext = null,
pub fn deinit(this: *UserRouteBuilder) void {
this.route.deinit();
this.callback.deinit();
if (this.websocket) |ws| {
ws.unprotect();
}
}
};

View File

@@ -1,9 +1,6 @@
const ServerWebSocket = @This();
#handler: *WebSocketServer.Handler,
/// Optional Shared pointer for route-specific WebSocket contexts.
/// When set, this holds a reference to keep the context alive.
#shared_context: ?bun.api.server.NewServer(.http, .debug).SharedWebSocketContext = null,
#this_value: jsc.JSRef = .empty(),
#flags: Flags = .{},
#signal: ?*bun.webcore.AbortSignal = null,
@@ -54,23 +51,6 @@ pub fn init(handler: *WebSocketServer.Handler, data_value: jsc.JSValue, signal:
return this;
}
/// Initialize a ServerWebSocket with a route-specific shared context.
/// This clones the shared context to hold a reference and keep it alive.
pub fn initWithSharedContext(shared_ctx: bun.api.server.NewServer(.http, .debug).SharedWebSocketContext, data_value: jsc.JSValue, signal: ?*bun.webcore.AbortSignal) *ServerWebSocket {
const handler = shared_ctx.get();
const globalObject = handler.globalObject;
const this = ServerWebSocket.new(.{
.#handler = &handler.handler,
.#shared_context = shared_ctx.clone(), // Clone to increment ref count
.#signal = signal,
});
// Get a strong ref and downgrade when terminating/close and GC will be able to collect the newly created value
const this_value = this.toJS(globalObject);
this.#this_value = .initStrong(this_value, globalObject);
js.dataSetCached(this_value, globalObject, data_value);
return this;
}
pub fn memoryCost(this: *const ServerWebSocket) usize {
if (this.#flags.closed) {
return @sizeOf(ServerWebSocket);
@@ -308,7 +288,11 @@ pub fn onClose(this: *ServerWebSocket, _: uws.AnyWebSocket, code: i32, message:
var handler = this.#handler;
const was_closed = this.isClosed();
this.#flags.closed = true;
defer {
if (!was_closed) {
handler.active_connections -|= 1;
}
}
const signal = this.#signal;
this.#signal = null;
@@ -321,21 +305,6 @@ pub fn onClose(this: *ServerWebSocket, _: uws.AnyWebSocket, code: i32, message:
if (this.#this_value.isNotEmpty()) {
this.#this_value.downgrade();
}
// Decrement active connections BEFORE releasing shared context
// to avoid use-after-free (handler pointer may be inside the context)
if (!was_closed) {
handler.active_connections -|= 1;
}
// Release the shared context reference if we have one
// When the last reference is released, WebSocketServerContext.deinit()
// will be called automatically to unprotect JSValues
if (this.#shared_context) |shared_ctx| {
var ctx = shared_ctx;
this.#shared_context = null;
ctx.deinit();
}
}
const vm = handler.vm;

View File

@@ -124,9 +124,6 @@ pub fn protect(this: WebSocketServerContext) void {
pub fn unprotect(this: WebSocketServerContext) void {
this.handler.unprotect();
}
pub fn deinit(this: *WebSocketServerContext) void {
this.unprotect();
}
const CompressTable = bun.ComptimeStringMap(i32, .{
.{ "disable", 0 },

View File

@@ -18,6 +18,7 @@
macro(SHA512) \
macro(SHA512_256) \
macro(TOML) \
macro(TOON) \
macro(YAML) \
macro(Transpiler) \
macro(ValkeyClient) \

View File

@@ -722,6 +722,7 @@ JSC_DEFINE_HOST_FUNCTION(functionFileURLToPath, (JSC::JSGlobalObject * globalObj
SHA512 BunObject_lazyPropCb_wrap_SHA512 DontDelete|PropertyCallback
SHA512_256 BunObject_lazyPropCb_wrap_SHA512_256 DontDelete|PropertyCallback
TOML BunObject_lazyPropCb_wrap_TOML DontDelete|PropertyCallback
TOON BunObject_lazyPropCb_wrap_TOON DontDelete|PropertyCallback
YAML BunObject_lazyPropCb_wrap_YAML DontDelete|PropertyCallback
Transpiler BunObject_lazyPropCb_wrap_Transpiler DontDelete|PropertyCallback
embeddedFiles BunObject_lazyPropCb_wrap_embeddedFiles DontDelete|PropertyCallback

View File

@@ -1,7 +1,92 @@
/// Holds a reference to a JSValue.
/// Holds a reference to a JSValue with lifecycle management.
///
/// JSRef is used to safely maintain a reference to a JavaScript object from native code,
/// with explicit control over whether the reference keeps the object alive during garbage collection.
///
/// # Common Usage Pattern
///
/// JSRef is typically used in native objects that need to maintain a reference to their
/// corresponding JavaScript wrapper object. The reference can be upgraded to "strong" when
/// the native object has pending work or active connections, and downgraded to "weak" when idle:
///
/// ```zig
/// const MyNativeObject = struct {
/// this_value: jsc.JSRef = .empty(),
/// connection: SomeConnection,
///
/// pub fn init(globalObject: *jsc.JSGlobalObject) *MyNativeObject {
/// const this = MyNativeObject.new(.{});
/// const this_value = this.toJS(globalObject);
/// // Start with strong ref - object has pending work (initialization)
/// this.this_value = .initStrong(this_value, globalObject);
/// return this;
/// }
///
/// fn updateReferenceType(this: *MyNativeObject) void {
/// if (this.connection.isActive()) {
/// // Keep object alive while connection is active
/// if (this.this_value.isNotEmpty() and this.this_value == .weak) {
/// this.this_value.upgrade(globalObject);
/// }
/// } else {
/// // Allow GC when connection is idle
/// if (this.this_value.isNotEmpty() and this.this_value == .strong) {
/// this.this_value.downgrade();
/// }
/// }
/// }
///
/// pub fn onMessage(this: *MyNativeObject) void {
/// // Safely retrieve the JSValue if still alive
/// const this_value = this.this_value.tryGet() orelse return;
/// // Use this_value...
/// }
///
/// pub fn finalize(this: *MyNativeObject) void {
/// // Called when JS object is being garbage collected
/// this.this_value.finalize();
/// this.cleanup();
/// }
/// };
/// ```
///
/// # States
///
/// - **weak**: Holds a JSValue directly. Does NOT prevent garbage collection.
/// The JSValue may become invalid if the object is collected.
/// Use `tryGet()` to safely check if the value is still alive.
///
/// - **strong**: Holds a Strong reference that prevents garbage collection.
/// The JavaScript object will stay alive as long as this reference exists.
/// Must call `deinit()` or `finalize()` to release.
///
/// - **finalized**: The reference has been finalized (object was GC'd or explicitly cleaned up).
/// Indicates the JSValue is no longer valid. `tryGet()` returns null.
///
/// # Key Methods
///
/// - `initWeak()` / `initStrong()`: Create a new JSRef in weak or strong mode
/// - `tryGet()`: Safely retrieve the JSValue if still alive (returns null if finalized or empty)
/// - `upgrade()`: Convert weak → strong to prevent GC
/// - `downgrade()`: Convert strong → weak to allow GC (keeps the JSValue if still alive)
/// - `finalize()`: Mark as finalized and release resources (typically called from GC finalizer)
/// - `deinit()`: Release resources without marking as finalized
///
/// # When to Use Strong vs Weak
///
/// Use **strong** references when:
/// - The native object has active operations (network connections, pending requests, timers)
/// - You need to guarantee the JS object stays alive
/// - You'll call methods on the JS object from callbacks
///
/// Use **weak** references when:
/// - The native object is idle with no pending work
/// - The JS object should be GC-able if no other references exist
/// - You want to allow natural garbage collection
///
/// Common pattern: Start strong, downgrade to weak when idle, upgrade to strong when active.
/// See ServerWebSocket, UDPSocket, MySQLConnection, and ValkeyClient for examples.
///
/// This reference can be either weak (a JSValue) or may be strong, in which
/// case it prevents the garbage collector from collecting the value.
pub const JSRef = union(enum) {
weak: jsc.JSValue,
strong: jsc.Strong.Optional,

View File

@@ -1,3 +1,4 @@
pub const json = @import("./interchange/json.zig");
pub const toml = @import("./interchange/toml.zig");
pub const toon = @import("./interchange/toon.zig");
pub const yaml = @import("./interchange/yaml.zig");

258
src/interchange/toon.zig Normal file
View File

@@ -0,0 +1,258 @@
const std = @import("std");
const bun = @import("bun");
const logger = bun.logger;
const JSC = bun.jsc;
const ast = bun.ast;
const wtf = bun.jsc.wtf;
const Expr = ast.Expr;
const E = ast.E;
const G = ast.G;
const OOM = bun.OOM;
const JSError = bun.JSError;
/// Token-Oriented Object Notation (TOON) parser and stringifier
/// TOON is a compact, human-readable format designed for passing structured data
/// to Large Language Models with significantly reduced token usage.
pub const TOON = struct {
/// Parse TOON text into a JavaScript AST Expr
pub fn parse(source: *const logger.Source, log: *logger.Log, allocator: std.mem.Allocator) (OOM || error{SyntaxError})!Expr {
bun.analytics.Features.toon_parse += 1;
var parser = Parser.init(allocator, source.contents);
const result = parser.parseValue() catch |err| {
if (err == error.SyntaxError) {
try log.addErrorFmt(
source,
logger.Loc{ .start = @as(i32, @intCast(parser.pos)) },
allocator,
"Syntax error parsing TOON: {s}",
.{parser.error_msg orelse "unexpected input"},
);
}
return err;
};
return result;
}
/// Stringify a JavaScript value to TOON format
/// Returns a Stringifier that owns the string builder
pub fn stringify(
allocator: std.mem.Allocator,
globalThis: *JSC.JSGlobalObject,
value: JSC.JSValue,
space_value: JSC.JSValue,
) (OOM || error{ JSError, JSTerminated, StackOverflow })!Stringifier {
bun.analytics.Features.toon_stringify += 1;
var stringifier = try Stringifier.init(allocator, globalThis, space_value);
errdefer stringifier.deinit();
try stringifier.stringify(globalThis, value, 0);
return stringifier;
}
};
const Parser = struct {
allocator: std.mem.Allocator,
input: []const u8,
pos: usize = 0,
error_msg: ?[]const u8 = null,
fn init(allocator: std.mem.Allocator, input: []const u8) Parser {
return .{
.allocator = allocator,
.input = input,
};
}
fn parseValue(self: *Parser) (OOM || error{SyntaxError})!Expr {
self.skipWhitespace();
if (self.pos >= self.input.len) {
return Expr.init(E.Null, .{}, .Empty);
}
// For now, return a placeholder
// Full implementation would parse the TOON format here
self.error_msg = "TOON parsing not fully implemented yet";
return error.SyntaxError;
}
fn skipWhitespace(self: *Parser) void {
while (self.pos < self.input.len) {
switch (self.input[self.pos]) {
' ', '\t', '\r', '\n' => self.pos += 1,
else => break,
}
}
}
};
pub const Stringifier = struct {
allocator: std.mem.Allocator,
builder: wtf.StringBuilder,
indent: usize,
space: Space,
known_collections: std.AutoHashMap(JSC.JSValue, void),
const Space = union(enum) {
none,
spaces: u8,
string: []const u8,
};
pub fn toString(this: *Stringifier, global: *JSC.JSGlobalObject) JSError!JSC.JSValue {
return this.builder.toString(global);
}
fn init(
allocator: std.mem.Allocator,
globalThis: *JSC.JSGlobalObject,
space_value: JSC.JSValue,
) (OOM || error{ JSError, JSTerminated })!Stringifier {
const space = if (space_value.isNumber()) blk: {
const num = space_value.toInt32();
const clamped: u8 = @intCast(@max(0, @min(num, 10)));
if (clamped == 0) {
break :blk Space.none;
}
break :blk Space{ .spaces = clamped };
} else if (space_value.isString()) blk: {
const str = try space_value.toBunString(globalThis);
defer str.deref();
if (str.length() == 0) {
break :blk Space.none;
}
const str_utf8 = str.toUTF8(allocator);
defer str_utf8.deinit();
const str_slice = try allocator.dupe(u8, str_utf8.slice());
break :blk Space{ .string = str_slice };
} else Space.none;
return .{
.allocator = allocator,
.builder = wtf.StringBuilder.init(),
.indent = 0,
.space = space,
.known_collections = std.AutoHashMap(JSC.JSValue, void).init(allocator),
};
}
pub fn deinit(self: *Stringifier) void {
self.builder.deinit();
self.known_collections.deinit();
if (self.space == .string) {
self.allocator.free(self.space.string);
}
}
fn stringify(
self: *Stringifier,
globalThis: *JSC.JSGlobalObject,
value: JSC.JSValue,
depth: usize,
) (OOM || error{ JSError, JSTerminated, StackOverflow })!void {
_ = depth;
// Check for circular references
if (value.isObject()) {
const gop = try self.known_collections.getOrPut(value);
if (gop.found_existing) {
// Circular reference - for now just write null
self.builder.append(.latin1, "null");
return;
}
}
defer {
if (value.isObject()) {
_ = self.known_collections.remove(value);
}
}
if (value.isNull()) {
self.builder.append(.latin1, "null");
} else if (value.isUndefinedOrNull()) {
self.builder.append(.latin1, "null");
} else if (value.isBoolean()) {
if (value.asBoolean()) {
self.builder.append(.latin1, "true");
} else {
self.builder.append(.latin1, "false");
}
} else if (value.isNumber()) {
const num = value.asNumber();
if (std.math.isNan(num) or std.math.isInf(num)) {
self.builder.append(.latin1, "null");
} else {
self.builder.append(.double, num);
}
} else if (value.isString()) {
const str = try value.toBunString(globalThis);
defer str.deref();
const slice = str.toUTF8(self.allocator);
defer slice.deinit();
try self.writeString(slice.slice());
} else if (value.jsType().isArray()) {
// Placeholder for array handling
self.builder.append(.latin1, "[]");
} else if (value.isObject()) {
// Placeholder for object handling
self.builder.append(.latin1, "{}");
} else {
self.builder.append(.latin1, "null");
}
}
fn writeString(self: *Stringifier, str: []const u8) OOM!void {
// Check if quoting is needed
const needs_quotes = needsQuotes(str);
if (needs_quotes) {
self.builder.append(.lchar, '"');
for (str) |c| {
switch (c) {
'"' => self.builder.append(.latin1, "\\\""),
'\\' => self.builder.append(.latin1, "\\\\"),
'\n' => self.builder.append(.latin1, "\\n"),
'\r' => self.builder.append(.latin1, "\\r"),
'\t' => self.builder.append(.latin1, "\\t"),
else => self.builder.append(.lchar, c),
}
}
self.builder.append(.lchar, '"');
} else {
self.builder.append(.latin1, str);
}
}
fn needsQuotes(str: []const u8) bool {
if (str.len == 0) return true;
// Check for leading/trailing spaces
if (str[0] == ' ' or str[str.len - 1] == ' ') return true;
// Check for special characters or keywords
if (std.mem.eql(u8, str, "true") or
std.mem.eql(u8, str, "false") or
std.mem.eql(u8, str, "null")) return true;
// Check if it looks like a number
if (str[0] >= '0' and str[0] <= '9') return true;
if (str[0] == '-' and str.len > 1 and str[1] >= '0' and str[1] <= '9') return true;
// Check for characters that need quoting
for (str) |c| {
switch (c) {
':', ',', '"', '\\', '\n', '\r', '\t', '[', ']', '{', '}' => return true,
else => {},
}
}
return false;
}
};

View File

@@ -1,868 +0,0 @@
import { describe, expect, test } from "bun:test";
describe("Bun.serve() route-specific WebSocket handlers", () => {
test("route-specific websocket handlers work independently", async () => {
using server = Bun.serve({
port: 0,
routes: {
"/api/v1/chat": {
websocket: {
open(ws) {
ws.send("chat:welcome");
},
message(ws, data) {
ws.send("chat:" + data);
},
},
upgrade(req, server) {
return server.upgrade(req);
},
},
"/api/v2/notifications": {
websocket: {
open(ws) {
ws.send("notif:connected");
},
message(ws, data) {
ws.send("notif:" + data);
},
},
upgrade(req, server) {
return server.upgrade(req);
},
},
},
});
// Test chat WebSocket
const chatWs = new WebSocket(`ws://localhost:${server.port}/api/v1/chat`);
const chatMessages: string[] = [];
const { promise: chatResponse, resolve: resolveChatResponse } = Promise.withResolvers<void>();
let chatResponseCount = 0;
chatWs.onmessage = e => {
chatMessages.push(e.data);
chatResponseCount++;
if (chatResponseCount === 2) resolveChatResponse();
};
await new Promise(resolve => (chatWs.onopen = resolve));
expect(chatMessages[0]).toBe("chat:welcome");
chatWs.send("hello");
await chatResponse;
expect(chatMessages[1]).toBe("chat:hello");
chatWs.close();
// Test notifications WebSocket
const notifWs = new WebSocket(`ws://localhost:${server.port}/api/v2/notifications`);
const notifMessages: string[] = [];
const { promise: notifResponse, resolve: resolveNotifResponse } = Promise.withResolvers<void>();
let notifResponseCount = 0;
notifWs.onmessage = e => {
notifMessages.push(e.data);
notifResponseCount++;
if (notifResponseCount === 2) resolveNotifResponse();
};
await new Promise(resolve => (notifWs.onopen = resolve));
expect(notifMessages[0]).toBe("notif:connected");
notifWs.send("test");
await notifResponse;
expect(notifMessages[1]).toBe("notif:test");
notifWs.close();
});
test("route-specific websocket with data in upgrade", async () => {
using server = Bun.serve({
port: 0,
routes: {
"/ws": {
websocket: {
open(ws) {
ws.send("data:" + JSON.stringify(ws.data));
},
},
upgrade(req, server) {
return server.upgrade(req, {
data: { user: "alice", room: "general" },
});
},
},
},
});
const ws = new WebSocket(`ws://localhost:${server.port}/ws`);
const messages: string[] = [];
ws.onmessage = e => messages.push(e.data);
await new Promise(resolve => (ws.onopen = resolve));
expect(messages[0]).toBe('data:{"user":"alice","room":"general"}');
ws.close();
});
test("route-specific websocket with close handler", async () => {
let closeCode = 0;
const { promise: closeHandlerCalled, resolve: resolveCloseHandler } = Promise.withResolvers<void>();
using server = Bun.serve({
port: 0,
routes: {
"/ws": {
websocket: {
open(ws) {
ws.send("ready");
},
close(ws, code) {
closeCode = code;
resolveCloseHandler();
},
},
upgrade(req, server) {
return server.upgrade(req);
},
},
},
});
const ws = new WebSocket(`ws://localhost:${server.port}/ws`);
await new Promise(resolve => (ws.onopen = resolve));
ws.close(1000);
await closeHandlerCalled;
expect(closeCode).toBe(1000);
});
test("global websocket handler still works", async () => {
using server = Bun.serve({
port: 0,
websocket: {
open(ws) {
ws.send("global:welcome");
},
message(ws, data) {
ws.send("global:" + data);
},
},
routes: {
"/api/test": {
upgrade(req, server) {
return server.upgrade(req);
},
},
},
});
const ws = new WebSocket(`ws://localhost:${server.port}/api/test`);
const messages: string[] = [];
const { promise: messageReceived, resolve: resolveMessageReceived } = Promise.withResolvers<void>();
ws.onmessage = e => {
messages.push(e.data);
if (messages.length > 1) resolveMessageReceived();
};
await new Promise(resolve => (ws.onopen = resolve));
expect(messages[0]).toBe("global:welcome");
ws.send("test");
await messageReceived;
expect(messages[1]).toBe("global:test");
ws.close();
});
test("mix of route-specific and global websocket handlers", async () => {
using server = Bun.serve({
port: 0,
websocket: {
open(ws) {
ws.send("global:open");
},
message(ws, data) {
ws.send("global:" + data);
},
},
routes: {
"/specific": {
websocket: {
open(ws) {
ws.send("specific:open");
},
message(ws, data) {
ws.send("specific:" + data);
},
},
upgrade(req, server) {
return server.upgrade(req);
},
},
"/global": {
upgrade(req, server) {
return server.upgrade(req);
},
},
},
});
// Test route-specific handler
const specificWs = new WebSocket(`ws://localhost:${server.port}/specific`);
const specificMessages: string[] = [];
const { promise: specificMessageReceived, resolve: resolveSpecificMessage } = Promise.withResolvers<void>();
specificWs.onmessage = e => {
specificMessages.push(e.data);
if (specificMessages.length > 1) resolveSpecificMessage();
};
await new Promise(resolve => (specificWs.onopen = resolve));
expect(specificMessages[0]).toBe("specific:open");
specificWs.send("hello");
await specificMessageReceived;
expect(specificMessages[1]).toBe("specific:hello");
specificWs.close();
// Test global handler
const globalWs = new WebSocket(`ws://localhost:${server.port}/global`);
const globalMessages: string[] = [];
const { promise: globalMessageReceived, resolve: resolveGlobalMessage } = Promise.withResolvers<void>();
globalWs.onmessage = e => {
globalMessages.push(e.data);
if (globalMessages.length > 1) resolveGlobalMessage();
};
await new Promise(resolve => (globalWs.onopen = resolve));
expect(globalMessages[0]).toBe("global:open");
globalWs.send("world");
await globalMessageReceived;
expect(globalMessages[1]).toBe("global:world");
globalWs.close();
});
test("route-specific websocket with multiple HTTP methods", async () => {
let wsMessageReceived = "";
const { promise: messageProcessed, resolve: resolveMessageProcessed } = Promise.withResolvers<void>();
using server = Bun.serve({
port: 0,
routes: {
"/api/resource": {
GET() {
return new Response("GET response");
},
POST() {
return new Response("POST response");
},
websocket: {
open(ws) {
ws.send("ws:ready");
},
message(ws, data) {
wsMessageReceived = data.toString();
ws.send("ws:received");
resolveMessageProcessed();
},
},
upgrade(req, server) {
return server.upgrade(req);
},
},
},
});
// Test HTTP GET
const getResp = await fetch(`http://localhost:${server.port}/api/resource`);
expect(await getResp.text()).toBe("GET response");
// Test HTTP POST
const postResp = await fetch(`http://localhost:${server.port}/api/resource`, { method: "POST" });
expect(await postResp.text()).toBe("POST response");
// Test WebSocket (which uses GET under the hood)
const ws = new WebSocket(`ws://localhost:${server.port}/api/resource`);
const messages: string[] = [];
const { promise: messageReceived, resolve: resolveMessageReceived } = Promise.withResolvers<void>();
ws.onmessage = e => {
messages.push(e.data);
if (messages.length > 1) resolveMessageReceived();
};
await new Promise(resolve => (ws.onopen = resolve));
expect(messages[0]).toBe("ws:ready");
ws.send("test-message");
await Promise.all([messageReceived, messageProcessed]);
expect(messages[1]).toBe("ws:received");
expect(wsMessageReceived).toBe("test-message");
ws.close();
});
test("route-specific websocket without upgrade handler errors appropriately", () => {
// Should throw an error because websocket requires upgrade handler
expect(() => {
Bun.serve({
port: 0,
routes: {
"/ws": {
websocket: {
open(ws) {
ws.send("should not reach here");
},
},
// Note: no upgrade handler
GET() {
return new Response("This is not a WebSocket endpoint");
},
},
},
});
}).toThrow("Route has 'websocket' but missing 'upgrade' handler");
});
test("server.reload() preserves route-specific websocket handlers", async () => {
const { promise, resolve } = Promise.withResolvers<void>();
let stage = 0;
using server = Bun.serve({
port: 0,
routes: {
"/ws": {
websocket: {
open(ws) {
ws.send(`stage${stage}:open`);
},
},
upgrade(req, server) {
return server.upgrade(req);
},
},
},
async fetch(req, server) {
if (req.url.endsWith("/reload")) {
stage = 1;
server.reload({
routes: {
"/ws": {
websocket: {
open(ws) {
ws.send("reloaded:open");
},
},
upgrade(req, server) {
return server.upgrade(req);
},
},
},
});
resolve();
return new Response("reloaded");
}
return new Response("not found", { status: 404 });
},
});
// Connect before reload
const ws1 = new WebSocket(`ws://localhost:${server.port}/ws`);
const messages1: string[] = [];
ws1.onmessage = e => messages1.push(e.data);
await new Promise(resolve => (ws1.onopen = resolve));
expect(messages1[0]).toBe("stage0:open");
ws1.close();
// Trigger reload
await fetch(`http://localhost:${server.port}/reload`);
await promise;
// Connect after reload
const ws2 = new WebSocket(`ws://localhost:${server.port}/ws`);
const messages2: string[] = [];
ws2.onmessage = e => messages2.push(e.data);
await new Promise(resolve => (ws2.onopen = resolve));
expect(messages2[0]).toBe("reloaded:open");
ws2.close();
});
test("server.reload() removes websocket handler", async () => {
using server = Bun.serve({
port: 0,
routes: {
"/ws": {
websocket: {
open(ws) {
ws.send("initial");
},
},
upgrade(req, server) {
return server.upgrade(req);
},
},
},
});
// Connect with websocket handler
const ws1 = new WebSocket(`ws://localhost:${server.port}/ws`);
const messages1: string[] = [];
ws1.onmessage = e => messages1.push(e.data);
await new Promise(resolve => (ws1.onopen = resolve));
expect(messages1[0]).toBe("initial");
ws1.close();
// Reload without websocket handler
server.reload({
routes: {
"/ws": {
GET() {
return new Response("no websocket");
},
},
},
});
// Regular GET should work
const resp = await fetch(`http://localhost:${server.port}/ws`);
expect(await resp.text()).toBe("no websocket");
// WebSocket should fail
const ws2 = new WebSocket(`ws://localhost:${server.port}/ws`);
const { promise: errorOccurred, resolve: resolveError } = Promise.withResolvers<void>();
ws2.onerror = () => {
resolveError();
};
await errorOccurred;
});
test("server.reload() adds websocket handler to existing route", async () => {
using server = Bun.serve({
port: 0,
routes: {
"/ws": {
GET() {
return new Response("no websocket yet");
},
},
},
});
// Regular GET should work
const resp1 = await fetch(`http://localhost:${server.port}/ws`);
expect(await resp1.text()).toBe("no websocket yet");
// Reload with websocket handler
server.reload({
routes: {
"/ws": {
GET() {
return new Response("now has websocket");
},
websocket: {
open(ws) {
ws.send("added");
},
},
upgrade(req, server) {
return server.upgrade(req);
},
},
},
});
// Regular GET should still work
const resp2 = await fetch(`http://localhost:${server.port}/ws`);
expect(await resp2.text()).toBe("now has websocket");
// WebSocket should now work
const ws = new WebSocket(`ws://localhost:${server.port}/ws`);
const messages: string[] = [];
ws.onmessage = e => messages.push(e.data);
await new Promise(resolve => (ws.onopen = resolve));
expect(messages[0]).toBe("added");
ws.close();
});
test("server.reload() with active websocket connections", async () => {
let messageReceived = "";
const { promise: messageProcessed, resolve: resolveMessageProcessed } = Promise.withResolvers<void>();
using server = Bun.serve({
port: 0,
routes: {
"/ws": {
websocket: {
open(ws) {
ws.send("v1");
},
message(ws, data) {
messageReceived = data.toString();
ws.send("v1:echo");
resolveMessageProcessed();
},
},
upgrade(req, server) {
return server.upgrade(req);
},
},
},
});
// Create active connection
const ws = new WebSocket(`ws://localhost:${server.port}/ws`);
const messages: string[] = [];
const { promise: messageReceived1, resolve: resolveMessageReceived1 } = Promise.withResolvers<void>();
ws.onmessage = e => {
messages.push(e.data);
if (messages.length > 1) resolveMessageReceived1();
};
await new Promise(resolve => (ws.onopen = resolve));
expect(messages[0]).toBe("v1");
// Reload while connection is active
server.reload({
routes: {
"/ws": {
websocket: {
open(ws) {
ws.send("v2");
},
message(ws, data) {
ws.send("v2:echo");
},
},
upgrade(req, server) {
return server.upgrade(req);
},
},
},
});
// Existing connection should still use old handlers
ws.send("test");
await Promise.all([messageReceived1, messageProcessed]);
expect(messages[1]).toBe("v1:echo");
expect(messageReceived).toBe("test");
ws.close();
// New connection should use new handlers
const ws2 = new WebSocket(`ws://localhost:${server.port}/ws`);
const messages2: string[] = [];
const { promise: messageReceived2, resolve: resolveMessageReceived2 } = Promise.withResolvers<void>();
ws2.onmessage = e => {
messages2.push(e.data);
if (messages2.length > 1) resolveMessageReceived2();
};
await new Promise(resolve => (ws2.onopen = resolve));
expect(messages2[0]).toBe("v2");
ws2.send("test2");
await messageReceived2;
expect(messages2[1]).toBe("v2:echo");
ws2.close();
});
test("multiple concurrent websocket connections to same route", async () => {
const openCount = { count: 0 };
const messageCount = { count: 0 };
const { promise: allMessagesReceived, resolve: resolveAllMessagesReceived } = Promise.withResolvers<void>();
using server = Bun.serve({
port: 0,
routes: {
"/ws": {
websocket: {
open(ws) {
openCount.count++;
ws.send(`connection-${openCount.count}`);
},
message(ws, data) {
messageCount.count++;
ws.send(`echo-${data}`);
if (messageCount.count === 5) resolveAllMessagesReceived();
},
},
upgrade(req, server) {
return server.upgrade(req);
},
},
},
});
// Create 5 concurrent connections with promise resolvers for each
const connectionPromises = Array.from({ length: 5 }, (_, i) => {
const { promise, resolve } = Promise.withResolvers<void>();
return { promise, resolve, id: i };
});
const connections = await Promise.all(
connectionPromises.map(async ({ promise, resolve, id }) => {
const ws = new WebSocket(`ws://localhost:${server.port}/ws`);
const messages: string[] = [];
ws.onmessage = e => {
messages.push(e.data);
if (messages.length >= 2) resolve();
};
await new Promise(resolveOpen => (ws.onopen = resolveOpen));
return { ws, messages, id, promise };
}),
);
expect(openCount.count).toBe(5);
// Each should have unique connection message
for (let i = 0; i < 5; i++) {
expect(connections[i].messages[0]).toMatch(/^connection-\d+$/);
}
// Send messages from all connections
for (const conn of connections) {
conn.ws.send(`msg-${conn.id}`);
}
// Wait for server to receive all messages
await allMessagesReceived;
expect(messageCount.count).toBe(5);
// Wait for all echo responses to arrive back at clients
await Promise.all(connections.map(conn => conn.promise));
// Each should get their echo back
for (const conn of connections) {
expect(conn.messages[1]).toBe(`echo-msg-${conn.id}`);
conn.ws.close();
}
});
test("multiple concurrent websocket connections to different routes", async () => {
using server = Bun.serve({
port: 0,
routes: {
"/chat": {
websocket: {
open(ws) {
ws.send("chat");
},
},
upgrade(req, server) {
return server.upgrade(req);
},
},
"/notifications": {
websocket: {
open(ws) {
ws.send("notif");
},
},
upgrade(req, server) {
return server.upgrade(req);
},
},
"/updates": {
websocket: {
open(ws) {
ws.send("updates");
},
},
upgrade(req, server) {
return server.upgrade(req);
},
},
},
});
// Connect to all routes simultaneously
const [chat, notif, updates] = await Promise.all([
(async () => {
const ws = new WebSocket(`ws://localhost:${server.port}/chat`);
const messages: string[] = [];
ws.onmessage = e => messages.push(e.data);
await new Promise(resolve => (ws.onopen = resolve));
return { ws, messages };
})(),
(async () => {
const ws = new WebSocket(`ws://localhost:${server.port}/notifications`);
const messages: string[] = [];
ws.onmessage = e => messages.push(e.data);
await new Promise(resolve => (ws.onopen = resolve));
return { ws, messages };
})(),
(async () => {
const ws = new WebSocket(`ws://localhost:${server.port}/updates`);
const messages: string[] = [];
ws.onmessage = e => messages.push(e.data);
await new Promise(resolve => (ws.onopen = resolve));
return { ws, messages };
})(),
]);
expect(chat.messages[0]).toBe("chat");
expect(notif.messages[0]).toBe("notif");
expect(updates.messages[0]).toBe("updates");
chat.ws.close();
notif.ws.close();
updates.ws.close();
});
test("websocket with only open handler (no message/close)", async () => {
using server = Bun.serve({
port: 0,
routes: {
"/ws": {
websocket: {
open(ws) {
ws.send("opened");
},
// No message or close handlers
},
upgrade(req, server) {
return server.upgrade(req);
},
},
},
});
const ws = new WebSocket(`ws://localhost:${server.port}/ws`);
const messages: string[] = [];
ws.onmessage = e => messages.push(e.data);
await new Promise(resolve => (ws.onopen = resolve));
expect(messages[0]).toBe("opened");
// Should be able to send messages even without handler
ws.send("test");
// Should be able to close
ws.close();
});
test("websocket error handler is called on server-side exceptions", async () => {
const { promise: errorHandlerCalled, resolve: resolveErrorHandler } = Promise.withResolvers<void>();
using server = Bun.serve({
port: 0,
routes: {
"/ws": {
websocket: {
message(ws, message) {
// Trigger an error when receiving "trigger-error"
if (message === "trigger-error") {
throw new Error("Intentional test error");
}
},
error(ws, error) {
resolveErrorHandler();
},
},
upgrade(req, server) {
return server.upgrade(req);
},
},
},
});
const ws = new WebSocket(`ws://localhost:${server.port}/ws`);
await new Promise(resolve => (ws.onopen = resolve));
// Send message that triggers server-side error
ws.send("trigger-error");
// Wait for error handler to be called
await errorHandlerCalled;
ws.close();
});
test("server.stop() with active websocket connections", async () => {
const server = Bun.serve({
port: 0,
routes: {
"/ws": {
websocket: {
open(ws) {
ws.send("connected");
},
close(ws) {
// Close handler is called when connection closes
},
},
upgrade(req, server) {
return server.upgrade(req);
},
},
},
});
const ws = new WebSocket(`ws://localhost:${server.port}/ws`);
await new Promise(resolve => (ws.onopen = resolve));
// Manually close the connection and wait for it
const closePromise = new Promise(resolve => (ws.onclose = resolve));
ws.close();
await closePromise;
// Now stop server and await completion
await server.stop();
// Server should stop successfully even after WebSocket was used
expect(server.port).toBe(0);
});
test("multiple routes with same path but different methods and websocket", async () => {
using server = Bun.serve({
port: 0,
routes: {
"/api": {
GET() {
return new Response("get");
},
POST() {
return new Response("post");
},
PUT() {
return new Response("put");
},
websocket: {
open(ws) {
ws.send("ws");
},
},
upgrade(req, server) {
return server.upgrade(req);
},
},
},
});
// Test all HTTP methods work
const getResp = await fetch(`http://localhost:${server.port}/api`);
expect(await getResp.text()).toBe("get");
const postResp = await fetch(`http://localhost:${server.port}/api`, { method: "POST" });
expect(await postResp.text()).toBe("post");
const putResp = await fetch(`http://localhost:${server.port}/api`, { method: "PUT" });
expect(await putResp.text()).toBe("put");
// Test WebSocket works
const ws = new WebSocket(`ws://localhost:${server.port}/api`);
const messages: string[] = [];
ws.onmessage = e => messages.push(e.data);
await new Promise(resolve => (ws.onopen = resolve));
expect(messages[0]).toBe("ws");
ws.close();
});
});

View File

@@ -0,0 +1,99 @@
import { TOON } from "bun";
import { describe, expect, test } from "bun:test";
describe("Bun.TOON", () => {
test("TOON object exists", () => {
expect(TOON).toBeDefined();
expect(TOON.parse).toBeDefined();
expect(TOON.stringify).toBeDefined();
});
describe("TOON.stringify", () => {
test("stringify null", () => {
expect(TOON.stringify(null)).toBe("null");
});
test("stringify boolean", () => {
expect(TOON.stringify(true)).toBe("true");
expect(TOON.stringify(false)).toBe("false");
});
test("stringify number", () => {
expect(TOON.stringify(42)).toBe("42");
expect(TOON.stringify(3.14)).toBe("3.14");
expect(TOON.stringify(0)).toBe("0");
});
test("stringify string", () => {
expect(TOON.stringify("hello")).toBe("hello");
expect(TOON.stringify("hello world")).toBe("hello world");
});
test("stringify string with special characters", () => {
expect(TOON.stringify("hello, world")).toBe('"hello, world"');
expect(TOON.stringify("true")).toBe('"true"');
expect(TOON.stringify("false")).toBe('"false"');
expect(TOON.stringify("123")).toBe('"123"');
});
test("stringify empty string", () => {
expect(TOON.stringify("")).toBe('""');
});
test("stringify simple object", () => {
const result = TOON.stringify({ name: "Alice", age: 30 });
// For now, just check it doesn't crash
expect(result).toBeDefined();
});
test("stringify array", () => {
const result = TOON.stringify(["a", "b", "c"]);
// For now, just check it doesn't crash
expect(result).toBeDefined();
});
});
describe("TOON.parse", () => {
test.todo("parse null", () => {
expect(TOON.parse("null")).toBe(null);
});
test.todo("parse boolean", () => {
expect(TOON.parse("true")).toBe(true);
expect(TOON.parse("false")).toBe(false);
});
test.todo("parse number", () => {
expect(TOON.parse("42")).toBe(42);
expect(TOON.parse("3.14")).toBe(3.14);
});
test.todo("parse string", () => {
expect(TOON.parse("hello")).toBe("hello");
expect(TOON.parse('"hello, world"')).toBe("hello, world");
});
test.todo("parse simple object", () => {
const result = TOON.parse("name: Alice\nage: 30");
expect(result).toEqual({ name: "Alice", age: 30 });
});
test.todo("parse array", () => {
const result = TOON.parse("items[3]: a,b,c");
expect(result).toEqual({ items: ["a", "b", "c"] });
});
});
describe("round-trip", () => {
test.todo("null round-trip", () => {
const value = null;
expect(TOON.parse(TOON.stringify(value))).toEqual(value);
});
test.todo("simple values round-trip", () => {
expect(TOON.parse(TOON.stringify(true))).toBe(true);
expect(TOON.parse(TOON.stringify(42))).toBe(42);
expect(TOON.parse(TOON.stringify("hello"))).toBe("hello");
});
});
});

View File

@@ -28,7 +28,9 @@ const empty = Buffer.alloc(0);
describe.concurrent("fetch() with streaming", () => {
[-1, 0, 20, 50, 100].forEach(timeout => {
it(`should be able to fail properly when reading from readable stream with timeout ${timeout}`, async () => {
// This test is flaky.
// Sometimes, we don't throw if signal.abort(). We need to fix that.
it.todo(`should be able to fail properly when reading from readable stream with timeout ${timeout}`, async () => {
using server = Bun.serve({
port: 0,
async fetch(req) {

View File

@@ -2,6 +2,6 @@
{
"package": "elysia",
"repository": "https://github.com/elysiajs/elysia",
"tag": "1.4.12"
"tag": "1.4.13"
}
]