@gjermundgaraba/effect-actions-cloudflare 0.21.0 → 0.23.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/dist/ActionDurableObject.d.mts +7 -1
- package/dist/ActionDurableObject.mjs +7 -7
- package/dist/alarm.d.mts +15 -6
- package/dist/alarm.mjs +42 -39
- package/dist/batches.mjs +0 -1
- package/dist/calls.mjs +0 -11
- package/dist/events.mjs +0 -15
- package/dist/frames.mjs +6 -9
- package/dist/idempotency.mjs +2 -8
- package/dist/placed.mjs +0 -4
- package/dist/sockets.d.mts +0 -4
- package/dist/sockets.mjs +7 -14
- package/dist/stub.d.mts +4 -4
- package/dist/stub.mjs +9 -8
- package/dist/subscriptions.mjs +10 -22
- package/dist/tasks.mjs +3 -8
- package/dist/webhooks.mjs +5 -21
- package/docs/ActionDurableObject-events.md +0 -20
- package/docs/ActionDurableObject-failures.md +1 -1
- package/docs/ActionDurableObject-placement.md +1 -4
- package/docs/ActionDurableObject-stub.md +3 -31
- package/docs/ActionDurableObject-testing.md +0 -5
- package/docs/ActionDurableObject.md +4 -50
- package/docs/README.md +1 -1
- package/examples/chat-rooms/README.md +2 -2
- package/examples/chat-rooms/src/Api.ts +0 -3
- package/examples/chat-rooms/src/Rpc.ts +0 -7
- package/examples/chat-rooms/src/browser.ts +2 -34
- package/examples/chat-rooms/src/chat/Chat.ts +3 -23
- package/examples/chat-rooms/src/chat/activity.ts +0 -5
- package/examples/chat-rooms/src/chat/authorize.ts +0 -3
- package/examples/chat-rooms/src/chat/events.ts +0 -12
- package/examples/chat-rooms/src/chat/handlers.ts +0 -6
- package/examples/chat-rooms/src/chat/room.ts +2 -7
- package/examples/chat-rooms/src/chat/store.ts +0 -6
- package/examples/chat-rooms/src/identity/Identity.ts +0 -6
- package/examples/chat-rooms/src/identity/members.ts +1 -3
- package/examples/chat-rooms/src/identity/verifier.ts +6 -9
- package/examples/chat-rooms/src/lobby/Calls.ts +0 -5
- package/examples/chat-rooms/src/lobby/Lobby.ts +2 -7
- package/examples/chat-rooms/src/lobby/events.ts +0 -5
- package/examples/chat-rooms/src/lobby/handlers.ts +0 -3
- package/examples/chat-rooms/src/lobby/host.ts +0 -1
- package/examples/chat-rooms/src/lobby/store.ts +2 -4
- package/examples/chat-rooms/src/platform/kv-previews.ts +6 -5
- package/examples/chat-rooms/src/platform/lobby-stubs.ts +0 -15
- package/examples/chat-rooms/src/platform/sql-store.ts +1 -13
- package/examples/chat-rooms/src/rooms-entrypoint.ts +2 -10
- package/examples/chat-rooms/src/routes.ts +0 -25
- package/examples/chat-rooms/src/worker.ts +3 -24
- package/examples/chat-rooms/test/platform/sql-store.test.ts +0 -5
- package/examples/chat-rooms/test/support/socket.ts +0 -9
- package/examples/chat-rooms/test/worker.test.ts +17 -24
- package/examples/chat-rooms/vite.config.ts +0 -5
- package/package.json +3 -3
- package/examples/chat-rooms/node_modules/.vite/vitest/CACHEDIR.TAG +0 -3
- package/examples/chat-rooms/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
package/dist/subscriptions.mjs
CHANGED
|
@@ -2,7 +2,6 @@ import { deleted } from "./batches.mjs";
|
|
|
2
2
|
import { RequestId, closeQuietly, groupTag, socketIdOf } from "./frames.mjs";
|
|
3
3
|
import { Effect, Function, Match, Option, Predicate, Schema } from "effect";
|
|
4
4
|
// src/durable/subscriptions.ts
|
|
5
|
-
/** Where an object keeps its sockets' event subscriptions. */
|
|
6
5
|
const keptPrefix = "effect-actions/subscription/";
|
|
7
6
|
const Request = Schema.TaggedStruct("Request", {
|
|
8
7
|
id: RequestId,
|
|
@@ -11,33 +10,27 @@ const Request = Schema.TaggedStruct("Request", {
|
|
|
11
10
|
});
|
|
12
11
|
const requestOf = Schema.decodeUnknownOption(Request);
|
|
13
12
|
const payloadOf = Schema.decodeUnknownOption(Schema.Json);
|
|
13
|
+
const resumeFailedCode = 1e3;
|
|
14
14
|
const unresumable = "This server could not resume this socket's subscriptions: connect again";
|
|
15
15
|
const suffixOf = (socket, id) => `${socket}#${Predicate.isNumber(id) ? "n" : "s"}${id}`;
|
|
16
16
|
const socketOf = (suffix) => Number(suffix.slice(0, suffix.indexOf("#")));
|
|
17
|
-
/**
|
|
18
|
-
* The event subscriptions of the sockets one `layerWebSocket` path accepted, kept in storage:
|
|
19
|
-
* `keeper` stores each request as `ActionRpc.layer` admits it, `sent` stores the payload that
|
|
20
|
-
* resumes it with each chunk sent and forgets it at its exit, `closed` forgets a socket's, and
|
|
21
|
-
* `resumed` writes each kept one whose socket of this path is still open to the server again,
|
|
22
|
-
* from there.
|
|
23
|
-
*/
|
|
24
17
|
const makeSubscriptions = (state, path) => {
|
|
25
18
|
const { storage } = state;
|
|
26
19
|
const group = groupTag(path);
|
|
27
20
|
const requests = `${keptPrefix}request/${path}#`;
|
|
28
21
|
const payloads = `${keptPrefix}payload/${path}#`;
|
|
29
22
|
const keysOf = (suffix) => [`${requests}${suffix}`, `${payloads}${suffix}`];
|
|
30
|
-
const
|
|
23
|
+
const liveSubscriptionsMirror = /* @__PURE__ */ new Map();
|
|
31
24
|
const listed = (prefix) => Effect.map(Effect.promise(async () => await storage.list({ prefix })), (found) => [...found].map(([key, value]) => [key.slice(prefix.length), value]));
|
|
32
25
|
const closed = (socket) => Effect.suspend(() => {
|
|
33
|
-
const owned = [...
|
|
34
|
-
for (const suffix of owned)
|
|
26
|
+
const owned = [...liveSubscriptionsMirror.keys()].filter((suffix) => socketOf(suffix) === socket);
|
|
27
|
+
for (const suffix of owned) liveSubscriptionsMirror.delete(suffix);
|
|
35
28
|
return deleted(storage, owned.flatMap(keysOf));
|
|
36
29
|
});
|
|
37
30
|
const keeper = { keep: ({ clientId, requestId, tag, payload, headers, resumeFrom }) => Effect.suspend(() => {
|
|
38
31
|
const suffix = suffixOf(clientId, requestId);
|
|
39
|
-
const resuming =
|
|
40
|
-
|
|
32
|
+
const resuming = liveSubscriptionsMirror.has(suffix);
|
|
33
|
+
liveSubscriptionsMirror.set(suffix, resumeFrom);
|
|
41
34
|
if (resuming) return Effect.void;
|
|
42
35
|
return Effect.promise(async () => {
|
|
43
36
|
await storage.put({
|
|
@@ -51,23 +44,18 @@ const makeSubscriptions = (state, path) => {
|
|
|
51
44
|
});
|
|
52
45
|
}) };
|
|
53
46
|
const moved = (suffix, values) => Effect.suspend(() => {
|
|
54
|
-
const payload =
|
|
47
|
+
const payload = liveSubscriptionsMirror.get(suffix)?.(values);
|
|
55
48
|
return payload === void 0 ? Effect.void : Effect.promise(async () => {
|
|
56
49
|
await storage.put(`${payloads}${suffix}`, payload);
|
|
57
50
|
});
|
|
58
51
|
});
|
|
59
52
|
const sent = (socket, response) => Match.value(response).pipe(Match.tag("Defect", () => closed(socket)), Match.tag("Chunk", ({ requestId, values }) => moved(suffixOf(socket, requestId), values)), Match.tag("Exit", ({ requestId }) => Effect.suspend(() => {
|
|
60
53
|
const suffix = suffixOf(socket, requestId);
|
|
61
|
-
return
|
|
54
|
+
return liveSubscriptionsMirror.delete(suffix) ? Effect.promise(async () => await storage.delete(keysOf(suffix))) : Effect.void;
|
|
62
55
|
})), Match.orElse(() => Effect.void));
|
|
63
56
|
const unresumed = (socket) => Effect.andThen(Effect.logWarning(`layerWebSocket at ${path} could not resume a socket's kept event subscriptions, and closes it`), Effect.sync(() => {
|
|
64
|
-
closeQuietly(socket,
|
|
57
|
+
closeQuietly(socket, resumeFailedCode, unresumable);
|
|
65
58
|
}));
|
|
66
|
-
/**
|
|
67
|
-
* Writes each kept subscription of a socket of this path still open to the server, from where
|
|
68
|
-
* it reached, and forgets the rest: `held` answers whether a socket stays open, given whether
|
|
69
|
-
* one of its requests is a subscription kept for it, and closes one that does not.
|
|
70
|
-
*/
|
|
71
59
|
const resumed = (write, held) => Effect.gen(function* () {
|
|
72
60
|
const kept = yield* listed(requests);
|
|
73
61
|
const resumeFrom = new Map(yield* listed(payloads));
|
|
@@ -88,7 +76,7 @@ const makeSubscriptions = (state, path) => {
|
|
|
88
76
|
const unread = new Set(entries.flatMap(({ socket, request }) => open.has(socket) && Option.isNone(request) ? [socket] : []));
|
|
89
77
|
const gone = [...resumeFrom.keys()].flatMap((suffix) => keptIds.has(suffix) ? [] : [`${payloads}${suffix}`]);
|
|
90
78
|
for (const { suffix, socket, request } of entries) if (open.has(socket) && !unread.has(socket) && Option.isSome(request)) {
|
|
91
|
-
|
|
79
|
+
liveSubscriptionsMirror.set(suffix, Function.constUndefined);
|
|
92
80
|
yield* write(socket, request.value);
|
|
93
81
|
} else gone.push(...keysOf(suffix));
|
|
94
82
|
yield* deleted(storage, gone);
|
package/dist/tasks.mjs
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { Clock, Context, Effect } from "effect";
|
|
2
2
|
// src/durable/tasks.ts
|
|
3
|
-
/** The alarm owner a task's time is kept under, one per task name. */
|
|
4
3
|
const taskOwner = (name) => `task:${name}`;
|
|
5
4
|
/**
|
|
6
5
|
* Runs the object's tasks, `make`'s or `handlers`' `tasks`, by name, after a change, on the
|
|
@@ -15,16 +14,12 @@ const taskOwner = (name) => `task:${name}`;
|
|
|
15
14
|
* - A run that fails is logged and retried as the alarm retries an owner: 2 seconds later, doubling, six times, then hourly.
|
|
16
15
|
*/
|
|
17
16
|
var Tasks = class extends Context.Service()("effect-actions-cloudflare/ActionDurableObject/Tasks") {};
|
|
18
|
-
/**
|
|
19
|
-
* The instance's `Tasks` over its `schedule`, and the alarm owners that run each of `tasks`, by
|
|
20
|
-
* the name `runAfter` takes.
|
|
21
|
-
*/
|
|
22
17
|
const makeTasks = (schedule, tasks) => ({
|
|
23
18
|
tasks: Tasks.of({ runAfter: (name, change) => Effect.gen(function* () {
|
|
24
19
|
if (!Object.hasOwn(tasks, name)) return yield* Effect.die(/* @__PURE__ */ new Error(`ActionDurableObject.Tasks: this object has no task ${name} (give make or handlers a tasks entry named ${name})`));
|
|
25
|
-
const
|
|
26
|
-
yield*
|
|
27
|
-
return yield* Effect.ensuring(change,
|
|
20
|
+
const markDueNow = schedule.set(taskOwner(name), yield* Clock.currentTimeMillis);
|
|
21
|
+
yield* markDueNow;
|
|
22
|
+
return yield* Effect.ensuring(change, markDueNow);
|
|
28
23
|
}) }),
|
|
29
24
|
owners: Object.fromEntries(Object.entries(tasks).map(([name, task]) => [taskOwner(name), () => task]))
|
|
30
25
|
});
|
package/dist/webhooks.mjs
CHANGED
|
@@ -4,12 +4,7 @@ import { Clock, Context, Deferred, Duration, Effect, Exit, Layer, Option, Schema
|
|
|
4
4
|
import * as Webhook from "@gjermundgaraba/effect-actions/Webhook";
|
|
5
5
|
import { KeyValueStore } from "effect/persistence";
|
|
6
6
|
// src/durable/webhooks.ts
|
|
7
|
-
/** The owner of the object's alarm times that end its long waits. */
|
|
8
7
|
const clockOwner = "webhooks";
|
|
9
|
-
/**
|
|
10
|
-
* Where `layerWebhooks` keeps its subscriptions in the object's storage: every key of its store,
|
|
11
|
-
* whatever layout `Webhook.layer`'s `persist` gives them, is under it.
|
|
12
|
-
*/
|
|
13
8
|
const webhookPrefix = "effect-actions/webhooks/";
|
|
14
9
|
/**
|
|
15
10
|
* The instance's long waits, which the object's alarm ends: `until(at)` waits until the alarm
|
|
@@ -17,13 +12,9 @@ const webhookPrefix = "effect-actions/webhooks/";
|
|
|
17
12
|
* `make` provide it to the routes, over the instance's schedule, for `layerWebhooks`.
|
|
18
13
|
*/
|
|
19
14
|
var Waits = class extends Context.Service()("effect-actions-cloudflare/ActionDurableObject/Waits") {};
|
|
20
|
-
/**
|
|
21
|
-
* The instance's `Waits`, their time set on `schedule`, the one its `alarm` handler runs, and
|
|
22
|
-
* `wakeDue`, which that handler runs at their time.
|
|
23
|
-
*/
|
|
24
15
|
const makeWaits = (schedule) => {
|
|
25
16
|
const held = /* @__PURE__ */ new Set();
|
|
26
|
-
const
|
|
17
|
+
const armForEarliestWait = Semaphore.makeUnsafe(1).withPermits(1)(Effect.suspend(() => held.size === 0 ? schedule.clear(clockOwner) : schedule.set(clockOwner, Math.min(...[...held].map(({ at }) => at)))));
|
|
27
18
|
return {
|
|
28
19
|
waits: Waits.of({ until: (at) => Effect.suspend(() => {
|
|
29
20
|
const wait = {
|
|
@@ -31,25 +22,19 @@ const makeWaits = (schedule) => {
|
|
|
31
22
|
woken: Deferred.makeUnsafe()
|
|
32
23
|
};
|
|
33
24
|
held.add(wait);
|
|
34
|
-
return Effect.andThen(
|
|
25
|
+
return Effect.andThen(armForEarliestWait, Deferred.await(wait.woken)).pipe(Effect.ensuring(Effect.suspend(() => held.delete(wait) ? armForEarliestWait : Effect.void)));
|
|
35
26
|
}) }),
|
|
36
|
-
/** Ends each wait that is due, and sets the alarm for the next. */
|
|
37
27
|
wakeDue: Effect.gen(function* () {
|
|
38
28
|
const now = yield* Clock.currentTimeMillis;
|
|
39
29
|
for (const wait of held) if (wait.at <= now) {
|
|
40
30
|
held.delete(wait);
|
|
41
31
|
Deferred.doneUnsafe(wait.woken, Exit.void);
|
|
42
32
|
}
|
|
43
|
-
yield*
|
|
33
|
+
yield* armForEarliestWait;
|
|
44
34
|
})
|
|
45
35
|
};
|
|
46
36
|
};
|
|
47
|
-
const
|
|
48
|
-
/**
|
|
49
|
-
* The Clock the webhook layer runs on: a wait of 10 seconds or more waits on the object's alarm
|
|
50
|
-
* and holds no timer, so the object may hibernate meanwhile; if the instance is evicted, the
|
|
51
|
-
* alarm builds the next, which restores the subscriptions from storage.
|
|
52
|
-
*/
|
|
37
|
+
const alarmWaitThreshold = Duration.seconds(10);
|
|
53
38
|
const alarmClock = (base, { until }) => ({
|
|
54
39
|
currentTimeMillisUnsafe: () => base.currentTimeMillisUnsafe(),
|
|
55
40
|
currentTimeMillis: base.currentTimeMillis,
|
|
@@ -57,7 +42,7 @@ const alarmClock = (base, { until }) => ({
|
|
|
57
42
|
currentTimeNanos: base.currentTimeNanos,
|
|
58
43
|
monotonicTimeNanosUnsafe: () => base.monotonicTimeNanosUnsafe(),
|
|
59
44
|
monotonicTimeNanos: base.monotonicTimeNanos,
|
|
60
|
-
sleep: (duration) => Duration.isLessThan(duration,
|
|
45
|
+
sleep: (duration) => Duration.isLessThan(duration, alarmWaitThreshold) || !Duration.isFinite(duration) ? base.sleep(duration) : until(base.currentTimeMillisUnsafe() + Duration.toMillis(duration))
|
|
61
46
|
});
|
|
62
47
|
const stringOf = Schema.decodeUnknownOption(Schema.String);
|
|
63
48
|
const attempted = (method, key, run) => Effect.tryPromise({
|
|
@@ -70,7 +55,6 @@ const attempted = (method, key, run) => Effect.tryPromise({
|
|
|
70
55
|
})
|
|
71
56
|
});
|
|
72
57
|
const keyOf = (key) => `${webhookPrefix}${key}`;
|
|
73
|
-
/** A `KeyValueStore` over the object's storage under `webhookPrefix`, every key of it there. */
|
|
74
58
|
const storageStore = (storage) => {
|
|
75
59
|
const listed = attempted("list", void 0, async () => await storage.list({ prefix: webhookPrefix }));
|
|
76
60
|
return KeyValueStore.makeStringOnly({
|
|
@@ -14,19 +14,12 @@ import { authorize } from "./authorize.js";
|
|
|
14
14
|
import { MessagePosted } from "./Chat.js";
|
|
15
15
|
import { MessageStore } from "./store.js";
|
|
16
16
|
|
|
17
|
-
// A cursor is the id of the last message a page holds, as `readMessages`' `after` is.
|
|
18
17
|
const idOf = Schema.decodeUnknownOption(
|
|
19
18
|
Schema.NumberFromString.pipe(Schema.check(Schema.isInt()))
|
|
20
19
|
);
|
|
21
20
|
|
|
22
|
-
// An agent polling over MCP asks again after `nextPoll`, 5 seconds by default: a request that
|
|
23
|
-
// often keeps the object from the 10 idle seconds after which the runtime may hibernate it, so it
|
|
24
|
-
// asks every 30 seconds, and an agent that needs a message sooner holds a stream open instead.
|
|
25
21
|
const nextPoll = "30 seconds";
|
|
26
22
|
|
|
27
|
-
// The room's messages as an event, read from the store that keeps them: a check source.
|
|
28
|
-
// `postMessage` notifies after each post, so a page receives it at once: when an object reads a
|
|
29
|
-
// check source, and what a write that does not notify misses, is on `ActionDurableObject.handlers`.
|
|
30
23
|
export const messageEvents = Event.implement(
|
|
31
24
|
MessagePosted,
|
|
32
25
|
Effect.gen(function* () {
|
|
@@ -42,11 +35,6 @@ export const messageEvents = Event.implement(
|
|
|
42
35
|
(id) => id >= 0 && id <= last
|
|
43
36
|
);
|
|
44
37
|
|
|
45
|
-
// No cursor starts after the room's last message. One this room never gave, such as
|
|
46
|
-
// an id past its last from another copy of its database, is answered `truncated` from
|
|
47
|
-
// there: the page learns it missed messages, and reads them with `readMessages`. A
|
|
48
|
-
// message deleted or expired after a cursor is simply not read: the store keeps no
|
|
49
|
-
// record of either, so no gap marks it.
|
|
50
38
|
if (Option.isNone(after)) {
|
|
51
39
|
return {
|
|
52
40
|
cursor: String(last),
|
|
@@ -89,7 +77,6 @@ import { LobbyHost } from "./host.js";
|
|
|
89
77
|
import { Previews, PreviewsChanged } from "./Lobby.js";
|
|
90
78
|
import { PreviewStore } from "./store.js";
|
|
91
79
|
|
|
92
|
-
// The Worker's `previews`, as the lobby's object keeps them.
|
|
93
80
|
export const lobby = Action.implement(
|
|
94
81
|
Previews,
|
|
95
82
|
Effect.map(
|
|
@@ -98,8 +85,6 @@ export const lobby = Action.implement(
|
|
|
98
85
|
)
|
|
99
86
|
);
|
|
100
87
|
|
|
101
|
-
// The lobby's own API, in its object. A change notifies `PreviewsChanged`, which an object
|
|
102
|
-
// outside this one could not do itself: its event is this object's.
|
|
103
88
|
export const calls = LobbyCalls.toLayer(
|
|
104
89
|
Effect.gen(function* () {
|
|
105
90
|
const store = yield* PreviewStore;
|
|
@@ -125,11 +110,6 @@ import { Effect } from "effect";
|
|
|
125
110
|
import { PreviewsChanged } from "./Lobby.js";
|
|
126
111
|
import { PreviewStore } from "./store.js";
|
|
127
112
|
|
|
128
|
-
// Every room's preview as one event, read from the lobby's store: a current source, for a value
|
|
129
|
-
// small enough to send whole. A page watching is sent the previews as they are, then again after
|
|
130
|
-
// each notify, which `roomChanged` makes after each change, so it needs no read before it and is
|
|
131
|
-
// never sent each change it missed, which a whole value makes needless. The object keeps the
|
|
132
|
-
// revision a page's cursor holds, so a page resuming at the latest is sent nothing.
|
|
133
113
|
export const previewEvents = Event.implement(
|
|
134
114
|
PreviewsChanged,
|
|
135
115
|
Effect.map(PreviewStore, (store) => ({
|
|
@@ -15,4 +15,4 @@ Messages the library does not own, workerd's, the compiler's and Effect's, and t
|
|
|
15
15
|
- `Could not serialize object of type "<Class>". This type does not support serialization`, workerd's `DataCloneError`, as an `RpcClientError`'s cause: a value of the call, success, error or defect, is one structured clone refuses, such as a class instance in a `Schema.Unknown`; declare its schema, whose plain form clones, or die with an `Error`.
|
|
16
16
|
- `Your Durable Object class must have an alarm() handler in order to call setAlarm()`, workerd's, from `storage.setAlarm`, which `Alarm.set` calls: the class delegating to `handlers` defines no `alarm`; delegate `handlers`' own, as `make`'s classes do, and on celld 0.6.2, Deno's self-hosted Durable Object runtime, which accepts the call and drops the alarm when it is due, an alarm that never runs means the same.
|
|
17
17
|
- `The Workers runtime canceled this request because it detected that your Worker's code had hung and would never generate a response`, workerd's, from every call through a client kept across a Worker's requests, `client` or `RpcClient.make` over `layerStubClient`, or through a handler or runtime holding one: it was first built lazily inside a request that ended without waiting on I/O, succeeding or refused, whose timer would have started the client's own fiber; run each call as its own program, or share it built at module scope under Effect's sync scheduler, as `HttpRouter.toWebHandler`'s gotchas show, a `ManagedRuntime.make` there building only at its first run, as `ActionDurableObject.layerStubClient`'s gotchas say.
|
|
18
|
-
- `RpcClientError` from every call over a stub, Effect's own error: the stub failed, its `reason.cause` the runtime's or the object's message; a stub an exception broke fails every later call with the same one, so call through `client
|
|
18
|
+
- `RpcClientError` from every call over a stub, Effect's own error: the stub failed, its `reason.cause` the runtime's or the object's message; a stub an exception broke fails every later call with the same one, so call through `client` or `layerStubClient`, either making a new stub for each call from its function.
|
|
@@ -13,13 +13,10 @@ import { MessagePosted, Messages } from "./chat/Chat.js";
|
|
|
13
13
|
import { Login } from "./identity/Identity.js";
|
|
14
14
|
import { Previews } from "./lobby/Lobby.js";
|
|
15
15
|
|
|
16
|
-
// The rooms' JSON routes, at `/api/<action>` and `/api/events/<name>`: the Worker's, which a
|
|
17
|
-
// client without a socket calls, and each room's object's, where the Worker sends each call on.
|
|
18
16
|
export const RoomHttp = ActionHttp.make([...Messages, MessagePosted], {
|
|
19
17
|
authentication: Login,
|
|
20
18
|
});
|
|
21
19
|
|
|
22
|
-
// The Worker's own JSON routes.
|
|
23
20
|
export const LobbyHttp = ActionHttp.make([Previews]);
|
|
24
21
|
```
|
|
25
22
|
|
|
@@ -30,7 +27,7 @@ Then, as `routes.ts` on [ActionDurableObject.md](ActionDurableObject.md) does:
|
|
|
30
27
|
- The class is given the placement as `make`'s `placed`, in `worker.ts`: the object then refuses `Forbidden`, on every surface, its socket and its stub included, a call or a watch whose key names another object, so a page's socket to one room cannot reach another.
|
|
31
28
|
- The Worker serves `ActionDurableObject.reaching(rooms, () => env.CHAT_ROOM)` beside its own implementations, in `ActionHttp.layer` and `ActionMcp.layerHttp`, each call sent to the room's object with the caller's headers.
|
|
32
29
|
|
|
33
|
-
The Worker authenticates each protected call as its own, and the object authenticates and authorizes it again, so both are given the verifier. A key's schema, `
|
|
30
|
+
The Worker authenticates each protected call as its own, and the object authenticates and authorizes it again, so both are given the verifier. A key's schema, `DemoRoomName` in `Chat.ts`, is what keeps a caller from creating objects: an unknown room is `InvalidInput` before any object is reached. [chat-rooms/test/worker.test.ts](../examples/chat-rooms/test/worker.test.ts) reaches two rooms through one MCP connection and has a room refuse a call naming another.
|
|
34
31
|
|
|
35
32
|
## Serve them over MCP from the Worker
|
|
36
33
|
|
|
@@ -12,11 +12,6 @@ import { Rpc, RpcGroup } from "effect/rpc";
|
|
|
12
12
|
|
|
13
13
|
import { RoomPreview } from "./Lobby.js";
|
|
14
14
|
|
|
15
|
-
// The lobby's own API, over its stub: a room tells it its latest activity, and the Worker reads
|
|
16
|
-
// the previews. A plain Effect `RpcGroup`, not actions: no page calls it, so it needs no contract
|
|
17
|
-
// checks, no authentication and no JSON route, and nothing authenticates a stub call anyway, as
|
|
18
|
-
// only a binding reaches it. Why a report heard late never overwrites a later one is at
|
|
19
|
-
// `tellLobby`, in lobby-stubs.ts, which sends it.
|
|
20
15
|
export const LobbyCalls = RpcGroup.make(
|
|
21
16
|
Rpc.make("roomChanged", { payload: RoomPreview }),
|
|
22
17
|
Rpc.make("previews", { success: Schema.Array(RoomPreview) })
|
|
@@ -25,7 +20,7 @@ export const LobbyCalls = RpcGroup.make(
|
|
|
25
20
|
|
|
26
21
|
Its handlers read the store the instance built ([chat-rooms/src/lobby/handlers.ts](../examples/chat-rooms/src/lobby/handlers.ts)), and `RpcServer.layer(LobbyCalls)` over them is the object's `calls`, beside its routes ([chat-rooms/src/routes.ts](../examples/chat-rooms/src/routes.ts)), given to `make` in [chat-rooms/src/worker.ts](../examples/chat-rooms/src/worker.ts). `make` gives the calls their protocol; what crosses it, as values and not JSON, what it refuses and what the calls may require are on `ActionDurableObject.Options`' `calls`.
|
|
27
22
|
|
|
28
|
-
A plain group suits an object's own API, which no page calls. Actions served on other surfaces too are served the same way, `calls: ActionRpc.layer(RoomRpc, room)`, and called with `ActionRpc.client(RoomRpc)` over `layerStubClient(env.CHAT_ROOM.getByName(room))`: their values cross as JSON, and a protected action authenticates each call from its headers, so the caller forwards the user's credential with `RpcClient.withHeaders`.
|
|
23
|
+
A plain group suits an object's own API, which no page calls. Actions served on other surfaces too are served the same way, `calls: ActionRpc.layer(RoomRpc, room)`, and called with `ActionRpc.client(RoomRpc)` over `layerStubClient(() => env.CHAT_ROOM.getByName(room))`: their values cross as JSON, and a protected action authenticates each call from its headers, so the caller forwards the user's credential with `RpcClient.withHeaders`.
|
|
29
24
|
|
|
30
25
|
## Call it from a Worker or another object
|
|
31
26
|
|
|
@@ -42,25 +37,14 @@ import { MessageStore } from "../chat/store.js";
|
|
|
42
37
|
import { LobbyCalls } from "../lobby/Calls.js";
|
|
43
38
|
import { LobbyHost } from "../lobby/host.js";
|
|
44
39
|
|
|
45
|
-
// The lobby is one object, named `lobby`: a new stub for each call, read from `env` as the call
|
|
46
|
-
// is sent, never as the module loads.
|
|
47
40
|
const lobbyStub = () => env.CHAT_LOBBY.getByName("lobby");
|
|
48
41
|
|
|
49
|
-
// The lobby's object as the Worker reaches it, for the previews: a lobby that cannot be reached
|
|
50
|
-
// is the deployment's failure, not the caller's, so it is a defect, the Worker answers 500, and
|
|
51
|
-
// the cause is in its logs.
|
|
52
42
|
export const layerLobbyHost = Layer.effect(LobbyHost)(
|
|
53
43
|
Effect.map(ActionDurableObject.client(LobbyCalls), (lobby) =>
|
|
54
44
|
LobbyHost.of({ previews: Effect.orDie(lobby(lobbyStub).previews()) })
|
|
55
45
|
)
|
|
56
46
|
);
|
|
57
47
|
|
|
58
|
-
// The room's task, worker.ts's `lobby`: telling the lobby the room's latest activity. The room's
|
|
59
|
-
// alarm runs its tasks one run at a time, each waiting for the lobby's answer, and each run reads
|
|
60
|
-
// the activity as it is then: the lobby hears a room's reports in the order the room made them,
|
|
61
|
-
// so a report heard late never overwrites a later one, and the last it hears is the room's
|
|
62
|
-
// latest. A run repeated after a lost answer sends what the lobby holds, which `record` finds
|
|
63
|
-
// unchanged, notifying no page.
|
|
64
48
|
export const tellLobby = Effect.gen(function* () {
|
|
65
49
|
const room = yield* Room;
|
|
66
50
|
const latest = yield* MessageStore.use((store) => store.latest);
|
|
@@ -69,10 +53,6 @@ export const tellLobby = Effect.gen(function* () {
|
|
|
69
53
|
yield* lobby(lobbyStub).roomChanged({ room, latestAt: latest?.at ?? null });
|
|
70
54
|
});
|
|
71
55
|
|
|
72
|
-
// A room's activity, a startup service of the room's: each change runs the `lobby` task after it,
|
|
73
|
-
// on the room's alarm once the call has answered, and again until the lobby hears it, so a lobby
|
|
74
|
-
// that cannot be reached fails no post and misses no change. `runAfter` marks the task before the
|
|
75
|
-
// change and again after it, so a crash at any point leaves the task to the alarm.
|
|
76
56
|
export const layerActivity = Layer.effect(Activity)(
|
|
77
57
|
Effect.map(ActionDurableObject.Tasks, (tasks) =>
|
|
78
58
|
Activity.of({ changing: (change) => tasks.runAfter("lobby", change) })
|
|
@@ -88,21 +68,13 @@ A Worker's entrypoint that is no Effect program, such as a `WorkerEntrypoint` me
|
|
|
88
68
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
89
69
|
import { Effect, Schema } from "effect";
|
|
90
70
|
|
|
91
|
-
import {
|
|
71
|
+
import { DemoRoomName } from "./chat/Chat.js";
|
|
92
72
|
import { LobbyHost } from "./lobby/host.js";
|
|
93
73
|
import { layerLobbyHost } from "./platform/lobby-stubs.js";
|
|
94
74
|
|
|
95
|
-
const decodeRoom = Schema.decodeUnknownEffect(
|
|
75
|
+
const decodeRoom = Schema.decodeUnknownEffect(DemoRoomName);
|
|
96
76
|
|
|
97
|
-
// The rooms' activity for another Worker, over a service binding to this one's `RoomsEntrypoint`,
|
|
98
|
-
// which worker.ts exports, as the lobby keeps it. A method of a `WorkerEntrypoint` is no Effect
|
|
99
|
-
// program, so each call runs as one, given the lobby's Layer: its client is built in the request
|
|
100
|
-
// that uses it and closed with it, which costs no I/O. Why a client kept across requests strands
|
|
101
|
-
// is on `ActionDurableObject.layerStubClient`.
|
|
102
77
|
export class RoomsEntrypoint extends WorkerEntrypoint {
|
|
103
|
-
// The calling Worker is another deployment, so its room is checked as any input is: an unknown
|
|
104
|
-
// room rejects the call with the schema's error, as a lobby that cannot be reached rejects it
|
|
105
|
-
// with the defect lobby-stubs.ts makes of it.
|
|
106
78
|
async latestAt(room: string) {
|
|
107
79
|
return await Effect.runPromise(
|
|
108
80
|
Effect.flatMap(decodeRoom(room), (name) =>
|
|
@@ -10,7 +10,6 @@ Run the tests inside the runtime with `@cloudflare/vitest-plugin`, on the Worker
|
|
|
10
10
|
import { cloudflareTest } from "@cloudflare/vitest-plugin";
|
|
11
11
|
import { defineConfig } from "vite-plus";
|
|
12
12
|
|
|
13
|
-
// Its tests run inside the Workers runtime, on the Worker wrangler.jsonc describes.
|
|
14
13
|
export default defineConfig({
|
|
15
14
|
plugins: [
|
|
16
15
|
cloudflareTest({
|
|
@@ -19,10 +18,6 @@ export default defineConfig({
|
|
|
19
18
|
],
|
|
20
19
|
test: {
|
|
21
20
|
include: ["test/**/*.test.ts"],
|
|
22
|
-
// Why: under @cloudflare/vitest-plugin 1.4.4, the warm-module response for a later file in a
|
|
23
|
-
// pool slot can exceed what the runner's control socket accepts, 41 MB in 709 entries
|
|
24
|
-
// measured on chat-rooms, which closes it with 1009 and loses the file as "Worker exited
|
|
25
|
-
// unexpectedly"; two small files alone did not trigger it.
|
|
26
21
|
fsModuleCache: false,
|
|
27
22
|
},
|
|
28
23
|
});
|
|
@@ -14,7 +14,7 @@ import { Effect, Layer, Schema } from "effect";
|
|
|
14
14
|
import { HttpRouter, HttpServer } from "effect/http";
|
|
15
15
|
|
|
16
16
|
import { Activity } from "./chat/activity.js";
|
|
17
|
-
import {
|
|
17
|
+
import { DemoRoomName } from "./chat/Chat.js";
|
|
18
18
|
import { Room } from "./chat/room.js";
|
|
19
19
|
import { MessageStore } from "./chat/store.js";
|
|
20
20
|
import { layerKvPreviews } from "./platform/kv-previews.js";
|
|
@@ -32,22 +32,10 @@ import {
|
|
|
32
32
|
workerRoutes,
|
|
33
33
|
} from "./routes.js";
|
|
34
34
|
|
|
35
|
-
// The rooms for another Worker, over a service binding: a named entrypoint of this one.
|
|
36
35
|
export { RoomsEntrypoint } from "./rooms-entrypoint.js";
|
|
37
36
|
|
|
38
|
-
|
|
39
|
-
// chooses the stores, the room and the stubs, and routes each request.
|
|
37
|
+
const decodeRoom = Schema.decodeUnknownEffect(DemoRoomName);
|
|
40
38
|
|
|
41
|
-
const decodeRoom = Schema.decodeUnknownEffect(RoomName);
|
|
42
|
-
|
|
43
|
-
// A room's Durable Object, bound as CHAT_ROOM in wrangler.jsonc under this export's name. Each
|
|
44
|
-
// instance builds its services, the store, the room and its activity, then its routes, as it is
|
|
45
|
-
// constructed, again after every eviction. The room is the name the Worker reached the object by,
|
|
46
|
-
// `getByName(room)`, decoded once there: `ActionDurableObject.name` dies for an object reached by
|
|
47
|
-
// an id, and the build fails for a name `RoomName` does not list, each a bug in the Worker. Given
|
|
48
|
-
// `rooms`, it refuses a call or a watch whose input names another room. Its `lobby` task tells
|
|
49
|
-
// the lobby of each change, on the alarm until the lobby hears. The alarm expires old messages
|
|
50
|
-
// and tells the lobby, as an expired message can be the room's latest.
|
|
51
39
|
export class ChatRoom extends ActionDurableObject.make({
|
|
52
40
|
placed: rooms,
|
|
53
41
|
services: () =>
|
|
@@ -64,18 +52,12 @@ export class ChatRoom extends ActionDurableObject.make({
|
|
|
64
52
|
),
|
|
65
53
|
}) {}
|
|
66
54
|
|
|
67
|
-
// The lobby's Durable Object, bound as CHAT_LOBBY: one for the Worker, named `lobby`, keeping
|
|
68
|
-
// every room's preview, which each room tells it of and its pages follow.
|
|
69
55
|
export class ChatLobby extends ActionDurableObject.make({
|
|
70
56
|
services: () => layerKvPreviews,
|
|
71
57
|
routes: lobbyRoutes,
|
|
72
58
|
calls: lobbyCalls,
|
|
73
59
|
}) {}
|
|
74
60
|
|
|
75
|
-
// The Worker's own handler, built once, when the module loads, which keeps the lobby's client
|
|
76
|
-
// from stranding as `ActionDurableObject.layerStubClient`'s gotchas say: building it does no I/O,
|
|
77
|
-
// which a Worker refuses outside a request. `toWebHandler` reads no `MaxBodySize`, so the
|
|
78
|
-
// platform limits request bodies.
|
|
79
61
|
const served = HttpRouter.toWebHandler(
|
|
80
62
|
workerRoutes.pipe(
|
|
81
63
|
Layer.provide(layerLobbyHost),
|
|
@@ -83,11 +65,8 @@ const served = HttpRouter.toWebHandler(
|
|
|
83
65
|
)
|
|
84
66
|
);
|
|
85
67
|
|
|
86
|
-
const isRoomName = Schema.is(
|
|
68
|
+
const isRoomName = Schema.is(DemoRoomName);
|
|
87
69
|
|
|
88
|
-
// A page's socket, `/rooms/<room>/rpc` or `/lobby/rpc`, is its object's `/rpc`: forwarding the
|
|
89
|
-
// request forwards its upgrade, and the object answers it. Everything else is the Worker's own,
|
|
90
|
-
// the rooms' calls among them, which it sends on to each room's object itself.
|
|
91
70
|
export default {
|
|
92
71
|
async fetch(request: Request, env: Cloudflare.Env) {
|
|
93
72
|
const url = new URL(request.url);
|
|
@@ -148,9 +127,6 @@ const room = [messages, messageEvents];
|
|
|
148
127
|
|
|
149
128
|
const byRoom = (input: { readonly room: string }) => input.room;
|
|
150
129
|
|
|
151
|
-
// Where each room's calls and watches are served: in the room's object, the one each contract's
|
|
152
|
-
// key names. worker.ts gives it to the room's class, which then refuses an input naming another
|
|
153
|
-
// room, on its socket too; the Worker serves it below.
|
|
154
130
|
export const rooms = ActionDurableObject.placed(RoomHttp, {
|
|
155
131
|
readMessages: byRoom,
|
|
156
132
|
postMessage: byRoom,
|
|
@@ -158,48 +134,26 @@ export const rooms = ActionDurableObject.placed(RoomHttp, {
|
|
|
158
134
|
"message.posted": byRoom,
|
|
159
135
|
});
|
|
160
136
|
|
|
161
|
-
// One socket route for each object's pages, refusing a page of another version.
|
|
162
137
|
const pageSocket = ActionDurableObject.layerWebSocket({
|
|
163
138
|
path: "/rpc",
|
|
164
139
|
protocol: { version: protocolVersion, code: unsupportedProtocol },
|
|
165
140
|
});
|
|
166
141
|
|
|
167
|
-
// What a room's object serves. Its rpcs on WebSockets the runtime holds while the object
|
|
168
|
-
// hibernates: a socket keeps the token its upgrade request carried, and each message is
|
|
169
|
-
// authenticated with it, or with the message's own. The socket's route refuses a page naming
|
|
170
|
-
// another version in its URL, `?protocol=<n>` as browser.ts sends it, with a close code the page's
|
|
171
|
-
// client reads as `SocketCloseError` and stops connecting again at, not a 403, which a browser
|
|
172
|
-
// reports as a bare 1006 its page connects again after. Its JSON routes, where the Worker sends
|
|
173
|
-
// each call and watch on, the token with it: the object authenticates it again, and authorizes.
|
|
174
|
-
// It requires `MessageStore`, `Room` and `Activity` at startup: worker.ts chooses them.
|
|
175
142
|
export const roomRoutes = Layer.mergeAll(
|
|
176
143
|
ActionRpc.layer(RoomRpc, room).pipe(Layer.provide(pageSocket)),
|
|
177
144
|
ActionHttp.layer(RoomHttp, room)
|
|
178
145
|
).pipe(Layer.provide(authenticate));
|
|
179
146
|
|
|
180
|
-
// What the lobby's object serves: `PreviewsChanged` on its pages' sockets, a socket a page
|
|
181
|
-
// signed out opens too, as nothing on it is protected. It requires `PreviewStore` at startup.
|
|
182
147
|
export const lobbyRoutes = ActionRpc.layer(LobbyRpc, [previewEvents]).pipe(
|
|
183
148
|
Layer.provide(pageSocket)
|
|
184
149
|
);
|
|
185
150
|
|
|
186
|
-
// What the lobby's object serves over its stub: its own calls, on Effect's own RpcServer, for
|
|
187
|
-
// the rooms and the Worker, which call them with Cloudflare's own RPC. It requires `PreviewStore`
|
|
188
|
-
// at startup.
|
|
189
151
|
export const lobbyCalls = RpcServer.layer(LobbyCalls).pipe(
|
|
190
152
|
Layer.provide(lobbyHandlers)
|
|
191
153
|
);
|
|
192
154
|
|
|
193
|
-
// The rooms as the Worker serves them, each call sent on to its room's object. The room is read
|
|
194
|
-
// from `env` as each call is sent.
|
|
195
155
|
const inRooms = ActionDurableObject.reaching(rooms, () => env.CHAT_ROOM);
|
|
196
156
|
|
|
197
|
-
// What the Worker serves: the rooms' JSON routes and `previews`, which it asks the lobby's object
|
|
198
|
-
// for, at `/api`, and all of them at `/mcp`, the actions as tools and `MessagePosted` as an MCP
|
|
199
|
-
// event, so one agent's connection reaches every room. Nothing in a Worker outlives a request, so
|
|
200
|
-
// the endpoint is request scoped: MCP 2026-07-28 alone, and events by `events/poll`, a poll every
|
|
201
|
-
// 30 seconds as events.ts asks. The Worker authenticates each call, and the room's object again.
|
|
202
|
-
// It requires `LobbyHost`.
|
|
203
157
|
export const workerRoutes = Layer.mergeAll(
|
|
204
158
|
ActionHttp.layer(LobbyHttp, lobby),
|
|
205
159
|
ActionHttp.layer(RoomHttp, inRooms),
|
|
@@ -224,7 +178,7 @@ Give each of a binding's contracts a key of its input, `rooms` in `routes.ts` ab
|
|
|
224
178
|
|
|
225
179
|
## Run an alarm
|
|
226
180
|
|
|
227
|
-
Give `make` an `alarm`, and set when it runs from a handler or a store with the `Alarm` service: the room's store sets it for
|
|
181
|
+
Give `make` an `alarm`, and set when it runs from a handler or a store with the `Alarm` service, which keeps the earliest time it is given: the room's store sets it for each message's expiry as it is posted and, each run, for the oldest left ([chat-rooms/src/platform/sql-store.ts](../examples/chat-rooms/src/platform/sql-store.ts)), and `worker.ts` above gives the alarm the store the routes read. How it runs, and how a failure is retried, are on `ActionDurableObject.Options`' `alarm`; how to postpone it, and why `State`'s `storage.setAlarm` is not the way, on `ActionDurableObject.Alarm`.
|
|
228
182
|
|
|
229
183
|
## Call an object from a Worker
|
|
230
184
|
|
package/docs/README.md
CHANGED
|
@@ -9,7 +9,7 @@ These pages cover what this package adds. Everything else, contracts, implementa
|
|
|
9
9
|
**Arriving with an error?** A message the library throws says its fix; for any other, step 4.
|
|
10
10
|
|
|
11
11
|
1. **Open one page per tool call, with your file-reading tool.** Pages are kept small to reduce truncation; if an output says it was truncated, read that page again alone.
|
|
12
|
-
2. **Start from [chat-rooms](../examples/chat-rooms/)**, the one example: its README has a layout tree and a "Where to look" table naming the file for each thing, and its
|
|
12
|
+
2. **Start from [chat-rooms](../examples/chat-rooms/)**, the one example: its README has a layout tree and a "Where to look" table naming the file for each thing, and its code and README carry the traps it shows.
|
|
13
13
|
3. **Read [ActionDurableObject.md](ActionDurableObject.md) completely**, then the page of each task you have ([Pages](#pages)), and the example files their sections link.
|
|
14
14
|
4. **For workerd's, a compiler diagnostic or Effect's own error, search [ActionDurableObject-failures.md](ActionDurableObject-failures.md)** for a fragment of the message without quotes, then the core's `*-failures.md` pages.
|
|
15
15
|
|
|
@@ -18,10 +18,10 @@ In a project that installs the package, copy this directory out of `node_modules
|
|
|
18
18
|
|
|
19
19
|
## When you copy this
|
|
20
20
|
|
|
21
|
-
Each trap is
|
|
21
|
+
Each trap below is shown in the code of the files it names.
|
|
22
22
|
|
|
23
23
|
- **A page sends its token on each message**, as a browser WebSocket sets no header on its upgrade: `src/browser.ts`; `test/worker.test.ts` connects both ways.
|
|
24
|
-
- **The room is the object's name**, and only a room `
|
|
24
|
+
- **The room is the object's name**, and only a room `DemoRoomName` lists is reached: `src/chat/Chat.ts`, `src/worker.ts`.
|
|
25
25
|
- **The Worker serves every room, each room answers**, through the placement `rooms`: `src/routes.ts`.
|
|
26
26
|
- **A page reads, then watches from the last message it holds**: `src/browser.ts`, `src/chat/events.ts`.
|
|
27
27
|
- **`postMessage` notifies `MessagePosted`**: `src/chat/handlers.ts`, `src/chat/events.ts`.
|
|
@@ -4,11 +4,8 @@ import { MessagePosted, Messages } from "./chat/Chat.js";
|
|
|
4
4
|
import { Login } from "./identity/Identity.js";
|
|
5
5
|
import { Previews } from "./lobby/Lobby.js";
|
|
6
6
|
|
|
7
|
-
// The rooms' JSON routes, at `/api/<action>` and `/api/events/<name>`: the Worker's, which a
|
|
8
|
-
// client without a socket calls, and each room's object's, where the Worker sends each call on.
|
|
9
7
|
export const RoomHttp = ActionHttp.make([...Messages, MessagePosted], {
|
|
10
8
|
authentication: Login,
|
|
11
9
|
});
|
|
12
10
|
|
|
13
|
-
// The Worker's own JSON routes.
|
|
14
11
|
export const LobbyHttp = ActionHttp.make([Previews]);
|
|
@@ -4,19 +4,12 @@ import { MessagePosted, Messages } from "./chat/Chat.js";
|
|
|
4
4
|
import { Login } from "./identity/Identity.js";
|
|
5
5
|
import { PreviewsChanged } from "./lobby/Lobby.js";
|
|
6
6
|
|
|
7
|
-
// A room's rpcs and its event, on the socket a page keeps open to its object. Plain data: the
|
|
8
|
-
// object and a browser page both import it.
|
|
9
7
|
export const RoomRpc = ActionRpc.make([...Messages, MessagePosted], {
|
|
10
8
|
authentication: Login,
|
|
11
9
|
});
|
|
12
10
|
|
|
13
|
-
// The lobby's event, on the socket a page keeps open to the lobby's object: public, so a page
|
|
14
|
-
// signed out follows it, and refused at another version as a room's socket is.
|
|
15
11
|
export const LobbyRpc = ActionRpc.make([PreviewsChanged]);
|
|
16
12
|
|
|
17
|
-
// The version of these rpcs a page was built with, which it names in its socket's URL, and the
|
|
18
|
-
// close code an object answers a page naming another with: raise the version when a change would
|
|
19
|
-
// break a page still open from before a deployment.
|
|
20
13
|
export const protocolVersion = "1";
|
|
21
14
|
|
|
22
15
|
export const unsupportedProtocol = 4000;
|