@gjermundgaraba/effect-actions-cloudflare 0.0.0-stage → 0.22.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/LICENSE +21 -0
- package/README.md +54 -2
- package/dist/ActionDurableObject.d.mts +240 -0
- package/dist/ActionDurableObject.mjs +190 -0
- package/dist/alarm.d.mts +42 -0
- package/dist/alarm.mjs +155 -0
- package/dist/batches.mjs +10 -0
- package/dist/calls.d.mts +9 -0
- package/dist/calls.mjs +92 -0
- package/dist/events.mjs +131 -0
- package/dist/fetcher.d.mts +17 -0
- package/dist/fetcher.mjs +19 -0
- package/dist/frames.mjs +33 -0
- package/dist/idempotency.mjs +40 -0
- package/dist/placed.d.mts +47 -0
- package/dist/placed.mjs +53 -0
- package/dist/sockets.d.mts +115 -0
- package/dist/sockets.mjs +243 -0
- package/dist/state.d.mts +18 -0
- package/dist/state.mjs +18 -0
- package/dist/stub.d.mts +59 -0
- package/dist/stub.mjs +75 -0
- package/dist/subscriptions.mjs +104 -0
- package/dist/tasks.d.mts +24 -0
- package/dist/tasks.mjs +31 -0
- package/dist/webhooks.d.mts +36 -0
- package/dist/webhooks.mjs +108 -0
- package/docs/ActionDurableObject-events.md +153 -0
- package/docs/ActionDurableObject-failures.md +18 -0
- package/docs/ActionDurableObject-own-class.md +9 -0
- package/docs/ActionDurableObject-placement.md +41 -0
- package/docs/ActionDurableObject-stub.md +135 -0
- package/docs/ActionDurableObject-testing.md +35 -0
- package/docs/ActionDurableObject.md +235 -0
- package/docs/README.md +28 -0
- package/examples/chat-rooms/README.md +97 -0
- package/examples/chat-rooms/src/Api.ts +14 -0
- package/examples/chat-rooms/src/Rpc.ts +22 -0
- package/examples/chat-rooms/src/browser.ts +107 -0
- package/examples/chat-rooms/src/chat/Chat.ts +95 -0
- package/examples/chat-rooms/src/chat/activity.ts +16 -0
- package/examples/chat-rooms/src/chat/authorize.ts +22 -0
- package/examples/chat-rooms/src/chat/events.ts +64 -0
- package/examples/chat-rooms/src/chat/handlers.ts +38 -0
- package/examples/chat-rooms/src/chat/room.ts +12 -0
- package/examples/chat-rooms/src/chat/store.ts +25 -0
- package/examples/chat-rooms/src/env.d.ts +12 -0
- package/examples/chat-rooms/src/identity/Identity.ts +21 -0
- package/examples/chat-rooms/src/identity/members.ts +8 -0
- package/examples/chat-rooms/src/identity/verifier.ts +24 -0
- package/examples/chat-rooms/src/lobby/Calls.ts +14 -0
- package/examples/chat-rooms/src/lobby/Lobby.ts +32 -0
- package/examples/chat-rooms/src/lobby/events.ts +17 -0
- package/examples/chat-rooms/src/lobby/handlers.ts +34 -0
- package/examples/chat-rooms/src/lobby/host.ts +10 -0
- package/examples/chat-rooms/src/lobby/store.ts +18 -0
- package/examples/chat-rooms/src/platform/kv-previews.ts +35 -0
- package/examples/chat-rooms/src/platform/lobby-stubs.ts +46 -0
- package/examples/chat-rooms/src/platform/sql-store.ts +110 -0
- package/examples/chat-rooms/src/rooms-entrypoint.ts +33 -0
- package/examples/chat-rooms/src/routes.ts +89 -0
- package/examples/chat-rooms/src/worker.ts +99 -0
- package/examples/chat-rooms/test/platform/sql-store.test.ts +132 -0
- package/examples/chat-rooms/test/support/socket.ts +70 -0
- package/examples/chat-rooms/test/worker.test.ts +579 -0
- package/examples/chat-rooms/tsconfig.json +22 -0
- package/examples/chat-rooms/vite.config.ts +19 -0
- package/examples/chat-rooms/wrangler.jsonc +15 -0
- package/package.json +49 -3
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { State } from "./state.mjs";
|
|
2
|
+
import { Context, Effect, Layer } from "effect";
|
|
3
|
+
import { HttpRouter, HttpServerResponse } from "effect/http";
|
|
4
|
+
import { RpcServer } from "effect/rpc";
|
|
5
|
+
/** What a protocol does with the events of the sockets carrying its tag. */
|
|
6
|
+
interface Receiver {
|
|
7
|
+
/** Hands a message to the protocol. */
|
|
8
|
+
readonly message: (socket: WebSocket, data: string | ArrayBuffer) => Effect.Effect<void>;
|
|
9
|
+
/** Tells the protocol the socket closed or failed. */
|
|
10
|
+
readonly close: (socket: WebSocket) => Effect.Effect<void>;
|
|
11
|
+
}
|
|
12
|
+
declare const Sockets_base: Context.ServiceClass<Sockets, "effect-actions-cloudflare/ActionDurableObject/Sockets", {
|
|
13
|
+
/** Hands a message on `socket`, as `webSocketMessage` receives it, to its protocol. */
|
|
14
|
+
readonly message: (socket: WebSocket, data: string | ArrayBuffer) => Effect.Effect<boolean>;
|
|
15
|
+
/**
|
|
16
|
+
* Completes the closing handshake of any socket, replying with the client's `code` and
|
|
17
|
+
* `reason`, as `webSocketClose` receives them, and tells the socket's protocol it closed,
|
|
18
|
+
* interrupting its running calls.
|
|
19
|
+
*/
|
|
20
|
+
readonly close: (socket: WebSocket, code: number, reason: string) => Effect.Effect<boolean>;
|
|
21
|
+
/** Tells the socket's protocol it failed, which ends it as a close does. */
|
|
22
|
+
readonly error: (socket: WebSocket) => Effect.Effect<boolean>;
|
|
23
|
+
/**
|
|
24
|
+
* Hands the events of the sockets carrying `tag` to `receiver`, as a protocol builds, or
|
|
25
|
+
* answers `false` when another protocol already holds that tag.
|
|
26
|
+
*/
|
|
27
|
+
readonly register: (tag: string, receiver: Receiver) => boolean;
|
|
28
|
+
/** The next socket's id, never one an open socket has or this instance gave before. */
|
|
29
|
+
readonly allocate: Effect.Effect<number>;
|
|
30
|
+
}>;
|
|
31
|
+
/**
|
|
32
|
+
* The object's dispatcher for the sockets the protocols of its routes accept, such as
|
|
33
|
+
* `layerWebSocket`'s: each event method hands a socket's event to the protocol whose tag the
|
|
34
|
+
* socket carries, and succeeds with `true` once that protocol has it, or with `false` when no
|
|
35
|
+
* protocol accepted the socket, such as one the object accepted itself, which the caller answers.
|
|
36
|
+
* `handlers` and `make` provide one per instance, which the protocols register with as the routes
|
|
37
|
+
* build: a requirement of `layerWebSocket`, named as a type alone.
|
|
38
|
+
*
|
|
39
|
+
* **Gotchas**
|
|
40
|
+
*
|
|
41
|
+
* - A socket accepted by an earlier version of the routes at another `path` carries a tag no protocol holds now, and is answered `false`: close it, or serve that `path` again.
|
|
42
|
+
*/
|
|
43
|
+
export declare class Sockets extends Sockets_base {
|
|
44
|
+
/** One dispatcher, with no protocol registered, for the instance whose `State` it is given. */
|
|
45
|
+
static readonly layer: Layer.Layer<Sockets, never, State>;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The answer router middleware gives a WebSocket upgrade it refuses, such as one from a foreign
|
|
49
|
+
* `Origin`, as `layerWebSocket`'s `protocol` refuses a page of another version: 101, with a socket of its own closed at once with
|
|
50
|
+
* `code` and `reason`, which the page's Effect client fails its calls and streams with, as
|
|
51
|
+
* `SocketCloseError`. A browser cannot read the status of an upgrade it was refused, so this is
|
|
52
|
+
* how a refusal reaches the page.
|
|
53
|
+
*
|
|
54
|
+
* **Gotchas**
|
|
55
|
+
*
|
|
56
|
+
* - A 403, or any other status, reaches a browser's WebSocket as a bare 1006, as a network drop, and Effect's client connects again, half a second later and growing to every five seconds, for good.
|
|
57
|
+
* - Effect's client connects again after this close as well, as often and for good, each upgrade waking the object, unless its `retryPolicy` is the core's `ActionRpc.reconnectUntil(code)`, the client half of this refusal.
|
|
58
|
+
* - Its socket is accepted without the hibernation API and the library's route never runs, so the object keeps nothing of it; that accepted end ties up its request until the close handshake completes, so the object stays awake until the page answers the close.
|
|
59
|
+
* - `code` is an integer from 4000 to 4999, the codes the core's `watch` ends at, connecting again after any other: another dies.
|
|
60
|
+
*/
|
|
61
|
+
export declare const refuseUpgrade: (code: number, reason: string) => Effect.Effect<HttpServerResponse.HttpServerResponse>;
|
|
62
|
+
/** `layerWebSocket`'s options. */
|
|
63
|
+
export interface LayerWebSocketOptions {
|
|
64
|
+
/** The upgrade route. */
|
|
65
|
+
readonly path: HttpRouter.PathInput;
|
|
66
|
+
/**
|
|
67
|
+
* The version of the rpcs the route serves, which a page names in its socket's URL, as
|
|
68
|
+
* `?protocol=<version>`: an upgrade naming none, or another, is refused with
|
|
69
|
+
* `refuseUpgrade(code, reason)`, which a page's client stops at given the core's
|
|
70
|
+
* `ActionRpc.reconnectUntil(code)`.
|
|
71
|
+
*/
|
|
72
|
+
readonly protocol?: {
|
|
73
|
+
/** The version served, compared with the query parameter as a string. */
|
|
74
|
+
readonly version: string;
|
|
75
|
+
/** The close code of a refusal, which a page's client stops at and its `watch` ends at: an integer from 4000 to 4999, the private-use codes; another fails `layerWebSocket`'s build. */
|
|
76
|
+
readonly code: number;
|
|
77
|
+
/** The close reason of a refusal, at most 123 bytes UTF-8 encoded, as a close frame holds: a longer one, the default included, fails `layerWebSocket`'s build. By default `This server speaks protocol <version>: reload the page`, which fits a `version` of up to 78 bytes: give a reason with a longer one. */
|
|
78
|
+
readonly reason?: string;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The `RpcServer.Protocol` `ActionRpc.layer` serves on, in an object's routes: WebSockets the
|
|
83
|
+
* object accepts at `path`, with the runtime's hibernation API, for the runtime to hold while
|
|
84
|
+
* the object hibernates, speaking JSON, the client's `RpcSerialization.layerJson` over
|
|
85
|
+
* `RpcClient.layerProtocolSocket`. A socket keeps its upgrade request's headers, as Effect's own
|
|
86
|
+
* socket protocol does, its credential included, and each message is authenticated with those
|
|
87
|
+
* under its own, its own winning. It keeps each event subscription `ActionRpc.layer` admits
|
|
88
|
+
* across hibernation. It registers with the instance's `Sockets`, which hands it their events,
|
|
89
|
+
* and requires it.
|
|
90
|
+
*
|
|
91
|
+
* **Gotchas**
|
|
92
|
+
*
|
|
93
|
+
* - An idle object is evicted while its sockets stay open: a message constructs a new instance, which builds the routes and then answers it.
|
|
94
|
+
* - A message's event ends once the message is handed to the server: a call doing I/O or waiting on a timer keeps the object from hibernating, billed as long, and one waiting only on in-memory state, such as a `Deferred`, is lost if the object hibernates. A call an eviction, a deploy or a crash cut off closes its socket with 1012 as the next instance builds, so its client fails it with a `SocketCloseError` and connects again; on celld 0.6.2, which drops attachments, it waits for good.
|
|
95
|
+
* - An `ActionRpc` event subscription is kept in the object's storage, under `effect-actions/subscription/`, its headers, tokens and cookies included, with it, and resumed from the position it reached when the object next wakes, on any event, authenticated again from those headers: a token that expired meanwhile ends it with `Unauthenticated`.
|
|
96
|
+
* - A kept subscription that cannot be resumed, such as one a later version of this package stores in another shape, closes its socket with 1000, logged, so the page's client connects again and its `watch` subscribes anew.
|
|
97
|
+
* - A native stream rpc or a streaming action is not kept: waiting on in-memory state, it is lost if the object hibernates, its socket closed with 1012 as the next instance builds; serve what a page follows as an event.
|
|
98
|
+
* - Each chunk that moves a kept subscription's position is one small storage write, and it is sent with no heartbeat: Effect's socket client tells a dead socket by its pings.
|
|
99
|
+
* - `storage.deleteAll()` deletes the kept subscriptions too, so a watch hears nothing more once the object hibernates: close the sockets with it, so their clients connect again and watch anew.
|
|
100
|
+
* - A client's close frame is answered with its own code and reason, as the closing handshake asks, whatever the `compatibility_date`: where the runtime already replied, under `web_socket_auto_reply_to_close`, the second reply is ignored; a socket the class accepted itself is the class's to answer.
|
|
101
|
+
* - An upgrade whose headers, cookies included, exceed the runtime's 16 KiB attachment is answered 431, and a call whose id would take the attachment past it fails at once with a defect naming it, never run; a request to `path` that is no upgrade is answered 426.
|
|
102
|
+
* - workerd keeps a socket's headers across hibernation; celld 0.6.2 drops them when it evicts an idle object, so a message relying on the upgrade's token is refused as unauthenticated: send a token on each message.
|
|
103
|
+
* - The upgrade's URL and query are not kept, as Effect's own socket protocol keeps neither: a message has no URL, so an API key in the query never authenticates; `protocol` reads the upgrade's query for a version, and router middleware reads it for any other judge, refusing with `refuseUpgrade`.
|
|
104
|
+
* - A browser WebSocket sets no header on its upgrade, so a page sends its token on each message, with `RpcClient.withHeaders`; under a cookie scheme the browser sends the cookie on any page's upgrade, which is never preflighted, so refuse a foreign `Origin` with router middleware, which runs for the upgrade, returning `refuseUpgrade`: a 403 reaches the page as a bare 1006, and its client connects again for good.
|
|
105
|
+
* - The verifier runs for every message, so a token that expires refuses the next call: a client renews it on its messages, or connects again.
|
|
106
|
+
* - Router middleware around the routes runs for the upgrade request alone, never for a socket's messages: list an `RpcMiddleware` in `ActionRpc.layer`'s `middleware` for a per-message service.
|
|
107
|
+
* - Each protocol of one object needs a `path` of its own: a router prefix does not tell two apart, and two at one `path` fail the build.
|
|
108
|
+
* - The runtime answers a client's pings itself, every five seconds, so an open page does not wake the object.
|
|
109
|
+
* - Effect's `RpcServer.layerProtocolWebsocket` cannot upgrade a web handler's request: on Workers, serve sockets with this.
|
|
110
|
+
* - A handler's die under a plain `RpcServer.layer` is Effect's fatal `Defect`, failing every call in flight on that socket, a call made in the dying one's continuation included, as Effect's own design, unlike over the object's stub: serve with `RpcServer.layer(group, { disableFatalDefects: true })`, or through `ActionRpc.layer`, to fail that call alone.
|
|
111
|
+
*
|
|
112
|
+
* Guide: `docs/ActionDurableObject.md` in this package.
|
|
113
|
+
*/
|
|
114
|
+
export declare const layerWebSocket: (options: LayerWebSocketOptions) => Layer.Layer<RpcServer.Protocol, never, Sockets | State | HttpRouter.HttpRouter>;
|
|
115
|
+
export {};
|
package/dist/sockets.mjs
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { State } from "./state.mjs";
|
|
2
|
+
import { RequestId, closeQuietly, groupTag, parser, sendFrame, socketIdOf, socketTag } from "./frames.mjs";
|
|
3
|
+
import { makeSubscriptions } from "./subscriptions.mjs";
|
|
4
|
+
import { Context, Effect, Exit, Function, Layer, Match, Predicate, Queue, Schema } from "effect";
|
|
5
|
+
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
|
|
6
|
+
import { RpcMessage, RpcSerialization, RpcServer } from "effect/rpc";
|
|
7
|
+
import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
|
|
8
|
+
// src/durable/sockets.ts
|
|
9
|
+
const replyToClose = (socket, code, reason) => Effect.sync(() => {
|
|
10
|
+
if (code >= 1e3 && code !== 1005 && code !== 1006 && code !== 1015) closeQuietly(socket, code, reason);
|
|
11
|
+
else closeQuietly(socket);
|
|
12
|
+
});
|
|
13
|
+
/**
|
|
14
|
+
* The object's dispatcher for the sockets the protocols of its routes accept, such as
|
|
15
|
+
* `layerWebSocket`'s: each event method hands a socket's event to the protocol whose tag the
|
|
16
|
+
* socket carries, and succeeds with `true` once that protocol has it, or with `false` when no
|
|
17
|
+
* protocol accepted the socket, such as one the object accepted itself, which the caller answers.
|
|
18
|
+
* `handlers` and `make` provide one per instance, which the protocols register with as the routes
|
|
19
|
+
* build: a requirement of `layerWebSocket`, named as a type alone.
|
|
20
|
+
*
|
|
21
|
+
* **Gotchas**
|
|
22
|
+
*
|
|
23
|
+
* - A socket accepted by an earlier version of the routes at another `path` carries a tag no protocol holds now, and is answered `false`: close it, or serve that `path` again.
|
|
24
|
+
*/
|
|
25
|
+
var Sockets = class Sockets extends Context.Service()("effect-actions-cloudflare/ActionDurableObject/Sockets") {
|
|
26
|
+
/** One dispatcher, with no protocol registered, for the instance whose `State` it is given. */
|
|
27
|
+
static layer = Layer.effect(Sockets)(Effect.gen(function* () {
|
|
28
|
+
const state = yield* State;
|
|
29
|
+
const receivers = /* @__PURE__ */ new Map();
|
|
30
|
+
let next = Math.max(1, ...state.getWebSockets().map((socket) => socketIdOf(state.getTags(socket)) + 1));
|
|
31
|
+
const dispatch = (socket, event) => Effect.suspend(() => {
|
|
32
|
+
const receiver = state.getTags(socket).map((tag) => receivers.get(tag)).find((found) => found !== void 0);
|
|
33
|
+
return receiver === void 0 ? Effect.succeed(false) : Effect.as(event(receiver), true);
|
|
34
|
+
});
|
|
35
|
+
return Sockets.of({
|
|
36
|
+
message: (socket, data) => dispatch(socket, (receiver) => receiver.message(socket, data)),
|
|
37
|
+
close: (socket, code, reason) => Effect.andThen(replyToClose(socket, code, reason), dispatch(socket, (receiver) => receiver.close(socket))),
|
|
38
|
+
error: (socket) => dispatch(socket, (receiver) => receiver.close(socket)),
|
|
39
|
+
register: (tag, receiver) => {
|
|
40
|
+
if (receivers.has(tag)) return false;
|
|
41
|
+
receivers.set(tag, receiver);
|
|
42
|
+
return true;
|
|
43
|
+
},
|
|
44
|
+
allocate: Effect.sync(() => {
|
|
45
|
+
const id = next;
|
|
46
|
+
next += 1;
|
|
47
|
+
return id;
|
|
48
|
+
})
|
|
49
|
+
});
|
|
50
|
+
}));
|
|
51
|
+
};
|
|
52
|
+
const Attachment = Schema.Struct({
|
|
53
|
+
headers: Schema.Array(Schema.mutable(Schema.Tuple([Schema.String, Schema.String]))),
|
|
54
|
+
pending: Schema.optional(Schema.Array(RequestId))
|
|
55
|
+
});
|
|
56
|
+
const decodeAttachment = Schema.decodeUnknownExit(Schema.NullishOr(Attachment));
|
|
57
|
+
/** The socket's attachment, failing when it does not decode, or `undefined` with none. */
|
|
58
|
+
const attachmentOf = (socket) => Exit.map(decodeAttachment(socket.deserializeAttachment()), (held) => held ?? void 0);
|
|
59
|
+
/** The socket's attachment, or `undefined` with none or one that does not decode. */
|
|
60
|
+
const heldOf = (socket) => Exit.match(attachmentOf(socket), {
|
|
61
|
+
onFailure: Function.constUndefined,
|
|
62
|
+
onSuccess: (held) => held
|
|
63
|
+
});
|
|
64
|
+
const pendingOf = (socket) => heldOf(socket)?.pending ?? [];
|
|
65
|
+
const tracked = (socket, update) => {
|
|
66
|
+
const held = heldOf(socket);
|
|
67
|
+
if (held === void 0) return true;
|
|
68
|
+
const pending = update(held.pending ?? []);
|
|
69
|
+
try {
|
|
70
|
+
socket.serializeAttachment(Attachment.make({
|
|
71
|
+
headers: held.headers,
|
|
72
|
+
...pending.length > 0 && { pending }
|
|
73
|
+
}));
|
|
74
|
+
return true;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const settledBy = (response) => Match.value(response).pipe(Match.tag("Exit", ({ requestId }) => (pending) => pending.filter((id) => id !== requestId)), Match.tag("Defect", () => () => []), Match.orElse(Function.constUndefined));
|
|
80
|
+
const lostCalls = "This server lost the calls it was answering on this socket: connect again";
|
|
81
|
+
const untracked = "This socket's attachment, its upgrade's headers and the ids of its calls under way, would pass the runtime's 16 KiB: send fewer calls at once, or fewer or smaller headers on the upgrade";
|
|
82
|
+
const encodeDefect = Schema.encodeSync(RpcSerialization.json.codecFor(Schema.Defect()));
|
|
83
|
+
const upgraded = (client) => HttpServerResponse.raw(new Response(null, {
|
|
84
|
+
status: 101,
|
|
85
|
+
webSocket: client
|
|
86
|
+
}), { status: 101 });
|
|
87
|
+
const closeReasonBytes = 123;
|
|
88
|
+
const bytesOf = (text) => new TextEncoder().encode(text).byteLength;
|
|
89
|
+
const acceptedCode = (code) => Number.isInteger(code) && code >= 4e3 && code <= 4999;
|
|
90
|
+
const refusalOf = (protocol) => Effect.gen(function* () {
|
|
91
|
+
if (protocol === void 0) return;
|
|
92
|
+
const { version, code, reason = `This server speaks protocol ${version}: reload the page` } = protocol;
|
|
93
|
+
if (!acceptedCode(code)) return yield* Effect.die(/* @__PURE__ */ new Error(`ActionDurableObject.layerWebSocket's protocol.code ${code} is not one it accepts, an integer from 4000 to 4999, the codes a watch ends at: give one of those`));
|
|
94
|
+
if (bytesOf(reason) > closeReasonBytes) return yield* Effect.die(/* @__PURE__ */ new Error(`ActionDurableObject.layerWebSocket's protocol.reason, given or the default naming its version, is ${bytesOf(reason)} bytes, more than the ${closeReasonBytes} a close frame holds: give a reason of at most ${closeReasonBytes} bytes, UTF-8 encoded`));
|
|
95
|
+
return {
|
|
96
|
+
version,
|
|
97
|
+
code,
|
|
98
|
+
reason
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
/**
|
|
102
|
+
* The answer router middleware gives a WebSocket upgrade it refuses, such as one from a foreign
|
|
103
|
+
* `Origin`, as `layerWebSocket`'s `protocol` refuses a page of another version: 101, with a socket of its own closed at once with
|
|
104
|
+
* `code` and `reason`, which the page's Effect client fails its calls and streams with, as
|
|
105
|
+
* `SocketCloseError`. A browser cannot read the status of an upgrade it was refused, so this is
|
|
106
|
+
* how a refusal reaches the page.
|
|
107
|
+
*
|
|
108
|
+
* **Gotchas**
|
|
109
|
+
*
|
|
110
|
+
* - A 403, or any other status, reaches a browser's WebSocket as a bare 1006, as a network drop, and Effect's client connects again, half a second later and growing to every five seconds, for good.
|
|
111
|
+
* - Effect's client connects again after this close as well, as often and for good, each upgrade waking the object, unless its `retryPolicy` is the core's `ActionRpc.reconnectUntil(code)`, the client half of this refusal.
|
|
112
|
+
* - Its socket is accepted without the hibernation API and the library's route never runs, so the object keeps nothing of it; that accepted end ties up its request until the close handshake completes, so the object stays awake until the page answers the close.
|
|
113
|
+
* - `code` is an integer from 4000 to 4999, the codes the core's `watch` ends at, connecting again after any other: another dies.
|
|
114
|
+
*/
|
|
115
|
+
const refuseUpgrade = (code, reason) => Effect.suspend(() => {
|
|
116
|
+
if (!acceptedCode(code)) return Effect.die(/* @__PURE__ */ new Error(`ActionDurableObject.refuseUpgrade's code ${code} is not one it accepts, an integer from 4000 to 4999, the codes a watch ends at: give one of those`));
|
|
117
|
+
const { 0: client, 1: server } = new WebSocketPair();
|
|
118
|
+
server.accept();
|
|
119
|
+
server.close(code, reason);
|
|
120
|
+
return Effect.succeed(upgraded(client));
|
|
121
|
+
});
|
|
122
|
+
/**
|
|
123
|
+
* A hibernating `RpcServer.Protocol` over the object's WebSockets, speaking JSON, and its
|
|
124
|
+
* upgrade route at `path`. Each socket carries two tags, its group's and its own id's, and keeps
|
|
125
|
+
* its upgrade request's headers in its attachment, so both survive hibernation.
|
|
126
|
+
*/
|
|
127
|
+
const makeProtocol = (state, sockets, { path, protocol: version }) => Effect.gen(function* () {
|
|
128
|
+
const router = yield* HttpRouter.HttpRouter;
|
|
129
|
+
const context = yield* Effect.context();
|
|
130
|
+
const group = groupTag(path);
|
|
131
|
+
const refusal = yield* refusalOf(version);
|
|
132
|
+
const idOf = (socket) => socketIdOf(state.getTags(socket));
|
|
133
|
+
state.setWebSocketAutoResponse(new WebSocketRequestResponsePair(String(parser.encode(RpcMessage.constPing)), String(parser.encode(RpcMessage.constPong))));
|
|
134
|
+
const disconnects = yield* Queue.make();
|
|
135
|
+
const subscriptions = makeSubscriptions(state, path);
|
|
136
|
+
const send = (clientId, response) => sendFrame(state, clientId, response).pipe(Effect.andThen(Effect.sync(() => {
|
|
137
|
+
const settled = settledBy(response);
|
|
138
|
+
if (settled === void 0) return;
|
|
139
|
+
for (const socket of state.getWebSockets(socketTag(clientId))) tracked(socket, settled);
|
|
140
|
+
})), Effect.andThen(subscriptions.sent(clientId, response)));
|
|
141
|
+
const protocol = yield* RpcServer.Protocol.make((write) => {
|
|
142
|
+
if (!sockets.register(group, {
|
|
143
|
+
message: (socket, data) => {
|
|
144
|
+
const clientId = idOf(socket);
|
|
145
|
+
return Effect.gen(function* () {
|
|
146
|
+
const headers = (yield* Effect.orDie(attachmentOf(socket)))?.headers ?? [];
|
|
147
|
+
const messages = parser.decode(Predicate.isString(data) ? data : new Uint8Array(data));
|
|
148
|
+
const sent = messages.flatMap((message) => Predicate.isTagged(message, "Request") ? [message.id] : []);
|
|
149
|
+
const kept = sent.length === 0 || tracked(socket, (pending) => [...pending, ...sent]);
|
|
150
|
+
return yield* Effect.forEach(messages, (message) => {
|
|
151
|
+
if (!Predicate.isTagged(message, "Request")) return write(clientId, message);
|
|
152
|
+
return kept ? write(clientId, {
|
|
153
|
+
...message,
|
|
154
|
+
headers: [...headers, ...message.headers]
|
|
155
|
+
}) : send(clientId, RpcMessage.ResponseExitDieEncoded({
|
|
156
|
+
requestId: RpcMessage.RequestId(message.id),
|
|
157
|
+
encodedDefect: encodeDefect(untracked)
|
|
158
|
+
}));
|
|
159
|
+
}, { discard: true });
|
|
160
|
+
}).pipe(Effect.catchDefect((defect) => send(clientId, RpcMessage.ResponseDefectEncoded(encodeDefect(defect)))), Effect.asVoid, Effect.provideContext(context));
|
|
161
|
+
},
|
|
162
|
+
close: (socket) => {
|
|
163
|
+
const clientId = idOf(socket);
|
|
164
|
+
return Effect.andThen(subscriptions.closed(clientId), Effect.asVoid(Queue.offer(disconnects, clientId)));
|
|
165
|
+
}
|
|
166
|
+
})) return Effect.die(/* @__PURE__ */ new Error(`Two ActionDurableObject.layerWebSocket protocols of one object serve the path ${path}: a router prefix does not tell them apart: give each its own path, such as /a/rpc and /b/rpc`));
|
|
167
|
+
const resumed = subscriptions.resumed(write, (socket, kept) => {
|
|
168
|
+
if (pendingOf(socket).every(kept)) return true;
|
|
169
|
+
closeQuietly(socket, 1012, lostCalls);
|
|
170
|
+
return false;
|
|
171
|
+
});
|
|
172
|
+
return Effect.as(resumed, {
|
|
173
|
+
disconnects,
|
|
174
|
+
send,
|
|
175
|
+
end: (clientId) => Effect.sync(() => {
|
|
176
|
+
for (const socket of state.getWebSockets(socketTag(clientId))) closeQuietly(socket, 1e3);
|
|
177
|
+
}),
|
|
178
|
+
clientIds: Effect.sync(() => new Set(state.getWebSockets(group).map(idOf))),
|
|
179
|
+
initialMessage: Effect.succeedNone,
|
|
180
|
+
supportsAck: true,
|
|
181
|
+
supportsTransferables: false,
|
|
182
|
+
supportsSpanPropagation: true,
|
|
183
|
+
supportsNotifications: true,
|
|
184
|
+
codecFor: RpcSerialization.json.codecFor
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
yield* router.add("GET", path, Effect.gen(function* () {
|
|
188
|
+
const request = yield* HttpServerRequest.HttpServerRequest;
|
|
189
|
+
if (request.headers["upgrade"]?.toLowerCase() !== "websocket") return HttpServerResponse.text("Expected a WebSocket upgrade: connect with a WebSocket client, or forward the page's own Request to the object", { status: 426 });
|
|
190
|
+
if (refusal !== void 0) {
|
|
191
|
+
const { protocol: named } = yield* HttpServerRequest.ParsedSearchParams;
|
|
192
|
+
if (named !== refusal.version) return yield* refuseUpgrade(refusal.code, refusal.reason);
|
|
193
|
+
}
|
|
194
|
+
const { 0: client, 1: server } = new WebSocketPair();
|
|
195
|
+
const attached = yield* Effect.exit(Effect.try(() => {
|
|
196
|
+
server.serializeAttachment(Attachment.make({ headers: Object.entries(request.headers) }));
|
|
197
|
+
}));
|
|
198
|
+
if (Exit.isFailure(attached)) return HttpServerResponse.text("The headers a socket keeps exceed its attachment: send fewer or smaller cookies to this path, or the token on each message", { status: 431 });
|
|
199
|
+
state.acceptWebSocket(server, [group, socketTag(yield* sockets.allocate)]);
|
|
200
|
+
return upgraded(client);
|
|
201
|
+
}));
|
|
202
|
+
return {
|
|
203
|
+
...protocol,
|
|
204
|
+
[ActionRpc.subscriptionKeeper]: subscriptions.keeper
|
|
205
|
+
};
|
|
206
|
+
});
|
|
207
|
+
/**
|
|
208
|
+
* The `RpcServer.Protocol` `ActionRpc.layer` serves on, in an object's routes: WebSockets the
|
|
209
|
+
* object accepts at `path`, with the runtime's hibernation API, for the runtime to hold while
|
|
210
|
+
* the object hibernates, speaking JSON, the client's `RpcSerialization.layerJson` over
|
|
211
|
+
* `RpcClient.layerProtocolSocket`. A socket keeps its upgrade request's headers, as Effect's own
|
|
212
|
+
* socket protocol does, its credential included, and each message is authenticated with those
|
|
213
|
+
* under its own, its own winning. It keeps each event subscription `ActionRpc.layer` admits
|
|
214
|
+
* across hibernation. It registers with the instance's `Sockets`, which hands it their events,
|
|
215
|
+
* and requires it.
|
|
216
|
+
*
|
|
217
|
+
* **Gotchas**
|
|
218
|
+
*
|
|
219
|
+
* - An idle object is evicted while its sockets stay open: a message constructs a new instance, which builds the routes and then answers it.
|
|
220
|
+
* - A message's event ends once the message is handed to the server: a call doing I/O or waiting on a timer keeps the object from hibernating, billed as long, and one waiting only on in-memory state, such as a `Deferred`, is lost if the object hibernates. A call an eviction, a deploy or a crash cut off closes its socket with 1012 as the next instance builds, so its client fails it with a `SocketCloseError` and connects again; on celld 0.6.2, which drops attachments, it waits for good.
|
|
221
|
+
* - An `ActionRpc` event subscription is kept in the object's storage, under `effect-actions/subscription/`, its headers, tokens and cookies included, with it, and resumed from the position it reached when the object next wakes, on any event, authenticated again from those headers: a token that expired meanwhile ends it with `Unauthenticated`.
|
|
222
|
+
* - A kept subscription that cannot be resumed, such as one a later version of this package stores in another shape, closes its socket with 1000, logged, so the page's client connects again and its `watch` subscribes anew.
|
|
223
|
+
* - A native stream rpc or a streaming action is not kept: waiting on in-memory state, it is lost if the object hibernates, its socket closed with 1012 as the next instance builds; serve what a page follows as an event.
|
|
224
|
+
* - Each chunk that moves a kept subscription's position is one small storage write, and it is sent with no heartbeat: Effect's socket client tells a dead socket by its pings.
|
|
225
|
+
* - `storage.deleteAll()` deletes the kept subscriptions too, so a watch hears nothing more once the object hibernates: close the sockets with it, so their clients connect again and watch anew.
|
|
226
|
+
* - A client's close frame is answered with its own code and reason, as the closing handshake asks, whatever the `compatibility_date`: where the runtime already replied, under `web_socket_auto_reply_to_close`, the second reply is ignored; a socket the class accepted itself is the class's to answer.
|
|
227
|
+
* - An upgrade whose headers, cookies included, exceed the runtime's 16 KiB attachment is answered 431, and a call whose id would take the attachment past it fails at once with a defect naming it, never run; a request to `path` that is no upgrade is answered 426.
|
|
228
|
+
* - workerd keeps a socket's headers across hibernation; celld 0.6.2 drops them when it evicts an idle object, so a message relying on the upgrade's token is refused as unauthenticated: send a token on each message.
|
|
229
|
+
* - The upgrade's URL and query are not kept, as Effect's own socket protocol keeps neither: a message has no URL, so an API key in the query never authenticates; `protocol` reads the upgrade's query for a version, and router middleware reads it for any other judge, refusing with `refuseUpgrade`.
|
|
230
|
+
* - A browser WebSocket sets no header on its upgrade, so a page sends its token on each message, with `RpcClient.withHeaders`; under a cookie scheme the browser sends the cookie on any page's upgrade, which is never preflighted, so refuse a foreign `Origin` with router middleware, which runs for the upgrade, returning `refuseUpgrade`: a 403 reaches the page as a bare 1006, and its client connects again for good.
|
|
231
|
+
* - The verifier runs for every message, so a token that expires refuses the next call: a client renews it on its messages, or connects again.
|
|
232
|
+
* - Router middleware around the routes runs for the upgrade request alone, never for a socket's messages: list an `RpcMiddleware` in `ActionRpc.layer`'s `middleware` for a per-message service.
|
|
233
|
+
* - Each protocol of one object needs a `path` of its own: a router prefix does not tell two apart, and two at one `path` fail the build.
|
|
234
|
+
* - The runtime answers a client's pings itself, every five seconds, so an open page does not wake the object.
|
|
235
|
+
* - Effect's `RpcServer.layerProtocolWebsocket` cannot upgrade a web handler's request: on Workers, serve sockets with this.
|
|
236
|
+
* - A handler's die under a plain `RpcServer.layer` is Effect's fatal `Defect`, failing every call in flight on that socket, a call made in the dying one's continuation included, as Effect's own design, unlike over the object's stub: serve with `RpcServer.layer(group, { disableFatalDefects: true })`, or through `ActionRpc.layer`, to fail that call alone.
|
|
237
|
+
*
|
|
238
|
+
* Guide: `docs/ActionDurableObject.md` in this package.
|
|
239
|
+
*/
|
|
240
|
+
const layerWebSocket = (options) => Layer.effect(RpcServer.Protocol)(Effect.gen(function* () {
|
|
241
|
+
return yield* makeProtocol(yield* State, yield* Sockets, options);
|
|
242
|
+
}));
|
|
243
|
+
export { Sockets, layerWebSocket, refuseUpgrade };
|
package/dist/state.d.mts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Context, Effect } from "effect";
|
|
2
|
+
declare const State_base: Context.ServiceClass<State, "effect-actions-cloudflare/ActionDurableObject/State", DurableObjectState<unknown>>;
|
|
3
|
+
/**
|
|
4
|
+
* The object's own `DurableObjectState`, `ctx`: its storage, its id, its alarm and its sockets.
|
|
5
|
+
* A startup service of the object's services, routes and alarm, one per instance, so a store
|
|
6
|
+
* over `storage.sql` is a Layer requiring it.
|
|
7
|
+
*/
|
|
8
|
+
export declare class State extends State_base {}
|
|
9
|
+
/**
|
|
10
|
+
* The name the Worker reached the object by, `getByName(name)`, from `State`'s id: what the
|
|
11
|
+
* object is, such as its room, as a startup service, `Layer.effect(Room)(name)`.
|
|
12
|
+
*
|
|
13
|
+
* **Gotchas**
|
|
14
|
+
*
|
|
15
|
+
* - An object reached by an id that carries no name, from `idFromString` or `newUniqueId`, dies at once, `was reached by an id that carries none`: reach it with `getByName`.
|
|
16
|
+
*/
|
|
17
|
+
export declare const name: Effect.Effect<string, never, State>;
|
|
18
|
+
export {};
|
package/dist/state.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Context, Effect } from "effect";
|
|
2
|
+
// src/durable/state.ts
|
|
3
|
+
/**
|
|
4
|
+
* The object's own `DurableObjectState`, `ctx`: its storage, its id, its alarm and its sockets.
|
|
5
|
+
* A startup service of the object's services, routes and alarm, one per instance, so a store
|
|
6
|
+
* over `storage.sql` is a Layer requiring it.
|
|
7
|
+
*/
|
|
8
|
+
var State = class extends Context.Service()("effect-actions-cloudflare/ActionDurableObject/State") {};
|
|
9
|
+
/**
|
|
10
|
+
* The name the Worker reached the object by, `getByName(name)`, from `State`'s id: what the
|
|
11
|
+
* object is, such as its room, as a startup service, `Layer.effect(Room)(name)`.
|
|
12
|
+
*
|
|
13
|
+
* **Gotchas**
|
|
14
|
+
*
|
|
15
|
+
* - An object reached by an id that carries no name, from `idFromString` or `newUniqueId`, dies at once, `was reached by an id that carries none`: reach it with `getByName`.
|
|
16
|
+
*/
|
|
17
|
+
const name = Effect.flatMap(Effect.service(State), ({ id }) => id.name === void 0 ? Effect.die(/* @__PURE__ */ new Error(`ActionDurableObject.name reads the name the Worker reached the object by, and the object ${id.toString()} was reached by an id that carries none, as from idFromString or newUniqueId: reach it with getByName(name)`)) : Effect.succeed(id.name));
|
|
18
|
+
export { State, name };
|
package/dist/stub.d.mts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { StubRequest } from "./calls.mjs";
|
|
2
|
+
import { Effect, Layer, Scope } from "effect";
|
|
3
|
+
import { Rpc, RpcClient, RpcClientError, RpcGroup, RpcSchema } from "effect/rpc";
|
|
4
|
+
/**
|
|
5
|
+
* What a call reaches an object's rpcs through: a `DurableObjectStub` of a class `make` returns,
|
|
6
|
+
* or of a class delegating `effectActionsRpc` to `handlers`, as `getByName` returns it. Its
|
|
7
|
+
* answer is untyped data until the client checks it: Workers RPC's types rewrite a returned
|
|
8
|
+
* tuple as an array.
|
|
9
|
+
*/
|
|
10
|
+
export interface Stub {
|
|
11
|
+
readonly effectActionsRpc: (request: StubRequest) => Promise<readonly unknown[]>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The `RpcClient.Protocol` `RpcClient.make` and `ActionRpc.client` call an object's rpcs with,
|
|
15
|
+
* over `stub`, or over the stub a function returns as each call is sent: each call is one call
|
|
16
|
+
* of the object's `effectActionsRpc` method, its messages and their answer passed as values,
|
|
17
|
+
* each schema's plain form, which the object serves through `make`'s or `handlers`' `calls`. The stub's own failure is an `RpcClientError`, as an HTTP transport's
|
|
18
|
+
* is, in every call's errors.
|
|
19
|
+
*
|
|
20
|
+
* **Gotchas**
|
|
21
|
+
*
|
|
22
|
+
* - A stub given is held, and many exceptions leave a stub broken, failing every later call through it with the same error: give a function, `() => env.ROOM.getByName(name)`, called as each call is sent, for a new stub each time; one that throws is a defect, not an `RpcClientError`, as it is no failure of the stub.
|
|
23
|
+
* - An `RpcClientError`'s `RpcClientDefect` holds the runtime's error as its `cause`: `.retryable` true allows repeating the call, as Cloudflare's guide says, and `.durableObjectReset` true needs a new stub, which a function gives.
|
|
24
|
+
* - Workers RPC limits a call's arguments and its answer to 32 MiB, serialized: past it, the call fails with the runtime's `limited to 32MiB` error.
|
|
25
|
+
* - An object serving no `calls`, once built, or a class without the method, fails each call, with the object's or the runtime's message as the error's cause; an object whose build fails fails it with that failure first.
|
|
26
|
+
* - A call's interruption does not reach the object, as a stub call cannot be cancelled: its answer is dropped.
|
|
27
|
+
* - Unary: an rpc that streams fails with a defect at its first chunk, so a handler returning a stream that never emits leaves the call waiting for good; `client` gives such an rpc no method.
|
|
28
|
+
* - `RpcClient.make` over it forks a fiber started on a timer of the request that builds it, which workerd drops when that request ends: a client first built lazily inside a Worker's request that ends without waiting on I/O never starts it, and every later call waits until workerd cancels it as hung; build the client per request or, shared, at module scope under Effect's sync scheduler, as `HttpRouter.toWebHandler`'s gotchas show, where a `ManagedRuntime.make` builds only at its first run, so one made at module scope is still built inside a request; an object's build, under the microtask scheduler, did not strand it in the tests.
|
|
29
|
+
*
|
|
30
|
+
* Guide: `docs/ActionDurableObject-stub.md` in this package.
|
|
31
|
+
*/
|
|
32
|
+
export declare const layerStubClient: (stub: Stub | (() => Stub)) => Layer.Layer<RpcClient.Protocol>;
|
|
33
|
+
type Unary<Rpcs extends Rpc.Any> = Rpcs extends Rpc.Any ? [Rpc.SuccessSchema<Rpcs>] extends [RpcSchema.Stream<infer _A, infer _E>] ? never : Rpcs : never;
|
|
34
|
+
/**
|
|
35
|
+
* A group's rpcs, as `client.<rpc>(payload)`: Effect's native client, each call failing with
|
|
36
|
+
* the rpc's declared errors or an `RpcClientError`. An rpc that streams has no method: the
|
|
37
|
+
* transport is unary.
|
|
38
|
+
*/
|
|
39
|
+
export type Client<Group> = RpcClient.RpcClient<Unary<RpcGroup.Rpcs<Group>>, RpcClientError.RpcClientError>;
|
|
40
|
+
/**
|
|
41
|
+
* The rpcs of `group`, as `(stubOf) => client`, for a Worker or an object calling objects of
|
|
42
|
+
* one class: `room(() => env.CHAT_ROOM.getByName(name)).latestAt()`. One native client, built
|
|
43
|
+
* in the given `Scope`, serves every object, and each call gets its stub from `stubOf` as it is
|
|
44
|
+
* sent, so no stub is held, nothing is kept per object and an eviction changes nothing: a
|
|
45
|
+
* client over one object may be kept for good. Each call goes over `layerStubClient`'s
|
|
46
|
+
* transport, whose gotchas hold here too: a call fails with the rpc's declared errors and the
|
|
47
|
+
* stub's own failure, an `RpcClientError`, as Effect's native client does.
|
|
48
|
+
*
|
|
49
|
+
* **Gotchas**
|
|
50
|
+
*
|
|
51
|
+
* - `stubOf` reads `env` as each call is sent, from the `env` import of `cloudflare:workers` or the `env` `make`'s `services` function is given, never as the module evaluates; one that throws is a defect.
|
|
52
|
+
* - Only names you accept should reach `getByName`: each name is another object.
|
|
53
|
+
* - An `ActionRpc` binding's `group` fails here, `Expected null`, as `ActionRpc.layer` carries its values as JSON over the stub's protocol passing values: call such an object's actions and native rpcs with `ActionRpc.client` over `layerStubClient(() => stub)`.
|
|
54
|
+
* - An rpc that streams has no method, as `Client` types it.
|
|
55
|
+
*
|
|
56
|
+
* Guide: `docs/ActionDurableObject-stub.md` in this package.
|
|
57
|
+
*/
|
|
58
|
+
export declare const client: <Rpcs extends Rpc.Any>(group: RpcGroup.RpcGroup<Rpcs>) => Effect.Effect<(stubOf: () => Stub) => Client<RpcGroup.RpcGroup<Rpcs>>, never, Scope.Scope | Rpc.MiddlewareClient<Rpcs>>;
|
|
59
|
+
export {};
|
package/dist/stub.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { codecFor } from "./calls.mjs";
|
|
2
|
+
import { RequestId } from "./frames.mjs";
|
|
3
|
+
import { Context, Effect, Function, Layer, Option, Predicate, Schema } from "effect";
|
|
4
|
+
import { RpcClient, RpcClientError, RpcSchema } from "effect/rpc";
|
|
5
|
+
// src/durable/stub.ts
|
|
6
|
+
const clientError = (reason) => new RpcClientError.RpcClientError({ reason });
|
|
7
|
+
const isAnswer = Schema.is(Schema.Array(Schema.Struct({ _tag: Schema.String })));
|
|
8
|
+
const completionOf = Schema.decodeUnknownOption(Schema.Union([Schema.TaggedStruct("Exit", { requestId: RequestId }), Schema.Struct({ _tag: Schema.Literals(["Defect", "ClientProtocolError"]) })]));
|
|
9
|
+
const makeProtocol = (target) => RpcClient.Protocol.make((write) => Effect.succeed({
|
|
10
|
+
send: (clientId, request) => Predicate.isTagged(request, "Request") ? Effect.gen(function* () {
|
|
11
|
+
const stub = yield* target;
|
|
12
|
+
const answer = yield* Effect.tryPromise({
|
|
13
|
+
try: async () => await stub.effectActionsRpc([request]),
|
|
14
|
+
catch: (cause) => clientError(new RpcClientError.RpcClientDefect({
|
|
15
|
+
message: "The object's stub refused the call",
|
|
16
|
+
cause
|
|
17
|
+
}))
|
|
18
|
+
});
|
|
19
|
+
if (!(isAnswer(answer) && answer.some((response) => Option.exists(completionOf(response), (completion) => !Predicate.isTagged(completion, "Exit") || completion.requestId === request.id)))) return yield* clientError(new RpcClientError.RpcClientDefect({
|
|
20
|
+
message: "The object's answer ended before the call completed: delegate effectActionsRpc to handlers' own, returning its answer",
|
|
21
|
+
cause: request
|
|
22
|
+
}));
|
|
23
|
+
yield* Effect.forEach(answer, (response) => write(clientId, response), { discard: true });
|
|
24
|
+
}) : Effect.void,
|
|
25
|
+
supportsAck: false,
|
|
26
|
+
supportsTransferables: false,
|
|
27
|
+
codecFor
|
|
28
|
+
}));
|
|
29
|
+
/**
|
|
30
|
+
* The `RpcClient.Protocol` `RpcClient.make` and `ActionRpc.client` call an object's rpcs with,
|
|
31
|
+
* over `stub`, or over the stub a function returns as each call is sent: each call is one call
|
|
32
|
+
* of the object's `effectActionsRpc` method, its messages and their answer passed as values,
|
|
33
|
+
* each schema's plain form, which the object serves through `make`'s or `handlers`' `calls`. The stub's own failure is an `RpcClientError`, as an HTTP transport's
|
|
34
|
+
* is, in every call's errors.
|
|
35
|
+
*
|
|
36
|
+
* **Gotchas**
|
|
37
|
+
*
|
|
38
|
+
* - A stub given is held, and many exceptions leave a stub broken, failing every later call through it with the same error: give a function, `() => env.ROOM.getByName(name)`, called as each call is sent, for a new stub each time; one that throws is a defect, not an `RpcClientError`, as it is no failure of the stub.
|
|
39
|
+
* - An `RpcClientError`'s `RpcClientDefect` holds the runtime's error as its `cause`: `.retryable` true allows repeating the call, as Cloudflare's guide says, and `.durableObjectReset` true needs a new stub, which a function gives.
|
|
40
|
+
* - Workers RPC limits a call's arguments and its answer to 32 MiB, serialized: past it, the call fails with the runtime's `limited to 32MiB` error.
|
|
41
|
+
* - An object serving no `calls`, once built, or a class without the method, fails each call, with the object's or the runtime's message as the error's cause; an object whose build fails fails it with that failure first.
|
|
42
|
+
* - A call's interruption does not reach the object, as a stub call cannot be cancelled: its answer is dropped.
|
|
43
|
+
* - Unary: an rpc that streams fails with a defect at its first chunk, so a handler returning a stream that never emits leaves the call waiting for good; `client` gives such an rpc no method.
|
|
44
|
+
* - `RpcClient.make` over it forks a fiber started on a timer of the request that builds it, which workerd drops when that request ends: a client first built lazily inside a Worker's request that ends without waiting on I/O never starts it, and every later call waits until workerd cancels it as hung; build the client per request or, shared, at module scope under Effect's sync scheduler, as `HttpRouter.toWebHandler`'s gotchas show, where a `ManagedRuntime.make` builds only at its first run, so one made at module scope is still built inside a request; an object's build, under the microtask scheduler, did not strand it in the tests.
|
|
45
|
+
*
|
|
46
|
+
* Guide: `docs/ActionDurableObject-stub.md` in this package.
|
|
47
|
+
*/
|
|
48
|
+
const layerStubClient = (stub) => Layer.effect(RpcClient.Protocol)(makeProtocol(Predicate.isFunction(stub) ? Effect.sync(stub) : Effect.succeed(stub)));
|
|
49
|
+
const CurrentStub = Context.Reference("effect-actions-cloudflare/ActionDurableObject/CurrentStub", { defaultValue: Function.constUndefined });
|
|
50
|
+
const currentStub = Effect.flatMap(Effect.service(CurrentStub), (stubOf) => stubOf === void 0 ? Effect.die(/* @__PURE__ */ new Error("No stub for the call")) : Effect.sync(stubOf));
|
|
51
|
+
/**
|
|
52
|
+
* The rpcs of `group`, as `(stubOf) => client`, for a Worker or an object calling objects of
|
|
53
|
+
* one class: `room(() => env.CHAT_ROOM.getByName(name)).latestAt()`. One native client, built
|
|
54
|
+
* in the given `Scope`, serves every object, and each call gets its stub from `stubOf` as it is
|
|
55
|
+
* sent, so no stub is held, nothing is kept per object and an eviction changes nothing: a
|
|
56
|
+
* client over one object may be kept for good. Each call goes over `layerStubClient`'s
|
|
57
|
+
* transport, whose gotchas hold here too: a call fails with the rpc's declared errors and the
|
|
58
|
+
* stub's own failure, an `RpcClientError`, as Effect's native client does.
|
|
59
|
+
*
|
|
60
|
+
* **Gotchas**
|
|
61
|
+
*
|
|
62
|
+
* - `stubOf` reads `env` as each call is sent, from the `env` import of `cloudflare:workers` or the `env` `make`'s `services` function is given, never as the module evaluates; one that throws is a defect.
|
|
63
|
+
* - Only names you accept should reach `getByName`: each name is another object.
|
|
64
|
+
* - An `ActionRpc` binding's `group` fails here, `Expected null`, as `ActionRpc.layer` carries its values as JSON over the stub's protocol passing values: call such an object's actions and native rpcs with `ActionRpc.client` over `layerStubClient(() => stub)`.
|
|
65
|
+
* - An rpc that streams has no method, as `Client` types it.
|
|
66
|
+
*
|
|
67
|
+
* Guide: `docs/ActionDurableObject-stub.md` in this package.
|
|
68
|
+
*/
|
|
69
|
+
const client = (group) => Effect.gen(function* () {
|
|
70
|
+
const native = yield* RpcClient.make(group).pipe(Effect.provideServiceEffect(RpcClient.Protocol, makeProtocol(currentStub)), Effect.map((made) => made));
|
|
71
|
+
const rpcs = group.requests.values();
|
|
72
|
+
const unary = new Set([...rpcs].flatMap((rpc) => RpcSchema.isStreamSchema(rpc.successSchema) ? [] : [rpc._tag]));
|
|
73
|
+
return (stubOf) => Object.fromEntries(Object.entries(native).flatMap(([tag, method]) => unary.has(tag) ? [[tag, (...args) => method(...args).pipe(Effect.provideService(CurrentStub, stubOf))]] : []));
|
|
74
|
+
});
|
|
75
|
+
export { client, layerStubClient };
|