@belte/belte 0.19.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 (326) hide show
  1. package/CHANGELOG.md +313 -0
  2. package/LICENSE +21 -0
  3. package/README.md +559 -0
  4. package/bin/belte.ts +183 -0
  5. package/package.json +110 -0
  6. package/src/App.svelte +31 -0
  7. package/src/appEntry.ts +151 -0
  8. package/src/assets/app.html +14 -0
  9. package/src/belteResolverPlugin.ts +858 -0
  10. package/src/build.ts +147 -0
  11. package/src/buildCli.ts +129 -0
  12. package/src/buildDisconnected.ts +122 -0
  13. package/src/bundleApp.ts +149 -0
  14. package/src/bundleDisconnectedEntry.ts +17 -0
  15. package/src/cliEntry.ts +25 -0
  16. package/src/clientBuildPlugins.ts +41 -0
  17. package/src/clientEntry.ts +7 -0
  18. package/src/compile.ts +64 -0
  19. package/src/controlServerWorker.ts +422 -0
  20. package/src/dedupeSveltePlugin.ts +66 -0
  21. package/src/devEntry.ts +169 -0
  22. package/src/discoveryEntry.ts +81 -0
  23. package/src/lib/browser/applyStreamedResolution.ts +33 -0
  24. package/src/lib/browser/cacheEntryFromSnapshot.ts +48 -0
  25. package/src/lib/browser/flushUnresolvedPlaceholders.ts +16 -0
  26. package/src/lib/browser/installStreamingPlaceholders.ts +32 -0
  27. package/src/lib/browser/openResolveStream.ts +42 -0
  28. package/src/lib/browser/page.svelte.ts +258 -0
  29. package/src/lib/browser/pageStreamController.ts +17 -0
  30. package/src/lib/browser/refetchPlaceholder.ts +12 -0
  31. package/src/lib/browser/remoteProxy.ts +37 -0
  32. package/src/lib/browser/socketChannel.ts +192 -0
  33. package/src/lib/browser/socketProxy.ts +57 -0
  34. package/src/lib/browser/startClient.ts +153 -0
  35. package/src/lib/browser/subscribe.ts +131 -0
  36. package/src/lib/browser/types/Errors.ts +9 -0
  37. package/src/lib/browser/types/Layouts.ts +7 -0
  38. package/src/lib/browser/types/Pages.ts +7 -0
  39. package/src/lib/browser/types/StreamingDeferred.ts +9 -0
  40. package/src/lib/bundle/BundleMenu.ts +11 -0
  41. package/src/lib/bundle/BundleMenuItem.ts +24 -0
  42. package/src/lib/bundle/BundleWindow.ts +36 -0
  43. package/src/lib/bundle/WEBVIEW_BUILD_REVISION.ts +9 -0
  44. package/src/lib/bundle/WEBVIEW_VERSION.ts +7 -0
  45. package/src/lib/bundle/bindConnectedFlag.ts +29 -0
  46. package/src/lib/bundle/bindRequestNavigate.ts +31 -0
  47. package/src/lib/bundle/buildWebviewLib.ts +111 -0
  48. package/src/lib/bundle/disconnected.css +9 -0
  49. package/src/lib/bundle/disconnected.svelte +386 -0
  50. package/src/lib/bundle/ensureWebviewLib.ts +20 -0
  51. package/src/lib/bundle/exitWithParent.ts +28 -0
  52. package/src/lib/bundle/infoPlist.ts +46 -0
  53. package/src/lib/bundle/installDownloads.ts +24 -0
  54. package/src/lib/bundle/installMacMenu.ts +39 -0
  55. package/src/lib/bundle/listenLocalControlServer.ts +19 -0
  56. package/src/lib/bundle/native/belteMenu.mm +422 -0
  57. package/src/lib/bundle/native/webview.h +4557 -0
  58. package/src/lib/bundle/onMenu.ts +41 -0
  59. package/src/lib/bundle/openWebview.ts +104 -0
  60. package/src/lib/bundle/pngToIcns.ts +47 -0
  61. package/src/lib/bundle/probeBelteServer.ts +34 -0
  62. package/src/lib/bundle/resolveServerBinary.ts +12 -0
  63. package/src/lib/bundle/resolveWebviewLib.ts +53 -0
  64. package/src/lib/bundle/serverBinaryFilename.ts +8 -0
  65. package/src/lib/bundle/signMacApp.ts +35 -0
  66. package/src/lib/bundle/spawnEmbeddedServer.ts +65 -0
  67. package/src/lib/bundle/stableLocalPort.ts +19 -0
  68. package/src/lib/bundle/waitForServer.ts +23 -0
  69. package/src/lib/bundle/webviewCachePath.ts +23 -0
  70. package/src/lib/bundle/webviewLibName.ts +11 -0
  71. package/src/lib/cli/connectToServer.ts +23 -0
  72. package/src/lib/cli/createClient.ts +170 -0
  73. package/src/lib/cli/dispatchCommand.ts +71 -0
  74. package/src/lib/cli/loadEnvFromBinaryDir.ts +16 -0
  75. package/src/lib/cli/parseArgvForRpc.ts +97 -0
  76. package/src/lib/cli/printHelp.ts +119 -0
  77. package/src/lib/cli/printSessionHelp.ts +27 -0
  78. package/src/lib/cli/printSessionStatus.ts +21 -0
  79. package/src/lib/cli/printTrimmed.ts +8 -0
  80. package/src/lib/cli/printValue.ts +10 -0
  81. package/src/lib/cli/resolveCliTarget.ts +48 -0
  82. package/src/lib/cli/runCli.ts +139 -0
  83. package/src/lib/cli/runSession.ts +105 -0
  84. package/src/lib/cli/startLocalInstance.ts +14 -0
  85. package/src/lib/cli/tokenizeLine.ts +51 -0
  86. package/src/lib/cli/types/CliManifest.ts +9 -0
  87. package/src/lib/cli/types/CliManifestEntry.ts +17 -0
  88. package/src/lib/cli/types/CliTarget.ts +13 -0
  89. package/src/lib/mcp/annotationsForMethod.ts +29 -0
  90. package/src/lib/mcp/createMcpResourceServer.ts +101 -0
  91. package/src/lib/mcp/createMcpServer.ts +42 -0
  92. package/src/lib/mcp/dispatchMcpRequest.ts +146 -0
  93. package/src/lib/mcp/mcpResourceServerSlot.ts +18 -0
  94. package/src/lib/mcp/mcpSurface.ts +265 -0
  95. package/src/lib/mcp/toolResultFromResponse.ts +66 -0
  96. package/src/lib/mcp/types/JsonRpcRequest.ts +12 -0
  97. package/src/lib/mcp/types/JsonRpcResponse.ts +20 -0
  98. package/src/lib/mcp/types/McpResourceContents.ts +10 -0
  99. package/src/lib/mcp/types/McpResourceDescriptor.ts +6 -0
  100. package/src/lib/mcp/types/McpResourceServer.ts +12 -0
  101. package/src/lib/mcp/types/McpServer.ts +9 -0
  102. package/src/lib/mcp/types/McpServerOptions.ts +16 -0
  103. package/src/lib/server/AppModule.ts +33 -0
  104. package/src/lib/server/DELETE.ts +9 -0
  105. package/src/lib/server/GET.ts +9 -0
  106. package/src/lib/server/HEAD.ts +9 -0
  107. package/src/lib/server/PATCH.ts +9 -0
  108. package/src/lib/server/POST.ts +9 -0
  109. package/src/lib/server/PUT.ts +9 -0
  110. package/src/lib/server/agent.ts +76 -0
  111. package/src/lib/server/appDataDir.ts +15 -0
  112. package/src/lib/server/cli/buildEnvContent.ts +19 -0
  113. package/src/lib/server/cli/createTarGz.ts +76 -0
  114. package/src/lib/server/cli/handleCliDownload.ts +153 -0
  115. package/src/lib/server/cli/handleCliInstall.ts +37 -0
  116. package/src/lib/server/cli/installScript.ts +29 -0
  117. package/src/lib/server/cli/maxSourceMtime.ts +26 -0
  118. package/src/lib/server/cookies.ts +29 -0
  119. package/src/lib/server/env.ts +50 -0
  120. package/src/lib/server/error.ts +70 -0
  121. package/src/lib/server/json.ts +28 -0
  122. package/src/lib/server/jsonl.ts +46 -0
  123. package/src/lib/server/prompts/definePrompt.ts +20 -0
  124. package/src/lib/server/prompts/promptRegistry.ts +9 -0
  125. package/src/lib/server/prompts/registerPrompt.ts +6 -0
  126. package/src/lib/server/prompts/renderPromptTemplate.ts +16 -0
  127. package/src/lib/server/prompts/types/Prompt.ts +13 -0
  128. package/src/lib/server/prompts/types/PromptOptions.ts +12 -0
  129. package/src/lib/server/prompts/types/PromptRegistryEntry.ts +13 -0
  130. package/src/lib/server/prompts/types/PromptRoutes.ts +10 -0
  131. package/src/lib/server/redirect.ts +42 -0
  132. package/src/lib/server/request.ts +18 -0
  133. package/src/lib/server/rpc/defineVerb.ts +133 -0
  134. package/src/lib/server/rpc/dispatchVerbInProcess.ts +46 -0
  135. package/src/lib/server/rpc/findVerbByCommandName.ts +18 -0
  136. package/src/lib/server/rpc/parseArgs.ts +95 -0
  137. package/src/lib/server/rpc/registerVerb.ts +6 -0
  138. package/src/lib/server/rpc/types/RemoteHandler.ts +27 -0
  139. package/src/lib/server/rpc/types/RemoteRoutes.ts +13 -0
  140. package/src/lib/server/rpc/types/TypedResponse.ts +18 -0
  141. package/src/lib/server/rpc/types/VerbHelper.ts +68 -0
  142. package/src/lib/server/rpc/types/VerbRegistryEntry.ts +29 -0
  143. package/src/lib/server/rpc/unprocessed.ts +14 -0
  144. package/src/lib/server/rpc/verbRegistry.ts +11 -0
  145. package/src/lib/server/runtime/DEFAULT_PORT.ts +6 -0
  146. package/src/lib/server/runtime/DEV_REBUILD_MESSAGE.ts +4 -0
  147. package/src/lib/server/runtime/DEV_RELOAD_CLIENT_SCRIPT.ts +29 -0
  148. package/src/lib/server/runtime/acceptsZstd.ts +8 -0
  149. package/src/lib/server/runtime/buildOpenApiSpec.ts +106 -0
  150. package/src/lib/server/runtime/cacheControlForAsset.ts +22 -0
  151. package/src/lib/server/runtime/containsTraversal.ts +37 -0
  152. package/src/lib/server/runtime/createAssetHeaderCache.ts +35 -0
  153. package/src/lib/server/runtime/createPublicAssetServer.ts +63 -0
  154. package/src/lib/server/runtime/createRouteDispatcher.ts +100 -0
  155. package/src/lib/server/runtime/createServer.ts +692 -0
  156. package/src/lib/server/runtime/devReloadResponse.ts +35 -0
  157. package/src/lib/server/runtime/disableIdleTimeoutForStream.ts +27 -0
  158. package/src/lib/server/runtime/envSchemaStore.ts +15 -0
  159. package/src/lib/server/runtime/findOpenPort.ts +35 -0
  160. package/src/lib/server/runtime/getActiveServer.ts +6 -0
  161. package/src/lib/server/runtime/globToPathSet.ts +29 -0
  162. package/src/lib/server/runtime/inProcessServer.ts +20 -0
  163. package/src/lib/server/runtime/internalErrorResponse.ts +25 -0
  164. package/src/lib/server/runtime/isCrossOriginUpgrade.ts +19 -0
  165. package/src/lib/server/runtime/listenOnOpenPort.ts +36 -0
  166. package/src/lib/server/runtime/logExposedSurfaces.ts +162 -0
  167. package/src/lib/server/runtime/mimeForExtension.ts +20 -0
  168. package/src/lib/server/runtime/parseIdleTimeout.ts +10 -0
  169. package/src/lib/server/runtime/parsePort.ts +11 -0
  170. package/src/lib/server/runtime/registryManifests.ts +66 -0
  171. package/src/lib/server/runtime/requestContext.ts +5 -0
  172. package/src/lib/server/runtime/resolveStreamResponse.ts +29 -0
  173. package/src/lib/server/runtime/runWithRequestScope.ts +57 -0
  174. package/src/lib/server/runtime/safeJsonForScript.ts +17 -0
  175. package/src/lib/server/runtime/serializeCacheSnapshot.ts +45 -0
  176. package/src/lib/server/runtime/serverSlot.ts +13 -0
  177. package/src/lib/server/runtime/setActiveServer.ts +6 -0
  178. package/src/lib/server/runtime/snapshotEntryFromCache.ts +81 -0
  179. package/src/lib/server/runtime/streamCacheResolutions.ts +37 -0
  180. package/src/lib/server/runtime/streamFromIterator.ts +86 -0
  181. package/src/lib/server/runtime/streamStash.ts +64 -0
  182. package/src/lib/server/runtime/types/Assets.ts +1 -0
  183. package/src/lib/server/runtime/types/RequestStore.ts +27 -0
  184. package/src/lib/server/runtime/withResponseDefaults.ts +24 -0
  185. package/src/lib/server/server.ts +32 -0
  186. package/src/lib/server/socket.ts +31 -0
  187. package/src/lib/server/sockets/createSocketDispatcher.ts +311 -0
  188. package/src/lib/server/sockets/defineSocket.ts +167 -0
  189. package/src/lib/server/sockets/lookupSocket.ts +6 -0
  190. package/src/lib/server/sockets/recentHistory.ts +11 -0
  191. package/src/lib/server/sockets/registerSocket.ts +6 -0
  192. package/src/lib/server/sockets/socketOperations.ts +35 -0
  193. package/src/lib/server/sockets/socketRegistry.ts +9 -0
  194. package/src/lib/server/sockets/types/Socket.ts +21 -0
  195. package/src/lib/server/sockets/types/SocketClientFrame.ts +18 -0
  196. package/src/lib/server/sockets/types/SocketOperation.ts +22 -0
  197. package/src/lib/server/sockets/types/SocketOptions.ts +22 -0
  198. package/src/lib/server/sockets/types/SocketRegistryEntry.ts +17 -0
  199. package/src/lib/server/sockets/types/SocketRoutes.ts +10 -0
  200. package/src/lib/server/sockets/types/SocketServerFrame.ts +15 -0
  201. package/src/lib/server/sse.ts +53 -0
  202. package/src/lib/shared/BELTE_PACKAGE_NAME.ts +7 -0
  203. package/src/lib/shared/CACHE_CONTROL_VALUES.ts +16 -0
  204. package/src/lib/shared/HttpError.ts +19 -0
  205. package/src/lib/shared/RESOLVE_STREAM_PATH.ts +7 -0
  206. package/src/lib/shared/STREAMING_CONTENT_TYPES.ts +11 -0
  207. package/src/lib/shared/activeCacheStore.ts +20 -0
  208. package/src/lib/shared/appDataDir.ts +34 -0
  209. package/src/lib/shared/belteImportName.ts +44 -0
  210. package/src/lib/shared/browserClientFlags.ts +10 -0
  211. package/src/lib/shared/buildRpcRequest.ts +70 -0
  212. package/src/lib/shared/bundleLayout.ts +36 -0
  213. package/src/lib/shared/bundled.ts +34 -0
  214. package/src/lib/shared/cache.ts +559 -0
  215. package/src/lib/shared/cacheStoreSlot.ts +16 -0
  216. package/src/lib/shared/canonicalJson.ts +63 -0
  217. package/src/lib/shared/carriesBodyArgs.ts +13 -0
  218. package/src/lib/shared/clearLastConnection.ts +7 -0
  219. package/src/lib/shared/commandNameForUrl.ts +17 -0
  220. package/src/lib/shared/createCacheStore.ts +75 -0
  221. package/src/lib/shared/createPushIterator.ts +93 -0
  222. package/src/lib/shared/createRemoteFunction.ts +99 -0
  223. package/src/lib/shared/decodeResponse.ts +47 -0
  224. package/src/lib/shared/detectTarget.ts +27 -0
  225. package/src/lib/shared/exeSuffix.ts +9 -0
  226. package/src/lib/shared/exitOnBuildFailure.ts +17 -0
  227. package/src/lib/shared/extraForwardHeaders.ts +16 -0
  228. package/src/lib/shared/fileStem.ts +9 -0
  229. package/src/lib/shared/findExportCallSite.ts +479 -0
  230. package/src/lib/shared/forwardHeaders.ts +41 -0
  231. package/src/lib/shared/getRemoteMeta.ts +5 -0
  232. package/src/lib/shared/globalCacheStore.ts +15 -0
  233. package/src/lib/shared/globalCacheStoreSlot.ts +14 -0
  234. package/src/lib/shared/importNamesToStrip.ts +13 -0
  235. package/src/lib/shared/invalidateEvent.ts +11 -0
  236. package/src/lib/shared/isCompileTarget.ts +15 -0
  237. package/src/lib/shared/isDebugEnabled.ts +23 -0
  238. package/src/lib/shared/isModuleNotFound.ts +16 -0
  239. package/src/lib/shared/isReadOnlyMethod.ts +14 -0
  240. package/src/lib/shared/isStreamingResponse.ts +11 -0
  241. package/src/lib/shared/jsonSchemaForPromptArguments.ts +29 -0
  242. package/src/lib/shared/jsonSchemaForSchema.ts +32 -0
  243. package/src/lib/shared/jsonlErrorFrame.ts +24 -0
  244. package/src/lib/shared/keyForRemoteCall.ts +29 -0
  245. package/src/lib/shared/lastConnectionPath.ts +7 -0
  246. package/src/lib/shared/loadEnvFile.ts +17 -0
  247. package/src/lib/shared/loadEnvFromDataDir.ts +15 -0
  248. package/src/lib/shared/loadSvelteConfig.ts +18 -0
  249. package/src/lib/shared/log.ts +104 -0
  250. package/src/lib/shared/manifestModule.ts +39 -0
  251. package/src/lib/shared/memoizeByKey.ts +24 -0
  252. package/src/lib/shared/nearestLayoutPrefix.ts +36 -0
  253. package/src/lib/shared/normalizeTarget.ts +10 -0
  254. package/src/lib/shared/pageUrlForFile.ts +14 -0
  255. package/src/lib/shared/parseBoundedEnvInt.ts +20 -0
  256. package/src/lib/shared/parseEnv.ts +30 -0
  257. package/src/lib/shared/parsePromptMarkdown.ts +34 -0
  258. package/src/lib/shared/parseRouteSegments.ts +22 -0
  259. package/src/lib/shared/prepareRpcModule.ts +59 -0
  260. package/src/lib/shared/prepareSocketModule.ts +49 -0
  261. package/src/lib/shared/programNameForPackage.ts +14 -0
  262. package/src/lib/shared/promptNameForFile.ts +10 -0
  263. package/src/lib/shared/queryStringFromArgs.ts +27 -0
  264. package/src/lib/shared/readEnvFile.ts +15 -0
  265. package/src/lib/shared/readLastConnection.ts +18 -0
  266. package/src/lib/shared/readPackageJson.ts +9 -0
  267. package/src/lib/shared/recordRemoteMeta.ts +5 -0
  268. package/src/lib/shared/remoteMetaStore.ts +16 -0
  269. package/src/lib/shared/resolveClientFlags.ts +20 -0
  270. package/src/lib/shared/responseErrorText.ts +9 -0
  271. package/src/lib/shared/rpcUrlForFile.ts +19 -0
  272. package/src/lib/shared/runningAsStandaloneBinary.ts +13 -0
  273. package/src/lib/shared/serializeEnv.ts +18 -0
  274. package/src/lib/shared/setCacheStoreResolver.ts +6 -0
  275. package/src/lib/shared/setGlobalCacheStoreResolver.ts +6 -0
  276. package/src/lib/shared/socketNameForFile.ts +11 -0
  277. package/src/lib/shared/sseErrorFrame.ts +29 -0
  278. package/src/lib/shared/streamResponse.ts +169 -0
  279. package/src/lib/shared/stripImport.ts +27 -0
  280. package/src/lib/shared/subscribableFromResponse.ts +51 -0
  281. package/src/lib/shared/toBunRoutePattern.ts +28 -0
  282. package/src/lib/shared/types/CacheEntry.ts +63 -0
  283. package/src/lib/shared/types/CacheInvalidation.ts +9 -0
  284. package/src/lib/shared/types/CacheOptions.ts +33 -0
  285. package/src/lib/shared/types/CacheSnapshot.ts +16 -0
  286. package/src/lib/shared/types/CacheSnapshotEntry.ts +15 -0
  287. package/src/lib/shared/types/CacheStore.ts +32 -0
  288. package/src/lib/shared/types/ClientFlags.ts +11 -0
  289. package/src/lib/shared/types/CompileTarget.ts +6 -0
  290. package/src/lib/shared/types/HttpVerb.ts +1 -0
  291. package/src/lib/shared/types/LastConnection.ts +9 -0
  292. package/src/lib/shared/types/PromptArgument.ts +12 -0
  293. package/src/lib/shared/types/RawRemoteFunction.ts +13 -0
  294. package/src/lib/shared/types/RemoteFunction.ts +42 -0
  295. package/src/lib/shared/types/StandardSchemaV1.ts +57 -0
  296. package/src/lib/shared/types/StreamedResolution.ts +10 -0
  297. package/src/lib/shared/types/StreamingPlaceholder.ts +13 -0
  298. package/src/lib/shared/types/Subscribable.ts +15 -0
  299. package/src/lib/shared/types/SvelteConfig.ts +5 -0
  300. package/src/lib/shared/withJsonSchema.ts +20 -0
  301. package/src/lib/shared/writeLastConnection.ts +13 -0
  302. package/src/lib/shared/writeRoutesDts.ts +67 -0
  303. package/src/lib/test/clearVerbRegistry.ts +11 -0
  304. package/src/lib/test/createTestClient.ts +78 -0
  305. package/src/preload.ts +20 -0
  306. package/src/scaffold.ts +92 -0
  307. package/src/serverBuildPlugins.ts +25 -0
  308. package/src/serverEntry.ts +94 -0
  309. package/src/sveltePlugin.ts +58 -0
  310. package/src/tailwindStylePreprocessor.ts +62 -0
  311. package/template/bunfig.toml +4 -0
  312. package/template/package.json +19 -0
  313. package/template/src/app.ts +23 -0
  314. package/template/src/browser/app.css +21 -0
  315. package/template/src/browser/app.html +24 -0
  316. package/template/src/browser/pages/about/page.svelte +5 -0
  317. package/template/src/browser/pages/layout.svelte +26 -0
  318. package/template/src/browser/pages/page.svelte +20 -0
  319. package/template/src/bundle/icon.png +0 -0
  320. package/template/src/cli/banner.txt +3 -0
  321. package/template/src/cli/footer.txt +1 -0
  322. package/template/src/server/config.ts +17 -0
  323. package/template/src/server/rpc/getHello.ts +35 -0
  324. package/template/svelte.config.js +12 -0
  325. package/template/tsconfig.json +18 -0
  326. package/tsconfig.app.json +16 -0
@@ -0,0 +1,169 @@
1
+ import { watch } from 'node:fs'
2
+ import type { Subprocess } from 'bun'
3
+ import { build } from './build.ts'
4
+ import { DEFAULT_PORT } from './lib/server/runtime/DEFAULT_PORT.ts'
5
+ import { DEV_REBUILD_MESSAGE } from './lib/server/runtime/DEV_REBUILD_MESSAGE.ts'
6
+ import { findOpenPort } from './lib/server/runtime/findOpenPort.ts'
7
+ import { log } from './lib/shared/log.ts'
8
+
9
+ /*
10
+ Dev orchestrator. Replaces `bun --watch` (which only watches the import graph,
11
+ so new files / CSS / public assets never triggered a restart) with an explicit
12
+ loop we own end to end:
13
+
14
+ 1. Build the client once — uncompressed, unminified (zstd-22 on every rebuild
15
+ dwarfs the bundle; the server serves the plain bytes when no .zst exists).
16
+ 2. Spawn the server as a child against a fixed dev port and BELTE_DEV=1, which
17
+ makes it mount the /__belte/dev live-reload channel.
18
+ 3. Watch src/ recursively. On any change, rebuild then restart the child. SSR
19
+ renders pages through Bun's module cache, so a fresh module graph (a new
20
+ process) is the reliable way to reflect a source edit — Bun has no stable
21
+ in-process invalidation. The browser reconnects to the restarted server's
22
+ live-reload channel and reloads itself.
23
+
24
+ Restarts are serialized (a build mid-flight queues the next) and the port is
25
+ fixed so the browser tab stays valid across restarts. A failed build keeps the
26
+ last-good server running rather than tearing the loop down.
27
+ */
28
+ const cwd = process.cwd()
29
+ const PRELOAD = new URL('./preload.ts', import.meta.url).pathname
30
+ const SERVER_ENTRY = new URL('./serverEntry.ts', import.meta.url).pathname
31
+ const SOURCE_DIR = `${cwd}/src`
32
+ // Coalesce editor save bursts (and multi-file saves) into one rebuild.
33
+ const REBUILD_DEBOUNCE_MS = 60
34
+ /*
35
+ Generated dir the build itself writes into src/ (route type declarations). It
36
+ must be ignored or each rebuild's write retriggers the watcher — an endless
37
+ rebuild loop.
38
+ */
39
+ const GENERATED_DIR = '.belte'
40
+
41
+ // True for paths under src/.belte (the build's own generated output).
42
+ function isGenerated(filename: string): boolean {
43
+ return filename.split(/[\\/]/).includes(GENERATED_DIR)
44
+ }
45
+
46
+ // clean:false leaves the live dist in place — each build swaps _app in atomically,
47
+ // so the running server never serves a half-built or emptied bundle.
48
+ const buildOptions = {
49
+ cwd,
50
+ minify: false,
51
+ compress: false,
52
+ clean: false,
53
+ exitOnFailure: false,
54
+ } as const
55
+
56
+ let server: Subprocess | undefined
57
+
58
+ function startServer(port: number): void {
59
+ server = Bun.spawn({
60
+ cmd: ['bun', '--preload', PRELOAD, SERVER_ENTRY],
61
+ cwd,
62
+ env: { ...process.env, PORT: String(port), BELTE_DEV: '1' },
63
+ stdio: ['inherit', 'inherit', 'inherit'],
64
+ // The child's POST /__belte/reload route signals a rebuild over IPC, so the
65
+ // trigger rides the app's own port instead of a side channel.
66
+ ipc(message) {
67
+ if (message === DEV_REBUILD_MESSAGE) {
68
+ void rebuild(port)
69
+ }
70
+ },
71
+ })
72
+ }
73
+
74
+ /* Terminate the running child and wait for it to free the port (SIGKILL watchdog for a wedged exit). */
75
+ async function stopServer(): Promise<void> {
76
+ if (!server) {
77
+ return
78
+ }
79
+ const dying = server
80
+ server = undefined
81
+ dying.kill()
82
+ const watchdog = setTimeout(() => dying.kill('SIGKILL'), 3000)
83
+ await dying.exited
84
+ clearTimeout(watchdog)
85
+ }
86
+
87
+ let building = false
88
+ let queued = false
89
+
90
+ /*
91
+ Rebuild the client, then (on success) restart the server child. Serialized: a
92
+ change arriving mid-build sets `queued` so exactly one more rebuild runs after,
93
+ collapsing any further changes in between. A failed build leaves the current
94
+ child untouched — the error is logged and the last-good server keeps serving.
95
+ */
96
+ async function rebuild(port: number): Promise<void> {
97
+ if (building) {
98
+ queued = true
99
+ return
100
+ }
101
+ building = true
102
+ try {
103
+ const succeeded = await build(buildOptions)
104
+ if (succeeded) {
105
+ await stopServer()
106
+ startServer(port)
107
+ }
108
+ } finally {
109
+ building = false
110
+ if (queued) {
111
+ queued = false
112
+ void rebuild(port)
113
+ }
114
+ }
115
+ }
116
+
117
+ /*
118
+ Pick a free port once and reuse it for every restart, so the browser tab keeps
119
+ pointing at the same address. Scans upward from the shared default so dev lands
120
+ on the same predictable 3000+ address as `bun start`; reusing the number across
121
+ restarts (not re-scanning) is what keeps the tab valid.
122
+ */
123
+ const port = findOpenPort(DEFAULT_PORT)
124
+ const firstBuild = await build(buildOptions)
125
+ if (!firstBuild) {
126
+ log.warn('initial build failed — fix the error and save to retry')
127
+ }
128
+ startServer(port)
129
+
130
+ /*
131
+ BELTE_DEV_NO_WATCH=1 skips the fs watcher: rebuild only on demand via POST
132
+ /__belte/reload (always mounted under dev), so a long-lived in-process job — e.g.
133
+ an agent editing the app's own source — isn't yanked mid-run by a save.
134
+ */
135
+ const manualRebuild = Bun.env.BELTE_DEV_NO_WATCH === '1'
136
+
137
+ let debounce: ReturnType<typeof setTimeout> | undefined
138
+ const watcher = manualRebuild
139
+ ? undefined
140
+ : watch(SOURCE_DIR, { recursive: true }, (_event, filename) => {
141
+ if (!filename || isGenerated(filename)) {
142
+ return
143
+ }
144
+ clearTimeout(debounce)
145
+ debounce = setTimeout(() => void rebuild(port), REBUILD_DEBOUNCE_MS)
146
+ })
147
+ if (manualRebuild) {
148
+ log.info(`manual rebuild mode — POST http://localhost:${port}/__belte/reload to apply changes`)
149
+ }
150
+
151
+ /* Tear down the watcher and the child on shutdown so neither outlives the orchestrator. */
152
+ const shutdown = async () => {
153
+ watcher?.close()
154
+ await stopServer()
155
+ process.exit(0)
156
+ }
157
+ process.on('SIGINT', shutdown)
158
+ process.on('SIGTERM', shutdown)
159
+ process.on('SIGHUP', shutdown)
160
+
161
+ /*
162
+ Last-resort sync cleanup: Bun.spawn'd children aren't reaped when the parent
163
+ dies, so a crash (uncaught error, terminal close) would otherwise leave the
164
+ server holding the dev port. 'exit' fires for every exit path; kill is
165
+ synchronous, which is enough to signal the child before we go.
166
+ */
167
+ process.on('exit', () => {
168
+ server?.kill()
169
+ })
@@ -0,0 +1,81 @@
1
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
2
+ import { rpc } from './_virtual/rpc.ts'
3
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
4
+ import { sockets } from './_virtual/sockets.ts'
5
+ import type { CliManifestEntry } from './lib/cli/types/CliManifestEntry.ts'
6
+ import { verbRegistry } from './lib/server/rpc/verbRegistry.ts'
7
+ import { socketOperations } from './lib/server/sockets/socketOperations.ts'
8
+ import { socketRegistry } from './lib/server/sockets/socketRegistry.ts'
9
+ import { commandNameForUrl } from './lib/shared/commandNameForUrl.ts'
10
+ import { jsonSchemaForSchema } from './lib/shared/jsonSchemaForSchema.ts'
11
+
12
+ /*
13
+ One-shot script that imports every rpc + socket module so defineVerb /
14
+ defineSocket populate the process-wide registries, then prints the CLI
15
+ manifest to stdout as JSON. Used by buildCli to bake the manifest into
16
+ the standalone binary at build time without resorting to static source
17
+ parsing (which can't see toJsonSchema()/toJSONSchema() at compile time).
18
+ */
19
+ await Promise.all([
20
+ ...Object.values(rpc).map((loader) => (loader as () => Promise<unknown>)()),
21
+ ...Object.values(sockets).map((loader) => (loader as () => Promise<unknown>)()),
22
+ ])
23
+
24
+ const manifest: Record<string, CliManifestEntry> = {}
25
+
26
+ for (const entry of verbRegistry.values()) {
27
+ if (!entry.clients.cli) {
28
+ continue
29
+ }
30
+ manifest[commandNameForUrl(entry.remote.url)] = {
31
+ method: entry.remote.method,
32
+ url: entry.remote.url,
33
+ jsonSchema: jsonSchemaForSchema(entry.inputSchema),
34
+ }
35
+ }
36
+
37
+ /*
38
+ Sockets advertised to the CLI become commands against the socket's HTTP
39
+ face (see socketOperations): `<base>-tail` streams live (GET +
40
+ text/event-stream, with an optional --tail N to replay recent history
41
+ first) and, when clientPublish is set, `<base>-publish` sends the args bag
42
+ as a message (POST).
43
+ */
44
+ for (const entry of socketRegistry.values()) {
45
+ if (!entry.clients.cli) {
46
+ continue
47
+ }
48
+ for (const operation of socketOperations(entry)) {
49
+ if (operation.kind === 'tail') {
50
+ manifest[operation.name] = {
51
+ method: operation.method,
52
+ url: operation.restUrl,
53
+ accept: 'text/event-stream',
54
+ jsonSchema: {
55
+ type: 'object',
56
+ description: `tail the "${operation.socketName}" socket`,
57
+ properties: {
58
+ tail: {
59
+ type: 'number',
60
+ description: 'replay last N messages before tailing live',
61
+ },
62
+ },
63
+ },
64
+ }
65
+ continue
66
+ }
67
+ const payloadSchema = jsonSchemaForSchema(entry.schema)
68
+ manifest[operation.name] = {
69
+ method: operation.method,
70
+ url: operation.restUrl,
71
+ jsonSchema: {
72
+ ...payloadSchema,
73
+ description:
74
+ (payloadSchema.description as string | undefined) ??
75
+ `publish a message to the "${operation.socketName}" socket`,
76
+ },
77
+ }
78
+ }
79
+ }
80
+
81
+ process.stdout.write(JSON.stringify(manifest))
@@ -0,0 +1,33 @@
1
+ import { invalidateEvent } from '../shared/invalidateEvent.ts'
2
+ import type { CacheStore } from '../shared/types/CacheStore.ts'
3
+ import type { StreamedResolution } from '../shared/types/StreamedResolution.ts'
4
+ import { cacheEntryFromSnapshot } from './cacheEntryFromSnapshot.ts'
5
+ import { refetchPlaceholder } from './refetchPlaceholder.ts'
6
+ import type { StreamingDeferred } from './types/StreamingDeferred.ts'
7
+
8
+ /*
9
+ Settles one streamed resolution against its placeholder. A snapshot overwrites
10
+ the placeholder with a warm entry (so re-renders and later reads are sync) and
11
+ fires an invalidate to re-run any read mounted before it arrived; a miss
12
+ re-fetches the request live. Either way it resolves the deferred so a {#await}
13
+ already awaiting the placeholder promise unblocks, and removes it from the
14
+ registry so a later flush only touches genuine leftovers.
15
+ */
16
+ export function applyStreamedResolution(
17
+ store: CacheStore,
18
+ deferreds: Map<string, StreamingDeferred>,
19
+ resolution: StreamedResolution,
20
+ ): void {
21
+ const deferred = deferreds.get(resolution.key)
22
+ deferreds.delete(resolution.key)
23
+ if ('miss' in resolution) {
24
+ if (deferred) {
25
+ refetchPlaceholder(deferred)
26
+ }
27
+ return
28
+ }
29
+ const entry = cacheEntryFromSnapshot(resolution)
30
+ store.entries.set(resolution.key, entry)
31
+ deferred?.resolve(entry.promise as Promise<Response>)
32
+ store.events.dispatchEvent(invalidateEvent([resolution.key]))
33
+ }
@@ -0,0 +1,48 @@
1
+ import type { CacheEntry } from '../shared/types/CacheEntry.ts'
2
+ import type { CacheSnapshotEntry } from '../shared/types/CacheSnapshotEntry.ts'
3
+
4
+ /*
5
+ Rebuilds a warm cache entry from a wire snapshot: an already-resolved Response
6
+ plus the synchronously-decoded warm value, so cache() reads it without a network
7
+ round-trip or a microtask hop. Shared by the initial inline snapshot hydration
8
+ and the streamed resolution path. `settled` is true — the body shipped fully
9
+ resolved either way.
10
+ */
11
+ export function cacheEntryFromSnapshot(entry: CacheSnapshotEntry): CacheEntry {
12
+ const headers = new Headers(entry.headers)
13
+ const response = new Response(entry.body, {
14
+ status: entry.status,
15
+ statusText: entry.statusText,
16
+ headers,
17
+ })
18
+ return {
19
+ key: entry.key,
20
+ promise: Promise.resolve(response),
21
+ request: new Request(entry.url, { method: entry.method }),
22
+ ttl: undefined,
23
+ expiresAt: undefined,
24
+ value: warmValueFromSnapshot(entry.status, headers, entry.body),
25
+ settled: true,
26
+ }
27
+ }
28
+
29
+ /*
30
+ Synchronously decodes a snapshot body so the warm entry reads without a
31
+ microtask hop on first render. Mirrors decodeResponse for the textual cases the
32
+ snapshot ships; non-2xx and 204 yield no warm value and fall back to the async
33
+ path, which throws HttpError / returns undefined exactly as a live call would.
34
+ Binary/xml bodies also skip the warm path and decode asynchronously.
35
+ */
36
+ function warmValueFromSnapshot(status: number, headers: Headers, body: string): unknown {
37
+ if (status === 204 || status < 200 || status >= 300) {
38
+ return undefined
39
+ }
40
+ const contentType = (headers.get('content-type') ?? '').toLowerCase()
41
+ if (contentType.includes('json')) {
42
+ return JSON.parse(body)
43
+ }
44
+ if (contentType.startsWith('text/')) {
45
+ return body
46
+ }
47
+ return undefined
48
+ }
@@ -0,0 +1,16 @@
1
+ import { refetchPlaceholder } from './refetchPlaceholder.ts'
2
+ import type { StreamingDeferred } from './types/StreamingDeferred.ts'
3
+
4
+ /*
5
+ Drains any placeholder the resolution stream never settled — a clean EOF with
6
+ leftovers, or a cut (idleTimeout cap, dropped connection). Each unresolved
7
+ deferred re-fetches its request live, so the {#await} resolves from a normal
8
+ request instead of hanging on a deferred that will never settle. A fully-drained
9
+ stream leaves the registry empty, so this is a no-op then.
10
+ */
11
+ export function flushUnresolvedPlaceholders(deferreds: Map<string, StreamingDeferred>): void {
12
+ for (const deferred of deferreds.values()) {
13
+ refetchPlaceholder(deferred)
14
+ }
15
+ deferreds.clear()
16
+ }
@@ -0,0 +1,32 @@
1
+ import type { CacheStore } from '../shared/types/CacheStore.ts'
2
+ import type { StreamingPlaceholder } from '../shared/types/StreamingPlaceholder.ts'
3
+ import type { StreamingDeferred } from './types/StreamingDeferred.ts'
4
+
5
+ /*
6
+ Pre-creates a deferred cache entry per pending key before hydration. cache()
7
+ finds the placeholder (a pending promise, no warm value) and returns it instead
8
+ of firing a fetch, so the {#await} awaits the resolution stream rather than
9
+ racing it with a duplicate request. Returns the registry the resolver settles.
10
+ */
11
+ export function installStreamingPlaceholders(
12
+ store: CacheStore,
13
+ placeholders: StreamingPlaceholder[],
14
+ ): Map<string, StreamingDeferred> {
15
+ const deferreds = new Map<string, StreamingDeferred>()
16
+ for (const placeholder of placeholders) {
17
+ const request = new Request(placeholder.url, { method: placeholder.method })
18
+ let resolve!: StreamingDeferred['resolve']
19
+ const promise = new Promise<Response>((settle) => {
20
+ resolve = settle
21
+ })
22
+ store.entries.set(placeholder.key, {
23
+ key: placeholder.key,
24
+ promise,
25
+ request,
26
+ ttl: undefined,
27
+ expiresAt: undefined,
28
+ })
29
+ deferreds.set(placeholder.key, { resolve, request })
30
+ }
31
+ return deferreds
32
+ }
@@ -0,0 +1,42 @@
1
+ import { RESOLVE_STREAM_PATH } from '../shared/RESOLVE_STREAM_PATH.ts'
2
+ import { streamResponse } from '../shared/streamResponse.ts'
3
+ import type { CacheStore } from '../shared/types/CacheStore.ts'
4
+ import type { StreamedResolution } from '../shared/types/StreamedResolution.ts'
5
+ import { applyStreamedResolution } from './applyStreamedResolution.ts'
6
+ import { flushUnresolvedPlaceholders } from './flushUnresolvedPlaceholders.ts'
7
+ import { setPageStreamController } from './pageStreamController.ts'
8
+ import type { StreamingDeferred } from './types/StreamingDeferred.ts'
9
+
10
+ /*
11
+ Opens the out-of-band resolution stream (token from `__SSR__.streamToken`) and
12
+ applies each StreamedResolution to its placeholder as it arrives. The stream is
13
+ NDJSON, so it shares the canonical `streamResponse` frame parser the rpc/CLI/MCP
14
+ drains use rather than re-implementing line framing. The reader gives a reliable
15
+ end signal the inline document stream couldn't: on clean EOF or a cut (a non-ok
16
+ response or mid-stream error throws here), any still-pending placeholder
17
+ re-fetches live; on abort (navigation) the gone page's reads are left alone.
18
+ Registered with setPageStreamController so a navigation can cancel it and free
19
+ the connection.
20
+ */
21
+ export async function openResolveStream(
22
+ token: string,
23
+ store: CacheStore,
24
+ deferreds: Map<string, StreamingDeferred>,
25
+ ): Promise<void> {
26
+ const controller = new AbortController()
27
+ setPageStreamController(controller)
28
+ try {
29
+ const response = await fetch(`${RESOLVE_STREAM_PATH}${token}`, {
30
+ signal: controller.signal,
31
+ })
32
+ for await (const resolution of streamResponse<StreamedResolution>(response)) {
33
+ applyStreamedResolution(store, deferreds, resolution)
34
+ }
35
+ } catch {
36
+ // Navigated away mid-stream — the page is gone; don't re-fetch its reads.
37
+ }
38
+ // Clean EOF or a cut (non-abort error): re-fetch anything still pending.
39
+ if (!controller.signal.aborted) {
40
+ flushUnresolvedPlaceholders(deferreds)
41
+ }
42
+ }
@@ -0,0 +1,258 @@
1
+ import type { Component } from 'svelte'
2
+ import {
3
+ type NormalizedLayoutPrefix,
4
+ nearestLayoutPrefix,
5
+ normalizeLayoutPrefixes,
6
+ } from '../shared/nearestLayoutPrefix.ts'
7
+ import { abortPageStream } from './pageStreamController.ts'
8
+ import type { Layouts } from './types/Layouts.ts'
9
+ import type { Pages } from './types/Pages.ts'
10
+
11
+ /*
12
+ Augmentable route table. The codegen step emits a `declare module 'belte/browser/page'`
13
+ block that fills this interface with `routePath: paramShape` pairs derived
14
+ from the project's `src/browser/pages/**` tree. A bare belte install has no routes,
15
+ so the fallback arm below keeps the union inhabited before the generated
16
+ d.ts lands.
17
+
18
+ Declared as an `interface` (not a `type` alias) because the generated d.ts
19
+ augments it via `declare module … { interface Routes { … } }`, and module
20
+ augmentation only merges into interfaces.
21
+ */
22
+ // biome-ignore lint/suspicious/noEmptyInterface: augmented by the generated routes.d.ts
23
+ export interface Routes {}
24
+
25
+ type RouteKey = keyof Routes extends never ? string : keyof Routes
26
+ type ParamsFor<R extends RouteKey> = R extends keyof Routes ? Routes[R] : Record<string, string>
27
+
28
+ type PageStateFor<R extends RouteKey> = {
29
+ route: R
30
+ params: ParamsFor<R>
31
+ url: URL
32
+ }
33
+
34
+ /*
35
+ Discriminated union keyed on `route`, so consumers that narrow on `page.route`
36
+ get the matching `page.params` shape automatically. `url` is the live
37
+ WHATWG URL for the currently-displayed location; navigation reassigns the
38
+ reference so $derived subscribers re-run on every nav (not just on the
39
+ fields they happen to touch).
40
+ */
41
+ export type Page = keyof Routes extends never
42
+ ? PageStateFor<string>
43
+ : { [R in keyof Routes]: PageStateFor<R> }[keyof Routes]
44
+
45
+ // biome-ignore lint/suspicious/noExplicitAny: discriminated-union init needs a single arm
46
+ export const page: Page = $state<any>({
47
+ route: '',
48
+ params: {},
49
+ url: new URL('http://localhost/'),
50
+ })
51
+
52
+ /*
53
+ Internal renderer state — the Layout/Page components App.svelte mounts.
54
+ Kept on a separate $state object so it doesn't leak into the public `page`
55
+ shape; users only ever see route/params/url.
56
+ */
57
+ export const renderState = $state<{
58
+ Layout: Component | undefined
59
+ Page: Component | undefined
60
+ }>({
61
+ Layout: undefined,
62
+ Page: undefined,
63
+ })
64
+
65
+ let boundPages: Pages | undefined
66
+ let boundLayouts: Layouts | undefined
67
+ let layoutPrefixes: NormalizedLayoutPrefix[] = []
68
+
69
+ type SsrPayload = { route: string; params: Record<string, string> }
70
+
71
+ /*
72
+ Wires the route + layout tables produced by the bundler's virtual manifests
73
+ and seeds page state from the SSR payload. Called once from startClient
74
+ before `hydrate(App)` so the first render sees Page/Layout/params already
75
+ populated. Subsequent `navigate()` calls reuse `boundPages` / `boundLayouts`.
76
+ */
77
+ export async function bindPage({
78
+ pages,
79
+ layouts,
80
+ ssr,
81
+ }: {
82
+ pages: Pages
83
+ layouts?: Layouts
84
+ ssr: SsrPayload
85
+ }): Promise<void> {
86
+ boundPages = pages
87
+ boundLayouts = layouts
88
+ layoutPrefixes = layouts ? normalizeLayoutPrefixes(Object.keys(layouts)) : []
89
+ const { Page, Layout } = await loadView(ssr.route)
90
+ applyState(ssr.route, ssr.params, Page, Layout)
91
+ }
92
+
93
+ async function loadView(
94
+ route: string,
95
+ ): Promise<{ Page: Component; Layout: Component | undefined }> {
96
+ if (!boundPages) {
97
+ throw new Error('[belte] page is not initialized — call bindPage first')
98
+ }
99
+ const pageLoader = boundPages[route]
100
+ if (!pageLoader) {
101
+ throw new Error(`[belte] unknown route: ${route}`)
102
+ }
103
+ const layoutPrefix = nearestLayoutPrefix(route, layoutPrefixes)
104
+ const [pageMod, layoutMod] = await Promise.all([
105
+ pageLoader(),
106
+ layoutPrefix && boundLayouts ? boundLayouts[layoutPrefix]() : Promise.resolve(undefined),
107
+ ])
108
+ return { Page: pageMod.default, Layout: layoutMod?.default }
109
+ }
110
+
111
+ function applyState(
112
+ route: string,
113
+ params: Record<string, string>,
114
+ Page: Component,
115
+ Layout: Component | undefined,
116
+ ): void {
117
+ renderState.Layout = Layout
118
+ renderState.Page = Page
119
+ const mutable = page as PageStateFor<string>
120
+ mutable.route = route
121
+ mutable.params = params
122
+ syncUrl()
123
+ }
124
+
125
+ function syncUrl(): void {
126
+ const mutable = page as PageStateFor<string>
127
+ mutable.url = new URL(window.location.href)
128
+ }
129
+
130
+ /*
131
+ Resolves the JSON view payload for a target URL, or undefined when the fetch
132
+ fails for any reason (network error or non-2xx, including 404). The caller
133
+ falls back to a hard navigation in every failure case, so the failure modes
134
+ don't need to be distinguished.
135
+ */
136
+ async function safeResolveFetch(target: string): Promise<Response | undefined> {
137
+ try {
138
+ const response = await fetch(target, { headers: { Accept: 'application/json' } })
139
+ return response.ok ? response : undefined
140
+ } catch {
141
+ return undefined
142
+ }
143
+ }
144
+
145
+ function hasRoute(route: string): boolean {
146
+ return Boolean(boundPages?.[route])
147
+ }
148
+
149
+ type ResolvedView = {
150
+ route: string
151
+ params: Record<string, string>
152
+ Page: Component
153
+ Layout: Component | undefined
154
+ }
155
+
156
+ /*
157
+ Resolves a target into the Page/Layout components to render, or undefined
158
+ when the target isn't an SPA route: the resolve fetch failed, the body wasn't
159
+ a known route (e.g. a raw JSON endpoint that 200s on `Accept: json`), or the
160
+ page module import threw. Callers fall back to a hard navigation in every
161
+ undefined case. A missing/unknown route is expected and stays silent; a known
162
+ route whose module fails to import is a real error and is surfaced.
163
+ */
164
+ async function resolveView(fullTarget: string): Promise<ResolvedView | undefined> {
165
+ const response = await safeResolveFetch(fullTarget)
166
+ if (!response) {
167
+ return undefined
168
+ }
169
+ const result = (await response.json()) as SsrPayload
170
+ if (!result.route || !hasRoute(result.route)) {
171
+ return undefined
172
+ }
173
+ try {
174
+ const { Page, Layout } = await loadView(result.route)
175
+ return { route: result.route, params: result.params, Page, Layout }
176
+ } catch (err) {
177
+ console.error('[belte] navigation failed', err)
178
+ return undefined
179
+ }
180
+ }
181
+
182
+ function writeHistory(replace: boolean, fullTarget: string): void {
183
+ if (replace) {
184
+ window.history.replaceState(undefined, '', fullTarget)
185
+ } else {
186
+ window.history.pushState(undefined, '', fullTarget)
187
+ }
188
+ }
189
+
190
+ export type NavigateOptions = { replace?: boolean; scroll?: boolean }
191
+
192
+ /*
193
+ SPA navigation entrypoint. When only `search` or `hash` changes (same
194
+ pathname) the JSON resolve fetch + loadView are skipped — history is written
195
+ and `page.url` reassigned so $derived consumers re-run without a network
196
+ round-trip or page remount. On a pathname change the target view is resolved
197
+ *before* history is touched: a non-SPA target (raw JSON endpoint, unknown
198
+ route, failed import) hard-navigates cleanly via `location.href`, because a
199
+ pushed entry whose URL no longer matches its in-memory document corrupts
200
+ back/forward (Safari restores the stale document under the new URL).
201
+ */
202
+ export async function navigate(href: string, options: NavigateOptions = {}): Promise<void> {
203
+ const { replace = false, scroll = true } = options
204
+ const target = new URL(href, window.location.href)
205
+ if (target.origin !== window.location.origin) {
206
+ window.location.href = href
207
+ return
208
+ }
209
+ const fullTarget = `${target.pathname}${target.search}${target.hash}`
210
+ if (target.pathname === page.url.pathname) {
211
+ writeHistory(replace, fullTarget)
212
+ syncUrl()
213
+ return
214
+ }
215
+ /* Leaving this page: cancel its still-open resolution stream (if any) so the
216
+ connection frees instead of running to completion for a page that's gone. */
217
+ abortPageStream()
218
+ const view = await resolveView(fullTarget)
219
+ if (!view) {
220
+ window.location.href = fullTarget
221
+ return
222
+ }
223
+ writeHistory(replace, fullTarget)
224
+ applyState(view.route, view.params, view.Page, view.Layout)
225
+ if (scroll && !replace) {
226
+ window.scrollTo(0, 0)
227
+ }
228
+ }
229
+
230
+ /*
231
+ popstate fires after the browser has already restored the URL, so this never
232
+ writes history — it just applies the current location. A same-pathname change
233
+ only refreshes `page.url`; a pathname change resolves and swaps the page, or
234
+ hard-navigates when the restored URL isn't an SPA route.
235
+ */
236
+ async function applyTarget(pathname: string, fullTarget: string): Promise<void> {
237
+ if (pathname === page.url.pathname) {
238
+ syncUrl()
239
+ return
240
+ }
241
+ abortPageStream()
242
+ const view = await resolveView(fullTarget)
243
+ if (!view) {
244
+ window.location.href = fullTarget
245
+ return
246
+ }
247
+ applyState(view.route, view.params, view.Page, view.Layout)
248
+ }
249
+
250
+ /*
251
+ popstate fires after the browser has already restored the URL. Scroll
252
+ position is left alone — the browser's built-in history scroll restoration
253
+ wins for back/forward.
254
+ */
255
+ export function handlePopstate(): void {
256
+ const fullTarget = `${window.location.pathname}${window.location.search}${window.location.hash}`
257
+ void applyTarget(window.location.pathname, fullTarget)
258
+ }