@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
package/dist/alarm.mjs
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { State } from "./state.mjs";
|
|
2
|
+
import { Clock, Context, DateTime, Effect, Exit, Layer, Option, Schema, Semaphore } from "effect";
|
|
3
|
+
// src/durable/alarm.ts
|
|
4
|
+
/** Where the object's alarm times are kept, one key per owner. */
|
|
5
|
+
const alarmPrefix = "effect-actions/alarm/";
|
|
6
|
+
const keyOf = (owner) => `${alarmPrefix}${owner}`;
|
|
7
|
+
const Kept = Schema.Struct({
|
|
8
|
+
at: Schema.Finite,
|
|
9
|
+
failures: Schema.Int
|
|
10
|
+
});
|
|
11
|
+
const keptOf = Schema.decodeUnknownOption(Kept);
|
|
12
|
+
const backoffRetries = 6;
|
|
13
|
+
const hour = 36e5;
|
|
14
|
+
const retryDelay = (failures) => failures > backoffRetries ? hour : 2e3 * 2 ** (failures - 1);
|
|
15
|
+
/**
|
|
16
|
+
* The object's alarm times, one per owner, kept in its storage: the runtime's one alarm is set
|
|
17
|
+
* for the earliest, and `runDue` runs each owner whose time has come. `handlers` and `make` make
|
|
18
|
+
* one per instance, which its `Alarm` and its `alarm` handler share.
|
|
19
|
+
*/
|
|
20
|
+
const makeSchedule = (state) => {
|
|
21
|
+
const { storage } = state;
|
|
22
|
+
const lock = Semaphore.makeUnsafe(1);
|
|
23
|
+
const running = /* @__PURE__ */ new Set();
|
|
24
|
+
const read = (owner) => Effect.map(Effect.promise(async () => await storage.get(keyOf(owner))), keptOf);
|
|
25
|
+
const times = Effect.promise(async () => await storage.list({ prefix: alarmPrefix })).pipe(Effect.map((listed) => [...listed].flatMap(([key, value]) => Option.match(keptOf(value), {
|
|
26
|
+
onNone: () => [],
|
|
27
|
+
onSome: (kept) => [{
|
|
28
|
+
owner: key.slice(21),
|
|
29
|
+
...kept
|
|
30
|
+
}]
|
|
31
|
+
}))));
|
|
32
|
+
const rearm = Effect.flatMap(times, (kept) => {
|
|
33
|
+
const earliest = Math.min(...kept.map(({ at }) => at));
|
|
34
|
+
return Effect.promise(async () => {
|
|
35
|
+
await (Number.isFinite(earliest) ? storage.setAlarm(earliest) : storage.deleteAlarm());
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
const keep = (owner, kept) => Effect.promise(async () => {
|
|
39
|
+
await storage.put(keyOf(owner), kept);
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
rearm,
|
|
43
|
+
restore: lock.withPermits(1)(Effect.flatMap(Effect.promise(async () => await storage.getAlarm()), (alarm) => alarm === null ? Effect.flatMap(times, (kept) => kept.length === 0 ? Effect.void : rearm) : Effect.void)),
|
|
44
|
+
/**
|
|
45
|
+
* Keeps `at` as the owner's time, in place of any: set as the owner's run fails, it gives way
|
|
46
|
+
* to that run's retry where the retry is earlier.
|
|
47
|
+
*/
|
|
48
|
+
set: (owner, at) => lock.withPermits(1)(Effect.flatMap(read(owner), (kept) => Option.exists(kept, (held) => held.at === at && held.failures === 0) ? Effect.void : Effect.andThen(keep(owner, {
|
|
49
|
+
at,
|
|
50
|
+
failures: 0
|
|
51
|
+
}), rearm))),
|
|
52
|
+
/**
|
|
53
|
+
* Keeps `at` as the owner's time unless an earlier one is kept, a retry due included: the
|
|
54
|
+
* mark of the owner's run under way is no time, so a time set from the run is kept.
|
|
55
|
+
*/
|
|
56
|
+
setBy: (owner, at) => lock.withPermits(1)(Effect.flatMap(read(owner), (kept) => Option.exists(kept, (held) => held.at <= at && (held.failures === 0 || !running.has(owner))) ? Effect.void : Effect.andThen(keep(owner, {
|
|
57
|
+
at,
|
|
58
|
+
failures: 0
|
|
59
|
+
}), rearm))),
|
|
60
|
+
clear: (owner) => lock.withPermits(1)(Effect.andThen(Effect.promise(async () => await storage.delete(keyOf(owner))), rearm)),
|
|
61
|
+
/**
|
|
62
|
+
* Runs each owner whose time has come as its turn comes, removing its time once its run
|
|
63
|
+
* succeeds unless one was set or cleared as it ran: one that fails is logged and retried, 2
|
|
64
|
+
* seconds later, doubling, six times, then hourly, unless cleared as it ran or a time set as
|
|
65
|
+
* it ran is earlier.
|
|
66
|
+
*/
|
|
67
|
+
runDue: (owners) => Effect.gen(function* () {
|
|
68
|
+
const now = yield* Clock.currentTimeMillis;
|
|
69
|
+
const kept = yield* times;
|
|
70
|
+
yield* Effect.forEach(kept.filter(({ at }) => at <= now), ({ owner }) => Effect.gen(function* () {
|
|
71
|
+
const run = owners[owner];
|
|
72
|
+
const stored = Effect.map(read(owner), Option.getOrUndefined);
|
|
73
|
+
const drop = Effect.promise(async () => await storage.delete(keyOf(owner)));
|
|
74
|
+
const due = yield* lock.withPermits(1)(Effect.gen(function* () {
|
|
75
|
+
const current = yield* stored;
|
|
76
|
+
if (current === void 0 || current.at > now) return;
|
|
77
|
+
if (run === void 0) return yield* Effect.as(drop, void 0);
|
|
78
|
+
const mark = {
|
|
79
|
+
at: (yield* Clock.currentTimeMillis) + retryDelay(current.failures + 1),
|
|
80
|
+
failures: current.failures + 1
|
|
81
|
+
};
|
|
82
|
+
yield* keep(owner, mark).pipe(Effect.andThen(rearm), Effect.andThen(Effect.promise(async () => {
|
|
83
|
+
await storage.sync();
|
|
84
|
+
})));
|
|
85
|
+
running.add(owner);
|
|
86
|
+
return {
|
|
87
|
+
...current,
|
|
88
|
+
mark,
|
|
89
|
+
run
|
|
90
|
+
};
|
|
91
|
+
}));
|
|
92
|
+
if (due === void 0) return;
|
|
93
|
+
const { at, failures, mark } = due;
|
|
94
|
+
const attempt = failures + 1;
|
|
95
|
+
const exit = yield* Effect.exit(due.run({
|
|
96
|
+
retryCount: failures,
|
|
97
|
+
isRetry: failures > 0,
|
|
98
|
+
scheduledTime: at
|
|
99
|
+
}));
|
|
100
|
+
if (Exit.isFailure(exit)) yield* Effect.logError(`The alarm of ${owner} failed: retried in ${retryDelay(attempt)} ms`, exit.cause);
|
|
101
|
+
const endedAt = yield* Clock.currentTimeMillis;
|
|
102
|
+
yield* lock.withPermits(1)(Effect.gen(function* () {
|
|
103
|
+
running.delete(owner);
|
|
104
|
+
const current = yield* stored;
|
|
105
|
+
const isMark = current?.at === mark.at && current.failures === mark.failures;
|
|
106
|
+
if (Exit.isSuccess(exit)) {
|
|
107
|
+
if (isMark) yield* drop;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (current === void 0) return;
|
|
111
|
+
const retry = {
|
|
112
|
+
at: endedAt + retryDelay(attempt),
|
|
113
|
+
failures: attempt
|
|
114
|
+
};
|
|
115
|
+
if (isMark || retry.at < current.at) yield* keep(owner, retry);
|
|
116
|
+
}));
|
|
117
|
+
}).pipe(Effect.ensuring(Effect.sync(() => {
|
|
118
|
+
running.delete(owner);
|
|
119
|
+
}))), { discard: true });
|
|
120
|
+
yield* lock.withPermits(1)(rearm);
|
|
121
|
+
})
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
/** The owner the application's `alarm` option runs as. */
|
|
125
|
+
const applicationOwner = "application";
|
|
126
|
+
/** The application's alarm over `schedule`, the instance's. */
|
|
127
|
+
const alarmOf = (schedule) => Alarm.of({
|
|
128
|
+
set: (at) => schedule.setBy(applicationOwner, DateTime.toEpochMillis(at)),
|
|
129
|
+
clear: schedule.clear(applicationOwner)
|
|
130
|
+
});
|
|
131
|
+
/**
|
|
132
|
+
* When the object's `alarm` option runs: `set(at)` has it run no later than `at`, and `clear`
|
|
133
|
+
* cancels it. A startup service of the object's services, routes, calls and `run`, as `State`
|
|
134
|
+
* is. The object's one runtime alarm is the package's, set for the earliest of the times it
|
|
135
|
+
* keeps, the package's own work among them, so an application never sets it itself.
|
|
136
|
+
*
|
|
137
|
+
* **Gotchas**
|
|
138
|
+
*
|
|
139
|
+
* - `State`'s `storage.setAlarm` replaces the package's alarm: the times it keeps then run at that alarm, early or late, or never, and the `alarm` option runs only for a time `Alarm.set` gave, so schedule with `Alarm`.
|
|
140
|
+
* - A firing before every time it keeps, as a repeated one or `runDurableObjectAlarm` in a test, runs none and sets the alarm again: a test runs a time once it has come.
|
|
141
|
+
* - A time already past runs at once; the option runs once for every time set before its run, and sets its next itself.
|
|
142
|
+
* - The times live in the object's storage, under `effect-actions/alarm/`, beside the application's data: a store deleting every key deletes them too.
|
|
143
|
+
*/
|
|
144
|
+
var Alarm = class Alarm extends Context.Service()("effect-actions-cloudflare/ActionDurableObject/Alarm") {
|
|
145
|
+
/**
|
|
146
|
+
* The application's alarm, over the schedule of the instance whose `State` it is given.
|
|
147
|
+
*
|
|
148
|
+
* **Gotchas**
|
|
149
|
+
*
|
|
150
|
+
* - Only `handlers`' and `make`'s `alarm` runs the times it keeps: provide this layer alone only where nothing calls `set`, as to a store under test.
|
|
151
|
+
* - Each build makes a schedule of its own, which takes no turns with the object's: a `set` through it while the object's alarm runs may be lost, so an object's own code uses the `Alarm` `handlers` and `make` provide.
|
|
152
|
+
*/
|
|
153
|
+
static layer = Layer.effect(Alarm)(Effect.map(State, (state) => alarmOf(makeSchedule(state))));
|
|
154
|
+
};
|
|
155
|
+
export { Alarm, alarmOf, alarmPrefix, applicationOwner, makeSchedule };
|
package/dist/batches.mjs
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Array, Effect } from "effect";
|
|
2
|
+
// src/durable/batches.ts
|
|
3
|
+
/**
|
|
4
|
+
* `keys`, or entries, in the batches one storage call takes: `get`, `put` and `delete` take at
|
|
5
|
+
* most 128 keys each, as Cloudflare's storage API documents, though local workerd takes more.
|
|
6
|
+
*/
|
|
7
|
+
const batches = (keys) => Array.chunksOf(keys, 128);
|
|
8
|
+
/** Deletes `keys` from `storage`, one batch per call. */
|
|
9
|
+
const deleted = (storage, keys) => Effect.forEach(batches(keys), (batch) => Effect.promise(async () => await storage.delete([...batch])), { discard: true });
|
|
10
|
+
export { batches, deleted };
|
package/dist/calls.d.mts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Effect, Queue } from "effect";
|
|
2
|
+
import { RpcMessage, RpcSerialization } from "effect/rpc";
|
|
3
|
+
import "@gjermundgaraba/effect-actions/ActionRpc";
|
|
4
|
+
/**
|
|
5
|
+
* What a call over the stub carries to the object's `effectActionsRpc`: its messages, as values.
|
|
6
|
+
* A class of its own delegating the method takes it as its parameter,
|
|
7
|
+
* `effectActionsRpc(request: ActionDurableObject.StubRequest)`.
|
|
8
|
+
*/
|
|
9
|
+
export type StubRequest = readonly RpcMessage.FromClientEncoded[];
|
package/dist/calls.mjs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Effect, Predicate, Queue, Schema } from "effect";
|
|
2
|
+
import { RpcMessage, RpcServer } from "effect/rpc";
|
|
3
|
+
import * as ActionRpc from "@gjermundgaraba/effect-actions/ActionRpc";
|
|
4
|
+
// src/durable/calls.ts
|
|
5
|
+
/**
|
|
6
|
+
* The codec of the stub's values: each schema's type to its plain form, `Schema.toCodecIso`, which
|
|
7
|
+
* Workers RPC's structured clone carries as it is, a `Date`, bytes, `undefined` and a `Map` among
|
|
8
|
+
* them, where JSON would encode them again. An `Exit`, a `Cause` and an `Option` encode to their
|
|
9
|
+
* tagged objects, a class instance to its fields.
|
|
10
|
+
*/
|
|
11
|
+
const codecFor = Schema.toCodecIso;
|
|
12
|
+
const encodeDefect = Schema.encodeSync(codecFor(Schema.Defect()));
|
|
13
|
+
/**
|
|
14
|
+
* A unary `RpcServer.Protocol` over the object's stub, passing values, modeled on Effect's HTTP
|
|
15
|
+
* protocol: one call is one client, its requests and their responses, then its end. `serve`
|
|
16
|
+
* answers one call of `effectActionsRpc`, and `served` dies unless a server runs on `protocol`.
|
|
17
|
+
*/
|
|
18
|
+
const makeStubProtocol = Effect.gen(function* () {
|
|
19
|
+
const context = yield* Effect.context();
|
|
20
|
+
let running = false;
|
|
21
|
+
const disconnects = yield* Queue.make();
|
|
22
|
+
const clients = /* @__PURE__ */ new Map();
|
|
23
|
+
let nextId = 0;
|
|
24
|
+
const failPending = (client, defect) => {
|
|
25
|
+
const requests = [...client.pending.keys()];
|
|
26
|
+
client.pending.clear();
|
|
27
|
+
return Queue.offerAll(client.responses, requests.length === 0 ? [defect] : requests.map((id) => RpcMessage.ResponseExitDieEncoded({
|
|
28
|
+
requestId: RpcMessage.RequestId(id),
|
|
29
|
+
encodedDefect: defect.defect
|
|
30
|
+
})));
|
|
31
|
+
};
|
|
32
|
+
let serve;
|
|
33
|
+
const protocol = yield* RpcServer.Protocol.make((write) => {
|
|
34
|
+
serve = (request) => Effect.gen(function* () {
|
|
35
|
+
const clientId = nextId;
|
|
36
|
+
nextId += 1;
|
|
37
|
+
const client = {
|
|
38
|
+
responses: yield* Queue.make(),
|
|
39
|
+
pending: /* @__PURE__ */ new Map()
|
|
40
|
+
};
|
|
41
|
+
clients.set(clientId, client);
|
|
42
|
+
yield* Effect.addFinalizer(() => Effect.sync(() => {
|
|
43
|
+
clients.delete(clientId);
|
|
44
|
+
Queue.offerUnsafe(disconnects, clientId);
|
|
45
|
+
}));
|
|
46
|
+
yield* Effect.suspend(() => Effect.forEach(request, (message) => {
|
|
47
|
+
if (Predicate.isTagged(message, "Request")) client.pending.set(message.id, message.tag);
|
|
48
|
+
return write(clientId, message);
|
|
49
|
+
}, { discard: true })).pipe(Effect.catchDefect((defect) => failPending(client, RpcMessage.ResponseDefectEncoded(encodeDefect(defect)))));
|
|
50
|
+
yield* write(clientId, RpcMessage.constEof);
|
|
51
|
+
return yield* Queue.collect(client.responses);
|
|
52
|
+
}).pipe(Effect.scoped, Effect.provideContext(context));
|
|
53
|
+
return Effect.succeed({
|
|
54
|
+
disconnects,
|
|
55
|
+
send: (clientId, response) => Effect.suspend(() => {
|
|
56
|
+
const client = clients.get(clientId);
|
|
57
|
+
if (client === void 0) return Effect.void;
|
|
58
|
+
if (Predicate.isTagged(response, "Defect")) return failPending(client, response);
|
|
59
|
+
if (!Predicate.isTagged(response, "Chunk")) {
|
|
60
|
+
if (Predicate.isTagged(response, "Exit")) client.pending.delete(response.requestId);
|
|
61
|
+
return Queue.offer(client.responses, response);
|
|
62
|
+
}
|
|
63
|
+
const tag = client.pending.get(response.requestId) ?? "";
|
|
64
|
+
return failPending(client, RpcMessage.ResponseDefectEncoded(encodeDefect(/* @__PURE__ */ new Error(`The rpc ${tag} streams, and the object's stub is unary: serve streams over layerWebSocket`)))).pipe(Effect.andThen(Queue.end(client.responses)), Effect.andThen(Queue.offer(disconnects, clientId)));
|
|
65
|
+
}),
|
|
66
|
+
end: (clientId) => Effect.suspend(() => {
|
|
67
|
+
const client = clients.get(clientId);
|
|
68
|
+
return client === void 0 ? Effect.void : Queue.end(client.responses);
|
|
69
|
+
}),
|
|
70
|
+
clientIds: Effect.sync(() => new Set(clients.keys())),
|
|
71
|
+
initialMessage: Effect.succeedNone,
|
|
72
|
+
supportsAck: false,
|
|
73
|
+
supportsTransferables: false,
|
|
74
|
+
supportsSpanPropagation: true,
|
|
75
|
+
supportsNotifications: false,
|
|
76
|
+
codecFor
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
protocol: {
|
|
81
|
+
...protocol,
|
|
82
|
+
run: (f) => Effect.suspend(() => {
|
|
83
|
+
running = true;
|
|
84
|
+
return protocol.run(f);
|
|
85
|
+
}),
|
|
86
|
+
[ActionRpc.unaryProtocol]: true
|
|
87
|
+
},
|
|
88
|
+
serve,
|
|
89
|
+
served: Effect.yieldNow.pipe(Effect.andThen(Effect.suspend(() => running ? Effect.void : Effect.die(/* @__PURE__ */ new Error("No rpc server runs on this object's stub protocol: serve a group over it in make's or handlers' calls, such as RpcServer.layer(group)")))))
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
export { codecFor, makeStubProtocol };
|
package/dist/events.mjs
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { State } from "./state.mjs";
|
|
2
|
+
import { deleted } from "./batches.mjs";
|
|
3
|
+
import { Effect, Function, Layer, Option, Schema, Semaphore } from "effect";
|
|
4
|
+
import * as Event from "@gjermundgaraba/effect-actions/Event";
|
|
5
|
+
// src/durable/events.ts
|
|
6
|
+
const Position = Schema.Struct({
|
|
7
|
+
epoch: Schema.String,
|
|
8
|
+
last: Schema.Int
|
|
9
|
+
});
|
|
10
|
+
const Stored = Schema.Struct({
|
|
11
|
+
at: Schema.Finite,
|
|
12
|
+
key: Schema.optional(Schema.String),
|
|
13
|
+
eventId: Schema.optional(Schema.String),
|
|
14
|
+
timestamp: Schema.optional(Schema.DateTimeUtcFromMillis),
|
|
15
|
+
data: Schema.optional(Schema.Json)
|
|
16
|
+
});
|
|
17
|
+
const positionOf = Schema.decodeUnknownOption(Position);
|
|
18
|
+
const storedOf = Schema.decodeUnknownOption(Stored);
|
|
19
|
+
const encodeStored = Schema.encodeSync(Stored);
|
|
20
|
+
const seqWidth = 16;
|
|
21
|
+
/**
|
|
22
|
+
* A log's storage in the object's, under `effect-actions/log/<event>/`: its position, and each
|
|
23
|
+
* entry by its sequence number, its data encoded as the event's payload JSON.
|
|
24
|
+
*/
|
|
25
|
+
const storageLog = (storage, event) => {
|
|
26
|
+
const prefix = `effect-actions/log/${event.name}/`;
|
|
27
|
+
const positionKey = `${prefix}meta`;
|
|
28
|
+
const entries = `${prefix}entry/`;
|
|
29
|
+
const entryKey = (seq) => `${entries}${String(seq).padStart(seqWidth, "0")}`;
|
|
30
|
+
const seqOfKey = (key) => Number(key.slice(entries.length));
|
|
31
|
+
const codec = Schema.toCodecJson(event.payload);
|
|
32
|
+
const encode = Schema.encodeUnknownEffect(codec);
|
|
33
|
+
const decode = Schema.decodeUnknownEffect(codec);
|
|
34
|
+
const listedOf = (found) => Option.match(found, {
|
|
35
|
+
onNone: () => Effect.succeed({
|
|
36
|
+
at: 0,
|
|
37
|
+
logged: void 0
|
|
38
|
+
}),
|
|
39
|
+
onSome: ({ at, key, eventId, timestamp, data }) => Effect.map(data === void 0 ? Effect.succeedNone : Effect.option(decode(data)), (decoded) => ({
|
|
40
|
+
at,
|
|
41
|
+
logged: Option.match(decoded, {
|
|
42
|
+
onNone: Function.constUndefined,
|
|
43
|
+
onSome: (payload) => ({
|
|
44
|
+
key,
|
|
45
|
+
value: {
|
|
46
|
+
data: payload,
|
|
47
|
+
eventId,
|
|
48
|
+
timestamp
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
}))
|
|
53
|
+
});
|
|
54
|
+
return {
|
|
55
|
+
position: Effect.map(Effect.promise(async () => await storage.get(positionKey)), (found) => Option.getOrUndefined(positionOf(found))),
|
|
56
|
+
put: (position, entry) => Effect.gen(function* () {
|
|
57
|
+
if (entry === void 0) return yield* Effect.promise(async () => {
|
|
58
|
+
await storage.put(positionKey, position);
|
|
59
|
+
});
|
|
60
|
+
const { at, logged } = entry.stored;
|
|
61
|
+
const data = yield* encode(logged.value.data).pipe(Effect.catch((error) => Effect.as(Effect.logError(`An occurrence published to the log of event "${event.name}" does not encode as its payload, and is kept as a gap`, error), void 0)));
|
|
62
|
+
yield* Effect.promise(async () => {
|
|
63
|
+
await storage.put({
|
|
64
|
+
[positionKey]: position,
|
|
65
|
+
[entryKey(entry.seq)]: encodeStored({
|
|
66
|
+
at,
|
|
67
|
+
key: logged.key,
|
|
68
|
+
eventId: logged.value.eventId,
|
|
69
|
+
timestamp: logged.value.timestamp,
|
|
70
|
+
data
|
|
71
|
+
})
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}),
|
|
75
|
+
list: (from, limit) => Effect.flatMap(Effect.promise(async () => await storage.list({
|
|
76
|
+
prefix: entries,
|
|
77
|
+
start: entryKey(from),
|
|
78
|
+
limit
|
|
79
|
+
})), (listed) => Effect.forEach(listed, ([key, value]) => Effect.map(listedOf(storedOf(value)), (kept) => [seqOfKey(key), kept]))),
|
|
80
|
+
delete: (seqs) => deleted(storage, seqs.map(entryKey))
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
const revisionPrefix = "effect-actions/revisions/";
|
|
84
|
+
const epochKey = `${revisionPrefix}epoch`;
|
|
85
|
+
const countPrefix = `${revisionPrefix}count/`;
|
|
86
|
+
const epochOf = Schema.decodeUnknownOption(Schema.String);
|
|
87
|
+
const countOf = Schema.decodeUnknownOption(Schema.Int);
|
|
88
|
+
/**
|
|
89
|
+
* Current sources' revisions in the object's storage, under `effect-actions/revisions/`: an
|
|
90
|
+
* epoch made as the object first builds, and a counter by name.
|
|
91
|
+
*/
|
|
92
|
+
const storageRevisions = (storage) => {
|
|
93
|
+
const get = (name) => Effect.map(Effect.promise(async () => await storage.get(`${countPrefix}${name}`)), (found) => Option.getOrElse(countOf(found), () => 0));
|
|
94
|
+
return Effect.gen(function* () {
|
|
95
|
+
const kept = Option.getOrUndefined(epochOf(yield* Effect.promise(async () => await storage.get(epochKey))));
|
|
96
|
+
const epoch = kept ?? crypto.randomUUID();
|
|
97
|
+
if (kept === void 0) yield* Effect.promise(async () => {
|
|
98
|
+
await storage.put(epochKey, epoch);
|
|
99
|
+
});
|
|
100
|
+
const inTurn = Semaphore.withPermit(yield* Semaphore.make(1));
|
|
101
|
+
return {
|
|
102
|
+
epoch,
|
|
103
|
+
get,
|
|
104
|
+
bump: (names) => Effect.flatMap(Effect.forEach(names, (name) => Effect.map(get(name), (count) => [name, count])), (counts) => Effect.promise(async () => {
|
|
105
|
+
await storage.put(Object.fromEntries(counts.map(([name, count]) => [`${countPrefix}${name}`, count + 1])));
|
|
106
|
+
})).pipe(inTurn)
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* The object's `Event.Events`, which `handlers` and `make` provide to the services, calls and
|
|
112
|
+
* routes: each log source's log kept in the object's storage, under `effect-actions/log/`, each
|
|
113
|
+
* current source's revisions beside it, so a cursor outlives an eviction, and a check or current
|
|
114
|
+
* source's subscribers read again when notified, not on its `nextPoll`, a timer that would keep the
|
|
115
|
+
* object from hibernating. Its rules are on `handlers`.
|
|
116
|
+
*/
|
|
117
|
+
const layerEvents = Layer.effect(Event.Events)(Effect.flatMap(State, ({ storage }) => Effect.flatMap(storageRevisions(storage), (revisions) => {
|
|
118
|
+
const named = /* @__PURE__ */ new Map();
|
|
119
|
+
return Event.makeEvents({
|
|
120
|
+
log: (event) => {
|
|
121
|
+
const prior = named.get(event.name);
|
|
122
|
+
if (prior !== void 0 && prior !== event) throw new Error(`An object's Event.Events keeps each log source's log in the object's storage by its event's name, and two event types are named "${event.name}" (give each event type a name of its own)`);
|
|
123
|
+
named.set(event.name, event);
|
|
124
|
+
return storageLog(storage, event);
|
|
125
|
+
},
|
|
126
|
+
polls: false,
|
|
127
|
+
emits: false,
|
|
128
|
+
revisions
|
|
129
|
+
});
|
|
130
|
+
})));
|
|
131
|
+
export { layerEvents };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { HttpClient } from "effect/http";
|
|
3
|
+
/**
|
|
4
|
+
* An `HttpClient` whose requests go to `fetcher`, a Durable Object stub or a service binding,
|
|
5
|
+
* in place of the network: what `ActionHttp.client`, `ActionRpc.client` over
|
|
6
|
+
* `RpcClient.layerProtocolHttp` and `ActionMcp.client` call an object's routes with, or a
|
|
7
|
+
* Worker's.
|
|
8
|
+
*
|
|
9
|
+
* **Gotchas**
|
|
10
|
+
*
|
|
11
|
+
* - A request's URL needs a host, which the fetcher ignores, such as `https://object/api`: a relative URL fails before it is sent.
|
|
12
|
+
* - Nothing authenticates the request on its way: forward the caller's credential, with the client's `transformClient`, as for any remote surface.
|
|
13
|
+
* - To pass a page's request on to an object, forward the `Request` itself with the stub's `fetch`: its upgrade goes with it, which this client does not send.
|
|
14
|
+
*
|
|
15
|
+
* Guide: `docs/ActionDurableObject.md` in this package.
|
|
16
|
+
*/
|
|
17
|
+
export declare const layerHttpClient: (fetcher: Pick<Fetcher, "fetch">) => Layer.Layer<HttpClient.HttpClient>;
|
package/dist/fetcher.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { FetchHttpClient } from "effect/http";
|
|
3
|
+
// src/durable/fetcher.ts
|
|
4
|
+
/**
|
|
5
|
+
* An `HttpClient` whose requests go to `fetcher`, a Durable Object stub or a service binding,
|
|
6
|
+
* in place of the network: what `ActionHttp.client`, `ActionRpc.client` over
|
|
7
|
+
* `RpcClient.layerProtocolHttp` and `ActionMcp.client` call an object's routes with, or a
|
|
8
|
+
* Worker's.
|
|
9
|
+
*
|
|
10
|
+
* **Gotchas**
|
|
11
|
+
*
|
|
12
|
+
* - A request's URL needs a host, which the fetcher ignores, such as `https://object/api`: a relative URL fails before it is sent.
|
|
13
|
+
* - Nothing authenticates the request on its way: forward the caller's credential, with the client's `transformClient`, as for any remote surface.
|
|
14
|
+
* - To pass a page's request on to an object, forward the `Request` itself with the stub's `fetch`: its upgrade goes with it, which this client does not send.
|
|
15
|
+
*
|
|
16
|
+
* Guide: `docs/ActionDurableObject.md` in this package.
|
|
17
|
+
*/
|
|
18
|
+
const layerHttpClient = (fetcher) => FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(async (input, init) => await fetcher.fetch(input, init))));
|
|
19
|
+
export { layerHttpClient };
|
package/dist/frames.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import { RpcSerialization } from "effect/rpc";
|
|
3
|
+
// src/durable/frames.ts
|
|
4
|
+
const socketPrefix = "effect-actions/socket#";
|
|
5
|
+
/** The tag a socket carries for its id, unique among the object's sockets. */
|
|
6
|
+
const socketTag = (id) => `${socketPrefix}${id}`;
|
|
7
|
+
/** The id a socket's tags give it, or 0, which no socket is given, for one no protocol accepted. */
|
|
8
|
+
const socketIdOf = (tags) => {
|
|
9
|
+
const tag = tags.find((found) => found.startsWith(socketPrefix));
|
|
10
|
+
return tag === void 0 ? 0 : Number(tag.slice(22));
|
|
11
|
+
};
|
|
12
|
+
/** The tag a socket carries for the `layerWebSocket` path that accepted it. */
|
|
13
|
+
const groupTag = (path) => `effect-actions/rpc:${path}`;
|
|
14
|
+
/** A request's id on the wire, a finite number or a string, which a client tells apart. */
|
|
15
|
+
const RequestId = Schema.Union([Schema.String, Schema.Finite]);
|
|
16
|
+
/** The object's JSON parser, which keeps no state between frames. */
|
|
17
|
+
const parser = RpcSerialization.json.makeUnsafe();
|
|
18
|
+
/** Closes `socket` with `code`, or with none, unless it is already closing. */
|
|
19
|
+
const closeQuietly = (socket, code, reason) => {
|
|
20
|
+
try {
|
|
21
|
+
if (code === void 0) socket.close();
|
|
22
|
+
else socket.close(code, reason);
|
|
23
|
+
} catch {}
|
|
24
|
+
};
|
|
25
|
+
/** Sends `frame` to the socket `id`, if one holds it and takes it: one the object closed does not. */
|
|
26
|
+
const sendFrame = (state, id, frame) => Effect.sync(() => {
|
|
27
|
+
const encoded = parser.encode(frame);
|
|
28
|
+
if (encoded === void 0) return;
|
|
29
|
+
try {
|
|
30
|
+
for (const socket of state.getWebSockets(socketTag(id))) socket.send(encoded);
|
|
31
|
+
} catch {}
|
|
32
|
+
});
|
|
33
|
+
export { RequestId, closeQuietly, groupTag, parser, sendFrame, socketIdOf, socketTag };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { deleted } from "./batches.mjs";
|
|
2
|
+
import { Clock, Effect, Layer, Option, Schema } from "effect";
|
|
3
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
4
|
+
// src/durable/idempotency.ts
|
|
5
|
+
/** Where the object's kept answers live, one key per action, caller's principal and idempotency key. */
|
|
6
|
+
const keptPrefix = "effect-actions/idempotency/";
|
|
7
|
+
const hour = 36e5;
|
|
8
|
+
const page = 128;
|
|
9
|
+
const keptOf = Schema.decodeUnknownOption(Action.Idempotency.Kept);
|
|
10
|
+
/**
|
|
11
|
+
* `Action.Idempotency` over the object's storage: each answer kept 24 hours, through
|
|
12
|
+
* hibernation, eviction and deployments, and deleted once its day is past as a later one is kept,
|
|
13
|
+
* at most once an hour, so an object given no further key keeps its last day's answers.
|
|
14
|
+
*/
|
|
15
|
+
const layerIdempotency = (state) => Layer.sync(Action.Idempotency)(() => {
|
|
16
|
+
let prunedAt = -Infinity;
|
|
17
|
+
const prune = Effect.gen(function* () {
|
|
18
|
+
const now = yield* Clock.currentTimeMillis;
|
|
19
|
+
if (now - prunedAt < hour) return;
|
|
20
|
+
prunedAt = now;
|
|
21
|
+
let after;
|
|
22
|
+
do {
|
|
23
|
+
const options = {
|
|
24
|
+
prefix: keptPrefix,
|
|
25
|
+
limit: page,
|
|
26
|
+
...after === void 0 ? {} : { startAfter: after }
|
|
27
|
+
};
|
|
28
|
+
const listed = [...yield* Effect.promise(async () => await state.storage.list(options))];
|
|
29
|
+
yield* deleted(state.storage, listed.flatMap(([key, value]) => Option.exists(keptOf(value), ({ at }) => at + Action.Idempotency.keptFor <= now) ? [key] : []));
|
|
30
|
+
after = listed.length === page ? listed.at(-1)?.[0] : void 0;
|
|
31
|
+
} while (after !== void 0);
|
|
32
|
+
});
|
|
33
|
+
return Action.Idempotency.of({
|
|
34
|
+
get: (id) => Effect.map(Effect.promise(async () => await state.storage.get(`${keptPrefix}${id}`)), (stored) => Option.getOrUndefined(keptOf(stored))),
|
|
35
|
+
set: (id, kept) => Effect.andThen(Effect.promise(async () => {
|
|
36
|
+
await state.storage.put(`${keptPrefix}${id}`, kept);
|
|
37
|
+
}), prune)
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
export { layerIdempotency };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
3
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
4
|
+
type Contract<H extends ActionHttp.Any> = H["actions"][number] | H["events"][number];
|
|
5
|
+
/**
|
|
6
|
+
* The key of each contract of `H`, by its name, as `placed` takes them: the name of the object
|
|
7
|
+
* serving a call or a subscription, read from its input.
|
|
8
|
+
*/
|
|
9
|
+
export type PlacementKeys<H extends ActionHttp.Any> = { readonly [C in Contract<H> as C["name"]]: (input: C["input"]["Type"]) => string; };
|
|
10
|
+
/**
|
|
11
|
+
* What `placed` returns: the objects whose routes serve `binding`, each the one an input's key
|
|
12
|
+
* names. Given to `make` or `handlers` as `placed`, it is what the object checks each input by;
|
|
13
|
+
* given to `reaching` with the namespace, what a Worker serves.
|
|
14
|
+
*/
|
|
15
|
+
export interface Placement<H extends ActionHttp.Any> {
|
|
16
|
+
readonly binding: H;
|
|
17
|
+
/** `placed`'s keys: by a contract's name, the name of the object serving its input. */
|
|
18
|
+
readonly keys: {
|
|
19
|
+
readonly [name: string]: (input: Action.Any["input"]["Type"]) => string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** Any placement, as `make` and `handlers` take it. */
|
|
23
|
+
export type AnyPlacement = Placement<ActionHttp.Any>;
|
|
24
|
+
/**
|
|
25
|
+
* The actions and event types of `binding` served in Durable Objects, each call and subscription
|
|
26
|
+
* in the object its contract's key in `keys` names for its input, reached by
|
|
27
|
+
* `getByName(keys[contract.name](input))`. Each object is
|
|
28
|
+
* given it as `make`'s or `handlers`' `placed`, so that it refuses an input naming another
|
|
29
|
+
* object, and serves `binding` in an `ActionHttp.layer` of its routes, where each call arrives; a
|
|
30
|
+
* Worker serves `reaching(placement, () => env.ROOM)` on any surface.
|
|
31
|
+
*
|
|
32
|
+
* Guide: `docs/ActionDurableObject-placement.md` in this package.
|
|
33
|
+
*/
|
|
34
|
+
export declare const placed: <const H extends ActionHttp.Any>(binding: H, keys: NoInfer<PlacementKeys<H>>) => Placement<H>;
|
|
35
|
+
/**
|
|
36
|
+
* The implementations a Worker serves of `placement`, reaching its objects in the namespace
|
|
37
|
+
* `namespace` answers, `() => env.ROOM`: beside the Worker's own, on any surface, each call and
|
|
38
|
+
* subscription sent on to the object its input's key names, as `ActionHttp.placed` sends it on.
|
|
39
|
+
*
|
|
40
|
+
* **Gotchas**
|
|
41
|
+
*
|
|
42
|
+
* - `namespace` is called as each call is sent, never as the module evaluates, so it reads `env` from `cloudflare:workers`, which a module-level value finds unset.
|
|
43
|
+
* - Its rules, a credential verified by the Worker and again in the object among them, are `ActionHttp.placed`'s.
|
|
44
|
+
* - An event an object's socket serves through it holds a request to the other object open while it is watched: both stay awake, billed, and neither hibernates. Serve an event in the object whose socket follows it.
|
|
45
|
+
*/
|
|
46
|
+
export declare const reaching: <H extends ActionHttp.Any>(placement: Placement<H>, namespace: () => DurableObjectNamespace) => ActionHttp.Placed<H>;
|
|
47
|
+
export {};
|
package/dist/placed.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Effect, Layer } from "effect";
|
|
2
|
+
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
|
|
3
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
4
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
5
|
+
// src/durable/placed.ts
|
|
6
|
+
/**
|
|
7
|
+
* The actions and event types of `binding` served in Durable Objects, each call and subscription
|
|
8
|
+
* in the object its contract's key in `keys` names for its input, reached by
|
|
9
|
+
* `getByName(keys[contract.name](input))`. Each object is
|
|
10
|
+
* given it as `make`'s or `handlers`' `placed`, so that it refuses an input naming another
|
|
11
|
+
* object, and serves `binding` in an `ActionHttp.layer` of its routes, where each call arrives; a
|
|
12
|
+
* Worker serves `reaching(placement, () => env.ROOM)` on any surface.
|
|
13
|
+
*
|
|
14
|
+
* Guide: `docs/ActionDurableObject-placement.md` in this package.
|
|
15
|
+
*/
|
|
16
|
+
const placed = (binding, keys) => ({
|
|
17
|
+
binding,
|
|
18
|
+
keys
|
|
19
|
+
});
|
|
20
|
+
/**
|
|
21
|
+
* The implementations a Worker serves of `placement`, reaching its objects in the namespace
|
|
22
|
+
* `namespace` answers, `() => env.ROOM`: beside the Worker's own, on any surface, each call and
|
|
23
|
+
* subscription sent on to the object its input's key names, as `ActionHttp.placed` sends it on.
|
|
24
|
+
*
|
|
25
|
+
* **Gotchas**
|
|
26
|
+
*
|
|
27
|
+
* - `namespace` is called as each call is sent, never as the module evaluates, so it reads `env` from `cloudflare:workers`, which a module-level value finds unset.
|
|
28
|
+
* - Its rules, a credential verified by the Worker and again in the object among them, are `ActionHttp.placed`'s.
|
|
29
|
+
* - An event an object's socket serves through it holds a request to the other object open while it is watched: both stay awake, billed, and neither hibernates. Serve an event in the object whose socket follows it.
|
|
30
|
+
*/
|
|
31
|
+
const reaching = (placement, namespace) => {
|
|
32
|
+
const fetching = Effect.runSync(Effect.provide(Effect.service(HttpClient.HttpClient), FetchHttpClient.layer)).pipe(HttpClient.mapRequest(HttpClientRequest.prependUrl("https://object")));
|
|
33
|
+
return ActionHttp.placed(placement.binding, (input, contract) => {
|
|
34
|
+
const key = placement.keys[contract.name];
|
|
35
|
+
if (key === void 0) throw new Error(`ActionDurableObject.placed: its keys hold none for ${contract.name} (give placed a key for each contract of its binding, by name)`);
|
|
36
|
+
const stub = namespace().getByName(key(input));
|
|
37
|
+
return HttpClient.transform(fetching, (sent) => Effect.provideService(sent, FetchHttpClient.Fetch, async (url, init) => await stub.fetch(url, init)));
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* The input check of an object named `name`, given `placement`: an input of one of its contracts
|
|
42
|
+
* whose key names another object is refused.
|
|
43
|
+
*/
|
|
44
|
+
const layerInputCheck = (placement, name) => {
|
|
45
|
+
const contracts = /* @__PURE__ */ new Set([...placement.binding.actions, ...placement.binding.events]);
|
|
46
|
+
return Layer.succeed(Action.InputCheck)((contract, input) => {
|
|
47
|
+
const key = contracts.has(contract) ? placement.keys[contract.name] : void 0;
|
|
48
|
+
if (key === void 0) return Effect.void;
|
|
49
|
+
const named = key(input);
|
|
50
|
+
return named === name ? Effect.void : Effect.fail(new Action.Forbidden({ message: `This object is ${String(name)}, and the input of ${contract.name} names ${named}` }));
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
export { layerInputCheck, placed, reaching };
|