@multiplatform.one/frappe 6.7.0 → 7.0.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/README.md +1 -1
- package/dist/cjs/core/http.cjs +45 -1
- package/dist/cjs/core/http.native.js +72 -1
- package/dist/cjs/core/http.native.js.map +1 -1
- package/dist/cjs/doctype.cjs +15 -2
- package/dist/cjs/doctype.native.js +22 -5
- package/dist/cjs/doctype.native.js.map +1 -1
- package/dist/cjs/frappeReact.cjs +55 -1
- package/dist/cjs/frappeReact.native.js +66 -1
- package/dist/cjs/frappeReact.native.js.map +1 -1
- package/dist/cjs/index.cjs +7 -0
- package/dist/cjs/index.native.js +7 -0
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/optimistic.cjs +32 -2
- package/dist/cjs/optimistic.native.js +34 -1
- package/dist/cjs/optimistic.native.js.map +1 -1
- package/dist/cjs/pacer.native.js.map +1 -1
- package/dist/cjs/realtime.cjs +12 -0
- package/dist/cjs/realtime.native.js +16 -4
- package/dist/cjs/realtime.native.js.map +1 -1
- package/dist/cjs/sync/index.cjs +45 -1
- package/dist/cjs/sync/index.native.js +71 -1
- package/dist/cjs/sync/index.native.js.map +1 -1
- package/dist/cjs/sync/mutationEngine.cjs +51 -15
- package/dist/cjs/sync/mutationEngine.native.js +69 -16
- package/dist/cjs/sync/mutationEngine.native.js.map +1 -1
- package/dist/cjs/sync/mutationJournal.cjs +8 -0
- package/dist/cjs/sync/mutationJournal.native.js +10 -0
- package/dist/cjs/sync/mutationJournal.native.js.map +1 -1
- package/dist/cjs/sync/types.native.js.map +1 -1
- package/dist/esm/core/http.mjs +45 -2
- package/dist/esm/core/http.mjs.map +1 -1
- package/dist/esm/core/http.native.js +72 -2
- package/dist/esm/core/http.native.js.map +1 -1
- package/dist/esm/doctype.mjs +15 -2
- package/dist/esm/doctype.mjs.map +1 -1
- package/dist/esm/doctype.native.js +22 -5
- package/dist/esm/doctype.native.js.map +1 -1
- package/dist/esm/frappeReact.mjs +55 -3
- package/dist/esm/frappeReact.mjs.map +1 -1
- package/dist/esm/frappeReact.native.js +66 -3
- package/dist/esm/frappeReact.native.js.map +1 -1
- package/dist/esm/index.mjs +4 -4
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +4 -4
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/optimistic.mjs +27 -1
- package/dist/esm/optimistic.mjs.map +1 -1
- package/dist/esm/optimistic.native.js +30 -1
- package/dist/esm/optimistic.native.js.map +1 -1
- package/dist/esm/pacer.mjs.map +1 -1
- package/dist/esm/pacer.native.js.map +1 -1
- package/dist/esm/realtime.mjs +12 -0
- package/dist/esm/realtime.mjs.map +1 -1
- package/dist/esm/realtime.native.js +16 -4
- package/dist/esm/realtime.native.js.map +1 -1
- package/dist/esm/sync/index.mjs +45 -1
- package/dist/esm/sync/index.mjs.map +1 -1
- package/dist/esm/sync/index.native.js +71 -1
- package/dist/esm/sync/index.native.js.map +1 -1
- package/dist/esm/sync/mutationEngine.mjs +51 -15
- package/dist/esm/sync/mutationEngine.mjs.map +1 -1
- package/dist/esm/sync/mutationEngine.native.js +69 -16
- package/dist/esm/sync/mutationEngine.native.js.map +1 -1
- package/dist/esm/sync/mutationJournal.mjs +8 -0
- package/dist/esm/sync/mutationJournal.mjs.map +1 -1
- package/dist/esm/sync/mutationJournal.native.js +10 -0
- package/dist/esm/sync/mutationJournal.native.js.map +1 -1
- package/dist/esm/sync/types.mjs.map +1 -1
- package/dist/esm/sync/types.native.js.map +1 -1
- package/dist/jsx/core/http.mjs +45 -2
- package/dist/jsx/core/http.mjs.map +1 -1
- package/dist/jsx/core/http.native.js +72 -1
- package/dist/jsx/core/http.native.js.map +1 -1
- package/dist/jsx/doctype.mjs +15 -2
- package/dist/jsx/doctype.mjs.map +1 -1
- package/dist/jsx/doctype.native.js +22 -5
- package/dist/jsx/doctype.native.js.map +1 -1
- package/dist/jsx/frappeReact.mjs +55 -3
- package/dist/jsx/frappeReact.mjs.map +1 -1
- package/dist/jsx/frappeReact.native.js +66 -1
- package/dist/jsx/frappeReact.native.js.map +1 -1
- package/dist/jsx/index.js +4 -4
- package/dist/jsx/index.js.map +1 -1
- package/dist/jsx/index.mjs +4 -4
- package/dist/jsx/index.mjs.map +1 -1
- package/dist/jsx/index.native.js +7 -0
- package/dist/jsx/index.native.js.map +1 -1
- package/dist/jsx/optimistic.mjs +27 -1
- package/dist/jsx/optimistic.mjs.map +1 -1
- package/dist/jsx/optimistic.native.js +34 -1
- package/dist/jsx/optimistic.native.js.map +1 -1
- package/dist/jsx/pacer.mjs.map +1 -1
- package/dist/jsx/pacer.native.js.map +1 -1
- package/dist/jsx/realtime.mjs +12 -0
- package/dist/jsx/realtime.mjs.map +1 -1
- package/dist/jsx/realtime.native.js +16 -4
- package/dist/jsx/realtime.native.js.map +1 -1
- package/dist/jsx/sync/index.mjs +45 -1
- package/dist/jsx/sync/index.mjs.map +1 -1
- package/dist/jsx/sync/index.native.js +71 -1
- package/dist/jsx/sync/index.native.js.map +1 -1
- package/dist/jsx/sync/mutationEngine.mjs +51 -15
- package/dist/jsx/sync/mutationEngine.mjs.map +1 -1
- package/dist/jsx/sync/mutationEngine.native.js +69 -16
- package/dist/jsx/sync/mutationEngine.native.js.map +1 -1
- package/dist/jsx/sync/mutationJournal.mjs +8 -0
- package/dist/jsx/sync/mutationJournal.mjs.map +1 -1
- package/dist/jsx/sync/mutationJournal.native.js +10 -0
- package/dist/jsx/sync/mutationJournal.native.js.map +1 -1
- package/dist/jsx/sync/types.mjs.map +1 -1
- package/dist/jsx/sync/types.native.js.map +1 -1
- package/package.json +7 -7
- package/src/core/http.spec.ts +81 -0
- package/src/core/http.ts +80 -3
- package/src/doctype.spec.ts +82 -3
- package/src/doctype.ts +51 -7
- package/src/frappeReact.ts +126 -4
- package/src/index.ts +15 -2
- package/src/optimistic.spec.ts +113 -2
- package/src/optimistic.ts +95 -0
- package/src/pacer.ts +8 -0
- package/src/realtime.spec.ts +62 -0
- package/src/realtime.ts +34 -0
- package/src/sync/index.ts +44 -1
- package/src/sync/mutationEngine.spec.ts +7 -2
- package/src/sync/mutationEngine.ts +83 -27
- package/src/sync/mutationJournal.ts +9 -0
- package/src/sync/mutations.spec.ts +1 -1
- package/src/sync/syncModule.spec.ts +255 -1
- package/src/sync/types.ts +20 -0
- package/types/core/http.d.ts +11 -0
- package/types/core/http.d.ts.map +1 -1
- package/types/doctype.d.ts +7 -0
- package/types/doctype.d.ts.map +1 -1
- package/types/frappeReact.d.ts +50 -2
- package/types/frappeReact.d.ts.map +1 -1
- package/types/index.d.ts +6 -5
- package/types/index.d.ts.map +1 -1
- package/types/optimistic.d.ts +38 -0
- package/types/optimistic.d.ts.map +1 -1
- package/types/pacer.d.ts +8 -0
- package/types/pacer.d.ts.map +1 -1
- package/types/realtime.d.ts +17 -0
- package/types/realtime.d.ts.map +1 -1
- package/types/sync/index.d.ts +16 -1
- package/types/sync/index.d.ts.map +1 -1
- package/types/sync/mutationEngine.d.ts +7 -0
- package/types/sync/mutationEngine.d.ts.map +1 -1
- package/types/sync/mutationJournal.d.ts +6 -0
- package/types/sync/mutationJournal.d.ts.map +1 -1
- package/types/sync/types.d.ts +20 -0
- package/types/sync/types.d.ts.map +1 -1
package/src/doctype.ts
CHANGED
|
@@ -503,6 +503,13 @@ function useHttpClientRef(
|
|
|
503
503
|
/**
|
|
504
504
|
* Fetch and cache DocType metadata (schema, fields, layout, permissions).
|
|
505
505
|
*
|
|
506
|
+
* Metadata comes from `frappe.desk.form.load.getdoctype`, the whitelisted RPC
|
|
507
|
+
* the Desk itself uses, so ordinary users load forms too — the REST
|
|
508
|
+
* `/api/resource/DocType/<name>` route reads the DocType *document* and is
|
|
509
|
+
* System-Manager-only. That route stays as a fallback for desk-less sites
|
|
510
|
+
* where the RPC is not registered. When `config.fixtures` carries a "DocType"
|
|
511
|
+
* entry for the doctype it is used instead and no request is made.
|
|
512
|
+
*
|
|
506
513
|
* Responses are cached per-session in memory keyed by `{baseURL}:{doctype}`.
|
|
507
514
|
* The `refetch` function clears the cache entry and forces a fresh fetch.
|
|
508
515
|
*
|
|
@@ -576,13 +583,50 @@ export function useDocTypeMeta(
|
|
|
576
583
|
|
|
577
584
|
(async () => {
|
|
578
585
|
try {
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
586
|
+
// Fixture hosts (stories/tests) keep the getdoctype-shaped meta doc in
|
|
587
|
+
// the provider's "DocType" collection — the same convention the
|
|
588
|
+
// backoffice meta loader uses. Providers that stub only documents fall
|
|
589
|
+
// through to the network below.
|
|
590
|
+
const fromFixtures = config.fixtures
|
|
591
|
+
? ((await config.fixtures.getDoc("DocType", doctype)) as RawDocTypeResponse | null)
|
|
592
|
+
: null;
|
|
583
593
|
if (cancelled) return;
|
|
584
594
|
|
|
585
|
-
|
|
595
|
+
let raw = fromFixtures?.fields ? fromFixtures : undefined;
|
|
596
|
+
|
|
597
|
+
if (!raw) {
|
|
598
|
+
// `getdoctype` is the whitelisted RPC the Desk itself loads form
|
|
599
|
+
// metadata with, and every logged-in user may call it. Reading
|
|
600
|
+
// `/api/resource/DocType/<name>` instead asks for the DocType
|
|
601
|
+
// *document*, which only System Manager holds read permission on, so
|
|
602
|
+
// ordinary users got a 403 PermissionError — and Frappe's
|
|
603
|
+
// developer-mode traceback rendered into the UI instead of a form.
|
|
604
|
+
const response = await http.get<{ docs?: RawDocTypeResponse[] }>(
|
|
605
|
+
"/api/method/frappe.desk.form.load.getdoctype",
|
|
606
|
+
{ doctype },
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
if (cancelled) return;
|
|
610
|
+
|
|
611
|
+
// docs[0] is the requested doctype; docs[1..] are its child tables.
|
|
612
|
+
const docs = response.payload?.docs;
|
|
613
|
+
raw = docs?.find((doc) => doc?.name === doctype) ?? docs?.[0];
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (!raw) {
|
|
617
|
+
// Sites that expose the REST API without the desk leave the RPC
|
|
618
|
+
// above unregistered, so it answers with no `docs`. The document
|
|
619
|
+
// route still serves meta there, and such a site's API clients are
|
|
620
|
+
// typically System Managers, who may read it.
|
|
621
|
+
const rest = await http.get<{ data?: RawDocTypeResponse }>(
|
|
622
|
+
`/api/resource/DocType/${encodeURIComponent(doctype)}`,
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
if (cancelled) return;
|
|
626
|
+
|
|
627
|
+
raw = rest.payload?.data;
|
|
628
|
+
}
|
|
629
|
+
|
|
586
630
|
if (!raw) {
|
|
587
631
|
throw new Error(`No data returned for DocType "${doctype}"`);
|
|
588
632
|
}
|
|
@@ -605,8 +649,8 @@ export function useDocTypeMeta(
|
|
|
605
649
|
// Depend on the stable string `cacheKey` (baseURL + doctype), NOT the
|
|
606
650
|
// `config` object — callers frequently pass a freshly-allocated config
|
|
607
651
|
// object every render. Keying on the object identity would re-run this
|
|
608
|
-
// effect on every render and, when the request fails (e.g. a Guest
|
|
609
|
-
// the
|
|
652
|
+
// effect on every render and, when the request fails (e.g. a Guest calling
|
|
653
|
+
// the login-gated getdoctype RPC → 403, which never populates
|
|
610
654
|
// `metaCache`), spin into an infinite refetch loop.
|
|
611
655
|
}, [cacheKey, version]);
|
|
612
656
|
|
package/src/frappeReact.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isWindowDefined } from "@multiplatform.one/platform";
|
|
2
2
|
import type { Collection } from "@tanstack/db";
|
|
3
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import {
|
|
5
5
|
createFrappeCollection,
|
|
6
6
|
releaseFrappeCollectionSync,
|
|
@@ -12,6 +12,8 @@ import { DbModule } from "./db";
|
|
|
12
12
|
import { FixtureDbModule } from "./fixtureDb";
|
|
13
13
|
import { RealtimeModule } from "./realtime";
|
|
14
14
|
import { SyncModule } from "./sync";
|
|
15
|
+
import { Status } from "./sync/types";
|
|
16
|
+
import type { DebugMutationEntry } from "./sync/types";
|
|
15
17
|
import { isBrowser } from "./util";
|
|
16
18
|
|
|
17
19
|
/**
|
|
@@ -26,6 +28,20 @@ export interface UseFrappeCollectionConfig {
|
|
|
26
28
|
* behind a reverse proxy so the base URL port is used.
|
|
27
29
|
*/
|
|
28
30
|
socketPort?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Dial the realtime socket at this origin instead of deriving it from
|
|
33
|
+
* `baseURL` + `socketPort` — for dev servers that proxy `/socket.io` on
|
|
34
|
+
* their own origin (same-origin socket). See
|
|
35
|
+
* `RealtimeConfig.socketBaseURL`.
|
|
36
|
+
*/
|
|
37
|
+
socketBaseURL?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Frappe site name — the Socket.IO namespace the realtime connection must
|
|
40
|
+
* join. Required whenever the `baseURL` hostname is not the bench site
|
|
41
|
+
* name (e.g. baseURL http://127.0.0.1:8000 serving site "localhost"). See
|
|
42
|
+
* `RealtimeConfig.siteName`.
|
|
43
|
+
*/
|
|
44
|
+
siteName?: string;
|
|
29
45
|
/** Authentication configuration */
|
|
30
46
|
auth?: {
|
|
31
47
|
useToken?: boolean;
|
|
@@ -61,6 +77,20 @@ export function __getActiveSyncModule(): SyncModule | undefined {
|
|
|
61
77
|
return undefined;
|
|
62
78
|
}
|
|
63
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Public accessor for the shared SyncModule behind a connection config —
|
|
82
|
+
* the seam consumers use to issue engine mutations that a collection can't
|
|
83
|
+
* express (e.g. custom-transport writes via `mutation.execute`). Returns
|
|
84
|
+
* the same refcounted instance the collection hooks use, or undefined
|
|
85
|
+
* during SSR.
|
|
86
|
+
*/
|
|
87
|
+
export function getFrappeSyncModule(
|
|
88
|
+
config: UseFrappeCollectionConfig | undefined,
|
|
89
|
+
): SyncModule | undefined {
|
|
90
|
+
if (!config) return undefined;
|
|
91
|
+
return getSyncModule(config);
|
|
92
|
+
}
|
|
93
|
+
|
|
64
94
|
/**
|
|
65
95
|
* Gets or creates a shared SyncModule for the given config.
|
|
66
96
|
* SyncModules are cached by baseURL to prevent duplicate connections.
|
|
@@ -90,7 +120,18 @@ function getSyncModule(config: UseFrappeCollectionConfig): SyncModule | undefine
|
|
|
90
120
|
return sync;
|
|
91
121
|
}
|
|
92
122
|
|
|
93
|
-
|
|
123
|
+
// Key on the full socket target, not just baseURL: socketPort /
|
|
124
|
+
// socketBaseURL / siteName all change where (or how) the realtime socket
|
|
125
|
+
// dials, so configs that differ in them must not share a SyncModule —
|
|
126
|
+
// sharing would silently keep the FIRST config's socket target for every
|
|
127
|
+
// later consumer. Consumers reading the same FrappeProvider still coalesce
|
|
128
|
+
// (identical fields → identical key).
|
|
129
|
+
const key = [
|
|
130
|
+
config.baseURL,
|
|
131
|
+
config.socketPort ?? "",
|
|
132
|
+
config.socketBaseURL ?? "",
|
|
133
|
+
config.siteName ?? "",
|
|
134
|
+
].join("|");
|
|
94
135
|
let sync = syncModuleCache.get(key);
|
|
95
136
|
if (!sync) {
|
|
96
137
|
// The FIRST config for a baseURL wins for every later consumer (the
|
|
@@ -114,6 +155,12 @@ function getSyncModule(config: UseFrappeCollectionConfig): SyncModule | undefine
|
|
|
114
155
|
const realtimeConfig = {
|
|
115
156
|
baseURL: config.baseURL,
|
|
116
157
|
socketPort: config.socketPort,
|
|
158
|
+
socketBaseURL: config.socketBaseURL,
|
|
159
|
+
// Forward siteName: Frappe's realtime server only emits events on the
|
|
160
|
+
// site's namespace, so dropping it here silently connected sockets to
|
|
161
|
+
// a namespace the server rejects whenever the baseURL hostname isn't
|
|
162
|
+
// the site name (e.g. 127.0.0.1 vs "localhost").
|
|
163
|
+
siteName: config.siteName,
|
|
117
164
|
auth: authConfig,
|
|
118
165
|
socketOptions: config.socketOptions,
|
|
119
166
|
};
|
|
@@ -601,6 +648,81 @@ export function useFrappeDoctypeEvents(
|
|
|
601
648
|
}, [enabled, config?.baseURL, !!config?.fixtures, doctype]);
|
|
602
649
|
}
|
|
603
650
|
|
|
651
|
+
// ---------------------------------------------------------------------------
|
|
652
|
+
// Mutation ledger hook
|
|
653
|
+
// ---------------------------------------------------------------------------
|
|
654
|
+
|
|
655
|
+
/** Result of {@link useFrappePendingMutations}. */
|
|
656
|
+
export interface UseFrappePendingMutationsResult {
|
|
657
|
+
/** All live ledger entries (pending + failed), submission order. */
|
|
658
|
+
entries: DebugMutationEntry[];
|
|
659
|
+
/** Entries still awaiting a server ack. */
|
|
660
|
+
pending: DebugMutationEntry[];
|
|
661
|
+
/** Entries the server rejected — rollback already ran; show these. */
|
|
662
|
+
failed: DebugMutationEntry[];
|
|
663
|
+
/** Re-issues every pending/failed entry with its ORIGINAL mutation id
|
|
664
|
+
* (idempotent on the server via Idempotency-Key). */
|
|
665
|
+
retry: () => Promise<void>;
|
|
666
|
+
/** Drops one failed entry without retrying (clears the error surface). */
|
|
667
|
+
dismiss: (id: string) => void;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const EMPTY_LEDGER: DebugMutationEntry[] = [];
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* React hook exposing the mutation ledger for a connection (optionally
|
|
674
|
+
* scoped to one doctype): which optimistic writes are still in flight and
|
|
675
|
+
* which the server rejected. This is the state behind the rejected-write
|
|
676
|
+
* UI contract — a surface that initiated a write renders `failed` entries
|
|
677
|
+
* inline (e.g. via frappe-ui's `SyncErrorNotice`) with `retry`/`dismiss`.
|
|
678
|
+
*
|
|
679
|
+
* Reactive: re-reads on every store emit, but only re-renders when the
|
|
680
|
+
* ledger signature (ids × status × retryCount) actually changes.
|
|
681
|
+
*/
|
|
682
|
+
export function useFrappePendingMutations(
|
|
683
|
+
config: UseFrappeCollectionConfig | undefined,
|
|
684
|
+
doctype?: string,
|
|
685
|
+
): UseFrappePendingMutationsResult {
|
|
686
|
+
const [entries, setEntries] = useState<DebugMutationEntry[]>(EMPTY_LEDGER);
|
|
687
|
+
const syncModuleRef = useRef<SyncModule | undefined>(undefined);
|
|
688
|
+
|
|
689
|
+
useEffect(() => {
|
|
690
|
+
if (!config || !isBrowser()) {
|
|
691
|
+
setEntries(EMPTY_LEDGER);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
const syncModule = getSyncModule(config);
|
|
695
|
+
if (!syncModule) return;
|
|
696
|
+
syncModuleRef.current = syncModule;
|
|
697
|
+
|
|
698
|
+
let lastSignature = "";
|
|
699
|
+
const read = () => {
|
|
700
|
+
const next = syncModule.getMutationEntries(doctype);
|
|
701
|
+
const signature = next.map((e) => `${e.id}:${e.status}:${e.retryCount}`).join("|");
|
|
702
|
+
if (signature === lastSignature) return;
|
|
703
|
+
lastSignature = signature;
|
|
704
|
+
setEntries(next.length === 0 ? EMPTY_LEDGER : next);
|
|
705
|
+
};
|
|
706
|
+
read();
|
|
707
|
+
return syncModule.subscribeStore(read);
|
|
708
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
709
|
+
}, [config?.baseURL, !!config?.fixtures, doctype]);
|
|
710
|
+
|
|
711
|
+
const retry = useCallback(async () => {
|
|
712
|
+
await syncModuleRef.current?.replayPending();
|
|
713
|
+
}, []);
|
|
714
|
+
|
|
715
|
+
const dismiss = useCallback((id: string) => {
|
|
716
|
+
syncModuleRef.current?.dismissMutation(id);
|
|
717
|
+
}, []);
|
|
718
|
+
|
|
719
|
+
return useMemo(() => {
|
|
720
|
+
const pending = entries.filter((e) => e.status === Status.Pending);
|
|
721
|
+
const failed = entries.filter((e) => e.status === Status.Failed);
|
|
722
|
+
return { entries, pending, failed, retry, dismiss };
|
|
723
|
+
}, [entries, retry, dismiss]);
|
|
724
|
+
}
|
|
725
|
+
|
|
604
726
|
// ---------------------------------------------------------------------------
|
|
605
727
|
// Infinite Scroll Hook
|
|
606
728
|
// ---------------------------------------------------------------------------
|
|
@@ -663,7 +785,7 @@ export interface UseInfiniteScrollResult {
|
|
|
663
785
|
*
|
|
664
786
|
* return (
|
|
665
787
|
* <div>
|
|
666
|
-
* {data.map(pokemon => <PokemonCard key={pokemon.name} {
|
|
788
|
+
* {data.map(pokemon => <PokemonCard key={pokemon.name} pokemon={pokemon} />)}
|
|
667
789
|
* <div ref={sentinelRef}>
|
|
668
790
|
* {isLoadingMore ? <Spinner /> : hasMore ? "Scroll for more" : "No more items"}
|
|
669
791
|
* </div>
|
|
@@ -865,7 +987,7 @@ export interface UseFrappeInfiniteListResult<TDoc> {
|
|
|
865
987
|
*
|
|
866
988
|
* return (
|
|
867
989
|
* <div>
|
|
868
|
-
* {items.map(pokemon => <PokemonCard key={pokemon.name} {
|
|
990
|
+
* {items.map(pokemon => <PokemonCard key={pokemon.name} pokemon={pokemon} />)}
|
|
869
991
|
* <div ref={sentinelRef}>
|
|
870
992
|
* {hasMore ? <Spinner /> : "No more pokemon"}
|
|
871
993
|
* </div>
|
package/src/index.ts
CHANGED
|
@@ -35,20 +35,30 @@ export {
|
|
|
35
35
|
fixtureCollectionOptions,
|
|
36
36
|
} from "./collection";
|
|
37
37
|
export type { FrappeCollectionOptionsConfig, FixtureCollectionOptionsConfig } from "./collection";
|
|
38
|
-
export {
|
|
38
|
+
export {
|
|
39
|
+
createOptimisticAction,
|
|
40
|
+
isOptimisticTempId,
|
|
41
|
+
newOptimisticTempId,
|
|
42
|
+
OPTIMISTIC_TEMP_PREFIX,
|
|
43
|
+
runOptimisticRpc,
|
|
44
|
+
} from "./optimistic";
|
|
45
|
+
export type { OptimisticRpcContext, OptimisticRpcOptions } from "./optimistic";
|
|
39
46
|
|
|
40
47
|
// React hooks for Frappe + TanStack DB
|
|
41
48
|
export {
|
|
42
49
|
useFrappeCollection,
|
|
43
50
|
useFrappeConnection,
|
|
44
51
|
useFrappeDoctypeEvents,
|
|
52
|
+
useFrappePendingMutations,
|
|
45
53
|
useInfiniteScroll,
|
|
46
54
|
useFrappeInfiniteList,
|
|
55
|
+
getFrappeSyncModule,
|
|
47
56
|
__getActiveSyncModule,
|
|
48
57
|
} from "./frappeReact";
|
|
49
58
|
export type {
|
|
50
59
|
FrappeConnectionState,
|
|
51
60
|
UseFrappeCollectionConfig,
|
|
61
|
+
UseFrappePendingMutationsResult,
|
|
52
62
|
UseInfiniteScrollConfig,
|
|
53
63
|
UseInfiniteScrollResult,
|
|
54
64
|
UseFrappeInfiniteListConfig,
|
|
@@ -180,6 +190,9 @@ export type {
|
|
|
180
190
|
PageBoundaries,
|
|
181
191
|
RehydrationStrategy,
|
|
182
192
|
Mutation,
|
|
193
|
+
CreateMutation,
|
|
194
|
+
UpdateMutation,
|
|
195
|
+
DeleteMutation,
|
|
183
196
|
ConnectionConfig,
|
|
184
197
|
TabCoordinationConfig,
|
|
185
198
|
TabMessage,
|
|
@@ -188,7 +201,7 @@ export type {
|
|
|
188
201
|
DebugMutationEntry,
|
|
189
202
|
PageIsland,
|
|
190
203
|
} from "./sync/types";
|
|
191
|
-
export { Order } from "./sync/types";
|
|
204
|
+
export { Order, Operation } from "./sync/types";
|
|
192
205
|
export { MergeStrategy, FieldMergeStrategy } from "./sync/merge";
|
|
193
206
|
export type { FieldMergeStrategyOrFunction, MergeConfig, MergeResult } from "./sync/merge";
|
|
194
207
|
|
package/src/optimistic.spec.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { createOptimisticAction as tanstackCreateOptimisticAction } from "@tanstack/db";
|
|
2
|
-
import { describe, expect, it } from "vitest";
|
|
3
|
-
import {
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
OPTIMISTIC_TEMP_PREFIX,
|
|
5
|
+
createOptimisticAction,
|
|
6
|
+
isOptimisticTempId,
|
|
7
|
+
newOptimisticTempId,
|
|
8
|
+
runOptimisticRpc,
|
|
9
|
+
} from "./optimistic";
|
|
4
10
|
|
|
5
11
|
describe("createOptimisticAction re-export", () => {
|
|
6
12
|
it("is the same reference as @tanstack/db createOptimisticAction", () => {
|
|
@@ -11,3 +17,108 @@ describe("createOptimisticAction re-export", () => {
|
|
|
11
17
|
expect(typeof createOptimisticAction).toBe("function");
|
|
12
18
|
});
|
|
13
19
|
});
|
|
20
|
+
|
|
21
|
+
describe("newOptimisticTempId / isOptimisticTempId", () => {
|
|
22
|
+
it("issues unique tmp_-prefixed identities (MutationEngine convention)", () => {
|
|
23
|
+
const a = newOptimisticTempId();
|
|
24
|
+
const b = newOptimisticTempId();
|
|
25
|
+
expect(a.startsWith(OPTIMISTIC_TEMP_PREFIX)).toBe(true);
|
|
26
|
+
expect(b.startsWith(OPTIMISTIC_TEMP_PREFIX)).toBe(true);
|
|
27
|
+
expect(a).not.toBe(b);
|
|
28
|
+
expect(isOptimisticTempId(a)).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("rejects server-issued names and non-strings", () => {
|
|
32
|
+
expect(isOptimisticTempId("TODO-0001")).toBe(false);
|
|
33
|
+
expect(isOptimisticTempId(undefined)).toBe(false);
|
|
34
|
+
expect(isOptimisticTempId(42)).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("runOptimisticRpc lifecycle", () => {
|
|
39
|
+
it("applies locally BEFORE the request resolves (local apply is the first reveal)", async () => {
|
|
40
|
+
const order: string[] = [];
|
|
41
|
+
let releaseSend: (value: string) => void = () => {};
|
|
42
|
+
const sendGate = new Promise<string>((resolve) => {
|
|
43
|
+
releaseSend = resolve;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const promise = runOptimisticRpc<string>({
|
|
47
|
+
apply: () => {
|
|
48
|
+
order.push("apply");
|
|
49
|
+
return () => order.push("undo");
|
|
50
|
+
},
|
|
51
|
+
send: async () => {
|
|
52
|
+
order.push("send");
|
|
53
|
+
return sendGate;
|
|
54
|
+
},
|
|
55
|
+
reconcile: () => order.push("reconcile"),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Apply and send already happened synchronously; the ack has not landed.
|
|
59
|
+
expect(order).toEqual(["apply", "send"]);
|
|
60
|
+
releaseSend("ack");
|
|
61
|
+
await expect(promise).resolves.toBe("ack");
|
|
62
|
+
expect(order).toEqual(["apply", "send", "reconcile"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("passes the SAME temp id through apply, send, and reconcile (identity convergence)", async () => {
|
|
66
|
+
const seen: string[] = [];
|
|
67
|
+
await runOptimisticRpc({
|
|
68
|
+
apply: (ctx) => {
|
|
69
|
+
seen.push(ctx.tempId);
|
|
70
|
+
},
|
|
71
|
+
send: async (ctx) => {
|
|
72
|
+
seen.push(ctx.tempId);
|
|
73
|
+
return "ok";
|
|
74
|
+
},
|
|
75
|
+
reconcile: (_ack, ctx) => {
|
|
76
|
+
seen.push(ctx.tempId);
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
expect(seen).toHaveLength(3);
|
|
80
|
+
expect(new Set(seen).size).toBe(1);
|
|
81
|
+
expect(isOptimisticTempId(seen[0])).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("rolls back (undo) then surfaces the error visibly (onError) on failure, and rethrows", async () => {
|
|
85
|
+
const order: string[] = [];
|
|
86
|
+
const failure = new Error("server said no");
|
|
87
|
+
const onError = vi.fn((error: unknown) => {
|
|
88
|
+
order.push("onError");
|
|
89
|
+
expect(error).toBe(failure);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await expect(
|
|
93
|
+
runOptimisticRpc({
|
|
94
|
+
apply: () => {
|
|
95
|
+
order.push("apply");
|
|
96
|
+
return () => order.push("undo");
|
|
97
|
+
},
|
|
98
|
+
send: async () => {
|
|
99
|
+
throw failure;
|
|
100
|
+
},
|
|
101
|
+
reconcile: () => order.push("reconcile"),
|
|
102
|
+
onError,
|
|
103
|
+
}),
|
|
104
|
+
).rejects.toBe(failure);
|
|
105
|
+
|
|
106
|
+
// Rollback precedes the visible error; reconcile never runs.
|
|
107
|
+
expect(order).toEqual(["apply", "undo", "onError"]);
|
|
108
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("still surfaces onError when apply returned no undo", async () => {
|
|
112
|
+
const onError = vi.fn();
|
|
113
|
+
await expect(
|
|
114
|
+
runOptimisticRpc({
|
|
115
|
+
apply: () => {},
|
|
116
|
+
send: async () => {
|
|
117
|
+
throw new Error("boom");
|
|
118
|
+
},
|
|
119
|
+
onError,
|
|
120
|
+
}),
|
|
121
|
+
).rejects.toThrow("boom");
|
|
122
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
123
|
+
});
|
|
124
|
+
});
|
package/src/optimistic.ts
CHANGED
|
@@ -37,3 +37,98 @@ export { tanstackCreateOptimisticAction as createOptimisticAction };
|
|
|
37
37
|
* await tx.isPersisted.promise;
|
|
38
38
|
* ```
|
|
39
39
|
*/
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Optimistic RPC lifecycle — the ONE pattern for mutations that ride
|
|
43
|
+
// whitelisted desk RPCs instead of the document CRUD path.
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
//
|
|
46
|
+
// Document CRUD (collection insert/update/delete) is already optimistic via
|
|
47
|
+
// the MutationEngine: local cache apply → journal Pending → server ack →
|
|
48
|
+
// temp-id → real-name remap, snapshot rollback on failure. Desk RPC
|
|
49
|
+
// mutations (add_comment, assign_to, tags, likes, shares…) bypass that
|
|
50
|
+
// engine, and every callsite used to hand-roll "await, then refetch" — the
|
|
51
|
+
// user saw nothing until the server round-trip completed.
|
|
52
|
+
//
|
|
53
|
+
// `runOptimisticRpc` gives those callsites the same lifecycle the engine
|
|
54
|
+
// gives documents, without per-callsite hacks:
|
|
55
|
+
//
|
|
56
|
+
// 1. APPLY — synchronous local apply BEFORE the request leaves;
|
|
57
|
+
// returns the undo used for rollback. The applied state
|
|
58
|
+
// should carry a pending marker so surfaces can render it
|
|
59
|
+
// visible-but-distinct (Axiom 6 HONEST STATE).
|
|
60
|
+
// 2. SEND — the RPC itself.
|
|
61
|
+
// 3. RECONCILE — on ack, converge the optimistic identity (the issued
|
|
62
|
+
// `tmp_*` id) to the server identity so the optimistic
|
|
63
|
+
// record and the server record become ONE record, never
|
|
64
|
+
// duplicates.
|
|
65
|
+
// 4. ROLLBACK — on failure, the apply-phase undo runs, then `onError`
|
|
66
|
+
// surfaces a visible error (house `notify()`), and the
|
|
67
|
+
// error rethrows for the caller.
|
|
68
|
+
|
|
69
|
+
/** Prefix shared with the MutationEngine's synthesized create identities. */
|
|
70
|
+
export const OPTIMISTIC_TEMP_PREFIX = "tmp_";
|
|
71
|
+
|
|
72
|
+
/** Issues a client-only identity for an optimistic record (`tmp_<uuid>`). */
|
|
73
|
+
export function newOptimisticTempId(): string {
|
|
74
|
+
const id =
|
|
75
|
+
typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
76
|
+
? crypto.randomUUID()
|
|
77
|
+
: Math.random().toString(36).slice(2);
|
|
78
|
+
return `${OPTIMISTIC_TEMP_PREFIX}${id}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** True when a document name is a client-issued optimistic identity. */
|
|
82
|
+
export function isOptimisticTempId(name: unknown): name is string {
|
|
83
|
+
return typeof name === "string" && name.startsWith(OPTIMISTIC_TEMP_PREFIX);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface OptimisticRpcContext {
|
|
87
|
+
/** Client-only identity for the optimistic record (`tmp_<uuid>`). */
|
|
88
|
+
tempId: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface OptimisticRpcOptions<TAck> {
|
|
92
|
+
/**
|
|
93
|
+
* Apply the mutation locally, synchronously, before the request is sent.
|
|
94
|
+
* Returns the undo that restores the exact pre-apply state (rollback).
|
|
95
|
+
* The applied record should use `ctx.tempId` as its identity and carry a
|
|
96
|
+
* pending marker for the UI.
|
|
97
|
+
*/
|
|
98
|
+
apply: (ctx: OptimisticRpcContext) => (() => void) | undefined | void;
|
|
99
|
+
/** The server RPC. */
|
|
100
|
+
send: (ctx: OptimisticRpcContext) => Promise<TAck>;
|
|
101
|
+
/**
|
|
102
|
+
* Converge on ack: replace the `ctx.tempId` record with the server's
|
|
103
|
+
* authoritative record (dedupe by server identity — a racing refetch may
|
|
104
|
+
* already have delivered it).
|
|
105
|
+
*/
|
|
106
|
+
reconcile?: (ack: TAck, ctx: OptimisticRpcContext) => void;
|
|
107
|
+
/**
|
|
108
|
+
* Visible-error surface for the rollback path (house `notify()` or an
|
|
109
|
+
* inline error slot). Runs after the apply-phase undo.
|
|
110
|
+
*/
|
|
111
|
+
onError?: (error: unknown, ctx: OptimisticRpcContext) => void;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Runs one mutation through the optimistic lifecycle:
|
|
116
|
+
* apply (local, immediate) → send → reconcile on ack, or undo + onError on
|
|
117
|
+
* failure (the error rethrows).
|
|
118
|
+
*/
|
|
119
|
+
export async function runOptimisticRpc<TAck>(options: OptimisticRpcOptions<TAck>): Promise<TAck> {
|
|
120
|
+
const ctx: OptimisticRpcContext = { tempId: newOptimisticTempId() };
|
|
121
|
+
const undo = options.apply(ctx) ?? undefined;
|
|
122
|
+
try {
|
|
123
|
+
const ack = await options.send(ctx);
|
|
124
|
+
options.reconcile?.(ack, ctx);
|
|
125
|
+
return ack;
|
|
126
|
+
} catch (error) {
|
|
127
|
+
try {
|
|
128
|
+
undo?.();
|
|
129
|
+
} finally {
|
|
130
|
+
options.onError?.(error, ctx);
|
|
131
|
+
}
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
package/src/pacer.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { useCallback, useMemo, useRef, useState } from "react";
|
|
|
6
6
|
import type { FrappeDoc, FrappeDocData } from "./common/types";
|
|
7
7
|
import type { AuthConfig } from "./core/types";
|
|
8
8
|
import { DbModule } from "./db";
|
|
9
|
+
import type { FixtureProvider } from "./fixtures";
|
|
9
10
|
import { HttpClient } from "./core/http";
|
|
10
11
|
import type { SyncModule } from "./sync";
|
|
11
12
|
import { Operation } from "./sync/types";
|
|
@@ -62,6 +63,13 @@ export interface FrappeConnectionConfig {
|
|
|
62
63
|
token?: string | (() => string | Promise<string>);
|
|
63
64
|
type?: string;
|
|
64
65
|
};
|
|
66
|
+
/**
|
|
67
|
+
* Fixture provider for story / test hosts. Consumers already forward it
|
|
68
|
+
* (frappe-ui's FrappeProvider builds this config from its own context), and
|
|
69
|
+
* `useDocTypeMeta` reads doctype metadata from it before reaching for the
|
|
70
|
+
* network.
|
|
71
|
+
*/
|
|
72
|
+
fixtures?: FixtureProvider;
|
|
65
73
|
}
|
|
66
74
|
|
|
67
75
|
function createDbModule(config: FrappeConnectionConfig): DbModule {
|
package/src/realtime.spec.ts
CHANGED
|
@@ -783,6 +783,68 @@ describe("RealtimeModule", () => {
|
|
|
783
783
|
rt.disconnect();
|
|
784
784
|
});
|
|
785
785
|
|
|
786
|
+
it("uses an explicit siteName as the Socket.IO namespace (127.0.0.1 → localhost)", async () => {
|
|
787
|
+
// Frappe socketio maps Host localhost/127.0.0.1 to conf.default_site
|
|
788
|
+
// and rejects any other namespace. Connecting via the IPv4 literal
|
|
789
|
+
// (the serve_dev.py workaround for shcd on [::1]:8000) must still
|
|
790
|
+
// join /localhost, not /127.0.0.1.
|
|
791
|
+
const rt = new RealtimeModule({
|
|
792
|
+
baseURL: "http://127.0.0.1:8000",
|
|
793
|
+
socketPort: 9000,
|
|
794
|
+
siteName: "localhost",
|
|
795
|
+
});
|
|
796
|
+
await (rt as any).connect();
|
|
797
|
+
|
|
798
|
+
const calls = (io as unknown as ReturnType<typeof vi.fn>).mock.calls;
|
|
799
|
+
expect(calls[calls.length - 1]?.[0]).toBe("http://127.0.0.1:9000/localhost");
|
|
800
|
+
rt.disconnect();
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it("dials socketBaseURL when set, joining the siteName namespace (same-origin dev)", async () => {
|
|
804
|
+
// Web dev: the dev server proxies /socket.io on the PAGE origin (e.g.
|
|
805
|
+
// :3998) to the bench socketio — the client must dial that origin, not
|
|
806
|
+
// baseURL+socketPort, so the polling handshake is same-origin.
|
|
807
|
+
const rt = new RealtimeModule({
|
|
808
|
+
baseURL: "http://127.0.0.1:8000",
|
|
809
|
+
socketBaseURL: "http://127.0.0.1:3998",
|
|
810
|
+
siteName: "localhost",
|
|
811
|
+
});
|
|
812
|
+
await (rt as any).connect();
|
|
813
|
+
|
|
814
|
+
const calls = (io as unknown as ReturnType<typeof vi.fn>).mock.calls;
|
|
815
|
+
expect(calls[calls.length - 1]?.[0]).toBe("http://127.0.0.1:3998/localhost");
|
|
816
|
+
rt.disconnect();
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
it("socketBaseURL wins over socketPort and trims a trailing slash", async () => {
|
|
820
|
+
const rt = new RealtimeModule({
|
|
821
|
+
baseURL: "http://127.0.0.1:8000",
|
|
822
|
+
socketPort: 9000,
|
|
823
|
+
socketBaseURL: "http://localhost:3998/",
|
|
824
|
+
siteName: "localhost",
|
|
825
|
+
});
|
|
826
|
+
await (rt as any).connect();
|
|
827
|
+
|
|
828
|
+
const calls = (io as unknown as ReturnType<typeof vi.fn>).mock.calls;
|
|
829
|
+
expect(calls[calls.length - 1]?.[0]).toBe("http://localhost:3998/localhost");
|
|
830
|
+
rt.disconnect();
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it("falls back to the baseURL hostname namespace under socketBaseURL without siteName", async () => {
|
|
834
|
+
// Same fallback as the derived path: the namespace comes from the
|
|
835
|
+
// BENCH URL's hostname — never from socketBaseURL, whose hostname is
|
|
836
|
+
// the dev server, not a Frappe site.
|
|
837
|
+
const rt = new RealtimeModule({
|
|
838
|
+
baseURL: "http://bench.example.com:8000",
|
|
839
|
+
socketBaseURL: "http://localhost:3998",
|
|
840
|
+
});
|
|
841
|
+
await (rt as any).connect();
|
|
842
|
+
|
|
843
|
+
const calls = (io as unknown as ReturnType<typeof vi.fn>).mock.calls;
|
|
844
|
+
expect(calls[calls.length - 1]?.[0]).toBe("http://localhost:3998/bench.example.com");
|
|
845
|
+
rt.disconnect();
|
|
846
|
+
});
|
|
847
|
+
|
|
786
848
|
it("uses the token scheme when auth type is token", async () => {
|
|
787
849
|
const rt = new RealtimeModule({
|
|
788
850
|
baseURL: "http://localhost:3000",
|
package/src/realtime.ts
CHANGED
|
@@ -120,6 +120,23 @@ export interface RealtimeConfig {
|
|
|
120
120
|
* `/socket.io` to the SocketIO backend.
|
|
121
121
|
*/
|
|
122
122
|
socketPort?: number;
|
|
123
|
+
/**
|
|
124
|
+
* Dial the Socket.IO connection at this origin instead of deriving host and
|
|
125
|
+
* port from `baseURL` + `socketPort`.
|
|
126
|
+
*
|
|
127
|
+
* Built for dev servers that proxy `/socket.io` on their own origin (e.g.
|
|
128
|
+
* apps/one's `frappeSocketioDevProxy`): pointing the socket at the PAGE
|
|
129
|
+
* origin makes the handshake same-origin, so the browser stops blocking the
|
|
130
|
+
* cross-origin polling response (the reflected allow-origin is the proxy's
|
|
131
|
+
* injected bench origin, not the page's). Only the connection target
|
|
132
|
+
* changes — the namespace still comes from `siteName` (falling back to the
|
|
133
|
+
* `baseURL` hostname), because Frappe resolves the site server-side from
|
|
134
|
+
* the proxy-injected headers, not from where the client dialed.
|
|
135
|
+
*
|
|
136
|
+
* Wins over `socketPort` when both are set. Omit in production, where the
|
|
137
|
+
* reverse proxy already serves `/socket.io` on the `baseURL` origin.
|
|
138
|
+
*/
|
|
139
|
+
socketBaseURL?: string;
|
|
123
140
|
/**
|
|
124
141
|
* Frappe site name, used as the Socket.IO namespace.
|
|
125
142
|
*
|
|
@@ -223,6 +240,23 @@ export class RealtimeModule {
|
|
|
223
240
|
|
|
224
241
|
private getSocketURL(): string {
|
|
225
242
|
const base = this.config.baseURL.replace(/\/$/, "");
|
|
243
|
+
// Same-origin override: dial socketBaseURL directly (e.g. the page
|
|
244
|
+
// origin, whose dev server proxies /socket.io to the bench's socketio).
|
|
245
|
+
// The namespace is still siteName — falling back to the baseURL hostname
|
|
246
|
+
// exactly like the derived path below, NOT the socketBaseURL hostname,
|
|
247
|
+
// which would be the dev-server host and never a Frappe site.
|
|
248
|
+
if (this.config.socketBaseURL) {
|
|
249
|
+
const socketBase = this.config.socketBaseURL.replace(/\/$/, "");
|
|
250
|
+
let siteName = this.config.siteName;
|
|
251
|
+
if (!siteName) {
|
|
252
|
+
try {
|
|
253
|
+
siteName = new URL(base).hostname;
|
|
254
|
+
} catch {
|
|
255
|
+
siteName = base.replace(/^https?:\/\//, "").split(/[/:]/)[0];
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return `${socketBase}/${siteName}`;
|
|
259
|
+
}
|
|
226
260
|
try {
|
|
227
261
|
const url = new URL(base);
|
|
228
262
|
// Assemble origin from parts instead of mutating url.port and reading
|