Skip to content
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

feat: native support for Websockets #12973

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions packages/kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"dependencies": {
"@types/cookie": "^0.6.0",
"cookie": "^0.6.0",
"crossws": "^0.3.1",
"devalue": "^5.1.0",
"esm-env": "^1.0.0",
"import-meta-resolve": "^4.1.0",
Expand Down
12 changes: 10 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,7 @@ import {
} from '../types/private.js';
import { BuildData, SSRNodeLoader, SSRRoute, ValidatedConfig } from 'types';
import type { PluginOptions } from '@sveltejs/vite-plugin-svelte';
import { AdapterInstance, Hooks } from 'crossws';

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

Expand Down Expand Up @@ -685,8 +686,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 | ResponseInit | Response>;
Copy link
Member

@eltigerchino eltigerchino Nov 11, 2024

Choose a reason for hiding this comment

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

Is returning void or a ResponseInit important to cancel the websocket connection? This would be a breaking change.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Its as I mentioned on Discord, returning a Response aborts the Websocket upgrade, and returning a headers object (or maybe more?) accepts the upgrade request, I'm not certain on the void bit here but that was their typing.

Copy link
Contributor Author

@LukeHagar LukeHagar Nov 11, 2024

Choose a reason for hiding this comment

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

I do think there is a path to improving the dev experience here, we have an issue logged on the crossws repo to discuss further.

Copy link
Member

Choose a reason for hiding this comment

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

Related to unjs/crossws#88

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Same, would love to keep this as is
unjs/crossws#90

}) => MaybePromise<void | ResponseInit | 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 +1077,13 @@ export interface RequestEvent<
* The original request object
*/
request: Request;
/**
* The two functions used to control the flow of websocket requests
*/
socket?: {
accept: (init: ResponseInit) => ResponseInit;
reject: (status: number, body: any) => Response;
};
/**
* Info about the current route
*/
Expand Down
53 changes: 32 additions & 21 deletions packages/kit/src/exports/vite/dev/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { URL } from 'node:url';
import crossws from 'crossws/adapters/node';
import { AsyncLocalStorage } from 'node:async_hooks';
import colors from 'kleur';
import sirv from 'sirv';
Expand Down Expand Up @@ -421,7 +422,7 @@ export async function dev(vite, vite_config, svelte_config) {
const env = loadEnv(vite_config.mode, svelte_config.kit.env.dir, '');
const emulator = await svelte_config.kit.adapter?.emulate?.();

return () => {
return async () => {
const serve_static_middleware = vite.middlewares.stack.find(
(middleware) =>
/** @type {function} */ (middleware.handle).name === 'viteServeStaticMiddleware'
Expand All @@ -431,6 +432,36 @@ 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);

// 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 { set_fix_stack_trace } = await vite.ssrLoadModule(`${runtime_base}/shared-server.js`);
set_fix_stack_trace(fix_stack_trace);

const { set_assets } = await vite.ssrLoadModule('__sveltekit/paths');
set_assets(assets);

const server = new Server(manifest);

// we have to initialize the server before we can call the resolve function to populate the webhook resolver in the websocket handler
await server.init({
env,
read: (file) => createReadableStream(from_fs(file))
});

/** @type {import('crossws/adapters/node').NodeAdapter} */
const ws = crossws({
resolve: server.resolve()
});

vite.httpServer?.on('upgrade', (req, socket, head) => {
if (req.headers['sec-websocket-protocol'] !== 'vite-hmr') {
ws.handleUpgrade(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 Expand Up @@ -474,26 +505,6 @@ export async function dev(vite, vite_config, svelte_config) {
return;
}

// 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 { set_fix_stack_trace } = await vite.ssrLoadModule(
`${runtime_base}/shared-server.js`
);
set_fix_stack_trace(fix_stack_trace);

const { set_assets } = await vite.ssrLoadModule('__sveltekit/paths');
set_assets(assets);

const server = new Server(manifest);

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

const request = await getRequest({
base,
request: req
Expand Down
30 changes: 22 additions & 8 deletions packages/kit/src/runtime/server/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { respond } from './respond.js';
import { resolve } from './resolve.js';
import { set_private_env, set_public_env, set_safe_public_env } from '../shared-server.js';
import { options, get_hooks } from '__SERVER__/internal.js';
import { DEV } from 'esm-env';
Expand All @@ -17,15 +18,15 @@ const prerender_env_handler = {

export class Server {
/** @type {import('types').SSROptions} */
#options;
options;

/** @type {import('@sveltejs/kit').SSRManifest} */
#manifest;

/** @param {import('@sveltejs/kit').SSRManifest} manifest */
constructor(manifest) {
/** @type {import('types').SSROptions} */
this.#options = options;
this.options = options;
this.#manifest = manifest;

set_manifest(manifest);
Expand All @@ -44,8 +45,8 @@ export class Server {

// set env, in case it's used in initialisation
const prefixes = {
public_prefix: this.#options.env_public_prefix,
private_prefix: this.#options.env_private_prefix
public_prefix: this.options.env_public_prefix,
private_prefix: this.options.env_private_prefix
};

const private_env = filter_private_env(env, prefixes);
Expand All @@ -63,19 +64,19 @@ export class Server {
set_read_implementation(read);
}

if (!this.#options.hooks) {
if (!this.options.hooks) {
try {
const module = await get_hooks();

this.#options.hooks = {
this.options.hooks = {
handle: module.handle || (({ event, resolve }) => resolve(event)),
handleError: module.handleError || (({ error }) => console.error(error)),
handleFetch: module.handleFetch || (({ request, fetch }) => fetch(request)),
reroute: module.reroute || (() => {})
};
} catch (error) {
if (DEV) {
this.#options.hooks = {
this.options.hooks = {
handle: () => {
throw error;
},
Expand All @@ -95,7 +96,20 @@ export class Server {
* @param {import('types').RequestOptions} options
*/
async respond(request, options) {
return respond(request, this.#options, this.#manifest, {
return respond(request, this.options, this.#manifest, {
...options,
error: false,
depth: 0
});
}

/**
* Returns a function that resolves the websocket hooks for a given request
* @param {import('types').RequestOptions} options
* @returns {(info: Request) => import('types').MaybePromise<Partial<import('crossws').Hooks>>}
*/
resolve(options) {
return resolve(this.options, this.#manifest, {
...options,
error: false,
depth: 0
Expand Down
193 changes: 193 additions & 0 deletions packages/kit/src/runtime/server/resolve.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { BROWSER, DEV } from 'esm-env';
import { validate_server_exports } from '../../utils/exports.js';
import { exec } from '../../utils/routing.js';
import { decode_pathname, decode_params } from '../../utils/url.js';
import { base } from '__sveltekit/paths';

/**
* @param {import('types').SSROptions} options
* @param {import('@sveltejs/kit').SSRManifest} manifest
* @param {import('types').SSRState} state
* @returns {(info: Request | import('crossws').Peer) => import('types').MaybePromise<Partial<import('crossws').Hooks>>}
*/
export function resolve(options, manifest, state) {
return async (info) => {
/** @type {Request} */
let request;

// These types all need to be straightened out
if (info.request) {
request = info.request;
} else {
request = info;
}

/** URL but stripped from the potential `/__data.json` suffix and its search param */
const url = new URL(request.url);

// reroute could alter the given URL, so we pass a copy
let rerouted_path;
try {
rerouted_path = options.hooks.reroute({ url }) ?? url.pathname;
} catch {
return {};
}

let decoded;
try {
decoded = decode_pathname(rerouted_path);
} catch (e) {
console.error(e);
return {};
}

if (base && decoded.startsWith(base)) {
decoded = decoded.slice(base.length) || '/';
}

/** @type {import('types').SSRRoute | null} */
let route = null;

/** @type {Record<string, string>} */
let params = {};

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

for (const candidate of manifest._.routes) {
const match = candidate.pattern.exec(decoded);

if (!match) continue;

const matched = exec(match, candidate.params, matchers);
if (matched) {
route = candidate;
params = decode_params(matched);
break;
}
}
} catch (e) {
console.error(e);
return {};
}

/** @type {Record<string, string>} */
const headers = {};

try {
// determine whether we need to redirect to add/remove a trailing slash
if (route && route.endpoint) {
// if `paths.base === '/a/b/c`, then the root route is `/a/b/c/`,
// regardless of the `trailingSlash` route option

const node = await route.endpoint();

if (DEV) {
validate_server_exports(node, /** @type {string} */ (route.endpoint_id));
}

return {
...node.socket,
upgrade: async (req) => {
/** @type {import('@sveltejs/kit').RequestEvent} */
const event = {
// @ts-expect-error `cookies` and `fetch` need to be created after the `event` itself
cookies: null,
// @ts-expect-error
fetch: null,
getClientAddress:
state.getClientAddress ||
(() => {
throw new Error(
`${__SVELTEKIT_ADAPTER_NAME__} does not specify getClientAddress. Please raise an issue`
);
}),
locals: {},
params,
platform: state.platform,
request: req,
socket: {
/**
* Accept a WebSocket Upgrade request
* @param {RequestInit} init
* @returns {RequestInit}
*/
accept: (init) => {
return { ...init };
},
/**
* Reject a WebSocket Upgrade request
* @param {number} status The [HTTP status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#client_error_responses). Must be in the range 400-599.
* @param {{ message: string } extends App.Error ? App.Error | string | undefined : never} body An object that conforms to the App.Error type. If a string is passed, it will be used as the message property.
* @return {Response} A Response object
* @throws {Error} If the provided status is invalid (not between 400 and 599).
*/
reject: (status, body) => {
if ((!BROWSER || DEV) && (isNaN(status) || status < 400 || status > 599)) {
throw new Error(
`HTTP error status codes must be between 400 and 599 — ${status} is invalid`
);
}

try {
const jsonBody = JSON.stringify(body);
return new Response(jsonBody, {
status,
headers: {
'content-type': 'application/json'
}
});
} catch (e) {
console.error(e);
throw new Error('Failed to serialize error body');
}
}
},
route: { id: route?.id ?? null },
setHeaders: (new_headers) => {
for (const key in new_headers) {
const lower = key.toLowerCase();
const value = new_headers[key];

if (lower === 'set-cookie') {
throw new Error(
'Use `event.cookies.set(name, value, options)` instead of `event.setHeaders` to set cookies'
);
} else if (lower in headers) {
throw new Error(`"${key}" header is already set`);
} else {
headers[lower] = value;

if (state.prerendering && lower === 'cache-control') {
state.prerendering.cache = /** @type {string} */ (value);
}
}
}
},
url,
isDataRequest: false,
isSubRequest: state.depth > 0
};

const response = await options.hooks.handle({
event,
resolve: async (event) => {
if (node.socket && node.socket.upgrade) {
return await node.socket.upgrade(event.request);
} else {
return new Response('Not Implemented', { status: 501 });
}
}
});

return response ?? new Response('Not Implemented', { status: 501 });
}
};
}
} catch (e) {
console.error(e);
return {};
}
};
}
Loading
Loading