Skip to content

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

ts
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:

ts
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:

  1. Prefix room names too — rooms are runtime strings no helper can collision-check. conn.join(`lib:thread:${id}`), never conn.join(`thread:${id}`).
  2. Declare @super-line/* as peerDependencies — host and library must share one core instance.
  3. 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

ts
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 shared and every role sees the library; merge into one role and it's scoped. Scope it unless you're sure.
  • data stays yoursmergeSurfaces deliberately rejects role blocks: a role's data schema, 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. implement requires 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

ts
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 prefix

The 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/* in peerDependencies
  • [ ] Standalone entry point mounts the same fragments
  • [ ] Document the ctx shape your handlers need from the host's authenticate

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.

Released under the MIT License.