solid-objects 0.14.6 → 0.14.7
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/CHANGELOG.md +16 -0
- package/README.md +4 -0
- package/dist/actor-runtime.d.ts +36 -0
- package/dist/actor-runtime.d.ts.map +1 -0
- package/dist/actor-runtime.js +2 -0
- package/dist/actor-runtime.js.map +1 -0
- package/dist/browser/index.js +1 -1
- package/dist/browser/index.js.map +1 -1
- package/dist/cloudflare/configuration.d.ts +32 -0
- package/dist/cloudflare/configuration.d.ts.map +1 -0
- package/dist/cloudflare/configuration.js +46 -0
- package/dist/cloudflare/configuration.js.map +1 -0
- package/dist/cloudflare/engine.d.ts +43 -0
- package/dist/cloudflare/engine.d.ts.map +1 -0
- package/dist/cloudflare/engine.js +921 -0
- package/dist/cloudflare/engine.js.map +1 -0
- package/dist/cloudflare/host.d.ts +10 -0
- package/dist/cloudflare/host.d.ts.map +1 -0
- package/dist/cloudflare/host.js +44 -0
- package/dist/cloudflare/host.js.map +1 -0
- package/dist/cloudflare/index.d.ts +9 -0
- package/dist/cloudflare/index.d.ts.map +1 -0
- package/dist/cloudflare/index.js +8 -0
- package/dist/cloudflare/index.js.map +1 -0
- package/dist/cloudflare/platform.d.ts +2 -0
- package/dist/cloudflare/platform.d.ts.map +1 -0
- package/dist/cloudflare/platform.js +4 -0
- package/dist/cloudflare/platform.js.map +1 -0
- package/dist/cloudflare/protocol.d.ts +58 -0
- package/dist/cloudflare/protocol.d.ts.map +1 -0
- package/dist/cloudflare/protocol.js +77 -0
- package/dist/cloudflare/protocol.js.map +1 -0
- package/dist/cloudflare/records.d.ts +71 -0
- package/dist/cloudflare/records.d.ts.map +1 -0
- package/dist/cloudflare/records.js +2 -0
- package/dist/cloudflare/records.js.map +1 -0
- package/dist/cloudflare/runtime.d.ts +83 -0
- package/dist/cloudflare/runtime.d.ts.map +1 -0
- package/dist/cloudflare/runtime.js +321 -0
- package/dist/cloudflare/runtime.js.map +1 -0
- package/dist/cloudflare/session.d.ts +12 -0
- package/dist/cloudflare/session.d.ts.map +1 -0
- package/dist/cloudflare/session.js +332 -0
- package/dist/cloudflare/session.js.map +1 -0
- package/dist/cloudflare/storage.d.ts +25 -0
- package/dist/cloudflare/storage.d.ts.map +1 -0
- package/dist/cloudflare/storage.js +195 -0
- package/dist/cloudflare/storage.js.map +1 -0
- package/dist/context.d.ts +5 -4
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +3 -0
- package/dist/context.js.map +1 -1
- package/dist/core.d.ts +8 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +4 -0
- package/dist/core.js.map +1 -0
- package/dist/default-runtime.d.ts +4 -4
- package/dist/default-runtime.d.ts.map +1 -1
- package/dist/default-runtime.js.map +1 -1
- package/dist/errors.d.ts +14 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +9 -0
- package/dist/errors.js.map +1 -1
- package/dist/realtime.d.ts +18 -2
- package/dist/realtime.d.ts.map +1 -1
- package/dist/realtime.js.map +1 -1
- package/dist/reference.d.ts +5 -5
- package/dist/reference.d.ts.map +1 -1
- package/dist/reference.js.map +1 -1
- package/dist/repository.d.ts +5 -1
- package/dist/repository.d.ts.map +1 -1
- package/dist/repository.js +16 -0
- package/dist/repository.js.map +1 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +18 -53
- package/dist/runtime.js.map +1 -1
- package/dist/turn.d.ts +34 -0
- package/dist/turn.d.ts.map +1 -0
- package/dist/turn.js +57 -0
- package/dist/turn.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/api.md +22 -0
- package/docs/architecture.md +5 -3
- package/docs/cloudflare.md +155 -0
- package/docs/parity.md +19 -0
- package/docs/support.md +12 -11
- package/examples/cloudflare/README.md +49 -0
- package/examples/cloudflare/environment.d.ts +14 -0
- package/examples/cloudflare/shopping-cart.ts +65 -0
- package/examples/cloudflare/worker.ts +87 -0
- package/examples/cloudflare/wrangler.jsonc +15 -0
- package/package.json +20 -5
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Cloudflare Durable Objects
|
|
2
|
+
|
|
3
|
+
Import actor definitions from `solid-objects/core` to share them between Node
|
|
4
|
+
and Workers. On Cloudflare, import `createRuntime`, `durableObjects`,
|
|
5
|
+
`createDurableObjectsHost`, and `createDurableObjectsSessionHost` from
|
|
6
|
+
`solid-objects/cloudflare`.
|
|
7
|
+
|
|
8
|
+
The [runnable example](../examples/cloudflare/README.md) includes both Durable
|
|
9
|
+
Object classes, Wrangler bindings, migrations, and a public shopping cart.
|
|
10
|
+
|
|
11
|
+
## Configure the hosts
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { createRuntime, durableObjects, withRuntime } from "solid-objects/cloudflare"
|
|
15
|
+
|
|
16
|
+
const runtime = createRuntime({
|
|
17
|
+
backend: durableObjects({ namespace: env.ACTORS, sessions: env.SESSIONS }),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const counter = runtime.ref(Counter, "counter-1")
|
|
21
|
+
const count = await counter.with({ authorizationContext: subject }).increment()
|
|
22
|
+
await withRuntime(
|
|
23
|
+
runtime,
|
|
24
|
+
() => Counter.ref("counter-1").with({ authorizationContext: subject }).count,
|
|
25
|
+
)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Define the actor host once with `createDurableObjectsHost({ actors, configure })`.
|
|
29
|
+
`configure(env)` returns a `CloudflareConfiguration` with the same backend
|
|
30
|
+
bindings, authorization policies, and an `effects` map of named handlers. A
|
|
31
|
+
handler receives the existing effect arguments and `EffectContext` with its
|
|
32
|
+
stable delivery ID. Definitions and configuration stay inside the deployment;
|
|
33
|
+
they are not transmitted through RPC.
|
|
34
|
+
|
|
35
|
+
Use `runtime.ref()` or request-scoped `withRuntime()` in Worker handlers.
|
|
36
|
+
`Actor.ref()` within actor operations and lifecycle callbacks retains its
|
|
37
|
+
runtime across awaits. The backend does not set a global default runtime.
|
|
38
|
+
|
|
39
|
+
The host supplies storage and scheduling, so there is no `install()` or
|
|
40
|
+
`run()` step. Configure both exported classes using `new_sqlite_classes` and
|
|
41
|
+
enable `nodejs_compat` for async context propagation. Library schema migrations
|
|
42
|
+
run synchronously on activation and use a separate migration ledger.
|
|
43
|
+
|
|
44
|
+
## Guarantees and recovery
|
|
45
|
+
|
|
46
|
+
An actor type plus its string-normalized ID routes to one Durable Object.
|
|
47
|
+
Operations and getters enter its ordered durable mailbox. Snapshots read one
|
|
48
|
+
committed state image without entering the mailbox. A retryable head blocks
|
|
49
|
+
later turns, and permanent failures pause that actor until operator recovery.
|
|
50
|
+
|
|
51
|
+
State, results, effects, outbound messages, reminders, and the next alarm commit
|
|
52
|
+
together. Actor code executes outside that short transaction. Destruction and
|
|
53
|
+
restart generations fence stale commits. State migrations and read-only
|
|
54
|
+
projection checks use the same implementation as the SQL runtime.
|
|
55
|
+
|
|
56
|
+
Delivery is at least once. An effect can reach its external service before the
|
|
57
|
+
acknowledgement is stored. Deduplicate using `EffectContext.id`. `sendTo()` stores
|
|
58
|
+
an outbound intent in the source object and deduplicates acceptance in the
|
|
59
|
+
destination. This is eventual delivery, not a cross-object transaction.
|
|
60
|
+
Actors cannot synchronously call or wait on other actors.
|
|
61
|
+
|
|
62
|
+
One alarm covers the next due mailbox turn, retry, reminder, outbox delivery,
|
|
63
|
+
subscription expiry, or retention deadline. Persistent retry state supplements
|
|
64
|
+
Cloudflare's finite automatic alarm retries. Idle objects have no recurring
|
|
65
|
+
polling loop. Completed records default to 30 days of retention; dead letters
|
|
66
|
+
remain available for inspection. Idempotency receipts expire with their messages,
|
|
67
|
+
so callers must not rely on deduplication after the retention window.
|
|
68
|
+
|
|
69
|
+
An invocation timeout after acceptance raises `SyncTimeout` with a recoverable
|
|
70
|
+
`messageReference`. If RPC fails before an acceptance response arrives,
|
|
71
|
+
`EnqueueOutcomeUnknown.details` contains `actorType`, `actorId`, and `requestId`:
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
const message = await runtime.lookupMessage({
|
|
75
|
+
...error.details,
|
|
76
|
+
authorizationContext: subject,
|
|
77
|
+
})
|
|
78
|
+
if (message) await message.wait({ authorizationContext: subject })
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
A missing lookup is not proof that an outstanding enqueue cannot still arrive.
|
|
82
|
+
Look up again, or retry the application operation with its original explicit
|
|
83
|
+
idempotency key. Lookup requires query authorization for `__lookupMessage__`;
|
|
84
|
+
an existing message also requires authorization for its original operation.
|
|
85
|
+
|
|
86
|
+
Use `runtime.actorAdministration({ actorType, actorId, authorizationContext })`
|
|
87
|
+
for `deadLetters()`, `retryDeadLetter(id)`, `reminders()`, and
|
|
88
|
+
`resumeReminder({ name, runAt })`. Inspection is bounded to 1,000 records per
|
|
89
|
+
call. These operations call `authorizeAdministration` with resource
|
|
90
|
+
`actor:` followed by the canonical JSON identity tuple.
|
|
91
|
+
|
|
92
|
+
## Realtime sessions
|
|
93
|
+
|
|
94
|
+
The application authenticates its HTTP upgrade before calling
|
|
95
|
+
`runtime.openWebSocket({ sessionId, expiresAt })`. Forward the returned response
|
|
96
|
+
to the browser. `sessionId` must be an opaque application session reference,
|
|
97
|
+
not a bearer credential or serialized user object. Never forward client-supplied
|
|
98
|
+
`X-Solid-Session-*` headers to the session namespace.
|
|
99
|
+
|
|
100
|
+
The session host's `resolveAuthorizationContext({ sessionId, environment })`
|
|
101
|
+
loads current authorization data and returns JSON, or `null` when access has
|
|
102
|
+
expired or been revoked. It runs when opening the connection, subscribing, and
|
|
103
|
+
delivering events. Actor policies still authorize subscriptions and each named
|
|
104
|
+
personalized payload. Authentication remains application-owned.
|
|
105
|
+
|
|
106
|
+
One hibernating WebSocket multiplexes up to 100 actors by default. Configure
|
|
107
|
+
`maxSubscriptions` on the session host to change that limit. The browser client,
|
|
108
|
+
version-1 envelopes, value versus invalidation projections, personalized
|
|
109
|
+
payloads, and revision fences use the existing protocol.
|
|
110
|
+
|
|
111
|
+
The handshake uses Fetch because WebSocket upgrades cannot travel through
|
|
112
|
+
ordinary RPC. Actor-to-session events use RPC. Durable registrations survive
|
|
113
|
+
hibernation; disconnect and expiry trigger registration cleanup. Reconnects
|
|
114
|
+
replay current committed projections, not every event missed while offline.
|
|
115
|
+
|
|
116
|
+
## Capability boundaries
|
|
117
|
+
|
|
118
|
+
| Surface | Cloudflare backend |
|
|
119
|
+
| ------------------------------------------------------------------ | -------------------------------------- |
|
|
120
|
+
| Actor operations, getters, snapshots, state migrations | Supported |
|
|
121
|
+
| Durable sends, results, retries, rejection, destruction | Supported |
|
|
122
|
+
| Effects, reminders, cross-actor `sendTo()` | Supported |
|
|
123
|
+
| Browser subscriptions and personalized payloads | Supported through session hosts |
|
|
124
|
+
| Actor-scoped dead letters and reminder administration | Supported |
|
|
125
|
+
| `commitAction`, shared application SQL transactions | Unsupported |
|
|
126
|
+
| Global repository, reconciliation, process controls, SQL dashboard | Unsupported |
|
|
127
|
+
| Process-local `runtime.realtime.connect()` / server `ref.live` | Unsupported; use browser subscriptions |
|
|
128
|
+
| SQL-to-Durable-Objects data migration | Not provided |
|
|
129
|
+
|
|
130
|
+
Unsupported runtime facilities raise `UnsupportedCapability`; staging a commit
|
|
131
|
+
action permanently fails that turn before any state or intent commits.
|
|
132
|
+
Application and effect code must run within Workers' APIs and execution limits.
|
|
133
|
+
Arbitrary JavaScript is cooperative; caller timeout does not preempt it.
|
|
134
|
+
Eviction may discard private fields, and does not guarantee `onDeactivate()`.
|
|
135
|
+
|
|
136
|
+
State defaults to a 1 MiB limit. Encoded records, including indexed copies of
|
|
137
|
+
fields, must also fit Cloudflare's 2 MB SQLite row limit. Oversized records raise
|
|
138
|
+
`PayloadTooLarge`; increasing a configured payload limit cannot bypass the
|
|
139
|
+
[platform limits](https://developers.cloudflare.com/durable-objects/platform/limits/).
|
|
140
|
+
|
|
141
|
+
Cloudflare hosting is JavaScript-specific. It does not change Ruby's SQL backend
|
|
142
|
+
or the guarantees of the existing SQL drivers.
|
|
143
|
+
|
|
144
|
+
## Validate and release
|
|
145
|
+
|
|
146
|
+
`pnpm run test:cloudflare` runs the Workers integration suite. `pnpm run check`
|
|
147
|
+
type-checks the separate Workers target alongside Node; `pnpm run check:cloudflare`
|
|
148
|
+
checks the example bundle and its imports. Keep backend schema changes additive.
|
|
149
|
+
Once an actor persists a newer state version, rollback to older actor code may
|
|
150
|
+
be unsafe; test that rollback against the stored version before deploying it.
|
|
151
|
+
|
|
152
|
+
Before calling this backend stable, run a deployed soak in a disposable
|
|
153
|
+
namespace covering alarms without traffic, eviction/restart recovery,
|
|
154
|
+
hibernation, and slow external effects. Local Workers tests do not establish
|
|
155
|
+
Cloudflare production failover behavior.
|
package/docs/parity.md
CHANGED
|
@@ -40,6 +40,10 @@ such boundary between a gem and its dependents.
|
|
|
40
40
|
|
|
41
41
|
## Runtime and correctness
|
|
42
42
|
|
|
43
|
+
SQL waiters read results and status from one statement. Ruby already checks
|
|
44
|
+
completion and returns the result from the same loaded message; no Ruby change
|
|
45
|
+
is needed for the JavaScript stale-result race fix.
|
|
46
|
+
|
|
43
47
|
| Capability | Status | TypeScript shape or remaining work |
|
|
44
48
|
| ------------------------------------------------------------------------------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
45
49
|
| Actor registry, durable identity, JSON state, and adjacent state migrations | Native | Ordinary classes, static actor types, inferred state, explicit migrations, and isolated runtime context across every actor-instance callback. |
|
|
@@ -180,6 +184,21 @@ and Rails to Node) ran in solid-objects-ruby#49; the one disagreement it
|
|
|
180
184
|
found (the optional `arguments` default) is fixed and pinned by the shared
|
|
181
185
|
fixture.
|
|
182
186
|
|
|
187
|
+
## JavaScript-only: Cloudflare hosting
|
|
188
|
+
|
|
189
|
+
The experimental `solid-objects/cloudflare` backend hosts each actor identity
|
|
190
|
+
in a SQLite-backed Durable Object. Portable actor definitions, turn evaluation,
|
|
191
|
+
state migrations, and authorization are shared with the SQL runtime. Alarms
|
|
192
|
+
drive durable mailbox/outbox recovery, and session Durable Objects host
|
|
193
|
+
hibernating browser subscriptions.
|
|
194
|
+
|
|
195
|
+
Ruby hosting on Cloudflare is **Not applicable**. This backend introduces no
|
|
196
|
+
change to Ruby's SQL behavior or roadmap. Its JavaScript support is **Partial**
|
|
197
|
+
until deployed failover and soak validation completes. Shared SQL transactions,
|
|
198
|
+
commit actions, fleet administration, reconciliation, and the SQL dashboard
|
|
199
|
+
are explicit unsupported capabilities. The [backend matrix](cloudflare.md#capability-boundaries)
|
|
200
|
+
records these boundaries separately from SQL-runtime parity.
|
|
201
|
+
|
|
183
202
|
## Rails-specific surfaces
|
|
184
203
|
|
|
185
204
|
Rails generators, Active Record models/controllers, Turbo rendering, and
|
package/docs/support.md
CHANGED
|
@@ -2,17 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## Runtime support
|
|
4
4
|
|
|
5
|
-
| Component
|
|
6
|
-
|
|
|
7
|
-
| Node.js
|
|
8
|
-
| TypeScript
|
|
9
|
-
| SQLite
|
|
10
|
-
| PostgreSQL
|
|
11
|
-
| MySQL
|
|
12
|
-
| Redis wake-up
|
|
13
|
-
| Browser client
|
|
14
|
-
| SQLite WASM
|
|
15
|
-
| Browser runtime
|
|
5
|
+
| Component | Supported or tested range |
|
|
6
|
+
| ------------------ | ------------------------------------------------------------------ |
|
|
7
|
+
| Node.js | 24.4.0 or newer; CI runs 24.4.0 and 24.15.0 |
|
|
8
|
+
| TypeScript | 5.9 or newer for TypeScript applications |
|
|
9
|
+
| SQLite | Node's built-in `node:sqlite` on the supported Node runtime |
|
|
10
|
+
| PostgreSQL | 14 or newer; CI runs 14 and 18 |
|
|
11
|
+
| MySQL | 8.0 or newer with InnoDB; CI runs 8.0 and 8.4 |
|
|
12
|
+
| Redis wake-up | Optional; CI runs Redis 7 |
|
|
13
|
+
| Browser client | Chromium through Playwright |
|
|
14
|
+
| SQLite WASM | `@sqlite.org/sqlite-wasm` 3.50 or newer; optional |
|
|
15
|
+
| Browser runtime | Chromium through Playwright; OPFS for persistent storage |
|
|
16
|
+
| Cloudflare runtime | Experimental; Workers integration tests and Wrangler bundle checks |
|
|
16
17
|
|
|
17
18
|
The package is ESM-only. PostgreSQL, MySQL, Redis, and SQLite WASM require
|
|
18
19
|
their optional peer dependency. The Node SQLite adapter has no driver
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Cloudflare shopping cart
|
|
2
|
+
|
|
3
|
+
This is an intentionally public shopping cart. Anyone can add or remove items
|
|
4
|
+
from `ShoppingCart("demo-cart")`; destruction and administration remain denied.
|
|
5
|
+
Replace the demo session resolver with your application's session lookup before
|
|
6
|
+
using this example for private data.
|
|
7
|
+
|
|
8
|
+
The example code is here:
|
|
9
|
+
|
|
10
|
+
- [`shopping-cart.ts`](./shopping-cart.ts) — actor state, cart operations, and realtime observables
|
|
11
|
+
- [`worker.ts`](./worker.ts) — Worker routes, authorization, and Durable Object hosts
|
|
12
|
+
- [`wrangler.jsonc`](./wrangler.jsonc) — SQLite-backed Durable Object bindings and migrations
|
|
13
|
+
- [`environment.d.ts`](./environment.d.ts) — generated Wrangler binding types
|
|
14
|
+
|
|
15
|
+
From the repository root:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
pnpm install
|
|
19
|
+
pnpm run build
|
|
20
|
+
pnpm run dev:cloudflare
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
In another terminal:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
curl http://localhost:8787/cart
|
|
27
|
+
curl -X POST http://localhost:8787/cart/items \
|
|
28
|
+
-H 'content-type: application/json' \
|
|
29
|
+
--data '{"sku":"book","name":"Solid Objects book","priceCents":2500,"quantity":1}'
|
|
30
|
+
curl -X POST http://localhost:8787/cart/remove \
|
|
31
|
+
-H 'content-type: application/json' \
|
|
32
|
+
--data '{"sku":"book"}'
|
|
33
|
+
curl -X POST http://localhost:8787/cart/clear-later
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The last call schedules the cart to clear five seconds later. Stop the local
|
|
37
|
+
server and start it again to verify persistence. Local state lives in
|
|
38
|
+
Wrangler's `.wrangler` directory. Each actor identity has its own SQLite-backed
|
|
39
|
+
Durable Object; browser connections use the separate `Sessions` class.
|
|
40
|
+
|
|
41
|
+
Connect the existing `SolidObjectsBrowserClient` to `/events` and subscribe to
|
|
42
|
+
`{ actorType: "ShoppingCart", actorId: "demo-cart" }`. The connection expires
|
|
43
|
+
after one hour. Reconnect and resubscribe to obtain the current committed
|
|
44
|
+
projection with `itemCount` and `totalCents`.
|
|
45
|
+
|
|
46
|
+
`pnpm run check:cloudflare` validates the production bundle without uploading it.
|
|
47
|
+
To deploy this example to your own account, run `fnox exec -- pnpm run deploy:cloudflare`.
|
|
48
|
+
Wrangler creates the two SQLite-backed namespaces using the configuration's
|
|
49
|
+
class migrations. No D1 database or external broker is required.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/* eslint-disable */
|
|
2
|
+
// Generated by Wrangler by running `wrangler types --config=examples/cloudflare/wrangler.jsonc --include-runtime=false examples/cloudflare/environment.d.ts` (hash: 30c3823992a5154da09c838c96a8f2fb)
|
|
3
|
+
interface __BaseEnv_Env {
|
|
4
|
+
ACTORS: DurableObjectNamespace<import("./worker").Actors>
|
|
5
|
+
SESSIONS: DurableObjectNamespace<import("./worker").Sessions>
|
|
6
|
+
}
|
|
7
|
+
declare namespace Cloudflare {
|
|
8
|
+
interface GlobalProps {
|
|
9
|
+
mainModule: typeof import("./worker")
|
|
10
|
+
durableNamespaces: "Actors" | "Sessions"
|
|
11
|
+
}
|
|
12
|
+
interface Env extends __BaseEnv_Env {}
|
|
13
|
+
}
|
|
14
|
+
interface Env extends __BaseEnv_Env {}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Actor, broadcastValue } from "solid-objects/core"
|
|
2
|
+
|
|
3
|
+
interface CartItem {
|
|
4
|
+
name: string
|
|
5
|
+
priceCents: number
|
|
6
|
+
quantity: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class ShoppingCart extends Actor {
|
|
10
|
+
static override readonly actorType = "ShoppingCart"
|
|
11
|
+
items: Record<string, CartItem> = {}
|
|
12
|
+
|
|
13
|
+
addItem(input: { sku: string; name: string; priceCents: number; quantity?: number }): number {
|
|
14
|
+
if (
|
|
15
|
+
!input.sku ||
|
|
16
|
+
!input.name ||
|
|
17
|
+
!Number.isSafeInteger(input.priceCents) ||
|
|
18
|
+
input.priceCents < 0
|
|
19
|
+
)
|
|
20
|
+
throw new TypeError("sku, name, and a non-negative integer priceCents are required")
|
|
21
|
+
const quantity = input.quantity ?? 1
|
|
22
|
+
if (!Number.isSafeInteger(quantity) || quantity <= 0)
|
|
23
|
+
throw new TypeError("quantity must be a positive safe integer")
|
|
24
|
+
const existing = this.items[input.sku]
|
|
25
|
+
const combinedQuantity = (existing?.quantity ?? 0) + quantity
|
|
26
|
+
if (!Number.isSafeInteger(combinedQuantity))
|
|
27
|
+
throw new TypeError("combined quantity must be a positive safe integer")
|
|
28
|
+
this.items[input.sku] = {
|
|
29
|
+
name: input.name,
|
|
30
|
+
priceCents: input.priceCents,
|
|
31
|
+
quantity: combinedQuantity,
|
|
32
|
+
}
|
|
33
|
+
return this.totalCents
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
removeItem(input: { sku: string }): void {
|
|
37
|
+
delete this.items[input.sku]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
clear(): void {
|
|
41
|
+
this.items = {}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
clearLater(): void {
|
|
45
|
+
this.schedule({ at: new Date(Date.now() + 5_000), key: "clear" }).clear!()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get itemCount(): number {
|
|
49
|
+
return Object.values(this.items).reduce((total, item) => total + item.quantity, 0)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get totalCents(): number {
|
|
53
|
+
return Object.values(this.items).reduce(
|
|
54
|
+
(total, item) => total + item.priceCents * item.quantity,
|
|
55
|
+
0,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
override observables() {
|
|
60
|
+
return {
|
|
61
|
+
itemCount: broadcastValue(this.itemCount),
|
|
62
|
+
totalCents: broadcastValue(this.totalCents),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createDurableObjectsHost,
|
|
3
|
+
createDurableObjectsSessionHost,
|
|
4
|
+
createRuntime,
|
|
5
|
+
durableObjects,
|
|
6
|
+
type CloudflareConfiguration,
|
|
7
|
+
} from "solid-objects/cloudflare"
|
|
8
|
+
import { ShoppingCart } from "./shopping-cart.js"
|
|
9
|
+
|
|
10
|
+
function backend(environment: Env) {
|
|
11
|
+
return durableObjects({ namespace: environment.ACTORS, sessions: environment.SESSIONS })
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function publicCart(input: {
|
|
15
|
+
actorType: string
|
|
16
|
+
actorId: string
|
|
17
|
+
authorizationContext: unknown
|
|
18
|
+
}): boolean {
|
|
19
|
+
return (
|
|
20
|
+
input.actorType === "ShoppingCart" &&
|
|
21
|
+
input.actorId === "demo-cart" &&
|
|
22
|
+
input.authorizationContext === "public-demo"
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class Actors extends createDurableObjectsHost<Env>({
|
|
27
|
+
actors: [ShoppingCart],
|
|
28
|
+
configure: (environment): CloudflareConfiguration => ({
|
|
29
|
+
backend: backend(environment),
|
|
30
|
+
authorizeMessage: publicCart,
|
|
31
|
+
authorizeQuery: publicCart,
|
|
32
|
+
authorizeSubscription: publicCart,
|
|
33
|
+
}),
|
|
34
|
+
}) {}
|
|
35
|
+
|
|
36
|
+
export class Sessions extends createDurableObjectsSessionHost<Env>({
|
|
37
|
+
backend,
|
|
38
|
+
resolveAuthorizationContext: ({ sessionId }) =>
|
|
39
|
+
sessionId === "demo-cart" ? "public-demo" : null,
|
|
40
|
+
}) {}
|
|
41
|
+
|
|
42
|
+
export default {
|
|
43
|
+
async fetch(request: Request, environment: Env): Promise<Response> {
|
|
44
|
+
const url = new URL(request.url)
|
|
45
|
+
const runtime = createRuntime({ backend: backend(environment) })
|
|
46
|
+
const cartReference = runtime.ref(ShoppingCart, "demo-cart")
|
|
47
|
+
const cart = cartReference.with({ authorizationContext: "public-demo" })
|
|
48
|
+
const isWebSocketRequest =
|
|
49
|
+
url.pathname === "/events" && request.headers.get("Upgrade") === "websocket"
|
|
50
|
+
const origin = request.headers.get("Origin")
|
|
51
|
+
if (isWebSocketRequest && origin !== null && origin !== url.origin)
|
|
52
|
+
return new Response("Forbidden", { status: 403 })
|
|
53
|
+
if (isWebSocketRequest)
|
|
54
|
+
return runtime.openWebSocket({
|
|
55
|
+
sessionId: "demo-cart",
|
|
56
|
+
expiresAt: new Date(Date.now() + 3_600_000),
|
|
57
|
+
})
|
|
58
|
+
if (request.method === "GET" && url.pathname === "/cart")
|
|
59
|
+
return Response.json(
|
|
60
|
+
await runtime.snapshot(cartReference, { authorizationContext: "public-demo" }),
|
|
61
|
+
)
|
|
62
|
+
if (request.method === "POST" && url.pathname === "/cart/items") {
|
|
63
|
+
const item = (await request.json()) as {
|
|
64
|
+
sku: string
|
|
65
|
+
name: string
|
|
66
|
+
priceCents: number
|
|
67
|
+
quantity?: number
|
|
68
|
+
}
|
|
69
|
+
return Response.json({ totalCents: await cart.addItem(item) })
|
|
70
|
+
}
|
|
71
|
+
if (request.method === "POST" && url.pathname === "/cart/remove") {
|
|
72
|
+
const item = (await request.json()) as { sku: string }
|
|
73
|
+
await cart.removeItem(item)
|
|
74
|
+
return new Response(null, { status: 204 })
|
|
75
|
+
}
|
|
76
|
+
if (request.method === "POST" && url.pathname === "/cart/clear-later") {
|
|
77
|
+
await cart.clearLater()
|
|
78
|
+
return new Response(null, { status: 202 })
|
|
79
|
+
}
|
|
80
|
+
return new Response(
|
|
81
|
+
"GET /cart; POST /cart/items; POST /cart/remove; POST /cart/clear-later; WebSocket /events",
|
|
82
|
+
{
|
|
83
|
+
status: url.pathname === "/" ? 200 : 404,
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
},
|
|
87
|
+
} satisfies ExportedHandler<Env>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../node_modules/wrangler/config-schema.json",
|
|
3
|
+
"name": "solid-objects-shopping-cart",
|
|
4
|
+
"main": "worker.ts",
|
|
5
|
+
"compatibility_date": "2026-09-04",
|
|
6
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
7
|
+
"durable_objects": {
|
|
8
|
+
"bindings": [
|
|
9
|
+
{ "name": "ACTORS", "class_name": "Actors" },
|
|
10
|
+
{ "name": "SESSIONS", "class_name": "Sessions" },
|
|
11
|
+
],
|
|
12
|
+
},
|
|
13
|
+
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Actors", "Sessions"] }],
|
|
14
|
+
"observability": { "enabled": true },
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "solid-objects",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.7",
|
|
4
4
|
"description": "Race-free realtime state per application identity, backed by your SQL database",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,6 +38,14 @@
|
|
|
38
38
|
"MIT-LICENSE"
|
|
39
39
|
],
|
|
40
40
|
"exports": {
|
|
41
|
+
"./core": {
|
|
42
|
+
"types": "./dist/core.d.ts",
|
|
43
|
+
"import": "./dist/core.js"
|
|
44
|
+
},
|
|
45
|
+
"./cloudflare": {
|
|
46
|
+
"types": "./dist/cloudflare/index.d.ts",
|
|
47
|
+
"import": "./dist/cloudflare/index.js"
|
|
48
|
+
},
|
|
41
49
|
".": {
|
|
42
50
|
"types": "./dist/index.d.ts",
|
|
43
51
|
"import": "./dist/index.js"
|
|
@@ -92,8 +100,12 @@
|
|
|
92
100
|
}
|
|
93
101
|
},
|
|
94
102
|
"scripts": {
|
|
95
|
-
"
|
|
96
|
-
"
|
|
103
|
+
"test:cloudflare": "vitest run --config vitest.cloudflare.config.ts",
|
|
104
|
+
"dev:cloudflare": "wrangler dev --config examples/cloudflare/wrangler.jsonc",
|
|
105
|
+
"deploy:cloudflare": "wrangler deploy --config examples/cloudflare/wrangler.jsonc",
|
|
106
|
+
"check:cloudflare": "node scripts/check-cloudflare-imports.mjs && tsc -p tsconfig.cloudflare-example.json --noEmit && wrangler deploy --dry-run --config examples/cloudflare/wrangler.jsonc",
|
|
107
|
+
"build": "pnpm run clean && tsc -p tsconfig.build.json && tsc -p tsconfig.cloudflare-build.json && tsc -p tsconfig.quickstart-build.json && node scripts/prepare-executable.mjs",
|
|
108
|
+
"check": "pnpm run check:parameters && pnpm run check:documentation && pnpm run check:browser-imports && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.examples.json --noEmit && tsc -p tsconfig.cloudflare.json --noEmit",
|
|
97
109
|
"check:browser-imports": "node scripts/check-browser-imports.mjs",
|
|
98
110
|
"check:documentation": "node scripts/check-documentation.mjs",
|
|
99
111
|
"check:parameters": "node scripts/check-parameter-style.mjs",
|
|
@@ -117,6 +129,8 @@
|
|
|
117
129
|
"prepack": "pnpm run build"
|
|
118
130
|
},
|
|
119
131
|
"devDependencies": {
|
|
132
|
+
"@cloudflare/vitest-plugin": "^1.1.4",
|
|
133
|
+
"@cloudflare/workers-types": "^5.20260904.1",
|
|
120
134
|
"@playwright/test": "^1.62.1",
|
|
121
135
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
122
136
|
"@types/node": "^24.0.0",
|
|
@@ -130,14 +144,15 @@
|
|
|
130
144
|
"signal-polyfill": "^0.2.2",
|
|
131
145
|
"typescript": "^5.9.0",
|
|
132
146
|
"vitest": "^4.1.10",
|
|
147
|
+
"wrangler": "^4.129.0",
|
|
133
148
|
"ws": "^8.21.3"
|
|
134
149
|
},
|
|
135
150
|
"peerDependencies": {
|
|
136
151
|
"@sqlite.org/sqlite-wasm": ">=3.50.0-build1",
|
|
137
|
-
"signal-polyfill": ">=0.2.2",
|
|
138
152
|
"mysql2": "^3.23.3",
|
|
139
153
|
"pg": "^8.23.0",
|
|
140
|
-
"redis": "^6.2.1"
|
|
154
|
+
"redis": "^6.2.1",
|
|
155
|
+
"signal-polyfill": ">=0.2.2"
|
|
141
156
|
},
|
|
142
157
|
"peerDependenciesMeta": {
|
|
143
158
|
"@sqlite.org/sqlite-wasm": {
|