@flamework-experimental/core 2.0.0-alpha.2 → 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.
Files changed (44) hide show
  1. package/README.md +41 -34
  2. package/docs/README.md +63 -0
  3. package/docs/guide/01-getting-started.md +334 -0
  4. package/docs/guide/02-modules.md +254 -0
  5. package/docs/guide/03-providers.md +423 -0
  6. package/docs/guide/04-lifecycle-events.md +423 -0
  7. package/docs/guide/05-components.md +793 -0
  8. package/docs/guide/06-networking.md +614 -0
  9. package/docs/guide/07-macros.md +332 -0
  10. package/docs/guide/08-plugins.md +203 -0
  11. package/docs/guide/09-project-structure.md +392 -0
  12. package/docs/guide/10-migrating-from-v1.md +573 -0
  13. package/docs/guide/11-scopes.md +165 -0
  14. package/docs/guide/12-testing.md +342 -0
  15. package/flamework.build +1 -1
  16. package/out/dependency.d.ts +4 -0
  17. package/out/dependency.luau +4 -0
  18. package/out/index.d.ts +5 -2
  19. package/out/init.luau +11 -2
  20. package/out/lifecycle/lifecyclePlugin.d.ts +13 -1
  21. package/out/lifecycle/lifecyclePlugin.luau +67 -7
  22. package/out/module/module.luau +126 -24
  23. package/out/module/moduleBuilder.d.ts +12 -3
  24. package/out/module/moduleBuilder.luau +23 -1
  25. package/out/module/moduleDefinition.d.ts +6 -0
  26. package/out/module/providerRegistration.d.ts +8 -0
  27. package/out/module/providerRegistration.luau +29 -0
  28. package/out/plugin/pluginDefinition.d.ts +7 -4
  29. package/out/provider.d.ts +19 -0
  30. package/out/provider.luau +8 -0
  31. package/out/reflect.luau +6 -0
  32. package/out/utility/explainUnresolved.d.ts +9 -0
  33. package/out/utility/explainUnresolved.luau +43 -0
  34. package/out/utility/getClassesInPath.d.ts +37 -3
  35. package/out/utility/getClassesInPath.luau +154 -33
  36. package/out/utility/globs.d.ts +2 -2
  37. package/out/utility/globs.luau +3 -3
  38. package/out/utility/leftOut.d.ts +35 -0
  39. package/out/utility/leftOut.luau +171 -0
  40. package/out/utility/moduleClasses.d.ts +9 -0
  41. package/out/utility/moduleClasses.luau +64 -0
  42. package/out/utility/pathRoot.d.ts +20 -1
  43. package/out/utility/pathRoot.luau +80 -4
  44. 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)