Compose & embed a super-line library
You built a library on super-line — it has its own contract, handlers, and collections. Now a host app that also runs super-line wants to embed it. Two servers would mean two sockets, two handshakes, two identities. Composition gives you one server, one client, one session, one identity: the library exports its contract pieces, and the host weaves them into its own.
There is no namespace field on the wire — namespacing is a key-prefix convention plus two helpers that make collisions impossible to miss:
defineSurface— author an exportable contract fragment (one{ clientToServer, serverToClient }block).mergeSurfaces— combine two fragments; a duplicate key is a compile error naming the key (and a runtime throw), never a silent spread-clobber.
Library side: export a surface
import * as z from 'zod'
import { defineSurface } from '@super-line/core'
// keys hard-prefixed in source — `lib.` is yours; pick something unmistakable
export const libSurface = defineSurface({
clientToServer: {
'lib.join': { input: z.object({ threadId: z.string() }), output: z.object({ ok: z.boolean() }) },
},
serverToClient: {
'lib.suspended': { payload: z.object({ threadId: z.string() }) },
'lib.feed': { payload: z.object({ text: z.string() }), subscribe: true },
},
})Why defineSurface and not a plain const?
defineContract preserves literal types for inline contracts, but a fragment declared as a plain const widens subscribe: true to boolean — and your topic silently degrades to a push event after merging. defineSurface is an identity function with the same const type parameter, so the literal survives.
Alongside the surface, export your handlers as a factory, prefixed the same way:
export const libHandlers = (deps: LibDeps) => ({
'lib.join': async (input, ctx, conn) => { /* … */ },
})Durable, typed state is a collection
mergeSurfaces merges only the wire surface — requests, events, and topics. A library's persisted state is a collection declared on the contract, contributed with a contract-fragment plugin (defineContractPlugin) and locked down with server-side policies. See Build a plugin for the recipe. Prefix collection names (lib.…) the same way you prefix keys.
Three more library-side rules:
- Prefix room names too — rooms are runtime strings no helper can collision-check.
conn.join(`lib:thread:${id}`), neverconn.join(`thread:${id}`). - Declare
@super-line/*aspeerDependencies— host and library must share one core instance. - Keep your standalone entry point — it just mounts the same fragments into a trivial contract of its own, so the library works with or without a host.
Host side: mount it
import { defineContract, defineSurface, mergeSurfaces } from '@super-line/core'
import { libSurface, libHandlers } from 'your-lib'
const userSurface = defineSurface({
clientToServer: { say: { input: z.object({ text: z.string() }), output: z.object({ id: z.string() }) } },
serverToClient: { posted: { payload: z.object({ id: z.string() }) } },
})
export const api = defineContract({
roles: {
user: mergeSurfaces(libSurface, userSurface), // ← the library rides this role
admin: adminSurface, // ← and not this one
},
})
const srv = createSuperLineServer(api, {
transports: [webSocketServerTransport({ server })],
authenticate, // ONE handshake — yours
})
srv.implement({ user: { ...libHandlers(deps), ...myHandlers } })Mounting decisions, in order:
- Which block gets the surface? Merge into
sharedand every role sees the library; merge into one role and it's scoped. Scope it unless you're sure. datastays yours —mergeSurfacesdeliberately rejects role blocks: a role'sdataschema, like roles and auth, belongs to the host. Add it beside the merge:user: { ...mergeSurfaces(libSurface, userSurface), data: myDataSchema }.- You can't forget the handlers.
implementrequires a handler for every merged key — dropping...libHandlers(deps)is a compile error, not a runtime 404. - Your middleware runs on library requests too. That's the point (it's how shared auth manifests), but remember it when rate-limiting.
The client mirrors the server: one createSuperLineClient(api, …), and the library exposes its client-side helpers over your client instance.
Package the weave as a plugin
Exporting handlers and middleware as separate factories and wiring them by hand works — but a plugin bundles them into one mountable unit (plugins: [lib()]), multiplexes lifecycle hooks so two libraries can coexist, and subtracts the library's handler keys from your implement() obligation. The surface merge above stays exactly the same; the plugin just carries the runtime half.
Collisions
mergeSurfaces(libSurface, defineSurface({
clientToServer: { 'lib.join': { input: z.void(), output: z.void() } },
}))
// compile error: … not assignable … { 'mergeSurfaces: duplicate keys': "lib.join" }
// runtime (untyped callers): Error: mergeSurfaces: duplicate keys: lib.join — rename or prefixThe same key in opposite directions is not a collision — a request and an event may share a name.
When composition isn't the tool
Composition assumes the two surfaces should share identity and lifecycle. If you need two independent stacks — separate authenticate, separate reconnect, true third-party isolation — that's a different problem: two sockets (fine in practice), or a mux transport carrying two independent sessions on one wire — designed, deferred, not built. Composition was chosen over both because the requirement driving it was shared identity, which composition gives by construction.
Library author checklist
- [ ] Keys prefixed in source: requests/events/topics (
lib.join), collection names (lib.threads), room names (lib:…) - [ ] Surface exported via
defineSurface; handlers exported as a factory - [ ]
@super-line/*inpeerDependencies - [ ] Standalone entry point mounts the same fragments
- [ ] Document the
ctxshape your handlers need from the host'sauthenticate
Next
- Build a plugin — bundle the weave (handlers · middleware · collections) into one mountable unit.
- Plugins — the model behind the paired runtime bundle.
- Test your library — boot a real loopback server and drive it with a real client.