@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 +8 -0
- package/package.json +10 -10
- package/src/Platform.res +10 -6
- package/src/Platform.res.mjs +3 -0
- package/src/adapter/Api/LocalEvents_Server.res +324 -0
- package/src/adapter/Api/LocalEvents_Server.res.mjs +341 -0
- package/src/adapter/DomainGraphQL_Server.res +23 -0
- package/src/adapter/DomainGraphQL_Server.res.mjs +36 -14
- package/src/adapter/LocalBus.res +12 -1
- package/src/adapter/LocalBus.res.mjs +36 -0
- package/tests/adapter/LocalEvents_ServerTest.res +200 -0
- package/tests/adapter/LocalEvents_ServerTest.res.mjs +147 -0
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.
|
|
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-
|
|
36
|
+
"@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.25",
|
|
38
37
|
"@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.20",
|
|
39
|
-
"@reventlessdev/
|
|
40
|
-
"@reventlessdev/reventless-
|
|
41
|
-
"@reventlessdev/reventless-
|
|
42
|
-
"@reventlessdev/reventless-
|
|
43
|
-
"@reventlessdev/reventless-
|
|
44
|
-
"@reventlessdev/reventless-
|
|
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.
|
|
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
|
|
1926
|
-
bundleVersion
|
|
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?:
|
|
1932
|
-
|
|
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}`)
|
package/src/Platform.res.mjs
CHANGED
|
@@ -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
|
+
}
|