@super-line/server 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -16272,7 +16272,8 @@ __export(index_exports, {
16272
16272
  Conn: () => Conn,
16273
16273
  MemoryBus: () => MemoryBus,
16274
16274
  createInMemoryAdapter: () => createInMemoryAdapter,
16275
- createSuperLineServer: () => createSuperLineServer
16275
+ createSuperLineServer: () => createSuperLineServer,
16276
+ resolvePrincipal: () => resolvePrincipal
16276
16277
  });
16277
16278
  module.exports = __toCommonJS(index_exports);
16278
16279
  var import_node_crypto = require("crypto");
@@ -16300,6 +16301,8 @@ var Conn = class {
16300
16301
  data = {};
16301
16302
  /** The client↔server transport (wire) this connection was accepted on (set by the server at accept). */
16302
16303
  transport;
16304
+ /** ACL identity for stores: `identify(conn) ?? conn.id`, set by the server at accept (always defined there). */
16305
+ principal;
16303
16306
  /** When this connection was accepted (`Date.now()`). */
16304
16307
  connectedAt = Date.now();
16305
16308
  /** When the server last sent a heartbeat ping to this connection (managed by the server). */
@@ -16332,6 +16335,9 @@ var Conn = class {
16332
16335
  this.raw.terminate();
16333
16336
  }
16334
16337
  };
16338
+ function resolvePrincipal(conn, identify) {
16339
+ return identify?.(conn) ?? conn.id;
16340
+ }
16335
16341
 
16336
16342
  // src/memory-adapter.ts
16337
16343
  var MemoryBus = class {
@@ -16448,6 +16454,8 @@ var CONN = "c:";
16448
16454
  var USER = "u:";
16449
16455
  var REPLY = "reply:";
16450
16456
  var INSPECT = "i:";
16457
+ var STORE = "s:";
16458
+ var SERVER_ORIGIN = "server";
16451
16459
  function createSuperLineServer(contract, opts) {
16452
16460
  const c = contract;
16453
16461
  const serializer = opts.serializer ?? import_core2.jsonSerializer;
@@ -16456,6 +16464,7 @@ function createSuperLineServer(contract, opts) {
16456
16464
  const inspectorRedact = new Set(
16457
16465
  opts.inspector && typeof opts.inspector === "object" ? opts.inspector.redact ?? [] : []
16458
16466
  );
16467
+ const storeMap = opts.stores ?? {};
16459
16468
  const conns = /* @__PURE__ */ new Set();
16460
16469
  const inspectorConns = /* @__PURE__ */ new Set();
16461
16470
  const members = /* @__PURE__ */ new Map();
@@ -16465,6 +16474,7 @@ function createSuperLineServer(contract, opts) {
16465
16474
  const replyChannel = REPLY + instanceId;
16466
16475
  let impl = {};
16467
16476
  let closing = false;
16477
+ let relaying = false;
16468
16478
  let nextSReq = 1;
16469
16479
  const originWaiters = /* @__PURE__ */ new Map();
16470
16480
  const ownerRouting = /* @__PURE__ */ new Map();
@@ -16497,6 +16507,10 @@ function createSuperLineServer(contract, opts) {
16497
16507
  handlePersonal(channel, payload);
16498
16508
  return;
16499
16509
  }
16510
+ if (channel.startsWith(STORE)) {
16511
+ handleStoreRelay(channel, payload);
16512
+ return;
16513
+ }
16500
16514
  const set = members.get(channel);
16501
16515
  if (set) for (const conn of set) conn.sendRaw(payload);
16502
16516
  const busSet = busListeners.get(channel);
@@ -16771,6 +16785,7 @@ function createSuperLineServer(contract, opts) {
16771
16785
  inspectorEnabled ? (event, data) => emitInspectorEvent({ type: "msg.event", target: connId, name: event, data: safeSnapshot(data) }) : void 0
16772
16786
  );
16773
16787
  conn.transport = auth.transport;
16788
+ conn.principal = resolvePrincipal(conn, opts.identify);
16774
16789
  raw.onMessage((bytes) => {
16775
16790
  void onMessage(conn, bytes);
16776
16791
  });
@@ -16820,7 +16835,11 @@ function createSuperLineServer(contract, opts) {
16820
16835
  else if (frame.t === "unsub") {
16821
16836
  const ns = topicNamespace(conn.role, frame.c);
16822
16837
  if (ns) leaveChannel(conn, TOPIC + ns + ":" + frame.c);
16823
- } else if (frame.t === "sres") {
16838
+ } else if (frame.t === "sopen") await handleStoreOpen(conn, frame);
16839
+ else if (frame.t === "srd") await handleStoreRead(conn, frame);
16840
+ else if (frame.t === "swr") await handleStoreWrite(conn, frame);
16841
+ else if (frame.t === "sclose") handleStoreClose(conn, frame);
16842
+ else if (frame.t === "sres") {
16824
16843
  await handleClientReply(conn, frame.i, { ok: true, d: frame.d });
16825
16844
  } else if (frame.t === "serr") {
16826
16845
  await handleClientReply(conn, frame.i, { ok: false, code: frame.code, m: frame.m, d: frame.d });
@@ -16909,6 +16928,100 @@ function createSuperLineServer(contract, opts) {
16909
16928
  conn.send({ t: "res", i: frame.i, d: null });
16910
16929
  });
16911
16930
  }
16931
+ function storeOrErr(conn, id, name) {
16932
+ const store = storeMap[name];
16933
+ if (!store) conn.send({ t: "err", i: id, code: "NOT_FOUND", m: `Unknown store: ${name}` });
16934
+ return store;
16935
+ }
16936
+ async function handleStoreOpen(conn, frame) {
16937
+ const store = storeOrErr(conn, frame.i, frame.n);
16938
+ if (!store) return;
16939
+ await dispatchOp(conn, frame.i, { kind: "subscribe", name: `store:${frame.n}/${frame.id}`, conn }, async () => {
16940
+ const resource = await store.read(frame.id);
16941
+ if (!resource) throw new import_core2.SuperLineError("NOT_FOUND", `No resource: ${frame.n}/${frame.id}`);
16942
+ const principal = conn.principal ?? conn.id;
16943
+ if (!resource.accessRules[principal]?.read)
16944
+ throw new import_core2.SuperLineError("FORBIDDEN", `Read denied: ${frame.n}/${frame.id}`);
16945
+ await joinChannel(conn, STORE + frame.n + ":" + frame.id);
16946
+ if (inspectorEnabled) emitInspectorEvent({ type: "store.subscribe", connId: conn.id, store: frame.n, id: frame.id });
16947
+ conn.send({ t: "res", i: frame.i, d: resource.data });
16948
+ });
16949
+ }
16950
+ async function handleStoreRead(conn, frame) {
16951
+ const store = storeOrErr(conn, frame.i, frame.n);
16952
+ if (!store) return;
16953
+ await dispatchOp(conn, frame.i, { kind: "request", name: `store:${frame.n}/${frame.id}`, conn }, async () => {
16954
+ const resource = await store.read(frame.id);
16955
+ if (!resource) throw new import_core2.SuperLineError("NOT_FOUND", `No resource: ${frame.n}/${frame.id}`);
16956
+ const principal = conn.principal ?? conn.id;
16957
+ if (!resource.accessRules[principal]?.read)
16958
+ throw new import_core2.SuperLineError("FORBIDDEN", `Read denied: ${frame.n}/${frame.id}`);
16959
+ conn.send({ t: "res", i: frame.i, d: resource.data });
16960
+ });
16961
+ }
16962
+ async function handleStoreWrite(conn, frame) {
16963
+ const store = storeOrErr(conn, frame.i, frame.n);
16964
+ if (!store) return;
16965
+ await dispatchOp(conn, frame.i, { kind: "request", name: `store:${frame.n}/${frame.id}`, conn }, async () => {
16966
+ const resource = await store.read(frame.id);
16967
+ if (!resource) throw new import_core2.SuperLineError("NOT_FOUND", `No resource: ${frame.n}/${frame.id}`);
16968
+ const principal = conn.principal ?? conn.id;
16969
+ if (!resource.accessRules[principal]?.write)
16970
+ throw new import_core2.SuperLineError("FORBIDDEN", `Write denied: ${frame.n}/${frame.id}`);
16971
+ await store.apply({ id: frame.id, update: frame.u, origin: frame.o });
16972
+ if (inspectorEnabled)
16973
+ emitInspectorEvent({
16974
+ type: "store.write",
16975
+ store: frame.n,
16976
+ id: frame.id,
16977
+ origin: frame.o,
16978
+ connId: conn.id,
16979
+ data: safeSnapshot(frame.u)
16980
+ });
16981
+ conn.send({ t: "res", i: frame.i, d: null });
16982
+ });
16983
+ }
16984
+ function handleStoreClose(conn, frame) {
16985
+ if (!storeMap[frame.n]) return;
16986
+ leaveChannel(conn, STORE + frame.n + ":" + frame.id);
16987
+ if (inspectorEnabled) emitInspectorEvent({ type: "store.unsubscribe", connId: conn.id, store: frame.n, id: frame.id });
16988
+ }
16989
+ for (const [name, store] of Object.entries(storeMap)) {
16990
+ store.onChange((change) => {
16991
+ if (relaying) return;
16992
+ void adapter.publish(
16993
+ STORE + name + ":" + change.id,
16994
+ serializer.encode({
16995
+ t: "sch",
16996
+ n: name,
16997
+ id: change.id,
16998
+ u: change.update,
16999
+ o: change.origin,
17000
+ nd: instanceId
17001
+ })
17002
+ );
17003
+ });
17004
+ }
17005
+ function handleStoreRelay(channel, payload) {
17006
+ const set = members.get(channel);
17007
+ if (set) for (const conn of set) conn.sendRaw(payload);
17008
+ let frame;
17009
+ try {
17010
+ frame = serializer.decode(payload);
17011
+ } catch {
17012
+ return;
17013
+ }
17014
+ if (frame.nd === instanceId) return;
17015
+ const store = storeMap[frame.n];
17016
+ if (!store || store.clustering !== "relay") return;
17017
+ relaying = true;
17018
+ try {
17019
+ void store.apply({ id: frame.id, update: frame.u, origin: frame.o });
17020
+ } catch {
17021
+ } finally {
17022
+ relaying = false;
17023
+ }
17024
+ }
16912
17025
  function room(name) {
16913
17026
  const channel = ROOM + name;
16914
17027
  return {
@@ -17013,6 +17126,68 @@ function createSuperLineServer(contract, opts) {
17013
17126
  void adapter.publish(CONN + id, serializer.encode(env));
17014
17127
  });
17015
17128
  }
17129
+ const storeApi = {};
17130
+ for (const [name, store] of Object.entries(storeMap)) {
17131
+ const readOrThrow = async (id) => {
17132
+ const r = await store.read(id);
17133
+ if (!r) throw new import_core2.SuperLineError("NOT_FOUND", `No resource: ${name}/${id}`);
17134
+ return r;
17135
+ };
17136
+ storeApi[name] = {
17137
+ async create(id, data, accessRules) {
17138
+ await store.create(id, data, accessRules);
17139
+ },
17140
+ read(id) {
17141
+ return Promise.resolve(store.read(id));
17142
+ },
17143
+ async write(id, data) {
17144
+ await store.apply({ id, update: data, origin: SERVER_ORIGIN });
17145
+ if (inspectorEnabled)
17146
+ emitInspectorEvent({ type: "store.write", store: name, id, origin: SERVER_ORIGIN, data: safeSnapshot(data) });
17147
+ },
17148
+ async grant(id, principal, perms) {
17149
+ const r = await readOrThrow(id);
17150
+ await store.setAccess(id, { ...r.accessRules, [principal]: perms });
17151
+ if (inspectorEnabled) emitInspectorEvent({ type: "store.grant", store: name, id, principal, perms });
17152
+ },
17153
+ async revoke(id, principal) {
17154
+ const r = await readOrThrow(id);
17155
+ const next = { ...r.accessRules };
17156
+ delete next[principal];
17157
+ await store.setAccess(id, next);
17158
+ if (inspectorEnabled) emitInspectorEvent({ type: "store.revoke", store: name, id, principal });
17159
+ },
17160
+ async delete(id) {
17161
+ await store.delete(id);
17162
+ },
17163
+ list() {
17164
+ return Promise.resolve(store.list());
17165
+ },
17166
+ open(id, openOpts) {
17167
+ if (!store.open)
17168
+ throw new import_core2.SuperLineError("UNSUPPORTED", `Store ${name} does not support reactive open()`);
17169
+ const replica = store.open(id, openOpts);
17170
+ if (!inspectorEnabled) return replica;
17171
+ const origin = openOpts?.origin ?? SERVER_ORIGIN;
17172
+ const emit = (data) => emitInspectorEvent({ type: "store.write", store: name, id, origin, data: safeSnapshot(data) });
17173
+ return {
17174
+ ...replica,
17175
+ set: (d) => {
17176
+ replica.set(d);
17177
+ emit(d);
17178
+ },
17179
+ update: (p) => {
17180
+ replica.update(p);
17181
+ emit(p);
17182
+ },
17183
+ delete: (path) => {
17184
+ replica.delete(path);
17185
+ emit({ delete: path });
17186
+ }
17187
+ };
17188
+ }
17189
+ };
17190
+ }
17016
17191
  const api = {
17017
17192
  nodeId: instanceId,
17018
17193
  nodeName,
@@ -17094,6 +17269,11 @@ function createSuperLineServer(contract, opts) {
17094
17269
  }
17095
17270
  };
17096
17271
  },
17272
+ store(name) {
17273
+ const handle = storeApi[name];
17274
+ if (!handle) throw new import_core2.SuperLineError("NOT_FOUND", `Store not configured: ${name}`);
17275
+ return handle;
17276
+ },
17097
17277
  async close() {
17098
17278
  if (closing) return;
17099
17279
  closing = true;
@@ -17112,5 +17292,6 @@ function createSuperLineServer(contract, opts) {
17112
17292
  Conn,
17113
17293
  MemoryBus,
17114
17294
  createInMemoryAdapter,
17115
- createSuperLineServer
17295
+ createSuperLineServer,
17296
+ resolvePrincipal
17116
17297
  });
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { ServerMessageDef, RawConn, Serializer, ServerFrame, EmitData, ConnDescriptor, Adapter, PresenceStore, Contract, RoleOf, NodeStat, SharedEvents, SharedServerRequests, ClientInput, Output, SharedRequests, ServerInput, AnyData, RoleRequests, Events, DataOf, RoleTopics, SharedTopics, EventData, ServerTransport, Handshake } from '@super-line/core';
1
+ import { ServerMessageDef, RawConn, Serializer, ServerFrame, EmitData, ConnDescriptor, Adapter, PresenceStore, Contract, RoleOf, NodeStat, SharedEvents, SharedServerRequests, ClientInput, Output, SharedRequests, ServerInput, AnyData, RoleRequests, Events, DataOf, RoleTopics, AccessRules, Resource, Perms, ServerReplica, SharedTopics, EventData, ServerTransport, Handshake, ServerStore } from '@super-line/core';
2
2
 
3
3
  /**
4
4
  * A single client connection, passed to handlers as the third argument.
@@ -26,6 +26,8 @@ declare class Conn<Ev = Record<string, ServerMessageDef>, Ctx = unknown, Role ex
26
26
  data: Data;
27
27
  /** The client↔server transport (wire) this connection was accepted on (set by the server at accept). */
28
28
  transport?: string;
29
+ /** ACL identity for stores: `identify(conn) ?? conn.id`, set by the server at accept (always defined there). */
30
+ principal?: string;
29
31
  /** When this connection was accepted (`Date.now()`). */
30
32
  readonly connectedAt: number;
31
33
  /** When the server last sent a heartbeat ping to this connection (managed by the server). */
@@ -56,6 +58,12 @@ declare class Conn<Ev = Record<string, ServerMessageDef>, Ctx = unknown, Role ex
56
58
  /** Hard close with no handshake — used by heartbeat reaping. */
57
59
  terminate(): void;
58
60
  }
61
+ /**
62
+ * The ACL principal for a connection: the stable `identify` key when configured, else the
63
+ * (random, per-connection) `conn.id`. Always returns a string — store access rules never key
64
+ * on `undefined`. Distinct from `identify`'s raw output (which may be undefined, used for presence).
65
+ */
66
+ declare function resolvePrincipal(conn: Conn, identify?: (conn: Conn) => string | undefined): string;
59
67
 
60
68
  /**
61
69
  * In-process pub/sub bus. Share one bus across multiple servers to simulate
@@ -210,6 +218,36 @@ interface RoleLens<C extends Contract, R extends RoleOf<C>> {
210
218
  /** Publish to a topic in role `R`'s surface (reaches that role's subscribers). */
211
219
  publish<T extends keyof RoleTopics<C, R>>(topic: T, data: EmitData<RoleTopics<C, R>[T]>): void;
212
220
  }
221
+ /**
222
+ * Server-side handle for one configured Store, reached via `srv.store.<name>`. The server is
223
+ * authoritative: it creates Resources, grants/revokes access, and may co-write. `data` is untyped
224
+ * (stores are off-contract — see ADR-0003); callers assert the shape.
225
+ */
226
+ interface ServerStoreHandle {
227
+ /** Create a Resource with initial data + access rules (deny-by-default for everyone unlisted). */
228
+ create(id: string, data: unknown, accessRules: AccessRules): Promise<void>;
229
+ /** Read a Resource (data + accessRules), or undefined if absent. */
230
+ read(id: string): Promise<Resource | undefined>;
231
+ /** Server co-write: replace the Resource's value (LWW), fanned out to subscribers with a `server` origin. */
232
+ write(id: string, data: unknown): Promise<void>;
233
+ /** Grant a principal read/write on a Resource. */
234
+ grant(id: string, principal: string, perms: Perms): Promise<void>;
235
+ /** Revoke a principal's access to a Resource entirely. */
236
+ revoke(id: string, principal: string): Promise<void>;
237
+ /** Delete a Resource. */
238
+ delete(id: string): Promise<void>;
239
+ /** All Resource ids in this store. */
240
+ list(): Promise<string[]>;
241
+ /**
242
+ * Open a reactive in-process co-writer over a Resource's canonical state — the server half's mirror of
243
+ * `client.store(name).open(id)`. Reactive reads, merging `update`, and surgical `delete(path)` (the only
244
+ * way to remove a key server-side), all server-authoritative and fanned out to subscribers. `origin`
245
+ * (default `"server"`) tags its Changes for inspector attribution. Throws if the store has no `open`.
246
+ */
247
+ open(id: string, opts?: {
248
+ origin?: string;
249
+ }): ServerReplica;
250
+ }
213
251
  /** Options for {@link createSuperLineServer}. */
214
252
  interface SuperLineServerOptions<C extends Contract, A extends AuthResult<C>> {
215
253
  /** Client↔server transports to accept connections on (e.g. `webSocketServerTransport({ server })`). */
@@ -251,6 +289,12 @@ interface SuperLineServerOptions<C extends Contract, A extends AuthResult<C>> {
251
289
  inspector?: boolean | {
252
290
  redact?: string[];
253
291
  };
292
+ /**
293
+ * Pluggable persisted-state Stores, keyed by name (`{ scene: crdtStoreServer(), config: memoryStoreServer() }`).
294
+ * Each is the server half of a Store pair; the client passes the matching client halves. Surfaced as
295
+ * `srv.store.<name>` and `client.store.<name>`. Stores are off-contract and untyped (ADR-0003).
296
+ */
297
+ stores?: Record<string, ServerStore>;
254
298
  /** Called once per accepted connection. */
255
299
  onConnection?: (conn: Conn, ctx: CtxUnion<A>) => void;
256
300
  /** Called when a connection closes, with the WebSocket close `code`. */
@@ -289,6 +333,8 @@ interface SuperLineServer<C extends Contract, A extends AuthResult<C>> {
289
333
  subscribe<T extends keyof SharedTopics<C>>(topic: T, handler: (data: EventData<SharedTopics<C>[T]>, meta: BusMeta) => void): () => void;
290
334
  /** Lens for role-scoped sends, e.g. forRole('user').publish('feed', data). */
291
335
  forRole<R extends RoleOf<C>>(role: R): RoleLens<C, R>;
336
+ /** Server-authoritative handle for a configured Store (`srv.store('scene').create(...)`). Throws `NOT_FOUND` if the name isn't configured. */
337
+ store(name: string): ServerStoreHandle;
292
338
  close(): Promise<void>;
293
339
  }
294
340
  /**
@@ -315,4 +361,4 @@ interface SuperLineServer<C extends Contract, A extends AuthResult<C>> {
315
361
  */
316
362
  declare function createSuperLineServer<C extends Contract, A extends AuthResult<C>>(contract: C, opts: SuperLineServerOptions<C, A>): SuperLineServer<C, A>;
317
363
 
318
- export { type AuthResult, type BusMeta, type ClusterView, Conn, type ConnTarget, type Handlers, type LocalView, MemoryBus, type Middleware, type MiddlewareInfo, type RoleLens, type Room, type SuperLineServer, type SuperLineServerOptions, type UserTarget, createInMemoryAdapter, createSuperLineServer };
364
+ export { type AuthResult, type BusMeta, type ClusterView, Conn, type ConnTarget, type Handlers, type LocalView, MemoryBus, type Middleware, type MiddlewareInfo, type RoleLens, type Room, type ServerStoreHandle, type SuperLineServer, type SuperLineServerOptions, type UserTarget, createInMemoryAdapter, createSuperLineServer, resolvePrincipal };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ServerMessageDef, RawConn, Serializer, ServerFrame, EmitData, ConnDescriptor, Adapter, PresenceStore, Contract, RoleOf, NodeStat, SharedEvents, SharedServerRequests, ClientInput, Output, SharedRequests, ServerInput, AnyData, RoleRequests, Events, DataOf, RoleTopics, SharedTopics, EventData, ServerTransport, Handshake } from '@super-line/core';
1
+ import { ServerMessageDef, RawConn, Serializer, ServerFrame, EmitData, ConnDescriptor, Adapter, PresenceStore, Contract, RoleOf, NodeStat, SharedEvents, SharedServerRequests, ClientInput, Output, SharedRequests, ServerInput, AnyData, RoleRequests, Events, DataOf, RoleTopics, AccessRules, Resource, Perms, ServerReplica, SharedTopics, EventData, ServerTransport, Handshake, ServerStore } from '@super-line/core';
2
2
 
3
3
  /**
4
4
  * A single client connection, passed to handlers as the third argument.
@@ -26,6 +26,8 @@ declare class Conn<Ev = Record<string, ServerMessageDef>, Ctx = unknown, Role ex
26
26
  data: Data;
27
27
  /** The client↔server transport (wire) this connection was accepted on (set by the server at accept). */
28
28
  transport?: string;
29
+ /** ACL identity for stores: `identify(conn) ?? conn.id`, set by the server at accept (always defined there). */
30
+ principal?: string;
29
31
  /** When this connection was accepted (`Date.now()`). */
30
32
  readonly connectedAt: number;
31
33
  /** When the server last sent a heartbeat ping to this connection (managed by the server). */
@@ -56,6 +58,12 @@ declare class Conn<Ev = Record<string, ServerMessageDef>, Ctx = unknown, Role ex
56
58
  /** Hard close with no handshake — used by heartbeat reaping. */
57
59
  terminate(): void;
58
60
  }
61
+ /**
62
+ * The ACL principal for a connection: the stable `identify` key when configured, else the
63
+ * (random, per-connection) `conn.id`. Always returns a string — store access rules never key
64
+ * on `undefined`. Distinct from `identify`'s raw output (which may be undefined, used for presence).
65
+ */
66
+ declare function resolvePrincipal(conn: Conn, identify?: (conn: Conn) => string | undefined): string;
59
67
 
60
68
  /**
61
69
  * In-process pub/sub bus. Share one bus across multiple servers to simulate
@@ -210,6 +218,36 @@ interface RoleLens<C extends Contract, R extends RoleOf<C>> {
210
218
  /** Publish to a topic in role `R`'s surface (reaches that role's subscribers). */
211
219
  publish<T extends keyof RoleTopics<C, R>>(topic: T, data: EmitData<RoleTopics<C, R>[T]>): void;
212
220
  }
221
+ /**
222
+ * Server-side handle for one configured Store, reached via `srv.store.<name>`. The server is
223
+ * authoritative: it creates Resources, grants/revokes access, and may co-write. `data` is untyped
224
+ * (stores are off-contract — see ADR-0003); callers assert the shape.
225
+ */
226
+ interface ServerStoreHandle {
227
+ /** Create a Resource with initial data + access rules (deny-by-default for everyone unlisted). */
228
+ create(id: string, data: unknown, accessRules: AccessRules): Promise<void>;
229
+ /** Read a Resource (data + accessRules), or undefined if absent. */
230
+ read(id: string): Promise<Resource | undefined>;
231
+ /** Server co-write: replace the Resource's value (LWW), fanned out to subscribers with a `server` origin. */
232
+ write(id: string, data: unknown): Promise<void>;
233
+ /** Grant a principal read/write on a Resource. */
234
+ grant(id: string, principal: string, perms: Perms): Promise<void>;
235
+ /** Revoke a principal's access to a Resource entirely. */
236
+ revoke(id: string, principal: string): Promise<void>;
237
+ /** Delete a Resource. */
238
+ delete(id: string): Promise<void>;
239
+ /** All Resource ids in this store. */
240
+ list(): Promise<string[]>;
241
+ /**
242
+ * Open a reactive in-process co-writer over a Resource's canonical state — the server half's mirror of
243
+ * `client.store(name).open(id)`. Reactive reads, merging `update`, and surgical `delete(path)` (the only
244
+ * way to remove a key server-side), all server-authoritative and fanned out to subscribers. `origin`
245
+ * (default `"server"`) tags its Changes for inspector attribution. Throws if the store has no `open`.
246
+ */
247
+ open(id: string, opts?: {
248
+ origin?: string;
249
+ }): ServerReplica;
250
+ }
213
251
  /** Options for {@link createSuperLineServer}. */
214
252
  interface SuperLineServerOptions<C extends Contract, A extends AuthResult<C>> {
215
253
  /** Client↔server transports to accept connections on (e.g. `webSocketServerTransport({ server })`). */
@@ -251,6 +289,12 @@ interface SuperLineServerOptions<C extends Contract, A extends AuthResult<C>> {
251
289
  inspector?: boolean | {
252
290
  redact?: string[];
253
291
  };
292
+ /**
293
+ * Pluggable persisted-state Stores, keyed by name (`{ scene: crdtStoreServer(), config: memoryStoreServer() }`).
294
+ * Each is the server half of a Store pair; the client passes the matching client halves. Surfaced as
295
+ * `srv.store.<name>` and `client.store.<name>`. Stores are off-contract and untyped (ADR-0003).
296
+ */
297
+ stores?: Record<string, ServerStore>;
254
298
  /** Called once per accepted connection. */
255
299
  onConnection?: (conn: Conn, ctx: CtxUnion<A>) => void;
256
300
  /** Called when a connection closes, with the WebSocket close `code`. */
@@ -289,6 +333,8 @@ interface SuperLineServer<C extends Contract, A extends AuthResult<C>> {
289
333
  subscribe<T extends keyof SharedTopics<C>>(topic: T, handler: (data: EventData<SharedTopics<C>[T]>, meta: BusMeta) => void): () => void;
290
334
  /** Lens for role-scoped sends, e.g. forRole('user').publish('feed', data). */
291
335
  forRole<R extends RoleOf<C>>(role: R): RoleLens<C, R>;
336
+ /** Server-authoritative handle for a configured Store (`srv.store('scene').create(...)`). Throws `NOT_FOUND` if the name isn't configured. */
337
+ store(name: string): ServerStoreHandle;
292
338
  close(): Promise<void>;
293
339
  }
294
340
  /**
@@ -315,4 +361,4 @@ interface SuperLineServer<C extends Contract, A extends AuthResult<C>> {
315
361
  */
316
362
  declare function createSuperLineServer<C extends Contract, A extends AuthResult<C>>(contract: C, opts: SuperLineServerOptions<C, A>): SuperLineServer<C, A>;
317
363
 
318
- export { type AuthResult, type BusMeta, type ClusterView, Conn, type ConnTarget, type Handlers, type LocalView, MemoryBus, type Middleware, type MiddlewareInfo, type RoleLens, type Room, type SuperLineServer, type SuperLineServerOptions, type UserTarget, createInMemoryAdapter, createSuperLineServer };
364
+ export { type AuthResult, type BusMeta, type ClusterView, Conn, type ConnTarget, type Handlers, type LocalView, MemoryBus, type Middleware, type MiddlewareInfo, type RoleLens, type Room, type ServerStoreHandle, type SuperLineServer, type SuperLineServerOptions, type UserTarget, createInMemoryAdapter, createSuperLineServer, resolvePrincipal };
package/dist/index.js CHANGED
@@ -32,6 +32,8 @@ var Conn = class {
32
32
  data = {};
33
33
  /** The client↔server transport (wire) this connection was accepted on (set by the server at accept). */
34
34
  transport;
35
+ /** ACL identity for stores: `identify(conn) ?? conn.id`, set by the server at accept (always defined there). */
36
+ principal;
35
37
  /** When this connection was accepted (`Date.now()`). */
36
38
  connectedAt = Date.now();
37
39
  /** When the server last sent a heartbeat ping to this connection (managed by the server). */
@@ -64,6 +66,9 @@ var Conn = class {
64
66
  this.raw.terminate();
65
67
  }
66
68
  };
69
+ function resolvePrincipal(conn, identify) {
70
+ return identify?.(conn) ?? conn.id;
71
+ }
67
72
 
68
73
  // src/memory-adapter.ts
69
74
  var MemoryBus = class {
@@ -180,6 +185,8 @@ var CONN = "c:";
180
185
  var USER = "u:";
181
186
  var REPLY = "reply:";
182
187
  var INSPECT = "i:";
188
+ var STORE = "s:";
189
+ var SERVER_ORIGIN = "server";
183
190
  function createSuperLineServer(contract, opts) {
184
191
  const c = contract;
185
192
  const serializer = opts.serializer ?? jsonSerializer;
@@ -188,6 +195,7 @@ function createSuperLineServer(contract, opts) {
188
195
  const inspectorRedact = new Set(
189
196
  opts.inspector && typeof opts.inspector === "object" ? opts.inspector.redact ?? [] : []
190
197
  );
198
+ const storeMap = opts.stores ?? {};
191
199
  const conns = /* @__PURE__ */ new Set();
192
200
  const inspectorConns = /* @__PURE__ */ new Set();
193
201
  const members = /* @__PURE__ */ new Map();
@@ -197,6 +205,7 @@ function createSuperLineServer(contract, opts) {
197
205
  const replyChannel = REPLY + instanceId;
198
206
  let impl = {};
199
207
  let closing = false;
208
+ let relaying = false;
200
209
  let nextSReq = 1;
201
210
  const originWaiters = /* @__PURE__ */ new Map();
202
211
  const ownerRouting = /* @__PURE__ */ new Map();
@@ -229,6 +238,10 @@ function createSuperLineServer(contract, opts) {
229
238
  handlePersonal(channel, payload);
230
239
  return;
231
240
  }
241
+ if (channel.startsWith(STORE)) {
242
+ handleStoreRelay(channel, payload);
243
+ return;
244
+ }
232
245
  const set = members.get(channel);
233
246
  if (set) for (const conn of set) conn.sendRaw(payload);
234
247
  const busSet = busListeners.get(channel);
@@ -503,6 +516,7 @@ function createSuperLineServer(contract, opts) {
503
516
  inspectorEnabled ? (event, data) => emitInspectorEvent({ type: "msg.event", target: connId, name: event, data: safeSnapshot(data) }) : void 0
504
517
  );
505
518
  conn.transport = auth.transport;
519
+ conn.principal = resolvePrincipal(conn, opts.identify);
506
520
  raw.onMessage((bytes) => {
507
521
  void onMessage(conn, bytes);
508
522
  });
@@ -552,7 +566,11 @@ function createSuperLineServer(contract, opts) {
552
566
  else if (frame.t === "unsub") {
553
567
  const ns = topicNamespace(conn.role, frame.c);
554
568
  if (ns) leaveChannel(conn, TOPIC + ns + ":" + frame.c);
555
- } else if (frame.t === "sres") {
569
+ } else if (frame.t === "sopen") await handleStoreOpen(conn, frame);
570
+ else if (frame.t === "srd") await handleStoreRead(conn, frame);
571
+ else if (frame.t === "swr") await handleStoreWrite(conn, frame);
572
+ else if (frame.t === "sclose") handleStoreClose(conn, frame);
573
+ else if (frame.t === "sres") {
556
574
  await handleClientReply(conn, frame.i, { ok: true, d: frame.d });
557
575
  } else if (frame.t === "serr") {
558
576
  await handleClientReply(conn, frame.i, { ok: false, code: frame.code, m: frame.m, d: frame.d });
@@ -641,6 +659,100 @@ function createSuperLineServer(contract, opts) {
641
659
  conn.send({ t: "res", i: frame.i, d: null });
642
660
  });
643
661
  }
662
+ function storeOrErr(conn, id, name) {
663
+ const store = storeMap[name];
664
+ if (!store) conn.send({ t: "err", i: id, code: "NOT_FOUND", m: `Unknown store: ${name}` });
665
+ return store;
666
+ }
667
+ async function handleStoreOpen(conn, frame) {
668
+ const store = storeOrErr(conn, frame.i, frame.n);
669
+ if (!store) return;
670
+ await dispatchOp(conn, frame.i, { kind: "subscribe", name: `store:${frame.n}/${frame.id}`, conn }, async () => {
671
+ const resource = await store.read(frame.id);
672
+ if (!resource) throw new SuperLineError("NOT_FOUND", `No resource: ${frame.n}/${frame.id}`);
673
+ const principal = conn.principal ?? conn.id;
674
+ if (!resource.accessRules[principal]?.read)
675
+ throw new SuperLineError("FORBIDDEN", `Read denied: ${frame.n}/${frame.id}`);
676
+ await joinChannel(conn, STORE + frame.n + ":" + frame.id);
677
+ if (inspectorEnabled) emitInspectorEvent({ type: "store.subscribe", connId: conn.id, store: frame.n, id: frame.id });
678
+ conn.send({ t: "res", i: frame.i, d: resource.data });
679
+ });
680
+ }
681
+ async function handleStoreRead(conn, frame) {
682
+ const store = storeOrErr(conn, frame.i, frame.n);
683
+ if (!store) return;
684
+ await dispatchOp(conn, frame.i, { kind: "request", name: `store:${frame.n}/${frame.id}`, conn }, async () => {
685
+ const resource = await store.read(frame.id);
686
+ if (!resource) throw new SuperLineError("NOT_FOUND", `No resource: ${frame.n}/${frame.id}`);
687
+ const principal = conn.principal ?? conn.id;
688
+ if (!resource.accessRules[principal]?.read)
689
+ throw new SuperLineError("FORBIDDEN", `Read denied: ${frame.n}/${frame.id}`);
690
+ conn.send({ t: "res", i: frame.i, d: resource.data });
691
+ });
692
+ }
693
+ async function handleStoreWrite(conn, frame) {
694
+ const store = storeOrErr(conn, frame.i, frame.n);
695
+ if (!store) return;
696
+ await dispatchOp(conn, frame.i, { kind: "request", name: `store:${frame.n}/${frame.id}`, conn }, async () => {
697
+ const resource = await store.read(frame.id);
698
+ if (!resource) throw new SuperLineError("NOT_FOUND", `No resource: ${frame.n}/${frame.id}`);
699
+ const principal = conn.principal ?? conn.id;
700
+ if (!resource.accessRules[principal]?.write)
701
+ throw new SuperLineError("FORBIDDEN", `Write denied: ${frame.n}/${frame.id}`);
702
+ await store.apply({ id: frame.id, update: frame.u, origin: frame.o });
703
+ if (inspectorEnabled)
704
+ emitInspectorEvent({
705
+ type: "store.write",
706
+ store: frame.n,
707
+ id: frame.id,
708
+ origin: frame.o,
709
+ connId: conn.id,
710
+ data: safeSnapshot(frame.u)
711
+ });
712
+ conn.send({ t: "res", i: frame.i, d: null });
713
+ });
714
+ }
715
+ function handleStoreClose(conn, frame) {
716
+ if (!storeMap[frame.n]) return;
717
+ leaveChannel(conn, STORE + frame.n + ":" + frame.id);
718
+ if (inspectorEnabled) emitInspectorEvent({ type: "store.unsubscribe", connId: conn.id, store: frame.n, id: frame.id });
719
+ }
720
+ for (const [name, store] of Object.entries(storeMap)) {
721
+ store.onChange((change) => {
722
+ if (relaying) return;
723
+ void adapter.publish(
724
+ STORE + name + ":" + change.id,
725
+ serializer.encode({
726
+ t: "sch",
727
+ n: name,
728
+ id: change.id,
729
+ u: change.update,
730
+ o: change.origin,
731
+ nd: instanceId
732
+ })
733
+ );
734
+ });
735
+ }
736
+ function handleStoreRelay(channel, payload) {
737
+ const set = members.get(channel);
738
+ if (set) for (const conn of set) conn.sendRaw(payload);
739
+ let frame;
740
+ try {
741
+ frame = serializer.decode(payload);
742
+ } catch {
743
+ return;
744
+ }
745
+ if (frame.nd === instanceId) return;
746
+ const store = storeMap[frame.n];
747
+ if (!store || store.clustering !== "relay") return;
748
+ relaying = true;
749
+ try {
750
+ void store.apply({ id: frame.id, update: frame.u, origin: frame.o });
751
+ } catch {
752
+ } finally {
753
+ relaying = false;
754
+ }
755
+ }
644
756
  function room(name) {
645
757
  const channel = ROOM + name;
646
758
  return {
@@ -745,6 +857,68 @@ function createSuperLineServer(contract, opts) {
745
857
  void adapter.publish(CONN + id, serializer.encode(env));
746
858
  });
747
859
  }
860
+ const storeApi = {};
861
+ for (const [name, store] of Object.entries(storeMap)) {
862
+ const readOrThrow = async (id) => {
863
+ const r = await store.read(id);
864
+ if (!r) throw new SuperLineError("NOT_FOUND", `No resource: ${name}/${id}`);
865
+ return r;
866
+ };
867
+ storeApi[name] = {
868
+ async create(id, data, accessRules) {
869
+ await store.create(id, data, accessRules);
870
+ },
871
+ read(id) {
872
+ return Promise.resolve(store.read(id));
873
+ },
874
+ async write(id, data) {
875
+ await store.apply({ id, update: data, origin: SERVER_ORIGIN });
876
+ if (inspectorEnabled)
877
+ emitInspectorEvent({ type: "store.write", store: name, id, origin: SERVER_ORIGIN, data: safeSnapshot(data) });
878
+ },
879
+ async grant(id, principal, perms) {
880
+ const r = await readOrThrow(id);
881
+ await store.setAccess(id, { ...r.accessRules, [principal]: perms });
882
+ if (inspectorEnabled) emitInspectorEvent({ type: "store.grant", store: name, id, principal, perms });
883
+ },
884
+ async revoke(id, principal) {
885
+ const r = await readOrThrow(id);
886
+ const next = { ...r.accessRules };
887
+ delete next[principal];
888
+ await store.setAccess(id, next);
889
+ if (inspectorEnabled) emitInspectorEvent({ type: "store.revoke", store: name, id, principal });
890
+ },
891
+ async delete(id) {
892
+ await store.delete(id);
893
+ },
894
+ list() {
895
+ return Promise.resolve(store.list());
896
+ },
897
+ open(id, openOpts) {
898
+ if (!store.open)
899
+ throw new SuperLineError("UNSUPPORTED", `Store ${name} does not support reactive open()`);
900
+ const replica = store.open(id, openOpts);
901
+ if (!inspectorEnabled) return replica;
902
+ const origin = openOpts?.origin ?? SERVER_ORIGIN;
903
+ const emit = (data) => emitInspectorEvent({ type: "store.write", store: name, id, origin, data: safeSnapshot(data) });
904
+ return {
905
+ ...replica,
906
+ set: (d) => {
907
+ replica.set(d);
908
+ emit(d);
909
+ },
910
+ update: (p) => {
911
+ replica.update(p);
912
+ emit(p);
913
+ },
914
+ delete: (path) => {
915
+ replica.delete(path);
916
+ emit({ delete: path });
917
+ }
918
+ };
919
+ }
920
+ };
921
+ }
748
922
  const api = {
749
923
  nodeId: instanceId,
750
924
  nodeName,
@@ -826,6 +1000,11 @@ function createSuperLineServer(contract, opts) {
826
1000
  }
827
1001
  };
828
1002
  },
1003
+ store(name) {
1004
+ const handle = storeApi[name];
1005
+ if (!handle) throw new SuperLineError("NOT_FOUND", `Store not configured: ${name}`);
1006
+ return handle;
1007
+ },
829
1008
  async close() {
830
1009
  if (closing) return;
831
1010
  closing = true;
@@ -843,5 +1022,6 @@ export {
843
1022
  Conn,
844
1023
  MemoryBus,
845
1024
  createInMemoryAdapter,
846
- createSuperLineServer
1025
+ createSuperLineServer,
1026
+ resolvePrincipal
847
1027
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@super-line/server",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Typesafe WebSocket server for super-line — rooms, topics, middleware, pluggable adapters.",
6
6
  "license": "MIT",
@@ -47,7 +47,7 @@
47
47
  "access": "public"
48
48
  },
49
49
  "dependencies": {
50
- "@super-line/core": "^0.4.0"
50
+ "@super-line/core": "^0.6.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@chainsafe/libp2p-noise": "^17.0.0",
@@ -68,14 +68,14 @@
68
68
  "zeromq": "^6.0.0",
69
69
  "zod": "^3.24.1",
70
70
  "zod-to-json-schema": "^3.25.2",
71
- "@super-line/adapter-zeromq": "0.4.0",
72
- "@super-line/adapter-libp2p": "0.4.0",
73
- "@super-line/client": "0.4.0",
74
- "@super-line/transport-http": "0.4.0",
75
- "@super-line/transport-loopback": "0.4.0",
76
- "@super-line/transport-websocket": "0.4.0",
77
- "@super-line/adapter-rabbitmq": "0.4.0",
78
- "@super-line/adapter-redis": "0.4.0"
71
+ "@super-line/adapter-zeromq": "0.5.0",
72
+ "@super-line/client": "0.6.0",
73
+ "@super-line/adapter-rabbitmq": "0.5.0",
74
+ "@super-line/adapter-libp2p": "0.5.0",
75
+ "@super-line/transport-websocket": "0.5.0",
76
+ "@super-line/transport-http": "0.5.0",
77
+ "@super-line/transport-loopback": "0.5.0",
78
+ "@super-line/adapter-redis": "0.5.0"
79
79
  },
80
80
  "optionalDependencies": {
81
81
  "@standard-community/standard-json": "^0.3.5"