dynamodb-reactive 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 +93 -51
- package/dist/{client-Zb67giI5.d.ts → client-BPAct-Dz.d.ts} +19 -0
- package/dist/client.cjs +68 -55
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +1 -1
- package/dist/client.d.ts +1 -1
- package/dist/client.js +68 -55
- package/dist/client.js.map +1 -1
- package/dist/cloudflare.cjs +223 -36
- package/dist/cloudflare.cjs.map +1 -1
- package/dist/cloudflare.d.cts +78 -6
- package/dist/cloudflare.d.ts +78 -6
- package/dist/cloudflare.js +223 -36
- package/dist/cloudflare.js.map +1 -1
- package/dist/cluster.cjs +81 -66
- package/dist/cluster.cjs.map +1 -1
- package/dist/cluster.js +81 -66
- package/dist/cluster.js.map +1 -1
- package/dist/core.cjs +1 -3
- package/dist/core.cjs.map +1 -1
- package/dist/core.js +1 -3
- package/dist/core.js.map +1 -1
- package/dist/hub.cjs +35 -10
- package/dist/hub.cjs.map +1 -1
- package/dist/hub.d.cts +18 -3
- package/dist/hub.d.ts +18 -3
- package/dist/hub.js +35 -10
- package/dist/hub.js.map +1 -1
- package/dist/index.cjs +1 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1 -3
- package/dist/index.js.map +1 -1
- package/dist/infra.cjs +19 -33
- package/dist/infra.cjs.map +1 -1
- package/dist/infra.js +19 -33
- package/dist/infra.js.map +1 -1
- package/dist/react.cjs +71 -65
- package/dist/react.cjs.map +1 -1
- package/dist/react.js +71 -65
- package/dist/react.js.map +1 -1
- package/dist/server.cjs +46 -139
- package/dist/server.cjs.map +1 -1
- package/dist/server.js +46 -139
- package/dist/server.js.map +1 -1
- package/package.json +22 -24
package/dist/cloudflare.d.cts
CHANGED
|
@@ -6,7 +6,8 @@ import { HubSocket, HubPaths } from './hub.cjs';
|
|
|
6
6
|
* takes the Durable Object's `ctx` instead of extending `DurableObject`, so this
|
|
7
7
|
* entry imports nothing from `cloudflare:workers` and builds like every other
|
|
8
8
|
* entry. Your Durable Object class owns its name and migrations and delegates
|
|
9
|
-
* `fetch`, `webSocketMessage`, `webSocketClose` and `webSocketError` to it
|
|
9
|
+
* `fetch`, `webSocketMessage`, `webSocketClose` and `webSocketError` to it, plus
|
|
10
|
+
* `alarm` when it tracks presence.
|
|
10
11
|
*/
|
|
11
12
|
/** Set by the route handler on requests it forwards to the hub; never trusted from clients. */
|
|
12
13
|
declare const HUB_PRINCIPAL_HEADER = "x-reactive-principal";
|
|
@@ -19,9 +20,17 @@ interface DurableObjectStateLike {
|
|
|
19
20
|
acceptWebSocket(socket: DurableObjectWebSocket, tags?: string[]): void;
|
|
20
21
|
getWebSockets(tag?: string): DurableObjectWebSocket[];
|
|
21
22
|
setWebSocketAutoResponse?(pair?: unknown): void;
|
|
23
|
+
/** When the runtime last answered this socket's `ping` itself; needed for presence. */
|
|
24
|
+
getWebSocketAutoResponseTimestamp?(socket: DurableObjectWebSocket): Date | null;
|
|
22
25
|
storage: {
|
|
23
26
|
get<T>(key: string): Promise<T | undefined>;
|
|
24
27
|
put<T>(key: string, value: T): Promise<void>;
|
|
28
|
+
/** Lets presence forget principals that left instead of storing `false`. */
|
|
29
|
+
delete?(key: string): Promise<boolean>;
|
|
30
|
+
/** Needed for presence. */
|
|
31
|
+
getAlarm?(): Promise<number | null>;
|
|
32
|
+
/** Needed for presence. */
|
|
33
|
+
setAlarm?(scheduledTime: number): Promise<void>;
|
|
25
34
|
};
|
|
26
35
|
}
|
|
27
36
|
/** Anything with a Durable Object stub's `fetch`. */
|
|
@@ -32,6 +41,10 @@ type WebSocketPairConstructor = new () => {
|
|
|
32
41
|
0: DurableObjectWebSocket;
|
|
33
42
|
1: DurableObjectWebSocket;
|
|
34
43
|
};
|
|
44
|
+
/** A frame from the client that is not part of the protocol, such as `{ type: 'listening' }`. */
|
|
45
|
+
type HubClientFrame = Record<string, unknown> & {
|
|
46
|
+
type: string;
|
|
47
|
+
};
|
|
35
48
|
interface DurableObjectHubOptions {
|
|
36
49
|
paths: HubPaths;
|
|
37
50
|
/** Storage key for a path's version. Defaults to `version:<path>`. */
|
|
@@ -42,15 +55,50 @@ interface DurableObjectHubOptions {
|
|
|
42
55
|
* Defaults to true.
|
|
43
56
|
*/
|
|
44
57
|
autoPong?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Tracks whether principals hold a live socket, so the hub can report a
|
|
60
|
+
* worker or listener as online without it polling over HTTP. Requires `autoPong`.
|
|
61
|
+
*/
|
|
62
|
+
presence?: HubPresenceOptions;
|
|
63
|
+
/** Runs after a socket is accepted. */
|
|
64
|
+
onOpen?: (socket: DurableObjectWebSocket, principal: string) => void | Promise<void>;
|
|
65
|
+
/**
|
|
66
|
+
* Runs when a socket closes or errors. The socket may still be listed by
|
|
67
|
+
* `sockets()` during this call, so exclude it when counting.
|
|
68
|
+
*/
|
|
69
|
+
onClose?: (socket: DurableObjectWebSocket, principal: string) => void | Promise<void>;
|
|
70
|
+
/** Handles frames the protocol does not, such as application presence updates. */
|
|
71
|
+
onMessage?: (socket: DurableObjectWebSocket, principal: string, frame: HubClientFrame) => void | Promise<void>;
|
|
45
72
|
/** Workers' `WebSocketPair`; injectable for tests. */
|
|
46
73
|
WebSocketPair?: WebSocketPairConstructor;
|
|
47
74
|
/** Creates a connection id; injectable for tests. */
|
|
48
75
|
randomId?: () => string;
|
|
49
76
|
}
|
|
77
|
+
interface HubPresenceOptions {
|
|
78
|
+
/** The principals to track, or a predicate for open-ended ones such as anonymous listeners. */
|
|
79
|
+
principals: readonly string[] | ((principal: string) => boolean);
|
|
80
|
+
/**
|
|
81
|
+
* A socket whose last `ping` is older than this is closed as dead, which is how
|
|
82
|
+
* a peer that vanished without a close frame is noticed. Clients must ping more
|
|
83
|
+
* often than this. @default 90000
|
|
84
|
+
*/
|
|
85
|
+
timeoutMs?: number;
|
|
86
|
+
/**
|
|
87
|
+
* Called when a principal gains its first live socket or loses its last one,
|
|
88
|
+
* for example to `notify` a status path. The last reported state is kept in
|
|
89
|
+
* storage under `presence:<principal>`, so each change is reported once.
|
|
90
|
+
*/
|
|
91
|
+
onChange?: (principal: string, present: boolean) => void | Promise<void>;
|
|
92
|
+
}
|
|
50
93
|
/** Wires the hub core to a Durable Object's hibernatable WebSockets and storage. */
|
|
51
94
|
declare function createDurableObjectHub(ctx: DurableObjectStateLike, options: DurableObjectHubOptions): {
|
|
52
95
|
hub: {
|
|
53
|
-
message(socket: HubSocket, raw: string):
|
|
96
|
+
message(socket: HubSocket, raw: string): (Record<string, unknown> & {
|
|
97
|
+
type: string;
|
|
98
|
+
}) | undefined;
|
|
99
|
+
socketData<T = unknown>(socket: HubSocket): T | undefined;
|
|
100
|
+
setSocketData(socket: HubSocket, data: unknown): void;
|
|
101
|
+
principalOf(socket: HubSocket): string;
|
|
54
102
|
subscribe(request: SubscribeRequest, principal: string): Promise<SnapshotResponse>;
|
|
55
103
|
unsubscribe(request: UnsubscribeRequest, principal: string): void;
|
|
56
104
|
notify(path: string): Promise<void>;
|
|
@@ -58,9 +106,24 @@ declare function createDurableObjectHub(ctx: DurableObjectStateLike, options: Du
|
|
|
58
106
|
};
|
|
59
107
|
/** The Durable Object's `fetch`: WebSocket upgrades and actions from `hubClient`. */
|
|
60
108
|
fetch(request: Request): Promise<Response>;
|
|
61
|
-
webSocketMessage(socket: DurableObjectWebSocket, message: string | ArrayBuffer): void
|
|
62
|
-
webSocketClose(socket: DurableObjectWebSocket, code: number): void
|
|
63
|
-
webSocketError(socket: DurableObjectWebSocket): void
|
|
109
|
+
webSocketMessage(socket: DurableObjectWebSocket, message: string | ArrayBuffer): Promise<void>;
|
|
110
|
+
webSocketClose(socket: DurableObjectWebSocket, code: number): Promise<void>;
|
|
111
|
+
webSocketError(socket: DurableObjectWebSocket): Promise<void>;
|
|
112
|
+
/** Every accepted socket, including one whose close is being handled. */
|
|
113
|
+
sockets(): DurableObjectWebSocket[];
|
|
114
|
+
/** The principal a socket authenticated as. */
|
|
115
|
+
principalOf: (socket: DurableObjectWebSocket) => string;
|
|
116
|
+
/** Application data stored with a socket by `setSocketData`. */
|
|
117
|
+
socketData: <T = unknown>(socket: DurableObjectWebSocket) => T | undefined;
|
|
118
|
+
/** Stores application data with a socket; it survives hibernation and shares the 2 KB limit. */
|
|
119
|
+
setSocketData: (socket: DurableObjectWebSocket, data: unknown) => void;
|
|
120
|
+
/** Whether `principal` holds a socket that has pinged within the presence timeout. */
|
|
121
|
+
present(principal: string): boolean;
|
|
122
|
+
/**
|
|
123
|
+
* The Durable Object's `alarm` when presence is tracked: closes silent
|
|
124
|
+
* sockets, reports principals that lost their last one, and reschedules.
|
|
125
|
+
*/
|
|
126
|
+
alarm(): Promise<void>;
|
|
64
127
|
};
|
|
65
128
|
type DurableObjectHub = ReturnType<typeof createDurableObjectHub>;
|
|
66
129
|
/** Calls a hub Durable Object from the Worker. */
|
|
@@ -77,18 +140,27 @@ interface ReactiveRoutes {
|
|
|
77
140
|
socket: string;
|
|
78
141
|
/** Reactive HTTP subscribe and unsubscribe, authenticated by the ticket as a bearer token. */
|
|
79
142
|
reactive: string;
|
|
143
|
+
/** `POST { path, patches? }` from other backends, authenticated by `notifySecret`. */
|
|
144
|
+
notify?: string;
|
|
80
145
|
}
|
|
81
146
|
interface ReactiveRoutesOptions {
|
|
82
147
|
routes: ReactiveRoutes;
|
|
83
148
|
/** Ticket signing secret. Requests get 503 when it is not configured. */
|
|
84
149
|
secret: string | undefined;
|
|
150
|
+
/** Bearer secret for `routes.notify`; the route answers 503 while it is unset. */
|
|
151
|
+
notifySecret?: string | undefined;
|
|
152
|
+
/**
|
|
153
|
+
* Browser origins allowed to call the ticket and reactive routes cross-origin,
|
|
154
|
+
* for a hub on a different host from the app.
|
|
155
|
+
*/
|
|
156
|
+
allowedOrigins?: readonly string[];
|
|
85
157
|
/** The hub Durable Object stub that serves these routes. */
|
|
86
158
|
hub: () => HubStub;
|
|
87
159
|
/** Returns the principal for a ticket request, or undefined to answer 401. */
|
|
88
160
|
authenticate(request: Request): string | undefined | Promise<string | undefined>;
|
|
89
161
|
ticketLifetimeSeconds?: number;
|
|
90
162
|
}
|
|
91
|
-
/** Serves the ticket, socket and
|
|
163
|
+
/** Serves the ticket, socket, reactive and notify routes; `undefined` for any other path. */
|
|
92
164
|
declare function handleReactiveRoutes(request: Request, options: ReactiveRoutesOptions): Promise<Response | undefined>;
|
|
93
165
|
|
|
94
166
|
export { HUB_PRINCIPAL_HEADER, createDurableObjectHub, handleReactiveRoutes, hubClient };
|
package/dist/cloudflare.d.ts
CHANGED
|
@@ -6,7 +6,8 @@ import { HubSocket, HubPaths } from './hub.js';
|
|
|
6
6
|
* takes the Durable Object's `ctx` instead of extending `DurableObject`, so this
|
|
7
7
|
* entry imports nothing from `cloudflare:workers` and builds like every other
|
|
8
8
|
* entry. Your Durable Object class owns its name and migrations and delegates
|
|
9
|
-
* `fetch`, `webSocketMessage`, `webSocketClose` and `webSocketError` to it
|
|
9
|
+
* `fetch`, `webSocketMessage`, `webSocketClose` and `webSocketError` to it, plus
|
|
10
|
+
* `alarm` when it tracks presence.
|
|
10
11
|
*/
|
|
11
12
|
/** Set by the route handler on requests it forwards to the hub; never trusted from clients. */
|
|
12
13
|
declare const HUB_PRINCIPAL_HEADER = "x-reactive-principal";
|
|
@@ -19,9 +20,17 @@ interface DurableObjectStateLike {
|
|
|
19
20
|
acceptWebSocket(socket: DurableObjectWebSocket, tags?: string[]): void;
|
|
20
21
|
getWebSockets(tag?: string): DurableObjectWebSocket[];
|
|
21
22
|
setWebSocketAutoResponse?(pair?: unknown): void;
|
|
23
|
+
/** When the runtime last answered this socket's `ping` itself; needed for presence. */
|
|
24
|
+
getWebSocketAutoResponseTimestamp?(socket: DurableObjectWebSocket): Date | null;
|
|
22
25
|
storage: {
|
|
23
26
|
get<T>(key: string): Promise<T | undefined>;
|
|
24
27
|
put<T>(key: string, value: T): Promise<void>;
|
|
28
|
+
/** Lets presence forget principals that left instead of storing `false`. */
|
|
29
|
+
delete?(key: string): Promise<boolean>;
|
|
30
|
+
/** Needed for presence. */
|
|
31
|
+
getAlarm?(): Promise<number | null>;
|
|
32
|
+
/** Needed for presence. */
|
|
33
|
+
setAlarm?(scheduledTime: number): Promise<void>;
|
|
25
34
|
};
|
|
26
35
|
}
|
|
27
36
|
/** Anything with a Durable Object stub's `fetch`. */
|
|
@@ -32,6 +41,10 @@ type WebSocketPairConstructor = new () => {
|
|
|
32
41
|
0: DurableObjectWebSocket;
|
|
33
42
|
1: DurableObjectWebSocket;
|
|
34
43
|
};
|
|
44
|
+
/** A frame from the client that is not part of the protocol, such as `{ type: 'listening' }`. */
|
|
45
|
+
type HubClientFrame = Record<string, unknown> & {
|
|
46
|
+
type: string;
|
|
47
|
+
};
|
|
35
48
|
interface DurableObjectHubOptions {
|
|
36
49
|
paths: HubPaths;
|
|
37
50
|
/** Storage key for a path's version. Defaults to `version:<path>`. */
|
|
@@ -42,15 +55,50 @@ interface DurableObjectHubOptions {
|
|
|
42
55
|
* Defaults to true.
|
|
43
56
|
*/
|
|
44
57
|
autoPong?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Tracks whether principals hold a live socket, so the hub can report a
|
|
60
|
+
* worker or listener as online without it polling over HTTP. Requires `autoPong`.
|
|
61
|
+
*/
|
|
62
|
+
presence?: HubPresenceOptions;
|
|
63
|
+
/** Runs after a socket is accepted. */
|
|
64
|
+
onOpen?: (socket: DurableObjectWebSocket, principal: string) => void | Promise<void>;
|
|
65
|
+
/**
|
|
66
|
+
* Runs when a socket closes or errors. The socket may still be listed by
|
|
67
|
+
* `sockets()` during this call, so exclude it when counting.
|
|
68
|
+
*/
|
|
69
|
+
onClose?: (socket: DurableObjectWebSocket, principal: string) => void | Promise<void>;
|
|
70
|
+
/** Handles frames the protocol does not, such as application presence updates. */
|
|
71
|
+
onMessage?: (socket: DurableObjectWebSocket, principal: string, frame: HubClientFrame) => void | Promise<void>;
|
|
45
72
|
/** Workers' `WebSocketPair`; injectable for tests. */
|
|
46
73
|
WebSocketPair?: WebSocketPairConstructor;
|
|
47
74
|
/** Creates a connection id; injectable for tests. */
|
|
48
75
|
randomId?: () => string;
|
|
49
76
|
}
|
|
77
|
+
interface HubPresenceOptions {
|
|
78
|
+
/** The principals to track, or a predicate for open-ended ones such as anonymous listeners. */
|
|
79
|
+
principals: readonly string[] | ((principal: string) => boolean);
|
|
80
|
+
/**
|
|
81
|
+
* A socket whose last `ping` is older than this is closed as dead, which is how
|
|
82
|
+
* a peer that vanished without a close frame is noticed. Clients must ping more
|
|
83
|
+
* often than this. @default 90000
|
|
84
|
+
*/
|
|
85
|
+
timeoutMs?: number;
|
|
86
|
+
/**
|
|
87
|
+
* Called when a principal gains its first live socket or loses its last one,
|
|
88
|
+
* for example to `notify` a status path. The last reported state is kept in
|
|
89
|
+
* storage under `presence:<principal>`, so each change is reported once.
|
|
90
|
+
*/
|
|
91
|
+
onChange?: (principal: string, present: boolean) => void | Promise<void>;
|
|
92
|
+
}
|
|
50
93
|
/** Wires the hub core to a Durable Object's hibernatable WebSockets and storage. */
|
|
51
94
|
declare function createDurableObjectHub(ctx: DurableObjectStateLike, options: DurableObjectHubOptions): {
|
|
52
95
|
hub: {
|
|
53
|
-
message(socket: HubSocket, raw: string):
|
|
96
|
+
message(socket: HubSocket, raw: string): (Record<string, unknown> & {
|
|
97
|
+
type: string;
|
|
98
|
+
}) | undefined;
|
|
99
|
+
socketData<T = unknown>(socket: HubSocket): T | undefined;
|
|
100
|
+
setSocketData(socket: HubSocket, data: unknown): void;
|
|
101
|
+
principalOf(socket: HubSocket): string;
|
|
54
102
|
subscribe(request: SubscribeRequest, principal: string): Promise<SnapshotResponse>;
|
|
55
103
|
unsubscribe(request: UnsubscribeRequest, principal: string): void;
|
|
56
104
|
notify(path: string): Promise<void>;
|
|
@@ -58,9 +106,24 @@ declare function createDurableObjectHub(ctx: DurableObjectStateLike, options: Du
|
|
|
58
106
|
};
|
|
59
107
|
/** The Durable Object's `fetch`: WebSocket upgrades and actions from `hubClient`. */
|
|
60
108
|
fetch(request: Request): Promise<Response>;
|
|
61
|
-
webSocketMessage(socket: DurableObjectWebSocket, message: string | ArrayBuffer): void
|
|
62
|
-
webSocketClose(socket: DurableObjectWebSocket, code: number): void
|
|
63
|
-
webSocketError(socket: DurableObjectWebSocket): void
|
|
109
|
+
webSocketMessage(socket: DurableObjectWebSocket, message: string | ArrayBuffer): Promise<void>;
|
|
110
|
+
webSocketClose(socket: DurableObjectWebSocket, code: number): Promise<void>;
|
|
111
|
+
webSocketError(socket: DurableObjectWebSocket): Promise<void>;
|
|
112
|
+
/** Every accepted socket, including one whose close is being handled. */
|
|
113
|
+
sockets(): DurableObjectWebSocket[];
|
|
114
|
+
/** The principal a socket authenticated as. */
|
|
115
|
+
principalOf: (socket: DurableObjectWebSocket) => string;
|
|
116
|
+
/** Application data stored with a socket by `setSocketData`. */
|
|
117
|
+
socketData: <T = unknown>(socket: DurableObjectWebSocket) => T | undefined;
|
|
118
|
+
/** Stores application data with a socket; it survives hibernation and shares the 2 KB limit. */
|
|
119
|
+
setSocketData: (socket: DurableObjectWebSocket, data: unknown) => void;
|
|
120
|
+
/** Whether `principal` holds a socket that has pinged within the presence timeout. */
|
|
121
|
+
present(principal: string): boolean;
|
|
122
|
+
/**
|
|
123
|
+
* The Durable Object's `alarm` when presence is tracked: closes silent
|
|
124
|
+
* sockets, reports principals that lost their last one, and reschedules.
|
|
125
|
+
*/
|
|
126
|
+
alarm(): Promise<void>;
|
|
64
127
|
};
|
|
65
128
|
type DurableObjectHub = ReturnType<typeof createDurableObjectHub>;
|
|
66
129
|
/** Calls a hub Durable Object from the Worker. */
|
|
@@ -77,18 +140,27 @@ interface ReactiveRoutes {
|
|
|
77
140
|
socket: string;
|
|
78
141
|
/** Reactive HTTP subscribe and unsubscribe, authenticated by the ticket as a bearer token. */
|
|
79
142
|
reactive: string;
|
|
143
|
+
/** `POST { path, patches? }` from other backends, authenticated by `notifySecret`. */
|
|
144
|
+
notify?: string;
|
|
80
145
|
}
|
|
81
146
|
interface ReactiveRoutesOptions {
|
|
82
147
|
routes: ReactiveRoutes;
|
|
83
148
|
/** Ticket signing secret. Requests get 503 when it is not configured. */
|
|
84
149
|
secret: string | undefined;
|
|
150
|
+
/** Bearer secret for `routes.notify`; the route answers 503 while it is unset. */
|
|
151
|
+
notifySecret?: string | undefined;
|
|
152
|
+
/**
|
|
153
|
+
* Browser origins allowed to call the ticket and reactive routes cross-origin,
|
|
154
|
+
* for a hub on a different host from the app.
|
|
155
|
+
*/
|
|
156
|
+
allowedOrigins?: readonly string[];
|
|
85
157
|
/** The hub Durable Object stub that serves these routes. */
|
|
86
158
|
hub: () => HubStub;
|
|
87
159
|
/** Returns the principal for a ticket request, or undefined to answer 401. */
|
|
88
160
|
authenticate(request: Request): string | undefined | Promise<string | undefined>;
|
|
89
161
|
ticketLifetimeSeconds?: number;
|
|
90
162
|
}
|
|
91
|
-
/** Serves the ticket, socket and
|
|
163
|
+
/** Serves the ticket, socket, reactive and notify routes; `undefined` for any other path. */
|
|
92
164
|
declare function handleReactiveRoutes(request: Request, options: ReactiveRoutesOptions): Promise<Response | undefined>;
|
|
93
165
|
|
|
94
166
|
export { HUB_PRINCIPAL_HEADER, createDurableObjectHub, handleReactiveRoutes, hubClient };
|
package/dist/cloudflare.js
CHANGED
|
@@ -39,7 +39,11 @@ async function createSignedClusterTicket(options) {
|
|
|
39
39
|
encoder.encode(JSON.stringify({ p: options.principal, exp: expiresAt }))
|
|
40
40
|
);
|
|
41
41
|
const signature = new Uint8Array(
|
|
42
|
-
await crypto.subtle.sign(
|
|
42
|
+
await crypto.subtle.sign(
|
|
43
|
+
"HMAC",
|
|
44
|
+
await hmacKey(options.secret, "sign"),
|
|
45
|
+
encoder.encode(payload)
|
|
46
|
+
)
|
|
43
47
|
);
|
|
44
48
|
return parseClusterTicket({
|
|
45
49
|
token: `${payload}.${base64UrlEncode(signature)}`,
|
|
@@ -48,8 +52,10 @@ async function createSignedClusterTicket(options) {
|
|
|
48
52
|
});
|
|
49
53
|
}
|
|
50
54
|
async function verifySignedClusterTicket(token, secret, now = Date.now()) {
|
|
51
|
-
const
|
|
52
|
-
if (
|
|
55
|
+
const parts = token.split(".");
|
|
56
|
+
if (parts.length !== 2) return void 0;
|
|
57
|
+
const [payload, signature] = parts;
|
|
58
|
+
if (!payload || !signature) return void 0;
|
|
53
59
|
try {
|
|
54
60
|
const valid = await crypto.subtle.verify(
|
|
55
61
|
"HMAC",
|
|
@@ -87,8 +93,13 @@ function bearerToken(request) {
|
|
|
87
93
|
const match = /^Bearer ([^\s]+)$/u.exec(request.headers.get("authorization") ?? "");
|
|
88
94
|
return match?.[1] ?? "";
|
|
89
95
|
}
|
|
90
|
-
function createHubSocketState(connectionId, principal) {
|
|
91
|
-
return {
|
|
96
|
+
function createHubSocketState(connectionId, principal, connectedAt) {
|
|
97
|
+
return {
|
|
98
|
+
connectionId,
|
|
99
|
+
principal,
|
|
100
|
+
subscriptions: [],
|
|
101
|
+
...connectedAt === void 0 ? {} : { connectedAt }
|
|
102
|
+
};
|
|
92
103
|
}
|
|
93
104
|
function socketState(socket) {
|
|
94
105
|
const state = socket.deserializeAttachment();
|
|
@@ -118,20 +129,42 @@ function createReactiveHub(options) {
|
|
|
118
129
|
return { fromVersion, toVersion };
|
|
119
130
|
};
|
|
120
131
|
return {
|
|
121
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* Answers the client's `init` and `ping` frames. Any other frame with a
|
|
134
|
+
* string `type` is returned for the application to handle.
|
|
135
|
+
*/
|
|
122
136
|
message(socket, raw) {
|
|
123
137
|
let frame;
|
|
124
138
|
try {
|
|
125
139
|
frame = JSON.parse(raw);
|
|
126
140
|
} catch {
|
|
127
|
-
return;
|
|
141
|
+
return void 0;
|
|
128
142
|
}
|
|
129
|
-
|
|
130
|
-
if (type === "init") {
|
|
143
|
+
if (!isRecord2(frame) || typeof frame.type !== "string") return void 0;
|
|
144
|
+
if (frame.type === "init") {
|
|
131
145
|
send(socket, { type: "connected", connectionId: socketState(socket).connectionId });
|
|
132
|
-
} else if (type === "ping") {
|
|
146
|
+
} else if (frame.type === "ping") {
|
|
133
147
|
send(socket, { type: "pong" });
|
|
148
|
+
} else {
|
|
149
|
+
return frame;
|
|
150
|
+
}
|
|
151
|
+
return void 0;
|
|
152
|
+
},
|
|
153
|
+
/** The application data stored with a socket. */
|
|
154
|
+
socketData(socket) {
|
|
155
|
+
return socketState(socket).data;
|
|
156
|
+
},
|
|
157
|
+
/** Stores application data with a socket; it shares the attachment size limit. */
|
|
158
|
+
setSocketData(socket, data) {
|
|
159
|
+
const next = { ...socketState(socket), data };
|
|
160
|
+
if (new TextEncoder().encode(JSON.stringify(next)).length > maxAttachmentBytes) {
|
|
161
|
+
throw new ReactiveHubError("Socket data is too large");
|
|
134
162
|
}
|
|
163
|
+
socket.serializeAttachment(next);
|
|
164
|
+
},
|
|
165
|
+
/** The principal a socket authenticated as. */
|
|
166
|
+
principalOf(socket) {
|
|
167
|
+
return socketState(socket).principal;
|
|
135
168
|
},
|
|
136
169
|
async subscribe(request, principal) {
|
|
137
170
|
const handler = resolve(request.path);
|
|
@@ -149,9 +182,7 @@ function createReactiveHub(options) {
|
|
|
149
182
|
const next = {
|
|
150
183
|
...state,
|
|
151
184
|
subscriptions: [
|
|
152
|
-
...state.subscriptions.filter(
|
|
153
|
-
(entry) => entry.subscriptionId !== request.subscriptionId
|
|
154
|
-
),
|
|
185
|
+
...state.subscriptions.filter((entry) => entry.subscriptionId !== request.subscriptionId),
|
|
155
186
|
{
|
|
156
187
|
subscriptionId: request.subscriptionId,
|
|
157
188
|
generation: request.generation,
|
|
@@ -222,6 +253,11 @@ function createReactiveHub(options) {
|
|
|
222
253
|
// ../client/src/cloudflare.ts
|
|
223
254
|
var HUB_PRINCIPAL_HEADER = "x-reactive-principal";
|
|
224
255
|
var HUB_ACTION_URL = "https://reactive-hub/";
|
|
256
|
+
var PRESENCE_TAG = "presence";
|
|
257
|
+
var PRESENCE_TAG_PREFIX = "presence:";
|
|
258
|
+
var PRESENCE_KEY_PREFIX = "presence:";
|
|
259
|
+
var DEFAULT_PRESENCE_TIMEOUT_MS = 9e4;
|
|
260
|
+
var HEARTBEAT_TIMEOUT_CLOSE_CODE = 4001;
|
|
225
261
|
function json(value, status = 200) {
|
|
226
262
|
return Response.json(value, { status, headers: { "cache-control": "no-store" } });
|
|
227
263
|
}
|
|
@@ -255,15 +291,66 @@ function createDurableObjectHub(ctx, options) {
|
|
|
255
291
|
);
|
|
256
292
|
}
|
|
257
293
|
}
|
|
258
|
-
const
|
|
294
|
+
const presence = options.presence;
|
|
295
|
+
const timeoutMs = presence?.timeoutMs ?? DEFAULT_PRESENCE_TIMEOUT_MS;
|
|
296
|
+
const isTracked = (principal) => {
|
|
297
|
+
if (!presence) return false;
|
|
298
|
+
const { principals } = presence;
|
|
299
|
+
return typeof principals === "function" ? principals(principal) : principals.includes(principal);
|
|
300
|
+
};
|
|
301
|
+
const principalOf = (socket) => socket.deserializeAttachment().principal;
|
|
302
|
+
const lastSeen = (socket) => Math.max(
|
|
303
|
+
ctx.getWebSocketAutoResponseTimestamp?.(socket)?.getTime() ?? 0,
|
|
304
|
+
socket.deserializeAttachment().connectedAt ?? 0
|
|
305
|
+
);
|
|
306
|
+
const liveSockets = (principal, closing) => {
|
|
307
|
+
const now = Date.now();
|
|
308
|
+
return ctx.getWebSockets(PRESENCE_TAG_PREFIX + principal).filter((socket) => socket !== closing && now - lastSeen(socket) <= timeoutMs);
|
|
309
|
+
};
|
|
310
|
+
const reconcile = async (principal, closing) => {
|
|
311
|
+
const present = liveSockets(principal, closing).length > 0;
|
|
312
|
+
const key = PRESENCE_KEY_PREFIX + principal;
|
|
313
|
+
if ((await ctx.storage.get(key) ?? false) === present) return;
|
|
314
|
+
if (present || !ctx.storage.delete) await ctx.storage.put(key, present);
|
|
315
|
+
else await ctx.storage.delete(key);
|
|
316
|
+
await presence?.onChange?.(principal, present);
|
|
317
|
+
};
|
|
318
|
+
const scheduleSweep = async () => {
|
|
319
|
+
const { storage } = ctx;
|
|
320
|
+
if (!storage.getAlarm || !storage.setAlarm) {
|
|
321
|
+
throw new Error("Hub presence needs Durable Object alarms");
|
|
322
|
+
}
|
|
323
|
+
const seen = ctx.getWebSockets(PRESENCE_TAG).map((socket) => lastSeen(socket));
|
|
324
|
+
if (seen.length === 0) return;
|
|
325
|
+
const due = Math.min(...seen) + timeoutMs + 1;
|
|
326
|
+
const current = await storage.getAlarm();
|
|
327
|
+
if (current === null || current > due) await storage.setAlarm(due);
|
|
328
|
+
};
|
|
329
|
+
const closed = async (socket) => {
|
|
330
|
+
const principal = principalOf(socket);
|
|
331
|
+
if (isTracked(principal)) await reconcile(principal, socket);
|
|
332
|
+
await options.onClose?.(socket, principal);
|
|
333
|
+
};
|
|
334
|
+
const accept = async (request) => {
|
|
259
335
|
const principal = request.headers.get(HUB_PRINCIPAL_HEADER);
|
|
260
336
|
if (!principal) return error("Unauthorized", 401);
|
|
261
337
|
const Pair = options.WebSocketPair ?? workersGlobal("WebSocketPair");
|
|
262
338
|
if (!Pair) throw new Error("WebSocketPair is not available in this runtime");
|
|
263
339
|
const pair = new Pair();
|
|
264
340
|
const connectionId = randomId();
|
|
265
|
-
|
|
266
|
-
|
|
341
|
+
const tracked = isTracked(principal);
|
|
342
|
+
ctx.acceptWebSocket(
|
|
343
|
+
pair[1],
|
|
344
|
+
tracked ? [connectionId, PRESENCE_TAG, PRESENCE_TAG_PREFIX + principal] : [connectionId]
|
|
345
|
+
);
|
|
346
|
+
pair[1].serializeAttachment(
|
|
347
|
+
createHubSocketState(connectionId, principal, tracked ? Date.now() : void 0)
|
|
348
|
+
);
|
|
349
|
+
if (tracked) {
|
|
350
|
+
await reconcile(principal);
|
|
351
|
+
await scheduleSweep();
|
|
352
|
+
}
|
|
353
|
+
await options.onOpen?.(pair[1], principal);
|
|
267
354
|
return new Response(null, { status: 101, webSocket: pair[0] });
|
|
268
355
|
};
|
|
269
356
|
const act = async (action) => {
|
|
@@ -295,20 +382,60 @@ function createDurableObjectHub(ctx, options) {
|
|
|
295
382
|
if (request.headers.get("upgrade") === "websocket") return accept(request);
|
|
296
383
|
return act(await request.json());
|
|
297
384
|
},
|
|
298
|
-
webSocketMessage(socket, message) {
|
|
299
|
-
if (typeof message
|
|
385
|
+
async webSocketMessage(socket, message) {
|
|
386
|
+
if (typeof message !== "string") return;
|
|
387
|
+
const frame = hub.message(socket, message);
|
|
388
|
+
if (frame && options.onMessage) await options.onMessage(socket, principalOf(socket), frame);
|
|
300
389
|
},
|
|
301
|
-
webSocketClose(socket, code) {
|
|
390
|
+
async webSocketClose(socket, code) {
|
|
302
391
|
try {
|
|
303
392
|
socket.close(code === 1005 ? 1e3 : code, "closed");
|
|
304
393
|
} catch {
|
|
305
394
|
}
|
|
395
|
+
await closed(socket);
|
|
306
396
|
},
|
|
307
|
-
webSocketError(socket) {
|
|
397
|
+
async webSocketError(socket) {
|
|
308
398
|
try {
|
|
309
399
|
socket.close(1011, "error");
|
|
310
400
|
} catch {
|
|
311
401
|
}
|
|
402
|
+
await closed(socket);
|
|
403
|
+
},
|
|
404
|
+
/** Every accepted socket, including one whose close is being handled. */
|
|
405
|
+
sockets() {
|
|
406
|
+
return ctx.getWebSockets();
|
|
407
|
+
},
|
|
408
|
+
/** The principal a socket authenticated as. */
|
|
409
|
+
principalOf,
|
|
410
|
+
/** Application data stored with a socket by `setSocketData`. */
|
|
411
|
+
socketData: (socket) => hub.socketData(socket),
|
|
412
|
+
/** Stores application data with a socket; it survives hibernation and shares the 2 KB limit. */
|
|
413
|
+
setSocketData: (socket, data) => hub.setSocketData(socket, data),
|
|
414
|
+
/** Whether `principal` holds a socket that has pinged within the presence timeout. */
|
|
415
|
+
present(principal) {
|
|
416
|
+
return liveSockets(principal).length > 0;
|
|
417
|
+
},
|
|
418
|
+
/**
|
|
419
|
+
* The Durable Object's `alarm` when presence is tracked: closes silent
|
|
420
|
+
* sockets, reports principals that lost their last one, and reschedules.
|
|
421
|
+
*/
|
|
422
|
+
async alarm() {
|
|
423
|
+
const now = Date.now();
|
|
424
|
+
const principals = /* @__PURE__ */ new Set();
|
|
425
|
+
for (const socket of ctx.getWebSockets(PRESENCE_TAG)) {
|
|
426
|
+
const principal = principalOf(socket);
|
|
427
|
+
principals.add(principal);
|
|
428
|
+
if (now - lastSeen(socket) <= timeoutMs) continue;
|
|
429
|
+
try {
|
|
430
|
+
socket.close(HEARTBEAT_TIMEOUT_CLOSE_CODE, "Heartbeat timeout");
|
|
431
|
+
} catch {
|
|
432
|
+
}
|
|
433
|
+
await options.onClose?.(socket, principal);
|
|
434
|
+
}
|
|
435
|
+
for (const principal of principals) {
|
|
436
|
+
await reconcile(principal);
|
|
437
|
+
}
|
|
438
|
+
await scheduleSweep();
|
|
312
439
|
}
|
|
313
440
|
};
|
|
314
441
|
}
|
|
@@ -326,24 +453,84 @@ function hubClient(stub) {
|
|
|
326
453
|
publish: async (path, patches) => ok(await post({ action: "publish", path, patches }))
|
|
327
454
|
};
|
|
328
455
|
}
|
|
456
|
+
function sameSecret(given, expected) {
|
|
457
|
+
const left = new TextEncoder().encode(given);
|
|
458
|
+
const right = new TextEncoder().encode(expected);
|
|
459
|
+
let difference = left.length ^ right.length;
|
|
460
|
+
for (let index = 0; index < right.length; index++) {
|
|
461
|
+
difference |= (left[index] ?? 0) ^ right[index];
|
|
462
|
+
}
|
|
463
|
+
return difference === 0;
|
|
464
|
+
}
|
|
465
|
+
function withCors(response, origin) {
|
|
466
|
+
if (!origin) return response;
|
|
467
|
+
const headers = new Headers(response.headers);
|
|
468
|
+
headers.set("access-control-allow-origin", origin);
|
|
469
|
+
headers.append("vary", "origin");
|
|
470
|
+
return new Response(response.body, {
|
|
471
|
+
status: response.status,
|
|
472
|
+
statusText: response.statusText,
|
|
473
|
+
headers
|
|
474
|
+
});
|
|
475
|
+
}
|
|
329
476
|
async function handleReactiveRoutes(request, options) {
|
|
330
477
|
const url = new URL(request.url);
|
|
331
478
|
const { routes, secret } = options;
|
|
332
|
-
|
|
333
|
-
if (!
|
|
334
|
-
|
|
479
|
+
const known = [routes.ticket, routes.socket, routes.reactive, routes.notify];
|
|
480
|
+
if (!known.includes(url.pathname)) return void 0;
|
|
481
|
+
const requestOrigin = request.headers.get("origin");
|
|
482
|
+
const corsOrigin = requestOrigin && options.allowedOrigins?.includes(requestOrigin) ? requestOrigin : null;
|
|
483
|
+
if (request.method === "OPTIONS" && url.pathname !== routes.socket) {
|
|
484
|
+
if (!corsOrigin) return new Response(null, { status: 403 });
|
|
485
|
+
return new Response(null, {
|
|
486
|
+
status: 204,
|
|
487
|
+
headers: {
|
|
488
|
+
"access-control-allow-origin": corsOrigin,
|
|
489
|
+
"access-control-allow-methods": "POST, OPTIONS",
|
|
490
|
+
"access-control-allow-headers": "authorization, content-type",
|
|
491
|
+
"access-control-max-age": "86400",
|
|
492
|
+
vary: "origin"
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
if (url.pathname === routes.notify) {
|
|
335
497
|
if (request.method !== "POST") return error("Method not allowed", 405);
|
|
498
|
+
if (!options.notifySecret) return error("Hub notify is not configured", 503);
|
|
499
|
+
if (!sameSecret(bearerToken(request), options.notifySecret)) {
|
|
500
|
+
return error("Unauthorized", 401);
|
|
501
|
+
}
|
|
502
|
+
let body2;
|
|
503
|
+
try {
|
|
504
|
+
body2 = await request.json();
|
|
505
|
+
} catch {
|
|
506
|
+
return error("Invalid JSON", 400);
|
|
507
|
+
}
|
|
508
|
+
const { path, patches } = body2 ?? {};
|
|
509
|
+
if (typeof path !== "string" || patches !== void 0 && !Array.isArray(patches)) {
|
|
510
|
+
return error("Expected { path, patches? }", 400);
|
|
511
|
+
}
|
|
512
|
+
const client = hubClient(options.hub());
|
|
513
|
+
if (patches) await client.publish(path, patches);
|
|
514
|
+
else await client.notify(path);
|
|
515
|
+
return json({ type: "ok" });
|
|
516
|
+
}
|
|
517
|
+
if (!secret) return withCors(error("Reactive hub is not configured", 503), corsOrigin);
|
|
518
|
+
if (url.pathname === routes.ticket) {
|
|
519
|
+
if (request.method !== "POST") return withCors(error("Method not allowed", 405), corsOrigin);
|
|
336
520
|
const principal2 = await options.authenticate(request);
|
|
337
|
-
if (!principal2) return error("Unauthorized", 401);
|
|
521
|
+
if (!principal2) return withCors(error("Unauthorized", 401), corsOrigin);
|
|
338
522
|
const socketUrl = new URL(routes.socket, url);
|
|
339
523
|
socketUrl.protocol = "wss:";
|
|
340
|
-
return
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
524
|
+
return withCors(
|
|
525
|
+
json(
|
|
526
|
+
await createSignedClusterTicket({
|
|
527
|
+
secret,
|
|
528
|
+
principal: principal2,
|
|
529
|
+
url: socketUrl.toString(),
|
|
530
|
+
...options.ticketLifetimeSeconds === void 0 ? {} : { lifetimeSeconds: options.ticketLifetimeSeconds }
|
|
531
|
+
})
|
|
532
|
+
),
|
|
533
|
+
corsOrigin
|
|
347
534
|
);
|
|
348
535
|
}
|
|
349
536
|
if (url.pathname === routes.socket) {
|
|
@@ -356,17 +543,17 @@ async function handleReactiveRoutes(request, options) {
|
|
|
356
543
|
forwarded.headers.set(HUB_PRINCIPAL_HEADER, principal2);
|
|
357
544
|
return options.hub().fetch(forwarded);
|
|
358
545
|
}
|
|
359
|
-
if (request.method !== "POST") return error("Method not allowed", 405);
|
|
546
|
+
if (request.method !== "POST") return withCors(error("Method not allowed", 405), corsOrigin);
|
|
360
547
|
const principal = await verifySignedClusterTicket(bearerToken(request), secret);
|
|
361
|
-
if (!principal) return error("Unauthorized", 401);
|
|
548
|
+
if (!principal) return withCors(error("Unauthorized", 401), corsOrigin);
|
|
362
549
|
let body;
|
|
363
550
|
try {
|
|
364
551
|
body = await request.json();
|
|
365
552
|
} catch {
|
|
366
|
-
return error("Invalid JSON", 400);
|
|
553
|
+
return withCors(error("Invalid JSON", 400), corsOrigin);
|
|
367
554
|
}
|
|
368
|
-
if (!isHubRequest(body)) return error("Unsupported reactive request", 400);
|
|
369
|
-
return hubClient(options.hub()).request(body, principal);
|
|
555
|
+
if (!isHubRequest(body)) return withCors(error("Unsupported reactive request", 400), corsOrigin);
|
|
556
|
+
return withCors(await hubClient(options.hub()).request(body, principal), corsOrigin);
|
|
370
557
|
}
|
|
371
558
|
|
|
372
559
|
export { HUB_PRINCIPAL_HEADER, createDurableObjectHub, handleReactiveRoutes, hubClient };
|