@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.
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +16 -0
- package/package.json +9 -1
- package/src/build.ts +14 -0
- package/src/lib/bundle/exitWithParent.ts +4 -2
- package/src/lib/server/rpc/readBodyWithinLimit.ts +3 -0
- package/src/lib/server/runtime/createAppAssetServer.ts +37 -11
- package/src/lib/server/runtime/createPublicAssetServer.ts +14 -5
- package/src/lib/server/runtime/createUiPageRenderer.ts +53 -42
- package/src/lib/server/runtime/internalErrorResponse.ts +5 -2
- package/src/lib/server/runtime/snapshotEntryFromCache.ts +4 -1
- package/src/lib/server/sockets/createSocketDispatcher.ts +7 -0
- package/src/lib/shared/createRemoteFunction.ts +20 -11
- package/src/lib/shared/escapeHtml.ts +15 -0
- package/src/lib/shared/markFrameworkSourcesIgnored.ts +47 -0
- package/src/lib/shared/streamResponse.ts +8 -1
- package/src/lib/shared/types/RemoteCallable.ts +12 -3
- package/src/lib/shared/types/RpcOptions.ts +22 -0
- package/src/lib/shared/types/SourceMap.ts +14 -0
- package/src/lib/ui/compile/SSR_ESCAPE.ts +13 -3
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +5 -0
- package/src/lib/ui/compile/compileModule.ts +5 -2
- package/src/lib/ui/compile/compileSSR.ts +32 -9
- package/src/lib/ui/compile/compileShadow.ts +11 -3
- package/src/lib/ui/compile/composeProps.ts +53 -0
- package/src/lib/ui/compile/desugarSignals.ts +45 -17
- package/src/lib/ui/compile/generateBuild.ts +46 -18
- package/src/lib/ui/compile/generateSSR.ts +196 -52
- package/src/lib/ui/compile/lowerDocAccess.ts +53 -16
- package/src/lib/ui/compile/parseTemplate.ts +44 -1
- package/src/lib/ui/compile/spreadExcludedNames.ts +27 -0
- package/src/lib/ui/compile/staticAttr.ts +1 -1
- package/src/lib/ui/compile/staticTextPart.ts +1 -1
- package/src/lib/ui/compile/types/TemplateAttr.ts +4 -1
- package/src/lib/ui/compile/types/TemplateNode.ts +3 -1
- package/src/lib/ui/dom/mergeProps.ts +32 -0
- package/src/lib/ui/dom/readCall.ts +27 -0
- package/src/lib/ui/dom/restProps.ts +32 -0
- package/src/lib/ui/dom/spreadAttrs.ts +34 -0
- package/src/lib/ui/dom/spreadProps.ts +32 -0
- package/src/lib/ui/installHotBridge.ts +10 -0
- package/src/lib/ui/remoteProxy.ts +68 -36
- package/src/lib/ui/renderChain.ts +39 -37
- package/src/lib/ui/renderToStream.ts +69 -61
- package/src/lib/ui/resumeSeedScript.ts +17 -0
- package/src/lib/ui/router.ts +81 -51
- package/src/lib/ui/runtime/createEffectNode.ts +5 -0
- package/src/lib/ui/runtime/localStoragePersistence.ts +8 -1
- package/src/lib/ui/runtime/toTeardown.ts +10 -5
- package/src/lib/ui/runtime/types/RenderContext.ts +8 -0
- package/src/lib/ui/runtime/types/SsrRender.ts +16 -11
- package/src/lib/ui/runtime/types/UiComponent.ts +7 -1
- package/src/lib/ui/compile/escapeHtml.ts +0 -15
- /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`
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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[
|
|
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(
|
|
84
|
+
headersForAsset(assetPath),
|
|
67
85
|
)
|
|
68
86
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (
|
|
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[
|
|
63
|
+
const compressed = publicAssets[assetPath]
|
|
55
64
|
if (!compressed) {
|
|
56
65
|
return undefined
|
|
57
66
|
}
|
|
58
|
-
return respondWithEmbeddedAsset(compressed, acceptsGzip(req), headersFor(
|
|
67
|
+
return respondWithEmbeddedAsset(compressed, acceptsGzip(req), headersFor(assetPath))
|
|
59
68
|
}
|
|
60
|
-
if (!diskPaths.has(
|
|
69
|
+
if (!diskPaths.has(assetPath)) {
|
|
61
70
|
return undefined
|
|
62
71
|
}
|
|
63
|
-
return new Response(Bun.file(publicDir +
|
|
64
|
-
headers: headersFor(
|
|
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.
|
|
37
|
-
|
|
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
|
-
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
seeded after the drain (
|
|
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 →
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: (
|
|
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(
|
|
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
|
+
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
|
-
|
|
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)
|