@multiplatform.one/core 7.10.0 → 7.15.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 +25 -0
- package/package.json +16 -16
- package/src/app/CreateApp.frappe.spec.tsx +109 -0
- package/src/app/CreateApp.origin.spec.tsx +182 -0
- package/src/app/CreateApp.tsx +67 -16
- package/src/app/CreateRootLayout.hydration.spec.tsx +97 -0
- package/src/app/CreateRootLayout.schemeClass.spec.tsx +165 -0
- package/src/app/CreateRootLayout.web.spec.tsx +12 -1
- package/src/app/CreateRootLayout.web.tsx +7 -10
- package/src/app/TanstackProvider.devtools.spec.tsx +77 -0
- package/src/app/TanstackProvider.tsx +3 -2
- package/src/app/deepLinkPath.spec.ts +23 -0
- package/src/app/deepLinkPath.ts +16 -3
- package/src/app/frappeSocketPort.spec.ts +65 -0
- package/src/app/frappeSocketPort.ts +28 -0
- package/src/app/frappeSyncOptions.ts +20 -0
- package/src/app/index.ts +1 -0
- package/src/app/oneFrappeNavigation.ts +11 -0
- package/src/app/rootLayoutShared.ts +2 -0
- package/src/app/runtimePublicConfigMiddleware.spec.ts +111 -0
- package/src/app/runtimePublicConfigMiddleware.ts +50 -0
- package/types/app/CreateApp.d.ts +37 -10
- package/types/app/CreateApp.d.ts.map +1 -1
- package/types/app/CreateRootLayout.web.d.ts.map +1 -1
- package/types/app/TanstackProvider.d.ts.map +1 -1
- package/types/app/deepLinkPath.d.ts +4 -0
- package/types/app/deepLinkPath.d.ts.map +1 -1
- package/types/app/frappeSocketPort.d.ts +12 -0
- package/types/app/frappeSocketPort.d.ts.map +1 -0
- package/types/app/frappeSyncOptions.d.ts +13 -0
- package/types/app/frappeSyncOptions.d.ts.map +1 -0
- package/types/app/index.d.ts +1 -0
- package/types/app/index.d.ts.map +1 -1
- package/types/app/oneFrappeNavigation.d.ts +4 -0
- package/types/app/oneFrappeNavigation.d.ts.map +1 -0
- package/types/app/rootLayoutShared.d.ts +2 -0
- package/types/app/rootLayoutShared.d.ts.map +1 -1
- package/types/app/runtimePublicConfigMiddleware.d.ts +24 -0
- package/types/app/runtimePublicConfigMiddleware.d.ts.map +1 -0
package/README.md
CHANGED
|
@@ -52,6 +52,31 @@ Pass `true` to a provider slot to read all settings from env config, or an
|
|
|
52
52
|
object (`TanstackConfig`, `KeycloakConfig`, `FrappeConfig`, `Web3AppConfig`)
|
|
53
53
|
to configure explicitly. Disabled slots add zero bundle weight.
|
|
54
54
|
|
|
55
|
+
### Frappe catch-up and the `live` app
|
|
56
|
+
|
|
57
|
+
After a reconnect, focus or `online`, the Frappe provider catches up on what it
|
|
58
|
+
missed. The default, CDC, calls `live.live.api.backfill`, which exists only
|
|
59
|
+
when the bench carries the `live` Frappe app (`apps/frappe/apps/live`). A bench
|
|
60
|
+
without it answers 417 ("App live is not installed") on every catch-up.
|
|
61
|
+
|
|
62
|
+
Such a deployment sets `FRAPPE_CATCH_UP_STRATEGY=snapshot` in the web
|
|
63
|
+
container's environment. Catch-up then re-reads each mounted collection through
|
|
64
|
+
`/api/resource` and makes no `live.*` request, for signed-in users and visitors
|
|
65
|
+
alike. The key is public config, so the SSR document publishes it per request:
|
|
66
|
+
one image serves a bench with `live` and one without, no rebuild. Unset, or
|
|
67
|
+
`cdc`, keeps CDC. Writes through the SDK (`createDoc`, `updateDoc`,
|
|
68
|
+
`deleteDoc`) still call `live`, so this makes a bench without it work for
|
|
69
|
+
reads, not writes. Snapshot mode also refuses progressive subscriptions, so a
|
|
70
|
+
screen built on `useFrappeInfiniteList` fails under it.
|
|
71
|
+
|
|
72
|
+
An app can fix the choice in code instead, through `frappe.syncOptions`, which
|
|
73
|
+
`createApp` hands to `FrappeProvider` as is. A `catchUpStrategy` set there wins
|
|
74
|
+
over the env.
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
createApp({ frappe: { syncOptions: { catchUpStrategy: "snapshot" } } });
|
|
78
|
+
```
|
|
79
|
+
|
|
55
80
|
## License
|
|
56
81
|
|
|
57
82
|
Apache-2.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplatform.one/core",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.15.0",
|
|
4
4
|
"description": "Core framework shell, provider composition, and layout factories for multiplatform.one",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"app",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"author": "BitSpur",
|
|
12
12
|
"repository": {
|
|
13
13
|
"type": "git",
|
|
14
|
-
"url": "git+https://
|
|
14
|
+
"url": "git+https://git.corp.bitspur.com/multiplatform.one/mpo.git",
|
|
15
15
|
"directory": "public/core"
|
|
16
16
|
},
|
|
17
17
|
"source": "src/index.ts",
|
|
@@ -41,19 +41,21 @@
|
|
|
41
41
|
"access": "public"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
+
"@multiplatform.one/i18n": "7.15.0",
|
|
45
|
+
"@multiplatform.one/logger": "7.15.0",
|
|
46
|
+
"@multiplatform.one/platform": "7.15.0",
|
|
47
|
+
"@multiplatform.one/store": "7.15.0",
|
|
48
|
+
"@multiplatform.one/theme": "7.15.0",
|
|
44
49
|
"@tanstack/devtools-event-client": "^0.4.3",
|
|
45
50
|
"@tanstack/devtools-utils": "^0.4.0",
|
|
46
51
|
"@tanstack/pacer-devtools": "1.3.0",
|
|
47
52
|
"@tanstack/react-devtools": "^0.10.2",
|
|
48
53
|
"@tanstack/react-form-devtools": "0.2.22",
|
|
49
|
-
"react-cookie": "^8.1.2"
|
|
50
|
-
"@multiplatform.one/i18n": "7.10.0",
|
|
51
|
-
"@multiplatform.one/theme": "7.10.0",
|
|
52
|
-
"@multiplatform.one/store": "7.10.0",
|
|
53
|
-
"@multiplatform.one/platform": "7.10.0",
|
|
54
|
-
"@multiplatform.one/logger": "7.10.0"
|
|
54
|
+
"react-cookie": "^8.1.2"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
|
+
"@multiplatform.one/frappe": "7.15.0",
|
|
58
|
+
"@multiplatform.one/web3": "7.15.0",
|
|
57
59
|
"@sentry/react": "^10.51.0",
|
|
58
60
|
"@tanstack/react-query": "^5.100.7",
|
|
59
61
|
"@tanstack/react-query-devtools": "^5.100.7",
|
|
@@ -67,11 +69,13 @@
|
|
|
67
69
|
"react-native-safe-area-context": "~5.7.0",
|
|
68
70
|
"tamagui": "2.7.6",
|
|
69
71
|
"typescript": "~5.9.3",
|
|
70
|
-
"vitest": "^4.1.5"
|
|
71
|
-
"@multiplatform.one/frappe": "7.10.0",
|
|
72
|
-
"@multiplatform.one/web3": "7.10.0"
|
|
72
|
+
"vitest": "^4.1.5"
|
|
73
73
|
},
|
|
74
74
|
"peerDependencies": {
|
|
75
|
+
"@multiplatform.one/frappe": "^7.15.0",
|
|
76
|
+
"@multiplatform.one/frappe-ui": "^7.15.0",
|
|
77
|
+
"@multiplatform.one/keycloak": "^7.15.0",
|
|
78
|
+
"@multiplatform.one/web3": "^7.15.0",
|
|
75
79
|
"@sentry/react": "^9.0.0",
|
|
76
80
|
"@tanstack/react-query": "^5.0.0",
|
|
77
81
|
"@tanstack/react-query-devtools": "^5.0.0",
|
|
@@ -84,11 +88,7 @@
|
|
|
84
88
|
"react-native": ">=0.83.0",
|
|
85
89
|
"react-native-gesture-handler": ">=2.0.0",
|
|
86
90
|
"react-native-safe-area-context": ">=4.0.0",
|
|
87
|
-
"tamagui": "^2.0.0-rc"
|
|
88
|
-
"@multiplatform.one/frappe": "^7.10.0",
|
|
89
|
-
"@multiplatform.one/frappe-ui": "^7.10.0",
|
|
90
|
-
"@multiplatform.one/web3": "^7.10.0",
|
|
91
|
-
"@multiplatform.one/keycloak": "^7.10.0"
|
|
91
|
+
"tamagui": "^2.0.0-rc"
|
|
92
92
|
},
|
|
93
93
|
"peerDependenciesMeta": {
|
|
94
94
|
"@multiplatform.one/web3": {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import type { SyncOptions } from "@multiplatform.one/frappe";
|
|
5
|
+
import type { FrappeNavigation } from "@multiplatform.one/frappe-ui";
|
|
6
|
+
import { createApp, type FrappeConfig } from "./CreateApp";
|
|
7
|
+
|
|
8
|
+
const { env, provided, router } = vi.hoisted(() => ({
|
|
9
|
+
env: {} as Record<string, string | undefined>,
|
|
10
|
+
provided: { navigation: undefined as FrappeNavigation | undefined },
|
|
11
|
+
router: { pushed: [] as string[], pathname: "/moves/a1b2c3" },
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("@multiplatform.one/platform", () => ({
|
|
15
|
+
config: { get: (key: string) => env[key] },
|
|
16
|
+
isDev: false,
|
|
17
|
+
isWeb: true,
|
|
18
|
+
}));
|
|
19
|
+
vi.mock("one", () => ({
|
|
20
|
+
usePathname: () => router.pathname,
|
|
21
|
+
useRouter: () => ({ push: (href: string) => router.pushed.push(href) }),
|
|
22
|
+
}));
|
|
23
|
+
vi.mock("@multiplatform.one/theme", () => ({
|
|
24
|
+
ThemeProvider: ({ children }: { children: ReactNode }) => children,
|
|
25
|
+
}));
|
|
26
|
+
vi.mock("@multiplatform.one/frappe-ui", () => ({
|
|
27
|
+
FrappeProvider: ({
|
|
28
|
+
children,
|
|
29
|
+
syncOptions,
|
|
30
|
+
navigation,
|
|
31
|
+
}: {
|
|
32
|
+
children: ReactNode;
|
|
33
|
+
syncOptions?: SyncOptions;
|
|
34
|
+
navigation?: FrappeNavigation;
|
|
35
|
+
}) => {
|
|
36
|
+
provided.navigation = navigation;
|
|
37
|
+
return (
|
|
38
|
+
<>
|
|
39
|
+
<output>{JSON.stringify(syncOptions ?? null)}</output>
|
|
40
|
+
{children}
|
|
41
|
+
</>
|
|
42
|
+
);
|
|
43
|
+
},
|
|
44
|
+
}));
|
|
45
|
+
vi.mock("./TanstackProvider", () => ({ createTanstackProvider: vi.fn() }));
|
|
46
|
+
vi.mock("./loadWalletProvider", () => ({ default: () => null }));
|
|
47
|
+
|
|
48
|
+
function renderedSyncOptions(frappe: FrappeConfig = {}) {
|
|
49
|
+
const { AppProvider } = createApp({
|
|
50
|
+
frappe: { enabled: true, baseURL: "https://bench.test", ...frappe },
|
|
51
|
+
});
|
|
52
|
+
const html = renderToStaticMarkup(<AppProvider />);
|
|
53
|
+
const json = html.match(/<output>(.*)<\/output>/)?.[1].replace(/"/g, '"');
|
|
54
|
+
return json === undefined ? undefined : JSON.parse(json);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
for (const key of Object.keys(env)) delete env[key];
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("createApp frappe sync options (MPO-324)", () => {
|
|
62
|
+
it("hands the provider snapshot catch-up when FRAPPE_CATCH_UP_STRATEGY=snapshot", () => {
|
|
63
|
+
env.FRAPPE_CATCH_UP_STRATEGY = "snapshot";
|
|
64
|
+
expect(renderedSyncOptions()).toEqual({ catchUpStrategy: "snapshot" });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("reads the strategy when the provider renders, not when the app is created", () => {
|
|
68
|
+
const { AppProvider } = createApp({ frappe: { enabled: true, baseURL: "https://bench.test" } });
|
|
69
|
+
env.FRAPPE_CATCH_UP_STRATEGY = "snapshot";
|
|
70
|
+
expect(renderToStaticMarkup(<AppProvider />)).toContain(
|
|
71
|
+
"{"catchUpStrategy":"snapshot"}",
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("leaves CDC as the default when the env is unset or names no strategy", () => {
|
|
76
|
+
expect(renderedSyncOptions()).toBeNull();
|
|
77
|
+
env.FRAPPE_CATCH_UP_STRATEGY = "live";
|
|
78
|
+
expect(renderedSyncOptions()).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("passes the app's own sync options through and adds the env strategy to them", () => {
|
|
82
|
+
expect(renderedSyncOptions({ syncOptions: { cdcInterval: 30000 } })).toEqual({
|
|
83
|
+
cdcInterval: 30000,
|
|
84
|
+
});
|
|
85
|
+
env.FRAPPE_CATCH_UP_STRATEGY = "snapshot";
|
|
86
|
+
expect(renderedSyncOptions({ syncOptions: { cdcInterval: 30000 } })).toEqual({
|
|
87
|
+
cdcInterval: 30000,
|
|
88
|
+
catchUpStrategy: "snapshot",
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("lets a catchUpStrategy the app sets win over the env", () => {
|
|
93
|
+
env.FRAPPE_CATCH_UP_STRATEGY = "snapshot";
|
|
94
|
+
expect(renderedSyncOptions({ syncOptions: { catchUpStrategy: "cdc" } })).toEqual({
|
|
95
|
+
catchUpStrategy: "cdc",
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("createApp frappe navigation (MPO-333)", () => {
|
|
101
|
+
it("hands the provider One's router for routed FrappeListView and FrappeForm", () => {
|
|
102
|
+
renderedSyncOptions();
|
|
103
|
+
const navigation = provided.navigation;
|
|
104
|
+
expect(navigation).toBeDefined();
|
|
105
|
+
expect(navigation?.usePathname()).toBe("/moves/a1b2c3");
|
|
106
|
+
navigation?.useNavigate()("/moves/a1b2c3/edit");
|
|
107
|
+
expect(router.pushed).toEqual(["/moves/a1b2c3/edit"]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where createApp's FrappeProvider dials, per page kind (MPO-328).
|
|
3
|
+
*
|
|
4
|
+
* One renders SPA-mode pages (`/`, `/backoffice/*`) to static HTML at build
|
|
5
|
+
* time, so their runtime config payload holds the build's `.env` values, not
|
|
6
|
+
* the container's BASE_URL. Live on https://pokemon.bitspur.com that sent the
|
|
7
|
+
* backoffice Desk to http://pokemon.bitspur.com:8000 and realtime to :9000,
|
|
8
|
+
* while the server-rendered /pokemon, which carries the runtime BASE_URL,
|
|
9
|
+
* reached its own origin.
|
|
10
|
+
*/
|
|
11
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
12
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
13
|
+
import type { ReactNode } from "react";
|
|
14
|
+
import { createApp, followPageOriginForLoopback } from "./CreateApp";
|
|
15
|
+
|
|
16
|
+
const { env, mode } = vi.hoisted(() => ({
|
|
17
|
+
env: {} as Record<string, string | undefined>,
|
|
18
|
+
mode: { dev: false },
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock("@multiplatform.one/platform", () => ({
|
|
22
|
+
config: { get: (key: string) => env[key] },
|
|
23
|
+
get isDev() {
|
|
24
|
+
return mode.dev;
|
|
25
|
+
},
|
|
26
|
+
isWeb: true,
|
|
27
|
+
}));
|
|
28
|
+
vi.mock("one", () => ({ usePathname: () => "/", useRouter: () => ({ push: () => undefined }) }));
|
|
29
|
+
vi.mock("@multiplatform.one/theme", () => ({
|
|
30
|
+
ThemeProvider: ({ children }: { children: ReactNode }) => children,
|
|
31
|
+
}));
|
|
32
|
+
vi.mock("@multiplatform.one/frappe-ui", () => ({
|
|
33
|
+
FrappeProvider: ({
|
|
34
|
+
children,
|
|
35
|
+
baseURL,
|
|
36
|
+
socketPort,
|
|
37
|
+
}: {
|
|
38
|
+
children: ReactNode;
|
|
39
|
+
baseURL?: string;
|
|
40
|
+
socketPort?: number;
|
|
41
|
+
}) => (
|
|
42
|
+
<>
|
|
43
|
+
<output>{JSON.stringify({ baseURL, socketPort: socketPort ?? null })}</output>
|
|
44
|
+
{children}
|
|
45
|
+
</>
|
|
46
|
+
),
|
|
47
|
+
}));
|
|
48
|
+
vi.mock("./TanstackProvider", () => ({ createTanstackProvider: vi.fn() }));
|
|
49
|
+
vi.mock("./loadWalletProvider", () => ({ default: () => null }));
|
|
50
|
+
|
|
51
|
+
function openPage(href: string) {
|
|
52
|
+
vi.stubGlobal("window", { location: new URL(href) });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function connection(): { baseURL?: string; socketPort: number | null } {
|
|
56
|
+
const { AppProvider } = createApp({ frappe: { enabled: true } });
|
|
57
|
+
const html = renderToStaticMarkup(<AppProvider />);
|
|
58
|
+
const json = html.match(/<output>(.*)<\/output>/)?.[1].replace(/"/g, '"');
|
|
59
|
+
if (json === undefined) throw new Error("FrappeProvider did not render");
|
|
60
|
+
return JSON.parse(json);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
for (const key of Object.keys(env)) delete env[key];
|
|
65
|
+
mode.dev = false;
|
|
66
|
+
vi.unstubAllGlobals();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("createApp frappe connection by page kind (MPO-328)", () => {
|
|
70
|
+
it("an SPA shell with no runtime BASE_URL on an https origin dials its own origin", () => {
|
|
71
|
+
env.FRAPPE_SOCKETIO_PORT = "9000";
|
|
72
|
+
openPage("https://pokemon.bitspur.com/backoffice/pokemon");
|
|
73
|
+
expect(connection()).toEqual({ baseURL: "https://pokemon.bitspur.com", socketPort: null });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("an SPA shell carrying the build's loopback BASE_URL dials its own origin too", () => {
|
|
77
|
+
env.BASE_URL = "http://localhost:8000";
|
|
78
|
+
env.FRAPPE_SOCKETIO_PORT = "9000";
|
|
79
|
+
openPage("https://pokemon.bitspur.com/backoffice/pokemon/pikachu");
|
|
80
|
+
expect(connection()).toEqual({ baseURL: "https://pokemon.bitspur.com", socketPort: null });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("an SSR page keeps the runtime BASE_URL in the browser", () => {
|
|
84
|
+
env.BASE_URL = "https://pokemon.bitspur.com";
|
|
85
|
+
env.FRAPPE_SOCKETIO_PORT = "9000";
|
|
86
|
+
openPage("https://pokemon.bitspur.com/pokemon");
|
|
87
|
+
expect(connection()).toEqual({ baseURL: "https://pokemon.bitspur.com", socketPort: null });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("an SSR page renders the runtime BASE_URL on the server", () => {
|
|
91
|
+
env.BASE_URL = "https://pokemon.bitspur.com";
|
|
92
|
+
env.FRAPPE_SOCKETIO_PORT = "9000";
|
|
93
|
+
expect(connection()).toEqual({ baseURL: "https://pokemon.bitspur.com", socketPort: 9000 });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("dev on localhost:3456 dials the bench behind :8000 with the socketio port", () => {
|
|
97
|
+
mode.dev = true;
|
|
98
|
+
env.BASE_URL = "http://localhost:8000";
|
|
99
|
+
env.FRAPPE_SOCKETIO_PORT = "9000";
|
|
100
|
+
openPage("http://localhost:3456/backoffice/pokemon");
|
|
101
|
+
expect(connection()).toEqual({ baseURL: "http://localhost:8000", socketPort: 9000 });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("dev without BASE_URL falls back to the :8000 bench", () => {
|
|
105
|
+
mode.dev = true;
|
|
106
|
+
env.FRAPPE_SOCKETIO_PORT = "9000";
|
|
107
|
+
openPage("http://localhost:3456/");
|
|
108
|
+
expect(connection()).toEqual({ baseURL: "http://localhost:8000", socketPort: 9000 });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("dev over the LAN keeps the :8000 bench on the page host", () => {
|
|
112
|
+
mode.dev = true;
|
|
113
|
+
env.BASE_URL = "http://localhost:8000";
|
|
114
|
+
env.FRAPPE_SOCKETIO_PORT = "9000";
|
|
115
|
+
openPage("http://10.236.0.10:3456/backoffice/pokemon");
|
|
116
|
+
expect(connection()).toEqual({ baseURL: "http://10.236.0.10:8000", socketPort: 9000 });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("native has no page location, so the resolved dev origin passes through", () => {
|
|
120
|
+
env.BASE_URL = "http://192.168.1.20:8000";
|
|
121
|
+
env.FRAPPE_SOCKETIO_PORT = "9000";
|
|
122
|
+
vi.stubGlobal("window", {});
|
|
123
|
+
expect(connection()).toEqual({ baseURL: "http://192.168.1.20:8000", socketPort: 9000 });
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("followPageOriginForLoopback", () => {
|
|
128
|
+
const page = (href: string) => new URL(href);
|
|
129
|
+
|
|
130
|
+
it("takes scheme, host and port from a production page", () => {
|
|
131
|
+
expect(
|
|
132
|
+
followPageOriginForLoopback("http://localhost:8000", page("https://app.example"), false),
|
|
133
|
+
).toBe("https://app.example");
|
|
134
|
+
expect(
|
|
135
|
+
followPageOriginForLoopback("http://127.0.0.1:8000", page("http://10.0.0.5:3000/x"), false),
|
|
136
|
+
).toBe("http://10.0.0.5:3000");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("keeps a configured path", () => {
|
|
140
|
+
expect(
|
|
141
|
+
followPageOriginForLoopback(
|
|
142
|
+
"http://localhost:8000/bench",
|
|
143
|
+
page("https://app.example"),
|
|
144
|
+
false,
|
|
145
|
+
),
|
|
146
|
+
).toBe("https://app.example/bench");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("swaps only the host in dev", () => {
|
|
150
|
+
expect(
|
|
151
|
+
followPageOriginForLoopback("http://localhost:8000", page("http://10.0.0.5:3456"), true),
|
|
152
|
+
).toBe("http://10.0.0.5:8000");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("leaves a real bench host alone", () => {
|
|
156
|
+
expect(
|
|
157
|
+
followPageOriginForLoopback("https://bench.example", page("https://app.example"), false),
|
|
158
|
+
).toBe("https://bench.example");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("leaves a loopback page alone", () => {
|
|
162
|
+
expect(
|
|
163
|
+
followPageOriginForLoopback("http://localhost:8000", page("http://127.0.0.1:3456"), false),
|
|
164
|
+
).toBe("http://localhost:8000");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("keeps the dev rewrite on non-http pages", () => {
|
|
168
|
+
expect(
|
|
169
|
+
followPageOriginForLoopback(
|
|
170
|
+
"http://localhost:8000",
|
|
171
|
+
{ protocol: "tauri:", hostname: "tauri.localhost", port: "" },
|
|
172
|
+
false,
|
|
173
|
+
),
|
|
174
|
+
).toBe("http://tauri.localhost:8000");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("passes through without a page", () => {
|
|
178
|
+
expect(followPageOriginForLoopback("http://localhost:8000", undefined, false)).toBe(
|
|
179
|
+
"http://localhost:8000",
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
});
|
package/src/app/CreateApp.tsx
CHANGED
|
@@ -3,15 +3,19 @@ import React, {
|
|
|
3
3
|
createContext,
|
|
4
4
|
lazy,
|
|
5
5
|
useContext,
|
|
6
|
+
useMemo,
|
|
6
7
|
type ComponentType,
|
|
7
8
|
type PropsWithChildren,
|
|
8
9
|
type ReactNode,
|
|
9
10
|
} from "react";
|
|
10
|
-
import { config, isWeb } from "@multiplatform.one/platform";
|
|
11
|
+
import { config, isDev, isWeb } from "@multiplatform.one/platform";
|
|
11
12
|
import type { ThemeConfig } from "@multiplatform.one/theme";
|
|
12
13
|
import { ThemeProvider } from "@multiplatform.one/theme";
|
|
13
14
|
import { FrappeProvider } from "@multiplatform.one/frappe-ui";
|
|
14
|
-
import type { FixtureProvider } from "@multiplatform.one/frappe";
|
|
15
|
+
import type { FixtureProvider, SyncOptions } from "@multiplatform.one/frappe";
|
|
16
|
+
import { frappeSocketPort } from "./frappeSocketPort";
|
|
17
|
+
import { frappeSyncOptions } from "./frappeSyncOptions";
|
|
18
|
+
import { oneFrappeNavigation } from "./oneFrappeNavigation";
|
|
15
19
|
import { createTanstackProvider } from "./TanstackProvider";
|
|
16
20
|
// Platform-split: native -> bridge-only web3 via the `/native` subpath (no connectkit
|
|
17
21
|
// in Hermes); web -> passthrough (web mounts the provider at screen scope). Importing a
|
|
@@ -23,23 +27,53 @@ export type ProviderComponent = ComponentType<PropsWithChildren>;
|
|
|
23
27
|
|
|
24
28
|
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
25
29
|
|
|
30
|
+
/** The parts of `window.location` the loopback rewrite reads. */
|
|
31
|
+
export interface PageLocation {
|
|
32
|
+
protocol: string;
|
|
33
|
+
hostname: string;
|
|
34
|
+
port: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function currentPageLocation(): PageLocation | undefined {
|
|
38
|
+
if (typeof window === "undefined" || !window.location?.hostname) return undefined;
|
|
39
|
+
return window.location;
|
|
40
|
+
}
|
|
41
|
+
|
|
26
42
|
/**
|
|
27
43
|
* Follow the page origin when a loopback-configured backend is opened from a
|
|
28
|
-
* non-loopback host.
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* the
|
|
34
|
-
*
|
|
44
|
+
* non-loopback host.
|
|
45
|
+
*
|
|
46
|
+
* Dev: configs point at `http://localhost:8000`, which is correct on the dev
|
|
47
|
+
* machine but breaks the moment the app is opened via a LAN IP
|
|
48
|
+
* (`http://10.x.x.x:3456` → the API and the Socket.IO handshake would target
|
|
49
|
+
* the *visitor's* machine, and Chromium's Local Network Access policy flags
|
|
50
|
+
* the cross-origin loopback fetch). Rewriting only the hostname keeps the
|
|
51
|
+
* configured scheme and port, so the dev bench beside the dev server answers.
|
|
52
|
+
*
|
|
53
|
+
* Production: One renders SPA-mode pages (`/`, `/backoffice/*`) to static
|
|
54
|
+
* HTML at build time, so their config payload carries the build's `.env`
|
|
55
|
+
* BASE_URL, not the container's. A deployment serves the bench through its
|
|
56
|
+
* own origin (MPO-322), so scheme, host and port all follow the page, and
|
|
57
|
+
* `frappeSocketPort` then drops the baked socketio port (MPO-328).
|
|
58
|
+
*
|
|
59
|
+
* Only ever triggers in a browser whose page host is itself not loopback, so
|
|
60
|
+
* SSR, native and same-machine dev are untouched. Non-http(s) pages (Tauri,
|
|
61
|
+
* extensions) keep the dev rewrite.
|
|
35
62
|
*/
|
|
36
|
-
export function followPageOriginForLoopback(
|
|
37
|
-
|
|
63
|
+
export function followPageOriginForLoopback(
|
|
64
|
+
configured: string,
|
|
65
|
+
page: PageLocation | undefined = currentPageLocation(),
|
|
66
|
+
dev: boolean = isDev,
|
|
67
|
+
): string {
|
|
68
|
+
if (!page?.hostname) return configured;
|
|
38
69
|
try {
|
|
39
70
|
const url = new URL(configured);
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
71
|
+
if (!LOOPBACK_HOSTS.has(url.hostname) || LOOPBACK_HOSTS.has(page.hostname)) return configured;
|
|
72
|
+
url.hostname = page.hostname;
|
|
73
|
+
if (!dev && (page.protocol === "https:" || page.protocol === "http:")) {
|
|
74
|
+
url.protocol = page.protocol;
|
|
75
|
+
url.port = page.port;
|
|
76
|
+
}
|
|
43
77
|
return url.toString().replace(/\/$/, "");
|
|
44
78
|
} catch {
|
|
45
79
|
return configured;
|
|
@@ -51,7 +85,11 @@ export function followPageOriginForLoopback(configured: string): string {
|
|
|
51
85
|
// ---------------------------------------------------------------------------
|
|
52
86
|
|
|
53
87
|
export interface TanstackConfig {
|
|
54
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Show React Query devtools (web only, ignored on native and in Storybook).
|
|
90
|
+
* Omitted, they show in a dev build or when DEBUG=1, and a production
|
|
91
|
+
* visitor never loads them.
|
|
92
|
+
*/
|
|
55
93
|
debug?: boolean;
|
|
56
94
|
/**
|
|
57
95
|
* Shake the device to open the devtools shell (web only; requires
|
|
@@ -136,6 +174,12 @@ export interface FrappeConfig {
|
|
|
136
174
|
token?: string | (() => string | Promise<string>);
|
|
137
175
|
type?: string;
|
|
138
176
|
};
|
|
177
|
+
/**
|
|
178
|
+
* Options for the live data engine. When `catchUpStrategy` is omitted,
|
|
179
|
+
* FRAPPE_CATCH_UP_STRATEGY (`cdc` or `snapshot`) is read at runtime; a
|
|
180
|
+
* bench without the `live` app needs `snapshot`, since CDC catch-up calls it.
|
|
181
|
+
*/
|
|
182
|
+
syncOptions?: SyncOptions;
|
|
139
183
|
/**
|
|
140
184
|
* Fixture corpus every Frappe read and write is served from — the whole
|
|
141
185
|
* app runs with no bench and no network. Setting it enables the provider
|
|
@@ -348,6 +392,11 @@ export function createApp(appConfig: CreateAppConfig) {
|
|
|
348
392
|
const cfg: FrappeConfig = appConfig.frappe === true ? {} : appConfig.frappe;
|
|
349
393
|
|
|
350
394
|
function FrappeProviderWrapper({ children }: PropsWithChildren) {
|
|
395
|
+
const configuredCatchUp = config.get("FRAPPE_CATCH_UP_STRATEGY");
|
|
396
|
+
const syncOptions = useMemo(
|
|
397
|
+
() => frappeSyncOptions(cfg.syncOptions, configuredCatchUp),
|
|
398
|
+
[configuredCatchUp],
|
|
399
|
+
);
|
|
351
400
|
// A fixture corpus is an explicit opt-in, so it enables the provider
|
|
352
401
|
// without FRAPPE_ENABLED=1 in .env.
|
|
353
402
|
const enabled = cfg.enabled ?? (!!cfg.fixtures || config.get("FRAPPE_ENABLED") === "1");
|
|
@@ -363,7 +412,7 @@ export function createApp(appConfig: CreateAppConfig) {
|
|
|
363
412
|
: cfg.baseURL || config.get("BASE_URL") || "http://localhost:8000";
|
|
364
413
|
const baseURL = configured ? followPageOriginForLoopback(configured) : undefined;
|
|
365
414
|
const socketPort =
|
|
366
|
-
cfg.socketPort ?? (
|
|
415
|
+
cfg.socketPort ?? frappeSocketPort(config.get("FRAPPE_SOCKETIO_PORT"), baseURL);
|
|
367
416
|
|
|
368
417
|
// Keycloak auth uses ONE transport on every platform: the HttpClient
|
|
369
418
|
// and the Socket.IO handshake send `Authorization: Bearer <keycloak
|
|
@@ -394,7 +443,9 @@ export function createApp(appConfig: CreateAppConfig) {
|
|
|
394
443
|
realtime={cfg.realtime}
|
|
395
444
|
socketPort={socketPort}
|
|
396
445
|
auth={auth}
|
|
446
|
+
syncOptions={syncOptions}
|
|
397
447
|
fixtures={cfg.fixtures}
|
|
448
|
+
navigation={oneFrappeNavigation}
|
|
398
449
|
>
|
|
399
450
|
{children}
|
|
400
451
|
</FrappeProvider>
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { renderToString } from "react-dom/server";
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { hydrateRoot } from "react-dom/client";
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { ReactNode } from "react";
|
|
7
|
+
import { createRootLayout } from "./CreateRootLayout.web";
|
|
8
|
+
|
|
9
|
+
vi.mock("one", () => ({ LoadProgressBar: () => null, Slot: () => null, useMatches: () => [] }));
|
|
10
|
+
vi.mock("tamagui", async () => ({
|
|
11
|
+
useDidFinishSSR: (await import("@tamagui/use-did-finish-ssr")).useDidFinishSSR,
|
|
12
|
+
}));
|
|
13
|
+
const scheme = vi.hoisted(() => ({ value: "light", stored: "dark", system: "dark" }));
|
|
14
|
+
vi.mock("@vxrn/color-scheme", () => ({
|
|
15
|
+
SchemeProvider: ({ children }: { children: ReactNode }) => children,
|
|
16
|
+
setUserScheme: (value: string) => {
|
|
17
|
+
scheme.value = value === "system" ? scheme.system : value;
|
|
18
|
+
},
|
|
19
|
+
useUserScheme: () => ({ value: scheme.value }),
|
|
20
|
+
}));
|
|
21
|
+
vi.mock("i18next", () => ({
|
|
22
|
+
default: {
|
|
23
|
+
use: () => ({ init: () => Promise.resolve() }),
|
|
24
|
+
changeLanguage: vi.fn(),
|
|
25
|
+
language: "en",
|
|
26
|
+
},
|
|
27
|
+
}));
|
|
28
|
+
vi.mock("react-i18next", () => ({ initReactI18next: {} }));
|
|
29
|
+
vi.mock("@multiplatform.one/platform", () => ({
|
|
30
|
+
config: { get: (_key: string, fallback?: string) => fallback },
|
|
31
|
+
isDev: false,
|
|
32
|
+
platform: { isServer: true },
|
|
33
|
+
}));
|
|
34
|
+
vi.mock("@multiplatform.one/i18n", () => ({ createI18nConfig: () => ({}) }));
|
|
35
|
+
vi.mock("@multiplatform.one/logger", () => ({ logger: { error: vi.fn() } }));
|
|
36
|
+
vi.mock("@multiplatform.one/store", () => ({ storage: { getItem: () => scheme.stored } }));
|
|
37
|
+
vi.mock("./RuntimePublicConfigScript", () => ({ RuntimePublicConfigScript: () => null }));
|
|
38
|
+
|
|
39
|
+
describe("web root scheme hydration", () => {
|
|
40
|
+
it.each(["dark", "system", "light"])(
|
|
41
|
+
"hydrates server markup before adopting %s preference",
|
|
42
|
+
async (stored) => {
|
|
43
|
+
scheme.stored = stored;
|
|
44
|
+
scheme.system = "dark";
|
|
45
|
+
const observed: string[] = [];
|
|
46
|
+
const Provider = ({
|
|
47
|
+
systemTheme,
|
|
48
|
+
children,
|
|
49
|
+
}: {
|
|
50
|
+
systemTheme: string;
|
|
51
|
+
children?: ReactNode;
|
|
52
|
+
}) => {
|
|
53
|
+
observed.push(systemTheme);
|
|
54
|
+
return (
|
|
55
|
+
<section
|
|
56
|
+
data-scheme={systemTheme}
|
|
57
|
+
style={{ color: systemTheme === "dark" ? "white" : "black" }}
|
|
58
|
+
>
|
|
59
|
+
{children}
|
|
60
|
+
</section>
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
const Layout = createRootLayout({
|
|
64
|
+
AppProvider: Provider,
|
|
65
|
+
RootLayout: ({ children }: { children?: ReactNode }) => children,
|
|
66
|
+
i18n: { defaultLanguage: "en" },
|
|
67
|
+
} as Parameters<typeof createRootLayout>[0]);
|
|
68
|
+
scheme.value = "light";
|
|
69
|
+
document.documentElement.lang = "en";
|
|
70
|
+
document.documentElement.innerHTML = renderToString(<Layout />).replace(
|
|
71
|
+
/^<html[^>]*>|<\/html>$/g,
|
|
72
|
+
"",
|
|
73
|
+
);
|
|
74
|
+
observed.length = 0;
|
|
75
|
+
scheme.value = stored === "system" ? "dark" : stored;
|
|
76
|
+
const errors: unknown[] = [];
|
|
77
|
+
const consoleError = vi.spyOn(console, "error").mockImplementation((...args) => {
|
|
78
|
+
errors.push(args);
|
|
79
|
+
});
|
|
80
|
+
let root: ReturnType<typeof hydrateRoot>;
|
|
81
|
+
await act(async () => {
|
|
82
|
+
root = hydrateRoot(document, <Layout />, {
|
|
83
|
+
onRecoverableError: (error) => errors.push(error),
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
expect(observed[0]).toBe("light");
|
|
87
|
+
expect(document.querySelector("[data-scheme]")?.getAttribute("data-scheme")).toBe(
|
|
88
|
+
stored === "system" ? "dark" : stored,
|
|
89
|
+
);
|
|
90
|
+
expect(
|
|
91
|
+
errors.filter((error) => /hydration|hydrated|didn't match/i.test(String(error))),
|
|
92
|
+
).toEqual([]);
|
|
93
|
+
await act(async () => root!.unmount());
|
|
94
|
+
consoleError.mockRestore();
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
});
|