@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.
- package/README.md +11 -6
- package/docs/README.md +63 -0
- package/docs/guide/01-getting-started.md +334 -0
- package/docs/guide/02-modules.md +254 -0
- package/docs/guide/03-providers.md +423 -0
- package/docs/guide/04-lifecycle-events.md +423 -0
- package/docs/guide/05-components.md +793 -0
- package/docs/guide/06-networking.md +614 -0
- package/docs/guide/07-macros.md +332 -0
- package/docs/guide/08-plugins.md +203 -0
- package/docs/guide/09-project-structure.md +392 -0
- package/docs/guide/10-migrating-from-v1.md +573 -0
- package/docs/guide/11-scopes.md +165 -0
- package/docs/guide/12-testing.md +342 -0
- package/flamework.build +1 -1
- package/out/index.d.ts +1 -0
- package/out/init.luau +1 -0
- package/out/module/module.luau +1 -1
- package/out/module/moduleBuilder.luau +1 -1
- package/out/utility/getClassesInPath.d.ts +27 -1
- package/out/utility/getClassesInPath.luau +101 -19
- package/out/utility/pathRoot.d.ts +20 -1
- package/out/utility/pathRoot.luau +80 -4
- package/package.json +14 -7
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# 7. Macros
|
|
2
|
+
|
|
3
|
+
A **macro** is a function with some arguments that the transformer fills in when you build, from
|
|
4
|
+
the place where the function is called (the **callsite**). Macros are why `Flamework.id<Shop>()`
|
|
5
|
+
knows about `Shop` at runtime, and why `registerProviders("src/services")` knows where that folder
|
|
6
|
+
ends up in the DataModel.
|
|
7
|
+
|
|
8
|
+
This matters for one practical reason: **when a macro does not fire, you get `nil`, not an error.**
|
|
9
|
+
|
|
10
|
+
## What you already used
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
Flamework.id<Shop>(); // the type's generated identifier, as a string
|
|
14
|
+
Flamework.implements<OnTick>(value); // does this object implement the interface?
|
|
15
|
+
Flamework.createGuard<{ x: number }>(); // a `t` guard generated from the type
|
|
16
|
+
Flamework.env("BUILD_CHANNEL", "dev"); // an environment variable, inlined as a string literal
|
|
17
|
+
Modding.inspect<Array<"a" | "b">>(); // ["a", "b"] at runtime
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`Flamework.env` reads the variable from `.env`, `.env.local` and the process environment when
|
|
21
|
+
`rbxtsc` starts (see
|
|
22
|
+
[values from the environment](09-project-structure.md#values-from-the-environment)). It replaces
|
|
23
|
+
the call with the value, so nothing is looked up at runtime:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
const channel = Flamework.env("BUILD_CHANNEL", "dev"); // string: the fallback is inlined if unset
|
|
27
|
+
const tests = Flamework.env("TESTS_ENABLED"); // string | undefined: nil if unset
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```lua
|
|
31
|
+
local channel = "dev"
|
|
32
|
+
local tests = "true"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The value is always the string as written in `.env`, so compare or convert it yourself. The
|
|
36
|
+
fallback has to be a string literal, since it is inlined too. Use `Flamework.env` for deployment
|
|
37
|
+
values, such as a place id, a channel or a version. Don't use it for secrets: the value ends up in
|
|
38
|
+
the emitted Luau, where anyone with the place can read it.
|
|
39
|
+
|
|
40
|
+
`Modding.inspect` is the general way to get a type as a value:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
Modding.inspect<{ label: "hello"; count: 3 }>(); // { label: "hello", count: 3 }
|
|
44
|
+
Modding.inspect<[1, "two", true]>(); // { 1, "two", true }
|
|
45
|
+
Modding.inspect<Array<"a" | "b">>(); // { "a", "b" } -- a union becomes an array
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Writing your own
|
|
49
|
+
|
|
50
|
+
Add `@metadata macro` to the function's JSDoc, and make the generated parameters **optional**.
|
|
51
|
+
Flamework fills them in at each callsite. Where you use one, the `!` (as in `guard!` below) tells
|
|
52
|
+
TypeScript it will be there.
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { Modding } from "@flamework-experimental/core";
|
|
56
|
+
|
|
57
|
+
/** @metadata macro */
|
|
58
|
+
export function logHere(message: string, line?: Modding.Caller.Line, text?: Modding.Caller.Text) {
|
|
59
|
+
print(`${line}: ${text} -- ${message}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
logHere("hello");
|
|
63
|
+
// 42: logHere("hello") -- hello
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Ordinary parameters come first, generated ones after. A caller passes only the ordinary ones.
|
|
67
|
+
|
|
68
|
+
### Callsite information
|
|
69
|
+
|
|
70
|
+
`Modding.Caller.*` describes where the call is written:
|
|
71
|
+
|
|
72
|
+
| Type | Is |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `Line` | The line number in the TypeScript source, from 1. |
|
|
75
|
+
| `Character` | The column, from 1. |
|
|
76
|
+
| `Width` | The width of the call expression. |
|
|
77
|
+
| `Text` | The source text of the call. |
|
|
78
|
+
| `Uuid` | A string that is unique to each callsite and the same in every build of the same source. With obfuscation on, it changes with every plain build; a running watcher and an incremental build keep it ([Obfuscation](09-project-structure.md#obfuscation)). |
|
|
79
|
+
|
|
80
|
+
`Networking.createEvent` uses `Uuid` to give each network object its own name, without you naming
|
|
81
|
+
it.
|
|
82
|
+
|
|
83
|
+
`Modding.Caller.Constant<T>` generates its metadata **once per callsite**, and every call from that
|
|
84
|
+
callsite gets the same table. So you can use it as a cache key:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
/** @metadata macro */
|
|
88
|
+
function cached<T>(options?: Modding.Caller.Constant<Modding.Emit<{ marker: true }>>) {
|
|
89
|
+
return cache.get(options!) ?? cache.set(options!, expensive()).get(options!);
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Type information
|
|
94
|
+
|
|
95
|
+
`Modding.Target.*` describes a type argument:
|
|
96
|
+
|
|
97
|
+
| Type | Is |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `Id<T>` | The generated identifier. |
|
|
100
|
+
| `Text<T>` | The type rendered as TypeScript would show it. |
|
|
101
|
+
| `Guard<T>` | A `t` guard for the type. |
|
|
102
|
+
| `Dependency<T>` | The dependency info: id plus any metadata on the type. |
|
|
103
|
+
| `Labels<T>` | The parameter names of a tuple. |
|
|
104
|
+
| `Hash<T, C>` | A hash of a string literal type, under an optional context. |
|
|
105
|
+
| `Obfuscate<T, C>` | The same, but only when obfuscation is enabled. |
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
/** @metadata macro */
|
|
109
|
+
export function validate<T>(value: unknown, guard?: Modding.Target.Guard<T>): value is T {
|
|
110
|
+
return guard!(value);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (validate<{ x: number }>(payload)) {
|
|
114
|
+
payload.x;
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Emitting a type as a value
|
|
119
|
+
|
|
120
|
+
`Modding.Emit<T>` turns a type into runtime data. Objects become tables, tuples become arrays, and
|
|
121
|
+
`Array<T>` becomes an array of `T`'s union members.
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
/** @metadata macro */
|
|
125
|
+
export function keysOf<T>(keys?: Modding.Emit<Array<keyof T>>) {
|
|
126
|
+
return keys!;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
keysOf<{ a: 1; b: 2 }>(); // { "a", "b" }
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Paths
|
|
133
|
+
|
|
134
|
+
Core has one path macro built in besides `registerProviders`: `requireModules`. It requires every
|
|
135
|
+
ModuleScript in a folder, for what the modules do as they load. This is what v1's
|
|
136
|
+
`Flamework.addPaths` did for a folder of modules that register themselves with a library, such as
|
|
137
|
+
commands:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import { requireModules } from "@flamework-experimental/core";
|
|
141
|
+
|
|
142
|
+
requireModules("src/server/commands");
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
```lua
|
|
146
|
+
requireModules("src/server/commands", { "ServerScriptService", "TS", "commands" })
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
- It takes the same source paths as `registerProviders`, and works in any module of your game: an
|
|
150
|
+
entry point, a provider's `onStart`.
|
|
151
|
+
- It requires the ModuleScripts at and under the folder, in tree order. It returns what they
|
|
152
|
+
export, leaving out the ones that export nothing.
|
|
153
|
+
- Each module runs once. Calling it again returns the same exports.
|
|
154
|
+
- A folder inside a folder that `registerProviders` registers needs no call: registration already
|
|
155
|
+
requires every ModuleScript under it.
|
|
156
|
+
- A folder that is not in the place raises `requireModules("..."): the folder is not in the place`,
|
|
157
|
+
and the message names the part of the path that is missing. The folder gets five seconds to
|
|
158
|
+
appear first, once the place has loaded. A misspelled path, a name that differs in case from the
|
|
159
|
+
folder on disk, and a folder without a module are warned about when you build, where the call is.
|
|
160
|
+
- A folder of the other realm raises at once and says so: a server folder required on a client, or
|
|
161
|
+
a client folder required on the server.
|
|
162
|
+
|
|
163
|
+
A macro of your own can take a source path too. Give it a parameter typed
|
|
164
|
+
`Modding.Intrinsic<"path", [T], string[]>`. That parameter receives the folder the caller's string
|
|
165
|
+
literal `T` names, as a Rojo path: an array of instance names from the root of the tree.
|
|
166
|
+
|
|
167
|
+
The build checks the caller's path as it checks `registerProviders`'s: a path with no module at or
|
|
168
|
+
under it is warned about at the call, named after your macro (`commandsIn("src/server/Commands")`).
|
|
169
|
+
|
|
170
|
+
To use the path, core exports the functions `registerProviders` and `requireModules` are built on:
|
|
171
|
+
|
|
172
|
+
- `requireModulesInPath(path)` requires every ModuleScript at and under the path, and returns what
|
|
173
|
+
they export.
|
|
174
|
+
- `getClassesInPath(path, caller?)` returns the Flamework classes those ModuleScripts define.
|
|
175
|
+
`caller`, such as `` `commandsIn("${_path}")` ``, names the call in the warning a folder that is
|
|
176
|
+
still missing after five seconds gets; without it, the warning names no call.
|
|
177
|
+
|
|
178
|
+
This macro finds the command classes in a folder by metadata of your own (see
|
|
179
|
+
[custom decorators](10-migrating-from-v1.md#8-custom-decorators)):
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
import { getClassesInGlob, getClassesInPath, Modding, Reflect } from "@flamework-experimental/core";
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The classes under a source folder that carry a command name, by name.
|
|
186
|
+
*
|
|
187
|
+
* @metadata macro
|
|
188
|
+
*/
|
|
189
|
+
export function commandsIn<T extends string>(_path: T, path?: Modding.Intrinsic<"path", [T], string[]>) {
|
|
190
|
+
const commands = new Map<string, object>();
|
|
191
|
+
for (const ctor of getClassesInPath(path!)) {
|
|
192
|
+
const name = Reflect.getOwnMetadata<string>(ctor, "myGame:command");
|
|
193
|
+
if (name !== undefined) commands.set(name, ctor);
|
|
194
|
+
}
|
|
195
|
+
return commands;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
commandsIn("src/server/commands");
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
```lua
|
|
202
|
+
commandsIn("src/server/commands", { "ServerScriptService", "TS", "commands" })
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`Modding.Intrinsic<"pathglob", [T], string>` does the same for a glob. The glob is matched against
|
|
206
|
+
your source when you build, and the parameter receives the glob string (obfuscated when obfuscation
|
|
207
|
+
is on). Pass it to `getGlobPaths(glob)` for the Rojo paths it matched, or to
|
|
208
|
+
`getClassesInGlob(glob)` for the classes found under them:
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
/**
|
|
212
|
+
* Every Flamework class the modules under the folders a glob matches define.
|
|
213
|
+
*
|
|
214
|
+
* @metadata macro
|
|
215
|
+
*/
|
|
216
|
+
export function classesIn<T extends string>(_glob: T, glob?: Modding.Intrinsic<"pathglob", [T], string>) {
|
|
217
|
+
return getClassesInGlob(glob!);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
classesIn("src/*/commands");
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
```lua
|
|
224
|
+
classesIn("src/*/commands", "src/*/commands")
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The rules are the same as for `registerProviders`:
|
|
228
|
+
|
|
229
|
+
- The argument must be a string literal naming a source path (a file path like `src/...`), not a
|
|
230
|
+
Rojo path.
|
|
231
|
+
- A `path` folder must be in your Rojo project. Otherwise you get
|
|
232
|
+
`Could not find Rojo data for '...'`.
|
|
233
|
+
- A `path` is resolved in the project that compiles the call, with that project's Rojo file. So a
|
|
234
|
+
path macro called inside a published package (`requireModules`, `registerProviders`, one of your own)
|
|
235
|
+
gets a path in the package's own project, such as `{ "out", "commands" }`. A game's place has no
|
|
236
|
+
such path, so the call fails when it runs. A package without a Rojo project fails to build
|
|
237
|
+
instead, with `No Rojo project file was found`.
|
|
238
|
+
- What a glob matched is written to `include/flamework/globs.json`, which only a game project gets.
|
|
239
|
+
So a glob macro called from a published package raises
|
|
240
|
+
`Flamework has no paths for the glob '...'` when it runs.
|
|
241
|
+
|
|
242
|
+
`Modding.Intrinsic` is marked `@hidden` in core's declarations. That tag is for documentation
|
|
243
|
+
generators, and TypeScript ignores it. It is the same type that `registerProviders`,
|
|
244
|
+
`ComponentPlugin.fromPath` and a plugin target's `registerProviders` declare.
|
|
245
|
+
|
|
246
|
+
### Serializers
|
|
247
|
+
|
|
248
|
+
`Flamework.createSerializer<T>()` generates encode and decode code for `T` at the call site:
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
interface Snapshot {
|
|
252
|
+
id: Serialization.u16;
|
|
253
|
+
position: Vector3;
|
|
254
|
+
tags: string[];
|
|
255
|
+
mode: "idle" | "walk";
|
|
256
|
+
owner: Instance; // travels alongside the buffer
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const snapshots = Flamework.createSerializer<Snapshot>();
|
|
260
|
+
const [payload, blobs] = snapshots.serialize(snapshot);
|
|
261
|
+
const back = snapshots.deserialize(payload, blobs); // raises on malformed input
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
The output is plain buffer code. Each field is a `buffer.write*` at an offset the transformer
|
|
265
|
+
computed, with fixed-size types at literal offsets, and the decoder mirrors it. Fields go in
|
|
266
|
+
declaration order. Counts and lengths are varints, and `Serialization.varint` does the same for an
|
|
267
|
+
integer of your own. Named types with a variable size are moved out into `s_`, `w_` and `r_`
|
|
268
|
+
functions (size, write, read), placed ahead of the statement, once per statement. That is also how
|
|
269
|
+
recursive types work. There is no runtime library behind it, and nothing in the output describes the
|
|
270
|
+
type.
|
|
271
|
+
|
|
272
|
+
- Wrap `deserialize` in `pcall` for untrusted input.
|
|
273
|
+
- Create serializers at the top level of a file (module scope). One built inside a function is
|
|
274
|
+
rebuilt on every call.
|
|
275
|
+
|
|
276
|
+
The same generator powers [networking serialization](06-networking.md#serialization), which lists
|
|
277
|
+
what each kind of type costs and what travels as a blob.
|
|
278
|
+
|
|
279
|
+
## When a macro does not fire
|
|
280
|
+
|
|
281
|
+
Learn to recognise this failure, because it is silent.
|
|
282
|
+
|
|
283
|
+
If Flamework does not recognise a parameter's type as a macro type, it generates **no argument**.
|
|
284
|
+
The parameter is `nil`, your `!` was wrong, and you get "attempt to index nil" or "attempt to call a
|
|
285
|
+
nil value" somewhere unrelated. Nothing warns when you build.
|
|
286
|
+
|
|
287
|
+
Causes, most likely first:
|
|
288
|
+
|
|
289
|
+
1. **The transformer is not configured.** Without `@flamework-experimental/transformer` in
|
|
290
|
+
`tsconfig.json`, *no* macro fires, Flamework's own included.
|
|
291
|
+
2. **`@metadata macro` is missing** from the function's JSDoc, or the JSDoc is not directly attached
|
|
292
|
+
to the declaration.
|
|
293
|
+
3. **The parameter is not optional.** A required parameter is one the caller is expected to pass.
|
|
294
|
+
4. **The type is not a macro type.** A plain `string` parameter is an ordinary string.
|
|
295
|
+
5. **A type alias hid the marker.** Macro types are intersections with a marker. An alias that widens
|
|
296
|
+
the type or strips the marker loses it.
|
|
297
|
+
|
|
298
|
+
The quickest check is to read the emitted Luau. If the call has fewer arguments than you expect,
|
|
299
|
+
the macro did not fire.
|
|
300
|
+
|
|
301
|
+
`Flamework.implements` fails differently. It is `declare`d, so it has no runtime value of its own:
|
|
302
|
+
the transformer rewrites it into a real call. If its macro does not fire, you get
|
|
303
|
+
`attempt to call a nil value` on the call itself, not a `nil` argument.
|
|
304
|
+
|
|
305
|
+
## Patterns
|
|
306
|
+
|
|
307
|
+
**A macro is a compile-time constant.** Put `Flamework.id<T>()` in a `const` rather than calling it
|
|
308
|
+
in a loop. It emits the same string either way, but the intent is clearer.
|
|
309
|
+
|
|
310
|
+
**Wrap `Modding.Target.Guard` for validation at boundaries.** A one-line `validate<T>` macro is often
|
|
311
|
+
simpler than importing `t` and writing the guard out.
|
|
312
|
+
|
|
313
|
+
**Use `Uuid` when you need an identity but don't want to name it.** Anything that needs a stable,
|
|
314
|
+
unique key per callsite (caches, network objects, hooks) can take one instead of asking the caller
|
|
315
|
+
for a string.
|
|
316
|
+
|
|
317
|
+
## Caveats
|
|
318
|
+
|
|
319
|
+
- **Generated parameters must be optional**, and by convention come last.
|
|
320
|
+
- **String arguments to path macros must be literals.** `registerProviders(path)`, where `path` is a
|
|
321
|
+
variable, fails to compile.
|
|
322
|
+
- **A macro does not fire without the transformer.** You get a silent `nil`, not an error.
|
|
323
|
+
- **`Line` and `Character` are numbers**, not strings, even though they describe the callsite's text.
|
|
324
|
+
- **`Line` is the TypeScript line.** For the line in the emitted Luau (what the console and
|
|
325
|
+
tracebacks report), call `debug.info(1, "l")` yourself where you need it.
|
|
326
|
+
- **Macros are resolved at each callsite.** A wrapper function around a macro captures *the
|
|
327
|
+
wrapper's* callsite, not its caller's. To get the caller's, take the metadata as a parameter and
|
|
328
|
+
pass it through.
|
|
329
|
+
|
|
330
|
+
---
|
|
331
|
+
|
|
332
|
+
Previous: [Networking](06-networking.md) · Next: [Plugins](08-plugins.md)
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# 8. Plugins
|
|
2
|
+
|
|
3
|
+
A **plugin** is a function that sets up a module before it ignites (starts). `LifecyclePlugin` and
|
|
4
|
+
`ComponentPlugin` are both ordinary plugins with no special access: anything they do, you can do.
|
|
5
|
+
|
|
6
|
+
Use a plugin when you want behaviour that applies to *whatever providers exist*, not to one specific
|
|
7
|
+
class.
|
|
8
|
+
|
|
9
|
+
## A minimal plugin
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { Flamework } from "@flamework-experimental/core";
|
|
13
|
+
|
|
14
|
+
export const MetricsPlugin = Flamework.createPlugin("Metrics", (target) => {
|
|
15
|
+
const metrics = new Metrics();
|
|
16
|
+
|
|
17
|
+
target.provideInstance(metrics); // providers can now inject Metrics
|
|
18
|
+
target.onPostIgnite(() => metrics.start()); // once every provider exists
|
|
19
|
+
});
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
Flamework.createModule().includePlugin(MetricsPlugin).ignite();
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The setup function runs **once per ignition** of every module that includes the plugin. It receives
|
|
27
|
+
`target`, which it uses to set up that module. Anything it creates, like `metrics` above, belongs to
|
|
28
|
+
that one ignition. Two modules that include `MetricsPlugin` get one `Metrics` each, and so do two
|
|
29
|
+
ignitions of one definition. Nothing is shared unless you deliberately use something from outside
|
|
30
|
+
the function.
|
|
31
|
+
|
|
32
|
+
The name (`"Metrics"`) is used in error messages.
|
|
33
|
+
|
|
34
|
+
## What a plugin can do
|
|
35
|
+
|
|
36
|
+
Everything is a method on `target`, and everything registers into the module being set up.
|
|
37
|
+
|
|
38
|
+
| Method | Does |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `provideInstance(value)` | Hands the module an object under its type's id. Providers inject it; `resolveDependency` finds it. |
|
|
41
|
+
| `registerClassProvider(Class, options?)` | Registers a provider, exactly as the module builder would. |
|
|
42
|
+
| `registerProvider<T>(config)` | The same, for a function or alias provider. |
|
|
43
|
+
| `registerProviders(path, options?)` / `registerProvidersGlob(glob, options?)` | Registers every `@Provider()` class the ModuleScripts under a folder define, exported or not, as the module builder does. How a plugin in your game's source ships a folder of providers: the path is resolved in the project that compiles the plugin, so a plugin published as a package cannot use it ([Macros › Paths](07-macros.md#paths)). When the options' scope condition does not hold, the folder is not looked up at all. |
|
|
44
|
+
| `includePlugin(plugin, options?)` | Includes another plugin, set up now, before this one continues. |
|
|
45
|
+
| `onPreIgnite(cb, options?)` | Runs `cb` before the module's providers are constructed. |
|
|
46
|
+
| `onPostIgnite(cb, options?)` | Runs `cb` after every provider has been constructed. |
|
|
47
|
+
| `onIgnited(cb, options?)` | Runs `cb` once ignition has completed; the lifecycle plugin starts the providers here. |
|
|
48
|
+
| `onExtinguished(cb, options?)` | Runs `cb` when the module extinguishes. |
|
|
49
|
+
| `observe<T>({ onAdded, onRemoved })` | Tells the plugin about every object implementing `T`. |
|
|
50
|
+
| `isActive(...conditions)` | Whether something with these [scope conditions](11-scopes.md) is registered in this module, the module's own condition included. |
|
|
51
|
+
| `module` | The module itself, for the hooks to close over. It cannot resolve anything until it ignites. |
|
|
52
|
+
| `scope` | The module's own scope condition, when `ignite` was given one. For messages; `isActive` already folds it in. |
|
|
53
|
+
|
|
54
|
+
Every hook receives the module: `target.onPostIgnite((module) => module.resolveDependency<Shop>())`.
|
|
55
|
+
|
|
56
|
+
The `options` on the registrations and on `includePlugin` are a
|
|
57
|
+
[scope condition](11-scopes.md#conditions) (`activeIn`, `inactiveIn`). When an inclusion's condition
|
|
58
|
+
does not hold, the plugin's setup never runs, so the folders its setup registers are never looked up. A
|
|
59
|
+
plugin that keeps its own registry of classes, as the components plugin does, has to call
|
|
60
|
+
`target.isActive(condition)` for each class it holds. Otherwise its classes ignore the module's
|
|
61
|
+
condition:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
for (const component of registered) {
|
|
65
|
+
if (target.isActive(registrationScopes.get(component), decoratorScope(component))) {
|
|
66
|
+
active.push(component);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Hooks
|
|
72
|
+
|
|
73
|
+
| Hook | Runs |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `onPreIgnite` | After every plugin has been set up, **before** the module's providers are constructed. |
|
|
76
|
+
| `onPostIgnite` | After every provider has been constructed, and `onInit` has run. The module is still igniting, so an error raised here fails the ignition. |
|
|
77
|
+
| `onIgnited` | Once ignition has completed and the module is ignited. The lifecycle plugin calls `onStart` here. An error raised here is only warned about. If the module is extinguished (by an `onStart`, say), the hooks after that one do not run. |
|
|
78
|
+
| `onExtinguished` | When `extinguish()` runs, before the providers are released. |
|
|
79
|
+
|
|
80
|
+
Which one to use:
|
|
81
|
+
|
|
82
|
+
- `onPreIgnite`: registering state that providers look at while they are constructed.
|
|
83
|
+
- `onPostIgnite`: anything that needs the providers to exist.
|
|
84
|
+
- `onIgnited`: anything that should only start once the module is fully up, after the providers.
|
|
85
|
+
|
|
86
|
+
**Nothing can be resolved during setup or `onPreIgnite`.** Providers do not exist yet, and trying
|
|
87
|
+
raises `module is in pre-ignite phase, dependency cannot be resolved`.
|
|
88
|
+
|
|
89
|
+
### Ordering
|
|
90
|
+
|
|
91
|
+
Hooks of the same phase run in `priority` order, lowest first, then in registration order:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
target.onPostIgnite(() => {}, { priority: HookPriority.First });
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`HookPriority.First` is `-1000`, `Normal` is `0` (the default), `Last` is `1000`. They are
|
|
98
|
+
conventions, not an enum: any number works. They let two plugins order their hooks against each
|
|
99
|
+
other without agreeing on magic numbers.
|
|
100
|
+
|
|
101
|
+
## Observing interfaces
|
|
102
|
+
|
|
103
|
+
`observe` lets a plugin see every object that implements a type: providers, and anything from
|
|
104
|
+
`createClassInstance` or `listen`.
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
interface OnPlayerJoined {
|
|
108
|
+
onPlayerJoined(player: Player): void;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const PlayerPlugin = Flamework.createPlugin("Players", (target) => {
|
|
112
|
+
const listeners = new Set<OnPlayerJoined>();
|
|
113
|
+
|
|
114
|
+
target.observe<OnPlayerJoined>({
|
|
115
|
+
onAdded: (value) => listeners.add(value),
|
|
116
|
+
onRemoved: (value) => listeners.delete(value),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
target.onPostIgnite(() => {
|
|
120
|
+
Players.PlayerAdded.Connect((player) => {
|
|
121
|
+
for (const listener of listeners) listener.onPlayerJoined(player);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Now any provider can opt in:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
@Provider()
|
|
131
|
+
class Greeter implements OnPlayerJoined {
|
|
132
|
+
public onPlayerJoined(player: Player) {}
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`onAdded` fires as each implementing object is constructed. `onRemoved` fires when the object is
|
|
137
|
+
released or its module extinguishes. Both are optional. Both get a second argument whose `kind` says
|
|
138
|
+
what kind of object it was:
|
|
139
|
+
|
|
140
|
+
- `"provider"`: one the module constructed, or one a plugin provided.
|
|
141
|
+
- `"instance"`: one attached through `createClassInstance` or `listen`.
|
|
142
|
+
|
|
143
|
+
Matching goes by name, not by shape. A class matches the interfaces listed in its `implements`
|
|
144
|
+
clause. The transformer records their ids as metadata, but only on a class that carries a Flamework
|
|
145
|
+
decorator, which is why the class needs one for this to work. A class it extends counts too, when
|
|
146
|
+
that class carries a Flamework decorator as well: each class's ids are recorded on that class. So a
|
|
147
|
+
`@Provider()` that extends an undecorated `abstract class Base implements OnPlayerJoined` is not
|
|
148
|
+
matched. Neither is a class that only has the method, without `implements OnPlayerJoined`.
|
|
149
|
+
|
|
150
|
+
Several plugins may observe the same interface. Each of them is told, in the order the plugins were
|
|
151
|
+
included.
|
|
152
|
+
|
|
153
|
+
## Plugins that need other plugins
|
|
154
|
+
|
|
155
|
+
A plugin includes what it depends on, and the dependency is set up first:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
export const DatabasePlugin = Flamework.createPlugin("Database", (target) => {
|
|
159
|
+
target.registerClassProvider(Connection);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
export const InventoryPlugin = Flamework.createPlugin("Inventory", (target) => {
|
|
163
|
+
target.includePlugin(DatabasePlugin); // Connection is registered before this line returns
|
|
164
|
+
target.registerClassProvider(InventoryService);
|
|
165
|
+
});
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
A plugin reached more than once in one ignition (by the module, by two plugins, or both) is set up
|
|
169
|
+
**once**. If `InventoryPlugin` and `ShopPlugin` both include `DatabasePlugin`, there is one
|
|
170
|
+
`Connection`. Plugins are told apart by the plugin object, so two libraries that each build their
|
|
171
|
+
own database plugin get two.
|
|
172
|
+
|
|
173
|
+
## Patterns
|
|
174
|
+
|
|
175
|
+
**Observe plus hook** is the standard shape. The observer collects the objects that implement the
|
|
176
|
+
interface, and the hook starts whatever drives them. `LifecyclePlugin` is built exactly this way.
|
|
177
|
+
|
|
178
|
+
**Provide what other code should reach.** `ComponentPlugin` provides `Components`, so any provider
|
|
179
|
+
in the module can inject it.
|
|
180
|
+
|
|
181
|
+
**Clean up in `onExtinguished`.** Disconnect anything the plugin connected, so a module that
|
|
182
|
+
extinguishes leaves nothing running. This is not automatic.
|
|
183
|
+
|
|
184
|
+
**A plugin is the right answer when the alternative is a global registry.** If you find yourself
|
|
185
|
+
writing `SomeRegistry.add(this)` in every provider's constructor, use `observe` instead.
|
|
186
|
+
|
|
187
|
+
## Caveats
|
|
188
|
+
|
|
189
|
+
- **No resolving during setup or `onPreIgnite`.** Keep `target.module` for the hooks that run later.
|
|
190
|
+
- **Setup runs per ignition.** State at the top level of the plugin's file is shared by every module
|
|
191
|
+
that includes the plugin. State inside the setup function is not. Put it where you mean it.
|
|
192
|
+
- **Interfaces need decorated classes.** A plain class with no Flamework decorator carries no
|
|
193
|
+
`implements` metadata and will never match.
|
|
194
|
+
- **`onRemoved` fires on extinguish** for every object the plugin was told about. Keep it idempotent.
|
|
195
|
+
- **A provider registered by a plugin collides like any other.** The error
|
|
196
|
+
`provider ID was registered more than once` names the id: the module and a plugin, or two plugins,
|
|
197
|
+
registered the same thing.
|
|
198
|
+
- **You do not control hook order across *different* modules.** Priority orders hooks within one
|
|
199
|
+
module.
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
Previous: [Macros](07-macros.md) · Next: [Project structure](09-project-structure.md)
|