ras-stack 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/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
 
@@ -192,6 +217,31 @@ The workflow consumes pending changesets, commits the resulting versions and cha
192
217
 
193
218
  Pin actions and reusable workflows to a release tag and let Dependabot propose upgrades.
194
219
 
220
+ The reusable check workflow owns checkout and toolchain setup while the repository keeps its check command:
221
+
222
+ ```yaml
223
+ jobs:
224
+ check:
225
+ uses: richardsolomou/ras-stack/.github/workflows/check-js.yml@v0.6.0
226
+ with:
227
+ command: just check
228
+ just-version: '1.58.0'
229
+ ```
230
+
231
+ Simple Playwright jobs can also share browser installation and failure artifacts:
232
+
233
+ ```yaml
234
+ jobs:
235
+ end-to-end:
236
+ uses: richardsolomou/ras-stack/.github/workflows/check-browser.yml@v0.6.0
237
+ with:
238
+ prepare-command: pnpm build
239
+ command: pnpm test:e2e:run
240
+ artifact-path: test-results
241
+ ```
242
+
243
+ Callers continue to own workflow triggers, concurrency, required-job dependencies, permissions, services, caches, custom setup, and release or deployment policy. Keep jobs local when they need steps beyond these stable shapes.
244
+
195
245
  ## Development
196
246
 
197
247
  Development requires Node 24 and pnpm 11.15.0.
@@ -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.6.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
  },