@flamework-experimental/core 2.0.0-alpha.3 → 2.0.0-alpha.4

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.
@@ -0,0 +1,614 @@
1
+ # 6. Networking
2
+
3
+ You declare two interfaces: one for what the server receives and one for what the client receives.
4
+ From them, Flamework generates the remotes, the **guards** that check incoming arguments, and the
5
+ typed handlers.
6
+
7
+ ```sh
8
+ npm install @flamework-experimental/networking
9
+ ```
10
+
11
+ Map it in your Rojo project next to `core` ([Getting started › Rojo](01-getting-started.md#rojo)).
12
+
13
+ ## Declaring a network
14
+
15
+ Put this in shared code, so both realms import the same object.
16
+
17
+ ```ts
18
+ // src/shared/network.ts
19
+ import { Networking } from "@flamework-experimental/networking";
20
+
21
+ interface ServerEvents {
22
+ setReady(ready: boolean): void;
23
+ }
24
+
25
+ interface ClientEvents {
26
+ matchStarted(map: string): void;
27
+ }
28
+
29
+ export const GlobalEvents = Networking.createEvent<ServerEvents, ClientEvents>();
30
+ ```
31
+
32
+ The first type parameter is what the **server receives**. The second is what the **client
33
+ receives**. The rest follows from that: the server can `connect` to `ServerEvents` and `fire`
34
+ `ClientEvents`, and the client does the opposite.
35
+
36
+ ## Using it
37
+
38
+ ```ts
39
+ // server
40
+ const events = GlobalEvents.createServer({});
41
+
42
+ events.setReady.connect((player, ready) => print(player, ready));
43
+ events.matchStarted.fire(player, "Sandbox");
44
+ ```
45
+
46
+ ```ts
47
+ // client
48
+ const events = GlobalEvents.createClient({});
49
+
50
+ events.matchStarted.connect((map) => print(map));
51
+ events.setReady.fire(true);
52
+ ```
53
+
54
+ Call `createServer` on the server and `createClient` on the client. Calling the wrong one **returns
55
+ nothing** instead of raising an error. A stray `createServer()` on the client gives you a `nil` that
56
+ you trip over later.
57
+
58
+ Once a message has passed the guards and middleware, each handler passed to `connect` runs at once,
59
+ on a thread of its own. The newest connection runs first. A handler that yields holds up nothing,
60
+ and a handler that raises has its error printed while the others still run. Every handler gets the
61
+ same argument values, not copies, so a decoded `Map` or `Set` arrives intact.
62
+
63
+ `connect` returns a `Networking.Connection`, with `Connected`, `Disconnect()` and `Destroy()` (for
64
+ maids and janitors). It is networking's own type, not an engine `RBXScriptConnection`.
65
+
66
+ ### Where to create the handlers
67
+
68
+ Create each realm's handlers once, in a `network.ts` of that realm, and import them where they are
69
+ used. The first `createServer` or `createClient` call wins: later calls return the same handler and
70
+ ignore their config, middleware included. A second call in some provider would drop its middleware
71
+ without a word.
72
+
73
+ ```ts
74
+ // src/server/network.ts
75
+ import { GlobalEvents, GlobalFunctions } from "shared/network";
76
+ import { throttle } from "./middleware/throttle";
77
+
78
+ export const Events = GlobalEvents.createServer({
79
+ middleware: { setReady: [throttle(1)] },
80
+ });
81
+ export const Functions = GlobalFunctions.createServer({});
82
+ ```
83
+
84
+ ```ts
85
+ // src/client/network.ts
86
+ import { GlobalEvents, GlobalFunctions } from "shared/network";
87
+
88
+ export const Events = GlobalEvents.createClient({});
89
+ export const Functions = GlobalFunctions.createClient({});
90
+ ```
91
+
92
+ Providers import the realm's file and connect in `onStart`:
93
+
94
+ ```ts
95
+ import { Events } from "server/network";
96
+
97
+ @Provider()
98
+ export class MatchService implements OnStart {
99
+ public onStart() {
100
+ Events.setReady.connect((player, ready) => this.setReady(player, ready));
101
+ }
102
+ }
103
+ ```
104
+
105
+ Each realm's `createServer` or `createClient` call then sits in that realm's own file, where the
106
+ wrong one (which returns nothing) stands out. `throttle` is the middleware from
107
+ [Middleware](#middleware).
108
+
109
+ ### Sending
110
+
111
+ | Method | Realm | Sends to |
112
+ |---|---|---|
113
+ | `fire(player, ...)` | server | One player. |
114
+ | `fire([a, b], ...)` | server | Each player in the list. |
115
+ | `broadcast(...)` | server | Everyone. |
116
+ | `except(player, ...)` | server | Everyone but that player (or list). |
117
+ | `fire(...)` | client | The server. |
118
+
119
+ `predict(...)` runs the *receiving* side locally, guards and middleware included, without using a
120
+ remote. It is meant for client prediction. It is also the easiest way to test a handler: by the time
121
+ `predict` returns, the handlers have been called, unless a middleware yields.
122
+
123
+ In a test, if the handler answers with `fire(player, ...)`, call `predict` with a stand-in instead
124
+ of a real player. The engine queues a message fired at a client that has not connected yet, and
125
+ delivers it once the client connects. The reply would then show up later, in that client's own
126
+ tests. See [both realms in one session](12-testing.md#both-realms-in-one-session).
127
+
128
+ ## Functions
129
+
130
+ Same idea, but the call returns a value.
131
+
132
+ ```ts
133
+ interface ServerFunctions {
134
+ buy(itemId: string): boolean;
135
+ }
136
+
137
+ export const GlobalFunctions = Networking.createFunction<ServerFunctions, {}>();
138
+ ```
139
+
140
+ ```ts
141
+ // server
142
+ GlobalFunctions.createServer({}).buy.setCallback((player, itemId) => shop.buy(player, itemId));
143
+
144
+ // client
145
+ const bought = await GlobalFunctions.createClient({}).buy.invoke("sword");
146
+ ```
147
+
148
+ `invoke` returns a Promise. It times out after `defaultTimeout` seconds: **30 on the client, 10 on
149
+ the server**. `invokeWithTimeout(timeout, ...)` sets a different timeout for one call.
150
+
151
+ A rejection is always a `NetworkingFunctionError`:
152
+
153
+ | Value | Means |
154
+ |---|---|
155
+ | `Timeout` | No response in time. |
156
+ | `Cancelled` | Middleware returned `Networking.Skip`, the callback or a middleware returned a promise that was cancelled, or the player left. |
157
+ | `BadRequest` | The arguments failed the generated guards. |
158
+ | `Unprocessed` | The other realm has not called `setCallback`. |
159
+ | `InvalidResult` | The response failed the return type's guard. |
160
+
161
+ ```ts
162
+ GlobalFunctions.createClient({})
163
+ .buy.invoke("sword")
164
+ .catch((reason: unknown) => {
165
+ if (reason === NetworkingFunctionError.Timeout) warn("server did not answer");
166
+ });
167
+ ```
168
+
169
+ Return values are checked too. When a callback returns the wrong type, the value is not delivered:
170
+ the caller's Promise rejects with `InvalidResult`.
171
+
172
+ ## Namespaces
173
+
174
+ Nest an object to group events. Each gets its own remote, named after the path:
175
+
176
+ ```ts
177
+ interface ServerEvents {
178
+ stats: {
179
+ report(value: number): void;
180
+ };
181
+ }
182
+
183
+ events.stats.report.connect((player, value) => {});
184
+ ```
185
+
186
+ Middleware nests the same way.
187
+
188
+ ## Unreliable events
189
+
190
+ ```ts
191
+ interface ClientEvents {
192
+ position: Networking.Unreliable<(position: Vector3) => void>;
193
+ }
194
+ ```
195
+
196
+ These use an `UnreliableRemoteEvent`, on a channel of their own. Their messages may be dropped or
197
+ arrive out of order, so send **state, not deltas**: a position, not "moved by 3 studs".
198
+
199
+ ## Configuration
200
+
201
+ ```ts
202
+ const events = GlobalEvents.createServer({
203
+ disableIncomingGuards: false,
204
+ warnOnInvalidGuards: true,
205
+ middleware: {
206
+ setReady: [rateLimit(5)],
207
+ },
208
+ });
209
+ ```
210
+
211
+ | Option | Default | Effect |
212
+ |---|---|---|
213
+ | `disableIncomingGuards` | `false` | Skips generated argument validation entirely. |
214
+ | `warnOnInvalidGuards` | `RunService.IsStudio()` | Warns when a guard rejects something. |
215
+ | `middleware` | `{}` | Per-event middleware. |
216
+ | `defaultTimeout` | 30 client / 10 server | Functions only. |
217
+
218
+ **The config object must be written inline.** The transformer reads it when you build, so passing a
219
+ variable gives `Flamework expected this argument to be a literal expression`. The same goes for the
220
+ `middleware` object.
221
+
222
+ ## Serialization
223
+
224
+ With `"networking": { "serialization": true }` in `flamework.config.json`, every event argument list,
225
+ and every function request and result, is packed into a `buffer` before it is sent and unpacked when
226
+ it arrives. The code that does it is plain buffer code, generated from the declared types. Nothing
227
+ about the API changes, and the guards still run on the decoded values.
228
+
229
+ The switch covers every member. A member can opt in or out on its own: see
230
+ [Opting in and out per event](#opting-in-and-out-per-event).
231
+
232
+ The encoding is generated **at each call site**. For example, `Events.X.fire(value, where)` compiles
233
+ to `buffer.create(20)`, four writes at literal offsets and `Events.X._fire(payload)`, right where the
234
+ call was. A function callback is registered with a generated packer for its result type. The packer
235
+ runs after the middleware, so the result is sent packed.
236
+
237
+ Apart from those result packers and the code inlined at each call site, no function in the output
238
+ encodes an event's or a request's argument list. A file's shared `codec` table does keep a writer for each named or repeated type the
239
+ file reaches (`codec.w_Item`), even in a file that only decodes, and each writes one value into a
240
+ buffer it is given. The format follows from the types, so a payload can be forged; the decoder and
241
+ the guards are what check it.
242
+
243
+ Decoding is generated once per event and function, into the `createServer` / `createClient`
244
+ metadata, because a payload has to be unpacked before the guards and middleware see it. Nothing in
245
+ the output describes the type: there is no schema table and no runtime library.
246
+
247
+ The transformer finds call sites by their type. Send and register callbacks through the handler's
248
+ own type: `Events.X.fire(...)`, a typed reference to `Events.X`, or a helper generic over the event
249
+ name. Such a helper must not return members that are packed differently, such as a `Serialized`
250
+ member and a plain one while `networking.serialization` is off, or a `Raw` member and a packed one:
251
+ a call through it is packed one way, so the build refuses it. It refuses packed members whose
252
+ argument lists differ for the same reason. Don't go through a hand-written interface that widens
253
+ `fire` to `(...args: unknown[])`. Such a call is left alone and sends unpacked values, which the peer drops as malformed. A handler reached
254
+ through `?.` (`this.events?.X.fire(...)`) is packed like any other, behind the same short-circuit.
255
+ `predict` takes plain values and needs no typing, and `connect` is left as it is.
256
+
257
+ The same generator is available on its own as `Flamework.createSerializer<T>()`; see
258
+ [Macros](07-macros.md#serializers).
259
+
260
+ ### What each type costs
261
+
262
+ Sizes follow the types:
263
+
264
+ - A `number` is eight bytes. A `boolean` is one byte, and so is an `"idle" | "walk" | "run"`.
265
+ - An object is its fields in declaration order, with nothing spent on names.
266
+ - A `Vector3` is three floats.
267
+ - Counts and lengths (of arrays, sets, maps, strings and buffers) are varints: one byte below 128,
268
+ two below 16384, up to five.
269
+
270
+ To pick a number's width, use a brand. `Serialization.u8`, `i8`, `u16`, `i16`, `u32`, `i32`, `f32`,
271
+ `f64` and `varint` from `@flamework-experimental/core` are `number & { __brand: "u8" }`-style types.
272
+ Any brand with one of those literal names counts, so branded types you already have keep working.
273
+ `Serialization.string8` / `string16` / `string32` (and `buffer16` / `buffer32`) give a string or
274
+ buffer a fixed-width length instead.
275
+
276
+ Each union value carries a one-byte tag: its member's position as written. In
277
+ `{ Coins: number } | { Items: string[] }`, Coins is 0 and Items is 1. In `number | string`, the
278
+ number is 0. A union with `number` has one more tag, after its members, for a whole number from 0
279
+ up to 2^35 - 1, which is then sent as a varint. So in `number | string`, 3 is the tag 2 and one
280
+ byte, and 2.5 is the tag 0 and eight bytes. Array indices sent as `string | number` map keys stay
281
+ small that way. A `number` that is not in a union is always eight bytes.
282
+
283
+ "As written" means at the declaration the value is reached through: the parameter, property, return
284
+ type or tuple element, including inside arrays, sets, maps and Promises. So `a(x: string | number)`
285
+ and `b(x: number | string)` number their members differently, even though they are one TypeScript
286
+ type. Both sides agree, because both read the same declaration. A union that is not spelled out
287
+ where it is reached keeps TypeScript's order, which is the same everywhere in a program. One example
288
+ is a union that only arrives as a generic's type argument, as in `Box<A | B>`. If the order matters
289
+ to you, declare an alias for it.
290
+
291
+ Object members of a union are told apart by a shared discriminant (`kind: "a"` against
292
+ `kind: "b"`) or by a key only one of them has, so no guard is generated for them. A union with more
293
+ than 255 members travels whole, as a blob (see below).
294
+
295
+ The written order numbers the members. It is also the order most members are tried in, with these
296
+ exceptions:
297
+
298
+ - Members with a test of their own go first: a type, a literal, a discriminant or a key only they
299
+ have. A branded number member (`Serialization.u16`) only takes a number that fits its width, so
300
+ 70000 in `u16 | number` is sent as the `number`. An integer width checks the range and that the
301
+ number is whole; `f32` checks the range only, and rounds what it takes.
302
+ - The other members are checked by a guard: arrays, sets, maps, tuples, and objects without a key
303
+ of their own. A guard checks a value's shape, but not the keys an object does not declare, at any
304
+ depth. So one member can take another member's value and send it without those keys. Flamework
305
+ compares the members' types, nested ones included, and tries a member that would do that after
306
+ the member whose values it would take. So `Point | Map<string, number>` sends
307
+ `{ x: 1, y: 2, z: 3 }` as the map, which keeps `z`.
308
+ - When two members would each take part of the other's values, the written order stands, and
309
+ objects whose fields are all optional go last. The build warns, once for each union in a file (a
310
+ union spelled through another alias or a generic is warned again): a value that fits both may be
311
+ sent as the first, without what only the other declares. A third member that takes such values
312
+ whole can make the warning more cautious than needed.
313
+ - A blob that takes anything, such as `unknown`, goes last.
314
+
315
+ So `Partial<Crate> | None` sends a `None` as `None` whichever way round it is written. When the last
316
+ member tried is an object or a collection without a test of its own, it is only checked to be a
317
+ table.
318
+
319
+ Values that have no buffer representation travel next to the buffer, in a **blob list**. These are
320
+ Instances, `unknown`, `object`, `defined`, class instances, EnumItems, and the Roblox datatypes
321
+ without a layout (anything roblox-ts declares, or anything with a `_nominal_` marker). The buffer
322
+ holds each one's index in that list as a u32. So a nil where an Instance was expected costs four
323
+ bytes and shifts nothing, and the receiving guard rejects it like any other wrong value. The blob
324
+ list is `nil` when the types have no such values, and a table (possibly empty) when they do.
325
+
326
+ Collections nest freely: a `Map<Instance, Array<Set<string>>>` is a varint count of blob keys, each
327
+ followed by its array.
328
+
329
+ Only a value that a remote cannot carry at all is a compile error. The message gives the path
330
+ through the type. These are functions, Promises outside a function's result, symbols, bigint,
331
+ `never`, and `LuaTuple` (several values at runtime, not a table; declare a tuple type such as
332
+ `[A, B]` instead).
333
+
334
+ An argument list that carries nothing (`bump(): void`) sends nothing. No buffer is allocated on
335
+ either side, and the remote fires with no arguments at all.
336
+
337
+ ### Payloads that cannot be decoded
338
+
339
+ A payload that cannot be decoded (truncated, the wrong shape, or hostile) is dropped and reported
340
+ through `onBadRequest` with `argIndex: -1`. A function reply that cannot be decoded rejects with
341
+ `InvalidResult` and fires `onBadResponse`.
342
+
343
+ Decoding never trusts a count or a length it reads. One that announces more elements, or more
344
+ bytes, than the buffer could hold is refused before anything is allocated. Elements that take no
345
+ bytes (a lone literal, `undefined`, an object of only literals) cannot be limited that way, so a
346
+ payload may announce at most 65535 of them in total, however they are nested.
347
+
348
+ Nothing checks a value before it is sent: it is written as its declared type says. Most values that
349
+ do not match raise an error at the sender while they are written, such as a table where a number
350
+ was declared, or a value that fits no member of a union. Some do not:
351
+
352
+ - A string that Luau reads as a number (`"5"`, `"0x10"`) is written as that number.
353
+ - A `boolean` is written as whether the value is truthy.
354
+ - An object is written field by field, so the fields its type does not declare are dropped. Any
355
+ table fits an object whose fields are all optional.
356
+ - The last member tried in a union may only be checked to be a table (see above). A table of the
357
+ wrong shape is then written as that member as far as it goes: a set sends every value as
358
+ `true`, and a tuple drops what is past its length.
359
+ - An array with holes is written without an error. The receiver then rejects the payload as
360
+ malformed.
361
+ - A guard rejects NaN in a `number` field, so in a union such a value can pass to a later member:
362
+ `{ v: number } | { v: boolean }` sends `{ v = NaN }` as `{ v = false }`.
363
+
364
+ A value that does not match its type is a bug in the caller, not in the peer.
365
+
366
+ ### Opting in and out per event
367
+
368
+ A marker on a member overrides the switch for that member alone. The plain name is for functions;
369
+ the `Reliable` and `Unreliable` forms are for events.
370
+
371
+ | Markers | Values travel |
372
+ |---|---|
373
+ | `Raw`, `RawReliable`, `RawUnreliable` | As they are, whatever the switch says. |
374
+ | `Serialized`, `SerializedReliable`, `SerializedUnreliable` | Packed, whatever the switch says. |
375
+
376
+ ```ts
377
+ interface ServerEvents {
378
+ // Packed, even with networking.serialization off.
379
+ saveBuild: Networking.SerializedReliable<(build: BuildData) => void>;
380
+ }
381
+
382
+ interface ClientEvents {
383
+ position: Networking.RawUnreliable<(position: Vector3) => void>;
384
+ chat: Networking.RawReliable<(text: string) => void>;
385
+ // An unreliable event that is packed. These three spellings are one type:
386
+ snapshot: Networking.SerializedUnreliable<(state: State) => void>;
387
+ // snapshot: Networking.Unreliable<Networking.Serialized<(state: State) => void>>;
388
+ // snapshot: Networking.Serialized<Networking.Unreliable<(state: State) => void>>;
389
+ }
390
+
391
+ interface ServerFunctions {
392
+ lookup: Networking.Raw<(id: string) => Entry | undefined>;
393
+ loadPlot: Networking.Serialized<(plotId: number) => PlotData>;
394
+ }
395
+ ```
396
+
397
+ `SerializedUnreliable<T>` is `Unreliable<Serialized<T>>`, and the order does not matter. The same
398
+ goes for `RawUnreliable`. The unreliable forms put the event on an `UnreliableRemoteEvent`, like
399
+ `Unreliable`.
400
+
401
+ A raw member's values are sent as they are, and the generated guards still run on arrival. Use it
402
+ for an event whose payload is already a buffer of your own, or to compare the two formats on the
403
+ wire.
404
+
405
+ A serialized member is packed exactly as the switch would pack it: the encoding at each call site,
406
+ the decoding in the handler metadata, and a function's result after the middleware. With the switch
407
+ on, `Serialized` changes nothing on the wire (its type still differs from a plain member's). With
408
+ it off, it lets a game pack only its heavy remotes.
409
+
410
+ `Raw` and `Serialized` on the same member is a build error, which names the member.
411
+
412
+ Changing a member's marker changes its wire format. The server and the client must come from the
413
+ same build, as they must for the switch.
414
+
415
+ ### Size on the wire
416
+
417
+ Roblox already compresses what a remote carries, buffers included, so the size win is in packing.
418
+ These are bytes per message, measured in Studio on 2026-09-28:
419
+
420
+ | Payload | Plain tables | `Serialized` |
421
+ |---|---|---|
422
+ | A small event: a number, a `Vector3` and a boolean | 35 B | 35 B |
423
+ | An inventory of 500 items (id, name, count, rarity, equipped) | 35.0 KB | 3.1 KB |
424
+ | A 32 by 32 tile map, `Map<string, Tile>` | 46.7 KB | 3.4 KB |
425
+ | A 128 by 128 tile map | 774 KB | 67 KB |
426
+
427
+ Packed, the buffers are 21 B, 13.7 KB, 15.7 KB and 267 KB; the engine shrinks them from there.
428
+ Packing was faster at both ends too: sending the inventory took 126 µs instead of 560, and receiving
429
+ it 342 µs instead of 678. Compressing the packed buffer again with Zstd at level 3 saved at most 4%
430
+ more, and made the small event, the inventory and the 32 by 32 map larger. Level 19 saved 26% on the
431
+ 128 by 128 map, and took 111 ms to compress it.
432
+
433
+ **Unreliable events** drop a message whose payload is over about 1000 bytes, counted after the
434
+ engine's compression. A buffer that does not compress arrives up to 996 bytes, or 988 with a blob
435
+ list next to it. A packed list counts at its compressed size: 120 of the inventory's items went
436
+ through, and 150 did not. As plain tables, 10 did.
437
+
438
+ ## Middleware
439
+
440
+ A middleware is a factory. It receives `processNext` (which calls the next link in the chain) and
441
+ the event's info, and returns the handler for its own link.
442
+
443
+ Type it for any event with a type parameter, so one limiter serves every event:
444
+
445
+ ```ts
446
+ // src/server/middleware/throttle.ts
447
+ import { Networking } from "@flamework-experimental/networking";
448
+ import { Players } from "@rbxts/services";
449
+
450
+ /** Whether a player's message may pass: false when it comes less than `seconds` after the last. */
451
+ function createLimiter(seconds: number) {
452
+ const lastAccepted = new Map<Player, number>();
453
+ Players.PlayerRemoving.Connect((player) => lastAccepted.delete(player));
454
+
455
+ return (player: Player) => {
456
+ const now = os.clock();
457
+ const last = lastAccepted.get(player);
458
+ if (last !== undefined && now - last < seconds) return false;
459
+
460
+ lastAccepted.set(player, now);
461
+ return true;
462
+ };
463
+ }
464
+
465
+ /** Drops a player's event when it comes less than `seconds` after their last accepted one. */
466
+ export function throttle<T extends unknown[]>(seconds: number): Networking.EventMiddleware<T> {
467
+ return (processNext) => {
468
+ const accept = createLimiter(seconds);
469
+ // Not calling processNext drops the event.
470
+ return (player, ...args) => {
471
+ if (player === undefined || accept(player)) return processNext(player, ...args);
472
+ };
473
+ };
474
+ }
475
+ ```
476
+
477
+ `player` may be `undefined` in the type because the client shares it. On the server there is always
478
+ one.
479
+
480
+ The same for a function returns `Networking.Skip` for a call it drops, and the caller's Promise then
481
+ rejects with `Cancelled` at once:
482
+
483
+ ```ts
484
+ export function throttleFunction<T extends unknown[], O>(seconds: number): Networking.FunctionMiddleware<T, O> {
485
+ return (processNext) => {
486
+ const accept = createLimiter(seconds);
487
+ return (player, ...args) => {
488
+ if (player === undefined || accept(player)) return processNext(player, ...args);
489
+ return Networking.Skip;
490
+ };
491
+ };
492
+ }
493
+ ```
494
+
495
+ Each goes in the `middleware` of the handler's config, as in [Where to create the
496
+ handlers](#where-to-create-the-handlers): `getCoins: [throttleFunction(0.5)]`.
497
+
498
+ Things to know:
499
+
500
+ - **Order is registration order.** The first factory in the array is the outermost link.
501
+ - **Generated guards always run first**, ahead of all user middleware, so your middleware never sees
502
+ a payload that failed validation.
503
+ - **You can rewrite arguments** by passing different ones to `processNext`.
504
+ - **`processNext` returns the next link's result**, not a Promise. For an event that is nothing; for
505
+ a function it is the value (or `Networking.Skip`). The chain is plain calls in the thread that
506
+ received the message, so a middleware can read what the handler answered, time the call, or wrap
507
+ it in `pcall`.
508
+ - **A middleware may yield or return a Promise.** A yield holds up only the message it is
509
+ processing. A returned Promise is waited for, and `processNext` in the link that called it returns
510
+ the Promise's value. A cancelled Promise reads as `Networking.Skip` in a function's chain (and as
511
+ nothing in an event's). A rejected one raises an error.
512
+
513
+ Function middleware can also return `Networking.Skip` to cancel the request. The caller's Promise
514
+ then rejects with `Cancelled`:
515
+
516
+ ```ts
517
+ const requireAdmin: Networking.FunctionMiddleware<[itemId: string], boolean> = (processNext) => {
518
+ return (player, itemId) => (isAdmin(player) ? processNext(player, itemId) : Networking.Skip);
519
+ };
520
+ ```
521
+
522
+ The value `processNext` returns is the one the caller will receive, so a middleware can inspect or
523
+ replace it:
524
+
525
+ ```ts
526
+ const logPurchases: Networking.FunctionMiddleware<[itemId: string], boolean> = (processNext, fn) => {
527
+ return (player, itemId) => {
528
+ const bought = processNext(player, itemId);
529
+ print(`${player} ${fn.name} ${itemId}:`, bought);
530
+ return bought;
531
+ };
532
+ };
533
+ ```
534
+
535
+ In the earlier API, `processNext` returned a Promise, and a middleware used
536
+ `processNext(...).andThen(f)`. That becomes `f(processNext(...))`. A middleware that only returns
537
+ `processNext(...)` needs no change.
538
+
539
+ `event`, the second factory argument, is a `Networking.NetworkInfo`: the event's `name`,
540
+ `globalName` and `eventType`. With it you can write generic logging or metrics middleware. A unit
541
+ test can build one link by hand:
542
+
543
+ ```ts
544
+ const info: Networking.NetworkInfo = { name: "spawnCoin", globalName: "test", eventType: "Event" };
545
+ let passed = 0;
546
+ const link = throttle<[]>(1)(() => {
547
+ passed += 1;
548
+ }, info);
549
+
550
+ link(player);
551
+ link(player); // within the second: dropped, so `passed` is 1
552
+ ```
553
+
554
+ ## Observing rejections
555
+
556
+ ```ts
557
+ GlobalEvents.registerHandler("onBadRequest", (player, data) => {
558
+ warn(`${player} sent bad ${data.networkInfo.name} arg #${data.argIndex}:`, data.argValue);
559
+ });
560
+ ```
561
+
562
+ Functions also have `onBadResponse`, for a response that failed its return guard. Both are useful
563
+ for tracking exploit attempts.
564
+
565
+ ## Patterns
566
+
567
+ **One network object per area of the game.** For example, `GlobalEvents` for gameplay and another
568
+ for chat. Each gets its own folder of remotes, and the interfaces stay readable.
569
+
570
+ **Handlers in providers, logic elsewhere.** Connect in `onStart`, and have the handler call into
571
+ other code right away. The handler is a boundary, not a place for game rules.
572
+
573
+ **Trust nothing from the client.** The guards check *shapes*, not values: `buy(itemId: string)`
574
+ guarantees a string, not that the player can afford it.
575
+
576
+ **Use middleware only for concerns that cut across many events**: rate limits, admin checks,
577
+ logging. Game rules belong in the handler, where you can test them.
578
+
579
+ ## Caveats
580
+
581
+ - **`createServer` on the client returns nothing** (and vice versa), without an error. Outside a
582
+ running game both calls work, so the mistake shows up at runtime, not in Studio's editor.
583
+ - **Config and middleware must be object literals.**
584
+ - **The first `createServer`/`createClient` call wins.** The handler is cached per network object.
585
+ Later calls return the same one and ignore their config, so configure it once.
586
+ - **Guards are incoming-only.** Nothing checks what you send, only what you receive. For a packed
587
+ member, most values that do not match their type raise while they are written; see
588
+ [Payloads that cannot be decoded](#payloads-that-cannot-be-decoded) for the ones that do not.
589
+ - **The server and the client must come from the same build.** The switch is per project and the
590
+ markers are per member, and both realms read the same `flamework.config.json` and the same types,
591
+ so one build always agrees with itself. A client built without the switch, or with a member marked
592
+ differently, cannot talk to a server built with it.
593
+ - **Unreliable events can be dropped.** Never make later messages depend on an earlier one.
594
+ - **An event uses one remote for both directions; a function uses two.** In ReplicatedStorage, a
595
+ function's two remotes share a name and differ only by their `id` attribute (`$name` for one
596
+ direction, `@name` for the other).
597
+ - **Unreliable events can be missed by a late listener.** Flamework listens to a remote from the
598
+ first `connect` on, a moment later (a `task.defer`), so every handler connected in that moment
599
+ gets what was waiting. The engine keeps reliable events for a remote nothing listens to yet, up
600
+ to a limit, and hands them to the first connection; past the limit it drops them. It drops
601
+ unreliable ones outright. So an unreliable event sent before the first `connect` is lost. Under
602
+ `Immediate` signal behaviour, so is one that arrives right behind the event whose handler makes
603
+ that first `connect`. Connect handlers for unreliable events before the other side
604
+ can send them.
605
+ - **Remote folder names are stable across builds, unless obfuscation is on.** Each network object's
606
+ folder in ReplicatedStorage is named by a callsite id taken from the file and the declaration.
607
+ Without obfuscation, two builds of the same source produce the same tree, so committed output
608
+ does not change from build to build. With obfuscation on, the names change with every plain build;
609
+ a running watcher and an incremental build keep them
610
+ ([Obfuscation](09-project-structure.md#obfuscation)).
611
+
612
+ ---
613
+
614
+ Previous: [Components](05-components.md) · Next: [Macros](07-macros.md)