Add Headers with socket requests.
I realize this is pie in the sky request.
I have long wanted to be able to send header-like data in socket requests. When using REST, we have three places to transfer data with each request: body (aka data), search params (aka query), and headers. But for sockets there is no place to send "headers" with each request. HTTP headers are set at socket connection but we can't send header-like data thereafter.
I stumbled across this problem/desire again recently because my app has outgrown Feathers' authentication implementation. I am implementing sessions. Unfortunately this means removing the "timers", etc that are included with how the socket handles auth and "sessions" via the JWT. I just want to be able to pass the Authorization: Bearer 12345 with each socket request....and the auth implementation gets SOOOO much simpler.
Instead of only passing query with each request, it would be fantastic if the socket client could send params shaped like { query, headers }. So params.headers would be considered safe just like params.query. Obviously we don't want to send the entire params object because it could essentially overwrite the server side's params. But sending params.headers aligns with how REST can send headers with each request. Furthermore, we could disallow forbidden headers: https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_request_header. We could also not allow overwriting headers that already exists via the socket connection.
Furthermore, it would be great if we could create response headers like X-RateLimit-Remaining, but that is a bit more complicated.
This would give the developer a more REST-like experience and would massively simplify how sockets handle authentication.
I am willing to work on this. But looking for some feedback from @marshallswain and @daffl. Is this something we could squeeze in v6? Or is v6 too far along? Is this something you guys are even interested in at all?
This is in fact exactly what I've been thinking of doing with the fetch API via websockets library I have been prototyping. It still needs to be cleaned up and add support for streams so it can do real-time via SSE but this would basically mean that the same v6 web standard handler (and other frameworks like Hono) can be used via websockets.
The prototype client currently looks like this:
const ws = new WebSocket('ws://localhost:8080');
ws.addEventListener('open', async () => {
const client = new WsFetchclient(ws)
const response = await client.fetch('/something', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ name: `Dave ${i}` })
})
console.log(await response.json())
})
With a server like this:
const app = express()
const server = app.listen(8080)
const wss = new WebSocketServer({ server })
wss.on('connection', function connection(ws, request) {
new WsFetchServer(ws, async (request) => {
if (request.method === 'POST') {
const body = await request.json();
return Response.json({
message: `Hello, ${body.name}!`
});
}
return Response.json({ message: 'Hello, World!' });
});
})
Very interesting! I am glad to hear it is something you are moving towards. I am currently handling this with the "params for server" pattern. I am still in the process of implementing it and it doesn't support custom methods. But it seems to be working so far.
// client
import feathers from '@feathersjs/client';
import io from 'socket.io-client';
const socket = io(import.meta.env.VITE_API_URL, {
transports: ['websocket'],
forceNew: true
});
const methods = [
'_get',
'_find',
'_create',
'_update',
'_patch',
'_remove',
'get',
'find',
'create',
'update',
'patch',
'remove'
];
export const paramsPositions = {
get: 1,
find: 0,
create: 1,
update: 2,
patch: 2,
remove: 1
};
const property = '__FEATHERS_HEADERS__';
export default (app) => {
feathers.socketio(socket)(app);
// Feathers socket client only sends query to the server
// but not headers. This mixin allows params.headers to piggyback
// on params.query so that it can be sent to the server.
// The server will then extract params.headers from params.query
// in a hook (see server/src/app.hooks.js).
app.mixins.push(function (service) {
methods.forEach((method) => {
if (!service[method]) {
return;
}
const position = paramsPositions[method.replace('_', '')];
const original = service[method];
service[method] = function (...args) {
const params = { ...args[position] };
const query = { ...params.query };
query[property] = params.headers;
params.query = query;
args[position] = params;
return original.apply(this, args);
};
});
});
};
const authHeaderHook = (context) => {
const auth = context.app.auth;
if (!auth) {
return context;
}
context.params.headers = {
...context.params.headers,
Authorization: `Bearer ${auth.token}`
};
return context;
};
app.hooks({
before: {
all: [authHeaderHook]
}
});
// server
const headersProperty = '__FEATHERS_HEADERS__';
const headersHook = (context) => {
const query = { ...(context.params.query || {}) };
const headers = query[headersProperty];
if (!headers) {
return context;
}
delete query[headersProperty];
// TODO: Delete or throw on forbidden headers
context.params.headers = {
...headers,
...context.params.headers
};
context.params.query = query;
return context;
};
And then there is an authenticateHook that looks for the Authorizaiton header, looks up the session, etc.
And there is no concept of an "authenticated socket", WeakMap of sockets, updating the connection headers, etc.
Don't you still need to know if the socket is authenticated to securely send events?
TBH I am not really using events with this particular app. I am really just taking advantage of the speed that the sockets provide over REST. But, I was thinking I would be able to handle that with channels and publishers, no? In other apps where I am using events, I am never to just sending events to "all authenticated connections". Everything is scoped via channels.
This implementation has 3 methods on the "authentication" service
-
create({ email, password })- creates a new session. -
get(sessionToken)- If no session exists or is expired, throwNotAuthenticated. Else, return the session data and potentially set the expiry to a later date. -
remove(sessionToken)- Expires the session.
So I am thinking with those three methods I should be able to join/leave any appropriate channels. But I do see the problem that you are suggesting. Sessions may become "expired" without activity and then other actors' events would still be sent to those expired channels. So there may still be some need for timers/weakmaps.