@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.
- package/CHANGELOG.md +313 -0
- package/LICENSE +21 -0
- package/README.md +559 -0
- package/bin/belte.ts +183 -0
- package/package.json +110 -0
- package/src/App.svelte +31 -0
- package/src/appEntry.ts +151 -0
- package/src/assets/app.html +14 -0
- package/src/belteResolverPlugin.ts +858 -0
- package/src/build.ts +147 -0
- package/src/buildCli.ts +129 -0
- package/src/buildDisconnected.ts +122 -0
- package/src/bundleApp.ts +149 -0
- package/src/bundleDisconnectedEntry.ts +17 -0
- package/src/cliEntry.ts +25 -0
- package/src/clientBuildPlugins.ts +41 -0
- package/src/clientEntry.ts +7 -0
- package/src/compile.ts +64 -0
- package/src/controlServerWorker.ts +422 -0
- package/src/dedupeSveltePlugin.ts +66 -0
- package/src/devEntry.ts +169 -0
- package/src/discoveryEntry.ts +81 -0
- package/src/lib/browser/applyStreamedResolution.ts +33 -0
- package/src/lib/browser/cacheEntryFromSnapshot.ts +48 -0
- package/src/lib/browser/flushUnresolvedPlaceholders.ts +16 -0
- package/src/lib/browser/installStreamingPlaceholders.ts +32 -0
- package/src/lib/browser/openResolveStream.ts +42 -0
- package/src/lib/browser/page.svelte.ts +258 -0
- package/src/lib/browser/pageStreamController.ts +17 -0
- package/src/lib/browser/refetchPlaceholder.ts +12 -0
- package/src/lib/browser/remoteProxy.ts +37 -0
- package/src/lib/browser/socketChannel.ts +192 -0
- package/src/lib/browser/socketProxy.ts +57 -0
- package/src/lib/browser/startClient.ts +153 -0
- package/src/lib/browser/subscribe.ts +131 -0
- package/src/lib/browser/types/Errors.ts +9 -0
- package/src/lib/browser/types/Layouts.ts +7 -0
- package/src/lib/browser/types/Pages.ts +7 -0
- package/src/lib/browser/types/StreamingDeferred.ts +9 -0
- package/src/lib/bundle/BundleMenu.ts +11 -0
- package/src/lib/bundle/BundleMenuItem.ts +24 -0
- package/src/lib/bundle/BundleWindow.ts +36 -0
- package/src/lib/bundle/WEBVIEW_BUILD_REVISION.ts +9 -0
- package/src/lib/bundle/WEBVIEW_VERSION.ts +7 -0
- package/src/lib/bundle/bindConnectedFlag.ts +29 -0
- package/src/lib/bundle/bindRequestNavigate.ts +31 -0
- package/src/lib/bundle/buildWebviewLib.ts +111 -0
- package/src/lib/bundle/disconnected.css +9 -0
- package/src/lib/bundle/disconnected.svelte +386 -0
- package/src/lib/bundle/ensureWebviewLib.ts +20 -0
- package/src/lib/bundle/exitWithParent.ts +28 -0
- package/src/lib/bundle/infoPlist.ts +46 -0
- package/src/lib/bundle/installDownloads.ts +24 -0
- package/src/lib/bundle/installMacMenu.ts +39 -0
- package/src/lib/bundle/listenLocalControlServer.ts +19 -0
- package/src/lib/bundle/native/belteMenu.mm +422 -0
- package/src/lib/bundle/native/webview.h +4557 -0
- package/src/lib/bundle/onMenu.ts +41 -0
- package/src/lib/bundle/openWebview.ts +104 -0
- package/src/lib/bundle/pngToIcns.ts +47 -0
- package/src/lib/bundle/probeBelteServer.ts +34 -0
- package/src/lib/bundle/resolveServerBinary.ts +12 -0
- package/src/lib/bundle/resolveWebviewLib.ts +53 -0
- package/src/lib/bundle/serverBinaryFilename.ts +8 -0
- package/src/lib/bundle/signMacApp.ts +35 -0
- package/src/lib/bundle/spawnEmbeddedServer.ts +65 -0
- package/src/lib/bundle/stableLocalPort.ts +19 -0
- package/src/lib/bundle/waitForServer.ts +23 -0
- package/src/lib/bundle/webviewCachePath.ts +23 -0
- package/src/lib/bundle/webviewLibName.ts +11 -0
- package/src/lib/cli/connectToServer.ts +23 -0
- package/src/lib/cli/createClient.ts +170 -0
- package/src/lib/cli/dispatchCommand.ts +71 -0
- package/src/lib/cli/loadEnvFromBinaryDir.ts +16 -0
- package/src/lib/cli/parseArgvForRpc.ts +97 -0
- package/src/lib/cli/printHelp.ts +119 -0
- package/src/lib/cli/printSessionHelp.ts +27 -0
- package/src/lib/cli/printSessionStatus.ts +21 -0
- package/src/lib/cli/printTrimmed.ts +8 -0
- package/src/lib/cli/printValue.ts +10 -0
- package/src/lib/cli/resolveCliTarget.ts +48 -0
- package/src/lib/cli/runCli.ts +139 -0
- package/src/lib/cli/runSession.ts +105 -0
- package/src/lib/cli/startLocalInstance.ts +14 -0
- package/src/lib/cli/tokenizeLine.ts +51 -0
- package/src/lib/cli/types/CliManifest.ts +9 -0
- package/src/lib/cli/types/CliManifestEntry.ts +17 -0
- package/src/lib/cli/types/CliTarget.ts +13 -0
- package/src/lib/mcp/annotationsForMethod.ts +29 -0
- package/src/lib/mcp/createMcpResourceServer.ts +101 -0
- package/src/lib/mcp/createMcpServer.ts +42 -0
- package/src/lib/mcp/dispatchMcpRequest.ts +146 -0
- package/src/lib/mcp/mcpResourceServerSlot.ts +18 -0
- package/src/lib/mcp/mcpSurface.ts +265 -0
- package/src/lib/mcp/toolResultFromResponse.ts +66 -0
- package/src/lib/mcp/types/JsonRpcRequest.ts +12 -0
- package/src/lib/mcp/types/JsonRpcResponse.ts +20 -0
- package/src/lib/mcp/types/McpResourceContents.ts +10 -0
- package/src/lib/mcp/types/McpResourceDescriptor.ts +6 -0
- package/src/lib/mcp/types/McpResourceServer.ts +12 -0
- package/src/lib/mcp/types/McpServer.ts +9 -0
- package/src/lib/mcp/types/McpServerOptions.ts +16 -0
- package/src/lib/server/AppModule.ts +33 -0
- package/src/lib/server/DELETE.ts +9 -0
- package/src/lib/server/GET.ts +9 -0
- package/src/lib/server/HEAD.ts +9 -0
- package/src/lib/server/PATCH.ts +9 -0
- package/src/lib/server/POST.ts +9 -0
- package/src/lib/server/PUT.ts +9 -0
- package/src/lib/server/agent.ts +76 -0
- package/src/lib/server/appDataDir.ts +15 -0
- package/src/lib/server/cli/buildEnvContent.ts +19 -0
- package/src/lib/server/cli/createTarGz.ts +76 -0
- package/src/lib/server/cli/handleCliDownload.ts +153 -0
- package/src/lib/server/cli/handleCliInstall.ts +37 -0
- package/src/lib/server/cli/installScript.ts +29 -0
- package/src/lib/server/cli/maxSourceMtime.ts +26 -0
- package/src/lib/server/cookies.ts +29 -0
- package/src/lib/server/env.ts +50 -0
- package/src/lib/server/error.ts +70 -0
- package/src/lib/server/json.ts +28 -0
- package/src/lib/server/jsonl.ts +46 -0
- package/src/lib/server/prompts/definePrompt.ts +20 -0
- package/src/lib/server/prompts/promptRegistry.ts +9 -0
- package/src/lib/server/prompts/registerPrompt.ts +6 -0
- package/src/lib/server/prompts/renderPromptTemplate.ts +16 -0
- package/src/lib/server/prompts/types/Prompt.ts +13 -0
- package/src/lib/server/prompts/types/PromptOptions.ts +12 -0
- package/src/lib/server/prompts/types/PromptRegistryEntry.ts +13 -0
- package/src/lib/server/prompts/types/PromptRoutes.ts +10 -0
- package/src/lib/server/redirect.ts +42 -0
- package/src/lib/server/request.ts +18 -0
- package/src/lib/server/rpc/defineVerb.ts +133 -0
- package/src/lib/server/rpc/dispatchVerbInProcess.ts +46 -0
- package/src/lib/server/rpc/findVerbByCommandName.ts +18 -0
- package/src/lib/server/rpc/parseArgs.ts +95 -0
- package/src/lib/server/rpc/registerVerb.ts +6 -0
- package/src/lib/server/rpc/types/RemoteHandler.ts +27 -0
- package/src/lib/server/rpc/types/RemoteRoutes.ts +13 -0
- package/src/lib/server/rpc/types/TypedResponse.ts +18 -0
- package/src/lib/server/rpc/types/VerbHelper.ts +68 -0
- package/src/lib/server/rpc/types/VerbRegistryEntry.ts +29 -0
- package/src/lib/server/rpc/unprocessed.ts +14 -0
- package/src/lib/server/rpc/verbRegistry.ts +11 -0
- package/src/lib/server/runtime/DEFAULT_PORT.ts +6 -0
- package/src/lib/server/runtime/DEV_REBUILD_MESSAGE.ts +4 -0
- package/src/lib/server/runtime/DEV_RELOAD_CLIENT_SCRIPT.ts +29 -0
- package/src/lib/server/runtime/acceptsZstd.ts +8 -0
- package/src/lib/server/runtime/buildOpenApiSpec.ts +106 -0
- package/src/lib/server/runtime/cacheControlForAsset.ts +22 -0
- package/src/lib/server/runtime/containsTraversal.ts +37 -0
- package/src/lib/server/runtime/createAssetHeaderCache.ts +35 -0
- package/src/lib/server/runtime/createPublicAssetServer.ts +63 -0
- package/src/lib/server/runtime/createRouteDispatcher.ts +100 -0
- package/src/lib/server/runtime/createServer.ts +692 -0
- package/src/lib/server/runtime/devReloadResponse.ts +35 -0
- package/src/lib/server/runtime/disableIdleTimeoutForStream.ts +27 -0
- package/src/lib/server/runtime/envSchemaStore.ts +15 -0
- package/src/lib/server/runtime/findOpenPort.ts +35 -0
- package/src/lib/server/runtime/getActiveServer.ts +6 -0
- package/src/lib/server/runtime/globToPathSet.ts +29 -0
- package/src/lib/server/runtime/inProcessServer.ts +20 -0
- package/src/lib/server/runtime/internalErrorResponse.ts +25 -0
- package/src/lib/server/runtime/isCrossOriginUpgrade.ts +19 -0
- package/src/lib/server/runtime/listenOnOpenPort.ts +36 -0
- package/src/lib/server/runtime/logExposedSurfaces.ts +162 -0
- package/src/lib/server/runtime/mimeForExtension.ts +20 -0
- package/src/lib/server/runtime/parseIdleTimeout.ts +10 -0
- package/src/lib/server/runtime/parsePort.ts +11 -0
- package/src/lib/server/runtime/registryManifests.ts +66 -0
- package/src/lib/server/runtime/requestContext.ts +5 -0
- package/src/lib/server/runtime/resolveStreamResponse.ts +29 -0
- package/src/lib/server/runtime/runWithRequestScope.ts +57 -0
- package/src/lib/server/runtime/safeJsonForScript.ts +17 -0
- package/src/lib/server/runtime/serializeCacheSnapshot.ts +45 -0
- package/src/lib/server/runtime/serverSlot.ts +13 -0
- package/src/lib/server/runtime/setActiveServer.ts +6 -0
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +81 -0
- package/src/lib/server/runtime/streamCacheResolutions.ts +37 -0
- package/src/lib/server/runtime/streamFromIterator.ts +86 -0
- package/src/lib/server/runtime/streamStash.ts +64 -0
- package/src/lib/server/runtime/types/Assets.ts +1 -0
- package/src/lib/server/runtime/types/RequestStore.ts +27 -0
- package/src/lib/server/runtime/withResponseDefaults.ts +24 -0
- package/src/lib/server/server.ts +32 -0
- package/src/lib/server/socket.ts +31 -0
- package/src/lib/server/sockets/createSocketDispatcher.ts +311 -0
- package/src/lib/server/sockets/defineSocket.ts +167 -0
- package/src/lib/server/sockets/lookupSocket.ts +6 -0
- package/src/lib/server/sockets/recentHistory.ts +11 -0
- package/src/lib/server/sockets/registerSocket.ts +6 -0
- package/src/lib/server/sockets/socketOperations.ts +35 -0
- package/src/lib/server/sockets/socketRegistry.ts +9 -0
- package/src/lib/server/sockets/types/Socket.ts +21 -0
- package/src/lib/server/sockets/types/SocketClientFrame.ts +18 -0
- package/src/lib/server/sockets/types/SocketOperation.ts +22 -0
- package/src/lib/server/sockets/types/SocketOptions.ts +22 -0
- package/src/lib/server/sockets/types/SocketRegistryEntry.ts +17 -0
- package/src/lib/server/sockets/types/SocketRoutes.ts +10 -0
- package/src/lib/server/sockets/types/SocketServerFrame.ts +15 -0
- package/src/lib/server/sse.ts +53 -0
- package/src/lib/shared/BELTE_PACKAGE_NAME.ts +7 -0
- package/src/lib/shared/CACHE_CONTROL_VALUES.ts +16 -0
- package/src/lib/shared/HttpError.ts +19 -0
- package/src/lib/shared/RESOLVE_STREAM_PATH.ts +7 -0
- package/src/lib/shared/STREAMING_CONTENT_TYPES.ts +11 -0
- package/src/lib/shared/activeCacheStore.ts +20 -0
- package/src/lib/shared/appDataDir.ts +34 -0
- package/src/lib/shared/belteImportName.ts +44 -0
- package/src/lib/shared/browserClientFlags.ts +10 -0
- package/src/lib/shared/buildRpcRequest.ts +70 -0
- package/src/lib/shared/bundleLayout.ts +36 -0
- package/src/lib/shared/bundled.ts +34 -0
- package/src/lib/shared/cache.ts +559 -0
- package/src/lib/shared/cacheStoreSlot.ts +16 -0
- package/src/lib/shared/canonicalJson.ts +63 -0
- package/src/lib/shared/carriesBodyArgs.ts +13 -0
- package/src/lib/shared/clearLastConnection.ts +7 -0
- package/src/lib/shared/commandNameForUrl.ts +17 -0
- package/src/lib/shared/createCacheStore.ts +75 -0
- package/src/lib/shared/createPushIterator.ts +93 -0
- package/src/lib/shared/createRemoteFunction.ts +99 -0
- package/src/lib/shared/decodeResponse.ts +47 -0
- package/src/lib/shared/detectTarget.ts +27 -0
- package/src/lib/shared/exeSuffix.ts +9 -0
- package/src/lib/shared/exitOnBuildFailure.ts +17 -0
- package/src/lib/shared/extraForwardHeaders.ts +16 -0
- package/src/lib/shared/fileStem.ts +9 -0
- package/src/lib/shared/findExportCallSite.ts +479 -0
- package/src/lib/shared/forwardHeaders.ts +41 -0
- package/src/lib/shared/getRemoteMeta.ts +5 -0
- package/src/lib/shared/globalCacheStore.ts +15 -0
- package/src/lib/shared/globalCacheStoreSlot.ts +14 -0
- package/src/lib/shared/importNamesToStrip.ts +13 -0
- package/src/lib/shared/invalidateEvent.ts +11 -0
- package/src/lib/shared/isCompileTarget.ts +15 -0
- package/src/lib/shared/isDebugEnabled.ts +23 -0
- package/src/lib/shared/isModuleNotFound.ts +16 -0
- package/src/lib/shared/isReadOnlyMethod.ts +14 -0
- package/src/lib/shared/isStreamingResponse.ts +11 -0
- package/src/lib/shared/jsonSchemaForPromptArguments.ts +29 -0
- package/src/lib/shared/jsonSchemaForSchema.ts +32 -0
- package/src/lib/shared/jsonlErrorFrame.ts +24 -0
- package/src/lib/shared/keyForRemoteCall.ts +29 -0
- package/src/lib/shared/lastConnectionPath.ts +7 -0
- package/src/lib/shared/loadEnvFile.ts +17 -0
- package/src/lib/shared/loadEnvFromDataDir.ts +15 -0
- package/src/lib/shared/loadSvelteConfig.ts +18 -0
- package/src/lib/shared/log.ts +104 -0
- package/src/lib/shared/manifestModule.ts +39 -0
- package/src/lib/shared/memoizeByKey.ts +24 -0
- package/src/lib/shared/nearestLayoutPrefix.ts +36 -0
- package/src/lib/shared/normalizeTarget.ts +10 -0
- package/src/lib/shared/pageUrlForFile.ts +14 -0
- package/src/lib/shared/parseBoundedEnvInt.ts +20 -0
- package/src/lib/shared/parseEnv.ts +30 -0
- package/src/lib/shared/parsePromptMarkdown.ts +34 -0
- package/src/lib/shared/parseRouteSegments.ts +22 -0
- package/src/lib/shared/prepareRpcModule.ts +59 -0
- package/src/lib/shared/prepareSocketModule.ts +49 -0
- package/src/lib/shared/programNameForPackage.ts +14 -0
- package/src/lib/shared/promptNameForFile.ts +10 -0
- package/src/lib/shared/queryStringFromArgs.ts +27 -0
- package/src/lib/shared/readEnvFile.ts +15 -0
- package/src/lib/shared/readLastConnection.ts +18 -0
- package/src/lib/shared/readPackageJson.ts +9 -0
- package/src/lib/shared/recordRemoteMeta.ts +5 -0
- package/src/lib/shared/remoteMetaStore.ts +16 -0
- package/src/lib/shared/resolveClientFlags.ts +20 -0
- package/src/lib/shared/responseErrorText.ts +9 -0
- package/src/lib/shared/rpcUrlForFile.ts +19 -0
- package/src/lib/shared/runningAsStandaloneBinary.ts +13 -0
- package/src/lib/shared/serializeEnv.ts +18 -0
- package/src/lib/shared/setCacheStoreResolver.ts +6 -0
- package/src/lib/shared/setGlobalCacheStoreResolver.ts +6 -0
- package/src/lib/shared/socketNameForFile.ts +11 -0
- package/src/lib/shared/sseErrorFrame.ts +29 -0
- package/src/lib/shared/streamResponse.ts +169 -0
- package/src/lib/shared/stripImport.ts +27 -0
- package/src/lib/shared/subscribableFromResponse.ts +51 -0
- package/src/lib/shared/toBunRoutePattern.ts +28 -0
- package/src/lib/shared/types/CacheEntry.ts +63 -0
- package/src/lib/shared/types/CacheInvalidation.ts +9 -0
- package/src/lib/shared/types/CacheOptions.ts +33 -0
- package/src/lib/shared/types/CacheSnapshot.ts +16 -0
- package/src/lib/shared/types/CacheSnapshotEntry.ts +15 -0
- package/src/lib/shared/types/CacheStore.ts +32 -0
- package/src/lib/shared/types/ClientFlags.ts +11 -0
- package/src/lib/shared/types/CompileTarget.ts +6 -0
- package/src/lib/shared/types/HttpVerb.ts +1 -0
- package/src/lib/shared/types/LastConnection.ts +9 -0
- package/src/lib/shared/types/PromptArgument.ts +12 -0
- package/src/lib/shared/types/RawRemoteFunction.ts +13 -0
- package/src/lib/shared/types/RemoteFunction.ts +42 -0
- package/src/lib/shared/types/StandardSchemaV1.ts +57 -0
- package/src/lib/shared/types/StreamedResolution.ts +10 -0
- package/src/lib/shared/types/StreamingPlaceholder.ts +13 -0
- package/src/lib/shared/types/Subscribable.ts +15 -0
- package/src/lib/shared/types/SvelteConfig.ts +5 -0
- package/src/lib/shared/withJsonSchema.ts +20 -0
- package/src/lib/shared/writeLastConnection.ts +13 -0
- package/src/lib/shared/writeRoutesDts.ts +67 -0
- package/src/lib/test/clearVerbRegistry.ts +11 -0
- package/src/lib/test/createTestClient.ts +78 -0
- package/src/preload.ts +20 -0
- package/src/scaffold.ts +92 -0
- package/src/serverBuildPlugins.ts +25 -0
- package/src/serverEntry.ts +94 -0
- package/src/sveltePlugin.ts +58 -0
- package/src/tailwindStylePreprocessor.ts +62 -0
- package/template/bunfig.toml +4 -0
- package/template/package.json +19 -0
- package/template/src/app.ts +23 -0
- package/template/src/browser/app.css +21 -0
- package/template/src/browser/app.html +24 -0
- package/template/src/browser/pages/about/page.svelte +5 -0
- package/template/src/browser/pages/layout.svelte +26 -0
- package/template/src/browser/pages/page.svelte +20 -0
- package/template/src/bundle/icon.png +0 -0
- package/template/src/cli/banner.txt +3 -0
- package/template/src/cli/footer.txt +1 -0
- package/template/src/server/config.ts +17 -0
- package/template/src/server/rpc/getHello.ts +35 -0
- package/template/svelte.config.js +12 -0
- package/template/tsconfig.json +18 -0
- package/tsconfig.app.json +16 -0
package/src/devEntry.ts
ADDED
|
@@ -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
|
+
}
|