@lowdefy/shell 3.23.2 → 7.2.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.
Files changed (356) hide show
  1. package/LICENSE +183 -183
  2. package/README.md +364 -2
  3. package/app/(t0,t1,t2,t3,t4)/[...path].js +38 -0
  4. package/app/(t0,t1,t2,t3,t4)/_layout.js +48 -0
  5. package/app/(t0,t1,t2,t3,t4)/index.js +38 -0
  6. package/app/+native-intent.js +39 -0
  7. package/app/_layout.js +36 -0
  8. package/app.config.ts +183 -0
  9. package/appLinks.js +65 -0
  10. package/applyShellPlugins.js +43 -0
  11. package/authCallbackIntentFilters.js +46 -0
  12. package/babel.config.js +25 -0
  13. package/checkShellConfig.js +172 -0
  14. package/checkShellPlugins.js +74 -0
  15. package/getPrivacyManifest.js +39 -0
  16. package/metro.config.js +73 -0
  17. package/native/LowdefyAppShortcuts.swift.template +84 -0
  18. package/native/withAppShortcuts.js +60 -0
  19. package/native-fingerprint.json +16 -0
  20. package/package.json +120 -40
  21. package/profiles.js +607 -0
  22. package/readShellConfig.js +36 -0
  23. package/resolveShellPlugins.js +109 -0
  24. package/runtimeVersion.js +112 -0
  25. package/scripts/checkReleaseBundle.mjs +358 -0
  26. package/scripts/createDayjsLocales.mjs +67 -0
  27. package/scripts/createShellAssets.mjs +54 -0
  28. package/scripts/createShellVersion.mjs +83 -0
  29. package/scripts/createTypeManifest.mjs +205 -0
  30. package/shell.dev.json +11 -0
  31. package/shellVariant.js +126 -0
  32. package/src/ShellContext.js +32 -0
  33. package/src/actions/CopyToClipboard.js +29 -0
  34. package/src/actions/CopyToClipboard.test.js +33 -0
  35. package/src/actions/GeolocationCurrentPosition.js +61 -0
  36. package/src/actions/GeolocationCurrentPosition.test.js +79 -0
  37. package/src/actions/GetLocalStorage.js +36 -0
  38. package/src/actions/RemoveLocalStorage.js +31 -0
  39. package/src/actions/ScrollTo.js +66 -0
  40. package/src/actions/ScrollTo.test.js +83 -0
  41. package/src/actions/SetFocus.js +33 -0
  42. package/src/actions/SetFocus.test.js +49 -0
  43. package/src/actions/SetLocalStorage.js +34 -0
  44. package/src/actions/localStorage.test.js +68 -0
  45. package/src/actions/measureInScrollView.js +36 -0
  46. package/src/actions/nativeActions.js +41 -0
  47. package/src/auth/AuthButton.js +44 -0
  48. package/src/auth/AuthGate.js +57 -0
  49. package/src/auth/AuthLink.js +43 -0
  50. package/src/auth/LockScreen.js +67 -0
  51. package/src/auth/LockScreen.test.js +37 -0
  52. package/src/auth/SignInScreen.js +268 -0
  53. package/src/auth/SignInScreen.test.js +187 -0
  54. package/src/auth/createAppAuth.js +325 -0
  55. package/src/auth/createAppAuth.test.js +424 -0
  56. package/src/auth/createAuthApi.js +115 -0
  57. package/src/auth/createAuthApi.test.js +138 -0
  58. package/src/auth/createCoreAuth.js +96 -0
  59. package/src/auth/createCoreAuth.test.js +188 -0
  60. package/src/auth/createDeviceKey.js +62 -0
  61. package/src/auth/createDeviceKey.test.js +54 -0
  62. package/src/auth/createSessionStores.js +45 -0
  63. package/src/auth/createTokenStore.js +103 -0
  64. package/src/auth/createTokenStore.test.js +116 -0
  65. package/src/auth/getAuthSettings.js +24 -0
  66. package/src/auth/getAuthView.js +39 -0
  67. package/src/auth/getAuthView.test.js +52 -0
  68. package/src/auth/getSheetReturnUrl.js +35 -0
  69. package/src/auth/getTokenStoreOptions.js +46 -0
  70. package/src/auth/isBackgroundShell.js +26 -0
  71. package/src/auth/isSheetReturnLink.js +35 -0
  72. package/src/auth/parseQuery.js +39 -0
  73. package/src/auth/purgeAppData.js +80 -0
  74. package/src/auth/purgeAppData.test.js +104 -0
  75. package/src/auth/shouldLock.js +26 -0
  76. package/src/auth/signInWithSheet.js +97 -0
  77. package/src/auth/signInWithSheet.test.js +205 -0
  78. package/src/auth/styles.js +78 -0
  79. package/src/auth/unregisterPushToken.js +62 -0
  80. package/src/auth/unregisterPushToken.test.js +67 -0
  81. package/src/auth/usesBiometricUnlock.js +23 -0
  82. package/src/auth/watchSession.js +47 -0
  83. package/src/auth/withoutCookies.js +26 -0
  84. package/src/cache/createSqliteCacheStore.js +113 -0
  85. package/src/cache/createSqliteCacheStore.test.js +93 -0
  86. package/src/cache/getCacheKey.js +25 -0
  87. package/src/cache/getLastCacheKeyName.js +22 -0
  88. package/src/components/AppProvider.js +118 -0
  89. package/src/components/Frame.js +116 -0
  90. package/src/components/Frame.test.js +101 -0
  91. package/src/components/LoadingScreen.js +35 -0
  92. package/src/components/MenuButton.js +121 -0
  93. package/src/components/MenuButton.test.js +74 -0
  94. package/src/components/MessageScreen.js +51 -0
  95. package/src/components/NavigationTheme.js +33 -0
  96. package/src/components/NoConnectionBanner.js +51 -0
  97. package/src/components/NoConnectionBanner.test.js +73 -0
  98. package/src/components/OtherPageScreen.js +95 -0
  99. package/src/components/OtherPageScreen.test.js +74 -0
  100. package/src/components/PageScreen.js +254 -0
  101. package/src/components/PageScreen.test.js +492 -0
  102. package/src/components/RouteErrorScreen.js +47 -0
  103. package/src/components/ShellRoot.js +34 -0
  104. package/src/components/ShellRoot.test.js +62 -0
  105. package/src/components/ShellThemeProvider.js +45 -0
  106. package/src/components/SwitchToTab.js +36 -0
  107. package/src/components/SwitchToTab.test.js +63 -0
  108. package/src/components/TabIconImages.js +72 -0
  109. package/src/components/UpdateScreen.js +40 -0
  110. package/src/components/getNavigationTheme.js +44 -0
  111. package/src/components/getNavigationTheme.test.js +41 -0
  112. package/src/components/styles.js +50 -0
  113. package/src/components/useSurfaceTokens.js +47 -0
  114. package/src/config/getAppApiBase.js +34 -0
  115. package/src/config/getShellConfig.js +37 -0
  116. package/src/config/getShellConfig.test.js +80 -0
  117. package/src/createShellApp.js +126 -0
  118. package/src/dev/DevShell.js +239 -0
  119. package/src/dev/DevShell.test.js +233 -0
  120. package/src/dev/connect/ConfirmSheet.js +71 -0
  121. package/src/dev/connect/ConnectScreen.js +117 -0
  122. package/src/dev/connect/QrScanner.js +89 -0
  123. package/src/dev/connection/createDevShellConfig.js +47 -0
  124. package/src/dev/connection/createDevTransport.js +51 -0
  125. package/src/dev/connection/createDevTransport.test.js +87 -0
  126. package/src/dev/connection/createServerList.js +48 -0
  127. package/src/dev/connection/isLoopbackHost.js +25 -0
  128. package/src/dev/connection/pairedShellApp.test.js +104 -0
  129. package/src/dev/connection/parseDevLink.js +66 -0
  130. package/src/dev/connection/parseDevLink.test.js +97 -0
  131. package/src/dev/connection/parseQuery.js +33 -0
  132. package/src/dev/connection/parseServerInput.js +40 -0
  133. package/src/dev/connection/parseServerUrl.js +45 -0
  134. package/src/dev/devStyles.js +97 -0
  135. package/src/dev/index.js +21 -0
  136. package/src/dev/inject/createDeviceInjections.js +188 -0
  137. package/src/dev/inject/createDeviceInjections.test.js +224 -0
  138. package/src/dev/inject/createOfflineFetch.js +35 -0
  139. package/src/dev/inject/createOfflineFetch.test.js +68 -0
  140. package/src/dev/inject/injectPush.js +60 -0
  141. package/src/dev/inject/injectShare.js +55 -0
  142. package/src/dev/inject/withInjectedDevice.js +98 -0
  143. package/src/dev/menu/BlockGallery.js +33 -0
  144. package/src/dev/menu/DevMenu.js +121 -0
  145. package/src/dev/menu/createLogBuffer.js +83 -0
  146. package/src/dev/menu/watchShake.js +52 -0
  147. package/src/dev/menu/watchShake.test.js +61 -0
  148. package/src/dev/recorder/createNativeRecorder.js +204 -0
  149. package/src/dev/recorder/createNativeRecorder.test.js +307 -0
  150. package/src/dev/redirectDevLink.js +30 -0
  151. package/src/dev/redirectDevLink.test.js +49 -0
  152. package/src/dev/steps/createStepRunner.js +184 -0
  153. package/src/dev/steps/createStepRunner.test.js +262 -0
  154. package/src/dev/steps/findBlock.js +78 -0
  155. package/src/dev/steps/isContextSettled.js +37 -0
  156. package/src/dev/steps/waitForSettle.js +39 -0
  157. package/src/dev/steps/waitUntil.js +40 -0
  158. package/src/dev/tab/DevTabClient.js +97 -0
  159. package/src/dev/tab/answerEval.js +39 -0
  160. package/src/dev/tab/answerInspect.js +45 -0
  161. package/src/dev/tab/createDevTabClient.js +272 -0
  162. package/src/dev/tab/createDevTabClient.test.js +317 -0
  163. package/src/dev/tab/describeTabError.js +35 -0
  164. package/src/dev/tab/devClientMarker.js +22 -0
  165. package/src/dev/tab/devClientMarker.test.js +23 -0
  166. package/src/dev/tab/findPageContext.js +43 -0
  167. package/src/dev/tab/getShownPage.js +42 -0
  168. package/src/dev/tab/readEventStream.js +68 -0
  169. package/src/dev/tab/reloadApp.js +42 -0
  170. package/src/dev/tab/reloadApp.test.js +115 -0
  171. package/src/device/DeviceHosts.js +32 -0
  172. package/src/device/basic/createFilesFeature.js +41 -0
  173. package/src/device/basic/createHapticsFeature.js +46 -0
  174. package/src/device/basic/createKeepAwakeFeature.js +37 -0
  175. package/src/device/basic/createPhotosFeature.js +40 -0
  176. package/src/device/basic/createRequestQueueFeature.js +37 -0
  177. package/src/device/basic/createShareFeature.js +63 -0
  178. package/src/device/basic/createShareFeature.test.js +47 -0
  179. package/src/device/basic/createSpeechFeature.js +43 -0
  180. package/src/device/basic/createUploadFeature.js +41 -0
  181. package/src/device/basic/toImagePickerOptions.js +41 -0
  182. package/src/device/basic/toPickedFile.js +54 -0
  183. package/src/device/calendar/index.js +68 -0
  184. package/src/device/contacts/index.js +67 -0
  185. package/src/device/createDevice.js +65 -0
  186. package/src/device/device.js +27 -0
  187. package/src/device/field/MapView.js +93 -0
  188. package/src/device/field/ScanCodeSheet.js +86 -0
  189. package/src/device/field/ScannerView.js +52 -0
  190. package/src/device/field/createAudioFeature.js +70 -0
  191. package/src/device/field/createBrightnessFeature.js +40 -0
  192. package/src/device/field/createCameraFeature.js +56 -0
  193. package/src/device/field/createDocumentScannerFeature.js +51 -0
  194. package/src/device/field/createLocationFeature.js +39 -0
  195. package/src/device/field/createScanRequests.js +55 -0
  196. package/src/device/field/index.js +39 -0
  197. package/src/device/field/scanRequests.js +22 -0
  198. package/src/device/injected.js +60 -0
  199. package/src/device/injected.test.js +39 -0
  200. package/src/device/nfc/index.js +89 -0
  201. package/src/device/nfc/toNfcRecord.js +44 -0
  202. package/src/device/nfc/toNfcRecord.test.js +48 -0
  203. package/src/device/tracking/createBacklog.js +63 -0
  204. package/src/device/tracking/createBacklog.test.js +47 -0
  205. package/src/device/tracking/index.js +211 -0
  206. package/src/device/tracking/index.test.js +110 -0
  207. package/src/host/createDeviceStore.js +32 -0
  208. package/src/host/createMemoryStore.js +32 -0
  209. package/src/host/createNetworkStatus.js +94 -0
  210. package/src/host/createNetworkStatus.test.js +94 -0
  211. package/src/host/createRouterAdapter.js +33 -0
  212. package/src/host/createShellFetch.js +62 -0
  213. package/src/host/createShellFetch.test.js +165 -0
  214. package/src/host/createShellGlobals.js +62 -0
  215. package/src/host/createShellHost.js +83 -0
  216. package/src/host/createShellHost.test.js +36 -0
  217. package/src/host/getOrigin.js +31 -0
  218. package/src/host/isNetworkError.js +24 -0
  219. package/src/host/isOnline.js +22 -0
  220. package/src/host/networkStatus.js +24 -0
  221. package/src/host/readServerError.js +45 -0
  222. package/src/host/readServerError.test.js +85 -0
  223. package/src/host/reportShellError.js +47 -0
  224. package/src/host/reportShellError.test.js +79 -0
  225. package/src/host/withNetworkErrors.js +38 -0
  226. package/src/host/withNetworkErrors.test.js +61 -0
  227. package/src/host/withShellHeader.js +25 -0
  228. package/src/host/withShellHeader.test.js +28 -0
  229. package/src/js/evaluateJsEntries.js +43 -0
  230. package/src/js/evaluateJsEntries.test.js +48 -0
  231. package/src/locale/createShellLocale.js +112 -0
  232. package/src/locale/createShellLocale.test.js +74 -0
  233. package/src/locale/dayjsLocales.js +165 -0
  234. package/src/locale/getDeviceLocales.js +23 -0
  235. package/src/locale/loadDayjsLocale.js +32 -0
  236. package/src/locale/resolveActiveLocale.js +46 -0
  237. package/src/locale/resolveActiveLocale.test.js +40 -0
  238. package/src/native/backgroundFetch.js +97 -0
  239. package/src/native/callEndpoint.js +63 -0
  240. package/src/native/createAppEventTrigger.js +70 -0
  241. package/src/native/currentApp.js +49 -0
  242. package/src/native/getPagePath.js +32 -0
  243. package/src/native/openPath.js +55 -0
  244. package/src/native/openPath.test.js +39 -0
  245. package/src/native/parseShellLink.js +38 -0
  246. package/src/native/parseShellLink.test.js +29 -0
  247. package/src/native/startAppServices.js +94 -0
  248. package/src/native/startAppServices.test.js +97 -0
  249. package/src/native/startShareIn.js +59 -0
  250. package/src/native/startShortcuts.js +88 -0
  251. package/src/native/startShortcuts.test.js +59 -0
  252. package/src/native/toSharePayload.js +47 -0
  253. package/src/native/toSharePayload.test.js +47 -0
  254. package/src/native/whenAppReady.js +48 -0
  255. package/src/navigation/SlotContext.js +24 -0
  256. package/src/navigation/frameSlots.js +27 -0
  257. package/src/navigation/getFrame.js +30 -0
  258. package/src/navigation/getInitialScreenOptions.js +63 -0
  259. package/src/navigation/getInitialScreenOptions.test.js +66 -0
  260. package/src/navigation/getPresentation.js +28 -0
  261. package/src/navigation/getSlotRootPath.js +27 -0
  262. package/src/navigation/getStackOptions.js +185 -0
  263. package/src/navigation/getTabHref.js +45 -0
  264. package/src/navigation/getTabIcons.js +43 -0
  265. package/src/navigation/isPageUnderHeader.js +46 -0
  266. package/src/navigation/navigation.test.js +342 -0
  267. package/src/navigation/toAppLinkPath.js +43 -0
  268. package/src/navigation/toAppLinkPath.test.js +53 -0
  269. package/src/navigation/toRouterHref.js +29 -0
  270. package/src/notifications/createNotificationsFeature.js +61 -0
  271. package/src/notifications/getCategoryAction.js +31 -0
  272. package/src/notifications/handleNotificationResponse.js +82 -0
  273. package/src/notifications/registerPushToken.js +71 -0
  274. package/src/notifications/startNotifications.js +114 -0
  275. package/src/notifications/toCategoryActions.js +42 -0
  276. package/src/notifications/toNotificationEvent.js +36 -0
  277. package/src/notifications/toScheduleRequest.js +62 -0
  278. package/src/plugins/appTypes.js +28 -0
  279. package/src/plugins/prefixTypes.js +28 -0
  280. package/src/plugins/shellPlugins.json +15 -0
  281. package/src/plugins/typeManifest.js +789 -0
  282. package/src/plugins/types.js +64 -0
  283. package/src/plugins/types.test.js +173 -0
  284. package/src/polyfills/installGetRandomValues.js +28 -0
  285. package/src/queue/createRequestQueue.js +198 -0
  286. package/src/queue/createRequestQueue.test.js +215 -0
  287. package/src/queue/createSqliteQueueStore.js +121 -0
  288. package/src/queue/summarizeQueue.js +39 -0
  289. package/src/session/createAppSession.js +323 -0
  290. package/src/session/createAppSession.test.js +391 -0
  291. package/src/session/getPageView.js +59 -0
  292. package/src/session/getPageView.test.js +137 -0
  293. package/src/sync/encodePagePath.js +27 -0
  294. package/src/sync/fetchPage.js +72 -0
  295. package/src/sync/fetchPage.test.js +84 -0
  296. package/src/sync/fetchRoot.js +43 -0
  297. package/src/sync/fetchUser.js +33 -0
  298. package/src/sync/runLimited.js +36 -0
  299. package/src/sync/toRootConfig.js +35 -0
  300. package/src/test/createFakeNetwork.js +40 -0
  301. package/src/test/createFakeSecureStore.js +40 -0
  302. package/src/test/createFakeServer.js +98 -0
  303. package/src/test/createMemoryCacheStore.js +59 -0
  304. package/src/test/createNetworkError.js +24 -0
  305. package/src/test/expoImage.js +42 -0
  306. package/src/test/expoNotifications.js +40 -0
  307. package/src/test/expoQuickActions.js +22 -0
  308. package/src/test/expoSpeechRecognition.js +31 -0
  309. package/src/test/expoTaskManager.js +25 -0
  310. package/src/test/expoUi.js +55 -0
  311. package/src/test/expoUiBottomSheet.js +64 -0
  312. package/src/test/expoUiDateTimePicker.js +26 -0
  313. package/src/test/expoUiMenu.js +46 -0
  314. package/src/test/expoUiSegmentedControl.js +47 -0
  315. package/src/test/expoUiSwiftUi.js +62 -0
  316. package/src/test/expoUiSwiftUiModifiers.js +23 -0
  317. package/src/test/expoWebBrowser.js +23 -0
  318. package/src/test/fixtures.js +98 -0
  319. package/src/test/nativeModule.js +33 -0
  320. package/src/test/renderPageScreen.js +109 -0
  321. package/src/types/checkTypeSet.js +39 -0
  322. package/src/types/collectPageTypes.js +49 -0
  323. package/src/types/collectPageTypes.test.js +90 -0
  324. package/src/types/findMissingPageTypes.js +40 -0
  325. package/src/version/compareVersions.js +38 -0
  326. package/src/version/isShellTooOld.js +30 -0
  327. package/src/version/shellVersion.js +21 -0
  328. package/src/version/shellVersion.test.js +32 -0
  329. package/src/widgets/fetchWidgetData.js +59 -0
  330. package/src/widgets/index.js +156 -0
  331. package/src/widgets/refreshWidgets.js +31 -0
  332. package/withExcludedNativeModules.js +93 -0
  333. package/dist/location.js +0 -33
  334. package/dist/public/apple-touch-icon.png +0 -0
  335. package/dist/public/icon-32.png +0 -0
  336. package/dist/public/icon-512.png +0 -0
  337. package/dist/public/icon.svg +0 -17
  338. package/dist/public/logo-dark-theme.png +0 -0
  339. package/dist/public/logo-light-theme.png +0 -0
  340. package/dist/public/logo-square-dark-theme.png +0 -0
  341. package/dist/public/logo-square-light-theme.png +0 -0
  342. package/dist/public/manifest.webmanifest +0 -16
  343. package/dist/shell/246.93432ff68186aae6c87b.js +0 -2
  344. package/dist/shell/246.93432ff68186aae6c87b.js.LICENSE.txt +0 -8
  345. package/dist/shell/454.b597ca21366afda000af.js +0 -2
  346. package/dist/shell/454.b597ca21366afda000af.js.LICENSE.txt +0 -14
  347. package/dist/shell/484.d645e6c09079f45ce044.js +0 -1
  348. package/dist/shell/706.a66931e02a549fd269bd.js +0 -2
  349. package/dist/shell/706.a66931e02a549fd269bd.js.LICENSE.txt +0 -8
  350. package/dist/shell/9.78be8c1b6c3986f404fc.js +0 -2
  351. package/dist/shell/9.78be8c1b6c3986f404fc.js.LICENSE.txt +0 -45
  352. package/dist/shell/922.43b7a3ebe22ef05b3049.js +0 -2
  353. package/dist/shell/922.43b7a3ebe22ef05b3049.js.LICENSE.txt +0 -14
  354. package/dist/shell/index.html +0 -57
  355. package/dist/shell/main.bde87d1433f0625faed6.js +0 -1
  356. package/dist/shell/runtime.1ead93159ba56c05b969.js +0 -1
@@ -0,0 +1,391 @@
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 createNetworkError from '../test/createNetworkError.js';
23
+ import createMemoryStore from '../host/createMemoryStore.js';
24
+ import { createPages, createRoot } from '../test/fixtures.js';
25
+
26
+ const apiBase = 'https://field.example.com';
27
+ const app = { id: 'field', name: 'Field', urls: { production: apiBase } };
28
+
29
+ function createServer(overrides = {}) {
30
+ return createFakeServer({
31
+ caller: 'c-anon; roles=r0',
32
+ root: createRoot(),
33
+ rootEtag: '"root-1"',
34
+ pages: createPages([
35
+ ['today', 'today', '"p-today-1"'],
36
+ ['jobs', 'jobs', '"p-jobs-1"'],
37
+ ['jobs/{job_id}', 'job', '"p-job-1"'],
38
+ ]),
39
+ ...overrides,
40
+ });
41
+ }
42
+
43
+ function createSession({
44
+ server,
45
+ cacheStore = createMemoryCacheStore(),
46
+ kv = createMemoryStore(),
47
+ }) {
48
+ const tokenStore = { get: () => 'token-1' };
49
+ const session = createAppSession({
50
+ apiBase,
51
+ app,
52
+ cacheStore,
53
+ fetch: server.fetch,
54
+ kv,
55
+ tokenStore,
56
+ });
57
+ return { cacheStore, kv, session };
58
+ }
59
+
60
+ function callsTo(server, pathname) {
61
+ return server.fetch.mock.calls.filter(([url]) => new URL(url).pathname === pathname);
62
+ }
63
+
64
+ test('a first start fetches the root, the user and every page in the index', async () => {
65
+ const server = createServer({ user: { id: 'u1' } });
66
+ const { session } = createSession({ server });
67
+ expect(session.getState().status).toBe('loading');
68
+ await session.start();
69
+ const state = session.getState();
70
+ expect(state.status).toBe('ready');
71
+ expect(state.user).toEqual({ id: 'u1' });
72
+ expect(state.buildId).toBe('build-1');
73
+ expect(Object.keys(state.pages).sort()).toEqual(['job', 'jobs', 'today']);
74
+ expect(state.pages.job).toEqual(
75
+ expect.objectContaining({ hash: 'h-job-1', etag: '"p-job-1"', buildId: 'build-1' })
76
+ );
77
+ expect(state.rootConfig.pagePaths).toEqual({
78
+ today: 'today',
79
+ jobs: 'jobs',
80
+ job: 'jobs/{job_id}',
81
+ });
82
+ expect(callsTo(server, '/api/page/jobs/%7Bjob_id%7D')).toHaveLength(1);
83
+ });
84
+
85
+ test('every shell call carries the session token as a bearer token', async () => {
86
+ const server = createServer();
87
+ const { session } = createSession({ server });
88
+ await session.start();
89
+ server.fetch.mock.calls.forEach(([, init]) => {
90
+ expect(header(init, 'Authorization')).toBe('Bearer token-1');
91
+ });
92
+ });
93
+
94
+ test('pages are fetched at most 4 at a time', async () => {
95
+ const pageIndex = Array.from({ length: 10 }, (_, i) => ({
96
+ path: `p${i}`,
97
+ pageId: `p${i}`,
98
+ hash: `h${i}`,
99
+ typesKey: 'carried',
100
+ }));
101
+ const server = createServer({
102
+ root: createRoot({ pageIndex }),
103
+ pages: createPages(pageIndex.map((entry) => [entry.path, entry.pageId, `"${entry.hash}"`])),
104
+ });
105
+ let inFlight = 0;
106
+ let most = 0;
107
+ const serve = server.fetch.getMockImplementation();
108
+ server.fetch.mockImplementation(async (url, init) => {
109
+ inFlight += 1;
110
+ most = Math.max(most, inFlight);
111
+ await new Promise((resolve) => setTimeout(resolve, 5));
112
+ inFlight -= 1;
113
+ return serve(url, init);
114
+ });
115
+ const { session } = createSession({ server });
116
+ await session.start();
117
+ expect(Object.keys(session.getState().pages)).toHaveLength(10);
118
+ expect(most).toBeLessThanOrEqual(4);
119
+ });
120
+
121
+ describe('cache keying', () => {
122
+ test('the cache is kept per app, caller and roles', async () => {
123
+ const server = createServer({ caller: 'c-ada; session=s1; roles=r-staff' });
124
+ const { cacheStore, session } = createSession({ server });
125
+ await session.start();
126
+ expect(session.getState().cacheKey).toBe('field:c-ada:r-staff');
127
+ expect([...cacheStore.roots.keys()]).toEqual(['field:c-ada:r-staff']);
128
+ expect([...cacheStore.pages.get('field:c-ada:r-staff').keys()].sort()).toEqual([
129
+ 'job',
130
+ 'jobs',
131
+ 'today',
132
+ ]);
133
+ });
134
+
135
+ test('a start renders the last cache at once, before the server answers', async () => {
136
+ const server = createServer({ caller: 'c-ada; roles=r-staff' });
137
+ const cacheStore = createMemoryCacheStore();
138
+ const kv = createMemoryStore();
139
+ await createSession({ server, cacheStore, kv }).session.start();
140
+
141
+ let answer;
142
+ const offline = { fetch: jest.fn(() => new Promise((resolve) => (answer = resolve))) };
143
+ const { session } = createSession({ server: offline, cacheStore, kv });
144
+ const started = session.start();
145
+ await new Promise((resolve) => setTimeout(resolve, 0));
146
+ const state = session.getState();
147
+ expect(state.status).toBe('ready');
148
+ expect(state.cacheKey).toBe('field:c-ada:r-staff');
149
+ expect(Object.keys(state.pages).sort()).toEqual(['job', 'jobs', 'today']);
150
+ expect(offline.fetch).toHaveBeenCalled();
151
+ answer(Promise.reject(createNetworkError()));
152
+ await started;
153
+ });
154
+
155
+ test('with no signal the app keeps its cached pages', async () => {
156
+ const server = createServer();
157
+ const cacheStore = createMemoryCacheStore();
158
+ const kv = createMemoryStore();
159
+ await createSession({ server, cacheStore, kv }).session.start();
160
+ const offline = {
161
+ fetch: jest.fn(async () => Promise.reject(createNetworkError())),
162
+ };
163
+ const { session } = createSession({ server: offline, cacheStore, kv });
164
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
165
+ await session.start();
166
+ expect(session.getState().status).toBe('ready');
167
+ expect(Object.keys(session.getState().pages)).toHaveLength(3);
168
+ // No signal is not a fault.
169
+ expect(consoleError).not.toHaveBeenCalled();
170
+ consoleError.mockRestore();
171
+ });
172
+
173
+ test('a sync the server fails while the cache serves the app is reported', async () => {
174
+ const server = createServer();
175
+ const cacheStore = createMemoryCacheStore();
176
+ const kv = createMemoryStore();
177
+ await createSession({ server, cacheStore, kv }).session.start();
178
+ const failing = {
179
+ fetch: jest.fn(async () => ({
180
+ ok: false,
181
+ status: 500,
182
+ headers: { get: () => null },
183
+ json: async () => ({ message: 'Internal server error.' }),
184
+ })),
185
+ };
186
+ const { session } = createSession({ server: failing, cacheStore, kv });
187
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
188
+ await session.start();
189
+ expect(session.getState().status).toBe('ready');
190
+ // No page has made a core yet, so the report goes to the device console.
191
+ expect(consoleError).toHaveBeenCalledWith(
192
+ expect.objectContaining({ message: 'Internal server error.', status: 500 })
193
+ );
194
+ consoleError.mockRestore();
195
+ });
196
+
197
+ test('with no signal and no cache the app shows the error', async () => {
198
+ const offline = {
199
+ fetch: jest.fn(async () => Promise.reject(createNetworkError())),
200
+ };
201
+ const { session } = createSession({ server: offline });
202
+ await session.start();
203
+ expect(session.getState().status).toBe('error');
204
+ expect(session.getState().error.message).toBe('Network request failed');
205
+ });
206
+
207
+ test('an app without mobile pages shows an error', async () => {
208
+ const server = createServer();
209
+ server.fetch.mockImplementation(async () => ({
210
+ ok: false,
211
+ status: 404,
212
+ headers: { get: () => null },
213
+ json: async () => ({}),
214
+ }));
215
+ const { session } = createSession({ server });
216
+ const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
217
+ await session.start();
218
+ expect(session.getState().error.message).toBe('This app has no mobile pages.');
219
+ expect(consoleError).toHaveBeenCalledWith(session.getState().error);
220
+ consoleError.mockRestore();
221
+ });
222
+ });
223
+
224
+ describe('ETag reuse', () => {
225
+ test('a sync revalidates the root with its ETag and keeps it on a 304', async () => {
226
+ const server = createServer();
227
+ const { session } = createSession({ server });
228
+ await session.start();
229
+ const { root, rootConfig } = session.getState();
230
+ server.fetch.mockClear();
231
+ await session.sync();
232
+ const [rootCall] = callsTo(server, '/api/mobile/root');
233
+ expect(header(rootCall[1], 'If-None-Match')).toBe('"root-1"');
234
+ expect(session.getState().root).toBe(root);
235
+ expect(session.getState().rootConfig).toBe(rootConfig);
236
+ expect(server.fetch.mock.calls.filter(([url]) => url.includes('/api/page/'))).toHaveLength(0);
237
+ });
238
+
239
+ test('only pages whose hash changed are fetched again, revalidated with their ETag', async () => {
240
+ const server = createServer();
241
+ const { session } = createSession({ server });
242
+ await session.start();
243
+ const before = session.getState().pages;
244
+ server.state.root = createRoot({
245
+ buildId: 'build-2',
246
+ pageIndex: [
247
+ { path: 'today', pageId: 'today', hash: 'h-today-2', typesKey: 'carried' },
248
+ { path: 'jobs', pageId: 'jobs', hash: 'h-jobs-1', typesKey: 'carried' },
249
+ ],
250
+ });
251
+ server.state.rootEtag = '"root-2"';
252
+ server.state.pages.today = {
253
+ pageConfig: { ...before.today.pageConfig, v: 2 },
254
+ etag: '"p-today-2"',
255
+ };
256
+ server.fetch.mockClear();
257
+ await session.sync();
258
+ const pageCalls = server.fetch.mock.calls.filter(([url]) => url.includes('/api/page/'));
259
+ expect(pageCalls.map(([url]) => url)).toEqual([`${apiBase}/api/page/today`]);
260
+ expect(header(pageCalls[0][1], 'If-None-Match')).toBe('"p-today-1"');
261
+ const state = session.getState();
262
+ expect(state.buildId).toBe('build-2');
263
+ expect(state.pages.today.pageConfig.v).toBe(2);
264
+ expect(state.pages.jobs).toBe(before.jobs);
265
+ expect(state.pages.job).toBeUndefined();
266
+ });
267
+
268
+ test('a page answered with a 304 is kept under its new hash', async () => {
269
+ const server = createServer();
270
+ const { session } = createSession({ server });
271
+ await session.start();
272
+ server.state.root = createRoot({
273
+ pageIndex: [{ path: 'today', pageId: 'today', hash: 'h-today-2', typesKey: 'carried' }],
274
+ });
275
+ server.state.rootEtag = '"root-2"';
276
+ await session.sync();
277
+ expect(session.getState().pages.today.hash).toBe('h-today-2');
278
+ expect(session.getState().pages.today.etag).toBe('"p-today-1"');
279
+ });
280
+ });
281
+
282
+ describe('caller changes', () => {
283
+ test("a root answered as another caller resets the app onto that caller's cache", async () => {
284
+ const server = createServer({ caller: 'c-ada; roles=r-staff' });
285
+ const { cacheStore, session } = createSession({ server });
286
+ await session.start();
287
+ const { generation, homeRequests, lowdefy } = session.getState();
288
+ server.state.caller = 'c-ben; roles=r-admin';
289
+ server.state.root = createRoot({
290
+ pageIndex: [{ path: 'today', pageId: 'today', hash: 'h-today-admin', typesKey: 'carried' }],
291
+ });
292
+ server.state.rootEtag = '"root-admin"';
293
+ await session.sync();
294
+ const state = session.getState();
295
+ expect(state.cacheKey).toBe('field:c-ben:r-admin');
296
+ expect(state.generation).toBe(generation + 1);
297
+ expect(state.homeRequests).toBe(homeRequests + 1);
298
+ expect(state.lowdefy).not.toBe(lowdefy);
299
+ expect(Object.keys(state.pages)).toEqual(['today']);
300
+ expect(state.pages.today.hash).toBe('h-today-admin');
301
+ // The first caller's cache is kept for when they come back.
302
+ expect([...cacheStore.roots.keys()].sort()).toEqual([
303
+ 'field:c-ada:r-staff',
304
+ 'field:c-ben:r-admin',
305
+ ]);
306
+ expect(session.takeHomeRequest()).toBe(true);
307
+ expect(session.takeHomeRequest()).toBe(false);
308
+ });
309
+
310
+ test('onCallerChanged drops the screens, refetches the root under the new key and goes home', async () => {
311
+ const server = createServer({ caller: 'c-ada; roles=r-staff' });
312
+ const { session } = createSession({ server });
313
+ await session.start();
314
+ const before = session.getState();
315
+ const statuses = [];
316
+ session.subscribe(() => statuses.push(session.getState().status));
317
+ server.state.caller = 'c-anon; roles=r0';
318
+ const reset = session.handleCallerChanged();
319
+ expect(session.getState().status).toBe('loading');
320
+ await reset;
321
+ const state = session.getState();
322
+ expect(statuses[0]).toBe('loading');
323
+ expect(state.status).toBe('ready');
324
+ expect(state.cacheKey).toBe('field:c-anon:r0');
325
+ expect(state.generation).toBeGreaterThan(before.generation);
326
+ expect(state.homeRequests).toBeGreaterThan(before.homeRequests);
327
+ expect(session.takeHomeRequest()).toBe(true);
328
+ });
329
+ });
330
+
331
+ test('onBuildChanged syncs and rebuilds every screen on the new build', async () => {
332
+ const server = createServer();
333
+ const { session } = createSession({ server });
334
+ await session.start();
335
+ const before = session.getState();
336
+ server.state.root = createRoot({ buildId: 'build-2' });
337
+ server.state.rootEtag = '"root-2"';
338
+ await session.handleBuildChanged();
339
+ const state = session.getState();
340
+ expect(state.buildId).toBe('build-2');
341
+ expect(state.generation).toBe(before.generation + 1);
342
+ expect(state.lowdefy).toEqual({ buildId: 'build-2' });
343
+ expect(state.homeRequests).toBe(before.homeRequests);
344
+ });
345
+
346
+ describe('loadPage', () => {
347
+ test('a cached page with a hash is not fetched again on a visit', async () => {
348
+ const server = createServer();
349
+ const { session } = createSession({ server });
350
+ await session.start();
351
+ server.fetch.mockClear();
352
+ const entry = session.getState().root.pageIndex[0];
353
+ await session.loadPage({ entry, path: 'today' });
354
+ expect(server.fetch).not.toHaveBeenCalled();
355
+ });
356
+
357
+ test('a dev page (no hash) is fetched on every visit by its path', async () => {
358
+ const pageIndex = [{ path: 'jobs/{job_id}', pageId: 'job' }];
359
+ const server = createServer({
360
+ root: createRoot({ pageIndex, typeSets: {} }),
361
+ pages: createPages([['jobs/7', 'job', '"dev-1"']]),
362
+ });
363
+ const { session } = createSession({ server });
364
+ await session.start();
365
+ expect(session.getState().pages).toEqual({});
366
+ await session.loadPage({ entry: pageIndex[0], path: 'jobs/7' });
367
+ await session.loadPage({ entry: pageIndex[0], path: 'jobs/7' });
368
+ const pageCalls = callsTo(server, '/api/page/jobs/7');
369
+ expect(pageCalls).toHaveLength(2);
370
+ expect(header(pageCalls[1][1], 'If-None-Match')).toBe('"dev-1"');
371
+ expect(session.getState().pages.job.pageConfig.pageId).toBe('job');
372
+ });
373
+
374
+ test('a dynamic page is fetched on every visit', async () => {
375
+ const server = createServer();
376
+ server.state.pages.today.pageConfig.dynamic = true;
377
+ const { session } = createSession({ server });
378
+ await session.start();
379
+ server.fetch.mockClear();
380
+ await session.loadPage({ entry: session.getState().root.pageIndex[0], path: 'today' });
381
+ expect(callsTo(server, '/api/page/today')).toHaveLength(1);
382
+ });
383
+ });
384
+
385
+ test('fetchOtherPage reports a web page for the shell to open in the browser', async () => {
386
+ const server = createServer({ otherSurfaces: { reports: { pageConfig: null, surface: 'web' } } });
387
+ const { session } = createSession({ server });
388
+ await session.start();
389
+ expect(await session.fetchOtherPage('reports')).toEqual({ notFound: true, surface: 'web' });
390
+ expect(await session.fetchOtherPage('nothing')).toEqual({ notFound: true, surface: null });
391
+ });
@@ -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,72 @@
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 { LowdefyInternalError, UserError } from '@lowdefy/errors';
18
+ import { type } from '@lowdefy/helpers';
19
+
20
+ import encodePagePath from './encodePagePath.js';
21
+ import readServerError from '../host/readServerError.js';
22
+
23
+ // GET /api/page/<path> for a page the shell keeps, revalidated with the ETag of the copy it holds.
24
+ // Returns { notModified } on a 304, the page { buildId, etag, pageConfig } on a 200, and
25
+ // { surface } when the page is for another surface (a web page: the shell opens it in the
26
+ // browser). Any other answer throws, so a page without a pageConfig is never cached.
27
+ async function fetchPage({ apiBase, etag, fetchWithAuth, path }) {
28
+ const headers = etag ? { 'If-None-Match': etag } : {};
29
+ const res = await fetchWithAuth(`${apiBase}/api/page/${encodePagePath(path)}`, {
30
+ method: 'GET',
31
+ headers,
32
+ });
33
+ if (res.status === 304) {
34
+ return { notModified: true };
35
+ }
36
+ if (res.status === 404) {
37
+ const body = await res.json().catch(() => ({}));
38
+ return { notFound: true, surface: body?.surface ?? null };
39
+ }
40
+ if (!res.ok) {
41
+ throw await readServerError({
42
+ res,
43
+ message: `The app's server answered ${res.status} to the page request for "${path}".`,
44
+ });
45
+ }
46
+ const body = await res.json();
47
+ // lowdefy dev builds a page when it is first opened, and answers { installing } while it installs
48
+ // the plugins the page needs. Not a fault: the page screen shows this with "Try again".
49
+ if (body?.installing === true) {
50
+ const packages =
51
+ type.isArray(body.packages) && body.packages.length > 0
52
+ ? body.packages.join(', ')
53
+ : 'plugins';
54
+ throw new UserError(
55
+ `Lowdefy dev is installing ${packages} for "/${path}". Try again once the install finishes.`
56
+ );
57
+ }
58
+ if (!type.isObject(body?.pageConfig)) {
59
+ throw new LowdefyInternalError(
60
+ `The app's server answered the page request for "/${path}" with no page config.`
61
+ );
62
+ }
63
+ return {
64
+ page: {
65
+ buildId: body.buildId ?? body.pageConfig?._buildId ?? null,
66
+ etag: res.headers.get('etag'),
67
+ pageConfig: body.pageConfig,
68
+ },
69
+ };
70
+ }
71
+
72
+ export default fetchPage;