@reventlessdev/reventless-local 3.0.0-alpha.174 → 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,14 @@
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
+
6
14
  # 3.0.0-alpha.174 (2026-07-27)
7
15
 
8
16
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.174",
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-graphql-yoga": "1.0.0-alpha.25",
36
35
  "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
37
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
36
+ "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.25",
38
37
  "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.20",
39
- "@reventlessdev/reventless-core": "3.0.0-alpha.186",
40
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.34",
41
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.133",
42
- "@reventlessdev/reventless-infra": "3.0.0-alpha.106",
43
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.50",
44
- "@reventlessdev/reventless-spec": "3.0.0-alpha.81"
38
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
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)
@@ -1922,16 +1928,14 @@ module MakeWithConfig = (
1922
1928
  // `uiHintsFile` (the AWS deploy writes it as a BucketObject; local dev serves
1923
1929
  // `public/ui-hints.json` directly).
1924
1930
  type hostUiBundleConfig = {
1925
- assetsDir: string,
1926
- bundleVersion: string,
1931
+ assetsDir?: string,
1932
+ bundleVersion?: string,
1927
1933
  uiHintsFile?: string,
1928
1934
  // AWS host-ui deploy knobs — carried to satisfy the shared Platform.T
1929
1935
  // signature; the in-memory platform provisions no infrastructure and
1930
1936
  // ignores them.
1931
- geocoderPlaceIndex?: Pulumi.Input.t<string>,
1932
- enableUploads?: bool,
1933
- uploadBucketName?: Pulumi.Input.t<string>,
1934
- servedBuckets?: array<ReventlessInfra.Platform.servedBucket>,
1937
+ geocoderPlaceIndex?: ReventlessInfra.Platform.geocoderIndex,
1938
+ uploadBucket?: ReventlessInfra.Platform.objectStore,
1935
1939
  }
1936
1940
  let deployPlatform = (~version, ~hostUiBundle as _: option<hostUiBundleConfig>=?) => {
1937
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";
@@ -143,6 +144,7 @@ function MakeWithConfig(Config) {
143
144
  capacity: undefined,
144
145
  silent: Config.silent
145
146
  });
147
+ Bus.subscribeToAllStateChanges(LocalEvents_Server$ReventlessLocal.broadcastStateChange);
146
148
  let currentDeployTarget = {
147
149
  contents: "Domain"
148
150
  };
@@ -1822,6 +1824,7 @@ function Make($star) {
1822
1824
  capacity: undefined,
1823
1825
  silent: false
1824
1826
  });
1827
+ Bus.subscribeToAllStateChanges(LocalEvents_Server$ReventlessLocal.broadcastStateChange);
1825
1828
  let currentDeployTarget = {
1826
1829
  contents: "Domain"
1827
1830
  };
@@ -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
+ }