@reventlessdev/reventless-local 3.0.0-alpha.173 → 3.0.0-alpha.175

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 CHANGED
@@ -3,6 +3,21 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.175 (2026-07-28)
7
+
8
+ ### Features
9
+
10
+ * **aws:** provision platform capabilities through framework helpers ([5f87c57](https://github.com/ReventlessDev/reventless-core/commit/5f87c57c7c117dccb44c88d6132e5270e8707bc2))
11
+ * **events:** client-publishable /client namespace + local Events transport ([af98ce0](https://github.com/ReventlessDev/reventless-core/commit/af98ce07fb495bc629fcf3dc4f8383a9e0f2890d))
12
+
13
+
14
+ # 3.0.0-alpha.174 (2026-07-27)
15
+
16
+ ### Features
17
+
18
+ * **api:** event-history query — the Source A read counterpart ([9268b33](https://github.com/ReventlessDev/reventless-core/commit/9268b33a4835d4cd4bc79b38f19bc2b974853fa4))
19
+
20
+
6
21
  # 3.0.0-alpha.173 (2026-07-27)
7
22
 
8
23
  **Note:** Version bump only for package @reventlessdev/reventless-local
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.173",
3
+ "version": "3.0.0-alpha.175",
4
4
  "description": "Local platform for Reventless (in-memory or SQLite backend, for development and testing without AWS)",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -32,21 +32,21 @@
32
32
  "sury": "11.0.0-alpha.4",
33
33
  "ws": "^8.18.0",
34
34
  "@reventlessdev/rescript-effect": "0.1.0-alpha.31",
35
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
35
36
  "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.25",
36
37
  "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.20",
37
- "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
38
38
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
39
- "@reventlessdev/reventless-core": "3.0.0-alpha.185",
40
- "@reventlessdev/reventless-infra": "3.0.0-alpha.106",
41
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.132",
42
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.33",
43
- "@reventlessdev/reventless-spec": "3.0.0-alpha.81",
44
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.49"
39
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.134",
40
+ "@reventlessdev/reventless-core": "3.0.0-alpha.187",
41
+ "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.35",
42
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.82",
43
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.107",
44
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.51"
45
45
  },
46
46
  "devDependencies": {
47
47
  "rescript": "12.3.0",
48
48
  "sury-ppx": "11.0.0-alpha.2",
49
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.56"
49
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.57"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "rescript": "12.3.0"
package/src/Platform.res CHANGED
@@ -155,6 +155,12 @@ module MakeWithConfig = (
155
155
  let silent = Config.silent
156
156
  })
157
157
 
158
+ // Bridge Source B change descriptors onto the local Events transport
159
+ // (`/default/{readModel}/{entityKey}` channels) — the in-memory analogue of
160
+ // the AWS StateTopic → AppSync Events publish path. Harmless without
161
+ // subscribers; the transport itself is attached in DomainGraphQL_Server.start.
162
+ Bus.subscribeToAllStateChanges(LocalEvents_Server.broadcastStateChange)
163
+
158
164
  // Track which API target is active during deployPlugin / admin schema registration.
159
165
  // Domain = plugin-facing (default); Platform = admin/core (split mode).
160
166
  let currentDeployTarget: ref<apiTarget> = ref(Domain)
@@ -221,6 +227,10 @@ module MakeWithConfig = (
221
227
  }
222
228
  }
223
229
 
230
+ // Event-history query resolvers. Reads the same event logs the Source A
231
+ // subscription bridge below streams, but historically and per entity.
232
+ module EventHistoryResolvers = EventHistoryResolvers_GraphQL.Make(Bus)
233
+
224
234
  // Platform hook record — callbacks known at MakeWithConfig time plus a
225
235
  // mutable ref for admin extension points (set by makePlatform/deployPlugin
226
236
  // after Admin.construct returns, before plugins are built).
@@ -269,6 +279,11 @@ module MakeWithConfig = (
269
279
  inboundMutationBindReceiveHook: InboundTranslationResolvers_GraphQL.bindReceive,
270
280
  // Register GraphQL type definitions from the generated schema fragment.
271
281
  schemaTypeRegistrationHook: sdlTypes => resolveTargetGraphQL().registerTypes(~sdlTypes),
282
+ // Event-history queries — the historical read counterpart of the Source A
283
+ // subscription bridged further down. Same `eventLogEntries`, so the two
284
+ // always describe the same set of streams.
285
+ eventQueryResolverHook: params =>
286
+ EventHistoryResolvers.register(~server=resolveTargetGraphQL(), params),
272
287
  // MCP tools and resources — registered during plugin construction.
273
288
  // See the large lambda below; it references Bus for QueryDb lookups.
274
289
  mcpSchemaRegistrationHook: ({pluginName, mutationEntries, queryEntries, eventLogEntries, subscriptionFields}) => {
@@ -1913,16 +1928,14 @@ module MakeWithConfig = (
1913
1928
  // `uiHintsFile` (the AWS deploy writes it as a BucketObject; local dev serves
1914
1929
  // `public/ui-hints.json` directly).
1915
1930
  type hostUiBundleConfig = {
1916
- assetsDir: string,
1917
- bundleVersion: string,
1931
+ assetsDir?: string,
1932
+ bundleVersion?: string,
1918
1933
  uiHintsFile?: string,
1919
1934
  // AWS host-ui deploy knobs — carried to satisfy the shared Platform.T
1920
1935
  // signature; the in-memory platform provisions no infrastructure and
1921
1936
  // ignores them.
1922
- geocoderPlaceIndex?: Pulumi.Input.t<string>,
1923
- enableUploads?: bool,
1924
- uploadBucketName?: Pulumi.Input.t<string>,
1925
- servedBuckets?: array<ReventlessInfra.Platform.servedBucket>,
1937
+ geocoderPlaceIndex?: ReventlessInfra.Platform.geocoderIndex,
1938
+ uploadBucket?: ReventlessInfra.Platform.objectStore,
1926
1939
  }
1927
1940
  let deployPlatform = (~version, ~hostUiBundle as _: option<hostUiBundleConfig>=?) => {
1928
1941
  log.info(~comp="Platform", `deployPlatform v${version}`)
@@ -50,6 +50,7 @@ import * as PluginBaseFragment$ReventlessCore from "@reventlessdev/reventless-co
50
50
  import * as ProjectionPending$ReventlessLocal from "./adapter/ProjectionPending.res.mjs";
51
51
  import * as ReadModel_Builder$ReventlessLocal from "./components/ReadModel_Builder.res.mjs";
52
52
  import * as UiFragmentRegistry$ReventlessCore from "@reventlessdev/reventless-core/src/admin/UiFragmentRegistry/StateChangeSlice/UiFragmentRegistry.res.mjs";
53
+ import * as LocalEvents_Server$ReventlessLocal from "./adapter/Api/LocalEvents_Server.res.mjs";
53
54
  import * as PlatformMCP_Server$ReventlessLocal from "./adapter/PlatformMCP_Server.res.mjs";
54
55
  import * as Auth_GraphqlContext$ReventlessLocal from "./adapter/Auth/Auth_GraphqlContext.res.mjs";
55
56
  import * as PgProjectionCatchup$ReventlessLocal from "./adapter/PgProjectionCatchup.res.mjs";
@@ -76,6 +77,7 @@ import * as DcbEventLogStorage_Sqlite$ReventlessLocal from "./adapter/DcbEventLo
76
77
  import * as LocalEventCollectorChannel$ReventlessLocal from "./adapter/EventCollector/LocalEventCollectorChannel.res.mjs";
77
78
  import * as PluginRuntime_Builder_Micro$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Runtime/PluginRuntime_Builder_Micro.res.mjs";
78
79
  import * as UiFragmentRegistry_Behavior$ReventlessCore from "@reventlessdev/reventless-core/src/admin/UiFragmentRegistry/StateChangeSlice/UiFragmentRegistry_Behavior.res.mjs";
80
+ import * as EventHistoryResolvers_GraphQL$ReventlessLocal from "./adapter/EventHistory/EventHistoryResolvers_GraphQL.res.mjs";
79
81
  import * as InboundTranslationSlice_Builder$ReventlessLocal from "./components/InboundTranslationSlice_Builder.res.mjs";
80
82
  import * as Platform_ComponentDefinitionsApi$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_ComponentDefinitionsApi.res.mjs";
81
83
  import * as OutboundTranslationSlice_Builder$ReventlessLocal from "./components/OutboundTranslationSlice_Builder.res.mjs";
@@ -142,6 +144,7 @@ function MakeWithConfig(Config) {
142
144
  capacity: undefined,
143
145
  silent: Config.silent
144
146
  });
147
+ Bus.subscribeToAllStateChanges(LocalEvents_Server$ReventlessLocal.broadcastStateChange);
145
148
  let currentDeployTarget = {
146
149
  contents: "Domain"
147
150
  };
@@ -192,6 +195,7 @@ function MakeWithConfig(Config) {
192
195
  return PlatformMCP_Server$ReventlessLocal.asInterface;
193
196
  }
194
197
  };
198
+ let EventHistoryResolvers = EventHistoryResolvers_GraphQL$ReventlessLocal.Make(Bus);
195
199
  let hooks_mutationResolverHook = (kind, fields, commandSchema, commandAuthorization) => {
196
200
  let server = resolveTargetGraphQL();
197
201
  if (kind === "Aggregate") {
@@ -407,6 +411,7 @@ function MakeWithConfig(Config) {
407
411
  });
408
412
  LocalGraphQL_SubscriptionResolvers$ReventlessLocal.registerAll(server, subscriptionFields, sourceAEntries, []);
409
413
  };
414
+ let hooks_eventQueryResolverHook = params => EventHistoryResolvers.register(resolveTargetGraphQL(), params);
410
415
  let hooks_adminExtensionPoints = {
411
416
  contents: Pulumi.output({})
412
417
  };
@@ -435,6 +440,7 @@ function MakeWithConfig(Config) {
435
440
  inboundMutationBindReceiveHook: hooks_inboundMutationBindReceiveHook,
436
441
  schemaTypeRegistrationHook: hooks_schemaTypeRegistrationHook,
437
442
  mcpSchemaRegistrationHook: hooks_mcpSchemaRegistrationHook,
443
+ eventQueryResolverHook: hooks_eventQueryResolverHook,
438
444
  adminExtensionPoints: hooks_adminExtensionPoints,
439
445
  scheduler: hooks_scheduler,
440
446
  schedulerRoleUrn: hooks_schedulerRoleUrn,
@@ -1818,6 +1824,7 @@ function Make($star) {
1818
1824
  capacity: undefined,
1819
1825
  silent: false
1820
1826
  });
1827
+ Bus.subscribeToAllStateChanges(LocalEvents_Server$ReventlessLocal.broadcastStateChange);
1821
1828
  let currentDeployTarget = {
1822
1829
  contents: "Domain"
1823
1830
  };
@@ -1862,6 +1869,7 @@ function Make($star) {
1862
1869
  return PlatformMCP_Server$ReventlessLocal.asInterface;
1863
1870
  }
1864
1871
  };
1872
+ let EventHistoryResolvers = EventHistoryResolvers_GraphQL$ReventlessLocal.Make(Bus);
1865
1873
  let hooks_mutationResolverHook = (kind, fields, commandSchema, commandAuthorization) => {
1866
1874
  let server = resolveTargetGraphQL();
1867
1875
  if (kind === "Aggregate") {
@@ -2077,6 +2085,7 @@ function Make($star) {
2077
2085
  });
2078
2086
  LocalGraphQL_SubscriptionResolvers$ReventlessLocal.registerAll(server, subscriptionFields, sourceAEntries, []);
2079
2087
  };
2088
+ let hooks_eventQueryResolverHook = params => EventHistoryResolvers.register(resolveTargetGraphQL(), params);
2080
2089
  let hooks_adminExtensionPoints = {
2081
2090
  contents: Pulumi.output({})
2082
2091
  };
@@ -2105,6 +2114,7 @@ function Make($star) {
2105
2114
  inboundMutationBindReceiveHook: hooks_inboundMutationBindReceiveHook,
2106
2115
  schemaTypeRegistrationHook: hooks_schemaTypeRegistrationHook,
2107
2116
  mcpSchemaRegistrationHook: hooks_mcpSchemaRegistrationHook,
2117
+ eventQueryResolverHook: hooks_eventQueryResolverHook,
2108
2118
  adminExtensionPoints: hooks_adminExtensionPoints,
2109
2119
  scheduler: hooks_scheduler,
2110
2120
  schedulerRoleUrn: hooks_schedulerRoleUrn,
@@ -0,0 +1,324 @@
1
+ // LocalEvents_Server — local AppSync Events transport.
2
+ //
3
+ // Speaks the AppSync Events realtime wire protocol on the domain dev server
4
+ // so clients use one code path for dev and AWS:
5
+ // ws {server}/events/realtime — connection_init/ack, subscribe/unsubscribe,
6
+ // stringified-JSON `data` frames
7
+ // POST {server}/events — the publish endpoint, `/client/**` only
8
+ // (AWS analogue: POST {endpoint} with a JWT)
9
+ //
10
+ // Two producers feed `broadcast`:
11
+ // - LocalBus state changes (wired via `subscribeToAllStateChanges` in
12
+ // Platform.res) → `/default/{readModel}/{entityKey}` — the local parity
13
+ // of the AWS StateTopic Lambda publishes.
14
+ // - client HTTP publishes → `/client/**` (ephemeral fan-out: presence,
15
+ // typing, transient chat). `/default/**` publishes are rejected 403,
16
+ // mirroring the AWS namespaces' publish-auth asymmetry.
17
+ //
18
+ // Frame/connection handling is socket-free (a `connection` is just a `send`
19
+ // callback + subscription dict) so tests drive the protocol without ws.
20
+ // See docs/plans/events-client-publish-channels.md.
21
+
22
+ module YG = GraphqlYoga
23
+
24
+ let log = ReventlessCore.Logger.fromEnv()
25
+
26
+ // -- Channel naming ----------------------------------------------------------
27
+
28
+ // Mirrors AppSyncEventsSigner_Ops.pathSegment (aws) and the client's channel
29
+ // normalizer: any char outside [A-Za-z0-9-] becomes `-`.
30
+ let pathSegment = (s: string): string =>
31
+ s->String.replaceRegExp(/[^A-Za-z0-9-]/g, "-")
32
+
33
+ let clientChannelPrefix = "/client/"
34
+
35
+ /** Subscription channels may end in a `*` wildcard segment — AppSync
36
+ semantics: prefix match over the remaining path (any depth). */
37
+ let channelMatches = (~subscription: string, ~channel: string): bool =>
38
+ if subscription->String.endsWith("/*") {
39
+ let prefix = subscription->String.slice(~start=0, ~end=String.length(subscription) - 1)
40
+ channel->String.startsWith(prefix)
41
+ } else {
42
+ subscription == channel
43
+ }
44
+
45
+ // -- Connection registry -----------------------------------------------------
46
+
47
+ type connection = {
48
+ id: int,
49
+ send: string => unit,
50
+ /** subscription id → channel (exact, or ending in a `*` wildcard). */
51
+ subscriptions: dict<string>,
52
+ }
53
+
54
+ let connections: ref<array<connection>> = ref([])
55
+ let nextConnectionId = ref(0)
56
+
57
+ let addConnection = (~send: string => unit): connection => {
58
+ nextConnectionId.contents = nextConnectionId.contents + 1
59
+ let conn = {id: nextConnectionId.contents, send, subscriptions: Dict.make()}
60
+ connections.contents->Array.push(conn)
61
+ conn
62
+ }
63
+
64
+ let removeConnection = (conn: connection): unit =>
65
+ connections.contents = connections.contents->Array.filter(c => c.id != conn.id)
66
+
67
+ /** Test/reset hook — drops all registered connections. */
68
+ let resetConnections = (): unit => connections.contents = []
69
+
70
+ // -- Outbound frames ---------------------------------------------------------
71
+
72
+ let frame = (fields: array<(string, JSON.t)>): string =>
73
+ fields->Dict.fromArray->JSON.Encode.object->JSON.stringify
74
+
75
+ let connectionAckFrame = frame([
76
+ ("type", JSON.Encode.string("connection_ack")),
77
+ ("connectionTimeoutMs", JSON.Encode.int(300_000)),
78
+ ])
79
+
80
+ let dataFrame = (~subscriptionId: string, ~event: string): string =>
81
+ frame([
82
+ ("type", JSON.Encode.string("data")),
83
+ ("id", JSON.Encode.string(subscriptionId)),
84
+ // `event` is a *stringified* JSON payload, exactly as AWS delivers it —
85
+ // the client JSON-parses the string.
86
+ ("event", JSON.Encode.string(event)),
87
+ ])
88
+
89
+ // -- Broadcast ---------------------------------------------------------------
90
+
91
+ /** Deliver one already-stringified event to every matching subscription. */
92
+ let broadcast = (~channel: string, ~event: string): unit =>
93
+ connections.contents->Array.forEach(conn =>
94
+ conn.subscriptions
95
+ ->Dict.toArray
96
+ ->Array.forEach(((subscriptionId, subChannel)) =>
97
+ if channelMatches(~subscription=subChannel, ~channel) {
98
+ conn.send(dataFrame(~subscriptionId, ~event))
99
+ }
100
+ )
101
+ )
102
+
103
+ /** LocalBus bridge: a Source B change descriptor becomes a publish on the
104
+ same channel the AWS StateTopic Lambda would use. No-op without matching
105
+ subscribers, so wiring order against server start doesn't matter. */
106
+ let broadcastStateChange = (~name: string, ~descriptor: JSON.t): unit => {
107
+ let entityKey =
108
+ descriptor
109
+ ->JSON.Decode.object
110
+ ->Option.flatMap(o => o->Dict.get("id"))
111
+ ->Option.flatMap(JSON.Decode.string)
112
+ ->Option.getOr("")
113
+ if entityKey != "" {
114
+ let channel = `/default/${pathSegment(name)}/${pathSegment(entityKey)}`
115
+ broadcast(~channel, ~event=descriptor->JSON.stringify)
116
+ }
117
+ }
118
+
119
+ // -- Inbound frames (subscribe side) -----------------------------------------
120
+
121
+ let decodeStringField = (json: JSON.t, field: string): option<string> =>
122
+ json->JSON.Decode.object->Option.flatMap(o => o->Dict.get(field))->Option.flatMap(JSON.Decode.string)
123
+
124
+ /** Handle one client→server text frame on an established connection. */
125
+ let handleFrame = (conn: connection, text: string): unit => {
126
+ let parsed = try Some(text->JSON.parseOrThrow) catch {
127
+ | _ => None
128
+ }
129
+ switch parsed {
130
+ | None => ()
131
+ | Some(json) =>
132
+ switch decodeStringField(json, "type") {
133
+ | Some("connection_init") => conn.send(connectionAckFrame)
134
+ | Some("subscribe") =>
135
+ switch (decodeStringField(json, "id"), decodeStringField(json, "channel")) {
136
+ | (Some(id), Some(channel)) =>
137
+ conn.subscriptions->Dict.set(id, channel)
138
+ conn.send(
139
+ frame([
140
+ ("type", JSON.Encode.string("subscribe_success")),
141
+ ("id", JSON.Encode.string(id)),
142
+ ]),
143
+ )
144
+ | _ => ()
145
+ }
146
+ | Some("unsubscribe") =>
147
+ switch decodeStringField(json, "id") {
148
+ | Some(id) =>
149
+ conn.subscriptions->Dict.delete(id)
150
+ conn.send(
151
+ frame([
152
+ ("type", JSON.Encode.string("unsubscribe_success")),
153
+ ("id", JSON.Encode.string(id)),
154
+ ]),
155
+ )
156
+ | None => ()
157
+ }
158
+ | _ => ()
159
+ }
160
+ }
161
+ }
162
+
163
+ // -- Auth --------------------------------------------------------------------
164
+
165
+ type nodeBuffer
166
+ @val @scope("Buffer") external bufferFrom: (string, string) => nodeBuffer = "from"
167
+ @send external bufferToString: (nodeBuffer, string) => string = "toString"
168
+
169
+ let stripBearer = (token: string): string =>
170
+ if token->String.startsWith("Bearer ") {
171
+ token->String.slice(~start=7, ~end=String.length(token))->String.trim
172
+ } else {
173
+ token->String.trim
174
+ }
175
+
176
+ /** Local auth rule (mirrors the HTTP dispatch): absent token = anonymous,
177
+ allowed; present token must HMAC-verify. */
178
+ let tokenIsInvalid = (token: option<string>): bool =>
179
+ switch token {
180
+ | None => false
181
+ | Some(t) => LocalAuth.Login.verifyAndDecode(stripBearer(t))->Option.isNone
182
+ }
183
+
184
+ /** Extract the Authorization value from the `header-<base64url(JSON)>`
185
+ subprotocol entry the client offers on connect. */
186
+ let authFromSubprotocolHeader = (headerValue: string): option<string> =>
187
+ headerValue
188
+ ->String.split(",")
189
+ ->Array.map(String.trim)
190
+ ->Array.find(p => p->String.startsWith("header-"))
191
+ ->Option.flatMap(p => {
192
+ let blob = p->String.slice(~start=7, ~end=String.length(p))
193
+ try {
194
+ bufferFrom(blob, "base64url")
195
+ ->bufferToString("utf8")
196
+ ->JSON.parseOrThrow
197
+ ->decodeStringField("Authorization")
198
+ } catch {
199
+ | _ => None
200
+ }
201
+ })
202
+
203
+ // -- Publish route (transport-free; HTTP wiring lives in the dispatch) -------
204
+
205
+ let jsonError = (message: string): JSON.t =>
206
+ JSON.Encode.object(Dict.fromArray([("error", JSON.Encode.string(message))]))
207
+
208
+ /** Handle `POST /events`. Returns `(status, responseBody)`.
209
+ Contract (AWS parity): body `{"channel": "/client/…", "events": ["<json>", …]}`,
210
+ `Authorization` header carries the token (raw or `Bearer `-prefixed). Only
211
+ channels under the client prefix are publishable; each event must be a
212
+ string that parses as JSON.
213
+ Reply: `{"successful": [{"index": n}], "failed": [{"index": n}]}`. */
214
+ let handlePublish = (~authorization: option<string>, ~body: string): (int, JSON.t) =>
215
+ if tokenIsInvalid(authorization) {
216
+ (401, jsonError("Invalid token"))
217
+ } else {
218
+ let parsed = try Some(body->JSON.parseOrThrow) catch {
219
+ | _ => None
220
+ }
221
+ switch parsed {
222
+ | None => (400, jsonError("Invalid JSON body"))
223
+ | Some(json) =>
224
+ let channel = decodeStringField(json, "channel")
225
+ let events =
226
+ json
227
+ ->JSON.Decode.object
228
+ ->Option.flatMap(o => o->Dict.get("events"))
229
+ ->Option.flatMap(JSON.Decode.array)
230
+ switch (channel, events) {
231
+ | (Some(channel), Some(events)) if events->Array.length > 0 =>
232
+ if !(channel->String.startsWith(clientChannelPrefix)) {
233
+ // Publish-auth asymmetry: `/default/**` (and anything else) is
234
+ // reserved for the server-side publishers.
235
+ (403, jsonError(`Clients may only publish under ${clientChannelPrefix}`))
236
+ } else {
237
+ let successful = []
238
+ let failed = []
239
+ events->Array.forEachWithIndex((event, index) => {
240
+ let entry = Dict.fromArray([("index", JSON.Encode.int(index))])->JSON.Encode.object
241
+ switch event->JSON.Decode.string {
242
+ | Some(s) =>
243
+ let valid = try {
244
+ let _ = s->JSON.parseOrThrow
245
+ true
246
+ } catch {
247
+ | _ => false
248
+ }
249
+ if valid {
250
+ broadcast(~channel, ~event=s)
251
+ successful->Array.push(entry)
252
+ } else {
253
+ failed->Array.push(entry)
254
+ }
255
+ | None => failed->Array.push(entry)
256
+ }
257
+ })
258
+ (
259
+ 200,
260
+ JSON.Encode.object(
261
+ Dict.fromArray([
262
+ ("successful", JSON.Encode.array(successful)),
263
+ ("failed", JSON.Encode.array(failed)),
264
+ ]),
265
+ ),
266
+ )
267
+ }
268
+ | _ => (400, jsonError("Body must carry `channel` and a non-empty `events` array"))
269
+ }
270
+ }
271
+ }
272
+
273
+ // -- WebSocket attach --------------------------------------------------------
274
+
275
+ let realtimePath = "/events/realtime"
276
+ let subprotocol = "aws-appsync-event-ws"
277
+
278
+ type wsSocket
279
+ type incomingMessage = {headers: dict<string>}
280
+ type wssOptions = {
281
+ "server": YG.httpServer,
282
+ "path": string,
283
+ // ws calls this to pick the response subprotocol; the client offers
284
+ // ["aws-appsync-event-ws", "header-<blob>"] and expects the first back.
285
+ "handleProtocols": (unknown, unknown) => string,
286
+ }
287
+ type wss
288
+ @new @module("ws") external newWebSocketServer: wssOptions => wss = "WebSocketServer"
289
+ @send
290
+ external onConnection: (wss, @as("connection") _, (wsSocket, incomingMessage) => unit) => unit =
291
+ "on"
292
+ type wsMessageData
293
+ @send external wsMessageToString: (wsMessageData, string) => string = "toString"
294
+ @send external onMessage: (wsSocket, @as("message") _, wsMessageData => unit) => unit = "on"
295
+ @send external onSocketClose: (wsSocket, @as("close") _, unit => unit) => unit = "on"
296
+ @send external wsSend: (wsSocket, string) => unit = "send"
297
+ @send external wsClose: (wsSocket, int, string) => unit = "close"
298
+
299
+ /** Attach the realtime WebSocket endpoint to the domain dev server. Called
300
+ from DomainGraphQL_Server.start(), so every start mode carries it. */
301
+ let attach = (~server: YG.httpServer, ~port: int): unit => {
302
+ let wss = newWebSocketServer({
303
+ "server": server,
304
+ "path": realtimePath,
305
+ "handleProtocols": (_protocols, _req) => subprotocol,
306
+ })
307
+ wss->onConnection((ws, req) => {
308
+ let auth =
309
+ req.headers
310
+ ->Dict.get("sec-websocket-protocol")
311
+ ->Option.flatMap(authFromSubprotocolHeader)
312
+ if tokenIsInvalid(auth) {
313
+ ws->wsClose(4401, "Invalid token")
314
+ } else {
315
+ let conn = addConnection(~send=f => ws->wsSend(f))
316
+ ws->onMessage(data => handleFrame(conn, data->wsMessageToString("utf8")))
317
+ ws->onSocketClose(() => removeConnection(conn))
318
+ }
319
+ })
320
+ log.info(
321
+ ~comp="Events:Local",
322
+ `events transport on ws://localhost:${port->Int.toString}${realtimePath} (publish: POST /events)`,
323
+ )
324
+ }