Skip to content

Build a plugin ​

This is the hands-on companion to Plugins (the model and reference). We'll start from an empty object and grow it — one capability at a time — into a full server + client pair you could publish as @you/plugin-activity.

The running example is an activity plugin: it counts requests per role, aggregates the counts across a cluster, and exposes the totals through the contract. Every super-line plugin capability shows up along the way. Each step is runnable on its own — stop at whichever rung covers your need.

1 · The smallest plugin is a tap ​

A plugin is a plain object. The only required field is name. The cheapest useful field is onEvent — a node-local tap fired synchronously at every emit site (requests, responses, connects, room joins, topic subscribes…) with the TapEvent taxonomy.

ts
// activity.ts
import type { SuperLinePlugin } from '@super-line/server'

export function activity(): SuperLinePlugin {
  const requests = new Map<string, number>()   // role -> count

  return {
    name: 'activity',
    onEvent(e) {
      if (e.type === 'msg.request') {
        requests.set(e.role, (requests.get(e.role) ?? 0) + 1)
      }
    },
  }
}

Register it on the server — nothing else changes:

ts
import { activity } from './activity'

const srv = createSuperLineServer(api, {
  transports: [webSocketServerTransport({ server })],
  authenticate,
  plugins: [activity()],
})

That's a complete plugin. Two rules govern the tap:

  • The payload is a live reference, not a copy — read it, never mutate it. Mutating it corrupts the real traffic your handlers see.
  • A throwing tap is isolated — the error is routed to onError and the underlying request still succeeds. A tap can never break traffic.

The tap costs nothing when no plugin registers one. It is node-local: it only sees what happened on this process. Step 3 fixes that.

Which events fire?

TapEvent is the inspector's taxonomy — msg.request / msg.response / msg.event / msg.broadcast / msg.publish, connect / disconnect, room.add / room.remove, topic.sub / topic.unsub, plus the node-to-node msg.serverRequest / msg.serverReply. Narrow on e.type and the rest of the fields type-narrow with it.

2 · Add middleware & lifecycle ​

The server's use, onConnection, onDisconnect, and onError used to be singular — one function each, so two concerns couldn't share them. A plugin multiplexes into all of them: the host runs first, then every plugin in array order, each listener error-isolated.

ts
return {
  name: 'activity',
  onEvent(e) { /* … */ },

  onConnection: (conn) => console.log('joined', conn.id),
  onDisconnect: (conn, _ctx, code) => console.log('left', conn.id, code),

  use: [
    async (ctx, info, next) => {
      const t = performance.now()
      await next()                              // run the real handler
      console.log(info.name, `${(performance.now() - t).toFixed(1)}ms`)
    },
  ],
}

A throwing middleware keeps its meaning — it rejects the operation (return a SuperLineError code to shape the wire error). A throwing lifecycle hook is isolated and routed to onError. Middleware is the only interception seam a plugin gets — see what plugins don't do.

3 · Aggregate across the cluster with setup + a channel ​

The tap is node-local, so a two-node cluster has two half-counts. To build a cluster-wide view, pair the tap with a plugin channel — a private, cluster-wide pub/sub lane under a reserved x:<plugin>: prefix, fanned out over whatever adapter you run. Wire it in setup, which runs once at construction with the PluginContext and may return a dispose function called on server.close().

ts
export function activity(): SuperLinePlugin {
  const local = new Map<string, number>()          // this node's counts
  const cluster = new Map<string, Map<string, number>>()  // nodeId -> counts

  return {
    name: 'activity',
    onEvent(e) {
      if (e.type === 'msg.request') local.set(e.role, (local.get(e.role) ?? 0) + 1)
    },
    setup(ctx) {
      const ch = ctx.channel('counts')
      // publish our local tallies every few seconds
      const timer = setInterval(() => ch.publish({ node: ctx.nodeId, counts: [...local] }), 3000)
      // absorb every node's tallies (including our own echo)
      const off = ch.subscribe((data: any) => cluster.set(data.node, new Map(data.counts)))
      return () => { clearInterval(timer); off() }   // disposed on server.close()
    },
  }
}

cluster now holds a live, cluster-wide picture. Notice we didn't touch the contract, add a role, or require an app to configure anything — the channel is the plugin's own private lane.

4 · Expose it through the contract — the paired surface ​

To let clients ask for the totals, the plugin needs a request in the contract. A plugin can't inject typed surface on its own (the contract types can only hang off defineContract), so it ships a surface fragment the host merges — and in return, its handler keys are subtracted from implement()'s obligation at compile time.

ts
import { defineSurface } from '@super-line/core'
import type { SuperLinePlugin } from '@super-line/server'
import * as z from 'zod'

export const activitySurface = defineSurface({
  clientToServer: {
    'activity.totals': { input: z.object({}), output: z.record(z.string(), z.number()) },
  },
})

export function activity(): SuperLinePlugin<typeof activitySurface> {
  const cluster = new Map<string, Map<string, number>>()
  // …onEvent + setup exactly as in step 3, filling `cluster`…

  return {
    name: 'activity',
    // onEvent, setup: …
    handlers: (ctx) => ({
      'activity.totals': async () => {
        const totals: Record<string, number> = {}
        for (const counts of cluster.values())
          for (const [role, n] of counts) totals[role] = (totals[role] ?? 0) + n
        return totals
      },
    }),
  }
}

The host merges the fragment into a role, and implement() no longer requires activity.totals:

ts
import { mergeSurfaces } from '@super-line/core'
import { activity, activitySurface } from './activity'

const api = defineContract({
  roles: { user: mergeSurfaces(appSurface, activitySurface) },
})

const srv = createSuperLineServer(api, { transports, authenticate, plugins: [activity()] })

srv.implement({
  user: {
    say: async (t) => t,          // your own handlers
    // 'activity.totals' is NOT required here — the plugin owns it
  },
})

The type system enforces the pairing both ways: merge the surface but forget the plugin and implement still demands the key (compile error); implement it and mount the plugin and you get a compile error plus a runtime throw naming the key. Handling a key the contract never merged throws at construction — "did you forget to merge its surface?".

5 · Ship the client half ​

A plugin ships as a pair — a SuperLinePlugin for the server, an optional SuperLineClientPlugin for the client (same idea as transports). The client half is smaller: it grows the client's first lifecycle callbacks and can answer the library's server→client requests.

ts
// activity.client.ts
import type { SuperLineClientPlugin } from '@super-line/client'

export function activityClient(): SuperLineClientPlugin {
  return {
    name: 'activity',
    onConnect: () => console.log('activity: connected'),
    onReconnect: () => console.log('activity: resynced after a drop'),
    onDisconnect: (code) => console.log('activity: dropped', code),
    // implement: { 'activity.push': async (p) => { … } },  // if the surface had a serverToClient request
  }
}
ts
const client = createSuperLineClient(api, {
  transport: webSocketClientTransport({ url }),
  role: 'user',
  plugins: [activityClient()],
})

const totals = await client['activity.totals']({})   // the paired request, fully typed

The matching bare options live on the client too — onConnect / onDisconnect / onReconnect, plus a new onError(error, info) sink that catches any throw from a lifecycle hook (host or plugin; it logs to console by default). These are the client's first lifecycle hooks — before plugins, a reconnect was unobservable.

6 · Optional: contribute a collection ​

If the plugin needs durable, typed state, contribute a collection on the contract via a contract-fragment plugin, and lock it down with server-side policies. The fragment merges into the host's contract (so RowOf / client.collection(name) infer it end-to-end), and the collection is reachable as srv.collection(name) / client.collection(name) like any other — deny-by-default until the plugin's policy opens it. This is the @super-line/plugin-auth shape in miniature.

ts
import { defineContractPlugin } from '@super-line/core'
import * as z from 'zod'

// contract-time half: declare the collection (merges into the host contract)
export const activityContract = () =>
  defineContractPlugin('activity', {
    collections: { 'activity.daily': { schema: z.object({ id: z.string(), count: z.number() }), key: 'id' } },
  })

// server-time half: own its access — deny-by-default, so the host can't accidentally expose it
export const activityPlugin: SuperLinePlugin = {
  name: 'activity',
  policies: { 'activity.daily': { read: () => undefined, write: (principal) => principal === 'system' } },
  setup(ctx) {
    // ctx.server.collection('activity.daily').insert(...) — roll the day's totals on a timer…
  },
}

A collection name that collides with a host collection — or another plugin's — throws at construction.

CRDT collections take a guard-shaped policy instead ​

A CRDT document collection is opened by id, not queried, so its policy isn't a row filter — it's a guard: read/write return a plain boolean. Declare the collection with crdt instead of key, and contribute a CrdtCollectionPolicy instead of a CollectionPolicy:

ts
import type { CrdtCollectionPolicy, SuperLinePlugin } from '@super-line/server'

export const activityContract = () =>
  defineContractPlugin('activity', {
    collections: { 'activity.notes': { schema: noteSchema, crdt: { mode: 'document' } } },
  })

const notesPolicy: CrdtCollectionPolicy = {
  // (principal, id, snapshot, ctx) => boolean | Promise<boolean> — sees the post-merge doc, so access can be content-based
  read: (principal, id, snapshot, ctx) => isMember(principal, id),
  // (principal, id, ctx) => boolean | Promise<boolean> — no `create`: creation is server-authoritative (`srv.collection(n).create`)
  write: (principal, id, ctx) => isMember(principal, id),
}

export const activityPlugin: SuperLinePlugin = {
  name: 'activity',
  policies: { 'activity.notes': notesPolicy },
}

Row and CRDT policies live in the same policies map — a plugin can mix both shapes across its collections, keyed one per collection name. @super-line/plugin-chat's channel resources are the shipped reference: registering a resource kind contributes exactly this guard shape, resolving "is this doc attached to a channel you're a member of?" (see Attach collaborative resources to channels).

7 · Optional: a plugin-owned connection ​

The deepest seam: a plugin can own its own connection class — a reserved role the transport negotiates, dispatched against the plugin's own fixed contract (never merged into the app's), and observer-invisible (excluded from conns, presence, the heartbeat, and user lifecycle hooks). This is how a plugin attaches a side-channel — an admin console, a metrics scraper, or a live dashboard — without polluting the app's roles or presence.

ts
import { defineContract } from '@super-line/core'

const dashContract = defineContract({
  roles: {
    dash: {
      clientToServer: { 'dash.totals': { input: z.object({}), output: z.record(z.string(), z.number()) } },
      subscribe: { updates: { payload: z.record(z.string(), z.number()) } },   // a feed
    },
  },
})

return {
  name: 'activity',
  connection: {
    role: 'dash',                      // never one of the user contract's roles
    subprotocol: 'activity.dash.v1',   // the WS negotiates this; it short-circuits authenticate
    contract: dashContract,
    handlers: (ctx) => ({
      'dash.totals': async () => currentTotals(),
    }),
  },
  setup(ctx) {
    // push live updates: a subscribe to the `updates` topic bridges to this channel
    const updates = ctx.channel('updates')
    const timer = setInterval(() => {
      if (updates.subscribers > 0) updates.publish(currentTotals())
    }, 1000)
    return () => clearInterval(timer)
  },
}

A reserved connection's request goes to its handlers; a subscribe to one of its contract's topics is bridged to the plugin's ctx.channel(name) of the same name — so anything the plugin publishes there streams to attached clients, cluster-wide, wire-identical. The transport advertises the subprotocol only because the plugin declared it, so the server stays the single authority.

Publish only when something is listening ​

Every publish crosses the cluster whether or not anyone wants it, so a plugin that fans out observations should ask first. A channel reports its local subscriber count, and tells you when that count moves:

ts
updates.subscribers                              // how many subscribers this NODE has right now
updates.onSubscribersChanged((count) => { … })   // fires the moment one attaches or leaves

Reserved connections are observer-invisible — no lifecycle hook fires for them — so this is the only signal that your consumer arrived or left.

On a single node the count answers the question outright. Across a cluster it does not, and the difference matters: a client attaches to one node, but expects a feed the whole cluster produces, so every other node has to publish for it while its own count stays zero. Gossip that fact on a second channel — announce when your local count crosses zero, re-announce on a timer, and expire a peer's announcement if it goes quiet. Delivery is at-most-once, so a lost announcement must cost a node a few seconds of over-publishing, never permanent silence.

@super-line/plugin-inspector does exactly this, and it is worth knowing why: before it did, every node published every observed event unconditionally. Measured on a three-node libp2p mesh with no Control Center attached at all, that unwanted feed was 76% of all cross-node traffic — carried, decrypted, decoded and discarded on every node.

This is exactly what @super-line/plugin-inspector is: a tap that snapshots + redacts every event and publishes it on its events channel, plus a connection class (the superline.inspector.v1 subprotocol) that serves the Control Center. If you want a complete, real reference, read that package's src/index.ts.

8 · Test your plugin ​

Boot a real loopback server and drive it with a real client — you exercise the actual handshake, frames, and validation. No plugin-specific harness needed.

ts
import { afterEach, expect, test } from 'vitest'
import http from 'node:http'
import { createSuperLineServer } from '@super-line/server'
import { createSuperLineClient } from '@super-line/client'
import { webSocketServerTransport, webSocketClientTransport } from '@super-line/transport-websocket'
import { activity, activitySurface } from '../src/activity'

test('activity.totals counts requests', async () => {
  const api = defineContract({ roles: { user: mergeSurfaces(appSurface, activitySurface) } })
  const httpServer = http.createServer()
  const srv = createSuperLineServer(api, {
    transports: [webSocketServerTransport({ server: httpServer })],
    authenticate: () => ({ role: 'user', ctx: {} }),
    plugins: [activity()],
  })
  srv.implement({ user: { say: async (t) => t } })
  await new Promise<void>((r) => httpServer.listen(0, r))
  const { port } = httpServer.address() as import('node:net').AddressInfo

  const client = createSuperLineClient(api, {
    transport: webSocketClientTransport({ url: `ws://localhost:${port}` }),
    role: 'user',
    plugins: [/* activityClient() */],
  })
  await client.say({ text: 'hi' })
  const totals = await client['activity.totals']({})
  expect(totals.user).toBeGreaterThan(0)

  await client.close(); await srv.close(); httpServer.close()
})

See Testing for the full loopback-driven pattern.

9 · Package & publish ​

A plugin is just a package that exports the two factories. Mirror the conventions the built-in plugins use:

plugin-activity/
├─ package.json          # name: "@you/plugin-activity", type: "module"
├─ tsup.config.ts        # external: core, server, client — never bundle them
└─ src/
   ├─ index.ts           # export { activity, activityClient, activitySurface }
   ├─ activity.ts        # the server half (SuperLinePlugin)
   └─ activity.client.ts # the client half (SuperLineClientPlugin)
  • Depend on @super-line/core; keep @super-line/server and @super-line/client as peer dependencies (the app owns their versions), mirrored into devDependencies for your own build. Mark them external in tsup so they're never bundled.
  • Export the surface fragment alongside the factories so hosts can mergeSurfaces it.
  • Namespace your contributions — handler keys (activity.*), collection names (activity.*), and channel names — so they never collide with an app's. The x:<plugin>: channel prefix is applied for you; the rest is convention.

Every collision is caught, never silent:

CollisionWhere
Duplicate plugin namestartup throw
Two plugins (or host + plugin) claim a handler keycompile error where types reach; else startup / implement throw naming the key
Plugin collection name vs a host or plugin collectionstartup throw
Client implement key registered twicethrow naming the key

Where to go next ​

  • Plugins — the full model: every field, the PluginContext table, and what plugins deliberately don't do.
  • Compose & embed a super-line library — the defineSurface / mergeSurfaces discipline your paired surface builds on.

Released under the MIT License.