@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 +15 -0
- package/package.json +9 -9
- package/src/Platform.res +19 -6
- package/src/Platform.res.mjs +10 -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/EventHistory/EventHistoryResolvers_GraphQL.res +352 -0
- package/src/adapter/EventHistory/EventHistoryResolvers_GraphQL.res.mjs +362 -0
- package/src/adapter/LocalBus.res +12 -1
- package/src/adapter/LocalBus.res.mjs +36 -0
- package/tests/adapter/EventHistoryResolverTest.res +208 -0
- package/tests/adapter/EventHistoryResolverTest.res.mjs +286 -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,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.
|
|
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-
|
|
40
|
-
"@reventlessdev/reventless-
|
|
41
|
-
"@reventlessdev/reventless-
|
|
42
|
-
"@reventlessdev/reventless-
|
|
43
|
-
"@reventlessdev/reventless-
|
|
44
|
-
"@reventlessdev/reventless-postgres": "3.0.0-alpha.
|
|
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)
|
|
@@ -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
|
|
1917
|
-
bundleVersion
|
|
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?:
|
|
1923
|
-
|
|
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}`)
|
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";
|
|
@@ -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
|
+
}
|