ras-stack 0.4.0 → 0.5.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/README.md CHANGED
@@ -14,7 +14,7 @@ The package contains independent helpers for common application infrastructure:
14
14
 
15
15
  - **Authentication:** secure defaults and utilities for sessions, rate limits, secrets, social providers, tokens, and trusted origins.
16
16
  - **Server requests:** same-origin mutation guards, error-normalizing RPC wrappers, canonical-host redirects, and health responses.
17
- - **Realtime:** Centrifugo token signing and a bounded publisher with retries and graceful shutdown.
17
+ - **Realtime:** Centrifugo client lifecycle, token signing, presence synchronization, and a bounded publisher with retries and graceful shutdown.
18
18
  - **Email:** SMTP environment parsing and a small Nodemailer delivery interface.
19
19
  - **Uploads:** a promise-based wrapper around resumable `tus-js-client` uploads.
20
20
  - **Project configuration:** shared TypeScript and Oxlint bases.
@@ -35,10 +35,11 @@ The libraries underneath remain available normally. Applications still configure
35
35
  pnpm add ras-stack
36
36
  ```
37
37
 
38
- Nodemailer and `tus-js-client` are optional peer dependencies. Install one only when using its entrypoint:
38
+ Nodemailer, Centrifuge, and `tus-js-client` are optional peer dependencies. Install one only when using its integration:
39
39
 
40
40
  ```sh
41
41
  pnpm add nodemailer
42
+ pnpm add centrifuge
42
43
  pnpm add tus-js-client
43
44
  ```
44
45
 
@@ -89,10 +90,18 @@ Only enable `trustForwardedHeaders` behind a proxy that replaces incoming forwar
89
90
 
90
91
  ## Realtime updates
91
92
 
92
- Applications choose their channel names, authorize subscriptions, and define payloads. `ras-stack` handles Centrifugo's HTTP publication and signed tokens:
93
+ Applications choose their channel names, authorize subscriptions, and define payloads. `ras-stack` handles Centrifugo's HTTP publication, signed tokens, and repeated browser lifecycle mechanics:
93
94
 
94
95
  ```ts
95
- import { CentrifugoPublisher, signRealtimeToken } from 'ras-stack/realtime'
96
+ import {
97
+ CentrifugoPublisher,
98
+ connectRealtimeClient,
99
+ createSameOriginRealtimeClient,
100
+ openRealtimeSubscription,
101
+ requestRealtimeTicket,
102
+ signRealtimeToken,
103
+ watchSubscriptionPresence,
104
+ } from 'ras-stack/realtime'
96
105
 
97
106
  const publisher = new CentrifugoPublisher({
98
107
  apiUrl,
@@ -107,9 +116,25 @@ publisher.publish(`battle:${battle.id}`, { type: 'change' })
107
116
  const token = signRealtimeToken(user.id, { channel: `battle:${battle.id}`, info: presence }, { secret })
108
117
 
109
118
  await publisher.close()
119
+
120
+ const client = createSameOriginRealtimeClient({
121
+ getToken: () => requestRealtimeTicket('/api/realtime/token', { parse: (value) => (value as { token: string }).token }),
122
+ })
123
+ const channelToken = (channel: string) =>
124
+ requestRealtimeTicket('/api/realtime/token', {
125
+ init: { method: 'POST', body: JSON.stringify({ channel }) },
126
+ parse: (value) => (value as { token: string }).token,
127
+ })
128
+ const disconnect = connectRealtimeClient(client)
129
+ const live = openRealtimeSubscription(client, channel, { getToken: ({ channel }) => channelToken(channel) }, (subscription) =>
130
+ watchSubscriptionPresence(subscription, setClients),
131
+ )
132
+
133
+ live.close()
134
+ disconnect()
110
135
  ```
111
136
 
112
- `publish()` returns `false` when the publisher is closed, disabled, or at capacity. `close()` rejects new work and waits for accepted publications and their bounded retries to finish.
137
+ The client helpers return the underlying Centrifuge client and subscription. React ownership, channel conventions, ticket validation, event parsing, presence models, and query invalidation remain application code. `publish()` returns `false` when the publisher is closed, disabled, or at capacity. `close()` rejects new work and waits for accepted publications and their bounded retries to finish.
113
138
 
114
139
  ## Email and uploads
115
140
 
@@ -0,0 +1,29 @@
1
+ import { Centrifuge, type ClientInfo, type Options, type ServerPublicationContext, type ServerSubscribedContext, type Subscription, type SubscriptionOptions } from 'centrifuge';
2
+ type BrowserLocation = Pick<Location, 'host' | 'protocol'>;
3
+ export declare function sameOriginWebSocketUrl(location: BrowserLocation, path?: string): string;
4
+ export type RealtimeTicketOptions<T> = {
5
+ fetch?: typeof fetch;
6
+ init?: RequestInit;
7
+ parse: (value: unknown) => T;
8
+ unauthorizedStatuses?: readonly number[];
9
+ errorMessage?: (status: number) => string;
10
+ };
11
+ export declare function requestRealtimeTicket<T>(input: RequestInfo | URL, options: RealtimeTicketOptions<T>): Promise<T>;
12
+ export declare function createSameOriginRealtimeClient(options: Partial<Options>, config?: {
13
+ location?: BrowserLocation;
14
+ path?: string;
15
+ }): Centrifuge;
16
+ export declare function connectRealtimeClient(client: Centrifuge): () => void;
17
+ export type RealtimeSubscription = {
18
+ subscription: Subscription;
19
+ close: () => void;
20
+ };
21
+ export declare function openRealtimeSubscription(client: Centrifuge, channel: string, options?: SubscriptionOptions, configure?: (subscription: Subscription) => void | (() => void)): RealtimeSubscription;
22
+ export declare function watchServerChannel(client: Centrifuge, channel: string, handlers: {
23
+ publication?: (context: ServerPublicationContext) => void;
24
+ unrecovered?: (context: ServerSubscribedContext) => void;
25
+ }): () => void;
26
+ export declare function watchSubscriptionPresence(subscription: Subscription, update: (clients: Record<string, ClientInfo>) => void, options?: {
27
+ onError?: (error: unknown) => void;
28
+ }): () => void;
29
+ export {};
@@ -0,0 +1,133 @@
1
+ import { Centrifuge, UnauthorizedError, } from 'centrifuge';
2
+ export function sameOriginWebSocketUrl(location, path = '/connection/websocket') {
3
+ if (!path.startsWith('/') || path.startsWith('//'))
4
+ throw new Error('WebSocket path must be same-origin');
5
+ const url = new URL(path, `${location.protocol}//${location.host}`);
6
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
7
+ return url.toString();
8
+ }
9
+ export async function requestRealtimeTicket(input, options) {
10
+ const response = await (options.fetch ?? fetch)(input, options.init);
11
+ if ((options.unauthorizedStatuses ?? [401, 403]).includes(response.status))
12
+ throw new UnauthorizedError('unauthorized');
13
+ if (!response.ok)
14
+ throw new Error(options.errorMessage?.(response.status) ?? `Realtime authentication failed with status ${response.status}`);
15
+ return options.parse(await response.json());
16
+ }
17
+ export function createSameOriginRealtimeClient(options, config = {}) {
18
+ const location = config.location ?? window.location;
19
+ return new Centrifuge(sameOriginWebSocketUrl(location, config.path), options);
20
+ }
21
+ export function connectRealtimeClient(client) {
22
+ client.connect();
23
+ return () => client.disconnect();
24
+ }
25
+ export function openRealtimeSubscription(client, channel, options = {}, configure) {
26
+ const subscription = client.newSubscription(channel, options);
27
+ let cleanup;
28
+ try {
29
+ cleanup = configure?.(subscription);
30
+ subscription.subscribe();
31
+ }
32
+ catch (error) {
33
+ client.removeSubscription(subscription);
34
+ throw error;
35
+ }
36
+ let closed = false;
37
+ return {
38
+ subscription,
39
+ close: () => {
40
+ if (closed)
41
+ return;
42
+ closed = true;
43
+ try {
44
+ cleanup?.();
45
+ }
46
+ finally {
47
+ client.removeSubscription(subscription);
48
+ }
49
+ },
50
+ };
51
+ }
52
+ export function watchServerChannel(client, channel, handlers) {
53
+ const publication = (context) => {
54
+ if (context.channel === channel)
55
+ handlers.publication?.(context);
56
+ };
57
+ const subscribed = (context) => {
58
+ if (context.channel === channel && context.wasRecovering && !context.recovered)
59
+ handlers.unrecovered?.(context);
60
+ };
61
+ client.on('publication', publication);
62
+ client.on('subscribed', subscribed);
63
+ return () => {
64
+ client.off('publication', publication);
65
+ client.off('subscribed', subscribed);
66
+ };
67
+ }
68
+ export function watchSubscriptionPresence(subscription, update, options = {}) {
69
+ const clients = new Map();
70
+ let active = true;
71
+ let revision = 0;
72
+ let refreshing;
73
+ const render = () => update(Object.fromEntries(clients));
74
+ const sync = async () => {
75
+ const requestedAt = revision;
76
+ try {
77
+ const snapshot = await subscription.presence();
78
+ if (!active)
79
+ return;
80
+ if (revision !== requestedAt)
81
+ return sync();
82
+ clients.clear();
83
+ for (const [id, info] of Object.entries(snapshot.clients))
84
+ clients.set(id, info);
85
+ render();
86
+ }
87
+ catch (error) {
88
+ if (!active)
89
+ return;
90
+ if (revision !== requestedAt)
91
+ return sync();
92
+ clients.clear();
93
+ render();
94
+ options.onError?.(error);
95
+ }
96
+ };
97
+ const refresh = () => {
98
+ if (refreshing)
99
+ return refreshing;
100
+ refreshing = sync().finally(() => {
101
+ refreshing = undefined;
102
+ });
103
+ return refreshing;
104
+ };
105
+ const subscribed = () => {
106
+ revision++;
107
+ void refresh();
108
+ };
109
+ const join = ({ info }) => {
110
+ revision++;
111
+ clients.set(info.client, info);
112
+ render();
113
+ };
114
+ const leave = ({ info }) => {
115
+ revision++;
116
+ clients.delete(info.client);
117
+ render();
118
+ };
119
+ subscription.on('subscribed', subscribed);
120
+ subscription.on('join', join);
121
+ subscription.on('leave', leave);
122
+ return () => {
123
+ if (!active)
124
+ return;
125
+ active = false;
126
+ subscription.off('subscribed', subscribed);
127
+ subscription.off('join', join);
128
+ subscription.off('leave', leave);
129
+ clients.clear();
130
+ render();
131
+ };
132
+ }
133
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","sourceRoot":"","sources":["../../src/realtime/client.ts"],"names":[],"mappings":"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"}
@@ -1,2 +1,3 @@
1
+ export * from './client.js';
1
2
  export * from './publisher.js';
2
3
  export * from './tokens.js';
@@ -1,3 +1,4 @@
1
+ export * from './client.js';
1
2
  export * from './publisher.js';
2
3
  export * from './tokens.js';
3
4
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/realtime/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAA;AAC9B,cAAc,aAAa,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/realtime/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAA;AAC3B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,aAAa,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ras-stack",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Composable full-stack primitives shared across Richard Solomou's applications.",
5
5
  "keywords": [
6
6
  "authentication",
@@ -76,6 +76,7 @@
76
76
  "@tanstack/react-start": "^1.168.32",
77
77
  "@types/node": "^26.0.0",
78
78
  "@types/nodemailer": "^8.0.1",
79
+ "centrifuge": "5.7.0",
79
80
  "nodemailer": "^9.0.3",
80
81
  "oxfmt": "^0.59.0",
81
82
  "oxlint": "^1.74.0",
@@ -87,6 +88,7 @@
87
88
  "peerDependencies": {
88
89
  "@tanstack/react-query": ">=5 <6",
89
90
  "@tanstack/react-start": ">=1 <2",
91
+ "centrifuge": ">=5 <6",
90
92
  "nodemailer": ">=9 <10",
91
93
  "tus-js-client": ">=4 <5"
92
94
  },
@@ -97,6 +99,9 @@
97
99
  "@tanstack/react-start": {
98
100
  "optional": true
99
101
  },
102
+ "centrifuge": {
103
+ "optional": true
104
+ },
100
105
  "nodemailer": {
101
106
  "optional": true
102
107
  },