@abide/abide 0.38.1 → 0.39.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 (54) hide show
  1. package/AGENTS.md +3 -3
  2. package/CHANGELOG.md +16 -0
  3. package/package.json +9 -1
  4. package/src/build.ts +14 -0
  5. package/src/lib/bundle/exitWithParent.ts +4 -2
  6. package/src/lib/server/rpc/readBodyWithinLimit.ts +3 -0
  7. package/src/lib/server/runtime/createAppAssetServer.ts +37 -11
  8. package/src/lib/server/runtime/createPublicAssetServer.ts +14 -5
  9. package/src/lib/server/runtime/createUiPageRenderer.ts +53 -42
  10. package/src/lib/server/runtime/internalErrorResponse.ts +5 -2
  11. package/src/lib/server/runtime/snapshotEntryFromCache.ts +4 -1
  12. package/src/lib/server/sockets/createSocketDispatcher.ts +7 -0
  13. package/src/lib/shared/createRemoteFunction.ts +20 -11
  14. package/src/lib/shared/escapeHtml.ts +15 -0
  15. package/src/lib/shared/markFrameworkSourcesIgnored.ts +47 -0
  16. package/src/lib/shared/streamResponse.ts +8 -1
  17. package/src/lib/shared/types/RemoteCallable.ts +12 -3
  18. package/src/lib/shared/types/RpcOptions.ts +22 -0
  19. package/src/lib/shared/types/SourceMap.ts +14 -0
  20. package/src/lib/ui/compile/SSR_ESCAPE.ts +13 -3
  21. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +5 -0
  22. package/src/lib/ui/compile/compileModule.ts +5 -2
  23. package/src/lib/ui/compile/compileSSR.ts +32 -9
  24. package/src/lib/ui/compile/compileShadow.ts +11 -3
  25. package/src/lib/ui/compile/composeProps.ts +53 -0
  26. package/src/lib/ui/compile/desugarSignals.ts +45 -17
  27. package/src/lib/ui/compile/generateBuild.ts +46 -18
  28. package/src/lib/ui/compile/generateSSR.ts +196 -52
  29. package/src/lib/ui/compile/lowerDocAccess.ts +53 -16
  30. package/src/lib/ui/compile/parseTemplate.ts +44 -1
  31. package/src/lib/ui/compile/spreadExcludedNames.ts +27 -0
  32. package/src/lib/ui/compile/staticAttr.ts +1 -1
  33. package/src/lib/ui/compile/staticTextPart.ts +1 -1
  34. package/src/lib/ui/compile/types/TemplateAttr.ts +4 -1
  35. package/src/lib/ui/compile/types/TemplateNode.ts +3 -1
  36. package/src/lib/ui/dom/mergeProps.ts +32 -0
  37. package/src/lib/ui/dom/readCall.ts +27 -0
  38. package/src/lib/ui/dom/restProps.ts +32 -0
  39. package/src/lib/ui/dom/spreadAttrs.ts +34 -0
  40. package/src/lib/ui/dom/spreadProps.ts +32 -0
  41. package/src/lib/ui/installHotBridge.ts +10 -0
  42. package/src/lib/ui/remoteProxy.ts +68 -36
  43. package/src/lib/ui/renderChain.ts +39 -37
  44. package/src/lib/ui/renderToStream.ts +69 -61
  45. package/src/lib/ui/resumeSeedScript.ts +17 -0
  46. package/src/lib/ui/router.ts +81 -51
  47. package/src/lib/ui/runtime/createEffectNode.ts +5 -0
  48. package/src/lib/ui/runtime/localStoragePersistence.ts +8 -1
  49. package/src/lib/ui/runtime/toTeardown.ts +10 -5
  50. package/src/lib/ui/runtime/types/RenderContext.ts +8 -0
  51. package/src/lib/ui/runtime/types/SsrRender.ts +16 -11
  52. package/src/lib/ui/runtime/types/UiComponent.ts +7 -1
  53. package/src/lib/ui/compile/escapeHtml.ts +0 -15
  54. /package/src/lib/{server/runtime → shared}/safeJsonForScript.ts +0 -0
package/AGENTS.md CHANGED
@@ -69,7 +69,7 @@ Test suites compile `.abide` and rewrite verbs under `bun test` via `preload = [
69
69
 
70
70
  ### RPC verbs — @documentation rpc
71
71
 
72
- - `abide/server/GET`, `abide/server/POST`, `abide/server/PUT`, `abide/server/PATCH`, `abide/server/DELETE`, `abide/server/HEAD` — declare a verb: `VERB(handler, opts?)`; the preload rewrites each to `defineVerb(method, url, …)` (server) or `remoteProxy` (client). `opts`: `inputSchema`, `outputSchema`, `filesSchema`, `clients`, `crossOrigin`, `maxBodySize`, `timeout`. The returned function is callable in-process and exposes `.raw(args)` (the `Response`), `.stream(args)` (frame iterable), and `.fetch(request)` (router entry).
72
+ - `abide/server/GET`, `abide/server/POST`, `abide/server/PUT`, `abide/server/PATCH`, `abide/server/DELETE`, `abide/server/HEAD` — declare a verb: `VERB(handler, opts?)`; the preload rewrites each to `defineVerb(method, url, …)` (server) or `remoteProxy` (client). `opts`: `inputSchema`, `outputSchema`, `filesSchema`, `clients`, `crossOrigin`, `maxBodySize`, `timeout`. The returned function is callable in-process as `fn(args, callOpts?)` and exposes `.raw(args, callOpts?)` (the `Response`), `.stream(args)` (frame iterable), and `.fetch(request)` (router entry). `callOpts` is a curated `Pick` of `RequestInit` for per-call transport — `signal`, `keepalive`, `priority`, `cache`, `headers` — that the server handler never sees, so the call stays isomorphic: `signal` merges with the scope abort + client timeout (`AbortSignal.any`, ignored under `cache()`), and `headers` merge onto the framework headers with `traceparent`/offline/`content-type` winning.
73
73
  - `abide/server/rpc/defineVerb` — the rewrite target the verb helpers expand to: validates args against `inputSchema` (422 on issues), enforces `timeout` (504 + abort), and registers the verb for MCP/CLI/OpenAPI/inspector discovery.
74
74
 
75
75
  ### Responses — @documentation response
@@ -162,7 +162,7 @@ Test suites compile `.abide` and rewrite verbs under `bun test` via `preload = [
162
162
  - `abide/ui/enterScope`, `abide/ui/exitScope` — push/pop the lexical scope around an SSR render.
163
163
  - `abide/ui/router`, `abide/ui/startClient`, `abide/ui/renderToStream` — the client router, the official client entry (reads `window.__SSR__`, seeds cache, starts the router), and the out-of-order SSR streamer.
164
164
  - `abide/ui/remoteProxy`, `abide/ui/socketProxy` — the bundler-emitted client substitutes that swap a server verb/socket import for a `fetch` proxy / ws-multiplexed `Socket`.
165
- - `abide/ui/dom/*` — the compiled-template DOM runtime (one node-builder per construct): `mount`, `mountChild`, `mountSlot`, `outlet`, `hydrate`, `skeleton`, `cloneStatic`, `anchorCursor`, `text`, `appendText`, `appendTextAt`, `appendSnippet`, `appendStatic`, `attr`, `on`, `attach`, `each`, `eachAsync`, `when`, `awaitBlock`, `tryBlock`, `switchBlock`, `applyResolved`. `outlet` is the marker-range a layout `<slot/>` compiles to (no wrapper element — the router fills it with the next chain layer as a direct child).
165
+ - `abide/ui/dom/*` — the compiled-template DOM runtime (one node-builder per construct): `mount`, `mountChild`, `mergeProps`, `spreadProps`, `restProps`, `spreadAttrs`, `readCall`, `mountSlot`, `outlet`, `hydrate`, `skeleton`, `cloneStatic`, `anchorCursor`, `text`, `appendText`, `appendTextAt`, `appendSnippet`, `appendStatic`, `attr`, `on`, `attach`, `each`, `eachAsync`, `when`, `awaitBlock`, `tryBlock`, `switchBlock`, `applyResolved`. `outlet` is the marker-range a layout `<slot/>` compiles to (no wrapper element — the router fills it with the next chain layer as a direct child). `readCall` guards a method call lowered onto a reactive-doc read (`model.draft.trim()` → `readCall(model.read("draft"), …)`) so a nullish read throws naming the scope path and member, not the engine's opaque `undefined is not an object`. A `{...expr}` spreads onto a component (its props, via `mergeProps`/`spreadProps`) or a native element (its attributes, via `spreadAttrs` — each key reactive, an `on<event>` function becomes a listener); keys resolve last-wins in source order. A component reads the props it doesn't name with `const { foo, ...rest } = props()` — `rest` (a `restProps` bag of the unconsumed props) can itself be spread onward (`<input {...rest}/>`).
166
166
  - `abide/ui/runtime/*` — render-pass helpers: `escapeKey` (JSON-Pointer key escaping), `nextBlockId`, `enterRenderPass`, `exitRenderPass`.
167
167
 
168
168
  ## Build / tooling — @documentation building
@@ -171,7 +171,7 @@ Test suites compile `.abide` and rewrite verbs under `bun test` via `preload = [
171
171
  - `abide/compile` — `compile({ cwd?, target?, outfile?, buildClient? })`: produce a standalone Bun server executable (bytecode, embedded compressed assets).
172
172
  - `abide/preload` — the Bun preload (`bunfig.toml`) that installs the `.abide`, resolver, and CSS-noop plugins and rewrites verb/socket imports.
173
173
  - `abide/resolver-plugin` — `abideResolverPlugin({ cwd?, embedAssets?, target? })`: virtualizes every generated module and rewrites verbs/sockets per side.
174
- - `abide/ui-plugin` — `abideUiPlugin`: the Bun loader compiling `.abide` SFCs to ES modules (scoped `<style>` via virtual imports; `layout.abide` `<abide-outlet>`).
174
+ - `abide/ui-plugin` — `abideUiPlugin`: the Bun loader compiling `.abide` SFCs to ES modules (scoped `<style>` via virtual imports; a `layout.abide`'s `<slot/>` lowers to the marker-range `outlet` the router fills as a direct child, no wrapper element).
175
175
  - `abide/tsconfig` — `tsconfig.app.json` for consumers to extend (`bundler` resolution, `strict`, `noEmit`, `bun` types).
176
176
 
177
177
  ## Desktop bundle — @documentation bundle
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # abide
2
2
 
3
+ ## 0.39.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`ae15b27`](https://github.com/briancray/abide/commit/ae15b2733c3c569b9bde9e4d79f9b768836b3a46) - async SSR render with inline nested-blocking awaits and spread/rest props ([`514a796`](https://github.com/briancray/abide/commit/514a796833c5be4caa296b295d63fda6c8452a36))
8
+
9
+ - [`527795d`](https://github.com/briancray/abide/commit/527795d1035f7cb169723a03571603a9ce8556e1) - Remote functions now accept an optional trailing options bag — `fn(args, opts)` — for per-call transport control: `signal`, `keepalive`, `priority`, `cache`, and `headers`. It's a curated `Pick` of `RequestInit`, not a raw passthrough: the server handler never observes these, so the call stays isomorphic, and a caller can't clobber the method, body, or framework headers the RPC contract owns. `opts.signal` merges with the scope abort and client timeout (`AbortSignal.any`) rather than replacing them, and is ignored under `cache()` so one reader can't abort a coalesced flight the others share. `opts.headers` merge onto abide's headers with the framework winning — a caller adds transport metadata (idempotency-key, authorization) but can't overwrite `traceparent`, the offline marker, or `content-type`.
10
+
11
+ ### Patch Changes
12
+
13
+ - [`ae15b27`](https://github.com/briancray/abide/commit/ae15b2733c3c569b9bde9e4d79f9b768836b3a46) - harden client router, asset serving, sockets, and runtime edge cases ([`4735920`](https://github.com/briancray/abide/commit/4735920edea942dcd2954c522673b0c8af5a9a7f))
14
+
15
+ - [`ae15b27`](https://github.com/briancray/abide/commit/ae15b2733c3c569b9bde9e4d79f9b768836b3a46) - consolidate escapeHtml and safeJsonForScript into shared/ ([`ae2d602`](https://github.com/briancray/abide/commit/ae2d6022f6dc23994164d1764bf5b812b136e383))
16
+
17
+ - [`76a4a19`](https://github.com/briancray/abide/commit/76a4a19048fd5373b0d3ed75f2c6de017e33c7a3) - Make reactive errors and stack traces read in authored terms. A method call lowered onto a reactive-doc read now routes through a guard (`readCall`) that throws naming the scope path and member (`cannot call .close() — scope value "modal" is undefined`) instead of the engine's opaque `undefined is not an object`. The client build's source maps ignore-list abide's own framework sources, so a debugger collapses the mount-stack wall (`scope`/`mountRange`/`runNode`/…) and shows only authored `.abide`/`.ts` frames. Reactive bindings emit named thunks (`attr_title`/`text`/`bind_value`) so those surviving frames carry a name instead of `(anonymous)`.
18
+
3
19
  ## 0.38.1
4
20
 
5
21
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.38.1",
3
+ "version": "0.39.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
@@ -79,6 +79,11 @@
79
79
  "./ui/outbox": "./src/lib/ui/outbox.ts",
80
80
  "./ui/dom/mount": "./src/lib/ui/dom/mount.ts",
81
81
  "./ui/dom/mountChild": "./src/lib/ui/dom/mountChild.ts",
82
+ "./ui/dom/mergeProps": "./src/lib/ui/dom/mergeProps.ts",
83
+ "./ui/dom/spreadProps": "./src/lib/ui/dom/spreadProps.ts",
84
+ "./ui/dom/restProps": "./src/lib/ui/dom/restProps.ts",
85
+ "./ui/dom/spreadAttrs": "./src/lib/ui/dom/spreadAttrs.ts",
86
+ "./ui/dom/readCall": "./src/lib/ui/dom/readCall.ts",
82
87
  "./ui/dom/hydrate": "./src/lib/ui/dom/hydrate.ts",
83
88
  "./ui/dom/text": "./src/lib/ui/dom/text.ts",
84
89
  "./ui/dom/appendText": "./src/lib/ui/dom/appendText.ts",
@@ -127,6 +132,9 @@
127
132
  "./ui/socketProxy": "./src/lib/ui/socketProxy.ts",
128
133
  "./server/prompts/renderPromptTemplate": "./src/lib/server/prompts/renderPromptTemplate.ts"
129
134
  },
135
+ "scripts": {
136
+ "test": "bun test ./tests"
137
+ },
130
138
  "bin": {
131
139
  "abide": "./bin/abide.ts"
132
140
  },
package/src/build.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { clientBuildPlugins } from './clientBuildPlugins.ts'
2
2
  import { abideLog } from './lib/shared/abideLog.ts'
3
3
  import { exitOnBuildFailure } from './lib/shared/exitOnBuildFailure.ts'
4
+ import { markFrameworkSourcesIgnored } from './lib/shared/markFrameworkSourcesIgnored.ts'
4
5
 
5
6
  const CLIENT_ENTRY = new URL('./clientEntry.ts', import.meta.url).pathname
6
7
 
@@ -97,6 +98,19 @@ export async function build({
97
98
  return false
98
99
  }
99
100
 
101
+ /* Ignore-list abide's own framework sources in every emitted map, so a debugger
102
+ collapses the mount-stack wall (scope/mountRange/runNode/…) and a stack trace
103
+ shows only authored `.abide`/`.ts` frames. Runs before the gzip step so the
104
+ `.gz` siblings compress the updated maps. */
105
+ await Promise.all(
106
+ result.outputs
107
+ .filter((output) => output.kind === 'sourcemap')
108
+ .map(async (output) => {
109
+ const map = await Bun.file(output.path).json()
110
+ await Bun.write(output.path, JSON.stringify(markFrameworkSourcesIgnored(map)))
111
+ }),
112
+ )
113
+
100
114
  // Dev skips the gzip siblings (paths still point into stagingDir here).
101
115
  const compressedBytes = compress
102
116
  ? (
@@ -10,10 +10,12 @@ the var is absent (standalone `abide start`).
10
10
  */
11
11
  export function exitWithParent(): void {
12
12
  const parent = process.env.ABIDE_PARENT_PID
13
- if (!parent) {
13
+ const parentPid = Number(parent)
14
+ /* A non-numeric value is truthy but coerces to NaN; without the integer guard
15
+ process.kill(NaN, 0) throws on the first tick and exits a healthy server. */
16
+ if (!parent || !Number.isInteger(parentPid)) {
14
17
  return
15
18
  }
16
- const parentPid = Number(parent)
17
19
  const timer = setInterval(() => {
18
20
  try {
19
21
  // Signal 0 sends nothing — it only probes existence, throwing when the
@@ -40,5 +40,8 @@ export async function readBodyWithinLimit(request: Request, maxBytes: number): P
40
40
  method: request.method,
41
41
  headers: request.headers,
42
42
  body: buffered,
43
+ /* Carry the inbound signal forward so a handler reading `request().signal` still
44
+ aborts on client disconnect — a fresh Request would otherwise drop it. */
45
+ signal: request.signal,
43
46
  })
44
47
  }
@@ -34,13 +34,19 @@ export async function createAppAssetServer({
34
34
  }): Promise<(req: Request, url: URL) => Promise<Response>> {
35
35
  // Per-pathname asset header bundles, hashed-chunk-aware Cache-Control.
36
36
  const headersForAsset = createAssetHeaderCache(cacheControlForAsset)
37
- const diskGzipPaths = assets
37
+ /* Boot snapshot of every disk `_app` path, mirroring createPublicAssetServer: the
38
+ header cache keys on (request-controlled) pathnames, so building bundles for junk
39
+ `/_app/*` probes would grow it without bound. A rebuild restarts the server (same
40
+ restart `bun --watch` triggers on a code change), re-snapshotting. */
41
+ const diskPaths = assets
38
42
  ? new Set<string>()
39
- : await globToPathSet(
40
- `${distDir}/_app`,
41
- '**/*.gz',
42
- (file) => `/_app/${file.replace(/\.gz$/, '')}`,
43
- )
43
+ : await globToPathSet(`${distDir}/_app`, '**/*', (file) => `/_app/${file}`)
44
+ /* Derive the precompressed `.gz` sibling set from the single tree scan (it already
45
+ includes the `.gz` files) — a gzip-capable client gets those bytes without
46
+ on-the-fly compression. Keyed by the base path (the `.gz` suffix stripped). */
47
+ const diskGzipPaths = new Set(
48
+ [...diskPaths].filter((path) => path.endsWith('.gz')).map((path) => path.slice(0, -3)),
49
+ )
44
50
 
45
51
  return async function serveAppAsset(req, url) {
46
52
  if (containsTraversal(req.url)) {
@@ -49,8 +55,20 @@ export async function createAppAssetServer({
49
55
  headers: { 'Content-Type': TEXT_PLAIN, 'Cache-Control': NO_STORE },
50
56
  })
51
57
  }
58
+ /* Embed map and gzip Set hold decoded filesystem names; url.pathname stays
59
+ percent-encoded — decode so a chunk/asset with a non-ASCII name matches and
60
+ Bun.file opens the real path, not a literal `%xx` name. */
61
+ let assetPath: string
62
+ try {
63
+ assetPath = decodeURIComponent(url.pathname)
64
+ } catch {
65
+ return new Response('Not Found', {
66
+ status: 404,
67
+ headers: { 'Content-Type': TEXT_PLAIN, 'Cache-Control': NO_STORE },
68
+ })
69
+ }
52
70
  if (assets) {
53
- const compressed = assets[url.pathname]
71
+ const compressed = assets[assetPath]
54
72
  /* Miss-check before header work: the header cache keys on
55
73
  (request-controlled) pathnames, so building bundles for junk
56
74
  `/_app/*` probes would grow it without bound. */
@@ -63,12 +81,20 @@ export async function createAppAssetServer({
63
81
  return respondWithEmbeddedAsset(
64
82
  compressed,
65
83
  acceptsGzip(req),
66
- headersForAsset(url.pathname),
84
+ headersForAsset(assetPath),
67
85
  )
68
86
  }
69
- const { base: baseHeaders, gzip: gzipHeaders } = headersForAsset(url.pathname)
70
- const diskPath = distDir + url.pathname
71
- if (acceptsGzip(req) && diskGzipPaths.has(url.pathname)) {
87
+ /* Miss-check before header work so probes for non-existent chunks can't grow the
88
+ header cache (the embed branch above guards the same way). */
89
+ if (!diskPaths.has(assetPath)) {
90
+ return new Response('Not Found', {
91
+ status: 404,
92
+ headers: { 'Content-Type': TEXT_PLAIN, 'Cache-Control': NO_STORE },
93
+ })
94
+ }
95
+ const { base: baseHeaders, gzip: gzipHeaders } = headersForAsset(assetPath)
96
+ const diskPath = distDir + assetPath
97
+ if (acceptsGzip(req) && diskGzipPaths.has(assetPath)) {
72
98
  return new Response(Bun.file(`${diskPath}.gz`), { headers: gzipHeaders })
73
99
  }
74
100
  return new Response(Bun.file(diskPath), { headers: baseHeaders })
@@ -50,18 +50,27 @@ export async function createPublicAssetServer({
50
50
  if (containsTraversal(req.url)) {
51
51
  return undefined
52
52
  }
53
+ /* Both the embed map and the disk Set hold decoded filesystem names, but
54
+ url.pathname stays percent-encoded — decode it so a file whose name has a
55
+ space or non-ASCII char matches. A malformed escape can't name a real file. */
56
+ let assetPath: string
57
+ try {
58
+ assetPath = decodeURIComponent(url.pathname)
59
+ } catch {
60
+ return undefined
61
+ }
53
62
  if (publicAssets) {
54
- const compressed = publicAssets[url.pathname]
63
+ const compressed = publicAssets[assetPath]
55
64
  if (!compressed) {
56
65
  return undefined
57
66
  }
58
- return respondWithEmbeddedAsset(compressed, acceptsGzip(req), headersFor(url.pathname))
67
+ return respondWithEmbeddedAsset(compressed, acceptsGzip(req), headersFor(assetPath))
59
68
  }
60
- if (!diskPaths.has(url.pathname)) {
69
+ if (!diskPaths.has(assetPath)) {
61
70
  return undefined
62
71
  }
63
- return new Response(Bun.file(publicDir + url.pathname), {
64
- headers: headersFor(url.pathname).base,
72
+ return new Response(Bun.file(publicDir + assetPath), {
73
+ headers: headersFor(assetPath).base,
65
74
  })
66
75
  }
67
76
  }
@@ -3,16 +3,17 @@ import { SSR_CACHE_CONTROL } from '../../shared/CACHE_CONTROL_VALUES.ts'
3
3
  import { formatTraceparent } from '../../shared/formatTraceparent.ts'
4
4
  import { isReplayableMethod } from '../../shared/isReplayableMethod.ts'
5
5
  import { layoutChainForRoute } from '../../shared/layoutChainForRoute.ts'
6
+ import { safeJsonForScript } from '../../shared/safeJsonForScript.ts'
6
7
  import type { CacheEntry } from '../../shared/types/CacheEntry.ts'
7
8
  import type { CacheSnapshotEntry } from '../../shared/types/CacheSnapshotEntry.ts'
8
9
  import type { StreamedResolution } from '../../shared/types/StreamedResolution.ts'
9
10
  import { renderChain } from '../../ui/renderChain.ts'
10
11
  import { renderToStream } from '../../ui/renderToStream.ts'
12
+ import { resumeSeedScript } from '../../ui/resumeSeedScript.ts'
11
13
  import type { UiComponent } from '../../ui/runtime/types/UiComponent.ts'
12
14
  import { pageUrlFromStore } from './pageUrlFromStore.ts'
13
15
  import { SSR_SWAP_SCRIPT } from './SSR_SWAP_SCRIPT.ts'
14
16
  import { STREAMED_HTML_HEADER } from './STREAMED_HTML_HEADER.ts'
15
- import { safeJsonForScript } from './safeJsonForScript.ts'
16
17
  import { serializeCacheSnapshot } from './serializeCacheSnapshot.ts'
17
18
  import { streamCacheResolutions } from './streamCacheResolutions.ts'
18
19
  import type { RequestStore } from './types/RequestStore.ts'
@@ -33,11 +34,11 @@ function wantsJson(request: Request): boolean {
33
34
  const CACHE_RESOLVE_SCRIPT =
34
35
  'window.__abideResolve=function(r){(window.__abideResumeCache=window.__abideResumeCache||[]).push(r)}'
35
36
 
36
- /* One streamed cache resolution as an inline `__abideResolve(...)` call. `<` is escaped
37
- (as in encodeResume) so a body value can't close the script early. */
37
+ /* One streamed cache resolution as an inline `__abideResolve(...)` call. Encoded via
38
+ safeJsonForScript so `<`, `-->`, and U+2028/U+2029 can't close the script early or
39
+ parse as line terminators — the same escaping the page's other inline scripts use. */
38
40
  function resolveChunk(resolution: StreamedResolution): string {
39
- const payload = JSON.stringify(resolution).replace(/</g, '\\u003c')
40
- return `<script>__abideResolve(${payload})</script>`
41
+ return `<script>__abideResolve(${safeJsonForScript(resolution)})</script>`
41
42
  }
42
43
 
43
44
  /*
@@ -168,19 +169,21 @@ export function createUiPageRenderer({
168
169
  ...chainKeys.map((key) => layouts[key]?.().then((module) => module.default)),
169
170
  loadPage().then((module) => module.default),
170
171
  ])
171
- const ssr = renderChain(
172
+ const ssr = await renderChain(
172
173
  views.filter((view): view is UiComponent => view !== undefined),
173
174
  params,
174
175
  )
175
176
 
176
- /* Snapshot the cache settled by render-return — top-level `await` reads (render
177
- blocked on them) — into __SSR__. A `{#await}` read is NOT here: its expression
178
- is a thunk `renderToStream` runs lazily, so its entry is created mid-stream and
179
- seeded after the drain (see below). */
177
+ /* Snapshot the cache settled by render-return — top-level `await` reads and blocking
178
+ `{#await then}` reads (the async render awaited them inline) — into __SSR__. A
179
+ STREAMING `{#await}` read is NOT here: its expression is a thunk `renderToStream`
180
+ runs lazily, so its entry is created mid-stream and seeded after the drain (below). */
180
181
  const inline = await serializeCacheSnapshot(store.cache)
181
182
  const inlinedKeys = new Set(inline.map((entry) => entry.key))
182
183
 
183
- /* No await blocks → render synchronously, ship buffered. */
184
+ /* No STREAMING await blocks → ship buffered. Blocking `{#await … then}` blocks
185
+ rendered inline (their markup is already in `ssr.html`); seed their resolved
186
+ values so hydration adopts them without a refetch. */
184
187
  if (ssr.awaits.length === 0) {
185
188
  const html = injectRoutePreloads(
186
189
  shell.replace(SSR_MARKER, (_match, key: string) =>
@@ -190,7 +193,7 @@ export function createUiPageRenderer({
190
193
  )
191
194
  const withState = html.replace(
192
195
  '</body>',
193
- `${await stateTag(routeUrl, params, store, inline)}</body>`,
196
+ `${resumeSeedScript(ssr.resume)}${await stateTag(routeUrl, params, store, inline)}</body>`,
194
197
  )
195
198
  return new Response(withState, {
196
199
  headers: {
@@ -219,38 +222,46 @@ export function createUiPageRenderer({
219
222
  return new Response(
220
223
  new ReadableStream({
221
224
  async start(controller) {
222
- controller.enqueue(encoder.encode(before ?? ''))
223
- let first = true
224
- for await (const chunk of renderToStream(() => ssr)) {
225
- controller.enqueue(
226
- encoder.encode(
227
- first ? chunk : `${chunk}<script>__abideSwap()</script>`,
228
- ),
225
+ /* The shell `before` already flushed, so a mid-stream render rejection
226
+ (a streaming `{#await}` with no `:catch`) can't become a 500 — surface it
227
+ on the stream (`controller.error`) so the response terminates legibly
228
+ instead of leaking an unhandledrejection (process-fatal under Bun). */
229
+ try {
230
+ controller.enqueue(encoder.encode(before ?? ''))
231
+ let first = true
232
+ for await (const chunk of renderToStream(() => ssr)) {
233
+ controller.enqueue(
234
+ encoder.encode(
235
+ first ? chunk : `${chunk}<script>__abideSwap()</script>`,
236
+ ),
237
+ )
238
+ first = false
239
+ }
240
+ /* The {#await} reads created (and settled) their cache entries DURING the
241
+ stream — the await expression is a thunk `renderToStream` ran lazily —
242
+ so they missed the render-return snapshot. Drain them now: each lands an
243
+ inline `__abideResolve(...)` chunk (a warm snapshot, or a `{ key, miss }`
244
+ marker for an unshippable body → live refetch) before the deferred
245
+ bundle, so startClient seeds the store and the block's subscription read
246
+ is warm. Skip keys already shipped inline in __SSR__. */
247
+ const streamedEntries = Array.from(store.cache.entries.values()).filter(
248
+ (entry: CacheEntry) =>
249
+ entry.settled === true &&
250
+ entry.request !== undefined &&
251
+ isReplayableMethod(entry.request.method.toUpperCase()) &&
252
+ !inlinedKeys.has(entry.key),
229
253
  )
230
- first = false
254
+ for await (const resolution of streamCacheResolutions(
255
+ store.cache,
256
+ streamedEntries,
257
+ )) {
258
+ controller.enqueue(encoder.encode(resolveChunk(resolution)))
259
+ }
260
+ controller.enqueue(encoder.encode(after ?? ''))
261
+ controller.close()
262
+ } catch (streamError) {
263
+ controller.error(streamError)
231
264
  }
232
- /* The {#await} reads created (and settled) their cache entries DURING the
233
- stream — the await expression is a thunk `renderToStream` ran lazily —
234
- so they missed the render-return snapshot. Drain them now: each lands an
235
- inline `__abideResolve(...)` chunk (a warm snapshot, or a `{ key, miss }`
236
- marker for an unshippable body → live refetch) before the deferred
237
- bundle, so startClient seeds the store and the block's subscription read
238
- is warm. Skip keys already shipped inline in __SSR__. */
239
- const streamedEntries = Array.from(store.cache.entries.values()).filter(
240
- (entry: CacheEntry) =>
241
- entry.settled === true &&
242
- entry.request !== undefined &&
243
- isReplayableMethod(entry.request.method.toUpperCase()) &&
244
- !inlinedKeys.has(entry.key),
245
- )
246
- for await (const resolution of streamCacheResolutions(
247
- store.cache,
248
- streamedEntries,
249
- )) {
250
- controller.enqueue(encoder.encode(resolveChunk(resolution)))
251
- }
252
- controller.enqueue(encoder.encode(after ?? ''))
253
- controller.close()
254
265
  },
255
266
  }),
256
267
  {
@@ -1,4 +1,5 @@
1
1
  import { NO_STORE } from '../../shared/CACHE_CONTROL_VALUES.ts'
2
+ import { escapeHtml } from '../../shared/escapeHtml.ts'
2
3
 
3
4
  /*
4
5
  The framework's default 500 response. Shared by the per-request scope's catch
@@ -8,12 +9,14 @@ drift. Only reached when the app supplies no `handleError` hook.
8
9
  Secure by default: a bare `Internal Server Error` so paths, library versions,
9
10
  and message contents never leak to clients in production. The full stack is
10
11
  shown only under `abide dev` (ABIDE_DEV=1, the orchestrator's signal); the
11
- cause is logged server-side regardless.
12
+ cause is logged server-side regardless. The stack is HTML-escaped because a
13
+ thrown Error built from request-influenced input can carry markup that would
14
+ otherwise execute in the dev browser when embedded in the `<pre>`.
12
15
  */
13
16
  export function internalErrorResponse(error: unknown): Response {
14
17
  const body =
15
18
  Bun.env.ABIDE_DEV === '1'
16
- ? `<pre>${String((error as Error)?.stack ?? error)}</pre>`
19
+ ? `<pre>${escapeHtml(String((error as Error)?.stack ?? error))}</pre>`
17
20
  : 'Internal Server Error'
18
21
  return new Response(body, {
19
22
  status: 500,
@@ -52,7 +52,10 @@ export async function snapshotEntryFromCache(
52
52
  if (!isTextual(contentType)) {
53
53
  return undefined
54
54
  }
55
- const body = await response.text()
55
+ /* Read a CLONE, not the original: a reader that captured this same `entry.promise`
56
+ before the replacement below still holds `response` and may `.clone()` it — reading
57
+ the original here would lock its body and throw "Body already used" for that reader. */
58
+ const body = await response.clone().text()
56
59
  entry.promise = Promise.resolve(
57
60
  new Response(body, {
58
61
  status: response.status,
@@ -152,6 +152,13 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
152
152
  return fail(resolution.message)
153
153
  }
154
154
  const { entry } = resolution
155
+ /* A client reusing a live `sub` id rebinds it: drop the prior binding first (and
156
+ unsubscribe its topic if that was the last local sub) so the old socket's Set
157
+ doesn't retain the id forever, leaking the topic subscription. */
158
+ const rebound = removeSub(state, frame.sub)
159
+ if (rebound) {
160
+ ws.unsubscribe(`socket:${rebound}`)
161
+ }
155
162
  const isFirstLocalSub = addSub(state, frame.socket, frame.sub)
156
163
  if (isFirstLocalSub) {
157
164
  ws.subscribe(`socket:${frame.socket}`)
@@ -8,6 +8,7 @@ import type { ClientFlags } from './types/ClientFlags.ts'
8
8
  import type { HttpVerb } from './types/HttpVerb.ts'
9
9
  import type { RawRemoteFunction } from './types/RawRemoteFunction.ts'
10
10
  import type { RemoteFunction } from './types/RemoteFunction.ts'
11
+ import type { RpcOptions } from './types/RpcOptions.ts'
11
12
  import type { Subscribable } from './types/Subscribable.ts'
12
13
 
13
14
  /*
@@ -39,8 +40,12 @@ export function createRemoteFunction<Args, Return>(opts: {
39
40
  clients: ClientFlags
40
41
  /* Server-side only: exempts a mutating verb from the router's same-origin CSRF gate. */
41
42
  crossOrigin?: boolean
42
- buildRequest: (args: Args | undefined) => Request
43
- invoke: (args: Args | undefined, getRequest: () => Request) => Promise<Response>
43
+ buildRequest: (args: Args | undefined, opts?: RpcOptions) => Request
44
+ invoke: (
45
+ args: Args | undefined,
46
+ getRequest: () => Request,
47
+ opts?: RpcOptions,
48
+ ) => Promise<Response>
44
49
  parseArgsForFetch?: (request: Request) => Promise<Args | undefined>
45
50
  }): RemoteFunction<Args, Return> {
46
51
  const { method, url, clients, crossOrigin, buildRequest, invoke, parseArgsForFetch } = opts
@@ -52,15 +57,19 @@ export function createRemoteFunction<Args, Return>(opts: {
52
57
  short-circuits to the prebuilt one — and caches the result so the
53
58
  client invoke + the cache meta reader share a single Request.
54
59
  */
55
- function dispatch(args: Args | undefined, prebuilt?: Request): Promise<Response> {
60
+ function dispatch(
61
+ args: Args | undefined,
62
+ opts?: RpcOptions,
63
+ prebuilt?: Request,
64
+ ): Promise<Response> {
56
65
  let cached = prebuilt
57
66
  function getRequest(): Request {
58
67
  if (cached === undefined) {
59
- cached = buildRequest(args)
68
+ cached = buildRequest(args, opts)
60
69
  }
61
70
  return cached
62
71
  }
63
- const promise = invoke(args, getRequest)
72
+ const promise = invoke(args, getRequest, opts)
64
73
  recordRemoteMeta(promise, getRequest)
65
74
  return promise
66
75
  }
@@ -72,8 +81,8 @@ export function createRemoteFunction<Args, Return>(opts: {
72
81
  contained type lie — buildRpcRequest's `instanceof FormData` branch handles
73
82
  it at runtime.
74
83
  */
75
- function rawCall(args: Args | FormData): Promise<Response> {
76
- return dispatch(args as Args)
84
+ function rawCall(args: Args | FormData, opts?: RpcOptions): Promise<Response> {
85
+ return dispatch(args as Args, opts)
77
86
  }
78
87
  rawCall.method = method
79
88
  rawCall.url = url
@@ -81,8 +90,8 @@ export function createRemoteFunction<Args, Return>(opts: {
81
90
  Object.defineProperty(rawCall, REMOTE_FUNCTION, { value: true })
82
91
  const raw = rawCall as RawRemoteFunction<Args>
83
92
 
84
- function callable(args: Args | FormData): Promise<Return> {
85
- return raw(args).then(decodeResponse) as Promise<Return>
93
+ function callable(args: Args | FormData, opts?: RpcOptions): Promise<Return> {
94
+ return raw(args, opts).then(decodeResponse) as Promise<Return>
86
95
  }
87
96
  callable.method = method
88
97
  callable.url = url
@@ -113,10 +122,10 @@ export function createRemoteFunction<Args, Return>(opts: {
113
122
  }
114
123
  throw error
115
124
  }
116
- return dispatch(args, request)
125
+ return dispatch(args, undefined, request)
117
126
  }
118
127
  : (request: Request): Promise<Response> => {
119
- return dispatch(undefined, request)
128
+ return dispatch(undefined, undefined, request)
120
129
  }
121
130
  return callable as RemoteFunction<Args, Return>
122
131
  }
@@ -0,0 +1,15 @@
1
+ /*
2
+ HTML-escapes the five HTML-significant characters — the same set the runtime
3
+ `$esc` handles. Isomorphic: shared by the build-time SSR/static-clone generators
4
+ (so server markup and the client clone template can't diverge on escaping) and
5
+ the server runtime's dev error page. Static text reaches the compile callers
6
+ already entity-decoded (see parseTemplate), so escaping round-trips it through
7
+ the HTML parser to the same plain text the client would build directly.
8
+ */
9
+ export function escapeHtml(value: string): string {
10
+ return value.replace(
11
+ /[&<>"']/g,
12
+ (char) =>
13
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char] ?? char,
14
+ )
15
+ }
@@ -0,0 +1,47 @@
1
+ import type { SourceMap } from './types/SourceMap.ts'
2
+
3
+ /*
4
+ The path fragment that identifies abide's own framework sources in a source map.
5
+ The mount stack's "wall" — `scope`, `withScope`, `mountRange`, `runNode`,
6
+ `createEffectNode`, … — all live under the package's `src/lib/`; an author's
7
+ `.abide`/`.ts` frames never do. Matched as the contiguous directory + lib path
8
+ (not the npm name `@abide/abide`, which differs from the on-disk dir `abide`), so
9
+ it holds across the monorepo (`packages/abide/src/lib/`) and an install
10
+ (`node_modules/@abide/abide/src/lib/`) alike, while a user app dir like
11
+ `my-abide-app/src/lib/` does not match.
12
+
13
+ DERIVED from THIS module's own location (`…/<pkgDir>/src/lib/shared/…`) rather than
14
+ hardcoded, so it tracks a package-dir rename instead of silently going stale (an
15
+ empty ignore-list = the mount-stack wall reappears, which no test would catch). Falls
16
+ back to the literal if the expected `/src/lib/` layout is ever absent.
17
+ */
18
+ const FRAMEWORK_LIB_PATH = ((): string => {
19
+ const LIB_MARKER = '/src/lib/'
20
+ const here = new URL(import.meta.url).pathname
21
+ const libIndex = here.indexOf(LIB_MARKER)
22
+ if (libIndex === -1) {
23
+ return 'abide/src/lib/'
24
+ }
25
+ const beforeLib = here.slice(0, libIndex)
26
+ const packageDir = beforeLib.slice(beforeLib.lastIndexOf('/') + 1)
27
+ return `${packageDir}${LIB_MARKER}`
28
+ })()
29
+
30
+ /*
31
+ Marks every abide-framework source in a parsed source map as ignore-listed, so a
32
+ debugger collapses framework frames and a stack trace shows only authored ones —
33
+ the fix for the long mount-stack wall, applied without touching the runtime. Sets
34
+ both the standardized `ignoreList` and Chrome's legacy `x_google_ignoreList` for
35
+ the widest debugger support. Mutates and returns the same map.
36
+ */
37
+ export function markFrameworkSourcesIgnored(map: SourceMap): SourceMap {
38
+ const ignored = (map.sources ?? []).reduce<number[]>((indices, source, index) => {
39
+ if (source !== null && source.includes(FRAMEWORK_LIB_PATH)) {
40
+ indices.push(index)
41
+ }
42
+ return indices
43
+ }, [])
44
+ map.ignoreList = ignored
45
+ map.x_google_ignoreList = ignored
46
+ return map
47
+ }
@@ -122,6 +122,11 @@ async function* parseSse<T>(response: Response): AsyncGenerator<T> {
122
122
  if (errorMessage !== undefined) {
123
123
  throw new Error(errorMessage)
124
124
  }
125
+ /* A frame with an empty `data:` value (a keepalive) carries no payload — skip it
126
+ rather than letting JSON.parse('') throw and tear down the whole iteration. */
127
+ if (frame.data === '') {
128
+ continue
129
+ }
125
130
  yield JSON.parse(frame.data) as T
126
131
  }
127
132
  }
@@ -157,7 +162,9 @@ consumer loops can react to mid-stream failure.
157
162
  */
158
163
  async function* parseJsonLines<T>(response: Response): AsyncGenerator<T> {
159
164
  for await (const raw of frameReader(response, '\n')) {
160
- if (raw.length === 0) {
165
+ /* Skip blank lines: a CRLF stream yields a lone `\r` (non-zero length) between
166
+ records, and JSON.parse('\r') would throw and tear down the whole iteration. */
167
+ if (raw.trim().length === 0) {
161
168
  continue
162
169
  }
163
170
  const parsed = JSON.parse(raw)