@flamework-experimental/networking 2.0.0-alpha.0 → 2.0.0-alpha.2

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.
Files changed (44) hide show
  1. package/README.md +6 -0
  2. package/flamework.build +1 -1
  3. package/out/event/createEvent.d.ts +22 -4
  4. package/out/event/createEvent.luau +47 -49
  5. package/out/events/createClientMethod.luau +7 -3
  6. package/out/events/createGenericHandler.luau +6 -3
  7. package/out/events/createNetworkingEvent.luau +7 -5
  8. package/out/events/createServerMethod.d.ts +1 -1
  9. package/out/events/createServerMethod.luau +8 -3
  10. package/out/events/types.d.ts +4 -3
  11. package/out/function/createFunctionReceiver.d.ts +10 -9
  12. package/out/function/createFunctionReceiver.luau +53 -55
  13. package/out/function/createFunctionSender.d.ts +3 -2
  14. package/out/function/createFunctionSender.luau +118 -40
  15. package/out/functions/createClientMethod.d.ts +1 -1
  16. package/out/functions/createClientMethod.luau +9 -8
  17. package/out/functions/createGenericHandler.luau +8 -12
  18. package/out/functions/createNetworkingFunction.luau +7 -5
  19. package/out/functions/createServerMethod.d.ts +1 -1
  20. package/out/functions/createServerMethod.luau +9 -8
  21. package/out/functions/types.d.ts +10 -12
  22. package/out/handlers.d.ts +35 -36
  23. package/out/index.d.ts +6 -0
  24. package/out/init.luau +6 -0
  25. package/out/middleware/createGuards.d.ts +10 -0
  26. package/out/middleware/createGuards.luau +32 -0
  27. package/out/middleware/processor.d.ts +62 -0
  28. package/out/middleware/processor.luau +214 -0
  29. package/out/middleware/types.d.ts +26 -15
  30. package/out/util/createOnce.d.ts +14 -0
  31. package/out/util/createOnce.luau +66 -0
  32. package/out/util/createSignalContainer.d.ts +2 -1
  33. package/out/util/createSignalContainer.luau +2 -2
  34. package/out/util/signal.d.ts +27 -0
  35. package/out/util/signal.luau +135 -0
  36. package/out/util/trimArguments.d.ts +12 -0
  37. package/out/util/trimArguments.luau +48 -0
  38. package/package.json +2 -3
  39. package/out/middleware/createGuardMiddleware.d.ts +0 -6
  40. package/out/middleware/createGuardMiddleware.luau +0 -44
  41. package/out/middleware/createMiddlewareProcessor.d.ts +0 -3
  42. package/out/middleware/createMiddlewareProcessor.luau +0 -24
  43. package/out/util/timeoutPromise.d.ts +0 -1
  44. package/out/util/timeoutPromise.luau +0 -10
@@ -0,0 +1,32 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ local Players = TS.import(script, TS.getModule(script, "@rbxts", "services")).Players
4
+ --[[
5
+ *
6
+ * The generated argument checks of one event or function, for the receive pipeline, which runs them
7
+ * ahead of all user middleware. A failure warns (with `warnOnInvalid`) and fires `onBadRequest`.
8
+
9
+ ]]
10
+ local function createGuards(name, fixedParameters, restParameter, networkInfo, warnOnInvalid, signals)
11
+ return {
12
+ fixed = fixedParameters,
13
+ rest = restParameter,
14
+ reject = function(player, index, value)
15
+ if warnOnInvalid then
16
+ if player then
17
+ warn(`'{player}' sent invalid arguments for event '{name}' (arg #{index}):`, value)
18
+ else
19
+ warn(`Server sent invalid arguments for event '{name}' (arg #{index}):`, value)
20
+ end
21
+ end
22
+ signals:fire("onBadRequest", player or Players.LocalPlayer, {
23
+ networkInfo = networkInfo,
24
+ argIndex = index,
25
+ argValue = value,
26
+ })
27
+ end,
28
+ }
29
+ end
30
+ return {
31
+ createGuards = createGuards,
32
+ }
@@ -0,0 +1,62 @@
1
+ import { Serialization } from "@flamework-experimental/core";
2
+ import { t } from "@rbxts/t";
3
+ import { NetworkInfo } from "../types";
4
+ import { MiddlewareFactory } from "./types";
5
+ import { Signal } from "../util/signal";
6
+
7
+ /** One step of the receive pipeline: `(player, ...args) -> result`. The player is `undefined` on a client. */
8
+ export type Processor = (player: Player | undefined, ...args: unknown[]) => unknown;
9
+
10
+ /** The generated argument checks, run ahead of all user middleware. */
11
+ export interface Guards {
12
+ /** `fixed[i]` checks argument `i`. */
13
+ fixed: ReadonlyArray<t.check<unknown>>;
14
+
15
+ /** Checks every argument past the fixed ones, for a rest parameter. */
16
+ rest: t.check<unknown> | undefined;
17
+
18
+ /** Called with the first argument that failed, by its 0-based index. */
19
+ reject: (player: Player | undefined, index: number, value: unknown) => void;
20
+ }
21
+
22
+ /**
23
+ * Folds the guards and the middleware into one processor that runs them as plain calls in the
24
+ * calling thread and returns what the last link did, a Promise already followed (see `processor.luau`).
25
+ * @param rejected What the processor returns when a guard fails.
26
+ * @param cancelled What a Promise that was cancelled reads as.
27
+ */
28
+ export function createProcessor(
29
+ middleware: ReadonlyArray<MiddlewareFactory<any, any>> | undefined,
30
+ networkInfo: NetworkInfo,
31
+ final: Processor,
32
+ guards: Guards | undefined,
33
+ rejected: unknown,
34
+ cancelled: unknown,
35
+ ): Processor;
36
+
37
+ /**
38
+ * The handler for a remote's `OnServerEvent` (`withPlayer`) or `OnClientEvent`: decodes the payload
39
+ * when there is a decoder, reporting and dropping one it cannot read, and runs `process`.
40
+ */
41
+ export function createReceiver(
42
+ decoder: Serialization.Decoder | undefined,
43
+ onMalformed: ((player: Player | undefined, message: string) => void) | undefined,
44
+ process: Processor,
45
+ withPlayer: boolean,
46
+ ): (...args: unknown[]) => void;
47
+
48
+ /**
49
+ * Unpacks a serialized argument list, `(payload, blobs?)` as the remote carried it, under `pcall`:
50
+ * `true` and the list, or `false` and why it could not be read.
51
+ */
52
+ export function decode(
53
+ decoder: Serialization.Decoder,
54
+ payload: unknown,
55
+ blobs: unknown,
56
+ ): LuaTuple<[ok: true, list: unknown[]] | [ok: false, message: string]>;
57
+
58
+ /** The last step of an event's chain: fires `signal`, with the sender first when `withPlayer`. */
59
+ export function deliverTo(signal: Signal, withPlayer: boolean): Processor;
60
+
61
+ /** `callback` called without the player in front, which is how a client's callback is written. */
62
+ export function withoutPlayer(callback: (...args: never[]) => unknown): Processor;
@@ -0,0 +1,214 @@
1
+ --[[
2
+ The receive pipeline: what runs between a remote delivering a message (or `predict` standing in
3
+ for one) and the handler that receives it.
4
+
5
+ Plain calls in the thread that received the message: the payload is decoded, the generated guards
6
+ check the arguments, each middleware calls the next through `processNext`, and the last step
7
+ delivers (an event's signal, a function's callback). Nothing here makes a Promise or a thread. A
8
+ middleware may still yield, which holds up only the message it is processing, and one that
9
+ returns a Promise is waited for in place.
10
+
11
+ Plain Luau rather than TypeScript so that an argument list keeps its count: roblox-ts builds a
12
+ table from every `...args` and spreads it with `unpack`, which stops at `#list` and may lose the
13
+ values after a nil (see `util/trimArguments.ts`). Here the list travels as varargs, and is only cut
14
+ after its last value where it enters and where a middleware hands on a list of its own.
15
+ ]]
16
+
17
+ local TS = _G[script]
18
+
19
+ --- Stands in for a missing blob list when decoding: a sender whose type has blob slots always sends one.
20
+ local NO_BLOBS = {}
21
+
22
+ --[[
23
+ `callback(player, ...)`, with the argument list cut after its last value.
24
+
25
+ A list that ends in nil would reach a TypeScript `(...args)` as a table whose `#` can stop at any
26
+ earlier gap: `{1, nil, 3, nil}` may read as one value. One that ends in a value spreads whole. A
27
+ middleware that names its parameters, `(player, id, name) => processNext(player, id, name)`,
28
+ passes on a list ending in nil when `name` was not sent, which is why every link cuts it again.
29
+ Nothing is lost: an argument that was not passed reads as nil too.
30
+ ]]
31
+ local function callTrimmed(callback: (...any) -> ...any, player: any, ...: any): any
32
+ local count = select("#", ...)
33
+ if count == 0 or select(count, ...) ~= nil then
34
+ return callback(player, ...)
35
+ end
36
+
37
+ repeat
38
+ count -= 1
39
+ until count == 0 or select(count, ...) ~= nil
40
+
41
+ return callback(player, table.unpack({ ... }, 1, count))
42
+ end
43
+
44
+ --[[
45
+ A link's result, with a Promise it returned followed in this thread: its value once resolved,
46
+ `cancelled` if it was cancelled, and its rejection raised.
47
+ ]]
48
+ local function follow(value: any, cancelled: any): any
49
+ if type(value) ~= "table" then
50
+ return value
51
+ end
52
+
53
+ local Promise = TS.Promise
54
+ if not Promise.is(value) then
55
+ return value
56
+ end
57
+
58
+ local status, result = value:awaitStatus()
59
+ if status == Promise.Status.Resolved then
60
+ return result
61
+ elseif status == Promise.Status.Cancelled then
62
+ return cancelled
63
+ end
64
+
65
+ error(result, 0)
66
+ end
67
+
68
+ --[[
69
+ Folds the guards and the middleware into one function, `(player, ...args) -> result`, that runs
70
+ them in order in the calling thread and returns what the last one did.
71
+
72
+ `middleware` is a list of factories, the first the outermost; each is handed `processNext`, which
73
+ calls the next link and returns its result (a Promise the link returned already followed). A link
74
+ that does not call it drops the message there. `final` ends the chain.
75
+
76
+ `guards`, when given, check the arguments before any middleware runs: `fixed[i]` checks argument
77
+ `i`, `rest` every one past them. The first one that fails is reported through `reject` with its
78
+ 0-based index, and the processor returns `rejected` without calling anything else.
79
+
80
+ `cancelled` is what a Promise that was cancelled reads as (`Networking.Skip` for a function).
81
+ ]]
82
+ local function createProcessor(
83
+ middleware: { (processNext: any, networkInfo: any) -> any }?,
84
+ networkInfo: any,
85
+ final: (...any) -> ...any,
86
+ guards: any,
87
+ rejected: any,
88
+ cancelled: any
89
+ )
90
+ local head = final
91
+ if middleware ~= nil then
92
+ for index = #middleware, 1, -1 do
93
+ local after = head
94
+ head = middleware[index](function(player, ...)
95
+ return follow(callTrimmed(after, player, ...), cancelled)
96
+ end, networkInfo)
97
+ end
98
+ end
99
+
100
+ if guards == nil then
101
+ return function(player, ...)
102
+ return follow(callTrimmed(head, player, ...), cancelled)
103
+ end
104
+ end
105
+
106
+ local fixed = guards.fixed
107
+ local rest = guards.rest
108
+ local reject = guards.reject
109
+ local fixedCount = #fixed
110
+
111
+ return function(player, ...)
112
+ -- Checked up to the last value: a trailing nil is an argument that was not passed.
113
+ local count = select("#", ...)
114
+ while count > 0 and select(count, ...) == nil do
115
+ count -= 1
116
+ end
117
+
118
+ for index = 1, math.max(fixedCount, count) do
119
+ local guard = fixed[index] or rest
120
+ if guard ~= nil then
121
+ local value = select(index, ...)
122
+ if not guard(value) then
123
+ reject(player, index - 1, value)
124
+ return rejected
125
+ end
126
+ end
127
+ end
128
+
129
+ return follow(callTrimmed(head, player, ...), cancelled)
130
+ end
131
+ end
132
+
133
+ --[[
134
+ Unpacks a serialized argument list, `(payload, blobs?)` as the remote carried it. Returns `true`
135
+ and the list, or `false` and why it could not be read. The decoder runs under `pcall`: a hostile
136
+ buffer raises instead of yielding garbage.
137
+ ]]
138
+ local function decode(decoder: (buffer, { any }) -> { any }, payload: any, blobs: any): (boolean, any)
139
+ if type(payload) ~= "buffer" or (blobs ~= nil and type(blobs) ~= "table") then
140
+ return false, "payload is not a buffer with an optional blob list"
141
+ end
142
+
143
+ local ok, result = pcall(decoder, payload, blobs or NO_BLOBS)
144
+ if not ok then
145
+ return false, tostring(result)
146
+ end
147
+
148
+ return true, result
149
+ end
150
+
151
+ --[[
152
+ The handler to connect to a remote's `OnServerEvent` (`withPlayer`) or `OnClientEvent`: decodes
153
+ the payload when there is a `decoder`, reporting one it cannot read through `onMalformed` and
154
+ dropping it, and hands the arguments to `process`, in the thread the engine runs the handler on.
155
+ ]]
156
+ local function createReceiver(
157
+ decoder: ((buffer, { any }) -> { any })?,
158
+ onMalformed: ((player: any, message: string) -> ())?,
159
+ process: (...any) -> ...any,
160
+ withPlayer: boolean
161
+ )
162
+ local receive = process
163
+ if decoder ~= nil then
164
+ receive = function(player, payload, blobs)
165
+ local ok, result = decode(decoder, payload, blobs)
166
+ if not ok then
167
+ if onMalformed ~= nil then
168
+ onMalformed(player, result)
169
+ end
170
+ return
171
+ end
172
+
173
+ -- A decoded list has a slot per declared parameter, so an absent trailing optional leaves
174
+ -- it ending in nil: unpacked up to its last value.
175
+ return process(player, table.unpack(result, 1, table.maxn(result)))
176
+ end
177
+ end
178
+
179
+ if withPlayer then
180
+ return receive
181
+ end
182
+
183
+ return function(...)
184
+ return receive(nil, ...)
185
+ end
186
+ end
187
+
188
+ --- The last step of an event's chain: fires `signal`, with the sender first on the server.
189
+ local function deliverTo(signal: any, withPlayer: boolean)
190
+ if withPlayer then
191
+ return function(player, ...)
192
+ signal:Fire(player, ...)
193
+ end
194
+ end
195
+
196
+ return function(_player, ...)
197
+ signal:Fire(...)
198
+ end
199
+ end
200
+
201
+ --- `callback` called without the player in front, which is how a client's callback is written.
202
+ local function withoutPlayer(callback: (...any) -> ...any)
203
+ return function(_player, ...)
204
+ return callback(...)
205
+ end
206
+ end
207
+
208
+ return {
209
+ createProcessor = createProcessor,
210
+ createReceiver = createReceiver,
211
+ decode = decode,
212
+ deliverTo = deliverTo,
213
+ withoutPlayer = withoutPlayer,
214
+ }
@@ -1,15 +1,26 @@
1
- import { NetworkInfo } from "../types";
2
- import { Skip } from "./skip";
3
-
4
- export type MiddlewareProcessor<I extends readonly unknown[], O> = (player?: Player, ...args: I) => Promise<O>;
5
- export type Middleware<I extends readonly unknown[] = unknown[], O = void> = (
6
- player?: Player,
7
- ...args: I
8
- ) => O | Promise<O>;
9
- export type MiddlewareFactory<I extends readonly unknown[] = [], O = void> = (
10
- processNext: MiddlewareProcessor<I, O>,
11
- event: NetworkInfo,
12
- ) => Middleware<I, O>;
13
-
14
- export type EventMiddleware<I extends readonly unknown[] = unknown[]> = MiddlewareFactory<I, void>;
15
- export type FunctionMiddleware<I extends readonly unknown[] = unknown[], O = void> = MiddlewareFactory<I, O | Skip>;
1
+ import { NetworkInfo } from "../types";
2
+ import { Skip } from "./skip";
3
+
4
+ /**
5
+ * Calls the next link of the chain and returns its result: nothing for an event, the value (or
6
+ * `Networking.Skip`) for a function. A Promise the next link returned has already been waited for,
7
+ * in this thread.
8
+ */
9
+ export type MiddlewareProcessor<I extends readonly unknown[], O> = (player?: Player, ...args: I) => O;
10
+
11
+ /**
12
+ * One link of the chain. It may yield, which holds up only the message it is processing, and it may
13
+ * return a Promise, which is waited for before the link ahead of it continues.
14
+ */
15
+ export type Middleware<I extends readonly unknown[] = unknown[], O = void> = (
16
+ player?: Player,
17
+ ...args: I
18
+ ) => O | Promise<O>;
19
+
20
+ export type MiddlewareFactory<I extends readonly unknown[] = [], O = void> = (
21
+ processNext: MiddlewareProcessor<I, O>,
22
+ event: NetworkInfo,
23
+ ) => Middleware<I, O>;
24
+
25
+ export type EventMiddleware<I extends readonly unknown[] = unknown[]> = MiddlewareFactory<I, void>;
26
+ export type FunctionMiddleware<I extends readonly unknown[] = unknown[], O = void> = MiddlewareFactory<I, O | Skip>;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Returns a function that builds a value on its first call and returns that value from then on.
3
+ *
4
+ * `build` may yield: a client handler waits for the server's remotes to replicate. A thread that
5
+ * calls while a build is under way waits for it rather than starting a second one, which would wire
6
+ * a second handler to the same remotes. If the build throws, the thread that started it gets the
7
+ * error and the next waiting thread builds again.
8
+ *
9
+ * The build runs on a thread of its own, and the thread that started it waits like the others. A
10
+ * caller can be killed while it waits (`task.cancel`, a cancelled Promise, a test's timeout), and a
11
+ * killed thread never reaches a `finally`: a build killed with its caller would leave every thread
12
+ * queued behind it, and every later caller, waiting for good.
13
+ */
14
+ export declare function createOnce<T extends defined>(): (build: () => T) => T;
@@ -0,0 +1,66 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ --[[
3
+ *
4
+ * Returns a function that builds a value on its first call and returns that value from then on.
5
+ *
6
+ * `build` may yield: a client handler waits for the server's remotes to replicate. A thread that
7
+ * calls while a build is under way waits for it rather than starting a second one, which would wire
8
+ * a second handler to the same remotes. If the build throws, the thread that started it gets the
9
+ * error and the next waiting thread builds again.
10
+ *
11
+ * The build runs on a thread of its own, and the thread that started it waits like the others. A
12
+ * caller can be killed while it waits (`task.cancel`, a cancelled Promise, a test's timeout), and a
13
+ * killed thread never reaches a `finally`: a build killed with its caller would leave every thread
14
+ * queued behind it, and every later caller, waiting for good.
15
+
16
+ ]]
17
+ local function createOnce()
18
+ local value
19
+ local waiting
20
+ return function(build)
21
+ while value == nil do
22
+ if waiting ~= nil then
23
+ local _waiting = waiting
24
+ local _arg0 = coroutine.running()
25
+ table.insert(_waiting, _arg0)
26
+ coroutine.yield()
27
+ continue
28
+ end
29
+ local threads = {}
30
+ waiting = threads
31
+ -- Set by the build's thread, so widened: the checks below must not narrow them to their start.
32
+ local finished = false
33
+ local failure = nil
34
+ task.spawn(function()
35
+ local ok, result = pcall(build)
36
+ if ok then
37
+ value = result
38
+ else
39
+ failure = {
40
+ reason = result,
41
+ }
42
+ end
43
+ finished = true
44
+ waiting = nil
45
+ for _, thread in threads do
46
+ -- A waiter killed meanwhile, the starter included, is not resumed.
47
+ if coroutine.status(thread) == "suspended" then
48
+ task.spawn(thread)
49
+ end
50
+ end
51
+ end)
52
+ if not finished then
53
+ local _arg0 = coroutine.running()
54
+ table.insert(threads, _arg0)
55
+ coroutine.yield()
56
+ end
57
+ if failure then
58
+ error(failure.reason, 0)
59
+ end
60
+ end
61
+ return value
62
+ end
63
+ end
64
+ return {
65
+ createOnce = createOnce,
66
+ }
@@ -1,5 +1,6 @@
1
+ import { SignalConnection } from "./signal";
1
2
  export interface SignalContainer<T> {
2
3
  fire<K extends keyof T>(name: K, ...args: Parameters<T[K]>): void;
3
- connect<K extends keyof T>(name: K, callback: T[K]): RBXScriptConnection;
4
+ connect<K extends keyof T>(name: K, callback: T[K]): SignalConnection;
4
5
  }
5
6
  export declare function createSignalContainer<T>(): SignalContainer<T>;
@@ -1,6 +1,6 @@
1
1
  -- Compiled with roblox-ts v3.0.0
2
2
  local TS = _G[script]
3
- local Signal = TS.import(script, TS.getModule(script, "@rbxts", "signal"))
3
+ local createSignal = TS.import(script, script.Parent, "signal").createSignal
4
4
  local function createSignalContainer()
5
5
  local signals = {}
6
6
  return {
@@ -17,7 +17,7 @@ local function createSignalContainer()
17
17
  local signal = signals[_name]
18
18
  if not signal then
19
19
  local _exp = name
20
- signal = Signal.new()
20
+ signal = createSignal()
21
21
  local _signal = signal
22
22
  signals[_exp] = _signal
23
23
  end
@@ -0,0 +1,27 @@
1
+ /**
2
+ * A handler's connection, public as `Networking.Connection`. Shaped like an `RBXScriptConnection`
3
+ * (`Connected`, `Disconnect`), plus `Destroy` for maids and janitors; it is a table, not an engine
4
+ * connection.
5
+ */
6
+ export interface SignalConnection {
7
+ readonly Connected: boolean;
8
+ Disconnect(): void;
9
+ Destroy(): void;
10
+ }
11
+
12
+ /**
13
+ * Networking's own signal (see `signal.luau`): arguments are passed by reference, each handler runs
14
+ * on a recycled thread of its own, newest connection first.
15
+ */
16
+ export interface Signal<T extends Callback = Callback> {
17
+ Connect(callback: T): SignalConnection;
18
+ Fire(...args: Parameters<T>): void;
19
+ }
20
+
21
+ export function createSignal<T extends Callback = Callback>(): Signal<T>;
22
+
23
+ /**
24
+ * Runs `callback(...args)` at once on a recycled thread: a yield inside it does not hold up the
25
+ * caller, and an error is printed rather than raised to it.
26
+ */
27
+ export function spawn<A extends unknown[]>(callback: (...args: A) => unknown, ...args: A): void;
@@ -0,0 +1,135 @@
1
+ --[[
2
+ A signal of networking's own, and the recycled threads it and the receive pipeline run on.
3
+
4
+ Plain Luau rather than TypeScript: roblox-ts turns every `...args` into a table and spreads it with
5
+ `unpack`, which stops at the first nil it happens to land on. Here a vararg list keeps its count
6
+ from the remote to the handler, and nothing is allocated on the way.
7
+
8
+ Arguments are handed over by reference. A BindableEvent would copy them, turning a decoded
9
+ `Map<Instance, ...>` into one keyed by strings and raising on a `Set<boolean>`.
10
+
11
+ Like an engine signal, each handler runs on a thread of its own: one that yields does not hold up
12
+ the others, and one that raises has its error printed while the others still run. The newest
13
+ connection runs first, which is the order the engine runs a BindableEvent's handlers in; one made
14
+ during a fire does not receive it, and one disconnected before its turn is skipped.
15
+ ]]
16
+
17
+ --- Threads that finished their last job, waiting in `coroutine.yield()` for the next.
18
+ local idle: { thread } = {}
19
+
20
+ --- How many finished threads are kept. More than a few are only ever needed while handlers yield;
21
+ --- past this, a thread that finishes simply ends.
22
+ local MAX_IDLE = 16
23
+
24
+ local function call(callback: (...any) -> ...any, ...: any)
25
+ callback(...)
26
+ end
27
+
28
+ --[[
29
+ The body of a recycled thread: waits for a job, runs it, parks itself, and runs whatever it is
30
+ resumed with next. A job that raises ends the thread, and the error is printed by the scheduler
31
+ that resumed it; a job that yields keeps the thread until it returns, so it is only ever parked
32
+ when idle.
33
+
34
+ It takes no arguments, and every job arrives through `coroutine.yield()`: a job passed as the
35
+ thread's own arguments would stay in this frame's varargs for as long as the thread lives,
36
+ keeping its handler, sender and arguments reachable long after it returned.
37
+ ]]
38
+ local function runner()
39
+ local thread = coroutine.running()
40
+
41
+ while true do
42
+ call(coroutine.yield())
43
+
44
+ if #idle >= MAX_IDLE then
45
+ return
46
+ end
47
+ table.insert(idle, thread)
48
+ end
49
+ end
50
+
51
+ --[[
52
+ Runs `callback(...)` at once on a thread of its own, recycling one that finished its last job. An
53
+ idle thread that was killed meanwhile (`task.cancel`) is dropped rather than resumed.
54
+ ]]
55
+ local function spawn(callback: (...any) -> ...any, ...: any)
56
+ local count = #idle
57
+ while count > 0 do
58
+ local thread = idle[count]
59
+ idle[count] = nil
60
+ count -= 1
61
+
62
+ if coroutine.status(thread) == "suspended" then
63
+ task.spawn(thread, callback, ...)
64
+ return
65
+ end
66
+ end
67
+
68
+ -- A new thread, started up to its first `coroutine.yield()`, gets its first job the same way.
69
+ local thread = coroutine.create(runner)
70
+ coroutine.resume(thread)
71
+ task.spawn(thread, callback, ...)
72
+ end
73
+
74
+ local Connection = {}
75
+ Connection.__index = Connection
76
+
77
+ --- Disconnects the handler. Takes effect at once, even for a fire under way.
78
+ function Connection:Disconnect()
79
+ if not self.Connected then
80
+ return
81
+ end
82
+
83
+ self.Connected = false
84
+ self._callback = nil
85
+
86
+ -- Copied rather than edited in place: a fire under way keeps walking the list it started with.
87
+ local signal = self._signal
88
+ local connections = signal._connections
89
+ local index = table.find(connections, self)
90
+ if index ~= nil then
91
+ local remaining = table.clone(connections)
92
+ table.remove(remaining, index)
93
+ signal._connections = remaining
94
+ end
95
+ end
96
+
97
+ --- The name maids and janitors call.
98
+ Connection.Destroy = Connection.Disconnect
99
+
100
+ local Signal = {}
101
+ Signal.__index = Signal
102
+
103
+ function Signal:Connect(callback: (...any) -> ...any)
104
+ local connection = setmetatable({
105
+ Connected = true,
106
+ _callback = callback,
107
+ _signal = self,
108
+ }, Connection)
109
+
110
+ -- Newest first, copied so that a fire under way does not reach it.
111
+ local connections = self._connections
112
+ local updated = table.create(#connections + 1)
113
+ updated[1] = connection
114
+ table.move(connections, 1, #connections, 2, updated)
115
+ self._connections = updated
116
+
117
+ return connection
118
+ end
119
+
120
+ function Signal:Fire(...: any)
121
+ for _, connection in self._connections do
122
+ if connection.Connected then
123
+ spawn(connection._callback, ...)
124
+ end
125
+ end
126
+ end
127
+
128
+ local function createSignal()
129
+ return setmetatable({ _connections = {} }, Signal)
130
+ end
131
+
132
+ return {
133
+ createSignal = createSignal,
134
+ spawn = spawn,
135
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `args` cut after its last value, so that spreading it passes every argument.
3
+ *
4
+ * roblox-ts spreads a list as `unpack(list)`, which stops at `#list`. When the last slot is nil, `#`
5
+ * may stop at any earlier gap: `#{1, nil, nil, 4, nil}` can be 1, which drops the 4. A list that ends
6
+ * in a value always spreads whole. The `{ ... }` that roblox-ts rebuilds from that spread at the next
7
+ * hop also ends in a value, and so does every hop after it that passes the list on. So a list is
8
+ * trimmed where it enters, and again wherever code may build its own: a middleware that names its
9
+ * parameters passes on a list that can end in nil. Nothing is lost by trimming: an argument that was
10
+ * not passed reads as nil too.
11
+ */
12
+ export declare function trimArguments<T extends unknown[]>(args: T): T;
@@ -0,0 +1,48 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ --[[
3
+ *
4
+ * `args` cut after its last value, so that spreading it passes every argument.
5
+ *
6
+ * roblox-ts spreads a list as `unpack(list)`, which stops at `#list`. When the last slot is nil, `#`
7
+ * may stop at any earlier gap: `#{1, nil, nil, 4, nil}` can be 1, which drops the 4. A list that ends
8
+ * in a value always spreads whole. The `{ ... }` that roblox-ts rebuilds from that spread at the next
9
+ * hop also ends in a value, and so does every hop after it that passes the list on. So a list is
10
+ * trimmed where it enters, and again wherever code may build its own: a middleware that names its
11
+ * parameters passes on a list that can end in nil. Nothing is lost by trimming: an argument that was
12
+ * not passed reads as nil too.
13
+
14
+ ]]
15
+ local function trimArguments(args)
16
+ -- `pairs` visits every value, holes or not. Read as a map, the list gives the raw Luau (1-based)
17
+ -- index, which is the length up to that value.
18
+ local length = 0
19
+ for index in pairs(args) do
20
+ if index > length then
21
+ length = index
22
+ end
23
+ end
24
+ if #args == length then
25
+ return args
26
+ end
27
+ -- Sized up front, so that the last slot is the last value and `#` is exact.
28
+ local trimmed = table.create(length)
29
+ do
30
+ local i = 0
31
+ local _shouldIncrement = false
32
+ while true do
33
+ if _shouldIncrement then
34
+ i += 1
35
+ else
36
+ _shouldIncrement = true
37
+ end
38
+ if not (i < length) then
39
+ break
40
+ end
41
+ trimmed[i + 1] = args[i + 1]
42
+ end
43
+ end
44
+ return trimmed
45
+ end
46
+ return {
47
+ trimArguments = trimArguments,
48
+ }