ras-stack 0.12.0 → 0.14.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
@@ -182,6 +182,20 @@ live.close()
182
182
  disconnect()
183
183
  ```
184
184
 
185
+ React applications can keep transport ownership equally small while retaining the native client and subscription:
186
+
187
+ ```tsx
188
+ import { useCallback } from 'react'
189
+ import { useConnectedRealtimeClient, useRealtimePresence, useRealtimeSubscription } from 'ras-stack/realtime/react'
190
+
191
+ const createClient = useCallback(() => createSameOriginRealtimeClient({ getToken }), [workspaceId])
192
+ const client = useConnectedRealtimeClient(createClient)
193
+ const subscription = useRealtimeSubscription({ client, channel, options, configure })
194
+ const clients = useRealtimePresence(subscription)
195
+ ```
196
+
197
+ The factory, subscription options, and configure callback should have stable identities and change only when their corresponding lifecycle should restart.
198
+
185
199
  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.
186
200
 
187
201
  ## Email and uploads
@@ -208,6 +222,17 @@ await startTusUpload(upload)
208
222
 
209
223
  Applications retain ownership of email templates, missing-email behavior, upload metadata, authorization, quotas, and completion processing.
210
224
 
225
+ Stateful development servers can reuse one typed resource across HMR without application-specific `globalThis` casts:
226
+
227
+ ```ts
228
+ import { clearGlobalSingleton, globalAsyncSingleton } from 'ras-stack/server'
229
+
230
+ export const app = () => globalAsyncSingleton('my-app.instance', createApp)
231
+ export const resetApp = () => clearGlobalSingleton('my-app.instance', (instance) => instance.close())
232
+ ```
233
+
234
+ Rejected async initialization removes only its own pending value so a later request can retry. Clearing deletes the key before awaiting initialization or disposal, allowing replacement startup without reusing a closing resource.
235
+
211
236
  ## Shared project configuration
212
237
 
213
238
  Extend the supplied configuration and override anything specific to the application:
@@ -0,0 +1,13 @@
1
+ import type { Centrifuge, ClientInfo, Subscription, SubscriptionOptions } from 'centrifuge';
2
+ export declare function useConnectedRealtimeClient(create: () => Centrifuge, enabled?: boolean): Centrifuge | undefined;
3
+ export type RealtimeSubscriptionHookOptions = {
4
+ client?: Centrifuge;
5
+ channel?: string;
6
+ enabled?: boolean;
7
+ options?: SubscriptionOptions;
8
+ configure?: (subscription: Subscription) => void | (() => void);
9
+ };
10
+ export declare function useRealtimeSubscription(options: RealtimeSubscriptionHookOptions): Subscription | undefined;
11
+ export declare function useRealtimePresence(subscription: Subscription | undefined, options?: {
12
+ onError?: (error: unknown) => void;
13
+ }): Record<string, ClientInfo>;
@@ -0,0 +1,50 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { connectRealtimeClient, openRealtimeSubscription, watchSubscriptionPresence } from './client.js';
3
+ export function useConnectedRealtimeClient(create, enabled = true) {
4
+ const [client, setClient] = useState();
5
+ useEffect(() => {
6
+ if (!enabled) {
7
+ setClient(undefined);
8
+ return undefined;
9
+ }
10
+ const next = create();
11
+ setClient(next);
12
+ return connectRealtimeClient(next);
13
+ }, [create, enabled]);
14
+ return client;
15
+ }
16
+ export function useRealtimeSubscription(options) {
17
+ const [subscription, setSubscription] = useState();
18
+ const { channel, client, configure, enabled = true, options: subscriptionOptions } = options;
19
+ useEffect(() => {
20
+ if (!enabled || !client || !channel) {
21
+ setSubscription(undefined);
22
+ return undefined;
23
+ }
24
+ const opened = openRealtimeSubscription(client, channel, subscriptionOptions, configure);
25
+ setSubscription(opened.subscription);
26
+ return opened.close;
27
+ }, [channel, client, configure, enabled, subscriptionOptions]);
28
+ return subscription;
29
+ }
30
+ export function useRealtimePresence(subscription, options = {}) {
31
+ const [clients, setClients] = useState({});
32
+ const onError = options.onError;
33
+ useEffect(() => {
34
+ if (!subscription) {
35
+ setClients({});
36
+ return undefined;
37
+ }
38
+ let active = true;
39
+ const stop = watchSubscriptionPresence(subscription, (next) => {
40
+ if (active)
41
+ setClients(next);
42
+ }, onError ? { onError } : {});
43
+ return () => {
44
+ active = false;
45
+ stop();
46
+ };
47
+ }, [onError, subscription]);
48
+ return clients;
49
+ }
50
+ //# sourceMappingURL=react.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react.js","sourceRoot":"","sources":["../../src/realtime/react.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAC3C,OAAO,EAAE,qBAAqB,EAAE,wBAAwB,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAA;AAExG,MAAM,UAAU,0BAA0B,CAAC,MAAwB,EAAE,OAAO,GAAG,IAAI;IACjF,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GAAG,QAAQ,EAAc,CAAA;IAClD,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,SAAS,CAAC,SAAS,CAAC,CAAA;YACpB,OAAO,SAAS,CAAA;QAClB,CAAC;QACD,MAAM,IAAI,GAAG,MAAM,EAAE,CAAA;QACrB,SAAS,CAAC,IAAI,CAAC,CAAA;QACf,OAAO,qBAAqB,CAAC,IAAI,CAAC,CAAA;IACpC,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;IACrB,OAAO,MAAM,CAAA;AACf,CAAC;AAUD,MAAM,UAAU,uBAAuB,CAAC,OAAwC;IAC9E,MAAM,CAAC,YAAY,EAAE,eAAe,CAAC,GAAG,QAAQ,EAAgB,CAAA;IAChE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,GAAG,IAAI,EAAE,OAAO,EAAE,mBAAmB,EAAE,GAAG,OAAO,CAAA;IAC5F,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,CAAC,OAAO,IAAI,CAAC,MAAM,IAAI,CAAC,OAAO,EAAE,CAAC;YACpC,eAAe,CAAC,SAAS,CAAC,CAAA;YAC1B,OAAO,SAAS,CAAA;QAClB,CAAC;QACD,MAAM,MAAM,GAAG,wBAAwB,CAAC,MAAM,EAAE,OAAO,EAAE,mBAAmB,EAAE,SAAS,CAAC,CAAA;QACxF,eAAe,CAAC,MAAM,CAAC,YAAY,CAAC,CAAA;QACpC,OAAO,MAAM,CAAC,KAAK,CAAA;IACrB,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,mBAAmB,CAAC,CAAC,CAAA;IAC9D,OAAO,YAAY,CAAA;AACrB,CAAC;AAED,MAAM,UAAU,mBAAmB,CAAC,YAAsC,EAAE,OAAO,GAA2C,EAAE;IAC9H,MAAM,CAAC,OAAO,EAAE,UAAU,CAAC,GAAG,QAAQ,CAA6B,EAAE,CAAC,CAAA;IACtE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAA;IAC/B,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,CAAC,YAAY,EAAE,CAAC;YAClB,UAAU,CAAC,EAAE,CAAC,CAAA;YACd,OAAO,SAAS,CAAA;QAClB,CAAC;QACD,IAAI,MAAM,GAAG,IAAI,CAAA;QACjB,MAAM,IAAI,GAAG,yBAAyB,CACpC,YAAY,EACZ,CAAC,IAAI,EAAE,EAAE;YACP,IAAI,MAAM;gBAAE,UAAU,CAAC,IAAI,CAAC,CAAA;QAC9B,CAAC,EACD,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAC3B,CAAA;QACD,OAAO,GAAG,EAAE;YACV,MAAM,GAAG,KAAK,CAAA;YACd,IAAI,EAAE,CAAA;QACR,CAAC,CAAA;IACH,CAAC,EAAE,CAAC,OAAO,EAAE,YAAY,CAAC,CAAC,CAAA;IAC3B,OAAO,OAAO,CAAA;AAChB,CAAC","sourcesContent":["import type { Centrifuge, ClientInfo, Subscription, SubscriptionOptions } from 'centrifuge'\nimport { useEffect, useState } from 'react'\nimport { connectRealtimeClient, openRealtimeSubscription, watchSubscriptionPresence } from './client.js'\n\nexport function useConnectedRealtimeClient(create: () => Centrifuge, enabled = true) {\n const [client, setClient] = useState<Centrifuge>()\n useEffect(() => {\n if (!enabled) {\n setClient(undefined)\n return undefined\n }\n const next = create()\n setClient(next)\n return connectRealtimeClient(next)\n }, [create, enabled])\n return client\n}\n\nexport type RealtimeSubscriptionHookOptions = {\n client?: Centrifuge\n channel?: string\n enabled?: boolean\n options?: SubscriptionOptions\n configure?: (subscription: Subscription) => void | (() => void)\n}\n\nexport function useRealtimeSubscription(options: RealtimeSubscriptionHookOptions) {\n const [subscription, setSubscription] = useState<Subscription>()\n const { channel, client, configure, enabled = true, options: subscriptionOptions } = options\n useEffect(() => {\n if (!enabled || !client || !channel) {\n setSubscription(undefined)\n return undefined\n }\n const opened = openRealtimeSubscription(client, channel, subscriptionOptions, configure)\n setSubscription(opened.subscription)\n return opened.close\n }, [channel, client, configure, enabled, subscriptionOptions])\n return subscription\n}\n\nexport function useRealtimePresence(subscription: Subscription | undefined, options: { onError?: (error: unknown) => void } = {}) {\n const [clients, setClients] = useState<Record<string, ClientInfo>>({})\n const onError = options.onError\n useEffect(() => {\n if (!subscription) {\n setClients({})\n return undefined\n }\n let active = true\n const stop = watchSubscriptionPresence(\n subscription,\n (next) => {\n if (active) setClients(next)\n },\n onError ? { onError } : {},\n )\n return () => {\n active = false\n stop()\n }\n }, [onError, subscription])\n return clients\n}\n"]}
@@ -1,3 +1,4 @@
1
1
  export * from './canonical-host.js';
2
2
  export * from './health.js';
3
3
  export * from './rpc.js';
4
+ export * from './singleton.js';
@@ -1,4 +1,5 @@
1
1
  export * from './canonical-host.js';
2
2
  export * from './health.js';
3
3
  export * from './rpc.js';
4
+ export * from './singleton.js';
4
5
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA,cAAc,qBAAqB,CAAA;AACnC,cAAc,aAAa,CAAA;AAC3B,cAAc,UAAU,CAAA","sourcesContent":["export * from './canonical-host.js'\nexport * from './health.js'\nexport * from './rpc.js'\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA,cAAc,qBAAqB,CAAA;AACnC,cAAc,aAAa,CAAA;AAC3B,cAAc,UAAU,CAAA;AACxB,cAAc,gBAAgB,CAAA","sourcesContent":["export * from './canonical-host.js'\nexport * from './health.js'\nexport * from './rpc.js'\nexport * from './singleton.js'\n"]}
@@ -0,0 +1,4 @@
1
+ export declare function globalSingleton<T>(key: string, create: () => T): T;
2
+ export declare function globalAsyncSingleton<T>(key: string, create: () => T | Promise<T>): Promise<T>;
3
+ export declare function peekGlobalSingleton(key: string): unknown;
4
+ export declare function clearGlobalSingleton<T>(key: string, dispose?: (value: Awaited<T>) => void | Promise<void>): Promise<boolean>;
@@ -0,0 +1,48 @@
1
+ const storeKey = Symbol.for('ras-stack.server.singletons');
2
+ function store() {
3
+ const target = globalThis;
4
+ return (target[storeKey] ??= new Map());
5
+ }
6
+ export function globalSingleton(key, create) {
7
+ const singletons = store();
8
+ const existing = singletons.get(key);
9
+ if (existing) {
10
+ if (existing.kind !== 'sync')
11
+ throw new Error(`singleton ${key} is asynchronous`);
12
+ return existing.value;
13
+ }
14
+ const value = create();
15
+ singletons.set(key, { kind: 'sync', value });
16
+ return value;
17
+ }
18
+ export function globalAsyncSingleton(key, create) {
19
+ const singletons = store();
20
+ const existing = singletons.get(key);
21
+ if (existing) {
22
+ if (existing.kind !== 'async')
23
+ throw new Error(`singleton ${key} is synchronous`);
24
+ return existing.value;
25
+ }
26
+ const pending = Promise.resolve().then(create);
27
+ const guarded = pending.catch((error) => {
28
+ if (singletons.get(key)?.value === guarded)
29
+ singletons.delete(key);
30
+ throw error;
31
+ });
32
+ singletons.set(key, { kind: 'async', value: guarded });
33
+ return guarded;
34
+ }
35
+ export function peekGlobalSingleton(key) {
36
+ return store().get(key)?.value;
37
+ }
38
+ export async function clearGlobalSingleton(key, dispose) {
39
+ const singletons = store();
40
+ if (!singletons.has(key))
41
+ return false;
42
+ const value = singletons.get(key)?.value;
43
+ singletons.delete(key);
44
+ if (dispose)
45
+ await dispose(await value);
46
+ return true;
47
+ }
48
+ //# sourceMappingURL=singleton.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"singleton.js","sourceRoot":"","sources":["../../src/server/singleton.ts"],"names":[],"mappings":"AAAA,MAAM,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAC,6BAA6B,CAAC,CAAA;AAI1D,SAAS,KAAK;IACZ,MAAM,MAAM,GAAG,UAA6B,CAAA;IAC5C,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,IAAI,GAAG,EAAE,CAAC,CAAA;AACzC,CAAC;AAED,MAAM,UAAU,eAAe,CAAI,GAAW,EAAE,MAAe;IAC7D,MAAM,UAAU,GAAG,KAAK,EAAE,CAAA;IAC1B,MAAM,QAAQ,GAAG,UAAU,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;IACpC,IAAI,QAAQ,EAAE,CAAC;QACb,IAAI,QAAQ,CAAC,IAAI,KAAK,MAAM;YAAE,MAAM,IAAI,KAAK,CAAC,aAAa,GAAG,kBAAkB,CAAC,CAAA;QACjF,OAAO,QAAQ,CAAC,KAAU,CAAA;IAC5B,CAAC;IACD,MAAM,KAAK,GAAG,MAAM,EAAE,CAAA;IACtB,UAAU,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,CAAA;IAC5C,OAAO,KAAK,CAAA;AACd,CAAC;AAED,MAAM,UAAU,oBAAoB,CAAI,GAAW,EAAE,MAA4B;IAC/E,MAAM,UAAU,GAAG,KAAK,EAAE,CAAA;IAC1B,MAAM,QAAQ,GAAG,UAAU,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;IACpC,IAAI,QAAQ,EAAE,CAAC;QACb,IAAI,QAAQ,CAAC,IAAI,KAAK,OAAO;YAAE,MAAM,IAAI,KAAK,CAAC,aAAa,GAAG,iBAAiB,CAAC,CAAA;QACjF,OAAO,QAAQ,CAAC,KAAmB,CAAA;IACrC,CAAC;IACD,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;IAC9C,MAAM,OAAO,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;QAC/C,IAAI,UAAU,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,KAAK,KAAK,OAAO;YAAE,UAAU,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;QAClE,MAAM,KAAK,CAAA;IACb,CAAC,CAAC,CAAA;IACF,UAAU,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC,CAAA;IACtD,OAAO,OAAO,CAAA;AAChB,CAAC;AAED,MAAM,UAAU,mBAAmB,CAAC,GAAW;IAC7C,OAAO,KAAK,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,KAAK,CAAA;AAChC,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,oBAAoB,CAAI,GAAW,EAAE,OAAqD;IAC9G,MAAM,UAAU,GAAG,KAAK,EAAE,CAAA;IAC1B,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,GAAG,CAAC;QAAE,OAAO,KAAK,CAAA;IACtC,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,KAAU,CAAA;IAC7C,UAAU,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;IACtB,IAAI,OAAO;QAAE,MAAM,OAAO,CAAC,MAAM,KAAK,CAAC,CAAA;IACvC,OAAO,IAAI,CAAA;AACb,CAAC","sourcesContent":["const storeKey = Symbol.for('ras-stack.server.singletons')\ntype SingletonEntry = { kind: 'sync' | 'async'; value: unknown }\ntype SingletonGlobal = typeof globalThis & { [storeKey]?: Map<string, SingletonEntry> }\n\nfunction store() {\n const target = globalThis as SingletonGlobal\n return (target[storeKey] ??= new Map())\n}\n\nexport function globalSingleton<T>(key: string, create: () => T): T {\n const singletons = store()\n const existing = singletons.get(key)\n if (existing) {\n if (existing.kind !== 'sync') throw new Error(`singleton ${key} is asynchronous`)\n return existing.value as T\n }\n const value = create()\n singletons.set(key, { kind: 'sync', value })\n return value\n}\n\nexport function globalAsyncSingleton<T>(key: string, create: () => T | Promise<T>): Promise<T> {\n const singletons = store()\n const existing = singletons.get(key)\n if (existing) {\n if (existing.kind !== 'async') throw new Error(`singleton ${key} is synchronous`)\n return existing.value as Promise<T>\n }\n const pending = Promise.resolve().then(create)\n const guarded = pending.catch((error: unknown) => {\n if (singletons.get(key)?.value === guarded) singletons.delete(key)\n throw error\n })\n singletons.set(key, { kind: 'async', value: guarded })\n return guarded\n}\n\nexport function peekGlobalSingleton(key: string): unknown {\n return store().get(key)?.value\n}\n\nexport async function clearGlobalSingleton<T>(key: string, dispose?: (value: Awaited<T>) => void | Promise<void>) {\n const singletons = store()\n if (!singletons.has(key)) return false\n const value = singletons.get(key)?.value as T\n singletons.delete(key)\n if (dispose) await dispose(await value)\n return true\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ras-stack",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Composable full-stack primitives shared across Richard Solomou's applications.",
5
5
  "keywords": [
6
6
  "authentication",
@@ -58,6 +58,10 @@
58
58
  "types": "./dist/realtime/client.d.ts",
59
59
  "default": "./dist/realtime/client.js"
60
60
  },
61
+ "./realtime/react": {
62
+ "types": "./dist/realtime/react.d.ts",
63
+ "default": "./dist/realtime/react.js"
64
+ },
61
65
  "./server": {
62
66
  "types": "./dist/server/index.d.ts",
63
67
  "default": "./dist/server/index.js"
@@ -109,6 +113,8 @@
109
113
  "@types/better-sqlite3": "9.6.0",
110
114
  "@types/node": "^26.0.0",
111
115
  "@types/nodemailer": "^8.0.1",
116
+ "@types/react": "19.2.8",
117
+ "@types/react-test-renderer": "19.1.0",
112
118
  "better-sqlite3": "12.11.1",
113
119
  "centrifuge": "5.7.0",
114
120
  "drizzle-orm": "0.45.2",
@@ -117,6 +123,8 @@
117
123
  "oxlint": "^1.74.0",
118
124
  "oxlint-tsgolint": "^7.0.2001",
119
125
  "postgres": "3.4.9",
126
+ "react": "19.2.8",
127
+ "react-test-renderer": "19.2.8",
120
128
  "tus-js-client": "^4.3.1",
121
129
  "typescript": "^7.0.2",
122
130
  "vite": "8.2.1",
@@ -130,6 +138,7 @@
130
138
  "drizzle-orm": ">=0.45 <1",
131
139
  "nodemailer": ">=9 <10",
132
140
  "postgres": ">=3.4 <4",
141
+ "react": ">=19 <20",
133
142
  "tus-js-client": ">=4 <5"
134
143
  },
135
144
  "peerDependenciesMeta": {
@@ -154,6 +163,9 @@
154
163
  "postgres": {
155
164
  "optional": true
156
165
  },
166
+ "react": {
167
+ "optional": true
168
+ },
157
169
  "tus-js-client": {
158
170
  "optional": true
159
171
  }