@rdlabo/workers-hono-kit 0.7.2 → 0.9.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 +0 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/realtime/index.d.ts +4 -2
- package/dist/realtime/index.js +2 -1
- package/dist/realtime/invoke.d.ts +30 -0
- package/dist/realtime/invoke.js +27 -0
- package/dist/realtime/protocol.d.ts +22 -0
- package/dist/realtime/protocol.js +33 -0
- package/package.json +5 -3
- package/scripts/check-realtime-bundle.mjs +14 -0
- package/scripts/clean-dist.mjs +3 -0
- package/scripts/query-realtime-do-metrics.mjs +68 -0
- package/dist/realtime/legacy-sse.d.ts +0 -25
- package/dist/realtime/legacy-sse.js +0 -110
package/README.md
CHANGED
|
@@ -85,7 +85,6 @@ npm install ai ai-gateway-provider # createAiGatewayProvider
|
|
|
85
85
|
| `DeferExecutor` / `defaultDefer` / `createWaitUntilDefer(ctx)` | Fire-and-forget executor for Workers: `defaultDefer` swallows rejections (tests); `createWaitUntilDefer` registers work via `ctx.waitUntil`. |
|
|
86
86
|
| `configureHibernationAutoResponse` / `upgradeHibernationWebSocket` / `broadcastHibernationWebSockets` | Hibernation WebSocket room primitives: runtime ping/pong without waking JavaScript, attachment-before-accept upgrade, and broadcast through sockets restored by `getWebSockets()`. |
|
|
87
87
|
| `acknowledgeHibernationWebSocketClose` / `closeHibernationWebSocket` | Safe close helpers, including normalization of reserved received-only close codes. |
|
|
88
|
-
| `createLegacySseBridge(options)` | Migration adapter from legacy SSE clients to one or more Hibernation WebSocket rooms. Downstream cancellation aborts pending upgrades and closes late-arriving sockets. |
|
|
89
88
|
| `retryDurableObjectOperation(operation, options?)` / `isRetryableDurableObjectError(error)` | Retry idempotent DO work only for `retryable && !overloaded`, with jittered exponential backoff. `operation` runs per attempt so callers create a fresh stub after an exception. |
|
|
90
89
|
| `createAiGatewayProvider(config)` / `AiGatewayConfig` / `AiGatewayProvider` | Route `@ai-sdk` models through the Cloudflare AI Gateway, via either a Workers `AI` binding or REST credentials (`accountId` / `gateway` / `token`). |
|
|
91
90
|
| `KVCache` / `KVNamespace` / `KVCacheOptions` | Workers-KV cache-aside helper (key `appName+version+table_type_column`, sha256 for string ids, TTL clamped ≥60s). Set `appName` / `version` per application. |
|
package/dist/index.d.ts
CHANGED
|
@@ -49,10 +49,12 @@ export { createSentryErrorReporter } from './http/http-error.js';
|
|
|
49
49
|
export type { SentryExceptionReporterLike } from './http/http-error.js';
|
|
50
50
|
export { acknowledgeHibernationWebSocketClose, broadcastHibernationWebSockets, closeHibernationWebSocket, configureHibernationAutoResponse, upgradeHibernationWebSocket, } from './realtime/hibernation.js';
|
|
51
51
|
export type { HibernationAutoResponseOptions, HibernationUpgradeOptions, HibernationWebSocketLike, HibernationWebSocketStateLike, WebSocketAutoResponsePairFactory, WebSocketPairFactory, } from './realtime/hibernation.js';
|
|
52
|
-
export { createLegacySseBridge } from './realtime/legacy-sse.js';
|
|
53
|
-
export type { LegacySseBridgeOptions, RealtimeDurableObjectNamespaceLike } from './realtime/legacy-sse.js';
|
|
54
52
|
export { isRetryableDurableObjectError, retryDurableObjectOperation } from './realtime/retry.js';
|
|
55
53
|
export type { DurableObjectErrorLike, DurableObjectRetryOptions } from './realtime/retry.js';
|
|
54
|
+
export { DurableObjectResponseError, invokeDurableObjectFetch } from './realtime/invoke.js';
|
|
55
|
+
export type { DurableObjectFetchRequest, DurableObjectFetchStubLike, InvokeDurableObjectFetchOptions, } from './realtime/invoke.js';
|
|
56
|
+
export { parseRealtimeWebSocketProtocolOffer, parseWebSocketProtocols } from './realtime/protocol.js';
|
|
57
|
+
export type { ParseRealtimeWebSocketProtocolOptions, RealtimeWebSocketProtocolOffer } from './realtime/protocol.js';
|
|
56
58
|
export { KVCache } from './cache/kv-cache.js';
|
|
57
59
|
export type { KVNamespace, KVCacheOptions } from './cache/kv-cache.js';
|
|
58
60
|
export { createStripeClient, verifyStripeWebhook } from './stripe/client.js';
|
package/dist/index.js
CHANGED
|
@@ -37,8 +37,9 @@ export { defaultDefer, createWaitUntilDefer } from './http/defer.js';
|
|
|
37
37
|
export { createSentryErrorReporter } from './http/http-error.js';
|
|
38
38
|
// realtime
|
|
39
39
|
export { acknowledgeHibernationWebSocketClose, broadcastHibernationWebSockets, closeHibernationWebSocket, configureHibernationAutoResponse, upgradeHibernationWebSocket, } from './realtime/hibernation.js';
|
|
40
|
-
export { createLegacySseBridge } from './realtime/legacy-sse.js';
|
|
41
40
|
export { isRetryableDurableObjectError, retryDurableObjectOperation } from './realtime/retry.js';
|
|
41
|
+
export { DurableObjectResponseError, invokeDurableObjectFetch } from './realtime/invoke.js';
|
|
42
|
+
export { parseRealtimeWebSocketProtocolOffer, parseWebSocketProtocols } from './realtime/protocol.js';
|
|
42
43
|
// cache
|
|
43
44
|
export { KVCache } from './cache/kv-cache.js';
|
|
44
45
|
// stripe
|
package/dist/realtime/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { acknowledgeHibernationWebSocketClose, broadcastHibernationWebSockets, closeHibernationWebSocket, configureHibernationAutoResponse, upgradeHibernationWebSocket, } from './hibernation.js';
|
|
2
2
|
export type { HibernationAutoResponseOptions, HibernationUpgradeOptions, HibernationWebSocketLike, HibernationWebSocketStateLike, WebSocketAutoResponsePairFactory, WebSocketPairFactory, } from './hibernation.js';
|
|
3
|
-
export { createLegacySseBridge } from './legacy-sse.js';
|
|
4
|
-
export type { LegacySseBridgeOptions, RealtimeDurableObjectNamespaceLike } from './legacy-sse.js';
|
|
5
3
|
export { isRetryableDurableObjectError, retryDurableObjectOperation } from './retry.js';
|
|
6
4
|
export type { DurableObjectErrorLike, DurableObjectRetryOptions } from './retry.js';
|
|
5
|
+
export { DurableObjectResponseError, invokeDurableObjectFetch } from './invoke.js';
|
|
6
|
+
export type { DurableObjectFetchRequest, DurableObjectFetchStubLike, InvokeDurableObjectFetchOptions, } from './invoke.js';
|
|
7
|
+
export { parseRealtimeWebSocketProtocolOffer, parseWebSocketProtocols } from './protocol.js';
|
|
8
|
+
export type { ParseRealtimeWebSocketProtocolOptions, RealtimeWebSocketProtocolOffer } from './protocol.js';
|
package/dist/realtime/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { acknowledgeHibernationWebSocketClose, broadcastHibernationWebSockets, closeHibernationWebSocket, configureHibernationAutoResponse, upgradeHibernationWebSocket, } from './hibernation.js';
|
|
2
|
-
export { createLegacySseBridge } from './legacy-sse.js';
|
|
3
2
|
export { isRetryableDurableObjectError, retryDurableObjectOperation } from './retry.js';
|
|
3
|
+
export { DurableObjectResponseError, invokeDurableObjectFetch } from './invoke.js';
|
|
4
|
+
export { parseRealtimeWebSocketProtocolOffer, parseWebSocketProtocols } from './protocol.js';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { DurableObjectRetryOptions } from './retry.js';
|
|
2
|
+
/** Minimal fetch surface implemented by a Durable Object stub. */
|
|
3
|
+
export interface DurableObjectFetchStubLike {
|
|
4
|
+
fetch(input: Request | string | URL, init?: RequestInit): Promise<Response>;
|
|
5
|
+
}
|
|
6
|
+
/** A freshly-created request for one Durable Object invocation attempt. */
|
|
7
|
+
export interface DurableObjectFetchRequest {
|
|
8
|
+
input: Request | string | URL;
|
|
9
|
+
init?: RequestInit;
|
|
10
|
+
}
|
|
11
|
+
/** Options for invoking a Durable Object fetch endpoint safely. */
|
|
12
|
+
export interface InvokeDurableObjectFetchOptions {
|
|
13
|
+
getStub: () => DurableObjectFetchStubLike;
|
|
14
|
+
createRequest: () => DurableObjectFetchRequest;
|
|
15
|
+
retry?: boolean;
|
|
16
|
+
retryOptions?: DurableObjectRetryOptions;
|
|
17
|
+
errorMessage?: string;
|
|
18
|
+
}
|
|
19
|
+
/** HTTP response failure returned explicitly by a Durable Object fetch handler. */
|
|
20
|
+
export declare class DurableObjectResponseError extends Error {
|
|
21
|
+
readonly response: Response;
|
|
22
|
+
constructor(message: string, response: Response);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Invoke a Durable Object fetch endpoint with a fresh stub and request for every attempt.
|
|
26
|
+
*
|
|
27
|
+
* Non-2xx responses always throw. Runtime retryable exceptions are retried only when `retry` is
|
|
28
|
+
* explicitly enabled, allowing callers to keep non-idempotent operations at-most-once.
|
|
29
|
+
*/
|
|
30
|
+
export declare function invokeDurableObjectFetch(options: InvokeDurableObjectFetchOptions): Promise<Response>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { retryDurableObjectOperation } from './retry.js';
|
|
2
|
+
/** HTTP response failure returned explicitly by a Durable Object fetch handler. */
|
|
3
|
+
export class DurableObjectResponseError extends Error {
|
|
4
|
+
response;
|
|
5
|
+
constructor(message, response) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.response = response;
|
|
8
|
+
this.name = 'DurableObjectResponseError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Invoke a Durable Object fetch endpoint with a fresh stub and request for every attempt.
|
|
13
|
+
*
|
|
14
|
+
* Non-2xx responses always throw. Runtime retryable exceptions are retried only when `retry` is
|
|
15
|
+
* explicitly enabled, allowing callers to keep non-idempotent operations at-most-once.
|
|
16
|
+
*/
|
|
17
|
+
export async function invokeDurableObjectFetch(options) {
|
|
18
|
+
const invoke = async () => {
|
|
19
|
+
const request = options.createRequest();
|
|
20
|
+
const response = await options.getStub().fetch(request.input, request.init);
|
|
21
|
+
if (!response.ok) {
|
|
22
|
+
throw new DurableObjectResponseError(`${options.errorMessage ?? 'Durable Object request failed'}: ${response.status}`, response);
|
|
23
|
+
}
|
|
24
|
+
return response;
|
|
25
|
+
};
|
|
26
|
+
return options.retry ? retryDurableObjectOperation(invoke, options.retryOptions) : invoke();
|
|
27
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Parsed WebSocket subprotocol offer used by authenticated realtime endpoints. */
|
|
2
|
+
export interface RealtimeWebSocketProtocolOffer {
|
|
3
|
+
protocols: string[];
|
|
4
|
+
authToken?: string;
|
|
5
|
+
clientId?: string;
|
|
6
|
+
}
|
|
7
|
+
/** Options for validating an application/auth/client WebSocket subprotocol offer. */
|
|
8
|
+
export interface ParseRealtimeWebSocketProtocolOptions {
|
|
9
|
+
protocol: string;
|
|
10
|
+
authPrefix?: string;
|
|
11
|
+
clientPrefix: string;
|
|
12
|
+
requireAuth?: boolean;
|
|
13
|
+
clientIdPattern?: RegExp;
|
|
14
|
+
}
|
|
15
|
+
/** Split a `Sec-WebSocket-Protocol` header into trimmed, non-empty protocol tokens. */
|
|
16
|
+
export declare function parseWebSocketProtocols(header: string | undefined): string[];
|
|
17
|
+
/**
|
|
18
|
+
* Validate the standard application/auth/client WebSocket subprotocol offer.
|
|
19
|
+
*
|
|
20
|
+
* Returns `null` when the application protocol, required auth token, or client ID is invalid.
|
|
21
|
+
*/
|
|
22
|
+
export declare function parseRealtimeWebSocketProtocolOffer(header: string | undefined, options: ParseRealtimeWebSocketProtocolOptions): RealtimeWebSocketProtocolOffer | null;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Split a `Sec-WebSocket-Protocol` header into trimmed, non-empty protocol tokens. */
|
|
2
|
+
export function parseWebSocketProtocols(header) {
|
|
3
|
+
return (header ?? '')
|
|
4
|
+
.split(',')
|
|
5
|
+
.map((value) => value.trim())
|
|
6
|
+
.filter(Boolean);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Validate the standard application/auth/client WebSocket subprotocol offer.
|
|
10
|
+
*
|
|
11
|
+
* Returns `null` when the application protocol, required auth token, or client ID is invalid.
|
|
12
|
+
*/
|
|
13
|
+
export function parseRealtimeWebSocketProtocolOffer(header, options) {
|
|
14
|
+
const protocols = parseWebSocketProtocols(header);
|
|
15
|
+
if (!protocols.includes(options.protocol)) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const authPrefix = options.authPrefix;
|
|
19
|
+
const authToken = authPrefix
|
|
20
|
+
? protocols.find((value) => value.startsWith(authPrefix))?.slice(authPrefix.length)
|
|
21
|
+
: undefined;
|
|
22
|
+
if ((options.requireAuth ?? true) && !authToken) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const clientId = protocols
|
|
26
|
+
.find((value) => value.startsWith(options.clientPrefix))
|
|
27
|
+
?.slice(options.clientPrefix.length);
|
|
28
|
+
const clientIdPattern = options.clientIdPattern ?? /^[A-Za-z0-9_-]{1,64}$/;
|
|
29
|
+
if (clientId !== undefined && !clientIdPattern.test(clientId)) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return { protocols, authToken, clientId };
|
|
33
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rdlabo/workers-hono-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -41,7 +41,9 @@
|
|
|
41
41
|
"bin": {
|
|
42
42
|
"workers-hono-kit-sync-dev-aws": "./scripts/sync-dev-aws.mjs",
|
|
43
43
|
"workers-hono-kit-check-subrequest-fanout": "./scripts/check-subrequest-fanout.mjs",
|
|
44
|
-
"workers-hono-kit-db-baseline": "./scripts/db-baseline.mjs"
|
|
44
|
+
"workers-hono-kit-db-baseline": "./scripts/db-baseline.mjs",
|
|
45
|
+
"workers-hono-kit-check-realtime-bundle": "./scripts/check-realtime-bundle.mjs",
|
|
46
|
+
"workers-hono-kit-query-realtime-do-metrics": "./scripts/query-realtime-do-metrics.mjs"
|
|
45
47
|
},
|
|
46
48
|
"exports": {
|
|
47
49
|
".": {
|
|
@@ -71,7 +73,7 @@
|
|
|
71
73
|
}
|
|
72
74
|
},
|
|
73
75
|
"scripts": {
|
|
74
|
-
"build": "tsc -p tsconfig.build.json",
|
|
76
|
+
"build": "node scripts/clean-dist.mjs && tsc -p tsconfig.build.json",
|
|
75
77
|
"prepare": "npm run build",
|
|
76
78
|
"typecheck": "tsc --noEmit",
|
|
77
79
|
"test": "vitest run",
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
const bundlePath = process.argv[2] ?? 'dist-realtime/realtime-worker.js';
|
|
5
|
+
const maxBytes = Number(process.argv[3] ?? 32 * 1024);
|
|
6
|
+
if (!Number.isFinite(maxBytes) || maxBytes <= 0) {
|
|
7
|
+
throw new Error(`Invalid realtime bundle byte limit: ${process.argv[3]}`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { size } = await stat(bundlePath);
|
|
11
|
+
if (size > maxBytes) {
|
|
12
|
+
throw new Error(`Realtime Worker bundle is ${size} bytes; expected at most ${maxBytes}`);
|
|
13
|
+
}
|
|
14
|
+
console.log(`[realtime-bundle] ${size} bytes (limit ${maxBytes})`);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const token = process.env.CLOUDFLARE_API_TOKEN;
|
|
3
|
+
if (!token) {
|
|
4
|
+
throw new Error('CLOUDFLARE_API_TOKEN (Account Analytics:Read) is required');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const namespace = process.argv[2] ?? process.env.REALTIME_DO_CLASS;
|
|
8
|
+
if (!namespace) {
|
|
9
|
+
throw new Error('Durable Object class name is required as argv[2] or REALTIME_DO_CLASS');
|
|
10
|
+
}
|
|
11
|
+
const accountTag = process.env.CLOUDFLARE_ACCOUNT_ID;
|
|
12
|
+
if (!accountTag) {
|
|
13
|
+
throw new Error('CLOUDFLARE_ACCOUNT_ID is required');
|
|
14
|
+
}
|
|
15
|
+
const since = process.env.REALTIME_METRICS_SINCE ?? new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
|
16
|
+
const query = `query RealtimeMetrics($accountTag: string!, $since: Time!, $namespace: string!) {
|
|
17
|
+
viewer {
|
|
18
|
+
accounts(filter: { accountTag: $accountTag }) {
|
|
19
|
+
invocations: durableObjectsInvocationsAdaptiveGroups(
|
|
20
|
+
filter: { datetime_geq: $since, name: $namespace }
|
|
21
|
+
limit: 10000
|
|
22
|
+
) {
|
|
23
|
+
dimensions { datetimeHour type status }
|
|
24
|
+
sum { requests errors wallTime }
|
|
25
|
+
}
|
|
26
|
+
periodic: durableObjectsPeriodicGroups(
|
|
27
|
+
filter: { datetime_geq: $since, name: $namespace }
|
|
28
|
+
limit: 10000
|
|
29
|
+
) {
|
|
30
|
+
dimensions { datetimeHour }
|
|
31
|
+
sum {
|
|
32
|
+
duration activeTime cpuTime rowsRead rowsWritten storageDeletes storageReadUnits storageWriteUnits
|
|
33
|
+
inboundWebsocketMsgCount outboundWebsocketMsgCount
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}`;
|
|
39
|
+
|
|
40
|
+
const response = await fetch('https://api.cloudflare.com/client/v4/graphql', {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
43
|
+
body: JSON.stringify({ query, variables: { accountTag, since, namespace } }),
|
|
44
|
+
});
|
|
45
|
+
const payload = await response.json();
|
|
46
|
+
if (!response.ok || payload.errors?.length) {
|
|
47
|
+
throw new Error(JSON.stringify(payload.errors ?? payload));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const metrics = payload.data?.viewer?.accounts?.[0] ?? { invocations: [], periodic: [] };
|
|
51
|
+
const sum = (groups, field) => groups.reduce((total, group) => total + Number(group.sum?.[field] ?? 0), 0);
|
|
52
|
+
const summary = {
|
|
53
|
+
requests: sum(metrics.invocations, 'requests'),
|
|
54
|
+
errors: sum(metrics.invocations, 'errors'),
|
|
55
|
+
wallTime: sum(metrics.invocations, 'wallTime'),
|
|
56
|
+
duration: sum(metrics.periodic, 'duration'),
|
|
57
|
+
activeTime: sum(metrics.periodic, 'activeTime'),
|
|
58
|
+
cpuTime: sum(metrics.periodic, 'cpuTime'),
|
|
59
|
+
rowsRead: sum(metrics.periodic, 'rowsRead'),
|
|
60
|
+
rowsWritten: sum(metrics.periodic, 'rowsWritten'),
|
|
61
|
+
storageDeletes: sum(metrics.periodic, 'storageDeletes'),
|
|
62
|
+
storageReadUnits: sum(metrics.periodic, 'storageReadUnits'),
|
|
63
|
+
storageWriteUnits: sum(metrics.periodic, 'storageWriteUnits'),
|
|
64
|
+
inboundWebsocketMsgCount: sum(metrics.periodic, 'inboundWebsocketMsgCount'),
|
|
65
|
+
outboundWebsocketMsgCount: sum(metrics.periodic, 'outboundWebsocketMsgCount'),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
console.log(JSON.stringify({ since, namespace, summary, ...metrics }, null, 2));
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/** Minimal Durable Object namespace needed by the legacy SSE bridge. */
|
|
2
|
-
export interface RealtimeDurableObjectNamespaceLike<TId = unknown> {
|
|
3
|
-
idFromName(name: string): TId;
|
|
4
|
-
get(id: TId): {
|
|
5
|
-
fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
|
|
6
|
-
};
|
|
7
|
-
}
|
|
8
|
-
/** Options for bridging legacy SSE clients to Hibernation WebSocket rooms. */
|
|
9
|
-
export interface LegacySseBridgeOptions<TId = unknown> {
|
|
10
|
-
namespace: RealtimeDurableObjectNamespaceLike<TId>;
|
|
11
|
-
roomNames: readonly string[];
|
|
12
|
-
signal: AbortSignal;
|
|
13
|
-
protocol: string;
|
|
14
|
-
clientId?: string;
|
|
15
|
-
heartbeatMs?: number;
|
|
16
|
-
pong?: string;
|
|
17
|
-
connectUrl?: string;
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* Bridge legacy SSE clients to one or more Hibernation WebSocket rooms.
|
|
21
|
-
*
|
|
22
|
-
* The heartbeat lives in the outer Worker only; Durable Objects remain hibernatable. Aborting the
|
|
23
|
-
* downstream request also aborts pending upgrades, including the late-upgrade race during teardown.
|
|
24
|
-
*/
|
|
25
|
-
export declare function createLegacySseBridge<TId>(options: LegacySseBridgeOptions<TId>): Response;
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Bridge legacy SSE clients to one or more Hibernation WebSocket rooms.
|
|
3
|
-
*
|
|
4
|
-
* The heartbeat lives in the outer Worker only; Durable Objects remain hibernatable. Aborting the
|
|
5
|
-
* downstream request also aborts pending upgrades, including the late-upgrade race during teardown.
|
|
6
|
-
*/
|
|
7
|
-
export function createLegacySseBridge(options) {
|
|
8
|
-
const encoder = new TextEncoder();
|
|
9
|
-
const sockets = new Set();
|
|
10
|
-
const heartbeatMs = options.heartbeatMs ?? 25_000;
|
|
11
|
-
const pong = options.pong ?? 'pong';
|
|
12
|
-
const connectUrl = options.connectUrl ?? 'https://do/connect';
|
|
13
|
-
let heartbeat;
|
|
14
|
-
let closeStream;
|
|
15
|
-
const stream = new ReadableStream({
|
|
16
|
-
start(controller) {
|
|
17
|
-
let closed = false;
|
|
18
|
-
const upstreamAbort = new AbortController();
|
|
19
|
-
const close = () => {
|
|
20
|
-
if (closed) {
|
|
21
|
-
return;
|
|
22
|
-
}
|
|
23
|
-
closed = true;
|
|
24
|
-
upstreamAbort.abort();
|
|
25
|
-
if (heartbeat) {
|
|
26
|
-
clearInterval(heartbeat);
|
|
27
|
-
}
|
|
28
|
-
for (const socket of sockets) {
|
|
29
|
-
try {
|
|
30
|
-
socket.close(1000, 'legacy SSE closed');
|
|
31
|
-
}
|
|
32
|
-
catch {
|
|
33
|
-
// already closed
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
sockets.clear();
|
|
37
|
-
try {
|
|
38
|
-
controller.close();
|
|
39
|
-
}
|
|
40
|
-
catch {
|
|
41
|
-
// already closed
|
|
42
|
-
}
|
|
43
|
-
};
|
|
44
|
-
closeStream = close;
|
|
45
|
-
if (options.signal.aborted) {
|
|
46
|
-
close();
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
options.signal.addEventListener('abort', close, { once: true });
|
|
50
|
-
heartbeat = setInterval(() => {
|
|
51
|
-
try {
|
|
52
|
-
controller.enqueue(encoder.encode('event: ping\ndata: ping\n\n'));
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
close();
|
|
56
|
-
}
|
|
57
|
-
}, heartbeatMs);
|
|
58
|
-
const connect = async (roomName) => {
|
|
59
|
-
const id = options.namespace.idFromName(roomName);
|
|
60
|
-
const response = (await options.namespace.get(id).fetch(connectUrl, {
|
|
61
|
-
signal: upstreamAbort.signal,
|
|
62
|
-
headers: {
|
|
63
|
-
Upgrade: 'websocket',
|
|
64
|
-
'Sec-WebSocket-Protocol': options.protocol,
|
|
65
|
-
...(options.clientId ? { 'x-client-id': options.clientId } : {}),
|
|
66
|
-
},
|
|
67
|
-
}));
|
|
68
|
-
const socket = response.webSocket;
|
|
69
|
-
if (response.status !== 101 || !socket) {
|
|
70
|
-
socket?.close(1011, 'legacy SSE upgrade failed');
|
|
71
|
-
throw new Error('Legacy realtime WebSocket upgrade failed');
|
|
72
|
-
}
|
|
73
|
-
if (closed) {
|
|
74
|
-
socket.close(1000, 'legacy SSE already closed');
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
sockets.add(socket);
|
|
78
|
-
socket.accept();
|
|
79
|
-
socket.addEventListener('message', ({ data }) => {
|
|
80
|
-
if (typeof data !== 'string' || data === pong) {
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
try {
|
|
84
|
-
const parsed = JSON.parse(data);
|
|
85
|
-
for (const event of Array.isArray(parsed) ? parsed : [parsed]) {
|
|
86
|
-
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
catch {
|
|
90
|
-
// Ignore malformed upstream messages.
|
|
91
|
-
}
|
|
92
|
-
});
|
|
93
|
-
socket.addEventListener('close', close, { once: true });
|
|
94
|
-
socket.addEventListener('error', close, { once: true });
|
|
95
|
-
};
|
|
96
|
-
void Promise.all(options.roomNames.map((roomName) => connect(roomName))).catch(close);
|
|
97
|
-
},
|
|
98
|
-
cancel() {
|
|
99
|
-
closeStream?.();
|
|
100
|
-
},
|
|
101
|
-
});
|
|
102
|
-
return new Response(stream, {
|
|
103
|
-
headers: {
|
|
104
|
-
'Content-Type': 'text/event-stream',
|
|
105
|
-
'Cache-Control': 'no-cache',
|
|
106
|
-
Connection: 'keep-alive',
|
|
107
|
-
'X-Accel-Buffering': 'no',
|
|
108
|
-
},
|
|
109
|
-
});
|
|
110
|
-
}
|