Skip to content

Native support for Websockets #12961

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 14 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
} from '../types/private.js';
import { BuildData, SSRNodeLoader, SSRRoute, ValidatedConfig } from 'types';
import type { PluginOptions } from '@sveltejs/vite-plugin-svelte';
import type { IncomingMessage } from 'node:http';
import type { Duplex } from 'node:stream';

export { PrerenderOption } from '../types/private.js';

Expand Down Expand Up @@ -685,8 +687,8 @@ export interface KitConfig {
*/
export type Handle = (input: {
event: RequestEvent;
resolve(event: RequestEvent, opts?: ResolveOptions): MaybePromise<Response>;
}) => MaybePromise<Response>;
resolve(event: RequestEvent, opts?: ResolveOptions): MaybePromise<void | Response>;
}) => MaybePromise<void | Response>;

/**
* The server-side [`handleError`](https://svelte.dev/docs/kit/hooks#shared-hooks-handleError) hook runs when an unexpected error is thrown while responding to a request.
Expand Down Expand Up @@ -1076,6 +1078,10 @@ export interface RequestEvent<
* The original request object
*/
request: Request;
/**
* The upgrade request object
*/
upgrade: { request: IncomingMessage; socket: Duplex; head: Buffer } | null;
/**
* Info about the current route
*/
Expand Down Expand Up @@ -1133,6 +1139,16 @@ export type RequestHandler<
RouteId extends string | null = string | null
> = (event: RequestEvent<Params, RouteId>) => MaybePromise<Response>;

/**
* A `(event: UpgradeEvent) => void` function exported from a `+server.js` file with the name UPGRADE and handles server upgrade requests.
*
* It receives `Params` as the first generic argument, which you can skip by using [generated types](https://svelte.dev/docs/kit/types#Generated-types) instead.
*/
export type UpgradeHandler<
Params extends Partial<Record<string, string>> = Partial<Record<string, string>>,
RouteId extends string | null = string | null
> = (event: RequestEvent<Params, RouteId>) => MaybePromise<void>;

export interface ResolveOptions {
/**
* Applies custom transforms to HTML. If `done` is true, it's the final chunk. Chunks are not guaranteed to be well-formed HTML
Expand Down
46 changes: 46 additions & 0 deletions packages/kit/src/exports/vite/dev/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,52 @@ export async function dev(vite, vite_config, svelte_config) {
// serving routes with those names. See https://github.com/vitejs/vite/issues/7363
remove_static_middlewares(vite.middlewares);

vite.httpServer?.on('upgrade', async (req, socket, head) => {
const base = `${vite.config.server.https ? 'wss' : 'ws'}://${
req.headers[':authority'] || req.headers.host
}`;

// we have to import `Server` before calling `set_assets`
const { Server } = /** @type {import('types').ServerModule} */ (
await vite.ssrLoadModule(`${runtime_base}/server/index.js`, { fixStacktrace: true })
);

const server = new Server(manifest);

await server.init({
env,
read: (file) => createReadableStream(from_fs(file))
});

const request = await getRequest({
base,
request: req
});

await server.respond(
request,
{
getClientAddress: () => {
const { remoteAddress } = req.socket;
if (remoteAddress) return remoteAddress;
throw new Error('Could not determine clientAddress');
},
read: (file) => {
if (file in manifest._.server_assets) {
return fs.readFileSync(from_fs(file));
}

return fs.readFileSync(path.join(svelte_config.kit.files.assets, file));
},
before_handle: (event, config, prerender) => {
async_local_storage.enterWith({ event, config, prerender });
},
emulator
},
{ request: req, socket, head }
);
});

vite.middlewares.use(async (req, res) => {
// Vite's base middleware strips out the base path. Restore it
const original_url = req.url;
Expand Down
17 changes: 16 additions & 1 deletion packages/kit/src/runtime/server/endpoint.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,25 @@ import { method_not_allowed } from './utils.js';
* @param {import('@sveltejs/kit').RequestEvent} event
* @param {import('types').SSREndpoint} mod
* @param {import('types').SSRState} state
* @returns {Promise<Response>}
* @returns {Promise<Response | void>}
*/
export async function render_endpoint(event, mod, state) {
const method = /** @type {import('types').HttpMethod} */ (event.request.method);

/**
* The handler function to use for the request
* @type {import('@sveltejs/kit').RequestHandler | import('@sveltejs/kit').UpgradeHandler | undefined}
*/
let handler = mod[method] || mod.fallback;

if (method === 'HEAD' && mod.GET && !mod.HEAD) {
handler = mod.GET;
}

if (method === 'GET' && !mod.GET && mod.UPGRADE) {
handler = mod.UPGRADE;
}

if (!handler) {
return method_not_allowed(mod, method);
}
Expand All @@ -40,6 +48,13 @@ export async function render_endpoint(event, mod, state) {
}

try {
if (method === 'GET' && event.request.headers.has('upgrade') && event.upgrade && mod.UPGRADE) {
await handler(
/** @type {import('@sveltejs/kit').RequestEvent<Record<string, any>>} */ (event)
);
return;
}

let response = await handler(
/** @type {import('@sveltejs/kit').RequestEvent<Record<string, any>>} */ (event)
);
Expand Down
19 changes: 13 additions & 6 deletions packages/kit/src/runtime/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,19 @@ export class Server {
/**
* @param {Request} request
* @param {import('types').RequestOptions} options
* @param {{request: import('http').IncomingMessage, socket: import('stream').Duplex , head: Buffer}?} webhookRequest
*/
async respond(request, options) {
return respond(request, this.#options, this.#manifest, {
...options,
error: false,
depth: 0
});
async respond(request, options, webhookRequest) {
return respond(
request,
this.#options,
this.#manifest,
{
...options,
error: false,
depth: 0
},
webhookRequest
);
}
}
17 changes: 10 additions & 7 deletions packages/kit/src/runtime/server/respond.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,10 @@ const allowed_page_methods = new Set(['GET', 'HEAD', 'OPTIONS']);
* @param {import('types').SSROptions} options
* @param {import('@sveltejs/kit').SSRManifest} manifest
* @param {import('types').SSRState} state
* @returns {Promise<Response>}
* @param {{request: import('http').IncomingMessage, socket: import('stream').Duplex , head: Buffer}?} upgradeRequest
* @returns {Promise<void | Response>}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not that familiar with UPGRADE, but is it true that it sends no response? A quick peek at MDN makes it appear to me that it does send a response

https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Upgrade#overview

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There does appear to be some variance in the way the response is sent though, for example Bun does not send a response, but Deno does appear to return a standard HTTP response.

Socket.IO also returns a response but they refuse to just give you the resp object, and instead insist on binding directly to a Server so they can send it themselves.

If we look at the Node.JS server upgrade event, they specify the data is sent back through the socket, and their on upgrade handler does not even support sending a standard response object

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would love maintainer/community input here, its definitely a fun issue to try and solve for everyone :P

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

@LukeHagar LukeHagar Nov 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't had any luck getting this to work locally

Copy link
Member

@benmccann benmccann Nov 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh sorry, I hadn't event noticed on first glance that UPGRADE is not an http verb like the others and it's an http header. I'm afraid I'm not terribly familiar with web sockets. I think we'll need to find another way of expressing this that doesn't mirror the API we use for HTTP verbs

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood!

One thing that's worth mentioning is that this approach allows for different websocket handlers to be created on different routes, and for all other aspects of the requests and routing to use the existing SK response handling.

The incoming request is different, as the servers don't appear to pass these get requests through to middleware, so the existing middleware handling had to be somewhat copied to the server.on("upgrade") handler.

*/
export async function respond(request, options, manifest, state) {
export async function respond(request, options, manifest, state, upgradeRequest) {
/** URL but stripped from the potential `/__data.json` suffix and its search param */
const url = new URL(request.url);

Expand Down Expand Up @@ -139,7 +140,7 @@ export async function respond(request, options, manifest, state) {
}

if (!state.prerendering?.fallback) {
// TODO this could theoretically break should probably be inside a try-catch
// TODO this could theoretically break - should probably be inside a try-catch
const matchers = await manifest._.matchers();

for (const candidate of manifest._.routes) {
Expand Down Expand Up @@ -181,6 +182,7 @@ export async function respond(request, options, manifest, state) {
params,
platform: state.platform,
request,
upgrade: upgradeRequest || null,
route: { id: route?.id ?? null },
setHeaders: (new_headers) => {
for (const key in new_headers) {
Expand Down Expand Up @@ -328,6 +330,7 @@ export async function respond(request, options, manifest, state) {
event,
resolve: (event, opts) =>
resolve(event, opts).then((response) => {
if (!response) return;
// add headers/cookies here, rather than inside `resolve`, so that we
// can do it once for all responses instead of once per `return`
for (const key in headers) {
Expand All @@ -346,7 +349,7 @@ export async function respond(request, options, manifest, state) {
});

// respond with 304 if etag matches
if (response.status === 200 && response.headers.has('etag')) {
if (response?.status === 200 && response?.headers.has('etag')) {
let if_none_match_value = request.headers.get('if-none-match');

// ignore W/ prefix https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match#directives
Expand Down Expand Up @@ -381,7 +384,7 @@ export async function respond(request, options, manifest, state) {

// Edge case: If user does `return Response(30x)` in handle hook while processing a data request,
// we need to transform the redirect response to a corresponding JSON response.
if (is_data_request && response.status >= 300 && response.status <= 308) {
if (is_data_request && response && response.status >= 300 && response.status <= 308) {
const location = response.headers.get('location');
if (location) {
return redirect_json_response(new Redirect(/** @type {any} */ (response.status), location));
Expand Down Expand Up @@ -434,7 +437,7 @@ export async function respond(request, options, manifest, state) {
if (route) {
const method = /** @type {import('types').HttpMethod} */ (event.request.method);

/** @type {Response} */
/** @type {void | Response} */
let response;

if (is_data_request) {
Expand Down Expand Up @@ -484,7 +487,7 @@ export async function respond(request, options, manifest, state) {

// If the route contains a page and an endpoint, we need to add a
// `Vary: Accept` header to the response because of browser caching
if (request.method === 'GET' && route.page && route.endpoint) {
if (request.method === 'GET' && route.page && route.endpoint && response) {
const vary = response.headers
.get('vary')
?.split(',')
Expand Down
9 changes: 7 additions & 2 deletions packages/kit/src/types/internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import {
RequestEvent,
SSRManifest,
Emulator,
Adapter
Adapter,
UpgradeHandler
} from '@sveltejs/kit';
import {
HttpMethod,
Expand All @@ -26,6 +27,8 @@ import {
RequestOptions,
TrailingSlash
} from './private.js';
import type { IncomingMessage } from 'node:http';
import type { Duplex } from 'node:stream';

export interface ServerModule {
Server: typeof InternalServer;
Expand Down Expand Up @@ -131,7 +134,8 @@ export class InternalServer extends Server {
/** A hook called before `handle` during dev, so that `AsyncLocalStorage` can be populated */
before_handle?: (event: RequestEvent, config: any, prerender: PrerenderOption) => void;
emulator?: Emulator;
}
},
webhookRequest?: { request: IncomingMessage; socket: Duplex; head: Buffer }
): Promise<Response>;
}

Expand Down Expand Up @@ -386,6 +390,7 @@ export interface PageNodeIndexes {
export type PrerenderEntryGenerator = () => MaybePromise<Array<Record<string, string>>>;

export type SSREndpoint = Partial<Record<HttpMethod, RequestHandler>> & {
UPGRADE?: UpgradeHandler;
prerender?: PrerenderOption;
trailingSlash?: TrailingSlash;
config?: any;
Expand Down
1 change: 1 addition & 0 deletions packages/kit/src/utils/exports.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ const valid_page_exports = new Set([...valid_layout_exports, 'entries']);
const valid_layout_server_exports = new Set([...valid_layout_exports]);
const valid_page_server_exports = new Set([...valid_layout_server_exports, 'actions', 'entries']);
const valid_server_exports = new Set([
'UPGRADE',
'GET',
'POST',
'PATCH',
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/src/utils/exports.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ test('validates +server.js', () => {
validate_server_exports({
answer: 42
});
}, "Invalid export 'answer' (valid exports are GET, POST, PATCH, PUT, DELETE, OPTIONS, HEAD, fallback, prerender, trailingSlash, config, entries, or anything with a '_' prefix)");
}, "Invalid export 'answer' (valid exports are UPGRADE, GET, POST, PATCH, PUT, DELETE, OPTIONS, HEAD, fallback, prerender, trailingSlash, config, entries, or anything with a '_' prefix)");

check_error(() => {
validate_server_exports({
Expand Down
5 changes: 4 additions & 1 deletion packages/kit/test/apps/options-2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@
"@sveltejs/kit": "workspace:^",
"@sveltejs/vite-plugin-svelte": "^3.0.1",
"cross-env": "^7.0.3",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"svelte": "^4.2.10",
"svelte-check": "^4.0.1",
"typescript": "^5.3.3",
"vite": "^5.3.2"
"vite": "^5.3.2",
"ws": "^8.18.0"
},
"type": "module"
}
4 changes: 4 additions & 0 deletions packages/kit/test/apps/options-2/src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@
<p data-testid="assets">assets: {assets}</p>

<a href="{base}/hello" data-testid="link">Go to /hello</a>
<br />
<a href="{base}/ws" data-testid="link">Go to /ws</a>
<br />
<a href="{base}/socket.io" data-testid="link">Go to /socket.io</a>
35 changes: 35 additions & 0 deletions packages/kit/test/apps/options-2/src/routes/ws/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<script>
import { browser } from '$app/environment';
import { base } from '$app/paths';

let messages = [];

if (browser) {
const socket = new WebSocket(`${base}/ws`);

console.log(socket);

socket.onopen = () => {
console.log('websocket connected');
socket.send('hello world');
};

socket.onclose = () => {
console.log('disconnected');
};

socket.onmessage = (event) => {
console.log(event.data);
messages = [...messages, event.data];
console.log(messages);
};
}
</script>

<h1>Messages:</h1>

<div>
{#each messages as message}
<p>{message}</p>
{/each}
</div>
19 changes: 19 additions & 0 deletions packages/kit/test/apps/options-2/src/routes/ws/+server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ noServer: true });

wss.on('connection', (ws) => {
ws.on('error', console.error);

ws.on('message', (message) => {
console.log('received: %s', message);
ws.send(String(message));
});
});

export function UPGRADE({ upgrade }) {
wss.handleUpgrade(upgrade.request, upgrade.socket, upgrade.head, (ws) => {
console.log('UPGRADED');
wss.emit('connection', ws, upgrade.request);
});
}
Loading
Loading