@pithy-sh/core 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -0
- package/dist/audit/auditEvent.js +1 -1
- package/dist/audit/chain.d.ts +289 -0
- package/dist/audit/chain.d.ts.map +1 -0
- package/dist/audit/chain.js +400 -0
- package/dist/audit/citations.d.ts +52 -0
- package/dist/audit/citations.d.ts.map +1 -0
- package/dist/audit/citations.js +13 -0
- package/dist/audit/composed.d.ts +18 -0
- package/dist/audit/composed.d.ts.map +1 -0
- package/dist/audit/composed.js +28 -0
- package/dist/capability/bindings.d.ts +22 -0
- package/dist/capability/bindings.d.ts.map +1 -1
- package/dist/capability/bindings.js +32 -2
- package/dist/capability/capability.d.ts +133 -15
- package/dist/capability/capability.d.ts.map +1 -1
- package/dist/capability/capability.js +17 -2
- package/dist/capability/erasure.d.ts +75 -0
- package/dist/capability/erasure.d.ts.map +1 -0
- package/dist/capability/erasure.js +2 -0
- package/dist/capability/manifest.d.ts +5 -0
- package/dist/capability/manifest.d.ts.map +1 -1
- package/dist/capability/personColumns.d.ts +28 -0
- package/dist/capability/personColumns.d.ts.map +1 -0
- package/dist/capability/personColumns.js +66 -0
- package/dist/capability/personRows.d.ts +62 -0
- package/dist/capability/personRows.d.ts.map +1 -0
- package/dist/capability/personRows.js +59 -0
- package/dist/capability/purge.d.ts +113 -4
- package/dist/capability/purge.d.ts.map +1 -1
- package/dist/capability/purge.js +138 -5
- package/dist/capability/settings.d.ts +10 -0
- package/dist/capability/settings.d.ts.map +1 -1
- package/dist/capability/vectorIndex.d.ts +36 -0
- package/dist/capability/vectorIndex.d.ts.map +1 -0
- package/dist/capability/vectorIndex.js +25 -0
- package/dist/cli/commandCatalog.generated.d.ts +434 -4
- package/dist/cli/commandCatalog.generated.d.ts.map +1 -1
- package/dist/cli/commandCatalog.generated.js +455 -2
- package/dist/cli/commandFacts.d.ts +8 -5
- package/dist/cli/commandFacts.d.ts.map +1 -1
- package/dist/cli/commandFacts.js +12 -3
- package/dist/consent/purpose.d.ts +70 -0
- package/dist/consent/purpose.d.ts.map +1 -0
- package/dist/consent/purpose.js +97 -0
- package/dist/consent/reader.d.ts +99 -0
- package/dist/consent/reader.d.ts.map +1 -0
- package/dist/consent/reader.js +33 -0
- package/dist/controlPlane/audit/actions.d.ts +15 -4
- package/dist/controlPlane/audit/actions.d.ts.map +1 -1
- package/dist/controlPlane/audit/actions.js +16 -5
- package/dist/controlPlane/capability.d.ts +10 -0
- package/dist/controlPlane/capability.d.ts.map +1 -1
- package/dist/controlPlane/capability.js +56 -8
- package/dist/controlPlane/context.d.ts +4 -0
- package/dist/controlPlane/context.d.ts.map +1 -1
- package/dist/controlPlane/context.js +3 -2
- package/dist/controlPlane/data/connection.d.ts +21 -2
- package/dist/controlPlane/data/connection.d.ts.map +1 -1
- package/dist/controlPlane/data/connection.js +13 -4
- package/dist/controlPlane/data/connections.d.ts +94 -0
- package/dist/controlPlane/data/connections.d.ts.map +1 -0
- package/dist/controlPlane/data/connections.js +151 -0
- package/dist/controlPlane/data/hold.d.ts +46 -0
- package/dist/controlPlane/data/hold.d.ts.map +1 -0
- package/dist/controlPlane/data/hold.js +36 -0
- package/dist/controlPlane/data/oidcTrust.d.ts +122 -0
- package/dist/controlPlane/data/oidcTrust.d.ts.map +1 -0
- package/dist/controlPlane/data/oidcTrust.js +173 -0
- package/dist/controlPlane/data/tables.d.ts +4 -0
- package/dist/controlPlane/data/tables.d.ts.map +1 -1
- package/dist/controlPlane/data/tables.js +9 -4
- package/dist/controlPlane/data/trustFirstCall.d.ts +16 -0
- package/dist/controlPlane/data/trustFirstCall.d.ts.map +1 -0
- package/dist/controlPlane/data/trustFirstCall.js +25 -0
- package/dist/controlPlane/discovery/adminRoute.d.ts +165 -2
- package/dist/controlPlane/discovery/adminRoute.d.ts.map +1 -1
- package/dist/controlPlane/discovery/adminRoute.js +32 -8
- package/dist/controlPlane/discovery/routeInput.d.ts +23 -0
- package/dist/controlPlane/discovery/routeInput.d.ts.map +1 -0
- package/dist/controlPlane/discovery/routeInput.js +80 -0
- package/dist/controlPlane/discovery/toolName.d.ts +21 -0
- package/dist/controlPlane/discovery/toolName.d.ts.map +1 -0
- package/dist/controlPlane/discovery/toolName.js +25 -0
- package/dist/controlPlane/erasure/attest.d.ts +78 -0
- package/dist/controlPlane/erasure/attest.d.ts.map +1 -0
- package/dist/controlPlane/erasure/attest.js +164 -0
- package/dist/controlPlane/erasure/read.d.ts +39 -0
- package/dist/controlPlane/erasure/read.d.ts.map +1 -0
- package/dist/controlPlane/erasure/read.js +140 -0
- package/dist/controlPlane/error/errors.d.ts +61 -2
- package/dist/controlPlane/error/errors.d.ts.map +1 -1
- package/dist/controlPlane/error/errors.js +13 -0
- package/dist/controlPlane/evidence/derive.d.ts +124 -0
- package/dist/controlPlane/evidence/derive.d.ts.map +1 -0
- package/dist/controlPlane/evidence/derive.js +343 -0
- package/dist/controlPlane/evidence/mapping.d.ts +85 -0
- package/dist/controlPlane/evidence/mapping.d.ts.map +1 -0
- package/dist/controlPlane/evidence/mapping.js +114 -0
- package/dist/controlPlane/evidence/pack.d.ts +46 -0
- package/dist/controlPlane/evidence/pack.d.ts.map +1 -0
- package/dist/controlPlane/evidence/pack.js +124 -0
- package/dist/controlPlane/http/connections.d.ts +96 -0
- package/dist/controlPlane/http/connections.d.ts.map +1 -0
- package/dist/controlPlane/http/connections.js +109 -0
- package/dist/controlPlane/http/guard.d.ts +23 -2
- package/dist/controlPlane/http/guard.d.ts.map +1 -1
- package/dist/controlPlane/http/guard.js +103 -7
- package/dist/controlPlane/http/handlers.d.ts +212 -10
- package/dist/controlPlane/http/handlers.d.ts.map +1 -1
- package/dist/controlPlane/http/handlers.js +117 -26
- package/dist/controlPlane/http/holds.d.ts +57 -0
- package/dist/controlPlane/http/holds.d.ts.map +1 -0
- package/dist/controlPlane/http/holds.js +98 -0
- package/dist/controlPlane/http/presence.d.ts +27 -0
- package/dist/controlPlane/http/presence.d.ts.map +1 -0
- package/dist/controlPlane/http/presence.js +67 -0
- package/dist/controlPlane/http/processing.d.ts +73 -0
- package/dist/controlPlane/http/processing.d.ts.map +1 -0
- package/dist/controlPlane/http/processing.js +17 -0
- package/dist/controlPlane/http/responses.d.ts +927 -3
- package/dist/controlPlane/http/responses.d.ts.map +1 -1
- package/dist/controlPlane/http/responses.js +283 -7
- package/dist/controlPlane/http/routes.d.ts +12 -2
- package/dist/controlPlane/http/routes.d.ts.map +1 -1
- package/dist/controlPlane/http/routes.js +182 -5
- package/dist/controlPlane/http/schemas.d.ts +63 -0
- package/dist/controlPlane/http/schemas.d.ts.map +1 -1
- package/dist/controlPlane/http/schemas.js +39 -1
- package/dist/controlPlane/http/verify.d.ts.map +1 -1
- package/dist/controlPlane/http/verify.js +13 -1
- package/dist/controlPlane/http/verifyTrust.d.ts +76 -0
- package/dist/controlPlane/http/verifyTrust.d.ts.map +1 -0
- package/dist/controlPlane/http/verifyTrust.js +75 -0
- package/dist/controlPlane/migrations/0004_connection_trusts.d.ts +15 -0
- package/dist/controlPlane/migrations/0004_connection_trusts.d.ts.map +1 -0
- package/dist/controlPlane/migrations/0004_connection_trusts.js +23 -0
- package/dist/controlPlane/migrations/0005_holds.d.ts +12 -0
- package/dist/controlPlane/migrations/0005_holds.d.ts.map +1 -0
- package/dist/controlPlane/migrations/0005_holds.js +26 -0
- package/dist/controlPlane/processing/derive.d.ts +77 -0
- package/dist/controlPlane/processing/derive.d.ts.map +1 -0
- package/dist/controlPlane/processing/derive.js +206 -0
- package/dist/controlPlane/processing/read.d.ts +10 -0
- package/dist/controlPlane/processing/read.d.ts.map +1 -0
- package/dist/controlPlane/processing/read.js +89 -0
- package/dist/controlPlane/scope/kind.d.ts +46 -0
- package/dist/controlPlane/scope/kind.d.ts.map +1 -0
- package/dist/controlPlane/scope/kind.js +52 -0
- package/dist/controlPlane/scope/scope.d.ts +58 -3
- package/dist/controlPlane/scope/scope.d.ts.map +1 -1
- package/dist/controlPlane/scope/scope.js +59 -4
- package/dist/controlPlane/token/jws.js +1 -1
- package/dist/controlPlane/token/mint.js +1 -1
- package/dist/controlPlane/wire.d.ts +23 -0
- package/dist/controlPlane/wire.d.ts.map +1 -1
- package/dist/controlPlane/wire.js +24 -1
- package/dist/createBackend.d.ts +13 -0
- package/dist/createBackend.d.ts.map +1 -1
- package/dist/createBackend.js +11 -3
- package/dist/createEntrypoint.d.ts +14 -1
- package/dist/createEntrypoint.d.ts.map +1 -1
- package/dist/createEntrypoint.js +19 -5
- package/dist/error/http.d.ts +216 -0
- package/dist/error/http.d.ts.map +1 -1
- package/dist/error/payload.d.ts +432 -0
- package/dist/error/payload.d.ts.map +1 -1
- package/dist/error/payload.js +144 -0
- package/dist/error/pithyError.d.ts +22 -0
- package/dist/error/pithyError.d.ts.map +1 -1
- package/dist/error/pithyError.js +31 -1
- package/dist/http/routeSpec.d.ts +46 -1
- package/dist/http/routeSpec.d.ts.map +1 -1
- package/dist/http/routeSpec.js +39 -5
- package/dist/http/validation.d.ts +11 -2
- package/dist/http/validation.d.ts.map +1 -1
- package/dist/http/validation.js +20 -3
- package/dist/http/verification.d.ts.map +1 -1
- package/dist/http/verification.js +1 -1
- package/dist/i18n/translator.d.ts +2 -1
- package/dist/i18n/translator.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/logger/record.d.ts +126 -0
- package/dist/logger/record.d.ts.map +1 -1
- package/dist/mcp/annotations.d.ts +20 -0
- package/dist/mcp/annotations.d.ts.map +1 -0
- package/dist/mcp/annotations.js +23 -0
- package/dist/mcp/tools.d.ts +75 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +118 -0
- package/dist/openapi/document.d.ts.map +1 -1
- package/dist/openapi/document.js +8 -26
- package/dist/openapi/operations.d.ts +35 -0
- package/dist/openapi/operations.d.ts.map +1 -0
- package/dist/openapi/operations.js +37 -0
- package/dist/presence/channel.d.ts +11 -0
- package/dist/presence/channel.d.ts.map +1 -0
- package/dist/presence/channel.js +65 -0
- package/dist/presence/declaration.d.ts +154 -0
- package/dist/presence/declaration.d.ts.map +1 -0
- package/dist/presence/declaration.js +164 -0
- package/dist/presence/durableObject.d.ts +61 -0
- package/dist/presence/durableObject.d.ts.map +1 -0
- package/dist/presence/durableObject.js +257 -0
- package/dist/presence/frames.d.ts +166 -0
- package/dist/presence/frames.d.ts.map +1 -0
- package/dist/presence/frames.js +90 -0
- package/dist/presence/http/guard.d.ts +11 -0
- package/dist/presence/http/guard.d.ts.map +1 -0
- package/dist/presence/http/guard.js +20 -0
- package/dist/presence/http/responses.d.ts +31 -0
- package/dist/presence/http/responses.d.ts.map +1 -0
- package/dist/presence/http/responses.js +34 -0
- package/dist/presence/http/routes.d.ts +24 -0
- package/dist/presence/http/routes.d.ts.map +1 -0
- package/dist/presence/http/routes.js +127 -0
- package/dist/presence/http/schemas.d.ts +29 -0
- package/dist/presence/http/schemas.d.ts.map +1 -0
- package/dist/presence/http/schemas.js +28 -0
- package/dist/presence/names.d.ts +23 -0
- package/dist/presence/names.d.ts.map +1 -0
- package/dist/presence/names.js +22 -0
- package/dist/presence/room.d.ts +95 -0
- package/dist/presence/room.d.ts.map +1 -0
- package/dist/presence/room.js +105 -0
- package/dist/presence/ticket.d.ts +55 -0
- package/dist/presence/ticket.d.ts.map +1 -0
- package/dist/presence/ticket.js +109 -0
- package/dist/privacy/classify.js +1 -1
- package/dist/privacy/encrypted.js +1 -1
- package/dist/processing/activity.d.ts +134 -0
- package/dist/processing/activity.d.ts.map +1 -0
- package/dist/processing/activity.js +161 -0
- package/dist/processing/composed.d.ts +18 -0
- package/dist/processing/composed.d.ts.map +1 -0
- package/dist/processing/composed.js +28 -0
- package/dist/processing/vocabulary.d.ts +26 -0
- package/dist/processing/vocabulary.d.ts.map +1 -0
- package/dist/processing/vocabulary.js +35 -0
- package/dist/queue/consumer.d.ts +59 -0
- package/dist/queue/consumer.d.ts.map +1 -0
- package/dist/queue/consumer.js +46 -0
- package/dist/retention/composed.d.ts +37 -0
- package/dist/retention/composed.d.ts.map +1 -0
- package/dist/retention/composed.js +66 -0
- package/dist/retention/holds.d.ts +61 -0
- package/dist/retention/holds.d.ts.map +1 -0
- package/dist/retention/holds.js +107 -0
- package/dist/retention/holdsPurge.d.ts +22 -0
- package/dist/retention/holdsPurge.d.ts.map +1 -0
- package/dist/retention/holdsPurge.js +50 -0
- package/dist/retention/policy.d.ts +132 -0
- package/dist/retention/policy.d.ts.map +1 -0
- package/dist/retention/policy.js +197 -0
- package/dist/session/rotated.d.ts +34 -0
- package/dist/session/rotated.d.ts.map +1 -0
- package/dist/session/rotated.js +25 -0
- package/dist/test-utils/connectionFixtures.d.ts +13 -0
- package/dist/test-utils/connectionFixtures.d.ts.map +1 -0
- package/dist/test-utils/connectionFixtures.js +58 -0
- package/dist/test-utils/interleave.d.ts +12 -0
- package/dist/test-utils/interleave.d.ts.map +1 -0
- package/dist/test-utils/interleave.js +26 -0
- package/dist/testWorker.d.ts +8 -0
- package/dist/testWorker.d.ts.map +1 -0
- package/dist/testWorker.js +9 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/dist/workflow/dispatchRoute.js +1 -1
- package/package.json +1 -1
- package/src/audit/auditEvent.ts +1 -1
- package/src/audit/chain.ts +551 -0
- package/src/audit/citations.ts +63 -0
- package/src/audit/composed.ts +30 -0
- package/src/capability/bindings.ts +61 -0
- package/src/capability/capability.ts +166 -18
- package/src/capability/erasure.ts +82 -0
- package/src/capability/personColumns.ts +83 -0
- package/src/capability/personRows.ts +125 -0
- package/src/capability/purge.ts +208 -8
- package/src/capability/settings.ts +7 -0
- package/src/capability/vectorIndex.ts +60 -0
- package/src/cli/commandCatalog.generated.ts +488 -2
- package/src/cli/commandFacts.ts +12 -3
- package/src/cloudflare-test.d.ts +3 -1
- package/src/consent/purpose.ts +160 -0
- package/src/consent/reader.ts +134 -0
- package/src/controlPlane/audit/actions.ts +15 -4
- package/src/controlPlane/capability.ts +99 -20
- package/src/controlPlane/context.ts +10 -2
- package/src/controlPlane/data/connection.ts +16 -2
- package/src/controlPlane/data/connections.ts +231 -0
- package/src/controlPlane/data/hold.ts +52 -0
- package/src/controlPlane/data/oidcTrust.ts +249 -0
- package/src/controlPlane/data/tables.ts +7 -0
- package/src/controlPlane/data/trustFirstCall.ts +37 -0
- package/src/controlPlane/discovery/adminRoute.ts +71 -9
- package/src/controlPlane/discovery/routeInput.ts +86 -0
- package/src/controlPlane/discovery/toolName.ts +23 -0
- package/src/controlPlane/erasure/attest.ts +205 -0
- package/src/controlPlane/erasure/read.ts +169 -0
- package/src/controlPlane/error/errors.ts +69 -2
- package/src/controlPlane/evidence/derive.ts +426 -0
- package/src/controlPlane/evidence/mapping.ts +118 -0
- package/src/controlPlane/evidence/pack.ts +190 -0
- package/src/controlPlane/http/connections.ts +129 -0
- package/src/controlPlane/http/guard.ts +178 -10
- package/src/controlPlane/http/handlers.ts +172 -40
- package/src/controlPlane/http/holds.ts +108 -0
- package/src/controlPlane/http/presence.ts +71 -0
- package/src/controlPlane/http/processing.ts +19 -0
- package/src/controlPlane/http/responses.ts +590 -7
- package/src/controlPlane/http/routes.ts +302 -3
- package/src/controlPlane/http/schemas.ts +126 -0
- package/src/controlPlane/http/verify.ts +19 -2
- package/src/controlPlane/http/verifyTrust.ts +176 -0
- package/src/controlPlane/migrations/0004_connection_trusts.ts +30 -0
- package/src/controlPlane/migrations/0005_holds.ts +40 -0
- package/src/controlPlane/processing/derive.ts +359 -0
- package/src/controlPlane/processing/read.ts +102 -0
- package/src/controlPlane/scope/kind.ts +63 -0
- package/src/controlPlane/scope/scope.ts +65 -3
- package/src/controlPlane/wire.ts +26 -0
- package/src/createBackend.ts +31 -6
- package/src/createEntrypoint.ts +34 -2
- package/src/error/payload.ts +302 -0
- package/src/error/pithyError.ts +42 -0
- package/src/http/routeSpec.ts +75 -5
- package/src/http/validation.ts +35 -3
- package/src/http/verification.ts +3 -1
- package/src/i18n/translator.ts +2 -1
- package/src/index.ts +2 -0
- package/src/mcp/annotations.ts +38 -0
- package/src/mcp/tools.ts +220 -0
- package/src/openapi/document.ts +8 -28
- package/src/openapi/operations.ts +62 -0
- package/src/presence/channel.ts +46 -0
- package/src/presence/declaration.ts +283 -0
- package/src/presence/durableObject.ts +287 -0
- package/src/presence/frames.ts +153 -0
- package/src/presence/http/guard.ts +23 -0
- package/src/presence/http/responses.ts +63 -0
- package/src/presence/http/routes.ts +165 -0
- package/src/presence/http/schemas.ts +39 -0
- package/src/presence/names.ts +45 -0
- package/src/presence/room.ts +145 -0
- package/src/presence/ticket.ts +145 -0
- package/src/processing/activity.ts +289 -0
- package/src/processing/composed.ts +33 -0
- package/src/processing/vocabulary.ts +43 -0
- package/src/queue/consumer.ts +104 -0
- package/src/retention/composed.ts +75 -0
- package/src/retention/holds.ts +175 -0
- package/src/retention/holdsPurge.ts +50 -0
- package/src/retention/policy.ts +305 -0
- package/src/session/rotated.ts +53 -0
- package/src/test-utils/connectionFixtures.ts +58 -0
- package/src/test-utils/interleave.ts +31 -0
- package/src/testWorker.ts +13 -0
- package/src/version.generated.ts +1 -1
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { PithyError, PresenceRoomFullError, PresenceTicketInvalidError, ValidationError, fromZodError } from "../error/pithyError.js";
|
|
4
|
+
import { PRESENCE_ROOM_VERSIONS, PresenceRoomClientFrame, PresenceRoomPing, PresenceRoomPong } from "./frames.js";
|
|
5
|
+
import { PRESENCE_MAX_FRAME_BYTES } from "./declaration.js";
|
|
6
|
+
import { SUBPROTOCOL_HEADER, negotiateSubprotocol } from "../http/subprotocol.js";
|
|
7
|
+
import { PRESENCE_ADMISSION_HEADER, PresenceAdmission, PresenceAttachment, admit, carrySpent, declaresActivity, inheritSpent } from "./room.js";
|
|
8
|
+
import { clientError } from "../error/client.js";
|
|
9
|
+
import { DurableObject } from "cloudflare:workers";
|
|
10
|
+
//#region src/presence/durableObject.ts
|
|
11
|
+
/**
|
|
12
|
+
* **One presence room: who has one record open, and what they are doing.** Addressed by `<kind>:<recordId>`,
|
|
13
|
+
* so each record has its own object.
|
|
14
|
+
*
|
|
15
|
+
* The Hibernation API only: `ctx.acceptWebSocket`, never `ws.accept()`, and no registry of sockets in
|
|
16
|
+
* memory — the participants are read back from `getWebSockets()`, each from its own attachment, so a room
|
|
17
|
+
* that hibernates and wakes knows exactly who is in it. Nothing is written to Durable Object storage: the
|
|
18
|
+
* room holds nothing but who is here, and when the last socket closes it is gone.
|
|
19
|
+
*
|
|
20
|
+
* The upgrade is forwarded by the Worker's socket route, which has verified the ticket and states the
|
|
21
|
+
* admission on {@link PRESENCE_ADMISSION_HEADER}. The room trusts that header — a Durable Object is reached
|
|
22
|
+
* only through its binding — and decides the two things only it can: whether the ticket is already spent, and
|
|
23
|
+
* whether the room is full.
|
|
24
|
+
*/
|
|
25
|
+
var PresenceRoom = class extends DurableObject {
|
|
26
|
+
/**
|
|
27
|
+
* Tickets redeemed by sockets that closed while no other socket was left to carry them. Memory, not
|
|
28
|
+
* storage, and only while the room is empty: the next socket to join takes them into its attachment, and
|
|
29
|
+
* until one does, {@link awake} keeps the room from hibernating and losing them.
|
|
30
|
+
*/
|
|
31
|
+
remembered = [];
|
|
32
|
+
/**
|
|
33
|
+
* The timer that holds an empty room awake until the last remembered ticket would expire. A Durable Object
|
|
34
|
+
* with no socket hibernates after about ten seconds idle and loses its memory, which would let a spent
|
|
35
|
+
* ticket in again; a pending timer is one of the things that stops it. With no timer, an idle object that
|
|
36
|
+
* cannot hibernate is evicted only after 70 seconds or more — past a 60-second ticket's life. A deploy or a
|
|
37
|
+
* restart still clears it, and drops every socket with it.
|
|
38
|
+
*/
|
|
39
|
+
awake = null;
|
|
40
|
+
async fetch(request) {
|
|
41
|
+
if (request.headers.get("upgrade") !== "websocket") return new Response("Expected a WebSocket upgrade.", { status: 426 });
|
|
42
|
+
try {
|
|
43
|
+
const admission = this.admission(request);
|
|
44
|
+
const version = negotiateSubprotocol(request.headers.get(SUBPROTOCOL_HEADER), PRESENCE_ROOM_VERSIONS);
|
|
45
|
+
if (version === null) throw new ValidationError({ message: "Offer pithy.presence.v1." });
|
|
46
|
+
const now = /* @__PURE__ */ new Date();
|
|
47
|
+
const nowSeconds = Math.floor(now.getTime() / 1e3);
|
|
48
|
+
const present = this.attachments();
|
|
49
|
+
const leaving = this.leavingAttachments().flatMap((attachment) => [attachment.ticket, ...attachment.spent]);
|
|
50
|
+
const refusal = admit(admission, present, [...this.remembered, ...leaving], nowSeconds);
|
|
51
|
+
if (refusal === "spent") throw new PresenceTicketInvalidError({ detail: `ticket ${admission.ticket.id} is spent` });
|
|
52
|
+
if (refusal === "full") throw new PresenceRoomFullError({ detail: `${admission.kind}/${admission.recordId} at ${admission.maxParticipants}` });
|
|
53
|
+
const participant = {
|
|
54
|
+
id: crypto.randomUUID(),
|
|
55
|
+
ref: admission.ref,
|
|
56
|
+
name: admission.name,
|
|
57
|
+
origin: admission.origin,
|
|
58
|
+
activity: admission.activities[0],
|
|
59
|
+
joinedAt: now.toISOString()
|
|
60
|
+
};
|
|
61
|
+
const pair = new WebSocketPair();
|
|
62
|
+
const [client, server] = [pair[0], pair[1]];
|
|
63
|
+
this.ctx.acceptWebSocket(server);
|
|
64
|
+
server.serializeAttachment({
|
|
65
|
+
participant,
|
|
66
|
+
version,
|
|
67
|
+
ticket: admission.ticket,
|
|
68
|
+
activities: admission.activities,
|
|
69
|
+
spent: inheritSpent(this.remembered, admission.ticket.id, nowSeconds)
|
|
70
|
+
});
|
|
71
|
+
this.remembered = [];
|
|
72
|
+
this.stayAwake(nowSeconds);
|
|
73
|
+
const everyone = [...present.map((attachment) => attachment.participant), participant].sort((a, b) => a.joinedAt.localeCompare(b.joinedAt));
|
|
74
|
+
server.send(render({
|
|
75
|
+
type: "init",
|
|
76
|
+
self: participant.id,
|
|
77
|
+
participants: everyone
|
|
78
|
+
}));
|
|
79
|
+
this.broadcast({
|
|
80
|
+
type: "joined",
|
|
81
|
+
participant
|
|
82
|
+
}, server);
|
|
83
|
+
return new Response(null, {
|
|
84
|
+
status: 101,
|
|
85
|
+
webSocket: client,
|
|
86
|
+
headers: { [SUBPROTOCOL_HEADER]: version }
|
|
87
|
+
});
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (!(error instanceof PithyError)) throw error;
|
|
90
|
+
return Response.json({ error: clientError(error.payload) }, { status: error.payload.status });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* One frame from a participant: `ping`, or `set_activity` naming an activity the room kind declares. A frame
|
|
95
|
+
* past the size limit, one that does not parse, and an undeclared activity are each answered with an error
|
|
96
|
+
* frame and change nothing.
|
|
97
|
+
*/
|
|
98
|
+
async webSocketMessage(ws, message) {
|
|
99
|
+
const attachment = this.attachmentOf(ws);
|
|
100
|
+
if (!attachment) return;
|
|
101
|
+
const bytes = typeof message === "string" ? new TextEncoder().encode(message).byteLength : message.byteLength;
|
|
102
|
+
try {
|
|
103
|
+
if (bytes > 1024) throw new ValidationError({
|
|
104
|
+
message: `A frame is at most ${PRESENCE_MAX_FRAME_BYTES} bytes.`,
|
|
105
|
+
detail: `${bytes} bytes`
|
|
106
|
+
});
|
|
107
|
+
const text = typeof message === "string" ? message : new TextDecoder().decode(message);
|
|
108
|
+
if (PresenceRoomPing.safeParse(text).success) {
|
|
109
|
+
ws.send(PresenceRoomPong.value);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
let json;
|
|
113
|
+
try {
|
|
114
|
+
json = JSON.parse(text);
|
|
115
|
+
} catch {
|
|
116
|
+
throw new ValidationError({ message: "This socket accepts `ping` and JSON frames." });
|
|
117
|
+
}
|
|
118
|
+
const parsed = PresenceRoomClientFrame.safeParse(json);
|
|
119
|
+
if (!parsed.success) throw fromZodError(parsed.error, { message: "This socket accepts `set_activity`." });
|
|
120
|
+
const frame = parsed.data;
|
|
121
|
+
if (!declaresActivity(attachment, frame.activity)) throw new ValidationError({
|
|
122
|
+
message: `This room's activities are ${attachment.activities.join(", ")}.`,
|
|
123
|
+
params: { activity: frame.activity }
|
|
124
|
+
});
|
|
125
|
+
if (frame.activity === attachment.participant.activity) return;
|
|
126
|
+
const participant = {
|
|
127
|
+
...attachment.participant,
|
|
128
|
+
activity: frame.activity
|
|
129
|
+
};
|
|
130
|
+
ws.serializeAttachment({
|
|
131
|
+
...attachment,
|
|
132
|
+
participant
|
|
133
|
+
});
|
|
134
|
+
this.broadcast({
|
|
135
|
+
type: "activity",
|
|
136
|
+
participantId: participant.id,
|
|
137
|
+
activity: frame.activity
|
|
138
|
+
}, ws);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (!(error instanceof PithyError)) throw error;
|
|
141
|
+
try {
|
|
142
|
+
ws.send(render({
|
|
143
|
+
type: "error",
|
|
144
|
+
error: clientError(error.payload)
|
|
145
|
+
}));
|
|
146
|
+
} catch {}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async webSocketClose(ws) {
|
|
150
|
+
this.leave(ws);
|
|
151
|
+
}
|
|
152
|
+
async webSocketError(ws) {
|
|
153
|
+
this.leave(ws);
|
|
154
|
+
}
|
|
155
|
+
/** A socket leaves: everyone else hears it, and the ticket it spent stays spent. */
|
|
156
|
+
leave(ws) {
|
|
157
|
+
const closing = this.attachmentOf(ws);
|
|
158
|
+
if (closing) {
|
|
159
|
+
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
160
|
+
const [carrier] = this.sockets().filter((other) => other !== ws);
|
|
161
|
+
const carried = carrier ? this.attachmentOf(carrier) : void 0;
|
|
162
|
+
if (carrier && carried) {
|
|
163
|
+
const spent = carrySpent(carried, closing, nowSeconds);
|
|
164
|
+
if (spent) carrier.serializeAttachment({
|
|
165
|
+
...carried,
|
|
166
|
+
spent
|
|
167
|
+
});
|
|
168
|
+
} else {
|
|
169
|
+
this.remembered = [
|
|
170
|
+
...this.remembered,
|
|
171
|
+
closing.ticket,
|
|
172
|
+
...closing.spent
|
|
173
|
+
];
|
|
174
|
+
this.stayAwake(nowSeconds);
|
|
175
|
+
}
|
|
176
|
+
ws.serializeAttachment(null);
|
|
177
|
+
this.broadcast({
|
|
178
|
+
type: "left",
|
|
179
|
+
participantId: closing.participant.id
|
|
180
|
+
}, ws);
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
ws.close();
|
|
184
|
+
} catch {}
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Hold the room awake until the last remembered ticket would expire, or let it sleep when there is none.
|
|
188
|
+
* The timer wakes once that has passed and lets go.
|
|
189
|
+
*/
|
|
190
|
+
stayAwake(nowSeconds) {
|
|
191
|
+
this.remembered = this.remembered.filter((ticket) => ticket.exp > nowSeconds);
|
|
192
|
+
const until = Math.max(0, ...this.remembered.map((ticket) => ticket.exp));
|
|
193
|
+
if (this.awake && this.remembered.length > 0 && this.awake.until >= until) return;
|
|
194
|
+
if (this.awake) clearTimeout(this.awake.timer);
|
|
195
|
+
this.awake = null;
|
|
196
|
+
if (this.remembered.length === 0) return;
|
|
197
|
+
const timer = setTimeout(() => {
|
|
198
|
+
this.awake = null;
|
|
199
|
+
this.stayAwake(Math.floor(Date.now() / 1e3));
|
|
200
|
+
}, (until - nowSeconds + 1) * 1e3);
|
|
201
|
+
this.awake = {
|
|
202
|
+
until,
|
|
203
|
+
timer
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/** Send a frame to every participant but one. One dead socket never blocks the rest. */
|
|
207
|
+
broadcast(frame, except) {
|
|
208
|
+
const text = render(frame);
|
|
209
|
+
for (const ws of this.sockets()) {
|
|
210
|
+
if (ws === except || !this.attachmentOf(ws)) continue;
|
|
211
|
+
try {
|
|
212
|
+
ws.send(text);
|
|
213
|
+
} catch {}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** The open sockets the runtime holds for this room. */
|
|
217
|
+
sockets() {
|
|
218
|
+
return this.ctx.getWebSockets().filter((ws) => ws.readyState === WebSocket.OPEN);
|
|
219
|
+
}
|
|
220
|
+
/** The attachments of sockets the runtime still holds but that are no longer open. */
|
|
221
|
+
leavingAttachments() {
|
|
222
|
+
return this.ctx.getWebSockets().flatMap((ws) => {
|
|
223
|
+
if (ws.readyState === WebSocket.OPEN) return [];
|
|
224
|
+
const attachment = this.attachmentOf(ws);
|
|
225
|
+
return attachment ? [attachment] : [];
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
/** Every participant's attachment, read back from the runtime. */
|
|
229
|
+
attachments() {
|
|
230
|
+
return this.sockets().flatMap((ws) => {
|
|
231
|
+
const attachment = this.attachmentOf(ws);
|
|
232
|
+
return attachment ? [attachment] : [];
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
attachmentOf(ws) {
|
|
236
|
+
const parsed = PresenceAttachment.safeParse(ws.deserializeAttachment());
|
|
237
|
+
return parsed.success ? parsed.data : void 0;
|
|
238
|
+
}
|
|
239
|
+
/** The admission the Worker forwarded. Missing or malformed is a request that did not come through the route. */
|
|
240
|
+
admission(request) {
|
|
241
|
+
const raw = request.headers.get(PRESENCE_ADMISSION_HEADER);
|
|
242
|
+
let json;
|
|
243
|
+
try {
|
|
244
|
+
json = raw === null ? null : JSON.parse(raw);
|
|
245
|
+
} catch {
|
|
246
|
+
json = null;
|
|
247
|
+
}
|
|
248
|
+
const parsed = PresenceAdmission.safeParse(json);
|
|
249
|
+
if (!parsed.success) throw new PresenceTicketInvalidError({ detail: "no admission on the forwarded upgrade" });
|
|
250
|
+
return parsed.data;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
function render(frame) {
|
|
254
|
+
return JSON.stringify(frame);
|
|
255
|
+
}
|
|
256
|
+
//#endregion
|
|
257
|
+
export { PresenceRoom };
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import type { SocketVersions } from "../http/subprotocol";
|
|
5
|
+
/**
|
|
6
|
+
* **Every frame on a presence room socket, as the Zod schema it is sent and parsed with.**
|
|
7
|
+
*
|
|
8
|
+
* Pure: zod and core's leaf modules. A browser imports it to type the socket, and the AsyncAPI document
|
|
9
|
+
* reads the same schemas through the declaring capability's `sockets`, so the document and the Durable
|
|
10
|
+
* Object cannot disagree.
|
|
11
|
+
*
|
|
12
|
+
* **How a client reads a frame, stated once** (`docs/SOCKETS.md`). A field added to a frame later is
|
|
13
|
+
* optional. A client ignores a `type` and a key it does not know. A change no client could ignore is a new
|
|
14
|
+
* subprotocol, and the Worker keeps serving the one before it.
|
|
15
|
+
*
|
|
16
|
+
* **The keepalive is plain text**, the one exception to JSON: a client sends `ping`, the Worker answers
|
|
17
|
+
* `pong`. The only JSON frame a client sends is `set_activity`.
|
|
18
|
+
*/
|
|
19
|
+
/** The subprotocols a room serves. There is no unversioned room: a ticket can only arrive in the offer. */
|
|
20
|
+
export declare const PRESENCE_ROOM_VERSIONS: SocketVersions;
|
|
21
|
+
/** Which door a participant came in by. */
|
|
22
|
+
export declare const PresenceOrigin: z.ZodEnum<{
|
|
23
|
+
"control-plane": "control-plane";
|
|
24
|
+
session: "session";
|
|
25
|
+
}>;
|
|
26
|
+
export type PresenceOrigin = z.infer<typeof PresenceOrigin>;
|
|
27
|
+
/** Who a participant is, as the minting door states it. */
|
|
28
|
+
export declare const PresenceParticipantRef: z.ZodString;
|
|
29
|
+
/** The longest participant name, in UTF-16 code units — what zod's `.max` counts. */
|
|
30
|
+
export declare const PRESENCE_PARTICIPANT_NAME_MAX = 80;
|
|
31
|
+
/** How the others see a participant. */
|
|
32
|
+
export declare const PresenceParticipantName: z.ZodString;
|
|
33
|
+
/** One participant in a room. */
|
|
34
|
+
export declare const PresenceParticipant: z.ZodObject<{
|
|
35
|
+
id: z.ZodString;
|
|
36
|
+
ref: z.ZodString;
|
|
37
|
+
name: z.ZodString;
|
|
38
|
+
origin: z.ZodEnum<{
|
|
39
|
+
"control-plane": "control-plane";
|
|
40
|
+
session: "session";
|
|
41
|
+
}>;
|
|
42
|
+
activity: z.ZodString;
|
|
43
|
+
joinedAt: z.ZodISODateTime;
|
|
44
|
+
}, z.core.$strip>;
|
|
45
|
+
export type PresenceParticipant = z.infer<typeof PresenceParticipant>;
|
|
46
|
+
/** The first frame on every room socket. */
|
|
47
|
+
export declare const PresenceRoomInitFrame: z.ZodObject<{
|
|
48
|
+
type: z.ZodLiteral<"init">;
|
|
49
|
+
self: z.ZodString;
|
|
50
|
+
participants: z.ZodArray<z.ZodObject<{
|
|
51
|
+
id: z.ZodString;
|
|
52
|
+
ref: z.ZodString;
|
|
53
|
+
name: z.ZodString;
|
|
54
|
+
origin: z.ZodEnum<{
|
|
55
|
+
"control-plane": "control-plane";
|
|
56
|
+
session: "session";
|
|
57
|
+
}>;
|
|
58
|
+
activity: z.ZodString;
|
|
59
|
+
joinedAt: z.ZodISODateTime;
|
|
60
|
+
}, z.core.$strip>>;
|
|
61
|
+
}, z.core.$strip>;
|
|
62
|
+
export type PresenceRoomInitFrame = z.infer<typeof PresenceRoomInitFrame>;
|
|
63
|
+
/** Someone joined. */
|
|
64
|
+
export declare const PresenceJoinedFrame: z.ZodObject<{
|
|
65
|
+
type: z.ZodLiteral<"joined">;
|
|
66
|
+
participant: z.ZodObject<{
|
|
67
|
+
id: z.ZodString;
|
|
68
|
+
ref: z.ZodString;
|
|
69
|
+
name: z.ZodString;
|
|
70
|
+
origin: z.ZodEnum<{
|
|
71
|
+
"control-plane": "control-plane";
|
|
72
|
+
session: "session";
|
|
73
|
+
}>;
|
|
74
|
+
activity: z.ZodString;
|
|
75
|
+
joinedAt: z.ZodISODateTime;
|
|
76
|
+
}, z.core.$strip>;
|
|
77
|
+
}, z.core.$strip>;
|
|
78
|
+
export type PresenceJoinedFrame = z.infer<typeof PresenceJoinedFrame>;
|
|
79
|
+
/** Someone left, or their socket dropped. */
|
|
80
|
+
export declare const PresenceLeftFrame: z.ZodObject<{
|
|
81
|
+
type: z.ZodLiteral<"left">;
|
|
82
|
+
participantId: z.ZodString;
|
|
83
|
+
}, z.core.$strip>;
|
|
84
|
+
export type PresenceLeftFrame = z.infer<typeof PresenceLeftFrame>;
|
|
85
|
+
/** Someone changed what they are doing. */
|
|
86
|
+
export declare const PresenceActivityFrame: z.ZodObject<{
|
|
87
|
+
type: z.ZodLiteral<"activity">;
|
|
88
|
+
participantId: z.ZodString;
|
|
89
|
+
activity: z.ZodString;
|
|
90
|
+
}, z.core.$strip>;
|
|
91
|
+
export type PresenceActivityFrame = z.infer<typeof PresenceActivityFrame>;
|
|
92
|
+
/** A frame the room could not accept. */
|
|
93
|
+
export declare const PresenceRoomErrorFrame: z.ZodObject<{
|
|
94
|
+
type: z.ZodLiteral<"error">;
|
|
95
|
+
error: z.ZodObject<{
|
|
96
|
+
code: z.ZodString;
|
|
97
|
+
status: z.ZodNumber;
|
|
98
|
+
message: z.ZodString;
|
|
99
|
+
params: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
100
|
+
issues: z.ZodOptional<z.ZodArray<z.ZodUnknown>>;
|
|
101
|
+
}, z.core.$strip>;
|
|
102
|
+
}, z.core.$strip>;
|
|
103
|
+
export type PresenceRoomErrorFrame = z.infer<typeof PresenceRoomErrorFrame>;
|
|
104
|
+
/** Every JSON frame the Worker sends on a room socket. `pong` is the plain-text exception. */
|
|
105
|
+
export declare const PresenceRoomServerFrame: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
106
|
+
type: z.ZodLiteral<"init">;
|
|
107
|
+
self: z.ZodString;
|
|
108
|
+
participants: z.ZodArray<z.ZodObject<{
|
|
109
|
+
id: z.ZodString;
|
|
110
|
+
ref: z.ZodString;
|
|
111
|
+
name: z.ZodString;
|
|
112
|
+
origin: z.ZodEnum<{
|
|
113
|
+
"control-plane": "control-plane";
|
|
114
|
+
session: "session";
|
|
115
|
+
}>;
|
|
116
|
+
activity: z.ZodString;
|
|
117
|
+
joinedAt: z.ZodISODateTime;
|
|
118
|
+
}, z.core.$strip>>;
|
|
119
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
120
|
+
type: z.ZodLiteral<"joined">;
|
|
121
|
+
participant: z.ZodObject<{
|
|
122
|
+
id: z.ZodString;
|
|
123
|
+
ref: z.ZodString;
|
|
124
|
+
name: z.ZodString;
|
|
125
|
+
origin: z.ZodEnum<{
|
|
126
|
+
"control-plane": "control-plane";
|
|
127
|
+
session: "session";
|
|
128
|
+
}>;
|
|
129
|
+
activity: z.ZodString;
|
|
130
|
+
joinedAt: z.ZodISODateTime;
|
|
131
|
+
}, z.core.$strip>;
|
|
132
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
133
|
+
type: z.ZodLiteral<"left">;
|
|
134
|
+
participantId: z.ZodString;
|
|
135
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
136
|
+
type: z.ZodLiteral<"activity">;
|
|
137
|
+
participantId: z.ZodString;
|
|
138
|
+
activity: z.ZodString;
|
|
139
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
140
|
+
type: z.ZodLiteral<"error">;
|
|
141
|
+
error: z.ZodObject<{
|
|
142
|
+
code: z.ZodString;
|
|
143
|
+
status: z.ZodNumber;
|
|
144
|
+
message: z.ZodString;
|
|
145
|
+
params: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
146
|
+
issues: z.ZodOptional<z.ZodArray<z.ZodUnknown>>;
|
|
147
|
+
}, z.core.$strip>;
|
|
148
|
+
}, z.core.$strip>], "type">;
|
|
149
|
+
export type PresenceRoomServerFrame = z.infer<typeof PresenceRoomServerFrame>;
|
|
150
|
+
/** Announce a different activity. */
|
|
151
|
+
export declare const PresenceSetActivityFrame: z.ZodObject<{
|
|
152
|
+
type: z.ZodLiteral<"set_activity">;
|
|
153
|
+
activity: z.ZodString;
|
|
154
|
+
}, z.core.$strip>;
|
|
155
|
+
export type PresenceSetActivityFrame = z.infer<typeof PresenceSetActivityFrame>;
|
|
156
|
+
/** Every JSON frame a client may send on a room socket. `ping` is the plain-text exception. */
|
|
157
|
+
export declare const PresenceRoomClientFrame: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
158
|
+
type: z.ZodLiteral<"set_activity">;
|
|
159
|
+
activity: z.ZodString;
|
|
160
|
+
}, z.core.$strip>], "type">;
|
|
161
|
+
export type PresenceRoomClientFrame = z.infer<typeof PresenceRoomClientFrame>;
|
|
162
|
+
/** The keepalive a client sends, as plain text. */
|
|
163
|
+
export declare const PresenceRoomPing: z.ZodLiteral<"ping">;
|
|
164
|
+
/** The Worker's answer to `ping`, as plain text. */
|
|
165
|
+
export declare const PresenceRoomPong: z.ZodLiteral<"pong">;
|
|
166
|
+
//# sourceMappingURL=frames.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"frames.d.ts","sourceRoot":"","sources":["../../src/presence/frames.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAG1D;;;;;;;;;;;;;GAaG;AAEH,2GAA2G;AAC3G,eAAO,MAAM,sBAAsB,EAAE,cAAiE,CAAC;AAEvG,2CAA2C;AAC3C,eAAO,MAAM,cAAc;;;EAIxB,CAAC;AACJ,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAE5D,2DAA2D;AAC3D,eAAO,MAAM,sBAAsB,aAMhC,CAAC;AAEJ,qFAAqF;AACrF,eAAO,MAAM,6BAA6B,KAAK,CAAC;AAEhD,wCAAwC;AACxC,eAAO,MAAM,uBAAuB,aAK6E,CAAC;AAElH,iCAAiC;AACjC,eAAO,MAAM,mBAAmB;;;;;;;;;;iBAYmC,CAAC;AACpE,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,4CAA4C;AAC5C,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;iBAQuC,CAAC;AAC1E,MAAM,MAAM,qBAAqB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAE1E,sBAAsB;AACtB,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;iBAKmB,CAAC;AACpD,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,6CAA6C;AAC7C,eAAO,MAAM,iBAAiB;;;iBAK4C,CAAC;AAC3E,MAAM,MAAM,iBAAiB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAElE,2CAA2C;AAC3C,eAAO,MAAM,qBAAqB;;;;iBAMgC,CAAC;AACnE,MAAM,MAAM,qBAAqB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAE1E,yCAAyC;AACzC,eAAO,MAAM,sBAAsB;;;;;;;;;iBAOhC,CAAC;AACJ,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAE5E,8FAA8F;AAC9F,eAAO,MAAM,uBAAuB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2BAQmC,CAAC;AACxE,MAAM,MAAM,uBAAuB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,uBAAuB,CAAC,CAAC;AAE9E,qCAAqC;AACrC,eAAO,MAAM,wBAAwB;;;iBAKsB,CAAC;AAC5D,MAAM,MAAM,wBAAwB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAEhF,+FAA+F;AAC/F,eAAO,MAAM,uBAAuB;;;2BAEiC,CAAC;AACtE,MAAM,MAAM,uBAAuB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,uBAAuB,CAAC,CAAC;AAE9E,mDAAmD;AACnD,eAAO,MAAM,gBAAgB,sBAAyE,CAAC;AAEvG,oDAAoD;AACpD,eAAO,MAAM,gBAAgB,sBAAiE,CAAC"}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { ErrorBody } from "../error/wire.js";
|
|
4
|
+
import { PresenceActivity } from "./names.js";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
//#region src/presence/frames.ts
|
|
7
|
+
/**
|
|
8
|
+
* **Every frame on a presence room socket, as the Zod schema it is sent and parsed with.**
|
|
9
|
+
*
|
|
10
|
+
* Pure: zod and core's leaf modules. A browser imports it to type the socket, and the AsyncAPI document
|
|
11
|
+
* reads the same schemas through the declaring capability's `sockets`, so the document and the Durable
|
|
12
|
+
* Object cannot disagree.
|
|
13
|
+
*
|
|
14
|
+
* **How a client reads a frame, stated once** (`docs/SOCKETS.md`). A field added to a frame later is
|
|
15
|
+
* optional. A client ignores a `type` and a key it does not know. A change no client could ignore is a new
|
|
16
|
+
* subprotocol, and the Worker keeps serving the one before it.
|
|
17
|
+
*
|
|
18
|
+
* **The keepalive is plain text**, the one exception to JSON: a client sends `ping`, the Worker answers
|
|
19
|
+
* `pong`. The only JSON frame a client sends is `set_activity`.
|
|
20
|
+
*/
|
|
21
|
+
/** The subprotocols a room serves. There is no unversioned room: a ticket can only arrive in the offer. */
|
|
22
|
+
const PRESENCE_ROOM_VERSIONS = {
|
|
23
|
+
current: "pithy.presence.v1",
|
|
24
|
+
previous: null
|
|
25
|
+
};
|
|
26
|
+
/** Which door a participant came in by. */
|
|
27
|
+
const PresenceOrigin = z.enum(["control-plane", "session"]).describe("Which door the participant came in by: `control-plane`, a management client such as the dashboard, or `session`, a signed-in user of the app.");
|
|
28
|
+
/** Who a participant is, as the minting door states it. */
|
|
29
|
+
const PresenceParticipantRef = z.string().min(1).max(128).describe("Who the participant is. A management client's own opaque reference, which it chose; or the signed-in user's id, which the Worker chose.");
|
|
30
|
+
/** The longest participant name, in UTF-16 code units — what zod's `.max` counts. */
|
|
31
|
+
const PRESENCE_PARTICIPANT_NAME_MAX = 80;
|
|
32
|
+
/** How the others see a participant. */
|
|
33
|
+
const PresenceParticipantName = z.string().trim().min(1).max(80).describe("How the other participants see this one. A label, never an identity — `ref` and `origin` are that.");
|
|
34
|
+
/** One participant in a room. */
|
|
35
|
+
const PresenceParticipant = z.object({
|
|
36
|
+
id: z.string().uuid().describe("This participant's id in the room — one per socket, so one person in two tabs is two participants."),
|
|
37
|
+
ref: PresenceParticipantRef,
|
|
38
|
+
name: PresenceParticipantName,
|
|
39
|
+
origin: PresenceOrigin,
|
|
40
|
+
activity: PresenceActivity.describe("What they are doing now, one of the room kind's declared activities."),
|
|
41
|
+
joinedAt: z.iso.datetime().describe("When this socket joined, ISO-8601.")
|
|
42
|
+
}).describe("One participant in a presence room: one open socket.");
|
|
43
|
+
/** The first frame on every room socket. */
|
|
44
|
+
const PresenceRoomInitFrame = z.object({
|
|
45
|
+
type: z.literal("init").describe("Always `init`."),
|
|
46
|
+
self: z.string().uuid().describe("This socket's own participant id, so a client can find itself in the list."),
|
|
47
|
+
participants: z.array(PresenceParticipant).describe("Everyone in the room now, this socket included, in the order they joined.")
|
|
48
|
+
}).describe("Who is in the room, sent once as soon as the socket opens.");
|
|
49
|
+
/** Someone joined. */
|
|
50
|
+
const PresenceJoinedFrame = z.object({
|
|
51
|
+
type: z.literal("joined").describe("Always `joined`."),
|
|
52
|
+
participant: PresenceParticipant.describe("Who joined.")
|
|
53
|
+
}).describe("Another participant joined the room.");
|
|
54
|
+
/** Someone left, or their socket dropped. */
|
|
55
|
+
const PresenceLeftFrame = z.object({
|
|
56
|
+
type: z.literal("left").describe("Always `left`."),
|
|
57
|
+
participantId: z.string().uuid().describe("The participant who left.")
|
|
58
|
+
}).describe("Another participant left the room, or their socket dropped.");
|
|
59
|
+
/** Someone changed what they are doing. */
|
|
60
|
+
const PresenceActivityFrame = z.object({
|
|
61
|
+
type: z.literal("activity").describe("Always `activity`."),
|
|
62
|
+
participantId: z.string().uuid().describe("The participant whose activity changed."),
|
|
63
|
+
activity: PresenceActivity.describe("What they are doing now.")
|
|
64
|
+
}).describe("Another participant announced a different activity.");
|
|
65
|
+
/** A frame the room could not accept. */
|
|
66
|
+
const PresenceRoomErrorFrame = z.object({
|
|
67
|
+
type: z.literal("error").describe("Always `error`."),
|
|
68
|
+
error: ErrorBody.shape.error
|
|
69
|
+
}).describe("The room could not accept a frame — not one it reads, an activity the room kind does not declare, or past the size limit. The error is the one an HTTP response would carry.");
|
|
70
|
+
/** Every JSON frame the Worker sends on a room socket. `pong` is the plain-text exception. */
|
|
71
|
+
const PresenceRoomServerFrame = z.discriminatedUnion("type", [
|
|
72
|
+
PresenceRoomInitFrame,
|
|
73
|
+
PresenceJoinedFrame,
|
|
74
|
+
PresenceLeftFrame,
|
|
75
|
+
PresenceActivityFrame,
|
|
76
|
+
PresenceRoomErrorFrame
|
|
77
|
+
]).describe("A JSON frame the Worker sends on a presence room socket.");
|
|
78
|
+
/** Announce a different activity. */
|
|
79
|
+
const PresenceSetActivityFrame = z.object({
|
|
80
|
+
type: z.literal("set_activity").describe("Always `set_activity`."),
|
|
81
|
+
activity: PresenceActivity.describe("What this participant is doing now. One of the room kind's declared values.")
|
|
82
|
+
}).describe("Announce what this participant is doing now.");
|
|
83
|
+
/** Every JSON frame a client may send on a room socket. `ping` is the plain-text exception. */
|
|
84
|
+
const PresenceRoomClientFrame = z.discriminatedUnion("type", [PresenceSetActivityFrame]).describe("A JSON frame a client sends on a presence room socket.");
|
|
85
|
+
/** The keepalive a client sends, as plain text. */
|
|
86
|
+
const PresenceRoomPing = z.literal("ping").describe("The plain-text keepalive a client sends.");
|
|
87
|
+
/** The Worker's answer to `ping`, as plain text. */
|
|
88
|
+
const PresenceRoomPong = z.literal("pong").describe("The plain-text answer to `ping`.");
|
|
89
|
+
//#endregion
|
|
90
|
+
export { PRESENCE_PARTICIPANT_NAME_MAX, PRESENCE_ROOM_VERSIONS, PresenceActivityFrame, PresenceJoinedFrame, PresenceLeftFrame, PresenceOrigin, PresenceParticipant, PresenceParticipantName, PresenceParticipantRef, PresenceRoomClientFrame, PresenceRoomErrorFrame, PresenceRoomInitFrame, PresenceRoomPing, PresenceRoomPong, PresenceRoomServerFrame, PresenceSetActivityFrame };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import type { MiddlewareHandler } from "hono";
|
|
4
|
+
import type { PithyHonoEnv } from "../../capability/capability";
|
|
5
|
+
/**
|
|
6
|
+
* Require a signed-in user of the app, over the `AuthContext` seam. Kept here rather than imported from
|
|
7
|
+
* `@pithy-sh/auth`, which core may not depend on — and without auth composed `c.var.auth` is null, so the
|
|
8
|
+
* session ticket route denies rather than stands open.
|
|
9
|
+
*/
|
|
10
|
+
export declare function requireAuth(): MiddlewareHandler<PithyHonoEnv>;
|
|
11
|
+
//# sourceMappingURL=guard.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"guard.d.ts","sourceRoot":"","sources":["../../../src/presence/http/guard.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAC9C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAGhE;;;;GAIG;AACH,wBAAgB,WAAW,IAAI,iBAAiB,CAAC,YAAY,CAAC,CAU7D"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { UnauthorizedError } from "../../error/pithyError.js";
|
|
4
|
+
//#region src/presence/http/guard.ts
|
|
5
|
+
/**
|
|
6
|
+
* Require a signed-in user of the app, over the `AuthContext` seam. Kept here rather than imported from
|
|
7
|
+
* `@pithy-sh/auth`, which core may not depend on — and without auth composed `c.var.auth` is null, so the
|
|
8
|
+
* session ticket route denies rather than stands open.
|
|
9
|
+
*/
|
|
10
|
+
function requireAuth() {
|
|
11
|
+
return async (c, next) => {
|
|
12
|
+
if (!c.var.auth) throw new UnauthorizedError({
|
|
13
|
+
message: "Authentication required.",
|
|
14
|
+
action: "Sign in and retry with a valid session or bearer token."
|
|
15
|
+
});
|
|
16
|
+
await next();
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
export { requireAuth };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
/**
|
|
5
|
+
* A minted presence ticket and where to spend it. Both ticket routes answer it.
|
|
6
|
+
*
|
|
7
|
+
* Read across a version boundary like every response schema, so a field added here later is `.optional()`.
|
|
8
|
+
*/
|
|
9
|
+
export declare const PresenceTicket: z.ZodObject<{
|
|
10
|
+
ticket: z.ZodString;
|
|
11
|
+
expiresAt: z.ZodISODateTime;
|
|
12
|
+
kind: z.ZodString;
|
|
13
|
+
recordId: z.ZodString;
|
|
14
|
+
socketPath: z.ZodString;
|
|
15
|
+
protocol: z.ZodLiteral<string>;
|
|
16
|
+
}, z.core.$strip>;
|
|
17
|
+
export type PresenceTicket = z.infer<typeof PresenceTicket>;
|
|
18
|
+
/**
|
|
19
|
+
* One room kind, as `GET /control-plane/manifest` reports it under the capability that declares it. Built
|
|
20
|
+
* from the capability's resolved `basePath`, so a client never assumes where a socket is.
|
|
21
|
+
*/
|
|
22
|
+
export declare const PresenceRoomDescriptor: z.ZodObject<{
|
|
23
|
+
kind: z.ZodString;
|
|
24
|
+
scope: z.ZodString;
|
|
25
|
+
activities: z.ZodArray<z.ZodString>;
|
|
26
|
+
maxParticipants: z.ZodNumber;
|
|
27
|
+
sessionTicketPath: z.ZodNullable<z.ZodString>;
|
|
28
|
+
socketPath: z.ZodString;
|
|
29
|
+
}, z.core.$strip>;
|
|
30
|
+
export type PresenceRoomDescriptor = z.infer<typeof PresenceRoomDescriptor>;
|
|
31
|
+
//# sourceMappingURL=responses.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"responses.d.ts","sourceRoot":"","sources":["../../../src/presence/http/responses.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAKxB;;;;GAIG;AACH,eAAO,MAAM,cAAc;;;;;;;iBAmBgF,CAAC;AAC5G,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAE5D;;;GAGG;AACH,eAAO,MAAM,sBAAsB;;;;;;;iBAsB4D,CAAC;AAChG,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import { ControlPlaneScope } from "../../controlPlane/scope/scope.js";
|
|
4
|
+
import { PresenceActivity, PresenceRecordId, PresenceRoomKindName } from "../names.js";
|
|
5
|
+
import { PRESENCE_ROOM_VERSIONS } from "../frames.js";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
//#region src/presence/http/responses.ts
|
|
8
|
+
/**
|
|
9
|
+
* A minted presence ticket and where to spend it. Both ticket routes answer it.
|
|
10
|
+
*
|
|
11
|
+
* Read across a version boundary like every response schema, so a field added here later is `.optional()`.
|
|
12
|
+
*/
|
|
13
|
+
const PresenceTicket = z.object({
|
|
14
|
+
ticket: z.string().min(1).describe("The ticket. Offer it in `Sec-WebSocket-Protocol` as `pithy.ticket.<ticket>`, beside `protocol`. Single use."),
|
|
15
|
+
expiresAt: z.iso.datetime().describe("When the ticket stops being honored, ISO-8601 — 60 seconds after minting."),
|
|
16
|
+
kind: PresenceRoomKindName.describe("The room kind the ticket admits to."),
|
|
17
|
+
recordId: PresenceRecordId.describe("The record the room is about."),
|
|
18
|
+
socketPath: z.string().min(1).describe("The path to open the socket at, on this Worker — this room's, already filled in."),
|
|
19
|
+
protocol: z.literal(PRESENCE_ROOM_VERSIONS.current).describe("The subprotocol to offer beside the ticket. The Worker answers with it.")
|
|
20
|
+
}).describe("A single-use, 60-second ticket into one presence room, and where to open the socket with it.");
|
|
21
|
+
/**
|
|
22
|
+
* One room kind, as `GET /control-plane/manifest` reports it under the capability that declares it. Built
|
|
23
|
+
* from the capability's resolved `basePath`, so a client never assumes where a socket is.
|
|
24
|
+
*/
|
|
25
|
+
const PresenceRoomDescriptor = z.object({
|
|
26
|
+
kind: PresenceRoomKindName.describe("The room kind — `support.thread`."),
|
|
27
|
+
scope: ControlPlaneScope.describe("The scope that reads the record, and so joins its room. A management client mints its ticket with exactly this scope."),
|
|
28
|
+
activities: z.array(PresenceActivity).min(1).describe("What a participant may announce. The first is what everyone starts as."),
|
|
29
|
+
maxParticipants: z.number().int().positive().describe("How many participants the room admits at once."),
|
|
30
|
+
sessionTicketPath: z.string().nullable().describe("Where a signed-in user of the app mints a ticket for this kind, or null when the kind admits management clients only."),
|
|
31
|
+
socketPath: z.string().min(1).describe("The socket route, with `:kind` and `:recordId` to fill in — the path as the Worker mounted it.")
|
|
32
|
+
}).describe("One presence room kind this capability serves, and how to join one of its rooms.");
|
|
33
|
+
//#endregion
|
|
34
|
+
export { PresenceRoomDescriptor, PresenceTicket };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
import type { Context, Hono } from "hono";
|
|
4
|
+
import type { PithyHonoEnv } from "../../capability/capability";
|
|
5
|
+
import { type PresenceDeclaration, type PresenceRoomKind } from "../declaration";
|
|
6
|
+
import { type PresenceTicketRequest } from "../ticket";
|
|
7
|
+
import { type PresenceRoomDescriptor, PresenceTicket } from "./responses";
|
|
8
|
+
/**
|
|
9
|
+
* A capability's presence routes, mounted by `createBackend` under the `basePath` it declared:
|
|
10
|
+
*
|
|
11
|
+
* POST {base}/presence/tickets → a ticket for a signed-in user (bearer | session)
|
|
12
|
+
* GET {base}/presence/rooms/:kind/:recordId → the room's socket (a ticket)
|
|
13
|
+
*
|
|
14
|
+
* The control-plane door is the seam's own, `POST /control-plane/presence/tickets`, because one management
|
|
15
|
+
* client reaches every capability's rooms through it; both doors answer the same {@link PresenceTicket}.
|
|
16
|
+
*/
|
|
17
|
+
/** Find a declared room kind, or the 404 an undeclared one is. */
|
|
18
|
+
export declare function roomKind(presence: PresenceDeclaration, kind: string): PresenceRoomKind;
|
|
19
|
+
/** Mint the ticket a door answers with. */
|
|
20
|
+
export declare function issuePresenceTicket(c: Context<PithyHonoEnv>, presence: PresenceDeclaration, request: PresenceTicketRequest): Promise<PresenceTicket>;
|
|
21
|
+
/** The room kinds a declaration serves, as the manifest reports them. */
|
|
22
|
+
export declare function describePresenceRooms(presence: PresenceDeclaration): PresenceRoomDescriptor[];
|
|
23
|
+
export declare function registerPresenceRoutes(app: Hono<PithyHonoEnv>, presence: PresenceDeclaration): void;
|
|
24
|
+
//# sourceMappingURL=routes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../../src/presence/http/routes.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAC1C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAOhE,OAAO,EAEL,KAAK,mBAAmB,EACxB,KAAK,gBAAgB,EAOtB,MAAM,gBAAgB,CAAC;AAExB,OAAO,EAAsB,KAAK,qBAAqB,EAAyC,MAAM,WAAW,CAAC;AAElH,OAAO,EAAE,KAAK,sBAAsB,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAG1E;;;;;;;;GAQG;AAEH,kEAAkE;AAClE,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,mBAAmB,EAAE,IAAI,EAAE,MAAM,GAAG,gBAAgB,CAItF;AAED,2CAA2C;AAC3C,wBAAsB,mBAAmB,CACvC,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,EACxB,QAAQ,EAAE,mBAAmB,EAC7B,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,cAAc,CAAC,CAWzB;AAED,yEAAyE;AACzE,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,mBAAmB,GAAG,sBAAsB,EAAE,CAS7F;AAED,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,IAAI,CAAC,YAAY,CAAC,EAAE,QAAQ,EAAE,mBAAmB,GAAG,IAAI,CA2EnG"}
|