Skip to content

Commit fcf2a54

Browse files
authored
feat: add support for Websocket passthrough (#1172)
1 parent e24df60 commit fcf2a54

File tree

17 files changed

+298
-7
lines changed

17 files changed

+298
-7
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
hide_title: false
3+
hide_table_of_contents: false
4+
pagination_next: null
5+
pagination_prev: null
6+
---
7+
8+
# createWebsocketHandoff
9+
10+
The **`createWebsocketHandoff()`** function creates a Response instance which informs Fastly to pass the original Request through Websocket, to the declared backend.
11+
12+
## Syntax
13+
14+
```js
15+
createWebsocketHandoff(request, backend)
16+
```
17+
18+
### Parameters
19+
20+
- `request` _: Request_
21+
- The request to pass through Websocket.
22+
- `backend` _: string_
23+
- The name of the backend that Websocket should send the request to.
24+
- The name has to be between 1 and 254 characters inclusive.
25+
- Throws a [`TypeError`](../globals/TypeError/TypeError.mdx) if the value is not valid. I.E. The value is null, undefined, an empty string or a string with more than 254 characters.
26+
27+
### Return value
28+
29+
A Response instance is returned, which can then be used via `event.respondWith`.
30+
31+
## Examples
32+
33+
In this example application requests to the path `/stream` and sent handled via Websocket.
34+
35+
```js
36+
import { createWebsocketHandoff } from "fastly:websocket";
37+
38+
async function handleRequest(event) {
39+
try {
40+
const url = new URL(event.request.url);
41+
if (url.pathname === '/stream') {
42+
return createWebsocketHandoff(event.request, 'websocket_backend');
43+
} else {
44+
return new Response('oopsie, make a request to /stream for some websocket goodies', { status: 404 });
45+
}
46+
} catch (error) {
47+
console.error({error});
48+
return new Response(error.message, {status:500})
49+
}
50+
}
51+
52+
addEventListener("fetch", (event) => event.respondWith(handleRequest(event)));
53+
```

documentation/rename-docs.mjs

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const subsystems = [
2020
'env',
2121
'experimental',
2222
'fanout',
23+
'websocket',
2324
'geolocation',
2425
'kv-store',
2526
'logger',

integration-tests/js-compute/fixtures/app/src/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import './dictionary.js';
2121
import './edge-rate-limiter.js';
2222
import './env.js';
2323
import './fanout.js';
24+
import './websocket.js';
2425
import './fastly-global.js';
2526
import './fetch-errors.js';
2627
import './geoip.js';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { assert, assertDoesNotThrow, assertThrows } from './assertions.js';
2+
import { routes } from './routes.js';
3+
import { createWebsocketHandoff } from 'fastly:websocket';
4+
5+
routes.set('/createWebsocketHandoff', () => {
6+
assert(
7+
typeof createWebsocketHandoff,
8+
'function',
9+
'typeof createWebsocketHandoff',
10+
);
11+
12+
assert(
13+
createWebsocketHandoff.name,
14+
'createWebsocketHandoff',
15+
'createWebsocketHandoff.name',
16+
);
17+
18+
assert(createWebsocketHandoff.length, 2, 'createWebsocketHandoff.length');
19+
20+
assertDoesNotThrow(() => createWebsocketHandoff(new Request('.'), 'hello'));
21+
22+
assertThrows(() => createWebsocketHandoff());
23+
24+
assertThrows(() => createWebsocketHandoff(1, ''));
25+
26+
let result = createWebsocketHandoff(new Request('.'), 'hello');
27+
assert(result instanceof Response, true, 'result instanceof Response');
28+
29+
assertThrows(
30+
() => new createWebsocketHandoff(new Request('.'), 'hello'),
31+
TypeError,
32+
);
33+
34+
assertDoesNotThrow(() => {
35+
createWebsocketHandoff.call(undefined, new Request('.'), '1');
36+
});
37+
38+
// https://tc39.es/ecma262/#sec-tostring
39+
let sentinel;
40+
const test = () => {
41+
sentinel = Symbol();
42+
const key = {
43+
toString() {
44+
throw sentinel;
45+
},
46+
};
47+
createWebsocketHandoff(new Request('.'), key);
48+
};
49+
assertThrows(test);
50+
try {
51+
test();
52+
} catch (thrownError) {
53+
assert(thrownError, sentinel, 'thrownError === sentinel');
54+
}
55+
assertThrows(
56+
() => {
57+
createWebsocketHandoff(new Request('.'), Symbol());
58+
},
59+
TypeError,
60+
`can't convert symbol to string`,
61+
);
62+
63+
assertThrows(
64+
() => createWebsocketHandoff(new Request('.')),
65+
TypeError,
66+
`createWebsocketHandoff: At least 2 arguments required, but only 1 passed`,
67+
);
68+
69+
assertThrows(
70+
() => createWebsocketHandoff(new Request('.'), ''),
71+
Error,
72+
`createWebsocketHandoff: Backend parameter can not be an empty string`,
73+
);
74+
});

integration-tests/js-compute/fixtures/app/tests.json

+1
Original file line numberDiff line numberDiff line change
@@ -1002,6 +1002,7 @@
10021002
"environments": ["viceroy"]
10031003
},
10041004
"GET /createFanoutHandoff": {},
1005+
"GET /createWebsocketHandoff": {},
10051006
"GET /fastly/now": {},
10061007
"GET /fastly/version": {},
10071008
"GET /fastly/getgeolocationforipaddress/interface": {

runtime/fastly/builtins/fastly.cpp

+83-1
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,73 @@ bool Fastly::createFanoutHandoff(JSContext *cx, unsigned argc, JS::Value *vp) {
283283

284284
JS::RootedObject response(cx, Response::create(cx, response_instance, response_handle.unwrap(),
285285
body_handle.unwrap(), is_upstream,
286-
grip_upgrade_request, backend_str));
286+
grip_upgrade_request, nullptr, backend_str));
287+
if (!response) {
288+
return false;
289+
}
290+
291+
RequestOrResponse::set_url(response, RequestOrResponse::url(&request_value.toObject()));
292+
args.rval().setObject(*response);
293+
294+
return true;
295+
}
296+
297+
bool Fastly::createWebsocketHandoff(JSContext *cx, unsigned argc, JS::Value *vp) {
298+
JS::CallArgs args = CallArgsFromVp(argc, vp);
299+
REQUEST_HANDLER_ONLY("createWebsocketHandoff");
300+
if (!args.requireAtLeast(cx, "createWebsocketHandoff", 2)) {
301+
return false;
302+
}
303+
304+
auto request_value = args.get(0);
305+
if (!Request::is_instance(request_value)) {
306+
JS_ReportErrorUTF8(cx,
307+
"createWebsocketHandoff: request parameter must be an instance of Request");
308+
return false;
309+
}
310+
auto websocket_upgrade_request = &request_value.toObject();
311+
312+
auto response_handle = host_api::HttpResp::make();
313+
if (auto *err = response_handle.to_err()) {
314+
HANDLE_ERROR(cx, *err);
315+
return false;
316+
}
317+
auto body_handle = host_api::HttpBody::make();
318+
if (auto *err = body_handle.to_err()) {
319+
HANDLE_ERROR(cx, *err);
320+
return false;
321+
}
322+
323+
JS::RootedObject response_instance(
324+
cx, JS_NewObjectWithGivenProto(cx, &Response::class_, Response::proto_obj));
325+
if (!response_instance) {
326+
return false;
327+
}
328+
329+
auto backend_value = args.get(1);
330+
JS::RootedString backend_str(cx, JS::ToString(cx, backend_value));
331+
if (!backend_str) {
332+
return false;
333+
}
334+
auto backend_chars = core::encode(cx, backend_str);
335+
if (!backend_chars) {
336+
return false;
337+
}
338+
if (backend_chars.len == 0) {
339+
JS_ReportErrorUTF8(cx, "createWebsocketHandoff: Backend parameter can not be an empty string");
340+
return false;
341+
}
342+
343+
if (backend_chars.len > 254) {
344+
JS_ReportErrorUTF8(cx, "createWebsocketHandoff: name can not be more than 254 characters");
345+
return false;
346+
}
347+
348+
bool is_upstream = true;
349+
350+
JS::RootedObject response(cx, Response::create(cx, response_instance, response_handle.unwrap(),
351+
body_handle.unwrap(), is_upstream, nullptr,
352+
websocket_upgrade_request, backend_str));
287353
if (!response) {
288354
return false;
289355
}
@@ -547,6 +613,7 @@ bool install(api::Engine *engine) {
547613
JS_FN("getLogger", Fastly::getLogger, 1, JSPROP_ENUMERATE),
548614
JS_FN("includeBytes", Fastly::includeBytes, 1, JSPROP_ENUMERATE),
549615
JS_FN("createFanoutHandoff", Fastly::createFanoutHandoff, 2, JSPROP_ENUMERATE),
616+
JS_FN("createWebsocketHandoff", Fastly::createWebsocketHandoff, 2, JSPROP_ENUMERATE),
550617
ENABLE_EXPERIMENTAL_HIGH_RESOLUTION_TIME_METHODS ? nowfn : end,
551618
end};
552619

@@ -684,6 +751,21 @@ bool install(api::Engine *engine) {
684751
if (!engine->define_builtin_module("fastly:fanout", fanout_val)) {
685752
return false;
686753
}
754+
// fastly:websocket
755+
RootedObject websocket(engine->cx(), JS_NewObject(engine->cx(), nullptr));
756+
RootedValue websocket_val(engine->cx(), JS::ObjectValue(*websocket));
757+
RootedValue create_websocket_handoff_val(engine->cx());
758+
if (!JS_GetProperty(engine->cx(), fastly, "createWebsocketHandoff",
759+
&create_websocket_handoff_val)) {
760+
return false;
761+
}
762+
if (!JS_SetProperty(engine->cx(), websocket, "createWebsocketHandoff",
763+
create_websocket_handoff_val)) {
764+
return false;
765+
}
766+
if (!engine->define_builtin_module("fastly:websocket", websocket_val)) {
767+
return false;
768+
}
687769

688770
// debugMessages for debug-only builds
689771
#ifdef DEBUG

runtime/fastly/builtins/fastly.h

+1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class Fastly : public builtins::BuiltinNoConstructor<Fastly> {
4545
static const JSPropertySpec properties[];
4646

4747
static bool createFanoutHandoff(JSContext *cx, unsigned argc, JS::Value *vp);
48+
static bool createWebsocketHandoff(JSContext *cx, unsigned argc, JS::Value *vp);
4849
static bool now(JSContext *cx, unsigned argc, JS::Value *vp);
4950
static bool dump(JSContext *cx, unsigned argc, JS::Value *vp);
5051
static bool enableDebugLogging(JSContext *cx, unsigned argc, JS::Value *vp);

runtime/fastly/builtins/fetch-event.cpp

+11
Original file line numberDiff line numberDiff line change
@@ -679,6 +679,17 @@ bool response_promise_then_handler(JSContext *cx, JS::HandleObject event, JS::Ha
679679
return true;
680680
}
681681

682+
if (auto websocket_upgrade_request = Response::websocket_upgrade_request(response_obj)) {
683+
auto backend = Response::backend_str(cx, response_obj);
684+
685+
auto res = websocket_upgrade_request->redirect_to_websocket_proxy(backend);
686+
if (auto *err = res.to_err()) {
687+
HANDLE_ERROR(cx, *err);
688+
return false;
689+
}
690+
return true;
691+
}
692+
682693
bool streaming = false;
683694
if (!RequestOrResponse::maybe_stream_body(cx, response_obj, &streaming)) {
684695
return false;

runtime/fastly/builtins/fetch/request-response.cpp

+19-5
Original file line numberDiff line numberDiff line change
@@ -2958,6 +2958,16 @@ std::optional<host_api::HttpReq> Response::grip_upgrade_request(JSObject *obj) {
29582958
return host_api::HttpReq(grip_upgrade_request.toInt32());
29592959
}
29602960

2961+
std::optional<host_api::HttpReq> Response::websocket_upgrade_request(JSObject *obj) {
2962+
MOZ_ASSERT(is_instance(obj));
2963+
auto websocket_upgrade_request =
2964+
JS::GetReservedSlot(obj, static_cast<uint32_t>(Slots::WebsocketUpgradeRequest));
2965+
if (websocket_upgrade_request.isUndefined()) {
2966+
return std::nullopt;
2967+
}
2968+
return host_api::HttpReq(websocket_upgrade_request.toInt32());
2969+
}
2970+
29612971
host_api::HostString Response::backend_str(JSContext *cx, JSObject *obj) {
29622972
MOZ_ASSERT(is_instance(obj));
29632973

@@ -3418,7 +3428,7 @@ bool Response::redirect(JSContext *cx, unsigned argc, JS::Value *vp) {
34183428
return false;
34193429
}
34203430
JS::RootedObject response(
3421-
cx, create(cx, response_instance, response_handle, body, false, nullptr, nullptr));
3431+
cx, create(cx, response_instance, response_handle, body, false, nullptr, nullptr, nullptr));
34223432
if (!response) {
34233433
return false;
34243434
}
@@ -3562,7 +3572,7 @@ bool Response::json(JSContext *cx, unsigned argc, JS::Value *vp) {
35623572
return false;
35633573
}
35643574
JS::RootedObject response(
3565-
cx, create(cx, response_instance, response_handle, body, false, nullptr, nullptr));
3575+
cx, create(cx, response_instance, response_handle, body, false, nullptr, nullptr, nullptr));
35663576
if (!response) {
35673577
return false;
35683578
}
@@ -4338,7 +4348,7 @@ bool Response::constructor(JSContext *cx, unsigned argc, JS::Value *vp) {
43384348
auto body = make_res.unwrap();
43394349
JS::RootedObject responseInstance(cx, JS_NewObjectForConstructor(cx, &class_, args));
43404350
JS::RootedObject response(
4341-
cx, create(cx, responseInstance, response_handle, body, false, nullptr, nullptr));
4351+
cx, create(cx, responseInstance, response_handle, body, false, nullptr, nullptr, nullptr));
43424352
if (!response) {
43434353
return false;
43444354
}
@@ -4511,7 +4521,7 @@ JSObject *Response::create(JSContext *cx, HandleObject request, host_api::Respon
45114521
bool is_upstream = true;
45124522
RootedString backend(cx, RequestOrResponse::backend(request));
45134523
JS::RootedObject response(cx, Response::create(cx, response_instance, response_handle, body,
4514-
is_upstream, nullptr, backend));
4524+
is_upstream, nullptr, nullptr, backend));
45154525
if (!response) {
45164526
return nullptr;
45174527
}
@@ -4540,7 +4550,7 @@ void Response::finalize(JS::GCContext *gcx, JSObject *self) {
45404550
JSObject *Response::create(JSContext *cx, JS::HandleObject response,
45414551
host_api::HttpResp response_handle, host_api::HttpBody body_handle,
45424552
bool is_upstream, JSObject *grip_upgrade_request,
4543-
JS::HandleString backend) {
4553+
JSObject *websocket_upgrade_request, JS::HandleString backend) {
45444554
JS::SetReservedSlot(response, static_cast<uint32_t>(Slots::Response),
45454555
JS::Int32Value(response_handle.handle));
45464556
JS::SetReservedSlot(response, static_cast<uint32_t>(Slots::Headers), JS::NullValue());
@@ -4556,6 +4566,10 @@ JSObject *Response::create(JSContext *cx, JS::HandleObject response,
45564566
JS::SetReservedSlot(response, static_cast<uint32_t>(Slots::GripUpgradeRequest),
45574567
JS::Int32Value(Request::request_handle(grip_upgrade_request).handle));
45584568
}
4569+
if (websocket_upgrade_request) {
4570+
JS::SetReservedSlot(response, static_cast<uint32_t>(Slots::WebsocketUpgradeRequest),
4571+
JS::Int32Value(Request::request_handle(websocket_upgrade_request).handle));
4572+
}
45594573
JS::SetReservedSlot(response, static_cast<uint32_t>(Slots::StorageAction), JS::UndefinedValue());
45604574
JS::SetReservedSlot(response, static_cast<uint32_t>(RequestOrResponse::Slots::CacheEntry),
45614575
JS::UndefinedValue());

runtime/fastly/builtins/fetch/request-response.h

+3-1
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,7 @@ class Response final : public builtins::FinalizableBuiltinImpl<Response> {
264264
StatusMessage,
265265
Redirected,
266266
GripUpgradeRequest,
267+
WebsocketUpgradeRequest,
267268
StorageAction,
268269
SuggestedCacheWriteOptions,
269270
OverrideCacheWriteOptions,
@@ -291,7 +292,7 @@ class Response final : public builtins::FinalizableBuiltinImpl<Response> {
291292
static JSObject *create(JSContext *cx, JS::HandleObject response,
292293
host_api::HttpResp response_handle, host_api::HttpBody body_handle,
293294
bool is_upstream, JSObject *grip_upgrade_request,
294-
JS::HandleString backend);
295+
JSObject *websocket_upgrade_request, JS::HandleString backend);
295296

296297
static host_api::HttpResp response_handle(JSObject *obj);
297298

@@ -307,6 +308,7 @@ class Response final : public builtins::FinalizableBuiltinImpl<Response> {
307308

308309
static bool is_upstream(JSObject *obj);
309310
static std::optional<host_api::HttpReq> grip_upgrade_request(JSObject *obj);
311+
static std::optional<host_api::HttpReq> websocket_upgrade_request(JSObject *obj);
310312
static host_api::HostString backend_str(JSContext *cx, JSObject *obj);
311313
static uint16_t status(JSObject *obj);
312314
static JSString *status_message(JSObject *obj);

runtime/fastly/host-api/fastly.h

+4
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,10 @@ WASM_IMPORT("fastly_http_req", "redirect_to_grip_proxy_v2")
471471
int req_redirect_to_grip_proxy_v2(uint32_t req_handle, const char *backend_name,
472472
size_t backend_name_len);
473473

474+
WASM_IMPORT("fastly_http_req", "redirect_to_websocket_proxy_v2")
475+
int req_redirect_to_websocket_proxy_v2(uint32_t req_handle, const char *backend_name,
476+
size_t backend_name_len);
477+
474478
/**
475479
* Set the cache override behavior for this request.
476480
*

0 commit comments

Comments
 (0)