@lowdefy/shell 0.0.0-experimental-20261019052246
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/LICENSE +201 -0
- package/README.md +308 -0
- package/app/(t0,t1,t2,t3,t4)/[...path].js +38 -0
- package/app/(t0,t1,t2,t3,t4)/_layout.js +48 -0
- package/app/(t0,t1,t2,t3,t4)/index.js +38 -0
- package/app/+native-intent.js +30 -0
- package/app/_layout.js +33 -0
- package/app.config.ts +168 -0
- package/applyShellPlugins.js +43 -0
- package/authCallbackIntentFilters.js +46 -0
- package/babel.config.js +25 -0
- package/checkShellConfig.js +145 -0
- package/checkShellPlugins.js +74 -0
- package/getPrivacyManifest.js +39 -0
- package/metro.config.js +73 -0
- package/native-fingerprint.json +12 -0
- package/package.json +130 -0
- package/profiles.js +274 -0
- package/readShellConfig.js +36 -0
- package/resolveShellPlugins.js +109 -0
- package/runtimeVersion.js +105 -0
- package/scripts/checkReleaseBundle.mjs +358 -0
- package/scripts/createDayjsLocales.mjs +67 -0
- package/scripts/createShellAssets.mjs +54 -0
- package/scripts/createShellVersion.mjs +83 -0
- package/scripts/createTypeManifest.mjs +205 -0
- package/shell.dev.json +11 -0
- package/shellVariant.js +111 -0
- package/src/ShellContext.js +32 -0
- package/src/actions/CopyToClipboard.js +29 -0
- package/src/actions/CopyToClipboard.test.js +33 -0
- package/src/actions/GeolocationCurrentPosition.js +55 -0
- package/src/actions/GeolocationCurrentPosition.test.js +66 -0
- package/src/actions/GetLocalStorage.js +36 -0
- package/src/actions/RemoveLocalStorage.js +31 -0
- package/src/actions/ScrollTo.js +66 -0
- package/src/actions/ScrollTo.test.js +83 -0
- package/src/actions/SetFocus.js +33 -0
- package/src/actions/SetFocus.test.js +49 -0
- package/src/actions/SetLocalStorage.js +34 -0
- package/src/actions/localStorage.test.js +68 -0
- package/src/actions/measureInScrollView.js +36 -0
- package/src/actions/nativeActions.js +41 -0
- package/src/auth/AuthButton.js +44 -0
- package/src/auth/AuthGate.js +57 -0
- package/src/auth/AuthLink.js +43 -0
- package/src/auth/LockScreen.js +67 -0
- package/src/auth/LockScreen.test.js +37 -0
- package/src/auth/SignInScreen.js +256 -0
- package/src/auth/SignInScreen.test.js +182 -0
- package/src/auth/createAppAuth.js +306 -0
- package/src/auth/createAppAuth.test.js +414 -0
- package/src/auth/createAuthApi.js +99 -0
- package/src/auth/createAuthApi.test.js +135 -0
- package/src/auth/createCoreAuth.js +96 -0
- package/src/auth/createCoreAuth.test.js +188 -0
- package/src/auth/createSessionStores.js +45 -0
- package/src/auth/createTokenStore.js +103 -0
- package/src/auth/createTokenStore.test.js +116 -0
- package/src/auth/getAuthSettings.js +24 -0
- package/src/auth/getAuthView.js +39 -0
- package/src/auth/getAuthView.test.js +52 -0
- package/src/auth/getSheetReturnUrl.js +35 -0
- package/src/auth/getTokenStoreOptions.js +46 -0
- package/src/auth/isBackgroundShell.js +26 -0
- package/src/auth/isSheetReturnLink.js +35 -0
- package/src/auth/parseQuery.js +39 -0
- package/src/auth/purgeAppData.js +45 -0
- package/src/auth/shouldLock.js +26 -0
- package/src/auth/signInWithSheet.js +97 -0
- package/src/auth/signInWithSheet.test.js +205 -0
- package/src/auth/styles.js +78 -0
- package/src/auth/unregisterPushToken.js +21 -0
- package/src/auth/usesBiometricUnlock.js +23 -0
- package/src/auth/watchSession.js +47 -0
- package/src/auth/withoutCookies.js +26 -0
- package/src/cache/createSqliteCacheStore.js +113 -0
- package/src/cache/createSqliteCacheStore.test.js +93 -0
- package/src/cache/getCacheKey.js +25 -0
- package/src/cache/getLastCacheKeyName.js +22 -0
- package/src/components/AppProvider.js +90 -0
- package/src/components/Frame.js +116 -0
- package/src/components/Frame.test.js +101 -0
- package/src/components/LoadingScreen.js +35 -0
- package/src/components/MenuButton.js +108 -0
- package/src/components/MenuButton.test.js +51 -0
- package/src/components/MessageScreen.js +51 -0
- package/src/components/NavigationTheme.js +33 -0
- package/src/components/NoConnectionBanner.js +51 -0
- package/src/components/NoConnectionBanner.test.js +73 -0
- package/src/components/OtherPageScreen.js +56 -0
- package/src/components/PageScreen.js +244 -0
- package/src/components/PageScreen.test.js +492 -0
- package/src/components/ShellRoot.js +34 -0
- package/src/components/ShellRoot.test.js +62 -0
- package/src/components/ShellThemeProvider.js +45 -0
- package/src/components/SwitchToTab.js +36 -0
- package/src/components/SwitchToTab.test.js +63 -0
- package/src/components/TabIconImages.js +72 -0
- package/src/components/UpdateScreen.js +36 -0
- package/src/components/getNavigationTheme.js +44 -0
- package/src/components/getNavigationTheme.test.js +41 -0
- package/src/components/styles.js +50 -0
- package/src/components/useSurfaceTokens.js +47 -0
- package/src/config/getAppApiBase.js +31 -0
- package/src/config/getShellConfig.js +35 -0
- package/src/config/getShellConfig.test.js +80 -0
- package/src/createShellApp.js +105 -0
- package/src/dev/DevShell.js +228 -0
- package/src/dev/DevShell.test.js +233 -0
- package/src/dev/connect/ConfirmSheet.js +71 -0
- package/src/dev/connect/ConnectScreen.js +117 -0
- package/src/dev/connect/QrScanner.js +89 -0
- package/src/dev/connection/createDevShellConfig.js +47 -0
- package/src/dev/connection/createDevTransport.js +51 -0
- package/src/dev/connection/createDevTransport.test.js +87 -0
- package/src/dev/connection/createServerList.js +48 -0
- package/src/dev/connection/isLoopbackHost.js +25 -0
- package/src/dev/connection/pairedShellApp.test.js +104 -0
- package/src/dev/connection/parseDevLink.js +66 -0
- package/src/dev/connection/parseDevLink.test.js +97 -0
- package/src/dev/connection/parseQuery.js +33 -0
- package/src/dev/connection/parseServerInput.js +40 -0
- package/src/dev/connection/parseServerUrl.js +45 -0
- package/src/dev/devStyles.js +97 -0
- package/src/dev/index.js +21 -0
- package/src/dev/inject/createDeviceInjections.js +185 -0
- package/src/dev/inject/createDeviceInjections.test.js +208 -0
- package/src/dev/inject/createOfflineFetch.js +35 -0
- package/src/dev/inject/createOfflineFetch.test.js +68 -0
- package/src/dev/menu/BlockGallery.js +33 -0
- package/src/dev/menu/DevMenu.js +121 -0
- package/src/dev/menu/createLogBuffer.js +83 -0
- package/src/dev/menu/watchShake.js +52 -0
- package/src/dev/menu/watchShake.test.js +61 -0
- package/src/dev/recorder/createNativeRecorder.js +204 -0
- package/src/dev/recorder/createNativeRecorder.test.js +307 -0
- package/src/dev/redirectDevLink.js +30 -0
- package/src/dev/redirectDevLink.test.js +49 -0
- package/src/dev/steps/createStepRunner.js +184 -0
- package/src/dev/steps/createStepRunner.test.js +262 -0
- package/src/dev/steps/findBlock.js +78 -0
- package/src/dev/steps/isContextSettled.js +37 -0
- package/src/dev/steps/waitForSettle.js +39 -0
- package/src/dev/steps/waitUntil.js +40 -0
- package/src/dev/tab/DevTabClient.js +97 -0
- package/src/dev/tab/answerEval.js +39 -0
- package/src/dev/tab/answerInspect.js +45 -0
- package/src/dev/tab/answerLoadState.js +50 -0
- package/src/dev/tab/createDevTabClient.js +277 -0
- package/src/dev/tab/createDevTabClient.test.js +314 -0
- package/src/dev/tab/devClientMarker.js +22 -0
- package/src/dev/tab/devClientMarker.test.js +23 -0
- package/src/dev/tab/findPageContext.js +43 -0
- package/src/dev/tab/getShownPage.js +42 -0
- package/src/dev/tab/readEventStream.js +68 -0
- package/src/dev/tab/reloadApp.js +42 -0
- package/src/dev/tab/reloadApp.test.js +115 -0
- package/src/device/injected.js +60 -0
- package/src/device/injected.test.js +39 -0
- package/src/host/createDeviceStore.js +32 -0
- package/src/host/createMemoryStore.js +32 -0
- package/src/host/createNetworkStatus.js +94 -0
- package/src/host/createNetworkStatus.test.js +94 -0
- package/src/host/createRouterAdapter.js +33 -0
- package/src/host/createShellFetch.js +60 -0
- package/src/host/createShellFetch.test.js +150 -0
- package/src/host/createShellGlobals.js +62 -0
- package/src/host/createShellHost.js +73 -0
- package/src/host/createShellHost.test.js +36 -0
- package/src/host/getOrigin.js +26 -0
- package/src/host/isOnline.js +22 -0
- package/src/host/networkStatus.js +24 -0
- package/src/host/withShellHeader.js +25 -0
- package/src/host/withShellHeader.test.js +28 -0
- package/src/js/evaluateJsEntries.js +43 -0
- package/src/js/evaluateJsEntries.test.js +48 -0
- package/src/locale/createShellLocale.js +112 -0
- package/src/locale/createShellLocale.test.js +74 -0
- package/src/locale/dayjsLocales.js +165 -0
- package/src/locale/getDeviceLocales.js +23 -0
- package/src/locale/loadDayjsLocale.js +32 -0
- package/src/locale/resolveActiveLocale.js +46 -0
- package/src/locale/resolveActiveLocale.test.js +40 -0
- package/src/navigation/SlotContext.js +24 -0
- package/src/navigation/frameSlots.js +27 -0
- package/src/navigation/getFrame.js +30 -0
- package/src/navigation/getInitialScreenOptions.js +63 -0
- package/src/navigation/getInitialScreenOptions.test.js +66 -0
- package/src/navigation/getPresentation.js +28 -0
- package/src/navigation/getSlotRootPath.js +27 -0
- package/src/navigation/getStackOptions.js +185 -0
- package/src/navigation/getTabHref.js +45 -0
- package/src/navigation/getTabIcons.js +43 -0
- package/src/navigation/isPageUnderHeader.js +46 -0
- package/src/navigation/navigation.test.js +342 -0
- package/src/navigation/toRouterHref.js +29 -0
- package/src/plugins/appTypes.js +28 -0
- package/src/plugins/prefixTypes.js +28 -0
- package/src/plugins/shellPlugins.json +14 -0
- package/src/plugins/typeManifest.js +649 -0
- package/src/plugins/types.js +61 -0
- package/src/plugins/types.test.js +173 -0
- package/src/polyfills/installGetRandomValues.js +28 -0
- package/src/session/createAppSession.js +318 -0
- package/src/session/createAppSession.test.js +359 -0
- package/src/session/getPageView.js +59 -0
- package/src/session/getPageView.test.js +137 -0
- package/src/sync/encodePagePath.js +27 -0
- package/src/sync/fetchPage.js +49 -0
- package/src/sync/fetchRoot.js +38 -0
- package/src/sync/fetchUser.js +28 -0
- package/src/sync/runLimited.js +36 -0
- package/src/sync/toRootConfig.js +35 -0
- package/src/test/createFakeNetwork.js +40 -0
- package/src/test/createFakeSecureStore.js +40 -0
- package/src/test/createFakeServer.js +98 -0
- package/src/test/createMemoryCacheStore.js +59 -0
- package/src/test/expoImage.js +38 -0
- package/src/test/expoSpeechRecognition.js +31 -0
- package/src/test/expoUi.js +55 -0
- package/src/test/expoUiBottomSheet.js +64 -0
- package/src/test/expoUiDateTimePicker.js +26 -0
- package/src/test/expoUiMenu.js +46 -0
- package/src/test/expoUiSegmentedControl.js +47 -0
- package/src/test/expoUiSwiftUi.js +62 -0
- package/src/test/expoUiSwiftUiModifiers.js +23 -0
- package/src/test/expoWebBrowser.js +23 -0
- package/src/test/fixtures.js +98 -0
- package/src/test/renderPageScreen.js +109 -0
- package/src/types/checkTypeSet.js +39 -0
- package/src/types/collectPageTypes.js +49 -0
- package/src/types/collectPageTypes.test.js +90 -0
- package/src/types/findMissingPageTypes.js +40 -0
- package/src/version/compareVersions.js +38 -0
- package/src/version/isShellTooOld.js +30 -0
- package/src/version/shellVersion.js +21 -0
- package/src/version/shellVersion.test.js +32 -0
- package/withExcludedNativeModules.js +93 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { jest } from '@jest/globals';
|
|
18
|
+
|
|
19
|
+
import createAppSession from './createAppSession.js';
|
|
20
|
+
import createFakeServer, { header } from '../test/createFakeServer.js';
|
|
21
|
+
import createMemoryCacheStore from '../test/createMemoryCacheStore.js';
|
|
22
|
+
import createMemoryStore from '../host/createMemoryStore.js';
|
|
23
|
+
import { createPages, createRoot } from '../test/fixtures.js';
|
|
24
|
+
|
|
25
|
+
const apiBase = 'https://field.example.com';
|
|
26
|
+
const app = { id: 'field', name: 'Field', urls: { production: apiBase } };
|
|
27
|
+
|
|
28
|
+
function createServer(overrides = {}) {
|
|
29
|
+
return createFakeServer({
|
|
30
|
+
caller: 'c-anon; roles=r0',
|
|
31
|
+
root: createRoot(),
|
|
32
|
+
rootEtag: '"root-1"',
|
|
33
|
+
pages: createPages([
|
|
34
|
+
['today', 'today', '"p-today-1"'],
|
|
35
|
+
['jobs', 'jobs', '"p-jobs-1"'],
|
|
36
|
+
['jobs/{job_id}', 'job', '"p-job-1"'],
|
|
37
|
+
]),
|
|
38
|
+
...overrides,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function createSession({
|
|
43
|
+
server,
|
|
44
|
+
cacheStore = createMemoryCacheStore(),
|
|
45
|
+
kv = createMemoryStore(),
|
|
46
|
+
}) {
|
|
47
|
+
const tokenStore = { get: () => 'token-1' };
|
|
48
|
+
const session = createAppSession({
|
|
49
|
+
apiBase,
|
|
50
|
+
app,
|
|
51
|
+
cacheStore,
|
|
52
|
+
fetch: server.fetch,
|
|
53
|
+
kv,
|
|
54
|
+
tokenStore,
|
|
55
|
+
});
|
|
56
|
+
return { cacheStore, kv, session };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function callsTo(server, pathname) {
|
|
60
|
+
return server.fetch.mock.calls.filter(([url]) => new URL(url).pathname === pathname);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test('a first start fetches the root, the user and every page in the index', async () => {
|
|
64
|
+
const server = createServer({ user: { id: 'u1' } });
|
|
65
|
+
const { session } = createSession({ server });
|
|
66
|
+
expect(session.getState().status).toBe('loading');
|
|
67
|
+
await session.start();
|
|
68
|
+
const state = session.getState();
|
|
69
|
+
expect(state.status).toBe('ready');
|
|
70
|
+
expect(state.user).toEqual({ id: 'u1' });
|
|
71
|
+
expect(state.buildId).toBe('build-1');
|
|
72
|
+
expect(Object.keys(state.pages).sort()).toEqual(['job', 'jobs', 'today']);
|
|
73
|
+
expect(state.pages.job).toEqual(
|
|
74
|
+
expect.objectContaining({ hash: 'h-job-1', etag: '"p-job-1"', buildId: 'build-1' })
|
|
75
|
+
);
|
|
76
|
+
expect(state.rootConfig.pagePaths).toEqual({
|
|
77
|
+
today: 'today',
|
|
78
|
+
jobs: 'jobs',
|
|
79
|
+
job: 'jobs/{job_id}',
|
|
80
|
+
});
|
|
81
|
+
expect(callsTo(server, '/api/page/jobs/%7Bjob_id%7D')).toHaveLength(1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('every shell call carries the session token as a bearer token', async () => {
|
|
85
|
+
const server = createServer();
|
|
86
|
+
const { session } = createSession({ server });
|
|
87
|
+
await session.start();
|
|
88
|
+
server.fetch.mock.calls.forEach(([, init]) => {
|
|
89
|
+
expect(header(init, 'Authorization')).toBe('Bearer token-1');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('pages are fetched at most 4 at a time', async () => {
|
|
94
|
+
const pageIndex = Array.from({ length: 10 }, (_, i) => ({
|
|
95
|
+
path: `p${i}`,
|
|
96
|
+
pageId: `p${i}`,
|
|
97
|
+
hash: `h${i}`,
|
|
98
|
+
typesKey: 'carried',
|
|
99
|
+
}));
|
|
100
|
+
const server = createServer({
|
|
101
|
+
root: createRoot({ pageIndex }),
|
|
102
|
+
pages: createPages(pageIndex.map((entry) => [entry.path, entry.pageId, `"${entry.hash}"`])),
|
|
103
|
+
});
|
|
104
|
+
let inFlight = 0;
|
|
105
|
+
let most = 0;
|
|
106
|
+
const serve = server.fetch.getMockImplementation();
|
|
107
|
+
server.fetch.mockImplementation(async (url, init) => {
|
|
108
|
+
inFlight += 1;
|
|
109
|
+
most = Math.max(most, inFlight);
|
|
110
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
111
|
+
inFlight -= 1;
|
|
112
|
+
return serve(url, init);
|
|
113
|
+
});
|
|
114
|
+
const { session } = createSession({ server });
|
|
115
|
+
await session.start();
|
|
116
|
+
expect(Object.keys(session.getState().pages)).toHaveLength(10);
|
|
117
|
+
expect(most).toBeLessThanOrEqual(4);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('cache keying', () => {
|
|
121
|
+
test('the cache is kept per app, caller and roles', async () => {
|
|
122
|
+
const server = createServer({ caller: 'c-ada; session=s1; roles=r-staff' });
|
|
123
|
+
const { cacheStore, session } = createSession({ server });
|
|
124
|
+
await session.start();
|
|
125
|
+
expect(session.getState().cacheKey).toBe('field:c-ada:r-staff');
|
|
126
|
+
expect([...cacheStore.roots.keys()]).toEqual(['field:c-ada:r-staff']);
|
|
127
|
+
expect([...cacheStore.pages.get('field:c-ada:r-staff').keys()].sort()).toEqual([
|
|
128
|
+
'job',
|
|
129
|
+
'jobs',
|
|
130
|
+
'today',
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('a start renders the last cache at once, before the server answers', async () => {
|
|
135
|
+
const server = createServer({ caller: 'c-ada; roles=r-staff' });
|
|
136
|
+
const cacheStore = createMemoryCacheStore();
|
|
137
|
+
const kv = createMemoryStore();
|
|
138
|
+
await createSession({ server, cacheStore, kv }).session.start();
|
|
139
|
+
|
|
140
|
+
let answer;
|
|
141
|
+
const offline = { fetch: jest.fn(() => new Promise((resolve) => (answer = resolve))) };
|
|
142
|
+
const { session } = createSession({ server: offline, cacheStore, kv });
|
|
143
|
+
const started = session.start();
|
|
144
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
145
|
+
const state = session.getState();
|
|
146
|
+
expect(state.status).toBe('ready');
|
|
147
|
+
expect(state.cacheKey).toBe('field:c-ada:r-staff');
|
|
148
|
+
expect(Object.keys(state.pages).sort()).toEqual(['job', 'jobs', 'today']);
|
|
149
|
+
expect(offline.fetch).toHaveBeenCalled();
|
|
150
|
+
answer(Promise.reject(new Error('Network request failed')));
|
|
151
|
+
await started;
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('with no signal the app keeps its cached pages', async () => {
|
|
155
|
+
const server = createServer();
|
|
156
|
+
const cacheStore = createMemoryCacheStore();
|
|
157
|
+
const kv = createMemoryStore();
|
|
158
|
+
await createSession({ server, cacheStore, kv }).session.start();
|
|
159
|
+
const offline = {
|
|
160
|
+
fetch: jest.fn(async () => Promise.reject(new TypeError('Network request failed'))),
|
|
161
|
+
};
|
|
162
|
+
const { session } = createSession({ server: offline, cacheStore, kv });
|
|
163
|
+
await session.start();
|
|
164
|
+
expect(session.getState().status).toBe('ready');
|
|
165
|
+
expect(Object.keys(session.getState().pages)).toHaveLength(3);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('with no signal and no cache the app shows the error', async () => {
|
|
169
|
+
const offline = {
|
|
170
|
+
fetch: jest.fn(async () => Promise.reject(new TypeError('Network request failed'))),
|
|
171
|
+
};
|
|
172
|
+
const { session } = createSession({ server: offline });
|
|
173
|
+
await session.start();
|
|
174
|
+
expect(session.getState().status).toBe('error');
|
|
175
|
+
expect(session.getState().error.message).toBe('Network request failed');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('an app without mobile pages shows an error', async () => {
|
|
179
|
+
const server = createServer();
|
|
180
|
+
server.fetch.mockImplementation(async () => ({
|
|
181
|
+
ok: false,
|
|
182
|
+
status: 404,
|
|
183
|
+
headers: { get: () => null },
|
|
184
|
+
json: async () => ({}),
|
|
185
|
+
}));
|
|
186
|
+
const { session } = createSession({ server });
|
|
187
|
+
await session.start();
|
|
188
|
+
expect(session.getState().error.message).toBe('This app has no mobile pages.');
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('ETag reuse', () => {
|
|
193
|
+
test('a sync revalidates the root with its ETag and keeps it on a 304', async () => {
|
|
194
|
+
const server = createServer();
|
|
195
|
+
const { session } = createSession({ server });
|
|
196
|
+
await session.start();
|
|
197
|
+
const { root, rootConfig } = session.getState();
|
|
198
|
+
server.fetch.mockClear();
|
|
199
|
+
await session.sync();
|
|
200
|
+
const [rootCall] = callsTo(server, '/api/mobile/root');
|
|
201
|
+
expect(header(rootCall[1], 'If-None-Match')).toBe('"root-1"');
|
|
202
|
+
expect(session.getState().root).toBe(root);
|
|
203
|
+
expect(session.getState().rootConfig).toBe(rootConfig);
|
|
204
|
+
expect(server.fetch.mock.calls.filter(([url]) => url.includes('/api/page/'))).toHaveLength(0);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('only pages whose hash changed are fetched again, revalidated with their ETag', async () => {
|
|
208
|
+
const server = createServer();
|
|
209
|
+
const { session } = createSession({ server });
|
|
210
|
+
await session.start();
|
|
211
|
+
const before = session.getState().pages;
|
|
212
|
+
server.state.root = createRoot({
|
|
213
|
+
buildId: 'build-2',
|
|
214
|
+
pageIndex: [
|
|
215
|
+
{ path: 'today', pageId: 'today', hash: 'h-today-2', typesKey: 'carried' },
|
|
216
|
+
{ path: 'jobs', pageId: 'jobs', hash: 'h-jobs-1', typesKey: 'carried' },
|
|
217
|
+
],
|
|
218
|
+
});
|
|
219
|
+
server.state.rootEtag = '"root-2"';
|
|
220
|
+
server.state.pages.today = {
|
|
221
|
+
pageConfig: { ...before.today.pageConfig, v: 2 },
|
|
222
|
+
etag: '"p-today-2"',
|
|
223
|
+
};
|
|
224
|
+
server.fetch.mockClear();
|
|
225
|
+
await session.sync();
|
|
226
|
+
const pageCalls = server.fetch.mock.calls.filter(([url]) => url.includes('/api/page/'));
|
|
227
|
+
expect(pageCalls.map(([url]) => url)).toEqual([`${apiBase}/api/page/today`]);
|
|
228
|
+
expect(header(pageCalls[0][1], 'If-None-Match')).toBe('"p-today-1"');
|
|
229
|
+
const state = session.getState();
|
|
230
|
+
expect(state.buildId).toBe('build-2');
|
|
231
|
+
expect(state.pages.today.pageConfig.v).toBe(2);
|
|
232
|
+
expect(state.pages.jobs).toBe(before.jobs);
|
|
233
|
+
expect(state.pages.job).toBeUndefined();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('a page answered with a 304 is kept under its new hash', async () => {
|
|
237
|
+
const server = createServer();
|
|
238
|
+
const { session } = createSession({ server });
|
|
239
|
+
await session.start();
|
|
240
|
+
server.state.root = createRoot({
|
|
241
|
+
pageIndex: [{ path: 'today', pageId: 'today', hash: 'h-today-2', typesKey: 'carried' }],
|
|
242
|
+
});
|
|
243
|
+
server.state.rootEtag = '"root-2"';
|
|
244
|
+
await session.sync();
|
|
245
|
+
expect(session.getState().pages.today.hash).toBe('h-today-2');
|
|
246
|
+
expect(session.getState().pages.today.etag).toBe('"p-today-1"');
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('caller changes', () => {
|
|
251
|
+
test("a root answered as another caller resets the app onto that caller's cache", async () => {
|
|
252
|
+
const server = createServer({ caller: 'c-ada; roles=r-staff' });
|
|
253
|
+
const { cacheStore, session } = createSession({ server });
|
|
254
|
+
await session.start();
|
|
255
|
+
const { generation, homeRequests, lowdefy } = session.getState();
|
|
256
|
+
server.state.caller = 'c-ben; roles=r-admin';
|
|
257
|
+
server.state.root = createRoot({
|
|
258
|
+
pageIndex: [{ path: 'today', pageId: 'today', hash: 'h-today-admin', typesKey: 'carried' }],
|
|
259
|
+
});
|
|
260
|
+
server.state.rootEtag = '"root-admin"';
|
|
261
|
+
await session.sync();
|
|
262
|
+
const state = session.getState();
|
|
263
|
+
expect(state.cacheKey).toBe('field:c-ben:r-admin');
|
|
264
|
+
expect(state.generation).toBe(generation + 1);
|
|
265
|
+
expect(state.homeRequests).toBe(homeRequests + 1);
|
|
266
|
+
expect(state.lowdefy).not.toBe(lowdefy);
|
|
267
|
+
expect(Object.keys(state.pages)).toEqual(['today']);
|
|
268
|
+
expect(state.pages.today.hash).toBe('h-today-admin');
|
|
269
|
+
// The first caller's cache is kept for when they come back.
|
|
270
|
+
expect([...cacheStore.roots.keys()].sort()).toEqual([
|
|
271
|
+
'field:c-ada:r-staff',
|
|
272
|
+
'field:c-ben:r-admin',
|
|
273
|
+
]);
|
|
274
|
+
expect(session.takeHomeRequest()).toBe(true);
|
|
275
|
+
expect(session.takeHomeRequest()).toBe(false);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('onCallerChanged drops the screens, refetches the root under the new key and goes home', async () => {
|
|
279
|
+
const server = createServer({ caller: 'c-ada; roles=r-staff' });
|
|
280
|
+
const { session } = createSession({ server });
|
|
281
|
+
await session.start();
|
|
282
|
+
const before = session.getState();
|
|
283
|
+
const statuses = [];
|
|
284
|
+
session.subscribe(() => statuses.push(session.getState().status));
|
|
285
|
+
server.state.caller = 'c-anon; roles=r0';
|
|
286
|
+
const reset = session.handleCallerChanged();
|
|
287
|
+
expect(session.getState().status).toBe('loading');
|
|
288
|
+
await reset;
|
|
289
|
+
const state = session.getState();
|
|
290
|
+
expect(statuses[0]).toBe('loading');
|
|
291
|
+
expect(state.status).toBe('ready');
|
|
292
|
+
expect(state.cacheKey).toBe('field:c-anon:r0');
|
|
293
|
+
expect(state.generation).toBeGreaterThan(before.generation);
|
|
294
|
+
expect(state.homeRequests).toBeGreaterThan(before.homeRequests);
|
|
295
|
+
expect(session.takeHomeRequest()).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('onBuildChanged syncs and rebuilds every screen on the new build', async () => {
|
|
300
|
+
const server = createServer();
|
|
301
|
+
const { session } = createSession({ server });
|
|
302
|
+
await session.start();
|
|
303
|
+
const before = session.getState();
|
|
304
|
+
server.state.root = createRoot({ buildId: 'build-2' });
|
|
305
|
+
server.state.rootEtag = '"root-2"';
|
|
306
|
+
await session.handleBuildChanged();
|
|
307
|
+
const state = session.getState();
|
|
308
|
+
expect(state.buildId).toBe('build-2');
|
|
309
|
+
expect(state.generation).toBe(before.generation + 1);
|
|
310
|
+
expect(state.lowdefy).toEqual({ buildId: 'build-2' });
|
|
311
|
+
expect(state.homeRequests).toBe(before.homeRequests);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe('loadPage', () => {
|
|
315
|
+
test('a cached page with a hash is not fetched again on a visit', async () => {
|
|
316
|
+
const server = createServer();
|
|
317
|
+
const { session } = createSession({ server });
|
|
318
|
+
await session.start();
|
|
319
|
+
server.fetch.mockClear();
|
|
320
|
+
const entry = session.getState().root.pageIndex[0];
|
|
321
|
+
await session.loadPage({ entry, path: 'today' });
|
|
322
|
+
expect(server.fetch).not.toHaveBeenCalled();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('a dev page (no hash) is fetched on every visit by its path', async () => {
|
|
326
|
+
const pageIndex = [{ path: 'jobs/{job_id}', pageId: 'job' }];
|
|
327
|
+
const server = createServer({
|
|
328
|
+
root: createRoot({ pageIndex, typeSets: {} }),
|
|
329
|
+
pages: createPages([['jobs/7', 'job', '"dev-1"']]),
|
|
330
|
+
});
|
|
331
|
+
const { session } = createSession({ server });
|
|
332
|
+
await session.start();
|
|
333
|
+
expect(session.getState().pages).toEqual({});
|
|
334
|
+
await session.loadPage({ entry: pageIndex[0], path: 'jobs/7' });
|
|
335
|
+
await session.loadPage({ entry: pageIndex[0], path: 'jobs/7' });
|
|
336
|
+
const pageCalls = callsTo(server, '/api/page/jobs/7');
|
|
337
|
+
expect(pageCalls).toHaveLength(2);
|
|
338
|
+
expect(header(pageCalls[1][1], 'If-None-Match')).toBe('"dev-1"');
|
|
339
|
+
expect(session.getState().pages.job.pageConfig.pageId).toBe('job');
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test('a dynamic page is fetched on every visit', async () => {
|
|
343
|
+
const server = createServer();
|
|
344
|
+
server.state.pages.today.pageConfig.dynamic = true;
|
|
345
|
+
const { session } = createSession({ server });
|
|
346
|
+
await session.start();
|
|
347
|
+
server.fetch.mockClear();
|
|
348
|
+
await session.loadPage({ entry: session.getState().root.pageIndex[0], path: 'today' });
|
|
349
|
+
expect(callsTo(server, '/api/page/today')).toHaveLength(1);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test('fetchOtherPage reports a web page for the shell to open in the browser', async () => {
|
|
354
|
+
const server = createServer({ otherSurfaces: { reports: { pageConfig: null, surface: 'web' } } });
|
|
355
|
+
const { session } = createSession({ server });
|
|
356
|
+
await session.start();
|
|
357
|
+
expect(await session.fetchOtherPage('reports')).toEqual({ notFound: true, surface: 'web' });
|
|
358
|
+
expect(await session.fetchOtherPage('nothing')).toEqual({ notFound: true, surface: null });
|
|
359
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// The node-utils matcher is the one the server matches page paths with; this file imports only
|
|
18
|
+
// @lowdefy/helpers.
|
|
19
|
+
import matchPagePath from '@lowdefy/node-utils/matchPagePath.js';
|
|
20
|
+
|
|
21
|
+
import encodePagePath from '../sync/encodePagePath.js';
|
|
22
|
+
import findMissingPageTypes from '../types/findMissingPageTypes.js';
|
|
23
|
+
import isShellTooOld from '../version/isShellTooOld.js';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What a screen showing `path` draws, from the session state:
|
|
27
|
+
* - `{ view: 'update', reason }`: the shell is older than minShellVersion, or lacks a type the page
|
|
28
|
+
* needs ("Update the app to open this page"). Other pages still open.
|
|
29
|
+
* - `{ view: 'other', path }`: no mobile page has this path (a web page, or none).
|
|
30
|
+
* - `{ view: 'loading', entry, pathParams }`: the page is not in the cache yet.
|
|
31
|
+
* - `{ view: 'page', entry, page, pathParams }`: render it.
|
|
32
|
+
*
|
|
33
|
+
* @param {Object} params
|
|
34
|
+
* @param {Object} params.shell The shell config: `version` and `typeManifest`.
|
|
35
|
+
* @param {Object} params.state The session state (createAppSession).
|
|
36
|
+
* @param {string} params.path The route's path, without the leading "/".
|
|
37
|
+
*/
|
|
38
|
+
function getPageView({ path, shell, state }) {
|
|
39
|
+
const { pages, root } = state;
|
|
40
|
+
if (isShellTooOld({ minShellVersion: root.minShellVersion, shellVersion: shell.version })) {
|
|
41
|
+
return { view: 'update', reason: 'minShellVersion' };
|
|
42
|
+
}
|
|
43
|
+
const match = matchPagePath({ routes: root.pageIndex, path: encodePagePath(path) });
|
|
44
|
+
if (match === null) {
|
|
45
|
+
return { view: 'other', path };
|
|
46
|
+
}
|
|
47
|
+
const entry = root.pageIndex.find((indexEntry) => indexEntry.pageId === match.pageId);
|
|
48
|
+
const page = pages[entry.pageId];
|
|
49
|
+
const missing = findMissingPageTypes({ entry, manifest: shell.typeManifest, page, root });
|
|
50
|
+
if (missing.length > 0) {
|
|
51
|
+
return { view: 'update', reason: 'types', missing };
|
|
52
|
+
}
|
|
53
|
+
if (page === undefined) {
|
|
54
|
+
return { view: 'loading', entry, pathParams: match.pathParams };
|
|
55
|
+
}
|
|
56
|
+
return { view: 'page', entry, page, pathParams: match.pathParams };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default getPageView;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import getPageView from './getPageView.js';
|
|
18
|
+
import { createPage, createRoot, shell } from '../test/fixtures.js';
|
|
19
|
+
|
|
20
|
+
const pageIndex = [
|
|
21
|
+
{ path: 'today', pageId: 'today', hash: 'h1', typesKey: 'carried' },
|
|
22
|
+
{ path: 'map', pageId: 'map', hash: 'h2', typesKey: 'newBlock' },
|
|
23
|
+
{ path: 'report', pageId: 'report', hash: 'h3', typesKey: 'nextMajor' },
|
|
24
|
+
{ path: 'jobs/{job_id}', pageId: 'job', hash: 'h4', typesKey: 'carried' },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function createState({ root = createRoot({ pageIndex }), pages } = {}) {
|
|
28
|
+
return {
|
|
29
|
+
root,
|
|
30
|
+
pages: pages ?? {
|
|
31
|
+
today: { hash: 'h1', pageConfig: createPage('today') },
|
|
32
|
+
map: { hash: 'h2', pageConfig: createPage('map') },
|
|
33
|
+
report: { hash: 'h3', pageConfig: createPage('report') },
|
|
34
|
+
job: { hash: 'h4', pageConfig: createPage('job') },
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('the type check', () => {
|
|
40
|
+
test('a page needing a block the shell lacks shows the update screen, other pages open', () => {
|
|
41
|
+
const state = createState();
|
|
42
|
+
expect(getPageView({ path: 'map', shell, state })).toEqual({
|
|
43
|
+
view: 'update',
|
|
44
|
+
reason: 'types',
|
|
45
|
+
missing: [{ category: 'blocks', type: 'Map', package: '@lowdefy/blocks-native', version: 7 }],
|
|
46
|
+
});
|
|
47
|
+
expect(getPageView({ path: 'today', shell, state })).toEqual(
|
|
48
|
+
expect.objectContaining({ view: 'page', pathParams: {} })
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a type from another major version of its package is missing', () => {
|
|
53
|
+
expect(getPageView({ path: 'report', shell, state: createState() })).toEqual(
|
|
54
|
+
expect.objectContaining({
|
|
55
|
+
view: 'update',
|
|
56
|
+
missing: [
|
|
57
|
+
{ category: 'blocks', type: 'Title', package: '@lowdefy/blocks-native', version: 8 },
|
|
58
|
+
],
|
|
59
|
+
})
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a page is checked before it is fetched', () => {
|
|
64
|
+
expect(getPageView({ path: 'map', shell, state: createState({ pages: {} }) }).view).toBe(
|
|
65
|
+
'update'
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('a dev page (no type set) is checked by the types its config uses once it arrives', () => {
|
|
70
|
+
const root = createRoot({
|
|
71
|
+
pageIndex: [
|
|
72
|
+
{ path: 'today', pageId: 'today' },
|
|
73
|
+
{ path: 'map', pageId: 'map' },
|
|
74
|
+
],
|
|
75
|
+
typeSets: {},
|
|
76
|
+
});
|
|
77
|
+
const mapPage = createPage('map');
|
|
78
|
+
mapPage.slots.content.blocks.push({
|
|
79
|
+
id: 'block:map:m:0',
|
|
80
|
+
blockId: 'm',
|
|
81
|
+
type: 'Map',
|
|
82
|
+
events: { onClick: { try: [{ id: 'a', type: 'Vibrate' }], catch: [] } },
|
|
83
|
+
});
|
|
84
|
+
const state = createState({
|
|
85
|
+
root,
|
|
86
|
+
pages: { today: { pageConfig: createPage('today') }, map: { pageConfig: mapPage } },
|
|
87
|
+
});
|
|
88
|
+
expect(getPageView({ path: 'today', shell, state }).view).toBe('page');
|
|
89
|
+
expect(getPageView({ path: 'map', shell, state }).missing).toEqual([
|
|
90
|
+
{ category: 'blocks', type: 'Map' },
|
|
91
|
+
{ category: 'actions', type: 'Vibrate' },
|
|
92
|
+
]);
|
|
93
|
+
expect(getPageView({ path: 'map', shell, state: createState({ root, pages: {} }) }).view).toBe(
|
|
94
|
+
'loading'
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('minShellVersion', () => {
|
|
100
|
+
test('a shell older than minShellVersion shows the update screen for every page', () => {
|
|
101
|
+
const state = createState({ root: createRoot({ pageIndex, minShellVersion: '7.5.0' }) });
|
|
102
|
+
['today', 'jobs/12', 'nowhere'].forEach((path) => {
|
|
103
|
+
expect(getPageView({ path, shell, state })).toEqual({
|
|
104
|
+
view: 'update',
|
|
105
|
+
reason: 'minShellVersion',
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('a shell at or above minShellVersion opens pages', () => {
|
|
111
|
+
['7.4.0', '7.3.9', '6.0.0'].forEach((minShellVersion) => {
|
|
112
|
+
const state = createState({ root: createRoot({ pageIndex, minShellVersion }) });
|
|
113
|
+
expect(getPageView({ path: 'today', shell, state }).view).toBe('page');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('a path with params opens its page with the values from the path', () => {
|
|
119
|
+
expect(getPageView({ path: 'jobs/a b', shell, state: createState() })).toEqual(
|
|
120
|
+
expect.objectContaining({ view: 'page', pathParams: { job_id: 'a b' } })
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('a page not yet cached is loading', () => {
|
|
125
|
+
expect(getPageView({ path: 'jobs/12', shell, state: createState({ pages: {} }) })).toEqual({
|
|
126
|
+
view: 'loading',
|
|
127
|
+
entry: pageIndex[3],
|
|
128
|
+
pathParams: { job_id: '12' },
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('a path no mobile page has is another page', () => {
|
|
133
|
+
expect(getPageView({ path: 'reports', shell, state: createState() })).toEqual({
|
|
134
|
+
view: 'other',
|
|
135
|
+
path: 'reports',
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// A page path for /api/page/*, each segment encoded. An index entry's path can be a pattern such
|
|
18
|
+
// as jobs/{job_id}: its placeholder segment matches any value, so the server serves the page's
|
|
19
|
+
// config, which is the same for every value of its params.
|
|
20
|
+
function encodePagePath(path) {
|
|
21
|
+
return path
|
|
22
|
+
.split('/')
|
|
23
|
+
.map((segment) => encodeURIComponent(segment))
|
|
24
|
+
.join('/');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default encodePagePath;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import encodePagePath from './encodePagePath.js';
|
|
18
|
+
|
|
19
|
+
// GET /api/page/<path> for a page the shell keeps, revalidated with the ETag of the copy it holds.
|
|
20
|
+
// Returns { notModified } on a 304, the page { buildId, etag, pageConfig } on a 200, and
|
|
21
|
+
// { surface } when the page is for another surface (a web page: the shell opens it in the
|
|
22
|
+
// browser). Any other answer throws.
|
|
23
|
+
async function fetchPage({ apiBase, etag, fetchWithAuth, path }) {
|
|
24
|
+
const headers = etag ? { 'If-None-Match': etag } : {};
|
|
25
|
+
const res = await fetchWithAuth(`${apiBase}/api/page/${encodePagePath(path)}`, {
|
|
26
|
+
method: 'GET',
|
|
27
|
+
headers,
|
|
28
|
+
});
|
|
29
|
+
if (res.status === 304) {
|
|
30
|
+
return { notModified: true };
|
|
31
|
+
}
|
|
32
|
+
if (res.status === 404) {
|
|
33
|
+
const body = await res.json().catch(() => ({}));
|
|
34
|
+
return { notFound: true, surface: body?.surface ?? null };
|
|
35
|
+
}
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
throw new Error(`The app's server answered ${res.status} to the page request for "${path}".`);
|
|
38
|
+
}
|
|
39
|
+
const body = await res.json();
|
|
40
|
+
return {
|
|
41
|
+
page: {
|
|
42
|
+
buildId: body.buildId ?? body.pageConfig?._buildId ?? null,
|
|
43
|
+
etag: res.headers.get('etag'),
|
|
44
|
+
pageConfig: body.pageConfig,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default fetchPage;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { parseCallerStamp } from '@lowdefy/helpers';
|
|
18
|
+
|
|
19
|
+
// GET /api/mobile/root, revalidated with the ETag of the root the shell holds. Returns
|
|
20
|
+
// { notModified, root, etag, stamp }: on a 304 the held root is current and root is null. The
|
|
21
|
+
// stamp is the caller the server answered as, whichever the status.
|
|
22
|
+
async function fetchRoot({ apiBase, etag, fetchWithAuth }) {
|
|
23
|
+
const headers = etag ? { 'If-None-Match': etag } : {};
|
|
24
|
+
const res = await fetchWithAuth(`${apiBase}/api/mobile/root`, { method: 'GET', headers });
|
|
25
|
+
const stamp = parseCallerStamp(res.headers.get('x-lowdefy-caller'));
|
|
26
|
+
if (res.status === 304) {
|
|
27
|
+
return { notModified: true, root: null, etag, stamp };
|
|
28
|
+
}
|
|
29
|
+
if (res.status === 404) {
|
|
30
|
+
throw new Error('This app has no mobile pages.');
|
|
31
|
+
}
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
throw new Error(`The app's server answered ${res.status} to the mobile root request.`);
|
|
34
|
+
}
|
|
35
|
+
return { notModified: false, root: await res.json(), etag: res.headers.get('etag'), stamp };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default fetchRoot;
|