@rdlabo/workers-hono-kit 0.6.17 → 0.7.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 +39 -0
- package/dist/authorization/role-policy.d.ts +32 -0
- package/dist/authorization/role-policy.js +25 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +6 -0
- package/dist/realtime/hibernation.d.ts +48 -0
- package/dist/realtime/hibernation.js +57 -0
- package/dist/realtime/legacy-sse.d.ts +25 -0
- package/dist/realtime/legacy-sse.js +110 -0
- package/dist/realtime/retry.d.ts +22 -0
- package/dist/realtime/retry.js +42 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ It provides the building blocks a NestJS-style API needs but that don't run on `
|
|
|
13
13
|
- **Stripe** Workers-native client + async webhook verification.
|
|
14
14
|
- **Payment failure & subscription reconcile**: provider-agnostic `payment_failed` helpers — Stripe decline reasons → Japanese messages, Apple / Google subscription-renewal classification, `iapFailureKey` / receipt (de)serialization, and Stripe reconcile branch decisions.
|
|
15
15
|
- **Testing helpers** (`@rdlabo/workers-hono-kit/testing`): a Drizzle-migration-backed test database, in-memory Firebase fake, configurable test doubles, and Stripe fixtures.
|
|
16
|
+
- **Realtime helpers**: Hibernation WebSocket upgrade/broadcast/close, legacy SSE bridging, and Durable Object retry policy.
|
|
16
17
|
|
|
17
18
|
## Install
|
|
18
19
|
|
|
@@ -82,6 +83,10 @@ npm install ai ai-gateway-provider # createAiGatewayProvider
|
|
|
82
83
|
| `ErrorReporter` / `ErrorReportContext` | Types for a `reportError`-style unhandled-error reporter (e.g. wired to Sentry), paired with `createHttpErrorHandler`'s `onUnhandledError`. |
|
|
83
84
|
| `createSentryErrorReporter(sentry)` / `SentryExceptionReporterLike` | Build an `ErrorReporter` that forwards to Sentry with an optional `request_id` tag (no hard `@sentry/cloudflare` dependency). |
|
|
84
85
|
| `DeferExecutor` / `defaultDefer` / `createWaitUntilDefer(ctx)` | Fire-and-forget executor for Workers: `defaultDefer` swallows rejections (tests); `createWaitUntilDefer` registers work via `ctx.waitUntil`. |
|
|
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
|
+
| `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
|
+
| `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. |
|
|
85
90
|
| `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`). |
|
|
86
91
|
| `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. |
|
|
87
92
|
| `createStripeClient(secret, opts?)` / `verifyStripeWebhook(...)` / `CreateStripeClientOptions` | Workers-native Stripe client (fetch transport) + async webhook verification (SubtleCrypto). `apiVersion` optional (pin to a fixed Stripe API version). |
|
|
@@ -616,6 +621,40 @@ The package ships three `bin` commands (run via `npx` or an npm script in the co
|
|
|
616
621
|
| `workers-hono-kit-check-subrequest-fanout [dir…]` | CI gate that greps for per-item external-call fan-outs (`runWithConcurrency(` / `PromisePool` / `.withConcurrency(`) that would eventually exceed the Workers subrequest cap. Annotate a genuinely-safe site with `subrequest-ok`. Scans `src` by default; exits 1 on an un-annotated marker. |
|
|
617
622
|
| `workers-hono-kit-db-baseline [--migrations ./drizzle]` | Brownfield first-deploy helper: record the baseline `0000` migration as *already applied* on an existing MySQL DB without running its DDL (the CLI wrapper around `baselineMigrations` / `readBaselineEntry`). Reads DB credentials from `DB_SECRET` (AWS RDS managed secret) or the individual `DB_*` env vars. |
|
|
618
623
|
|
|
624
|
+
## Storage-agnostic role policies
|
|
625
|
+
|
|
626
|
+
`createRolePolicy` builds pure RBAC checks without coupling the policy to a database schema. The
|
|
627
|
+
application can resolve roles from a membership table, a `users.role` column, token claims, or any
|
|
628
|
+
other source.
|
|
629
|
+
|
|
630
|
+
```ts
|
|
631
|
+
import { createRolePolicy } from '@rdlabo/workers-hono-kit';
|
|
632
|
+
|
|
633
|
+
type Role = 'owner' | 'admin' | 'member' | 'read';
|
|
634
|
+
type Permission = 'organization.manage' | 'resource.write' | 'resource.read';
|
|
635
|
+
|
|
636
|
+
const policy = createRolePolicy<Role, Permission>({
|
|
637
|
+
permissions: {
|
|
638
|
+
owner: ['organization.manage', 'resource.write', 'resource.read'],
|
|
639
|
+
admin: ['resource.write', 'resource.read'],
|
|
640
|
+
member: ['resource.write', 'resource.read'],
|
|
641
|
+
read: ['resource.read'],
|
|
642
|
+
},
|
|
643
|
+
assignableRoles: {
|
|
644
|
+
owner: ['admin', 'member', 'read'],
|
|
645
|
+
admin: ['member', 'read'],
|
|
646
|
+
member: [],
|
|
647
|
+
read: [],
|
|
648
|
+
},
|
|
649
|
+
manageableRoles: {
|
|
650
|
+
owner: ['admin', 'member', 'read'],
|
|
651
|
+
admin: ['member', 'read'],
|
|
652
|
+
member: [],
|
|
653
|
+
read: [],
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
```
|
|
657
|
+
|
|
619
658
|
## Development
|
|
620
659
|
|
|
621
660
|
```bash
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** A role-to-permissions lookup used by {@link createRolePolicy}. */
|
|
2
|
+
export type RolePermissionMap<Role extends string, Permission extends string> = Readonly<Record<Role, readonly Permission[]>>;
|
|
3
|
+
/** A role-to-roles lookup used for assignment and management boundaries. */
|
|
4
|
+
export type RoleRelationMap<Role extends string> = Readonly<Record<Role, readonly Role[]>>;
|
|
5
|
+
/** Configuration for a storage-agnostic role policy. */
|
|
6
|
+
export interface RolePolicyConfig<Role extends string, Permission extends string> {
|
|
7
|
+
/** Permissions granted to each role. */
|
|
8
|
+
permissions: RolePermissionMap<Role, Permission>;
|
|
9
|
+
/** Roles that each actor role may assign to another subject. */
|
|
10
|
+
assignableRoles: RoleRelationMap<Role>;
|
|
11
|
+
/** Existing subject roles that each actor role may manage. */
|
|
12
|
+
manageableRoles: RoleRelationMap<Role>;
|
|
13
|
+
}
|
|
14
|
+
/** Pure authorization checks produced from a role policy configuration. */
|
|
15
|
+
export interface RolePolicy<Role extends string, Permission extends string> {
|
|
16
|
+
/** Returns whether `role` grants `permission`. */
|
|
17
|
+
hasPermission(role: Role, permission: Permission): boolean;
|
|
18
|
+
/** Returns whether `actorRole` may assign `nextRole`. */
|
|
19
|
+
canAssignRole(actorRole: Role, nextRole: Role): boolean;
|
|
20
|
+
/** Returns whether `actorRole` may manage a subject with `targetRole`. */
|
|
21
|
+
canManageRole(actorRole: Role, targetRole: Role): boolean;
|
|
22
|
+
/** Returns whether an actor may change a subject from one role to another. */
|
|
23
|
+
canChangeRole(actorRole: Role, currentRole: Role, nextRole: Role): boolean;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Creates pure, schema-independent RBAC checks.
|
|
27
|
+
*
|
|
28
|
+
* The caller resolves a role from any persistence model (for example `group_users.role`,
|
|
29
|
+
* `users.role`, a token claim, or an external identity provider) and passes that role into these
|
|
30
|
+
* checks. Keeping lookup and policy separate makes the same policy reusable across applications.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createRolePolicy<Role extends string, Permission extends string>(config: RolePolicyConfig<Role, Permission>): RolePolicy<Role, Permission>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
function ownListIncludes(record, key, value) {
|
|
2
|
+
if (!Object.prototype.hasOwnProperty.call(record, key)) {
|
|
3
|
+
return false;
|
|
4
|
+
}
|
|
5
|
+
return record[key]?.includes(value) ?? false;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Creates pure, schema-independent RBAC checks.
|
|
9
|
+
*
|
|
10
|
+
* The caller resolves a role from any persistence model (for example `group_users.role`,
|
|
11
|
+
* `users.role`, a token claim, or an external identity provider) and passes that role into these
|
|
12
|
+
* checks. Keeping lookup and policy separate makes the same policy reusable across applications.
|
|
13
|
+
*/
|
|
14
|
+
export function createRolePolicy(config) {
|
|
15
|
+
// Keep the public config strongly typed while treating runtime values as untrusted input.
|
|
16
|
+
const permissions = config.permissions;
|
|
17
|
+
const assignableRoles = config.assignableRoles;
|
|
18
|
+
const manageableRoles = config.manageableRoles;
|
|
19
|
+
return {
|
|
20
|
+
hasPermission: (role, permission) => ownListIncludes(permissions, role, permission),
|
|
21
|
+
canAssignRole: (actorRole, nextRole) => ownListIncludes(assignableRoles, actorRole, nextRole),
|
|
22
|
+
canManageRole: (actorRole, targetRole) => ownListIncludes(manageableRoles, actorRole, targetRole),
|
|
23
|
+
canChangeRole: (actorRole, currentRole, nextRole) => ownListIncludes(manageableRoles, actorRole, currentRole) && ownListIncludes(assignableRoles, actorRole, nextRole),
|
|
24
|
+
};
|
|
25
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -24,6 +24,8 @@ export { createIsolateMemo } from './container/isolate-memo.js';
|
|
|
24
24
|
export type { IsolateMemo } from './container/isolate-memo.js';
|
|
25
25
|
export { createContainerRuntime } from './container/middleware.js';
|
|
26
26
|
export type { ContainerBuildContext, ContainerRuntime, ContainerRuntimeOptions } from './container/middleware.js';
|
|
27
|
+
export { createRolePolicy } from './authorization/role-policy.js';
|
|
28
|
+
export type { RolePermissionMap, RolePolicy, RolePolicyConfig, RoleRelationMap } from './authorization/role-policy.js';
|
|
27
29
|
export { getUserProtocol } from './http/user-protocol.js';
|
|
28
30
|
export type { IUserProtocol } from './http/user-protocol.js';
|
|
29
31
|
export { getAppInfo } from './http/app-info.js';
|
|
@@ -45,6 +47,12 @@ export { defaultDefer, createWaitUntilDefer } from './http/defer.js';
|
|
|
45
47
|
export type { DeferExecutor } from './http/defer.js';
|
|
46
48
|
export { createSentryErrorReporter } from './http/http-error.js';
|
|
47
49
|
export type { SentryExceptionReporterLike } from './http/http-error.js';
|
|
50
|
+
export { acknowledgeHibernationWebSocketClose, broadcastHibernationWebSockets, closeHibernationWebSocket, configureHibernationAutoResponse, upgradeHibernationWebSocket, } from './realtime/hibernation.js';
|
|
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
|
+
export { isRetryableDurableObjectError, retryDurableObjectOperation } from './realtime/retry.js';
|
|
55
|
+
export type { DurableObjectErrorLike, DurableObjectRetryOptions } from './realtime/retry.js';
|
|
48
56
|
export { KVCache } from './cache/kv-cache.js';
|
|
49
57
|
export type { KVNamespace, KVCacheOptions } from './cache/kv-cache.js';
|
|
50
58
|
export { createStripeClient, verifyStripeWebhook } from './stripe/client.js';
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,8 @@ export { perfLog } from './middleware/perf-log.js';
|
|
|
21
21
|
export { createMaintenanceMiddleware, createMaintenanceWaitHandler, isMaintenanceEnabled, MAINTENANCE_BODY, MAINTENANCE_CODE, MAINTENANCE_WAIT_PATH, } from './middleware/maintenance.js';
|
|
22
22
|
export { createIsolateMemo } from './container/isolate-memo.js';
|
|
23
23
|
export { createContainerRuntime } from './container/middleware.js';
|
|
24
|
+
// authorization
|
|
25
|
+
export { createRolePolicy } from './authorization/role-policy.js';
|
|
24
26
|
// http
|
|
25
27
|
export { getUserProtocol } from './http/user-protocol.js';
|
|
26
28
|
export { getAppInfo } from './http/app-info.js';
|
|
@@ -33,6 +35,10 @@ export { createAppErrorHandler } from './http/app-error-handler.js';
|
|
|
33
35
|
export { normalizeTrailingSlash } from './http/trailing-slash.js';
|
|
34
36
|
export { defaultDefer, createWaitUntilDefer } from './http/defer.js';
|
|
35
37
|
export { createSentryErrorReporter } from './http/http-error.js';
|
|
38
|
+
// realtime
|
|
39
|
+
export { acknowledgeHibernationWebSocketClose, broadcastHibernationWebSockets, closeHibernationWebSocket, configureHibernationAutoResponse, upgradeHibernationWebSocket, } from './realtime/hibernation.js';
|
|
40
|
+
export { createLegacySseBridge } from './realtime/legacy-sse.js';
|
|
41
|
+
export { isRetryableDurableObjectError, retryDurableObjectOperation } from './realtime/retry.js';
|
|
36
42
|
// cache
|
|
37
43
|
export { KVCache } from './cache/kv-cache.js';
|
|
38
44
|
// stripe
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** Minimal Hibernation WebSocket state used by the shared helpers. */
|
|
2
|
+
export interface HibernationWebSocketStateLike {
|
|
3
|
+
acceptWebSocket(socket: WebSocket): void;
|
|
4
|
+
getWebSockets(): WebSocket[];
|
|
5
|
+
setWebSocketAutoResponse(pair: unknown): void;
|
|
6
|
+
}
|
|
7
|
+
/** A server-side WebSocket that can persist metadata across Durable Object hibernation. */
|
|
8
|
+
export interface HibernationWebSocketLike extends WebSocket {
|
|
9
|
+
serializeAttachment(value: unknown): void;
|
|
10
|
+
}
|
|
11
|
+
/** Constructor shape of the Workers `WebSocketPair` runtime global. */
|
|
12
|
+
export type WebSocketPairFactory = new () => {
|
|
13
|
+
0: HibernationWebSocketLike;
|
|
14
|
+
1: HibernationWebSocketLike;
|
|
15
|
+
};
|
|
16
|
+
/** Constructor shape of the Workers `WebSocketRequestResponsePair` runtime global. */
|
|
17
|
+
export type WebSocketAutoResponsePairFactory = new (request: string, response: string) => unknown;
|
|
18
|
+
/** Options for configuring runtime-handled ping/pong responses. */
|
|
19
|
+
export interface HibernationAutoResponseOptions {
|
|
20
|
+
state: HibernationWebSocketStateLike;
|
|
21
|
+
ping: string;
|
|
22
|
+
pong: string;
|
|
23
|
+
pairFactory?: WebSocketAutoResponsePairFactory;
|
|
24
|
+
}
|
|
25
|
+
/** Options for upgrading a request to a Hibernation WebSocket. */
|
|
26
|
+
export interface HibernationUpgradeOptions {
|
|
27
|
+
state: HibernationWebSocketStateLike;
|
|
28
|
+
request: Request;
|
|
29
|
+
protocol: string;
|
|
30
|
+
attachment?: unknown;
|
|
31
|
+
pairFactory?: WebSocketPairFactory;
|
|
32
|
+
responseFactory?: (client: WebSocket, protocol: string) => Response;
|
|
33
|
+
}
|
|
34
|
+
/** Configure ping/pong at the runtime layer so application heartbeats do not wake the object. */
|
|
35
|
+
export declare function configureHibernationAutoResponse(options: HibernationAutoResponseOptions): void;
|
|
36
|
+
/**
|
|
37
|
+
* Upgrade a request and register its server socket with the Hibernation WebSocket API.
|
|
38
|
+
*
|
|
39
|
+
* Attachments are serialized before acceptance so connection metadata remains available after
|
|
40
|
+
* the Durable Object instance is evicted and reconstructed.
|
|
41
|
+
*/
|
|
42
|
+
export declare function upgradeHibernationWebSocket(options: HibernationUpgradeOptions): Response;
|
|
43
|
+
/** Broadcast one JSON message to all sockets restored by `getWebSockets()`. */
|
|
44
|
+
export declare function broadcastHibernationWebSockets(state: Pick<HibernationWebSocketStateLike, 'getWebSockets'>, payload: unknown): void;
|
|
45
|
+
/** Close a Hibernation WebSocket while tolerating already-closed sockets. */
|
|
46
|
+
export declare function closeHibernationWebSocket(socket: WebSocket, code: number, reason: string): void;
|
|
47
|
+
/** Echo a peer close using a legal close-frame code. */
|
|
48
|
+
export declare function acknowledgeHibernationWebSocketClose(socket: WebSocket, code: number, reason: string): void;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Configure ping/pong at the runtime layer so application heartbeats do not wake the object. */
|
|
2
|
+
export function configureHibernationAutoResponse(options) {
|
|
3
|
+
const Pair = options.pairFactory ?? WebSocketRequestResponsePair;
|
|
4
|
+
options.state.setWebSocketAutoResponse(new Pair(options.ping, options.pong));
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Upgrade a request and register its server socket with the Hibernation WebSocket API.
|
|
8
|
+
*
|
|
9
|
+
* Attachments are serialized before acceptance so connection metadata remains available after
|
|
10
|
+
* the Durable Object instance is evicted and reconstructed.
|
|
11
|
+
*/
|
|
12
|
+
export function upgradeHibernationWebSocket(options) {
|
|
13
|
+
if (options.request.headers.get('Upgrade')?.toLowerCase() !== 'websocket') {
|
|
14
|
+
return new Response('Expected Upgrade: websocket', { status: 426 });
|
|
15
|
+
}
|
|
16
|
+
const Pair = options.pairFactory ?? WebSocketPair;
|
|
17
|
+
const pair = new Pair();
|
|
18
|
+
const client = pair[0];
|
|
19
|
+
const server = pair[1];
|
|
20
|
+
if (options.attachment !== undefined) {
|
|
21
|
+
server.serializeAttachment(options.attachment);
|
|
22
|
+
}
|
|
23
|
+
options.state.acceptWebSocket(server);
|
|
24
|
+
return options.responseFactory
|
|
25
|
+
? options.responseFactory(client, options.protocol)
|
|
26
|
+
: new Response(null, {
|
|
27
|
+
status: 101,
|
|
28
|
+
webSocket: client,
|
|
29
|
+
headers: { 'Sec-WebSocket-Protocol': options.protocol },
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/** Broadcast one JSON message to all sockets restored by `getWebSockets()`. */
|
|
33
|
+
export function broadcastHibernationWebSockets(state, payload) {
|
|
34
|
+
const message = JSON.stringify(payload);
|
|
35
|
+
for (const socket of state.getWebSockets()) {
|
|
36
|
+
try {
|
|
37
|
+
socket.send(message);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
closeHibernationWebSocket(socket, 1011, 'publish failed');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Close a Hibernation WebSocket while tolerating already-closed sockets. */
|
|
45
|
+
export function closeHibernationWebSocket(socket, code, reason) {
|
|
46
|
+
try {
|
|
47
|
+
socket.close(code, reason);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// The socket is already closed or cannot send a close frame.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Echo a peer close using a legal close-frame code. */
|
|
54
|
+
export function acknowledgeHibernationWebSocketClose(socket, code, reason) {
|
|
55
|
+
const replyCode = code === 1005 || code === 1006 || code === 1015 ? 1000 : code;
|
|
56
|
+
closeHibernationWebSocket(socket, replyCode, reason);
|
|
57
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Minimal Durable Object namespace needed by the legacy SSE bridge. */
|
|
2
|
+
export interface RealtimeDurableObjectNamespaceLike {
|
|
3
|
+
idFromName(name: string): unknown;
|
|
4
|
+
get(id: unknown): {
|
|
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 {
|
|
10
|
+
namespace: RealtimeDurableObjectNamespaceLike;
|
|
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(options: LegacySseBridgeOptions): Response;
|
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Durable Object error flags documented by the Workers runtime. */
|
|
2
|
+
export interface DurableObjectErrorLike {
|
|
3
|
+
retryable?: boolean;
|
|
4
|
+
overloaded?: boolean;
|
|
5
|
+
}
|
|
6
|
+
/** Retry policy for an idempotent Durable Object operation. */
|
|
7
|
+
export interface DurableObjectRetryOptions {
|
|
8
|
+
maxAttempts?: number;
|
|
9
|
+
baseDelayMs?: number;
|
|
10
|
+
maxDelayMs?: number;
|
|
11
|
+
random?: () => number;
|
|
12
|
+
wait?: (delayMs: number) => Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
/** Return whether a Durable Object failure may be retried safely by policy. */
|
|
15
|
+
export declare function isRetryableDurableObjectError(error: unknown): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Retry an idempotent Durable Object operation with jittered exponential backoff.
|
|
18
|
+
*
|
|
19
|
+
* `operation` is invoked again for every attempt. Callers must create a fresh stub inside it,
|
|
20
|
+
* because a stub that threw may remain in a broken state. Overload errors are never retried.
|
|
21
|
+
*/
|
|
22
|
+
export declare function retryDurableObjectOperation<T>(operation: (attempt: number) => Promise<T>, options?: DurableObjectRetryOptions): Promise<T>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
function hasFlag(error, flag) {
|
|
2
|
+
if (typeof error !== 'object' || error === null) {
|
|
3
|
+
return false;
|
|
4
|
+
}
|
|
5
|
+
return error[flag] === true;
|
|
6
|
+
}
|
|
7
|
+
function defaultWait(delayMs) {
|
|
8
|
+
const runtimeScheduler = globalThis.scheduler;
|
|
9
|
+
return runtimeScheduler?.wait
|
|
10
|
+
? runtimeScheduler.wait(delayMs)
|
|
11
|
+
: new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
12
|
+
}
|
|
13
|
+
/** Return whether a Durable Object failure may be retried safely by policy. */
|
|
14
|
+
export function isRetryableDurableObjectError(error) {
|
|
15
|
+
return hasFlag(error, 'retryable') && !hasFlag(error, 'overloaded');
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Retry an idempotent Durable Object operation with jittered exponential backoff.
|
|
19
|
+
*
|
|
20
|
+
* `operation` is invoked again for every attempt. Callers must create a fresh stub inside it,
|
|
21
|
+
* because a stub that threw may remain in a broken state. Overload errors are never retried.
|
|
22
|
+
*/
|
|
23
|
+
export async function retryDurableObjectOperation(operation, options = {}) {
|
|
24
|
+
const maxAttempts = Math.max(1, options.maxAttempts ?? 3);
|
|
25
|
+
const baseDelayMs = Math.max(0, options.baseDelayMs ?? 100);
|
|
26
|
+
const maxDelayMs = Math.max(baseDelayMs, options.maxDelayMs ?? 1000);
|
|
27
|
+
const random = options.random ?? Math.random;
|
|
28
|
+
const wait = options.wait ?? defaultWait;
|
|
29
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
30
|
+
try {
|
|
31
|
+
return await operation(attempt);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (!isRetryableDurableObjectError(error) || attempt + 1 >= maxAttempts) {
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
const delayMs = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt * random());
|
|
38
|
+
await wait(delayMs);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
throw new Error('Durable Object retry exhausted');
|
|
42
|
+
}
|