devframe 0.9.16 → 0.9.18

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.
@@ -9,10 +9,18 @@ import { BirpcReturn } from "birpc";
9
9
  * channel-name constant declared next to it.
10
10
  */
11
11
  interface InPageChannelProtocol {
12
- /** Functions and events received by the page script. */
13
- pageScript?: Record<string, (...args: any[]) => any>;
14
- /** Functions and events received by panels. */
15
- panel?: Record<string, (...args: any[]) => any>;
12
+ functions?: {
13
+ /** Functions implemented by the page script. */
14
+ pageScript?: Record<string, (...args: any[]) => any>;
15
+ /** Functions implemented by panels. */
16
+ panel?: Record<string, (...args: any[]) => any>;
17
+ };
18
+ events?: {
19
+ /** Events emitted by panels and received by the page script. */
20
+ pageScript?: Record<string, (...args: any[]) => void>;
21
+ /** Events emitted by the page script and received by panels. */
22
+ panel?: Record<string, (...args: any[]) => void>;
23
+ };
16
24
  /**
17
25
  * Shared-state slots. The page script is the authority: it owns the
18
26
  * canonical value; panels are seeded on connect and converge through
@@ -21,21 +29,14 @@ interface InPageChannelProtocol {
21
29
  sharedStates?: Record<string, object>;
22
30
  }
23
31
  type SideFunctions<S> = S extends Record<string, (...args: any[]) => any> ? S : Record<string, never>;
24
- type PageScriptFunctions<P extends InPageChannelProtocol> = SideFunctions<NonNullable<P['pageScript']>>;
25
- type PanelFunctions<P extends InPageChannelProtocol> = SideFunctions<NonNullable<P['panel']>>;
32
+ type SideDeclarations<P extends InPageChannelProtocol, Kind extends 'functions' | 'events', Side extends 'pageScript' | 'panel'> = Kind extends keyof P ? Side extends keyof NonNullable<P[Kind]> ? SideFunctions<NonNullable<NonNullable<P[Kind]>[Side]>> : Record<string, never> : Record<string, never>;
33
+ type PageScriptFunctions<P extends InPageChannelProtocol> = SideDeclarations<P, 'functions', 'pageScript'>;
34
+ type PanelFunctions<P extends InPageChannelProtocol> = SideDeclarations<P, 'functions', 'panel'>;
26
35
  type SharedStates<P extends InPageChannelProtocol> = P['sharedStates'] extends Record<string, object> ? P['sharedStates'] : Record<string, never>;
27
36
  type FnArgs<F> = F extends ((...args: infer A) => any) ? A : never;
28
37
  type FnReturn<F> = F extends ((...args: any[]) => infer R) ? Awaited<R> : never;
29
- /**
30
- * Page-script functions whose resolved return type marks an event.
31
- * @internal
32
- */
33
- type PageScriptFunctionsEvents<P extends InPageChannelProtocol> = { [K in keyof PageScriptFunctions<P> as FnReturn<PageScriptFunctions<P>[K]> extends void ? K : never]: PageScriptFunctions<P>[K]; };
34
- /**
35
- * Panel functions whose resolved return type marks an event.
36
- * @internal
37
- */
38
- type PanelFunctionsEvents<P extends InPageChannelProtocol> = { [K in keyof PanelFunctions<P> as FnReturn<PanelFunctions<P>[K]> extends void ? K : never]: PanelFunctions<P>[K]; };
38
+ type PageScriptEvents<P extends InPageChannelProtocol> = SideDeclarations<P, 'events', 'pageScript'>;
39
+ type PanelEvents<P extends InPageChannelProtocol> = SideDeclarations<P, 'events', 'panel'>;
39
40
  /**
40
41
  * Converts a protocol function to its accepted endpoint handler.
41
42
  *
@@ -99,9 +100,10 @@ interface InPageFunctionOptionBase {
99
100
  jsonSerializable?: boolean;
100
101
  }
101
102
  interface InPageEventFunctionOption<F> extends InPageFunctionOptionBase {
102
- type: 'event';
103
+ type?: 'event';
103
104
  handler?: ProtocolHandler<F>;
104
105
  }
106
+ type InPageEventOption<F> = [F] extends [never] ? never : InPageEventFunctionOption<F>;
105
107
  interface InPageQueryFunctionOption<F> extends InPageFunctionOptionBase {
106
108
  type?: 'query';
107
109
  handler: ProtocolHandler<F>;
@@ -110,7 +112,7 @@ interface InPageActionFunctionOption<F> extends InPageFunctionOptionBase {
110
112
  type: 'action';
111
113
  handler: ProtocolHandler<F>;
112
114
  }
113
- type InPageFunctionOption<F> = InPageEventFunctionOption<F> | InPageQueryFunctionOption<F> | InPageActionFunctionOption<F>;
115
+ type InPageFunctionOption<F> = InPageQueryFunctionOption<F> | InPageActionFunctionOption<F>;
114
116
  /**
115
117
  * Functions implemented by {@link createPageScriptChannel}.
116
118
  *
@@ -173,8 +175,10 @@ interface InPageChannelCommonOptions {
173
175
  }
174
176
  /** Options for {@link createPageScriptChannel}. */
175
177
  interface CreatePageScriptChannelOptions<Protocol extends InPageChannelProtocol = InPageChannelProtocol> extends InPageChannelCommonOptions {
176
- /** Every page-script function declaration; event handlers may use `channel.on()`. */
178
+ /** Every page-script function declaration, with a required handler. */
177
179
  functions: CreatePageScriptChannelOptionsFunctions<Protocol>;
180
+ /** Optional metadata or handlers for incoming events. Listeners may instead subscribe through `channel.on()`. */
181
+ events?: { [NAME in keyof PageScriptEvents<Protocol> & string]?: InPageEventOption<PageScriptEvents<Protocol>[NAME]>; };
178
182
  /**
179
183
  * Window whose `message` events carry panel hellos. Defaults to the
180
184
  * global `window`; pass `false` to skip the handshake listener entirely
@@ -184,8 +188,10 @@ interface CreatePageScriptChannelOptions<Protocol extends InPageChannelProtocol
184
188
  }
185
189
  /** Options for {@link connectPanelChannel}. */
186
190
  interface ConnectPanelChannelOptions<Protocol extends InPageChannelProtocol = InPageChannelProtocol> extends InPageChannelCommonOptions {
187
- /** Every panel function declaration; event handlers may use `channel.on()`. */
191
+ /** Every panel function declaration, with a required handler. */
188
192
  functions: ConnectPanelChannelOptionsFunctions<Protocol>;
193
+ /** Optional metadata or handlers for incoming events. Listeners may instead subscribe through `channel.on()`. */
194
+ events?: { [NAME in keyof PanelEvents<Protocol> & string]?: InPageEventOption<PanelEvents<Protocol>[NAME]>; };
189
195
  /**
190
196
  * The panel's own window (listens for the handshake grant). Defaults to
191
197
  * the global `window`; pass `false` with `transport` to skip the handshake.
@@ -262,11 +268,11 @@ interface PageScriptChannel<P extends InPageChannelProtocol> {
262
268
  readonly panels: readonly PanelPeer<P>[];
263
269
  readonly events: Pick<EventEmitter<PageScriptChannelEvents<P>>, 'on' | 'once'>;
264
270
  /** Fan an event out to every connected panel. */
265
- emit: <K extends keyof PanelFunctionsEvents<P> & string>(name: K, ...args: FnArgs<PanelFunctionsEvents<P>[K]>) => void;
271
+ emit: <K extends keyof PanelEvents<P> & string>(name: K, ...args: FnArgs<PanelEvents<P>[K]>) => void;
266
272
  /** @deprecated Use `emit()` instead. */
267
- callEvent: <K extends keyof PanelFunctionsEvents<P> & string>(name: K, ...args: FnArgs<PanelFunctionsEvents<P>[K]>) => void;
273
+ callEvent: <K extends keyof PanelEvents<P> & string>(name: K, ...args: FnArgs<PanelEvents<P>[K]>) => void;
268
274
  /** Subscribe to an event emitted by a panel. Returns an unsubscribe function. */
269
- on: <K extends keyof PageScriptFunctionsEvents<P> & string>(name: K, listener: (...args: FnArgs<PageScriptFunctionsEvents<P>[K]>) => void) => () => void;
275
+ on: <K extends keyof PageScriptEvents<P> & string>(name: K, listener: (...args: FnArgs<PageScriptEvents<P>[K]>) => void) => () => void;
270
276
  /** Page-script-authoritative shared states, replayed to joining panels. */
271
277
  readonly sharedState: InPageSharedStateHost<P>;
272
278
  /** Adopt a pre-established port as a panel peer (bring-your-own transport). */
@@ -307,11 +313,11 @@ interface PanelChannel<P extends InPageChannelProtocol> {
307
313
  * Emit an event to the page script. While `connecting` the event is buffered
308
314
  * (up to `eventBufferLimit`) and flushed on connect.
309
315
  */
310
- emit: <K extends keyof PageScriptFunctionsEvents<P> & string>(name: K, ...args: FnArgs<PageScriptFunctionsEvents<P>[K]>) => void;
316
+ emit: <K extends keyof PageScriptEvents<P> & string>(name: K, ...args: FnArgs<PageScriptEvents<P>[K]>) => void;
311
317
  /** @deprecated Use `emit()` instead. */
312
- callEvent: <K extends keyof PageScriptFunctionsEvents<P> & string>(name: K, ...args: FnArgs<PageScriptFunctionsEvents<P>[K]>) => void;
318
+ callEvent: <K extends keyof PageScriptEvents<P> & string>(name: K, ...args: FnArgs<PageScriptEvents<P>[K]>) => void;
313
319
  /** Subscribe to an event emitted by the page script. Returns an unsubscribe function. */
314
- on: <K extends keyof PanelFunctionsEvents<P> & string>(name: K, listener: (...args: FnArgs<PanelFunctionsEvents<P>[K]>) => void) => () => void;
320
+ on: <K extends keyof PanelEvents<P> & string>(name: K, listener: (...args: FnArgs<PanelEvents<P>[K]>) => void) => () => void;
315
321
  /** Shared states mirrored from the page-script authority. */
316
322
  readonly sharedState: InPageSharedStateHost<P>;
317
323
  /** Tear the endpoint down permanently. */
@@ -8,7 +8,7 @@ const diagnostics = /* #__PURE__ */ defineDiagnostics({
8
8
  docsBase: "https://devfra.me/errors",
9
9
  codes: { DF0077: {
10
10
  why: (p) => `In-page channel function "${p.name}" is not registered on this endpoint.`,
11
- fix: "Declare the function in this endpoint's `functions` option before subscribing with `on()`."
11
+ fix: "Declare the function in this endpoint's `functions` option."
12
12
  } }
13
13
  });
14
14
  //#endregion
@@ -169,6 +169,10 @@ function serializeArgs(codec, args) {
169
169
  function deserializeResult(codec, result) {
170
170
  return codec.deserialize && result !== void 0 ? codec.deserialize(result) : result;
171
171
  }
172
+ function channelMethod(type, name) {
173
+ return `devframe:in-page:${type === "event" ? "event" : "function"}:${name}`;
174
+ }
175
+ const FUNCTION_METHOD_PREFIX = channelMethod("function", "");
172
176
  /**
173
177
  * An endpoint's local function table, resolved by name when the remote side
174
178
  * calls in. Each handler is wrapped with the receive pipeline: deserialize
@@ -180,19 +184,25 @@ function createLocalFunctionRegistry(codec) {
180
184
  const listeners = /* @__PURE__ */ new Map();
181
185
  return {
182
186
  register(definition) {
183
- definitions.set(definition.name, definition);
187
+ definitions.set(channelMethod(definition.type, definition.name), definition);
188
+ },
189
+ registerInternal(method, handler) {
190
+ definitions.set(method, {
191
+ name: method,
192
+ handler
193
+ });
184
194
  },
185
195
  on(name, listener) {
186
- if (!definitions.has(name)) throw diagnostics.DF0077({ name });
187
- let registered = listeners.get(name);
196
+ const key = channelMethod("event", name);
197
+ let registered = listeners.get(key);
188
198
  if (!registered) {
189
199
  registered = /* @__PURE__ */ new Set();
190
- listeners.set(name, registered);
200
+ listeners.set(key, registered);
191
201
  }
192
202
  registered.add(listener);
193
203
  return () => {
194
204
  registered.delete(listener);
195
- if (registered.size === 0) listeners.delete(name);
205
+ if (registered.size === 0) listeners.delete(key);
196
206
  };
197
207
  },
198
208
  resolve(name) {
@@ -205,12 +215,28 @@ function createLocalFunctionRegistry(codec) {
205
215
  if (definition?.args?.length) await validateArgs(definition.name, definition.args, args);
206
216
  const result = await definition?.handler?.(...args);
207
217
  for (const listener of [...listeners.get(name) ?? []]) listener(...args);
208
- if (definition?.jsonSerializable) assertJsonSerializable(result, "its return value", definition.name);
218
+ if (definition?.type === "event") return void 0;
219
+ if (definition?.jsonSerializable && result !== void 0) assertJsonSerializable(result, "its return value", definition.name);
209
220
  return codec.serialize && result !== void 0 ? codec.serialize(result) : result;
210
221
  };
211
222
  }
212
223
  };
213
224
  }
225
+ /**
226
+ * Consult each registry in order and, only once none owns the name, fall back
227
+ * to the coded "not registered" error for a `function:` call (events stay
228
+ * silent). The fallback lives here, after every registry, so chaining a
229
+ * state registry ahead of the user registry never masks a real handler.
230
+ */
231
+ function resolveLocalHandler(name, registries) {
232
+ for (const registry of registries) {
233
+ const handler = registry(name);
234
+ if (handler) return handler;
235
+ }
236
+ if (name.startsWith(FUNCTION_METHOD_PREFIX)) return () => {
237
+ throw diagnostics.DF0077({ name: name.slice(FUNCTION_METHOD_PREFIX.length) });
238
+ };
239
+ }
214
240
  function wrapPostError(error) {
215
241
  if (error instanceof DOMException && error.name === "DataCloneError") return new InPageChannelError("not-cloneable", `a payload could not be structured-cloned across the in-page channel: ${error.message}. Strip non-cloneable values (functions, DOM nodes, framework reactivity proxies) before sending: declare the function \`jsonSerializable: true\` for a precise error, or provide a \`serialize\` hook.`, { cause: error });
216
242
  return error;
@@ -482,13 +508,18 @@ function createPageScriptChannel(options) {
482
508
  ...definition,
483
509
  name: fnName
484
510
  });
511
+ for (const [eventName, definition] of Object.entries(options.events ?? {})) registry.register({
512
+ ...definition,
513
+ name: eventName,
514
+ type: "event"
515
+ });
485
516
  const stateHost = createPageScriptStateHost(function* () {
486
517
  for (const peer of peers.values()) yield {
487
518
  subscribedStates: peer.subscribedStates,
488
519
  callEventRaw: (method, args) => {
489
520
  peer.attached.rpc.$callRaw({
490
521
  method,
491
- args,
522
+ args: serializeArgs(codec, args),
492
523
  event: true,
493
524
  optional: true
494
525
  }).catch(() => {});
@@ -517,14 +548,16 @@ function createPageScriptChannel(options) {
517
548
  callEventRaw: (method, args) => {
518
549
  internal.attached.rpc.$callRaw({
519
550
  method,
520
- args,
551
+ args: serializeArgs(codec, args),
521
552
  event: true,
522
553
  optional: true
523
554
  }).catch(() => {});
524
555
  }
525
556
  });
557
+ const stateRegistry = createLocalFunctionRegistry(codec);
558
+ for (const [method, handler] of Object.entries(internal.internalHandlers)) stateRegistry.registerInternal(method, handler);
526
559
  internal.attached = attachChannelPort(port, {
527
- resolveLocal: (fnName) => internal.internalHandlers[fnName] ?? registry.resolve(fnName),
560
+ resolveLocal: (fnName) => resolveLocalHandler(fnName, [stateRegistry.resolve, registry.resolve]),
528
561
  onControl: (kind) => {
529
562
  if (kind === "ping") internal.attached.postControl("pong");
530
563
  },
@@ -532,7 +565,7 @@ function createPageScriptChannel(options) {
532
565
  });
533
566
  internal.peer = {
534
567
  id,
535
- call: (fnName, ...args) => withCallDeadline(internal.attached.rpc.$call(fnName, ...serializeArgs(codec, args)).then((result) => deserializeResult(codec, result)), callTimeoutMs, () => `in-page channel "${name}": call "${fnName}" to panel "${id}" timed out after ${callTimeoutMs}ms`),
568
+ call: (fnName, ...args) => withCallDeadline(internal.attached.rpc.$call(channelMethod("function", fnName), ...serializeArgs(codec, args)).then((result) => deserializeResult(codec, result)), callTimeoutMs, () => `in-page channel "${name}": call "${fnName}" to panel "${id}" timed out after ${callTimeoutMs}ms`),
536
569
  close: () => removePeer(id, {
537
570
  bye: true,
538
571
  reason: "the page script closed this panel"
@@ -550,8 +583,8 @@ function createPageScriptChannel(options) {
550
583
  if (closed) return;
551
584
  const data = event.data;
552
585
  if (!isHandshakeMessage(data) || data.kind !== "hello" || data.name !== name) return;
553
- if (data.v !== 1) {
554
- warnOnce(`in-page channel "${name}": ignoring a hello with protocol version ${data.v} (this side speaks 1); align the devframe versions of the page script and the panel`);
586
+ if (data.v !== 2) {
587
+ warnOnce(`in-page channel "${name}": ignoring a hello with protocol version ${data.v} (this side speaks 2); align the devframe versions of the page script and the panel`);
555
588
  return;
556
589
  }
557
590
  if (!allowedOrigins.includes("*") && !allowedOrigins.includes(event.origin)) {
@@ -565,7 +598,7 @@ function createPageScriptChannel(options) {
565
598
  addPeer(messageChannel.port1, data.panelId);
566
599
  const grant = {
567
600
  channel: IN_PAGE_CHANNEL_TAG,
568
- v: 1,
601
+ v: 2,
569
602
  kind: "grant",
570
603
  name,
571
604
  panelId: data.panelId,
@@ -582,7 +615,7 @@ function createPageScriptChannel(options) {
582
615
  const emit = (fnName, ...args) => {
583
616
  const wireArgs = serializeArgs(codec, args);
584
617
  for (const peer of peers.values()) peer.attached.rpc.$callRaw({
585
- method: fnName,
618
+ method: channelMethod("event", fnName),
586
619
  args: wireArgs,
587
620
  event: true,
588
621
  optional: true
@@ -651,6 +684,11 @@ function connectPanelChannel(options) {
651
684
  ...definition,
652
685
  name: fnName
653
686
  });
687
+ for (const [eventName, definition] of Object.entries(options.events ?? {})) registry.register({
688
+ ...definition,
689
+ name: eventName,
690
+ type: "event"
691
+ });
654
692
  let status = "connecting";
655
693
  let attached;
656
694
  let pageScriptInfo;
@@ -668,9 +706,11 @@ function connectPanelChannel(options) {
668
706
  }
669
707
  const stateHost = createPanelStateHost({
670
708
  isConnected: () => status === "connected",
671
- callEvent: (method, args) => sendEvent(method, args),
672
- call: (method, args) => enqueueCall(method, args)
709
+ callEvent: (method, args) => sendEvent(method, serializeArgs(codec, args)),
710
+ call: (method, args) => enqueueCall(method, serializeArgs(codec, args))
673
711
  });
712
+ const stateRegistry = createLocalFunctionRegistry(codec);
713
+ for (const [method, handler] of Object.entries(stateHost.handlers)) stateRegistry.registerInternal(method, handler);
674
714
  function sendEventNow(method, args) {
675
715
  attached?.rpc.$callRaw({
676
716
  method,
@@ -716,7 +756,7 @@ function connectPanelChannel(options) {
716
756
  reason: "the panel adopted a newer port"
717
757
  });
718
758
  attached = attachChannelPort(port, {
719
- resolveLocal: (fnName) => stateHost.handlers[fnName] ?? registry.resolve(fnName),
759
+ resolveLocal: (fnName) => resolveLocalHandler(fnName, [stateRegistry.resolve, registry.resolve]),
720
760
  onControl: (kind) => {
721
761
  if (kind === "ping") attached?.postControl("pong");
722
762
  },
@@ -761,7 +801,7 @@ function connectPanelChannel(options) {
761
801
  const tick = () => {
762
802
  const hello = {
763
803
  channel: IN_PAGE_CHANNEL_TAG,
764
- v: 1,
804
+ v: 2,
765
805
  kind: "hello",
766
806
  name,
767
807
  panelId,
@@ -779,8 +819,8 @@ function connectPanelChannel(options) {
779
819
  if (status === "closed") return;
780
820
  const data = event.data;
781
821
  if (!isHandshakeMessage(data) || data.kind !== "grant" || data.name !== name || data.panelId !== panelId) return;
782
- if (data.v !== 1) {
783
- warnOnce(`in-page channel "${name}": ignoring a grant with protocol version ${data.v} (this side speaks 1); align the devframe versions of the page script and the panel`);
822
+ if (data.v !== 2) {
823
+ warnOnce(`in-page channel "${name}": ignoring a grant with protocol version ${data.v} (this side speaks 2); align the devframe versions of the page script and the panel`);
784
824
  return;
785
825
  }
786
826
  if (!allowedOrigins.includes("*") && !allowedOrigins.includes(event.origin)) {
@@ -825,9 +865,9 @@ function connectPanelChannel(options) {
825
865
  }, timeoutMs);
826
866
  });
827
867
  },
828
- call: (fnName, ...args) => enqueueCall(fnName, serializeArgs(codec, args)),
829
- emit: (fnName, ...args) => sendEvent(fnName, serializeArgs(codec, args)),
830
- callEvent: (fnName, ...args) => sendEvent(fnName, serializeArgs(codec, args)),
868
+ call: (fnName, ...args) => enqueueCall(channelMethod("function", fnName), serializeArgs(codec, args)),
869
+ emit: (fnName, ...args) => sendEvent(channelMethod("event", fnName), serializeArgs(codec, args)),
870
+ callEvent: (fnName, ...args) => sendEvent(channelMethod("event", fnName), serializeArgs(codec, args)),
831
871
  on: (fnName, listener) => registry.on(fnName, listener),
832
872
  sharedState: stateHost,
833
873
  close: () => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "devframe",
3
3
  "type": "module",
4
- "version": "0.9.16",
4
+ "version": "0.9.18",
5
5
  "description": "Framework for building one portable devtool integration that runs in any viewer.",
6
6
  "author": "Anthony Fu <anthonyfu117@hotmail.com>",
7
7
  "license": "MIT",
@@ -438,7 +438,7 @@ Use `my.rpc.sharedState(key)` for observable state, `my.rpc.register(...)` to re
438
438
 
439
439
  ### In-page channel (page script ↔ panel, server-free)
440
440
 
441
- For a live inspect-the-page loop, `devframe/in-page-channel` connects a devframe's **page script** (in the user app's page) to its **panels** entirely in the browser — no server, so it works identically in dev and static builds. Declare one shared protocol type; the page script is `createPageScriptChannel<P>({ name, functions })`, each panel `connectPanelChannel<P>({ name })`. Functions use `defineChannelFunction` (the `defineRpcFunction` shape: `type: 'event'` fans out to every panel, `query`/`action` are request/response); `channel.sharedState.get(key)` mirrors `rpc.sharedState` with the page script as authority (panels get automatic replay). The handshake is panel-initiated with retry — boot order and reloads don't matter — and panels expose `status`/`whenConnected(ms)` for "page script not loaded" fallbacks. Channel names follow `devframes:plugin:<slug>`. The a11y inspector's scan/highlight loop is the reference use.
441
+ For a live inspect-the-page loop, `devframe/in-page-channel` connects a devframe's **page script** (in the user app's page) to its **panels** entirely in the browser, in both dev and static builds. Declare a shared protocol type with separate `functions` and `events` sections, each with `pageScript` and `panel` maps naming the receiving direction. Both `createPageScriptChannel<P>()` and `connectPanelChannel<P>()` require `{ name, functions }` and accept optional `events`: function declarations require handlers; event declarations accept optional handlers or `{}` for dynamic `channel.on()` subscriptions. `call()` awaits functions, including void actions; `emit()` sends declared events. `channel.sharedState.get(key)` mirrors `rpc.sharedState` with the page script as authority and automatic replay to panels. The handshake retries across boot order and reloads; panels expose `status`/`whenConnected(ms)` for page-script availability fallbacks. Channel names follow `devframes:plugin:<slug>`. The a11y inspector's scan/highlight loop is the reference use.
442
442
 
443
443
  ## The Hub
444
444