@livestore/sync-cf 0.4.0-dev.9 → 0.5.0-dev.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 +7 -8
- package/dist/.tsbuildinfo +1 -1
- package/dist/cf-worker/do/durable-object.d.ts +1 -1
- package/dist/cf-worker/do/durable-object.d.ts.map +1 -1
- package/dist/cf-worker/do/durable-object.js +36 -18
- package/dist/cf-worker/do/durable-object.js.map +1 -1
- package/dist/cf-worker/do/layer.d.ts +24 -20
- package/dist/cf-worker/do/layer.d.ts.map +1 -1
- package/dist/cf-worker/do/layer.js +57 -24
- package/dist/cf-worker/do/layer.js.map +1 -1
- package/dist/cf-worker/do/pull.d.ts +8 -3
- package/dist/cf-worker/do/pull.d.ts.map +1 -1
- package/dist/cf-worker/do/pull.js +26 -21
- package/dist/cf-worker/do/pull.js.map +1 -1
- package/dist/cf-worker/do/push.d.ts +20 -6
- package/dist/cf-worker/do/push.d.ts.map +1 -1
- package/dist/cf-worker/do/push.js +84 -56
- package/dist/cf-worker/do/push.js.map +1 -1
- package/dist/cf-worker/do/push.test.d.ts +2 -0
- package/dist/cf-worker/do/push.test.d.ts.map +1 -0
- package/dist/cf-worker/do/push.test.js +30 -0
- package/dist/cf-worker/do/push.test.js.map +1 -0
- package/dist/cf-worker/do/sqlite.d.ts +49 -52
- package/dist/cf-worker/do/sqlite.d.ts.map +1 -1
- package/dist/cf-worker/do/sqlite.js +14 -5
- package/dist/cf-worker/do/sqlite.js.map +1 -1
- package/dist/cf-worker/do/sync-storage.d.ts +15 -9
- package/dist/cf-worker/do/sync-storage.d.ts.map +1 -1
- package/dist/cf-worker/do/sync-storage.js +111 -25
- package/dist/cf-worker/do/sync-storage.js.map +1 -1
- package/dist/cf-worker/do/transport/do-rpc-server.d.ts +2 -2
- package/dist/cf-worker/do/transport/do-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/do-rpc-server.js +31 -23
- package/dist/cf-worker/do/transport/do-rpc-server.js.map +1 -1
- package/dist/cf-worker/do/transport/http-rpc-server.d.ts +5 -3
- package/dist/cf-worker/do/transport/http-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/http-rpc-server.js +28 -19
- package/dist/cf-worker/do/transport/http-rpc-server.js.map +1 -1
- package/dist/cf-worker/do/transport/ws-rpc-server.d.ts +3 -2
- package/dist/cf-worker/do/transport/ws-rpc-server.d.ts.map +1 -1
- package/dist/cf-worker/do/transport/ws-rpc-server.js +32 -10
- package/dist/cf-worker/do/transport/ws-rpc-server.js.map +1 -1
- package/dist/cf-worker/shared.d.ts +124 -34
- package/dist/cf-worker/shared.d.ts.map +1 -1
- package/dist/cf-worker/shared.js +52 -17
- package/dist/cf-worker/shared.js.map +1 -1
- package/dist/cf-worker/worker.d.ts +47 -39
- package/dist/cf-worker/worker.d.ts.map +1 -1
- package/dist/cf-worker/worker.js +54 -35
- package/dist/cf-worker/worker.js.map +1 -1
- package/dist/client/transport/do-rpc-client.d.ts +13 -4
- package/dist/client/transport/do-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/do-rpc-client.js +74 -28
- package/dist/client/transport/do-rpc-client.js.map +1 -1
- package/dist/client/transport/http-rpc-client.d.ts +3 -3
- package/dist/client/transport/http-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/http-rpc-client.js +51 -22
- package/dist/client/transport/http-rpc-client.js.map +1 -1
- package/dist/client/transport/ws-rpc-client.d.ts +4 -3
- package/dist/client/transport/ws-rpc-client.d.ts.map +1 -1
- package/dist/client/transport/ws-rpc-client.js +40 -22
- package/dist/client/transport/ws-rpc-client.js.map +1 -1
- package/dist/common/constants.d.ts +7 -0
- package/dist/common/constants.d.ts.map +1 -0
- package/dist/common/constants.js +17 -0
- package/dist/common/constants.js.map +1 -0
- package/dist/common/do-rpc-schema.d.ts +42 -43
- package/dist/common/do-rpc-schema.d.ts.map +1 -1
- package/dist/common/do-rpc-schema.js +5 -5
- package/dist/common/do-rpc-schema.js.map +1 -1
- package/dist/common/http-rpc-schema.d.ts +37 -38
- package/dist/common/http-rpc-schema.d.ts.map +1 -1
- package/dist/common/http-rpc-schema.js +7 -7
- package/dist/common/http-rpc-schema.js.map +1 -1
- package/dist/common/mod.d.ts +5 -3
- package/dist/common/mod.d.ts.map +1 -1
- package/dist/common/mod.js +4 -2
- package/dist/common/mod.js.map +1 -1
- package/dist/common/sync-message-types.d.ts +113 -130
- package/dist/common/sync-message-types.d.ts.map +1 -1
- package/dist/common/sync-message-types.js +29 -17
- package/dist/common/sync-message-types.js.map +1 -1
- package/dist/common/ws-rpc-schema.d.ts +34 -35
- package/dist/common/ws-rpc-schema.d.ts.map +1 -1
- package/dist/common/ws-rpc-schema.js +5 -5
- package/dist/common/ws-rpc-schema.js.map +1 -1
- package/package.json +57 -15
- package/src/cf-worker/do/durable-object.ts +59 -41
- package/src/cf-worker/do/layer.ts +153 -88
- package/src/cf-worker/do/pull.ts +48 -31
- package/src/cf-worker/do/push.test.ts +40 -0
- package/src/cf-worker/do/push.ts +163 -125
- package/src/cf-worker/do/sqlite.ts +15 -5
- package/src/cf-worker/do/sync-storage.ts +171 -38
- package/src/cf-worker/do/transport/do-rpc-server.ts +40 -40
- package/src/cf-worker/do/transport/http-rpc-server.ts +37 -20
- package/src/cf-worker/do/transport/ws-rpc-server.ts +44 -16
- package/src/cf-worker/shared.ts +152 -36
- package/src/cf-worker/worker.ts +110 -58
- package/src/client/transport/do-rpc-client.ts +106 -40
- package/src/client/transport/http-rpc-client.ts +74 -38
- package/src/client/transport/ws-rpc-client.ts +55 -29
- package/src/common/constants.ts +18 -0
- package/src/common/do-rpc-schema.ts +6 -5
- package/src/common/http-rpc-schema.ts +8 -7
- package/src/common/mod.ts +4 -3
- package/src/common/sync-message-types.ts +19 -19
- package/src/common/ws-rpc-schema.ts +6 -5
- package/dist/cf-worker/do/ws-chunking.d.ts +0 -22
- package/dist/cf-worker/do/ws-chunking.d.ts.map +0 -1
- package/dist/cf-worker/do/ws-chunking.js +0 -49
- package/dist/cf-worker/do/ws-chunking.js.map +0 -1
- package/src/cf-worker/do/ws-chunking.ts +0 -76
package/src/cf-worker/shared.ts
CHANGED
|
@@ -1,27 +1,81 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { UnknownError } from '@livestore/common'
|
|
2
2
|
import type { CfTypes } from '@livestore/common-cf'
|
|
3
|
-
import { Effect
|
|
3
|
+
import type { Effect } from '@livestore/utils/effect'
|
|
4
|
+
import { Result, Schema } from '@livestore/utils/effect'
|
|
5
|
+
|
|
4
6
|
import type { SearchParams } from '../common/mod.ts'
|
|
5
7
|
import { SearchParamsSchema, SyncMessage } from '../common/mod.ts'
|
|
6
8
|
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
export type Env = {}
|
|
10
|
+
|
|
11
|
+
/** Headers forwarded from the request to callbacks */
|
|
12
|
+
export type ForwardedHeaders = ReadonlyMap<string, string>
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Configuration for forwarding request headers to DO callbacks.
|
|
16
|
+
* - `string[]`: List of header names to forward (case-insensitive)
|
|
17
|
+
* - `(request) => Record<string, string>`: Custom extraction function (sync)
|
|
18
|
+
*/
|
|
19
|
+
export type ForwardHeadersOption = readonly string[] | ((request: CfTypes.Request) => Record<string, string>)
|
|
20
|
+
|
|
21
|
+
/** Context passed to onPush/onPull callbacks */
|
|
22
|
+
export type CallbackContext = {
|
|
23
|
+
storeId: StoreId
|
|
24
|
+
payload?: Schema.Json
|
|
25
|
+
/** Headers forwarded from the request (only present if `forwardHeaders` is configured) */
|
|
26
|
+
headers?: ForwardedHeaders
|
|
11
27
|
}
|
|
12
28
|
|
|
13
29
|
export type MakeDurableObjectClassOptions = {
|
|
14
|
-
onPush?: (
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
) => Effect.SyncOrPromiseOrEffect<void>
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
30
|
+
onPush?: (message: SyncMessage.PushRequest, context: CallbackContext) => Effect.SyncOrPromiseOrEffect<void>
|
|
31
|
+
onPushRes?: (message: SyncMessage.PushAck | UnknownError) => Effect.SyncOrPromiseOrEffect<void>
|
|
32
|
+
onPull?: (message: SyncMessage.PullRequest, context: CallbackContext) => Effect.SyncOrPromiseOrEffect<void>
|
|
33
|
+
onPullRes?: (message: SyncMessage.PullResponse | UnknownError) => Effect.SyncOrPromiseOrEffect<void>
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Forward request headers to `onPush`/`onPull` callbacks for authentication.
|
|
37
|
+
*
|
|
38
|
+
* This enables cookie-based or header-based authentication patterns where
|
|
39
|
+
* you need access to request headers inside the Durable Object.
|
|
40
|
+
*
|
|
41
|
+
* @example Forward specific headers by name (case-insensitive)
|
|
42
|
+
* ```ts
|
|
43
|
+
* makeDurableObject({
|
|
44
|
+
* forwardHeaders: ['cookie', 'authorization'],
|
|
45
|
+
* onPush: async (message, { headers }) => {
|
|
46
|
+
* const cookie = headers?.get('cookie')
|
|
47
|
+
* const session = await validateSession(cookie)
|
|
48
|
+
* },
|
|
49
|
+
* })
|
|
50
|
+
* ```
|
|
51
|
+
*
|
|
52
|
+
* @example Custom extraction function for derived values
|
|
53
|
+
* ```ts
|
|
54
|
+
* makeDurableObject({
|
|
55
|
+
* forwardHeaders: (request) => ({
|
|
56
|
+
* 'x-user-id': request.headers.get('x-user-id') ?? '',
|
|
57
|
+
* 'x-session': request.headers.get('cookie')?.split('session=')[1]?.split(';')[0] ?? '',
|
|
58
|
+
* }),
|
|
59
|
+
* onPush: async (message, { headers }) => {
|
|
60
|
+
* const userId = headers?.get('x-user-id')
|
|
61
|
+
* },
|
|
62
|
+
* })
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
forwardHeaders?: ForwardHeadersOption
|
|
66
|
+
/**
|
|
67
|
+
* Storage engine for event persistence.
|
|
68
|
+
* - Default: `{ _tag: 'do-sqlite' }` (Durable Object SQLite)
|
|
69
|
+
* - D1: `{ _tag: 'd1', binding: string }` where `binding` is the D1 binding name in wrangler.toml.
|
|
70
|
+
*
|
|
71
|
+
* If omitted, the runtime defaults to DO SQLite. For backwards-compatibility, if an env binding named
|
|
72
|
+
* `DB` exists and looks like a D1Database, D1 will be used.
|
|
73
|
+
*
|
|
74
|
+
* Trade-offs:
|
|
75
|
+
* - DO SQLite: simpler deploy, data co-located with DO, not externally queryable
|
|
76
|
+
* - D1: centralized DB, inspectable with DB tools, extra network hop and JSON size limits
|
|
77
|
+
*/
|
|
78
|
+
storage?: { _tag: 'do-sqlite' } | { _tag: 'd1'; binding: string }
|
|
25
79
|
|
|
26
80
|
/**
|
|
27
81
|
* Enabled transports for sync backend
|
|
@@ -33,6 +87,26 @@ export type MakeDurableObjectClassOptions = {
|
|
|
33
87
|
*/
|
|
34
88
|
enabledTransports?: Set<'http' | 'ws' | 'do-rpc'>
|
|
35
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Custom HTTP response headers for HTTP transport
|
|
92
|
+
* These headers will be added to all HTTP RPC responses (Pull, Push, Ping)
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```ts
|
|
96
|
+
* {
|
|
97
|
+
* http: {
|
|
98
|
+
* responseHeaders: {
|
|
99
|
+
* 'Access-Control-Allow-Origin': '*',
|
|
100
|
+
* 'Cache-Control': 'no-cache'
|
|
101
|
+
* }
|
|
102
|
+
* }
|
|
103
|
+
* }
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
http?: {
|
|
107
|
+
responseHeaders?: Record<string, string>
|
|
108
|
+
}
|
|
109
|
+
|
|
36
110
|
otel?: {
|
|
37
111
|
baseUrl?: string
|
|
38
112
|
serviceName?: string
|
|
@@ -43,14 +117,27 @@ export type StoreId = string
|
|
|
43
117
|
export type DurableObjectId = string
|
|
44
118
|
|
|
45
119
|
/**
|
|
46
|
-
*
|
|
120
|
+
* CRITICAL: Increment this version whenever you modify the database schema structure.
|
|
121
|
+
*
|
|
122
|
+
* Bump required when:
|
|
123
|
+
* - Adding/removing/renaming columns in eventlogTable or contextTable (see sqlite.ts)
|
|
124
|
+
* - Changing column types or constraints
|
|
125
|
+
* - Modifying primary keys or indexes
|
|
47
126
|
*
|
|
48
|
-
*
|
|
127
|
+
* Bump NOT required when:
|
|
128
|
+
* - Changing query patterns, pagination logic, or streaming behavior
|
|
129
|
+
* - Adding new tables (as long as existing table schemas remain unchanged)
|
|
130
|
+
* - Updating implementation details in sync-storage.ts
|
|
131
|
+
*
|
|
132
|
+
* Impact: Changing this version triggers a "soft reset" - new table names are created
|
|
133
|
+
* and old data becomes inaccessible (but remains in storage).
|
|
49
134
|
*/
|
|
50
135
|
export const PERSISTENCE_FORMAT_VERSION = 7
|
|
51
136
|
|
|
52
|
-
export const encodeOutgoingMessage = Schema.encodeSync(Schema.
|
|
53
|
-
export const encodeIncomingMessage = Schema.encodeSync(Schema.
|
|
137
|
+
export const encodeOutgoingMessage = Schema.encodeSync(Schema.fromJsonString(SyncMessage.BackendToClientMessage))
|
|
138
|
+
export const encodeIncomingMessage = Schema.encodeSync(Schema.fromJsonString(SyncMessage.ClientToBackendMessage))
|
|
139
|
+
|
|
140
|
+
const SearchParamsFromUrlSearchParams = Schema.fromURLSearchParams(SearchParamsSchema)
|
|
54
141
|
|
|
55
142
|
/**
|
|
56
143
|
* Extracts the LiveStore sync search parameters from a request. Returns
|
|
@@ -59,27 +146,19 @@ export const encodeIncomingMessage = Schema.encodeSync(Schema.parseJson(SyncMess
|
|
|
59
146
|
*/
|
|
60
147
|
export const matchSyncRequest = (request: CfTypes.Request): SearchParams | undefined => {
|
|
61
148
|
const url = new URL(request.url)
|
|
62
|
-
const
|
|
63
|
-
const paramsResult = UrlParams.schemaStruct(SearchParamsSchema)(urlParams).pipe(Effect.option, Effect.runSync)
|
|
149
|
+
const paramsResult = Schema.decodeUnknownResult(SearchParamsFromUrlSearchParams)(url.searchParams)
|
|
64
150
|
|
|
65
|
-
if (paramsResult
|
|
151
|
+
if (Result.isFailure(paramsResult) === true) {
|
|
66
152
|
return undefined
|
|
67
153
|
}
|
|
68
154
|
|
|
69
|
-
return paramsResult.
|
|
155
|
+
return paramsResult.success
|
|
70
156
|
}
|
|
71
157
|
|
|
72
|
-
export const MAX_PULL_EVENTS_PER_MESSAGE = 100
|
|
73
|
-
|
|
74
|
-
// Cloudflare hibernated WebSocket frames begin failing just below 1MB. Keep our
|
|
75
|
-
// payloads comfortably beneath that ceiling so we don't rely on implementation
|
|
76
|
-
// quirks of local dev servers.
|
|
77
|
-
export const MAX_WS_MESSAGE_BYTES = 900_000
|
|
78
|
-
|
|
79
158
|
// RPC subscription storage (TODO refactor)
|
|
80
159
|
export type RpcSubscription = {
|
|
81
160
|
storeId: StoreId
|
|
82
|
-
payload?: Schema.
|
|
161
|
+
payload?: Schema.Json
|
|
83
162
|
subscribedAt: number
|
|
84
163
|
/** Effect RPC request ID */
|
|
85
164
|
requestId: string
|
|
@@ -89,6 +168,9 @@ export type RpcSubscription = {
|
|
|
89
168
|
}
|
|
90
169
|
}
|
|
91
170
|
|
|
171
|
+
/** Key prefix for DO-RPC subscriptions persisted in the DO's synchronous KV storage. */
|
|
172
|
+
export const rpcSubscriptionKeyPrefix = 'rpc-sub:'
|
|
173
|
+
|
|
92
174
|
/**
|
|
93
175
|
* Durable Object interface supporting the DO RPC protocol for DO <> DO syncing.
|
|
94
176
|
*/
|
|
@@ -97,12 +179,46 @@ export interface SyncBackendRpcInterface {
|
|
|
97
179
|
rpc(payload: Uint8Array): Promise<Uint8Array | CfTypes.ReadableStream>
|
|
98
180
|
}
|
|
99
181
|
|
|
100
|
-
export const WebSocketAttachmentSchema = Schema.
|
|
182
|
+
export const WebSocketAttachmentSchema = Schema.fromJsonString(
|
|
101
183
|
Schema.Struct({
|
|
102
184
|
// Same across all websocket connections
|
|
103
185
|
storeId: Schema.String,
|
|
104
186
|
// Different for each websocket connection
|
|
105
|
-
payload: Schema.optional(Schema.
|
|
106
|
-
pullRequestIds: Schema.Array(Schema.String),
|
|
187
|
+
payload: Schema.optional(Schema.Json),
|
|
188
|
+
pullRequestIds: Schema.Array(Schema.Union([Schema.String, Schema.Finite])),
|
|
189
|
+
// Headers forwarded from the initial request (via forwardHeaders option)
|
|
190
|
+
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
|
107
191
|
}),
|
|
108
192
|
)
|
|
193
|
+
|
|
194
|
+
/** Helper to extract headers from a request based on the forwardHeaders option */
|
|
195
|
+
export const extractForwardedHeaders = (
|
|
196
|
+
request: CfTypes.Request,
|
|
197
|
+
forwardHeaders: ForwardHeadersOption | undefined,
|
|
198
|
+
): Record<string, string> | undefined => {
|
|
199
|
+
if (forwardHeaders === undefined) {
|
|
200
|
+
return undefined
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (typeof forwardHeaders === 'function') {
|
|
204
|
+
return forwardHeaders(request)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Array of header names - extract them case-insensitively
|
|
208
|
+
const result: Record<string, string> = {}
|
|
209
|
+
for (const name of forwardHeaders) {
|
|
210
|
+
const value = request.headers.get(name)
|
|
211
|
+
if (value !== null) {
|
|
212
|
+
result[name.toLowerCase()] = value
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return Object.keys(result).length > 0 ? result : undefined
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Convert a headers record to a ReadonlyMap */
|
|
219
|
+
export const headersRecordToMap = (headers: Record<string, string> | undefined): ForwardedHeaders | undefined => {
|
|
220
|
+
if (headers === undefined) {
|
|
221
|
+
return undefined
|
|
222
|
+
}
|
|
223
|
+
return new Map(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]))
|
|
224
|
+
}
|
package/src/cf-worker/worker.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { env as importedEnv } from 'cloudflare:workers'
|
|
2
|
+
|
|
3
|
+
import { UnknownError } from '@livestore/common'
|
|
2
4
|
import type { HelperTypes } from '@livestore/common-cf'
|
|
3
|
-
import
|
|
4
|
-
import { Effect } from '@livestore/utils/effect'
|
|
5
|
+
import { CfDeclare } from '@livestore/common-cf/declare'
|
|
6
|
+
import { Effect, Result, Schema } from '@livestore/utils/effect'
|
|
7
|
+
|
|
5
8
|
import type { CfTypes, SearchParams } from '../common/mod.ts'
|
|
6
|
-
import type
|
|
7
|
-
import { type Env, matchSyncRequest } from './shared.ts'
|
|
9
|
+
import { type Env, type ForwardedHeaders, matchSyncRequest } from './shared.ts'
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
declare class Response extends CfDeclare.Response {}
|
|
11
|
+
const Response = CfDeclare.Response
|
|
11
12
|
|
|
12
13
|
// HINT: If we ever extend user's custom worker RPC, type T can help here with expected return type safety. Currently unused.
|
|
13
14
|
export type CFWorker<TEnv extends Env = Env, _T extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined> = {
|
|
@@ -18,21 +19,46 @@ export type CFWorker<TEnv extends Env = Env, _T extends CfTypes.Rpc.DurableObjec
|
|
|
18
19
|
) => Promise<CfTypes.Response>
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
/** Context passed to validatePayload callback */
|
|
23
|
+
export type ValidatePayloadContext = {
|
|
24
|
+
storeId: string
|
|
25
|
+
/** Request headers (raw, not filtered by forwardHeaders) */
|
|
26
|
+
headers: ForwardedHeaders
|
|
27
|
+
}
|
|
28
|
+
|
|
21
29
|
/**
|
|
22
30
|
* Options accepted by {@link makeWorker}. The Durable Object binding has to be
|
|
23
31
|
* supplied explicitly so we never fall back to deprecated defaults when Cloudflare config changes.
|
|
24
32
|
*/
|
|
25
|
-
export type MakeWorkerOptions<TEnv extends Env = Env> = {
|
|
33
|
+
export type MakeWorkerOptions<TEnv extends Env = Env, TSyncPayload = Schema.Json> = {
|
|
26
34
|
/**
|
|
27
35
|
* Binding name of the sync Durable Object declared in wrangler config.
|
|
28
36
|
*/
|
|
29
37
|
syncBackendBinding: HelperTypes.ExtractDurableObjectKeys<TEnv>
|
|
30
38
|
/**
|
|
31
|
-
*
|
|
39
|
+
* Optionally pass a schema to decode the client-provided payload into a typed object
|
|
40
|
+
* before calling {@link validatePayload}. If omitted, the raw JSON value is forwarded.
|
|
41
|
+
*/
|
|
42
|
+
syncPayloadSchema?: Schema.Decoder<TSyncPayload>
|
|
43
|
+
/**
|
|
44
|
+
* Validates the (optionally decoded) payload during WebSocket connection establishment.
|
|
45
|
+
* If {@link syncPayloadSchema} is provided, `payload` will be of the schema's inferred type.
|
|
46
|
+
*
|
|
47
|
+
* The context includes request headers for cookie-based or header-based authentication.
|
|
48
|
+
*
|
|
49
|
+
* @example Cookie-based authentication
|
|
50
|
+
* ```ts
|
|
51
|
+
* validatePayload: async (payload, { storeId, headers }) => {
|
|
52
|
+
* const cookie = headers.get('cookie')
|
|
53
|
+
* const session = await validateSessionFromCookie(cookie)
|
|
54
|
+
* if (!session) throw new Error('Unauthorized')
|
|
55
|
+
* }
|
|
56
|
+
* ```
|
|
57
|
+
*
|
|
32
58
|
* Note: This runs only at connection time, not for individual push events.
|
|
33
|
-
* For push event validation, use the `onPush` callback in the
|
|
59
|
+
* For push event validation, use the `onPush` callback in the Durable Object.
|
|
34
60
|
*/
|
|
35
|
-
validatePayload?: (payload:
|
|
61
|
+
validatePayload?: (payload: TSyncPayload, context: ValidatePayloadContext) => void | Promise<void>
|
|
36
62
|
/** @default false */
|
|
37
63
|
enableCORS?: boolean
|
|
38
64
|
}
|
|
@@ -47,22 +73,24 @@ export type MakeWorkerOptions<TEnv extends Env = Env> = {
|
|
|
47
73
|
export const makeWorker = <
|
|
48
74
|
TEnv extends Env = Env,
|
|
49
75
|
TDurableObjectRpc extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined,
|
|
76
|
+
TSyncPayload = Schema.Json,
|
|
50
77
|
>(
|
|
51
|
-
options: MakeWorkerOptions<TEnv>,
|
|
78
|
+
options: MakeWorkerOptions<TEnv, TSyncPayload>,
|
|
52
79
|
): CFWorker<TEnv, TDurableObjectRpc> => {
|
|
53
80
|
return {
|
|
54
81
|
fetch: async (request, env, _ctx) => {
|
|
55
82
|
const url = new URL(request.url)
|
|
56
83
|
|
|
57
|
-
const corsHeaders: CfTypes.HeadersInit =
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
84
|
+
const corsHeaders: CfTypes.HeadersInit =
|
|
85
|
+
options.enableCORS === true
|
|
86
|
+
? {
|
|
87
|
+
'Access-Control-Allow-Origin': '*',
|
|
88
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
89
|
+
'Access-Control-Allow-Headers': request.headers.get('Access-Control-Request-Headers') ?? '*',
|
|
90
|
+
}
|
|
91
|
+
: {}
|
|
64
92
|
|
|
65
|
-
if (request.method === 'OPTIONS' && options.enableCORS) {
|
|
93
|
+
if (request.method === 'OPTIONS' && options.enableCORS === true) {
|
|
66
94
|
return new Response(null, {
|
|
67
95
|
status: 204,
|
|
68
96
|
headers: corsHeaders,
|
|
@@ -73,7 +101,7 @@ export const makeWorker = <
|
|
|
73
101
|
|
|
74
102
|
// Check if this is a sync request first, before showing info message
|
|
75
103
|
if (searchParams !== undefined) {
|
|
76
|
-
return handleSyncRequest<TEnv, TDurableObjectRpc>({
|
|
104
|
+
return handleSyncRequest<TEnv, TDurableObjectRpc, unknown, TSyncPayload>({
|
|
77
105
|
request,
|
|
78
106
|
searchParams,
|
|
79
107
|
env,
|
|
@@ -81,6 +109,7 @@ export const makeWorker = <
|
|
|
81
109
|
syncBackendBinding: options.syncBackendBinding,
|
|
82
110
|
headers: corsHeaders,
|
|
83
111
|
validatePayload: options.validatePayload,
|
|
112
|
+
syncPayloadSchema: options.syncPayloadSchema,
|
|
84
113
|
})
|
|
85
114
|
}
|
|
86
115
|
|
|
@@ -106,83 +135,106 @@ export const makeWorker = <
|
|
|
106
135
|
}
|
|
107
136
|
}
|
|
108
137
|
|
|
138
|
+
/** Convert CF Request headers to a ForwardedHeaders map */
|
|
139
|
+
const requestHeadersToMap = (request: CfTypes.Request): ForwardedHeaders => {
|
|
140
|
+
const result = new Map<string, string>()
|
|
141
|
+
request.headers.forEach((value, key) => {
|
|
142
|
+
result.set(key.toLowerCase(), value)
|
|
143
|
+
})
|
|
144
|
+
return result
|
|
145
|
+
}
|
|
146
|
+
|
|
109
147
|
/**
|
|
110
|
-
* Handles
|
|
148
|
+
* Handles LiveStore sync requests (e.g. with search params `?storeId=...&transport=...`).
|
|
111
149
|
*
|
|
112
|
-
* @example
|
|
150
|
+
* @example Token-based authentication
|
|
113
151
|
* ```ts
|
|
114
|
-
* const validatePayload = (payload: Schema.
|
|
115
|
-
* console.log(`Validating connection for store: ${context.storeId}`)
|
|
152
|
+
* const validatePayload = (payload: Schema.Json | undefined, context: { storeId: string }) => {
|
|
116
153
|
* if (payload?.authToken !== 'insecure-token-change-me') {
|
|
117
154
|
* throw new Error('Invalid auth token')
|
|
118
155
|
* }
|
|
119
156
|
* }
|
|
157
|
+
* ```
|
|
120
158
|
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* return handleSyncRequest({
|
|
128
|
-
* request,
|
|
129
|
-
* searchParams,
|
|
130
|
-
* env,
|
|
131
|
-
* ctx,
|
|
132
|
-
* syncBackendBinding: 'SYNC_BACKEND_DO',
|
|
133
|
-
* headers: {},
|
|
134
|
-
* validatePayload,
|
|
135
|
-
* })
|
|
136
|
-
* }
|
|
137
|
-
*
|
|
138
|
-
* return new Response('Invalid path', { status: 400 })
|
|
139
|
-
* }
|
|
159
|
+
* @example Cookie-based authentication
|
|
160
|
+
* ```ts
|
|
161
|
+
* const validatePayload = async (payload: Schema.Json | undefined, { storeId, headers }) => {
|
|
162
|
+
* const cookie = headers.get('cookie')
|
|
163
|
+
* const session = await validateSessionFromCookie(cookie)
|
|
164
|
+
* if (!session) throw new Error('Unauthorized')
|
|
140
165
|
* }
|
|
141
166
|
* ```
|
|
142
167
|
*
|
|
143
|
-
* @throws {
|
|
168
|
+
* @throws {UnknownError} If the payload is invalid
|
|
144
169
|
*/
|
|
145
170
|
export const handleSyncRequest = <
|
|
146
171
|
TEnv extends Env = Env,
|
|
147
172
|
TDurableObjectRpc extends CfTypes.Rpc.DurableObjectBranded | undefined = undefined,
|
|
148
173
|
CFHostMetada = unknown,
|
|
174
|
+
TSyncPayload = Schema.Json,
|
|
149
175
|
>({
|
|
150
176
|
request,
|
|
151
177
|
searchParams: { storeId, payload, transport },
|
|
152
|
-
env,
|
|
178
|
+
env: explicitlyProvidedEnv,
|
|
153
179
|
syncBackendBinding,
|
|
154
180
|
headers,
|
|
155
181
|
validatePayload,
|
|
182
|
+
syncPayloadSchema,
|
|
156
183
|
}: {
|
|
157
184
|
request: CfTypes.Request<CFHostMetada>
|
|
158
185
|
searchParams: SearchParams
|
|
159
|
-
env
|
|
186
|
+
env?: TEnv | undefined
|
|
160
187
|
/** Only there for type-level reasons */
|
|
161
188
|
ctx: CfTypes.ExecutionContext
|
|
162
189
|
/** Binding name of the sync backend Durable Object */
|
|
163
|
-
syncBackendBinding: MakeWorkerOptions<TEnv>['syncBackendBinding']
|
|
190
|
+
syncBackendBinding: MakeWorkerOptions<TEnv, TSyncPayload>['syncBackendBinding']
|
|
164
191
|
headers?: CfTypes.HeadersInit | undefined
|
|
165
|
-
validatePayload?:
|
|
192
|
+
validatePayload?: MakeWorkerOptions<TEnv, TSyncPayload>['validatePayload']
|
|
193
|
+
syncPayloadSchema?: MakeWorkerOptions<TEnv, TSyncPayload>['syncPayloadSchema']
|
|
166
194
|
}): Promise<CfTypes.Response> =>
|
|
167
195
|
Effect.gen(function* () {
|
|
168
196
|
if (validatePayload !== undefined) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
Effect.either,
|
|
172
|
-
)
|
|
197
|
+
// Convert request headers to a Map for the validation context
|
|
198
|
+
const requestHeaders = requestHeadersToMap(request)
|
|
173
199
|
|
|
174
|
-
if
|
|
175
|
-
|
|
176
|
-
|
|
200
|
+
// Always decode with the supplied schema when present, even if payload is undefined.
|
|
201
|
+
// This ensures required payloads are enforced by the schema.
|
|
202
|
+
if (syncPayloadSchema !== undefined) {
|
|
203
|
+
const decodedResult = Schema.decodeUnknownResult(syncPayloadSchema)(payload)
|
|
204
|
+
if (Result.isFailure(decodedResult) === true) {
|
|
205
|
+
const message = decodedResult.failure.toString()
|
|
206
|
+
console.error('Invalid payload (decode failed)', message)
|
|
207
|
+
return new Response(message, { status: 400, ...(headers !== undefined ? { headers } : {}) })
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const result = yield* Effect.promise(async () =>
|
|
211
|
+
validatePayload(decodedResult.success, { storeId, headers: requestHeaders }),
|
|
212
|
+
).pipe(UnknownError.mapToUnknownError, Effect.result)
|
|
213
|
+
|
|
214
|
+
if (Result.isFailure(result) === true) {
|
|
215
|
+
console.error('Invalid payload (validation failed)', result.failure)
|
|
216
|
+
return new Response(result.failure.toString(), { status: 400, ...(headers !== undefined ? { headers } : {}) })
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
const result = yield* Effect.promise(async () =>
|
|
220
|
+
validatePayload(payload as TSyncPayload, { storeId, headers: requestHeaders }),
|
|
221
|
+
).pipe(UnknownError.mapToUnknownError, Effect.result)
|
|
222
|
+
|
|
223
|
+
if (Result.isFailure(result) === true) {
|
|
224
|
+
console.error('Invalid payload (validation failed)', result.failure)
|
|
225
|
+
return new Response(result.failure.toString(), { status: 400, ...(headers !== undefined ? { headers } : {}) })
|
|
226
|
+
}
|
|
177
227
|
}
|
|
178
228
|
}
|
|
179
229
|
|
|
230
|
+
const env = explicitlyProvidedEnv ?? (importedEnv as TEnv)
|
|
231
|
+
|
|
180
232
|
if (!(syncBackendBinding in env)) {
|
|
181
233
|
return new Response(
|
|
182
234
|
`Failed dependency: Required Durable Object binding '${syncBackendBinding as string}' not available`,
|
|
183
235
|
{
|
|
184
236
|
status: 424,
|
|
185
|
-
headers,
|
|
237
|
+
...(headers !== undefined ? { headers } : {}),
|
|
186
238
|
},
|
|
187
239
|
)
|
|
188
240
|
}
|
|
@@ -199,7 +251,7 @@ export const handleSyncRequest = <
|
|
|
199
251
|
if (transport === 'ws' && (upgradeHeader === null || upgradeHeader !== 'websocket')) {
|
|
200
252
|
return new Response('Durable Object expected Upgrade: websocket', {
|
|
201
253
|
status: 426,
|
|
202
|
-
headers,
|
|
254
|
+
...(headers !== undefined ? { headers } : {}),
|
|
203
255
|
})
|
|
204
256
|
}
|
|
205
257
|
|