Svelte Kit query.live first thoughts
With Svelte Kit 3 entering Release Candidate a couple of weeks ago, it seemed like a good time to finally start exploring the Remote Functions support that the Svelte team have been previewing since last year. I’ve got a hobby project in mind that I’d like to use it for, a dice roller for the Burning Wheel RPG, and query.live looks like a good place to start. Until now doing something like this with Svelte has been a bit of a pain in the ass as there was no first class support for WebSockets or Server-Sent Events.
A dice roller is really just a broadcast chat app that happens to do some random number stuff on the server, so a minimal in-memory chat program is a good place to start.
Here’s the core of this prototype lives in one file, chat.remote.ts:
import * as v from 'valibot';
import { query, command } from '$app/server';
import { PubSubHub } from '../lib/pubsubhub.ts';
const history:string[] = [];
const room = new PubSubHub<string[]>();
export const addChat = command(v.string(), (text: string) => {
history.push(text);
room.publish(history);
});
export const getChats = query.live(room.subscribe.bind(room));
Probably the most notable thing is the presence of PubSubHub (code below). I immediately ran into an issue where the new query.live consumes a Async Generator function. The example in the documentation, getTime, makes this appear very simple: the only source of events is setTimeout so it’s trivial to setup. Of course, in a real application you probably want the remote function to update in response to some external events, such as the user posting a chat message.
If you’ve ever looked at async generators before, you’ll know that this pretty quickly becomes a bunch of bookkeepping to manage a cavalcade of Promises. These Promise objects are subverted for use as signalling rather than as delayed values, resulting in the resolve functions being exfiltrated as one time event triggers. Because a Promise only delivers at most once, you need to keep creating new Promises after each yield.
To that end, I create this minimal publish subscribe object that returns an AsyncGenerator when you subscribe.
pubsubhub.ts:
interface SubscriberHandle<T> {
handle: (msg:T) => void;
}
/**
* A Publish Subscribe hub that is consumed via async generators.
*
* Subscribe uses an async generator because that’s what Svelte’s
* `query.live` consumes.
*
* ```typescript
* const hub = new PubSubHub<string>();
*
* for await (const message of hub.subscribe()) {
* // Process message
* }
*
* …
*
* function onSend(text:string) {
* hub.publish(text);
* }
* ```
*/
export class PubSubHub<T> {
private subscribers: Set<SubscriberHandle<T>> = new Set();
publish(msg:T) {
for (const sub of this.subscribers) {
sub.handle(msg);
}
}
async * subscribe(): AsyncGenerator<T> {
// To set up the generator's body, we need an object (the subscriber)
// to be registered with the `subscribers` set, that can hold the
// resolve functions generated by the `signal` sentinel Promises.
const subscriber:SubscriberHandle<T> = {handle:() => {}};
this.subscribers.add(subscriber);
try {
// Callers can break out of the loop by calling `return()` on the
// resulting generator, cleanly exiting.
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator/return
while (true) {
const signal = new Promise<T>((resolve) => void (subscriber.handle = resolve));
yield await signal;
}
}
finally {
// if the caller calls `return()` on the generator we need to
// remove the subscriber so that they no longer receive messages.
this.subscribers.delete(subscriber);
}
}
}
Note that you need to call bind on the subscribe method if you want to pass it as-is to query.live, otherwise it will become detached from the PubSubHub and not be able to find the list of subscribers.
In a real application you’ll probably want something else, connected to an external service such as a message bus, Postgres’s LISTEN / NOTIFY, etc. But the basic pattern of converting events in a generator stream remains.
query.command is trivial to use. I probably should have used a query.form, but for this toy example it was easier to just skip that for now. That interface seems solid, and query.form is an obvious expansion on the concept.
query.live consuming an async generator I’m less sure on. It works, but the busywork associated with setting it up feels more onerous than I would have liked. Not having an obviously documented example of it interacting with svelte’s observable values or stores seems like an oversight.
Likewise, the lack of an equivalent of PubSubHub, or other utilities for adapting external data to a generater, in the core library is puzzling. It’s still a release candidate so maybe that is still to come. The store API is very close but doesn’t, as best as I can tell, expose an async generator. Something that can consume any observable svelte object, or a store, and produce an async stream seems like an obvious addition.
From the consumer side, having the query.live function (getChats in my example) produce a single value that changes over time, and not a sequence is a little surprising. Not a deal breaker, but I dislike the asymmetry between producer and consumer; it means that – as in this example – the client or server has to do extra work to keep track of the history if that is required. I opted to do it on the server for convenience in the example, even though it’s wasteful. Again, some standard convenience functions in the library would resolve this.
Minor misgivings aside, this is new set of features is promising. Hopefully it will get a finalised release in the not too distant future.