@alfe.ai/mcp-bundler 0.0.1 → 0.1.0

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/dist/index.js CHANGED
@@ -1,3 +1,8 @@
1
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, watch, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
1
6
  //#region src/tool-naming.ts
2
7
  /**
3
8
  * Tool name sanitization and collision handling.
@@ -449,6 +454,587 @@ var McpBundler = class {
449
454
  }
450
455
  };
451
456
  //#endregion
452
- export { Connection, McpBundler, STDIO_ENV_DENYLIST, buildNamespacedToolName, defaultConnect, disambiguateAgainst, sanitizeNameSegment, sanitizeStdioEnv };
457
+ //#region src/store.ts
458
+ const DEFAULT_STORE_PATH = join(join(homedir(), ".alfe", "mcp"), "servers.json");
459
+ /** Inter-process lock tunings — exported as constants so tests can override. */
460
+ const LOCK_WAIT_MS = 5e3;
461
+ const LOCK_RETRY_INTERVAL_MS = 25;
462
+ const LOCK_STALE_MS = 1e4;
463
+ /**
464
+ * On-disk source of truth for the bundler's configured servers.
465
+ *
466
+ * Mutations go through `update()` (read-modify-write with atomic
467
+ * temp+rename) so a concurrent writer (e.g. two `alfe mcp add` invocations
468
+ * racing) can't lose data — the second writer reads the first's state.
469
+ *
470
+ * Schema is owner-tagged so `removeServersByOwner` can implement
471
+ * integration uninstall without touching CLI-owned or manual entries.
472
+ */
473
+ var Store = class {
474
+ storePath;
475
+ logger;
476
+ watcher;
477
+ watcherListeners = /* @__PURE__ */ new Set();
478
+ rewatchTimer;
479
+ constructor(opts = {}) {
480
+ this.storePath = opts.path ?? DEFAULT_STORE_PATH;
481
+ this.logger = opts.logger;
482
+ }
483
+ get path() {
484
+ return this.storePath;
485
+ }
486
+ read() {
487
+ if (!existsSync(this.storePath)) return cloneEmpty();
488
+ try {
489
+ const raw = readFileSync(this.storePath, "utf8");
490
+ return normalize(JSON.parse(raw));
491
+ } catch (err) {
492
+ this.logger?.warn("[mcp-bundler/store] failed to read store; returning empty", {
493
+ err: errMsg$1(err),
494
+ path: this.storePath
495
+ });
496
+ return cloneEmpty();
497
+ }
498
+ }
499
+ /**
500
+ * Read-modify-write with atomic temp+rename, guarded by an
501
+ * inter-process lock file. Caller passes a pure function that
502
+ * produces the next state; this serialises the mutation to disk in
503
+ * one rename, which is atomic on POSIX and on Windows when the
504
+ * target path is on the same volume.
505
+ *
506
+ * The lock guards the read-then-rename window so two processes
507
+ * (e.g. two `alfe mcp add` shells, or the CLI racing the daemon)
508
+ * can't drop each other's writes. The lock file is at
509
+ * `<storePath>.lock`; stale locks (older than `LOCK_STALE_MS`) are
510
+ * stolen so a crashed writer doesn't wedge the store.
511
+ *
512
+ * Pure-function shape (instead of a `read()` then `write(next)`
513
+ * pair) intentionally — it keeps the read-modify-write contract
514
+ * local to each caller so two updates back-to-back never see each
515
+ * other's partial state.
516
+ */
517
+ update(fn) {
518
+ mkdirSync(dirname(this.storePath), { recursive: true });
519
+ const release = this.acquireLock();
520
+ try {
521
+ const next = fn(this.read());
522
+ const tempPath = `${this.storePath}.${String(process.pid)}.${String(Date.now())}.tmp`;
523
+ writeFileSync(tempPath, JSON.stringify(next, null, 2), {
524
+ encoding: "utf8",
525
+ mode: 384
526
+ });
527
+ try {
528
+ renameSync(tempPath, this.storePath);
529
+ } catch (err) {
530
+ try {
531
+ unlinkSync(tempPath);
532
+ } catch {}
533
+ throw err;
534
+ }
535
+ return next;
536
+ } finally {
537
+ release();
538
+ }
539
+ }
540
+ /**
541
+ * Acquire an inter-process file lock by atomically creating a
542
+ * sentinel via `openSync(lockPath, 'wx')`. Spins with bounded
543
+ * backoff up to `LOCK_WAIT_MS`. If the lock file is older than
544
+ * `LOCK_STALE_MS` it's assumed orphaned (writer crashed mid-update)
545
+ * and stolen — the write window is sub-second in practice, so
546
+ * holding the lock for >5s means something went wrong.
547
+ *
548
+ * Returns the release function. Single-process callers are
549
+ * unaffected — re-entering the same process spins briefly while
550
+ * the prior call's `finally` runs.
551
+ */
552
+ acquireLock() {
553
+ const lockPath = `${this.storePath}.lock`;
554
+ const deadline = Date.now() + LOCK_WAIT_MS;
555
+ let fd = -1;
556
+ for (;;) try {
557
+ fd = openSync(lockPath, "wx", 384);
558
+ break;
559
+ } catch (err) {
560
+ if (err.code !== "EEXIST") throw err;
561
+ if (this.lockIsStale(lockPath)) {
562
+ try {
563
+ unlinkSync(lockPath);
564
+ } catch {}
565
+ continue;
566
+ }
567
+ if (Date.now() >= deadline) throw new Error(`Store.update: timed out waiting for ${lockPath} (held by another writer or stale lock)`);
568
+ const sleepUntil = Date.now() + LOCK_RETRY_INTERVAL_MS;
569
+ while (Date.now() < sleepUntil);
570
+ }
571
+ const held = fd;
572
+ return () => {
573
+ try {
574
+ closeSync(held);
575
+ } catch {}
576
+ try {
577
+ unlinkSync(lockPath);
578
+ } catch {}
579
+ };
580
+ }
581
+ lockIsStale(lockPath) {
582
+ try {
583
+ const st = statSync(lockPath);
584
+ return Date.now() - st.mtimeMs > LOCK_STALE_MS;
585
+ } catch {
586
+ return false;
587
+ }
588
+ }
589
+ /**
590
+ * Watch the store file for external changes (e.g. another `alfe mcp add`
591
+ * shelling out from a separate process). Returns an unsubscribe fn.
592
+ *
593
+ * Coalesces bursts via a 50 ms debounce — editors and atomic-rename
594
+ * writers commonly fire multiple events per logical save.
595
+ */
596
+ watch(cb) {
597
+ this.watcherListeners.add(cb);
598
+ this.ensureWatcher();
599
+ return () => {
600
+ this.watcherListeners.delete(cb);
601
+ if (this.watcherListeners.size === 0) this.disposeWatcher();
602
+ };
603
+ }
604
+ dispose() {
605
+ this.watcherListeners.clear();
606
+ this.disposeWatcher();
607
+ }
608
+ ensureWatcher() {
609
+ if (this.watcher) return;
610
+ mkdirSync(dirname(this.storePath), { recursive: true });
611
+ const dir = dirname(this.storePath);
612
+ const basename = this.storePath.slice(dir.length + 1);
613
+ let pending;
614
+ const fire = () => {
615
+ pending = void 0;
616
+ for (const cb of this.watcherListeners) try {
617
+ cb();
618
+ } catch (err) {
619
+ this.logger?.warn("[mcp-bundler/store] watcher listener threw", { err: errMsg$1(err) });
620
+ }
621
+ };
622
+ try {
623
+ this.watcher = watch(dir, (_event, fn) => {
624
+ if (fn !== basename) return;
625
+ if (pending) clearTimeout(pending);
626
+ pending = setTimeout(fire, 50);
627
+ });
628
+ this.watcher.on("error", (err) => {
629
+ this.logger?.warn("[mcp-bundler/store] watcher error; retrying in 1s", { err: errMsg$1(err) });
630
+ this.disposeWatcher();
631
+ if (!this.rewatchTimer && this.watcherListeners.size > 0) {
632
+ this.rewatchTimer = setTimeout(() => {
633
+ this.rewatchTimer = void 0;
634
+ this.ensureWatcher();
635
+ }, 1e3);
636
+ this.rewatchTimer.unref();
637
+ }
638
+ });
639
+ } catch (err) {
640
+ this.logger?.warn("[mcp-bundler/store] failed to start watcher", { err: errMsg$1(err) });
641
+ }
642
+ }
643
+ disposeWatcher() {
644
+ if (this.watcher) {
645
+ try {
646
+ this.watcher.close();
647
+ } catch {}
648
+ this.watcher = void 0;
649
+ }
650
+ if (this.rewatchTimer) {
651
+ clearTimeout(this.rewatchTimer);
652
+ this.rewatchTimer = void 0;
653
+ }
654
+ }
655
+ };
656
+ function defaultStorePath() {
657
+ return DEFAULT_STORE_PATH;
658
+ }
659
+ /** Pull the runtime config (transport + transport-specific fields) out of a stored entry. */
660
+ function toServerConfig(entry) {
661
+ if (entry.transport === "stdio") {
662
+ const { command, args, env, cwd } = entry;
663
+ const cfg = { command };
664
+ if (args) cfg.args = args;
665
+ if (env) cfg.env = env;
666
+ if (cwd) cfg.cwd = cwd;
667
+ return cfg;
668
+ }
669
+ const { url, transport, headers, connectionTimeoutMs } = entry;
670
+ const cfg = {
671
+ url,
672
+ transport
673
+ };
674
+ if (headers) cfg.headers = headers;
675
+ if (connectionTimeoutMs !== void 0) cfg.connectionTimeoutMs = connectionTimeoutMs;
676
+ return cfg;
677
+ }
678
+ /** Build a stored entry from a runtime config + ownership metadata. */
679
+ function toStoredEntry(config, meta) {
680
+ const addedAt = meta.addedAt ?? (/* @__PURE__ */ new Date()).toISOString();
681
+ if ("command" in config) return {
682
+ transport: "stdio",
683
+ owner: meta.owner,
684
+ addedAt,
685
+ ...meta.version !== void 0 ? { version: meta.version } : {},
686
+ ...config
687
+ };
688
+ const transport = meta.transport ?? config.transport ?? "sse";
689
+ if (transport === "stdio") throw new Error("toStoredEntry: transport=stdio specified but config is remote-shaped");
690
+ return {
691
+ transport,
692
+ owner: meta.owner,
693
+ addedAt,
694
+ ...meta.version !== void 0 ? { version: meta.version } : {},
695
+ ...config
696
+ };
697
+ }
698
+ function cloneEmpty() {
699
+ return {
700
+ servers: {},
701
+ config: {},
702
+ _ownedOpenclawKeys: []
703
+ };
704
+ }
705
+ function normalize(raw) {
706
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return cloneEmpty();
707
+ const r = raw;
708
+ return {
709
+ servers: r.servers && typeof r.servers === "object" ? r.servers : {},
710
+ config: r.config && typeof r.config === "object" ? r.config : {},
711
+ _ownedOpenclawKeys: Array.isArray(r._ownedOpenclawKeys) ? r._ownedOpenclawKeys.slice() : []
712
+ };
713
+ }
714
+ function errMsg$1(err) {
715
+ return err instanceof Error ? err.message : String(err);
716
+ }
717
+ //#endregion
718
+ //#region src/manager.ts
719
+ const execFileAsync = promisify(execFile);
720
+ const defaultOpenclawExecutor = {
721
+ async setBatch(batch) {
722
+ if (batch.length === 0) return;
723
+ await execFileAsync("openclaw", [
724
+ "config",
725
+ "set",
726
+ "--batch-json",
727
+ JSON.stringify(batch)
728
+ ], { timeout: 1e4 });
729
+ },
730
+ async unset(path) {
731
+ await execFileAsync("openclaw", [
732
+ "config",
733
+ "unset",
734
+ path
735
+ ], { timeout: 1e4 });
736
+ }
737
+ };
738
+ const DEFAULT_MIRROR_DEBOUNCE_MS = 250;
739
+ /**
740
+ * Bundler manager — owns the alfe store, mirrors it into openclaw.json,
741
+ * and surfaces a small CRUD API the CLI and integration applier both
742
+ * call into.
743
+ *
744
+ * The store is the Alfe-owned source of truth; openclaw.json is a
745
+ * derived mirror so the runtime keeps consuming its existing format.
746
+ */
747
+ var Manager = class {
748
+ store;
749
+ executor;
750
+ logger;
751
+ mirrorDebounceMs;
752
+ mirrorTimer;
753
+ mirrorPromise = Promise.resolve();
754
+ mirrorPending;
755
+ bundler;
756
+ changeListeners = /* @__PURE__ */ new Set();
757
+ storeUnsubscribe;
758
+ constructor(opts = {}) {
759
+ this.store = opts.store ?? new Store({ logger: opts.logger });
760
+ this.executor = opts.executor ?? defaultOpenclawExecutor;
761
+ this.logger = opts.logger;
762
+ this.mirrorDebounceMs = opts.mirrorDebounceMs ?? DEFAULT_MIRROR_DEBOUNCE_MS;
763
+ }
764
+ /** Direct accessor — handy for tests and the CLI's `alfe mcp list`. */
765
+ getStore() {
766
+ return this.store;
767
+ }
768
+ /**
769
+ * Register or overwrite a server entry. Resolves as soon as the store
770
+ * mutation is committed to disk — the openclaw.json mirror runs async
771
+ * in the background and is debounced so back-to-back calls coalesce
772
+ * into one runtime restart. Call `flush()` to await the mirror.
773
+ */
774
+ async addServer(config, opts) {
775
+ if (!opts.id) throw new Error("Manager.addServer: id is required");
776
+ const owner = opts.owner ?? "manual";
777
+ this.store.update((cur) => {
778
+ const previousAddedAt = lookupAddedAt(cur.servers, opts.id);
779
+ const entry = toStoredEntry(config, {
780
+ owner,
781
+ transport: opts.transport,
782
+ version: opts.version,
783
+ addedAt: previousAddedAt
784
+ });
785
+ return {
786
+ ...cur,
787
+ servers: {
788
+ ...cur.servers,
789
+ [opts.id]: entry
790
+ }
791
+ };
792
+ });
793
+ this.scheduleMirror();
794
+ this.fireChange();
795
+ return Promise.resolve();
796
+ }
797
+ /**
798
+ * Remove a single server entry. No-op if the id isn't in the store.
799
+ * Refuses to remove an entry whose owner doesn't match `expectedOwner`
800
+ * when supplied — the CLI uses this to guard `alfe mcp remove` from
801
+ * accidentally clobbering integration- or cli-owned entries.
802
+ *
803
+ * Resolves as soon as the store mutation is committed. Mirror runs
804
+ * async; call `flush()` to await it.
805
+ */
806
+ removeServer(id, opts = {}) {
807
+ const existing = lookupEntry(this.store.read().servers, id);
808
+ if (!existing) return Promise.resolve(false);
809
+ if (opts.expectedOwner && existing.owner !== opts.expectedOwner) return Promise.reject(/* @__PURE__ */ new Error(`Manager.removeServer: server "${id}" is owned by "${existing.owner}", not "${opts.expectedOwner}"`));
810
+ this.store.update((cur) => ({
811
+ ...cur,
812
+ servers: Object.fromEntries(Object.entries(cur.servers).filter(([k]) => k !== id))
813
+ }));
814
+ this.scheduleMirror();
815
+ this.fireChange();
816
+ return Promise.resolve(true);
817
+ }
818
+ /** Drop every entry whose owner matches — used by integration uninstall. */
819
+ async removeServersByOwner(owner) {
820
+ const removed = [];
821
+ this.store.update((cur) => {
822
+ const next = {};
823
+ for (const [id, entry] of Object.entries(cur.servers)) if (entry.owner === owner) removed.push(id);
824
+ else next[id] = entry;
825
+ if (removed.length === 0) return cur;
826
+ return {
827
+ ...cur,
828
+ servers: next
829
+ };
830
+ });
831
+ if (removed.length > 0) {
832
+ this.scheduleMirror();
833
+ this.fireChange();
834
+ }
835
+ return Promise.resolve(removed);
836
+ }
837
+ /** Read-only snapshot for `alfe mcp list` and similar UIs. */
838
+ listServers() {
839
+ const snap = this.store.read();
840
+ return Object.entries(snap.servers).map(([id, entry]) => ({
841
+ id,
842
+ entry
843
+ }));
844
+ }
845
+ /**
846
+ * Push the current store contents into a bundler instance (which owns
847
+ * connections / tools). Wires up a store watcher so external mutations
848
+ * (e.g. another shell running `alfe mcp add`) re-reconcile.
849
+ */
850
+ async loadIntoBundler(bundler) {
851
+ this.bundler = bundler;
852
+ await this.reconcileBundler();
853
+ this.storeUnsubscribe ??= this.store.watch(() => {
854
+ this.reconcileBundler().catch((err) => {
855
+ this.logger?.warn("[mcp-bundler/manager] watcher reconcile failed", { err: errMsg(err) });
856
+ });
857
+ });
858
+ }
859
+ /** Subscribe to store mutations. Returns an unsubscribe fn. */
860
+ onChange(cb) {
861
+ this.changeListeners.add(cb);
862
+ return () => {
863
+ this.changeListeners.delete(cb);
864
+ };
865
+ }
866
+ /**
867
+ * Cancel any pending mirror-write, flush the in-flight one, and stop
868
+ * watching the store. Safe to call multiple times.
869
+ */
870
+ async dispose() {
871
+ if (this.mirrorTimer) {
872
+ clearTimeout(this.mirrorTimer);
873
+ this.mirrorTimer = void 0;
874
+ await this.runMirror();
875
+ }
876
+ await this.mirrorPromise.catch(() => void 0);
877
+ if (this.storeUnsubscribe) {
878
+ this.storeUnsubscribe();
879
+ this.storeUnsubscribe = void 0;
880
+ }
881
+ this.store.dispose();
882
+ this.changeListeners.clear();
883
+ this.bundler = void 0;
884
+ }
885
+ /**
886
+ * Force the debounced mirror to run now and wait for it to finish.
887
+ * Surfaces the executor error if the mirror failed — callers wrap in
888
+ * try/catch (or .rejects in tests) if they need to handle it.
889
+ */
890
+ async flush() {
891
+ if (this.mirrorTimer) {
892
+ clearTimeout(this.mirrorTimer);
893
+ this.mirrorTimer = void 0;
894
+ await this.runMirror();
895
+ }
896
+ await this.mirrorPromise;
897
+ }
898
+ scheduleMirror() {
899
+ if (this.mirrorPending) return;
900
+ this.mirrorPromise = new Promise((resolve, reject) => {
901
+ this.mirrorPending = {
902
+ resolve,
903
+ reject
904
+ };
905
+ });
906
+ this.mirrorTimer = setTimeout(() => {
907
+ this.mirrorTimer = void 0;
908
+ this.runMirror();
909
+ }, this.mirrorDebounceMs);
910
+ this.mirrorTimer.unref();
911
+ }
912
+ async runMirror() {
913
+ const pending = this.mirrorPending;
914
+ this.mirrorPending = void 0;
915
+ try {
916
+ await this.applyMirror();
917
+ pending?.resolve();
918
+ } catch (err) {
919
+ pending?.reject(err);
920
+ this.logger?.error("[mcp-bundler/manager] mirror write failed", { err: errMsg(err) });
921
+ }
922
+ }
923
+ /**
924
+ * Compute the diff between this manager's owned set and what the store
925
+ * declares now, then apply the openclaw config delta. Foreign keys
926
+ * (entries in openclaw.json#mcp.servers.* not in our store) are
927
+ * preserved — we only touch the names we previously claimed.
928
+ */
929
+ async applyMirror() {
930
+ let previousOwned = [];
931
+ let desiredOwned = [];
932
+ let batch = [];
933
+ this.store.update((cur) => {
934
+ previousOwned = cur._ownedOpenclawKeys.slice();
935
+ const entries = Object.entries(cur.servers);
936
+ desiredOwned = entries.map(([id]) => id).sort();
937
+ batch = entries.flatMap(([id, entry]) => renderEntryToBatch(id, entry));
938
+ return {
939
+ ...cur,
940
+ _ownedOpenclawKeys: desiredOwned
941
+ };
942
+ });
943
+ await this.executor.setBatch(batch);
944
+ const toUnset = previousOwned.filter((k) => !desiredOwned.includes(k));
945
+ for (const id of toUnset) try {
946
+ await this.executor.unset(`mcp.servers.${id}`);
947
+ } catch (err) {
948
+ this.logger?.warn("[mcp-bundler/manager] mirror unset failed (continuing)", {
949
+ err: errMsg(err),
950
+ key: `mcp.servers.${id}`
951
+ });
952
+ }
953
+ if (this.bundler) try {
954
+ await this.reconcileBundler();
955
+ } catch (err) {
956
+ this.logger?.warn("[mcp-bundler/manager] post-mirror reconcile failed", { err: errMsg(err) });
957
+ }
958
+ }
959
+ async reconcileBundler() {
960
+ if (!this.bundler) return;
961
+ const snap = this.store.read();
962
+ const servers = {};
963
+ for (const [id, entry] of Object.entries(snap.servers)) servers[id] = toServerConfig(entry);
964
+ await this.bundler.reconcile(servers);
965
+ }
966
+ fireChange() {
967
+ for (const cb of this.changeListeners) try {
968
+ cb();
969
+ } catch (err) {
970
+ this.logger?.warn("[mcp-bundler/manager] onChange listener threw", { err: errMsg(err) });
971
+ }
972
+ }
973
+ };
974
+ /**
975
+ * Render one store entry as the set of dotted-path batch entries that
976
+ * `openclaw config set --batch-json` expects.
977
+ *
978
+ * Mirrors the format the integrations applier already emits today so the
979
+ * runtime sees the same shape regardless of which writer produced it.
980
+ */
981
+ function renderEntryToBatch(id, entry) {
982
+ const prefix = `mcp.servers.${id}`;
983
+ const out = [];
984
+ if (entry.transport === "stdio") {
985
+ out.push({
986
+ path: `${prefix}.command`,
987
+ value: entry.command
988
+ });
989
+ if (entry.args && entry.args.length > 0) out.push({
990
+ path: `${prefix}.args`,
991
+ value: entry.args
992
+ });
993
+ if (entry.env) for (const [k, v] of Object.entries(entry.env)) out.push({
994
+ path: `${prefix}.env.${k}`,
995
+ value: v
996
+ });
997
+ if (entry.cwd) out.push({
998
+ path: `${prefix}.cwd`,
999
+ value: entry.cwd
1000
+ });
1001
+ return out;
1002
+ }
1003
+ out.push({
1004
+ path: `${prefix}.url`,
1005
+ value: entry.url
1006
+ });
1007
+ out.push({
1008
+ path: `${prefix}.transport`,
1009
+ value: entry.transport
1010
+ });
1011
+ if (entry.headers) for (const [k, v] of Object.entries(entry.headers)) out.push({
1012
+ path: `${prefix}.headers.${k}`,
1013
+ value: v
1014
+ });
1015
+ if (entry.connectionTimeoutMs !== void 0) out.push({
1016
+ path: `${prefix}.connectionTimeoutMs`,
1017
+ value: entry.connectionTimeoutMs
1018
+ });
1019
+ return out;
1020
+ }
1021
+ function errMsg(err) {
1022
+ return err instanceof Error ? err.message : String(err);
1023
+ }
1024
+ /**
1025
+ * Indexed access on `Record<string, T>` returns `T` (not `T | undefined`)
1026
+ * unless `noUncheckedIndexedAccess` is set in tsconfig. These helpers
1027
+ * make the optional-ness explicit so the lint rules that hate
1028
+ * always-truthy conditionals stop firing on real lookups.
1029
+ */
1030
+ function lookupEntry(servers, id) {
1031
+ return Object.hasOwn(servers, id) ? servers[id] : void 0;
1032
+ }
1033
+ function lookupAddedAt(servers, id) {
1034
+ const entry = lookupEntry(servers, id);
1035
+ return entry ? entry.addedAt : void 0;
1036
+ }
1037
+ //#endregion
1038
+ export { Connection, Manager, McpBundler, STDIO_ENV_DENYLIST, Store, buildNamespacedToolName, defaultConnect, defaultOpenclawExecutor, defaultStorePath, disambiguateAgainst, sanitizeNameSegment, sanitizeStdioEnv, toServerConfig, toStoredEntry };
453
1039
 
454
1040
  //# sourceMappingURL=index.js.map