@vantage-ui/client 0.5.2 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0 — 2026-10-28
4
+
5
+ - Anonymous sessions (vantage-server 1.9.0, `session: { anonymous: true }`): `db.sessionToken` holds `welcome.session_token`, and `hello` resends it whenever `token` gives none, so a reconnect resumes the session and its grants. `createVantage({ session: "localStorage" })` keeps it across reloads and tabs (default `"memory"`). A 1008 `signed out` for an anonymous session drops its token and connects again for a new one, instead of halting.
6
+ - An invoke past its action's `rate_limit` (vantage-server 1.9.0) is an `error` frame with `kind: "rate_limited"`, `code: "rate_limited"` and `retry_after` (whole seconds); the types carry both.
7
+ - A `hello` refused as `rate_limited` (too many new sessions or failed tokens from one address) is not final: the client closes, stays `closed` with the message, and connects again after `retry_after` seconds. `MockServer.limitHellos(times, retryAfter)` answers hellos so.
8
+ - MockServer fixture `anonymousSessions`: tokenless or unknown `hello`s get a new session (`anon-1`, …); issued tokens resume.
9
+
3
10
  ## 0.5.2 — 2026-10-25
4
11
 
5
12
  - `vantage-client generate` leaves `--out` untouched when the content would be the same, and logs `unchanged <out> (schema <hash>)`.
@@ -35,6 +35,12 @@ export declare class ClientConnection extends ClientRuns {
35
35
  private settleReconnect;
36
36
  protected start(): Promise<void>;
37
37
  private connect;
38
+ /** Where `session: "localStorage"` keeps the anonymous session's token. */
39
+ private get sessionKey();
40
+ /** The anonymous session to resume: this client's, else the one localStorage kept. */
41
+ private savedSession;
42
+ /** Hold `token` as the anonymous session's (`null`: there is none), in localStorage too when asked. */
43
+ private keepSession;
38
44
  private hello;
39
45
  /**
40
46
  * Reopen every collection that is pinned or shown, and send the record
@@ -52,6 +58,12 @@ export declare class ClientConnection extends ClientRuns {
52
58
  * with `error`, writes queued offline included.
53
59
  */
54
60
  private halt;
61
+ /**
62
+ * The server refused a `hello` for now (`rate_limited`: too many new
63
+ * sessions or failed tokens from this address): not final. Close, stay
64
+ * `closed` with its message, and connect again once `seconds` have passed.
65
+ */
66
+ private retryAfter;
55
67
  private lost;
56
68
  private receive;
57
69
  private applyChange;
@@ -114,6 +114,39 @@ export class ClientConnection extends ClientRuns {
114
114
  close: (code, reason) => current() && this.lost(code, reason),
115
115
  });
116
116
  }
117
+ /** Where `session: "localStorage"` keeps the anonymous session's token. */
118
+ get sessionKey() {
119
+ return `vantage.session:${this.opts.url ?? "page"}`;
120
+ }
121
+ /** The anonymous session to resume: this client's, else the one localStorage kept. */
122
+ savedSession() {
123
+ const held = this.sessionToken.peek();
124
+ if (held !== null)
125
+ return held;
126
+ if (this.opts.session !== "localStorage")
127
+ return undefined;
128
+ try {
129
+ return globalThis.localStorage?.getItem(this.sessionKey) ?? undefined;
130
+ }
131
+ catch {
132
+ return undefined;
133
+ }
134
+ }
135
+ /** Hold `token` as the anonymous session's (`null`: there is none), in localStorage too when asked. */
136
+ keepSession(token) {
137
+ this.sessionToken.set(token);
138
+ if (this.opts.session !== "localStorage")
139
+ return;
140
+ try {
141
+ if (token === null)
142
+ globalThis.localStorage?.removeItem(this.sessionKey);
143
+ else
144
+ globalThis.localStorage?.setItem(this.sessionKey, token);
145
+ }
146
+ catch {
147
+ // Storage refused (private mode, quota): the session lasts as long as the page.
148
+ }
149
+ }
117
150
  async hello() {
118
151
  let token;
119
152
  try {
@@ -122,11 +155,16 @@ export class ClientConnection extends ClientRuns {
122
155
  catch (e) {
123
156
  return this.halt("refused", e instanceof Error ? e.message : String(e));
124
157
  }
158
+ // No token of the app's own: resume the anonymous session, if there is one.
159
+ const saved = token === undefined ? this.savedSession() : undefined;
160
+ token ??= saved;
125
161
  const welcome = await this.request({ t: "hello", protocol: PROTOCOL, token, cursor: this.cursor, schema_hash: this.schema.hash, features: FEATURES }, true);
126
162
  if (welcome.t === "error" && welcome.kind === "offline")
127
163
  return; // lost mid-hello: the reconnect says hello again
128
164
  if (welcome.t === "error" && welcome.code === "protocol")
129
165
  return this.halt("protocol_mismatch", welcome.message);
166
+ if (welcome.t === "error" && welcome.code === "rate_limited")
167
+ return this.retryAfter(welcome.retry_after ?? 1, welcome.message);
130
168
  if (welcome.t !== "welcome")
131
169
  return this.halt("refused", welcome.t === "error" ? welcome.message : `unexpected reply \`${welcome.t}\``);
132
170
  if (welcome.protocol !== undefined && welcome.protocol !== PROTOCOL) {
@@ -145,6 +183,13 @@ export class ClientConnection extends ClientRuns {
145
183
  this.dropRestoredEditsOfOthers(id);
146
184
  this.principalId = id;
147
185
  this.principal.set(welcome.principal);
186
+ // The session sent may have ended: the server then made a new one.
187
+ if (welcome.session_token !== undefined)
188
+ this.keepSession(welcome.session_token);
189
+ else if (saved !== undefined)
190
+ this.keepSession(null);
191
+ else
192
+ this.sessionToken.set(null);
148
193
  // Anonymous (a sign-in page before sign-in): the server reads no table
149
194
  // for it, so nothing opens, `instant` tables included; they load at the
150
195
  // signed-in `welcome` that `reconnect()` brings.
@@ -241,13 +286,32 @@ export class ClientConnection extends ClientRuns {
241
286
  this.markReady();
242
287
  this.settleReconnect();
243
288
  }
289
+ /**
290
+ * The server refused a `hello` for now (`rate_limited`: too many new
291
+ * sessions or failed tokens from this address): not final. Close, stay
292
+ * `closed` with its message, and connect again once `seconds` have passed.
293
+ */
294
+ retryAfter(seconds, error) {
295
+ // The close of this socket is not a loss to back off from.
296
+ this.attempt++;
297
+ this.transport.close();
298
+ this.forgetSocket();
299
+ this.setStatus("closed", error);
300
+ if (this.closed)
301
+ return;
302
+ clearTimeout(this.backoff);
303
+ this.backoff = setTimeout(() => this.connect(), Math.max(1, seconds) * 1000);
304
+ }
244
305
  lost(code, reason) {
245
306
  this.forgetSocket();
246
307
  const halted = this.haltedWith !== undefined;
247
308
  // 1008 (policy violation): the reloaded API refuses this client's token,
248
309
  // or its session ended (`signed out`). Either way the token is spent;
249
- // `reconnect()` with another one.
250
- if (code === 1008 && !halted)
310
+ // `reconnect()` with another one. An anonymous session that ended needs
311
+ // no sign-in: the next `hello`, without it, gets a new one.
312
+ if (code === 1008 && !halted && this.sessionToken.peek() !== null)
313
+ this.keepSession(null);
314
+ else if (code === 1008 && !halted)
251
315
  return this.halt("refused", reason || "the server refused this client");
252
316
  if (halted)
253
317
  return;
@@ -13,6 +13,12 @@ type Pending = (frame: ServerFrame) => void;
13
13
  export declare class ClientCore {
14
14
  readonly connection: State<Connection>;
15
15
  readonly principal: State<string | null>;
16
+ /**
17
+ * The token of this client's anonymous session (`welcome.session_token`,
18
+ * under `session: { anonymous: true }`), `null` without one. The client
19
+ * resends it in `hello` when `token` gives none (see `session`).
20
+ */
21
+ readonly sessionToken: State<string | null>;
16
22
  readonly seq: State<number>;
17
23
  /**
18
24
  * Resolves with the connection once `instant` tables have loaded for the
@@ -9,6 +9,12 @@ export class ClientCore {
9
9
  /** @internal */ store = new Store();
10
10
  connection = new State({ status: "connecting", halted: false });
11
11
  principal = new State(null);
12
+ /**
13
+ * The token of this client's anonymous session (`welcome.session_token`,
14
+ * under `session: { anonymous: true }`), `null` without one. The client
15
+ * resends it in `hello` when `token` gives none (see `session`).
16
+ */
17
+ sessionToken = new State(null);
12
18
  /**
13
19
  * @internal Who the last `welcome` said this client is, stably
14
20
  * (`welcome.principal_id`, else its name from an older server): what the
package/dist/client.d.ts CHANGED
@@ -10,6 +10,13 @@ export interface VantageOptions {
10
10
  url?: string;
11
11
  /** Sent in `hello`: a token, or a function returning one, called on every connect. */
12
12
  token?: string | (() => string | undefined | Promise<string | undefined>);
13
+ /**
14
+ * Where the token of an anonymous session (`welcome.session_token`) is
15
+ * kept, to resend in `hello` whenever `token` gives none. Default:
16
+ * `"memory"`, so a reconnect resumes the session. `"localStorage"` also
17
+ * keeps it across reloads and tabs of the page.
18
+ */
19
+ session?: "memory" | "localStorage";
13
20
  /** Offline mirror of `persist: true` tables (see ./persist). */
14
21
  storage?: Storage;
15
22
  /** Swap the WebSocket (tests use the MockServer). */
@@ -46,6 +46,13 @@ export interface Fixtures {
46
46
  * with "sign in first".
47
47
  */
48
48
  signIn?: boolean;
49
+ /**
50
+ * Like vantage-server under `session: { anonymous: true }`: a `hello`
51
+ * without a token, or with one this server never issued, gets a new
52
+ * session (`welcome.session_token`, `anon-1`, `anon-2`, …); a `hello`
53
+ * with an issued one resumes it.
54
+ */
55
+ anonymousSessions?: boolean;
49
56
  }
50
57
  export declare class MockStore {
51
58
  readonly schema: SchemaMeta;
@@ -45,7 +45,7 @@ export type ChangeEvent = {
45
45
  k: "invalidated";
46
46
  table: string;
47
47
  };
48
- export type ErrorKind = "invalid" | "not_found" | "forbidden" | "unavailable" | "conflict" | "failed" | "offline";
48
+ export type ErrorKind = "invalid" | "not_found" | "forbidden" | "unavailable" | "conflict" | "failed" | "rate_limited" | "offline";
49
49
  /**
50
50
  * Which access rule refused a request, sent with `code: "forbidden"` on a
51
51
  * `rejected`, an `error` and a failed run's `error`: the table's catalog
@@ -61,7 +61,9 @@ export type ServerFrame =
61
61
  /**
62
62
  * `principal` is the display name; `principal_id` tells people apart (an
63
63
  * older server sends none); `anonymous` is a socket not signed in yet,
64
- * which reads no table.
64
+ * which reads no table. `session_token`, under `session: { anonymous:
65
+ * true }`: the token of the client's anonymous session, which a `hello`
66
+ * resumes it with.
65
67
  */
66
68
  {
67
69
  t: "welcome";
@@ -69,6 +71,7 @@ export type ServerFrame =
69
71
  principal: string;
70
72
  principal_id?: string;
71
73
  anonymous?: boolean;
74
+ session_token?: string;
72
75
  epoch: string;
73
76
  seq: number;
74
77
  schema_hash: string;
@@ -145,7 +148,9 @@ export type ServerFrame =
145
148
  }
146
149
  /**
147
150
  * `code` names errors a client acts on: `protocol`, `unknown_frame`,
148
- * `forbidden` (with the {@link AccessRefusal}) and a run's {@link RunCode}.
151
+ * `forbidden` (with the {@link AccessRefusal}), `rate_limited` (an invoke
152
+ * past its action's `rate_limit`, with `retry_after`, whole seconds) and a
153
+ * run's {@link RunCode}.
149
154
  */
150
155
  | ({
151
156
  t: "error";
@@ -153,6 +158,7 @@ export type ServerFrame =
153
158
  kind: ErrorKind;
154
159
  message: string;
155
160
  code?: string;
161
+ retry_after?: number;
156
162
  } & Partial<AccessRefusal>) | {
157
163
  t: "pong";
158
164
  id: number;
package/dist/testing.d.ts CHANGED
@@ -22,8 +22,13 @@ export declare class MockServer extends MockStore {
22
22
  private held;
23
23
  private refusals;
24
24
  private writeRefusal?;
25
+ /** `hello`s still to answer `rate_limited` (`limitHellos`). */
26
+ private limitedHellos;
27
+ private helloRetryAfter;
25
28
  /** Sockets whose `hello` had no token, under fixture `signIn`. */
26
29
  private anonymous;
30
+ /** Anonymous sessions issued, by token (fixture `anonymousSessions`). */
31
+ private sessions;
27
32
  /** A server with one table, its schema made from the rows' keys. */
28
33
  static withTable(table: string, rows: Row[], fixtures?: Omit<Fixtures, "tables">): MockServer;
29
34
  transport(): MockTransport;
@@ -33,6 +38,12 @@ export declare class MockServer extends MockStore {
33
38
  reload(): void;
34
39
  /** Answer every `op` request with an error carrying `message`. */
35
40
  refuse(op: ClientFrame["t"], message: string): void;
41
+ /**
42
+ * Answer the next `times` `hello`s with `rate_limited` and `retry_after`
43
+ * (whole seconds), as vantage-server does once an address has made too
44
+ * many sessions.
45
+ */
46
+ limitHellos(times: number, retryAfter?: number): void;
36
47
  /** Stop refusing `op`. */
37
48
  allow(op: ClientFrame["t"]): void;
38
49
  /** Refuse every write with `message`, however the client sends it (`flash`, `batch` or `delete`). */
package/dist/testing.js CHANGED
@@ -44,8 +44,13 @@ export class MockServer extends MockStore {
44
44
  held = new Map();
45
45
  refusals = new Map();
46
46
  writeRefusal;
47
+ /** `hello`s still to answer `rate_limited` (`limitHellos`). */
48
+ limitedHellos = 0;
49
+ helloRetryAfter = 1;
47
50
  /** Sockets whose `hello` had no token, under fixture `signIn`. */
48
51
  anonymous = new Set();
52
+ /** Anonymous sessions issued, by token (fixture `anonymousSessions`). */
53
+ sessions = new Set();
49
54
  /** A server with one table, its schema made from the rows' keys. */
50
55
  static withTable(table, rows, fixtures = {}) {
51
56
  return new MockServer(tableSchema(table, rows), { ...fixtures, tables: { [table]: rows } });
@@ -70,6 +75,15 @@ export class MockServer extends MockStore {
70
75
  refuse(op, message) {
71
76
  this.refusals.set(op, message);
72
77
  }
78
+ /**
79
+ * Answer the next `times` `hello`s with `rate_limited` and `retry_after`
80
+ * (whole seconds), as vantage-server does once an address has made too
81
+ * many sessions.
82
+ */
83
+ limitHellos(times, retryAfter = 1) {
84
+ this.limitedHellos = times;
85
+ this.helloRetryAfter = retryAfter;
86
+ }
73
87
  /** Stop refusing `op`. */
74
88
  allow(op) {
75
89
  this.refusals.delete(op);
@@ -128,8 +142,33 @@ export class MockServer extends MockStore {
128
142
  // The close follows the error, as vantage-server's writer sends it.
129
143
  return void setTimeout(() => client.drop(1002, "unsupported protocol"), 0);
130
144
  }
145
+ if (this.limitedHellos > 0) {
146
+ this.limitedHellos--;
147
+ const wait = this.helloRetryAfter;
148
+ const message = `too many new sessions from this address; try again in ${wait} s`;
149
+ return reply({ t: "error", id, kind: "rate_limited", code: "rate_limited", retry_after: wait, message });
150
+ }
131
151
  if (this.fixtures.refuseHello)
132
152
  return reply({ t: "error", id, kind: "forbidden", message: this.fixtures.refuseHello });
153
+ if (this.fixtures.anonymousSessions) {
154
+ const resumed = frame.token !== undefined && this.sessions.has(frame.token);
155
+ const token = resumed ? frame.token : `anon-${this.sessions.size + 1}`;
156
+ this.sessions.add(token);
157
+ return reply({
158
+ t: "welcome",
159
+ id,
160
+ principal: `anonymous-${token}`,
161
+ principal_id: `session:${token}`,
162
+ anonymous: false,
163
+ session_token: token,
164
+ epoch: `mock-${this.epoch}`,
165
+ seq: this.seq,
166
+ schema_hash: this.schema.hash,
167
+ resync: true,
168
+ protocol: this.fixtures.protocol ?? PROTOCOL,
169
+ features: (this.fixtures.features ?? FEATURES).filter((f) => !frame.features || frame.features.includes(f)),
170
+ });
171
+ }
133
172
  {
134
173
  const anonymous = this.fixtures.signIn === true && !frame.token;
135
174
  if (anonymous)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vantage-ui/client",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "A vantage-ui project as a typed, live object graph: lazy references and collections, optimistic edits, server-side actions, React bindings.",
5
5
  "keywords": ["vantage", "vantage-ui", "websocket", "realtime", "react", "codegen"],
6
6
  "homepage": "https://vantage-ui.com",
@@ -360,6 +360,17 @@ sign-out action, an expired session, or one deleted on the server closes
360
360
  the socket with 1008 `signed out`, which reads as `refused` with that
361
361
  error: clear the stored token and `reconnect()` to continue anonymously.
362
362
 
363
+ When the project gives every visitor a session (`application.yaml`
364
+ `session: { anonymous: true }`), there is no sign-in: the `welcome` carries
365
+ `session_token`, which `db.sessionToken` holds, and the client resends it
366
+ in `hello` whenever `token` gives none, so a lost socket comes back to the
367
+ same session and whatever its actions granted (an unlocked plan, say).
368
+ `createVantage({ schema, session: "localStorage" })` keeps it across
369
+ reloads and tabs too; the default, `"memory"`, only for this client. A
370
+ session that ended (idle on the server) closes with 1008 `signed out`
371
+ without halting: the client drops the token and the next `hello` gets a
372
+ new session, without the old one's grants.
373
+
363
374
  This sign-in form is the only one such a project needs. Its desktop app never
364
375
  signs in (vantage-ui's
365
376
  `vantage-application-settings/references/sessions-and-audit.md`).
@@ -9,10 +9,11 @@ scripts and debugging. HTTP: `/v1/schema` (the schema `generate` reads),
9
9
 
10
10
  | Client → server | Server → client |
11
11
  |---|---|
12
- | `hello {protocol: 1, token?, cursor?, schema_hash?, features?}` | `welcome {protocol, principal, principal_id, anonymous, epoch, seq, schema_hash, resync, features}`: `principal` is the name to show, `principal_id` the stable id that tells people apart (`identity:<id>`, `anonymous:<socket>`, `token:<name>`), `anonymous` true for a socket not signed in (the client then opens no table, `instant` ones included, until a signed-in `welcome`); the features both sides have (`runs:1`, `session:1`). Under auth `session` a `hello` without a token is welcomed as `anonymous`, which may only `invoke` the actions the project lists in `session.signed_out` (sign in), `run_set`, `run_cancel` and `ping`; anything else gets `error {kind: "forbidden", message: "sign in first"}` |
12
+ | `hello {protocol: 1, token?, cursor?, schema_hash?, features?}` | `welcome {protocol, principal, principal_id, anonymous, session_token?, epoch, seq, schema_hash, resync, features}`: `session_token`, under `session: { anonymous: true }`, is the token of the client's anonymous session (a `hello` with it resumes the session; one that ended gets a new session and token); `principal` is the name to show, `principal_id` the stable id that tells people apart (`identity:<id>`, `anonymous:<socket>`, `token:<name>`), `anonymous` true for a socket not signed in (the client then opens no table, `instant` ones included, until a signed-in `welcome`); the features both sides have (`runs:1`, `session:1`). Under auth `session` a `hello` without a token is welcomed as `anonymous`, which may only `invoke` the actions the project lists in `session.signed_out` (sign in), `run_set`, `run_cancel` and `ping`; anything else gets `error {kind: "forbidden", message: "sign in first"}` |
13
13
  | a frame with a `t` the server doesn't know | `error {code: "unknown_frame"}`; a client ignores frames and fields it doesn't know |
14
14
  | | `error {kind, message, code: "protocol"}`, then close 1002: another protocol version |
15
15
  | | `error {kind: "forbidden", message}`: the token is refused; the socket stays open for another `hello` |
16
+ | | `error {kind: "rate_limited", code: "rate_limited", retry_after, message}`: too many new sessions or failed tokens from this address; say `hello` again after `retry_after` seconds (`@vantage-ui/client` closes, stays `closed` with the message, and reconnects then) |
16
17
  | | `resync {epoch, seq, schema_hash}`: the API was reloaded; reopen what you show, and re-attach the runs you follow (`invoke {run}`): an `invoke` answered around the reload may not stream its run |
17
18
  | | `reload`: the served frontend was rebuilt; reload the page (sent before `welcome` too) |
18
19
  | | close 1008: after a reload, the API refuses the token; or `signed out`: the session ended (signed out, expired, deleted, its person deactivated) |