@abide/abide 0.100.0 → 0.102.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 +31 -0
- package/bin/abide.ts +227 -163
- package/docs/REGISTRY.md +726 -0
- package/docs/RULEBOOK.md +2753 -0
- package/docs/abide.md +12878 -0
- package/package.json +10 -3
- package/scaffold/AGENTS.md +31 -0
- package/scaffold/CLAUDE.md +1 -0
- package/scaffold/src/layout.abide +18 -0
- package/src/compiler/Lowering.ts +78 -9
- package/src/compiler/ShadowMap.ts +2 -2
- package/src/compiler/TypeSession.ts +262 -67
- package/src/compiler/argsSchemaOf.ts +1 -1
- package/src/compiler/checkApp.ts +43 -15
- package/src/compiler/checkedShadow.ts +39 -5
- package/src/compiler/classifyRead.ts +6 -4
- package/src/compiler/classifyTemplate.ts +1 -1
- package/src/compiler/compileApp.ts +433 -193
- package/src/compiler/compiledEntry.ts +43 -39
- package/src/compiler/componentGraph.ts +58 -71
- package/src/compiler/declaredExports.ts +24 -7
- package/src/compiler/emitClient.ts +141 -71
- package/src/compiler/emitComponent.ts +37 -8
- package/src/compiler/emitSource.ts +106 -13
- package/src/compiler/handlersUnder.ts +106 -56
- package/src/compiler/index.ts +7 -0
- package/src/compiler/mountsOf.ts +138 -0
- package/src/compiler/nearest.ts +14 -0
- package/src/compiler/parseAbide.ts +157 -20
- package/src/compiler/projectShadow.ts +213 -7
- package/src/compiler/reactiveTypes.ts +20 -7
- package/src/compiler/readSources.ts +15 -2
- package/src/compiler/readsIn.ts +61 -7
- package/src/compiler/renderMarkup.ts +3 -3
- package/src/compiler/scaffold.ts +30 -11
- package/src/compiler/serveLanguage.ts +125 -34
- package/src/compiler/sourceMapOf.ts +193 -0
- package/src/compiler/splitImports.ts +205 -83
- package/src/compiler/syncTypes.ts +32 -0
- package/src/compiler/tokenize.ts +14 -0
- package/src/compiler/writeDefaultProject.ts +1 -1
- package/src/compiler/writeTypes.ts +82 -0
- package/src/server/CLIENT_BUILD.ts +11 -13
- package/src/server/DEFAULT_PORT.ts +1 -1
- package/src/server/LAUNCHER_FILES.ts +9 -0
- package/src/server/MODULE_QUERIES.ts +18 -0
- package/src/server/TABLES.ts +7 -0
- package/src/server/VIRTUAL_DIRECTORY.ts +4 -5
- package/src/server/abideModules.ts +391 -0
- package/src/server/answerFailure.ts +16 -6
- package/src/server/answerRequest.ts +235 -106
- package/src/server/answerRpc.ts +124 -39
- package/src/server/answerSocket.ts +141 -48
- package/src/server/answerValue.ts +22 -9
- package/src/server/appDirectories.ts +1 -1
- package/src/server/appPlugins.ts +1 -1
- package/src/server/appTree.ts +179 -0
- package/src/server/argsFor.ts +2 -2
- package/src/server/assistant.ts +2 -2
- package/src/server/bridgeRelay.ts +6 -8
- package/src/server/claudeCode.ts +0 -6
- package/src/server/clientBuildOptions.ts +8 -3
- package/src/server/coerceArgs.ts +1 -1
- package/src/server/commands.ts +37 -37
- package/src/server/componentModules.d.ts +3 -1
- package/src/server/compressAnswer.ts +48 -14
- package/src/server/config.ts +25 -29
- package/src/server/configFields.ts +31 -25
- package/src/server/createApp.ts +59 -73
- package/src/server/devWorker.ts +144 -183
- package/src/server/executableName.ts +4 -0
- package/src/server/fenceServer.ts +37 -35
- package/src/server/generatedSurfaces.ts +99 -66
- package/src/server/hooks.ts +124 -52
- package/src/server/iconAccent.ts +191 -0
- package/src/server/index.ts +11 -7
- package/src/server/installSourceMaps.ts +88 -0
- package/src/server/launcherOptions.ts +34 -31
- package/src/server/launcherPage.ts +107 -64
- package/src/server/manifestOf.ts +16 -13
- package/src/server/namesUnder.ts +2 -2
- package/src/server/nativeMenu.ts +5 -5
- package/src/server/negotiateEncoding.ts +1 -1
- package/src/server/principal.ts +10 -5
- package/src/server/realOrAsGiven.ts +10 -0
- package/src/server/refuseAddressCollision.ts +21 -14
- package/src/server/remote.ts +49 -52
- package/src/server/render.ts +65 -94
- package/src/server/renderComponent.ts +5 -0
- package/src/server/reportHealth.ts +2 -2
- package/src/server/requestScope.ts +12 -1
- package/src/server/runBuild.ts +24 -0
- package/src/server/runCompiled.ts +5 -5
- package/src/server/runDesktop.ts +52 -27
- package/src/server/serveBuild.ts +43 -52
- package/src/server/serveDev.ts +81 -39
- package/src/server/shell.ts +7 -22
- package/src/server/sourceMappings.ts +139 -0
- package/src/server/surfaceOf.ts +18 -3
- package/src/server/testBuild.ts +1 -19
- package/src/server/withAbideSources.ts +117 -0
- package/src/shared/ADDRESSES.ts +7 -18
- package/src/shared/APP_META.ts +7 -0
- package/src/shared/CopyOptions.ts +1 -1
- package/src/shared/DEBUG_META.ts +1 -1
- package/src/shared/GLOBAL_GETS.ts +9 -3
- package/src/shared/RESERVED_DIRECTORIES.ts +15 -0
- package/src/shared/RPC_SERVER.ts +2 -2
- package/src/shared/cookies.ts +2 -2
- package/src/shared/csp.ts +1 -1
- package/src/shared/escapeText.ts +1 -1
- package/src/shared/filledPath.ts +85 -0
- package/src/shared/framing.ts +79 -6
- package/src/shared/headKeyOf.ts +2 -0
- package/src/shared/index.ts +1 -1
- package/src/shared/log.ts +27 -13
- package/src/shared/matchRoute.ts +23 -4
- package/src/shared/menuItem.ts +3 -3
- package/src/shared/reactive/Cursor.ts +3 -9
- package/src/shared/reactive/Keyed.ts +4 -3
- package/src/shared/reactive/NotLanded.ts +1 -1
- package/src/shared/reactive/Reactive.ts +81 -60
- package/src/shared/reactive/argsContract.ts +4 -4
- package/src/shared/reactive/canonicalKey.ts +4 -4
- package/src/shared/reactive/chunksOfStream.ts +6 -0
- package/src/shared/reactive/index.ts +1 -1
- package/src/shared/reactive/recordWrites.ts +103 -5
- package/src/shared/reactive/structural.ts +1 -1
- package/src/shared/reactive/tracking.ts +155 -21
- package/src/shared/reactive/watch.ts +10 -6
- package/src/shared/refuse.ts +1 -1
- package/src/shared/routePattern.ts +82 -46
- package/src/shared/rpc.ts +47 -13
- package/src/shared/rpcRequest.ts +27 -3
- package/src/shared/url.ts +4 -44
- package/src/shared/writePath.ts +1 -1
- package/src/ui/LAYER_NAMES.ts +1 -1
- package/src/ui/List.ts +3 -3
- package/src/ui/MOUNTING_LEVEL.ts +18 -0
- package/src/ui/Mismatch.ts +1 -1
- package/src/ui/RawRun.ts +1 -1
- package/src/ui/Router.ts +38 -31
- package/src/ui/Scope.ts +1 -1
- package/src/ui/TryRegion.ts +2 -2
- package/src/ui/applyHead.ts +33 -0
- package/src/ui/index.ts +1 -1
- package/src/ui/menuBridge.ts +1 -1
- package/src/ui/moveNode.ts +1 -1
- package/src/ui/reconcile.ts +2 -2
- package/src/ui/reportMenu.ts +2 -2
- package/src/ui/routing.ts +2 -2
- package/src/ui/setAttribute.ts +1 -1
- package/src/ui/setText.ts +1 -1
- package/src/ui/socketWire.ts +9 -7
- package/src/ui/wire.ts +1 -1
- package/scaffold/src/server/app.ts +0 -6
- package/scaffold/src/shared/.gitkeep +0 -0
- package/scaffold/src/ui/app.html +0 -8
- package/src/server/componentImports.ts +0 -56
- /package/scaffold/src/{ui/pages/page.abide → page.abide} +0 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { relative, resolve } from 'node:path' // bun exposes no path helpers of its own
|
|
2
|
+
import { COMPONENT_NAMESPACE, type Compilation } from './abideModules.ts'
|
|
3
|
+
import { COMPONENT_QUERY } from './MODULE_QUERIES.ts'
|
|
4
|
+
import {
|
|
5
|
+
decodeMappings,
|
|
6
|
+
encodeMappings,
|
|
7
|
+
placeAt,
|
|
8
|
+
type Segment,
|
|
9
|
+
} from './sourceMappings.ts'
|
|
10
|
+
|
|
11
|
+
const NAMESPACED = `${COMPONENT_NAMESPACE}:`
|
|
12
|
+
|
|
13
|
+
// THE PARSER IS IMPORTED ON THE FIRST MAP ASKED FOR, not with the dev worker each reload boots:
|
|
14
|
+
// only a debugger reads one.
|
|
15
|
+
let parser: Promise<typeof import('#compiler/parseAbide.ts')> | undefined
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 38.97 — a client chunk's source map, with every position in a module compiled from a `.abide`
|
|
19
|
+
* placed in the `.abide` instead.
|
|
20
|
+
*
|
|
21
|
+
* Bun maps the chunk to the modules it bundled and reads no map those carry, so the second step is
|
|
22
|
+
* taken here. A position in the module's own scaffolding — what no `.abide` line wrote — stays in
|
|
23
|
+
* the compiled module, which is still listed, so a debugger steps through it rather than losing it.
|
|
24
|
+
*/
|
|
25
|
+
export async function withAbideSources(
|
|
26
|
+
mapText: string,
|
|
27
|
+
compilation: Compilation,
|
|
28
|
+
root: string,
|
|
29
|
+
): Promise<string> {
|
|
30
|
+
const map = JSON.parse(mapText) as {
|
|
31
|
+
sources: string[]
|
|
32
|
+
sourcesContent?: (string | null)[]
|
|
33
|
+
mappings: string
|
|
34
|
+
}
|
|
35
|
+
const sources = map.sources
|
|
36
|
+
const contents = map.sourcesContent ?? []
|
|
37
|
+
// Per bundled source, its module's map and the index its `.abide` is listed at.
|
|
38
|
+
const placed: ({ lines: Segment[][]; index: number } | undefined)[] = []
|
|
39
|
+
const listed = new Map<string, number>()
|
|
40
|
+
const reads: Promise<void>[] = []
|
|
41
|
+
const originals = sources.length
|
|
42
|
+
for (let index = 0; index < originals; index += 1) {
|
|
43
|
+
// Bun names a bundled source from where the command runs, and a component module — which
|
|
44
|
+
// the plugin served at its `.abide`'s path — by its namespace and that path.
|
|
45
|
+
const named = sources[index] as string
|
|
46
|
+
const component = named.startsWith(NAMESPACED)
|
|
47
|
+
const abide = resolve(
|
|
48
|
+
component ? named.slice(NAMESPACED.length) : named,
|
|
49
|
+
)
|
|
50
|
+
const mappings = component
|
|
51
|
+
? compilation.files[relative(root, abide)]?.clientMappings
|
|
52
|
+
: undefined
|
|
53
|
+
if (mappings === undefined) {
|
|
54
|
+
placed.push(undefined)
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
// The module bun bundled is named apart from the `.abide` it is placed in, which shares
|
|
58
|
+
// its path.
|
|
59
|
+
sources[index] = `${relative(process.cwd(), abide)}${COMPONENT_QUERY}`
|
|
60
|
+
let at = listed.get(abide)
|
|
61
|
+
if (at === undefined) {
|
|
62
|
+
at = sources.length
|
|
63
|
+
listed.set(abide, at)
|
|
64
|
+
// Named as bun names its own, from where the command runs; read from where it is.
|
|
65
|
+
sources.push(relative(process.cwd(), abide))
|
|
66
|
+
const slot = at
|
|
67
|
+
parser ??= import('#compiler/parseAbide.ts')
|
|
68
|
+
reads.push(
|
|
69
|
+
Promise.all([Bun.file(abide).text(), parser]).then(
|
|
70
|
+
([text, { parseAbide }]) => {
|
|
71
|
+
contents[slot] = withoutServerBlocks(text, parseAbide)
|
|
72
|
+
},
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
placed.push({ lines: decodeMappings(mappings), index: at })
|
|
77
|
+
}
|
|
78
|
+
if (listed.size === 0) return mapText
|
|
79
|
+
await Promise.all(reads)
|
|
80
|
+
for (let index = contents.length; index < sources.length; index += 1)
|
|
81
|
+
contents[index] ??= null
|
|
82
|
+
const lines = decodeMappings(map.mappings)
|
|
83
|
+
for (const segments of lines)
|
|
84
|
+
for (const segment of segments) {
|
|
85
|
+
const module = placed[segment[1]]
|
|
86
|
+
if (module === undefined) continue
|
|
87
|
+
const from = placeAt(module.lines, segment[2], segment[3])
|
|
88
|
+
if (from === undefined) continue
|
|
89
|
+
segment[1] = module.index
|
|
90
|
+
segment[2] = from[0]
|
|
91
|
+
segment[3] = from[1]
|
|
92
|
+
}
|
|
93
|
+
map.sourcesContent = contents
|
|
94
|
+
map.mappings = encodeMappings(lines)
|
|
95
|
+
return JSON.stringify(map)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 16.28, 34.20 — the `.abide`'s text with every `<script server>` body blanked: the map is served
|
|
100
|
+
* to a browser, which reads none of one. Each line is kept, so every line the map places is still
|
|
101
|
+
* the line it names.
|
|
102
|
+
*/
|
|
103
|
+
function withoutServerBlocks(
|
|
104
|
+
text: string,
|
|
105
|
+
parseAbide: typeof import('#compiler/parseAbide.ts').parseAbide,
|
|
106
|
+
): string {
|
|
107
|
+
const file = parseAbide(text)
|
|
108
|
+
let blanked = ''
|
|
109
|
+
let at = 0
|
|
110
|
+
for (let index = 0; index < file.servers.length; index += 1) {
|
|
111
|
+
const start = file.origins.servers[index] as number
|
|
112
|
+
const end = start + (file.servers[index] as string).length
|
|
113
|
+
blanked += `${text.slice(at, start)}${text.slice(start, end).replace(/[^\n]/g, '')}`
|
|
114
|
+
at = end
|
|
115
|
+
}
|
|
116
|
+
return blanked + text.slice(at)
|
|
117
|
+
}
|
package/src/shared/ADDRESSES.ts
CHANGED
|
@@ -7,20 +7,19 @@
|
|
|
7
7
|
/**
|
|
8
8
|
* The namespace every framework address lives under.
|
|
9
9
|
*
|
|
10
|
-
* UNDER `/__abide`, which is the namespace the framework's own addresses live in (
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* 38.21 is the clause.
|
|
10
|
+
* UNDER `/__abide`, which is the namespace the framework's own addresses live in (20.5, 25.1).
|
|
11
|
+
* That is what keeps the app's routes and abide's own addresses from colliding: a route whose first
|
|
12
|
+
* segment is `__abide` is refused at build (23.17), so nothing can be shadowed at run time. 38.20
|
|
13
|
+
* is the clause.
|
|
15
14
|
*/
|
|
16
15
|
export const ABIDE_ADDRESS = '/__abide'
|
|
17
16
|
|
|
18
17
|
/**
|
|
19
18
|
* Where the browser bundle is served.
|
|
20
19
|
*
|
|
21
|
-
* The bundle's own address below `ABIDE_ADDRESS` (38.
|
|
20
|
+
* The bundle's own address below `ABIDE_ADDRESS` (38.16, 38.20). One request path answers
|
|
22
21
|
* everything under `ABIDE_ADDRESS` and the command supplying the app decides what is there, so a
|
|
23
|
-
* command with a second address of its own — the reload client `abide dev` serves (38.
|
|
22
|
+
* command with a second address of its own — the reload client `abide dev` serves (38.17) — adds
|
|
24
23
|
* one rather than a lane.
|
|
25
24
|
*/
|
|
26
25
|
export const CLIENT_ADDRESS = `${ABIDE_ADDRESS}/client`
|
|
@@ -31,8 +30,6 @@ export const CLIENT_ADDRESS = `${ABIDE_ADDRESS}/client`
|
|
|
31
30
|
export const OPENAPI_ADDRESS = `${ABIDE_ADDRESS}/openapi.json`
|
|
32
31
|
/** The MCP endpoint. */
|
|
33
32
|
export const MCP_ADDRESS = `${ABIDE_ADDRESS}/mcp`
|
|
34
|
-
/** The stream of log records. */
|
|
35
|
-
export const LOGS_ADDRESS = `${ABIDE_ADDRESS}/logs`
|
|
36
33
|
/** The health check. */
|
|
37
34
|
export const HEALTH_ADDRESS = `${ABIDE_ADDRESS}/health`
|
|
38
35
|
/** Where a visitor's Claude Code bridge pairs, and is relayed turns (45.13). */
|
|
@@ -41,19 +38,11 @@ export const BRIDGE_ADDRESS = `${ABIDE_ADDRESS}/bridge`
|
|
|
41
38
|
/**
|
|
42
39
|
* The socket the dev reload client holds open.
|
|
43
40
|
*
|
|
44
|
-
* 38.
|
|
41
|
+
* 38.17 — the LISTENER upgrades it on the main thread and the dev WORKER serves the script that
|
|
45
42
|
* opens it.
|
|
46
43
|
*/
|
|
47
44
|
export const DEV_ADDRESS = `${ABIDE_ADDRESS}/dev`
|
|
48
45
|
|
|
49
|
-
/**
|
|
50
|
-
* Where a `server/rpc` handler is answered over http.
|
|
51
|
-
*
|
|
52
|
-
* 16.2 — its path below that directory, then its export name (19.1). The build writes the address
|
|
53
|
-
* into the browser's wrapper and the server answers at it.
|
|
54
|
-
*/
|
|
55
|
-
export const RPC_ADDRESS = `${ABIDE_ADDRESS}/rpc`
|
|
56
|
-
|
|
57
46
|
/**
|
|
58
47
|
* Where the one web socket a page follows its `GET`s over is served, below the app's root.
|
|
59
48
|
*
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 18.4 — WHAT A `GET`'s VALUE DECLARES THAT ITS BROWSER COPY HAS TO BEHAVE BY, as the build reads
|
|
5
5
|
* it off the declaration and writes it into the wrapper: data, because it crosses as text.
|
|
6
6
|
* `structural` is named rather than carried, being abide's own comparator on both sides; an app's
|
|
7
|
-
* own has no
|
|
7
|
+
* own has no serialization and stays the server's, as a function `by` or `tags` does.
|
|
8
8
|
*/
|
|
9
9
|
export type CopyOptions = {
|
|
10
10
|
by?: string
|
package/src/shared/DEBUG_META.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Every `GET` declared over a `global` value, to the value it is serving for some args.
|
|
3
3
|
*
|
|
4
|
-
* 21.8, 18.5 — `undefined` where no entry holds the args. The answer reads its
|
|
5
|
-
* (`deadlineOf`) and its version (`versionOf`), and the mux feeds a follower from
|
|
4
|
+
* 21.8, 18.5 — `at(key(args))` is `undefined` where no entry holds the args. The answer reads its
|
|
5
|
+
* `max-age` off it (`deadlineOf`) and its version (`versionOf`), and the mux feeds a follower from
|
|
6
|
+
* it. THE KEY IS ITS OWN STEP so each side takes it once: an answer once a request, a follower once
|
|
7
|
+
* as it subscribes — and the mux files its followers by it, so an entry answered reaches the
|
|
8
|
+
* followers of its own args and walks no other (18.8). An unkeyed value has one entry, under `''`.
|
|
6
9
|
*/
|
|
7
|
-
export const GLOBAL_GETS = new WeakMap<
|
|
10
|
+
export const GLOBAL_GETS = new WeakMap<
|
|
11
|
+
object,
|
|
12
|
+
{ key(args: unknown): string; at(key: string): unknown }
|
|
13
|
+
>()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// THE TWO DIRECTORY NAMES THAT ARE NEVER AN ADDRESS (23.15): `server` fences a graph (16.29) and
|
|
2
|
+
// `public` is answered as authored (36.11). Every other directory under the root is a segment of the
|
|
3
|
+
// address of the `page.abide`, `layout.abide` and `error.abide` in it (23.14).
|
|
4
|
+
|
|
5
|
+
/** A module under one only the server loads. */
|
|
6
|
+
export const SERVER_DIRECTORY = 'server'
|
|
7
|
+
|
|
8
|
+
/** A file under one is answered as authored, at the path above it. */
|
|
9
|
+
export const PUBLIC_DIRECTORY = 'public'
|
|
10
|
+
|
|
11
|
+
/** Both, as the walk of the app tests a directory against them. */
|
|
12
|
+
export const RESERVED_DIRECTORIES: ReadonlySet<string> = new Set([
|
|
13
|
+
SERVER_DIRECTORY,
|
|
14
|
+
PUBLIC_DIRECTORY,
|
|
15
|
+
])
|
package/src/shared/RPC_SERVER.ts
CHANGED
|
@@ -39,12 +39,12 @@ export const RPC_SERVER: {
|
|
|
39
39
|
| ((member: 'invalidate' | 'refresh', tags: string[] | null) => void)
|
|
40
40
|
| null
|
|
41
41
|
/**
|
|
42
|
-
* The `global` entry an answer came from.
|
|
42
|
+
* The `global` entry an answer came from, and the key it is filed under (`GLOBAL_GETS`).
|
|
43
43
|
*
|
|
44
44
|
* 18.8 — so a follower subscribed before it existed, or fed from one since evicted, is fed
|
|
45
45
|
* from this one.
|
|
46
46
|
*/
|
|
47
|
-
served: ((rpc: object, serving: unknown) => void) | null
|
|
47
|
+
served: ((rpc: object, serving: unknown, key: string) => void) | null
|
|
48
48
|
/**
|
|
49
49
|
* How long a handler's own body took inside its rungs.
|
|
50
50
|
*
|
package/src/shared/cookies.ts
CHANGED
|
@@ -185,12 +185,12 @@ export function serializeCookie(init: CookieInit, inBrowser: boolean): string {
|
|
|
185
185
|
// A page's own script cannot set an `HttpOnly` cookie, and a browser drops the whole line that
|
|
186
186
|
// asks to — so the attribute is a server's alone.
|
|
187
187
|
if (init.httpOnly === true && !inBrowser) line += '; HttpOnly'
|
|
188
|
-
line += `; SameSite=${
|
|
188
|
+
line += `; SameSite=${capitalized(init.sameSite ?? 'lax')}`
|
|
189
189
|
if (init.partitioned === true) line += '; Partitioned'
|
|
190
190
|
return line
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
function
|
|
193
|
+
function capitalized(sameSite: string): string {
|
|
194
194
|
return sameSite.charAt(0).toUpperCase() + sameSite.slice(1)
|
|
195
195
|
}
|
|
196
196
|
|
package/src/shared/csp.ts
CHANGED
|
@@ -41,7 +41,7 @@ export function documentNonce(): string {
|
|
|
41
41
|
* A middleware rung that sets the content security policy, and `csp.nonce()` for the current one.
|
|
42
42
|
*
|
|
43
43
|
* THE RUNG IS BUILT ON ITS FIRST REQUEST, because an app's `middleware` can be read before the
|
|
44
|
-
* server half has filled `CSP`: `
|
|
44
|
+
* server half has filled `CSP`: the root `layout.abide` is one more module, and nothing orders it after that one.
|
|
45
45
|
*/
|
|
46
46
|
export const csp = Object.assign(
|
|
47
47
|
(sources?: Record<string, string[]>): Rung => {
|
package/src/shared/escapeText.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { RAW_WRITES, Raw, textOf } from './Raw.ts'
|
|
|
10
10
|
// the fallback: a clean string is returned as it arrived, with no allocation.
|
|
11
11
|
//
|
|
12
12
|
// THE DETECTION IS THE ENGINE'S, not a `charCodeAt` loop. The hand-written scan is one JS
|
|
13
|
-
// iteration per character where this is
|
|
13
|
+
// iteration per character where this is vectorized, and it lost on the case it was written for:
|
|
14
14
|
// 5x on a clean 74-character slot and 14x on a clean 320-character one, over two runs that did
|
|
15
15
|
// not swap places. The share is named — `escapeText` is 74-86% of a 500-row x 4-slot markup
|
|
16
16
|
// build, and the whole build moves 1.7x.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// A ROUTE'S PATH WITH ITS PARAMETERS WRITTEN IN, for the two things that build an address from a
|
|
2
|
+
// route: `url`, from a page's literal (30.1), and a call, from its handler's route (16.9).
|
|
3
|
+
|
|
4
|
+
import { parseRoutePattern, type Segment } from './routePattern.ts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `pattern` with each parameter `params` names written in as a path segment, or `/` for none.
|
|
8
|
+
*
|
|
9
|
+
* `spelled` is how the caller is named where a parameter is refused — `url('/x/[id]')`, or a
|
|
10
|
+
* handler's route — and `refused` what is thrown for it: an `Error` naming the clause, by default.
|
|
11
|
+
*/
|
|
12
|
+
export function filledPath(
|
|
13
|
+
pattern: string,
|
|
14
|
+
params: Record<string, unknown>,
|
|
15
|
+
spelled: string,
|
|
16
|
+
refused: (name: string, message: string) => unknown = thrownError,
|
|
17
|
+
): string {
|
|
18
|
+
return filledSegments(parseRoutePattern(pattern), params, spelled, refused)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** `filledPath` over a route already parsed, for a caller that fills the same route per call. */
|
|
22
|
+
export function filledSegments(
|
|
23
|
+
segments: Segment[],
|
|
24
|
+
params: Record<string, unknown>,
|
|
25
|
+
spelled: string,
|
|
26
|
+
refused: (name: string, message: string) => unknown,
|
|
27
|
+
): string {
|
|
28
|
+
let address = ''
|
|
29
|
+
for (const segment of segments) {
|
|
30
|
+
if (segment.kind === 'static') {
|
|
31
|
+
address += `/${segment.name}`
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
const value = params[segment.name]
|
|
35
|
+
if (value === undefined || value === null || value === '') {
|
|
36
|
+
// 30.4 — an optional left out omits its segment, and so does an empty rest (30.5).
|
|
37
|
+
if (segment.kind !== 'required') continue
|
|
38
|
+
throw refused(
|
|
39
|
+
segment.name,
|
|
40
|
+
`abide: ${spelled} was given no '${segment.name}', and the route requires it. RULEBOOK 30.3.`,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
if (segment.kind === 'rest' && Array.isArray(value)) {
|
|
44
|
+
for (const each of value)
|
|
45
|
+
address += `/${encodedSegment(spelled, segment.name, each, refused)}`
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
address += `/${encodedSegment(spelled, segment.name, value, refused)}`
|
|
49
|
+
}
|
|
50
|
+
return address === '' ? '/' : address
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The names of `pattern`'s parameters — what a call takes out of its args and into the path (16.66). */
|
|
54
|
+
export function parametersOf(pattern: string): Set<string> {
|
|
55
|
+
const names = new Set<string>()
|
|
56
|
+
for (const segment of parseRoutePattern(pattern))
|
|
57
|
+
if (segment.kind !== 'static') names.add(segment.name)
|
|
58
|
+
return names
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A param value encoded as one path segment; `.` and `..` are refused.
|
|
63
|
+
*
|
|
64
|
+
* 30.19 — A VALUE OF `.` OR `..` HAS NO SPELLING AS A SEGMENT: the URL parser collapses it as a
|
|
65
|
+
* dot-segment, and `%2E` / `%2E%2E` with it, so the address would land on another route. Refused
|
|
66
|
+
* rather than written, as a missing required segment is (30.3).
|
|
67
|
+
*/
|
|
68
|
+
function encodedSegment(
|
|
69
|
+
spelled: string,
|
|
70
|
+
name: string,
|
|
71
|
+
value: unknown,
|
|
72
|
+
refused: (name: string, message: string) => unknown,
|
|
73
|
+
): string {
|
|
74
|
+
const text = String(value)
|
|
75
|
+
if (text === '.' || text === '..')
|
|
76
|
+
throw refused(
|
|
77
|
+
name,
|
|
78
|
+
`abide: ${spelled} was given '${text}' for '${name}', and no address carries it as a segment — the URL parser resolves it to another route. RULEBOOK 30.19.`,
|
|
79
|
+
)
|
|
80
|
+
return encodeURIComponent(text)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function thrownError(_name: string, message: string): Error {
|
|
84
|
+
return new Error(message)
|
|
85
|
+
}
|
package/src/shared/framing.ts
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
// 16.32 — A FRAMED ANSWER, read one JSON value per line: the whole line for jsonl, the `data:` line of
|
|
2
2
|
// each event for sse. A line that does not carry the prefix — an sse `event:` line, a blank line — is
|
|
3
3
|
// skipped. One line reader behind both a body read to the end and one read as it lands.
|
|
4
|
+
//
|
|
5
|
+
// 16.68 — AND ITS LAST FRAME MAY BE A REFUSAL: one that reached the stream after it had answered, so
|
|
6
|
+
// after its status was sent. An sse `event: refusal` and the Problem Details on its `data:` line, or a
|
|
7
|
+
// jsonl line `{"$refusal": <Problem Details>}` — read here as the refusal, thrown where it lands.
|
|
8
|
+
|
|
9
|
+
import { failedFrom, isProblem, type Problem } from './Failed.ts'
|
|
10
|
+
|
|
11
|
+
/** The key a jsonl line carries a refusal under (16.68). */
|
|
12
|
+
const REFUSAL_KEY = '$refusal'
|
|
13
|
+
|
|
14
|
+
/** A framed stream's last frame, as the server writes it for a refusal (16.68). */
|
|
15
|
+
export function refusalFrame(problem: Problem, prefix: string): string {
|
|
16
|
+
return prefix === ''
|
|
17
|
+
? `${JSON.stringify({ [REFUSAL_KEY]: problem })}\n`
|
|
18
|
+
: `event: refusal\n${prefix}${JSON.stringify(problem)}\n\n`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// What a read of one framed body carries from one line to the next: whether an sse event named the
|
|
22
|
+
// data line after it a refusal.
|
|
23
|
+
type Reading = { refusal: boolean }
|
|
24
|
+
|
|
25
|
+
// A refusal's frame, read: thrown where it lands among the chunks.
|
|
26
|
+
class Refused {
|
|
27
|
+
problem: Problem
|
|
28
|
+
constructor(problem: Problem) {
|
|
29
|
+
this.problem = problem
|
|
30
|
+
}
|
|
31
|
+
}
|
|
4
32
|
|
|
5
33
|
/**
|
|
6
34
|
* What precedes a chunk's JSON on its line: nothing for jsonl, `data: ` for sse, and `null` where
|
|
@@ -67,14 +95,16 @@ async function* bytesStream(
|
|
|
67
95
|
/** The chunks a framed text carried, every line of it complete. */
|
|
68
96
|
export function framedChunks(text: string, prefix: string): unknown[] {
|
|
69
97
|
const chunks: unknown[] = []
|
|
70
|
-
readLines(text, prefix, true, chunks)
|
|
98
|
+
readLines(text, prefix, true, chunks, { refusal: false })
|
|
99
|
+
for (const chunk of chunks)
|
|
100
|
+
if (chunk instanceof Refused) throw failedFrom(chunk.problem)
|
|
71
101
|
return chunks
|
|
72
102
|
}
|
|
73
103
|
|
|
74
104
|
/**
|
|
75
105
|
* A framed body's chunks, yielded as each line completes.
|
|
76
106
|
*
|
|
77
|
-
*
|
|
107
|
+
* Canceled when the reader stops reading — a refresh starts a new run and the old one's reader has
|
|
78
108
|
* nothing left to hand anything.
|
|
79
109
|
*/
|
|
80
110
|
export async function* framedStream(
|
|
@@ -84,6 +114,7 @@ export async function* framedStream(
|
|
|
84
114
|
const reader = body.getReader()
|
|
85
115
|
const decoder = new TextDecoder()
|
|
86
116
|
const chunks: unknown[] = []
|
|
117
|
+
const reading: Reading = { refusal: false }
|
|
87
118
|
let buffered = ''
|
|
88
119
|
try {
|
|
89
120
|
for (;;) {
|
|
@@ -97,8 +128,17 @@ export async function* framedStream(
|
|
|
97
128
|
buffered += decoded
|
|
98
129
|
continue
|
|
99
130
|
}
|
|
100
|
-
buffered = readLines(
|
|
101
|
-
|
|
131
|
+
buffered = readLines(
|
|
132
|
+
buffered + decoded,
|
|
133
|
+
prefix,
|
|
134
|
+
done,
|
|
135
|
+
chunks,
|
|
136
|
+
reading,
|
|
137
|
+
)
|
|
138
|
+
for (const chunk of chunks) {
|
|
139
|
+
if (chunk instanceof Refused) throw failedFrom(chunk.problem)
|
|
140
|
+
yield chunk
|
|
141
|
+
}
|
|
102
142
|
chunks.length = 0
|
|
103
143
|
if (done) return
|
|
104
144
|
}
|
|
@@ -117,15 +157,48 @@ function readLines(
|
|
|
117
157
|
prefix: string,
|
|
118
158
|
ended: boolean,
|
|
119
159
|
chunks: unknown[],
|
|
160
|
+
reading: Reading,
|
|
120
161
|
): string {
|
|
121
162
|
let start = 0
|
|
122
163
|
for (;;) {
|
|
123
164
|
const newline = text.indexOf('\n', start)
|
|
124
165
|
if (newline === -1 && !ended) return text.slice(start)
|
|
125
166
|
const end = newline === -1 ? text.length : newline
|
|
126
|
-
if (
|
|
127
|
-
|
|
167
|
+
if (prefix !== '' && text.startsWith('event: refusal', start))
|
|
168
|
+
reading.refusal = true
|
|
169
|
+
else if (end > start && text.startsWith(prefix, start)) {
|
|
170
|
+
const value: unknown = JSON.parse(
|
|
171
|
+
text.slice(start + prefix.length, end),
|
|
172
|
+
)
|
|
173
|
+
chunks.push(refusalIn(value, prefix, reading) ?? value)
|
|
174
|
+
reading.refusal = false
|
|
175
|
+
}
|
|
128
176
|
if (newline === -1) return ''
|
|
129
177
|
start = newline + 1
|
|
130
178
|
}
|
|
131
179
|
}
|
|
180
|
+
|
|
181
|
+
// The refusal a value carries as its frame: the data line an sse event named one, or a jsonl line
|
|
182
|
+
// holding the one reserved key and Problem Details under it.
|
|
183
|
+
function refusalIn(
|
|
184
|
+
value: unknown,
|
|
185
|
+
prefix: string,
|
|
186
|
+
reading: Reading,
|
|
187
|
+
): Refused | undefined {
|
|
188
|
+
if (prefix !== '')
|
|
189
|
+
return reading.refusal && isProblem(value)
|
|
190
|
+
? new Refused(value)
|
|
191
|
+
: undefined
|
|
192
|
+
// Per chunk: the reserved key is tested before any key list is built.
|
|
193
|
+
if (
|
|
194
|
+
value === null ||
|
|
195
|
+
typeof value !== 'object' ||
|
|
196
|
+
!Object.hasOwn(value, REFUSAL_KEY)
|
|
197
|
+
)
|
|
198
|
+
return undefined
|
|
199
|
+
const keys = Object.keys(value)
|
|
200
|
+
const problem = (value as Record<string, unknown>)[REFUSAL_KEY]
|
|
201
|
+
return keys.length === 1 && isProblem(problem)
|
|
202
|
+
? new Refused(problem)
|
|
203
|
+
: undefined
|
|
204
|
+
}
|
package/src/shared/headKeyOf.ts
CHANGED
|
@@ -20,6 +20,8 @@ export function headKeyOf(
|
|
|
20
20
|
): string | undefined {
|
|
21
21
|
if (lowerTag === 'title' || lowerTag === 'base') return lowerTag
|
|
22
22
|
if (lowerTag === 'meta') {
|
|
23
|
+
// One encoding a document, so a layout's replaces the default document's.
|
|
24
|
+
if (attribute('charset') != null) return 'meta charset'
|
|
23
25
|
const named = attribute('name')
|
|
24
26
|
const key = named === null ? attribute('property') : named
|
|
25
27
|
return key == null ? undefined : `meta ${key}`
|
package/src/shared/index.ts
CHANGED
|
@@ -36,7 +36,7 @@ export { listOf } from './listOf.ts'
|
|
|
36
36
|
export { type Logger, type LogRecord, log } from './log.ts'
|
|
37
37
|
export type { Middleware } from './Middleware.ts'
|
|
38
38
|
export type { Params } from './matchRoute.ts'
|
|
39
|
-
// 38.
|
|
39
|
+
// 38.88 — a desktop app's menu items, exported by the route's layouts and page.
|
|
40
40
|
export {
|
|
41
41
|
type MenuEntry,
|
|
42
42
|
type MenuItem,
|
package/src/shared/log.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// SECTION 28 — the logger, on both sides. A line is written to the console AND published to
|
|
2
|
-
// `log.records` (28.1), so a subscriber —
|
|
2
|
+
// `log.records` (28.1), so a subscriber — a handler, a test, a page — sees exactly what the
|
|
3
3
|
// terminal saw.
|
|
4
4
|
//
|
|
5
5
|
// THE RECORDS ARE BUILT ON FIRST ASK. The reactive core warns through this module, so a
|
|
6
6
|
// `reactive` built at this module's evaluation would be a cycle evaluated in the wrong order;
|
|
7
7
|
// built on the first line or the first read of `log.records`, the core has long since loaded.
|
|
8
8
|
//
|
|
9
|
-
// 28.
|
|
9
|
+
// 28.10, 28.11 — IN A BROWSER, `DEBUG` IS THE DOCUMENT'S: the server names it in a `<meta>` it writes
|
|
10
10
|
// into the head, and the browser's own stored `debug` answers only where the document carries none.
|
|
11
11
|
|
|
12
|
+
import { APP_META } from './APP_META.ts'
|
|
12
13
|
import { DEBUG_META } from './DEBUG_META.ts'
|
|
13
14
|
import { paint } from './paint.ts'
|
|
14
15
|
import { type Reactive, reactive } from './reactive/index.ts'
|
|
@@ -53,7 +54,7 @@ function recordsValue(): Reactive<LogRecord[]> {
|
|
|
53
54
|
Number.isInteger(configured) && configured >= 0
|
|
54
55
|
? configured
|
|
55
56
|
: DEFAULT_BUFFER
|
|
56
|
-
// 28.1 — one array for the process
|
|
57
|
+
// 28.1 — one array for the process.
|
|
57
58
|
records = reactive<LogRecord[]>([], { global: true })
|
|
58
59
|
}
|
|
59
60
|
return records
|
|
@@ -62,7 +63,7 @@ function recordsValue(): Reactive<LogRecord[]> {
|
|
|
62
63
|
/**
|
|
63
64
|
* Appends `record` to `log.records`, trimming to the buffer it keeps.
|
|
64
65
|
*
|
|
65
|
-
* THE TRIM IS
|
|
66
|
+
* THE TRIM IS AMORTIZED: grown to twice the buffer and cut back in one move, rather than a `shift`
|
|
66
67
|
* per line that moves every record still held.
|
|
67
68
|
*/
|
|
68
69
|
function keep(list: LogRecord[], record: LogRecord): void {
|
|
@@ -81,14 +82,26 @@ function keep(list: LogRecord[], record: LogRecord): void {
|
|
|
81
82
|
*/
|
|
82
83
|
let documentDebugRead: string | undefined
|
|
83
84
|
|
|
85
|
+
let documentAppRead: string | undefined
|
|
86
|
+
|
|
84
87
|
function environment(name: string): string | undefined {
|
|
85
88
|
if (typeof process !== 'undefined') return process.env[name]
|
|
89
|
+
// 28.4 — the app's name, as the server wrote it into the document.
|
|
90
|
+
if (name === 'APP_NAME') {
|
|
91
|
+
documentAppRead ??=
|
|
92
|
+
(typeof document === 'undefined'
|
|
93
|
+
? undefined
|
|
94
|
+
: document
|
|
95
|
+
.querySelector(`meta[name="${APP_META}"]`)
|
|
96
|
+
?.getAttribute('content')) ?? ''
|
|
97
|
+
return documentAppRead === '' ? undefined : documentAppRead
|
|
98
|
+
}
|
|
86
99
|
if (name !== 'DEBUG') return undefined
|
|
87
100
|
documentDebugRead ??= documentDebug() ?? ''
|
|
88
101
|
return documentDebugRead
|
|
89
102
|
}
|
|
90
103
|
|
|
91
|
-
/** The document's `DEBUG`, or the browser's own stored one where it carries none (28.
|
|
104
|
+
/** The document's `DEBUG`, or the browser's own stored one where it carries none (28.10, 28.11). */
|
|
92
105
|
export function documentDebug(): string | undefined {
|
|
93
106
|
if (typeof document === 'undefined') return undefined
|
|
94
107
|
const named = document
|
|
@@ -106,7 +119,7 @@ export function documentDebug(): string | undefined {
|
|
|
106
119
|
/**
|
|
107
120
|
* The compiled `DEBUG` patterns: namespaces included, and namespaces excluded.
|
|
108
121
|
*
|
|
109
|
-
* 28.
|
|
122
|
+
* 28.9 — THE debug-npm GRAMMAR: a list split on commas or whitespace, `*` any run of characters,
|
|
110
123
|
* and a leading `-` an exclusion that beats every inclusion. Compiled once per distinct `DEBUG`.
|
|
111
124
|
*/
|
|
112
125
|
type Gate = { include: RegExp[]; exclude: RegExp[] }
|
|
@@ -131,7 +144,7 @@ function currentGate(): Gate {
|
|
|
131
144
|
return gate
|
|
132
145
|
}
|
|
133
146
|
|
|
134
|
-
/** Whether `DEBUG` enables a namespace's gated lines (28.
|
|
147
|
+
/** Whether `DEBUG` enables a namespace's gated lines (28.6). */
|
|
135
148
|
function isEnabled(namespace: string): boolean {
|
|
136
149
|
const { include, exclude } = currentGate()
|
|
137
150
|
for (const pattern of exclude) if (pattern.test(namespace)) return false
|
|
@@ -215,12 +228,12 @@ function write(
|
|
|
215
228
|
recordsValue().patch((list) => keep(list, record))
|
|
216
229
|
}
|
|
217
230
|
|
|
218
|
-
/** A logger under `namespace`, which 28.
|
|
231
|
+
/** A logger under `namespace`, which 28.4 builds from the app's name and 28.8 from `abide:`. */
|
|
219
232
|
export function namedLogger(namespace: string): Logger {
|
|
220
233
|
return loggerOver(() => namespace)
|
|
221
234
|
}
|
|
222
235
|
|
|
223
|
-
/** Both lanes' render logger (28.
|
|
236
|
+
/** Both lanes' render logger (28.8): the server's escalations and 32.20's dropped URLs. */
|
|
224
237
|
export const RENDER_LOG = namedLogger('abide:render')
|
|
225
238
|
|
|
226
239
|
/**
|
|
@@ -229,7 +242,7 @@ export const RENDER_LOG = namedLogger('abide:render')
|
|
|
229
242
|
* The namespace is ASKED per line rather than held, which is all `log` needs over a named logger.
|
|
230
243
|
*/
|
|
231
244
|
function loggerOver(namespaceOf: () => string): Logger {
|
|
232
|
-
// 28.
|
|
245
|
+
// 28.2 — the bare call always writes. 28.3 — so do warnings and errors.
|
|
233
246
|
const logger = ((...args: unknown[]): void => {
|
|
234
247
|
write(namespaceOf(), 'info', args)
|
|
235
248
|
}) as Logger
|
|
@@ -243,7 +256,7 @@ function loggerOver(namespaceOf: () => string): Logger {
|
|
|
243
256
|
}
|
|
244
257
|
logger.warning = (...args) => write(namespaceOf(), 'warning', args)
|
|
245
258
|
logger.error = (...args) => write(namespaceOf(), 'error', args)
|
|
246
|
-
// 28.
|
|
259
|
+
// 28.4 — another segment, below this one. 28.12 — asked per line too, so a logger named at module
|
|
247
260
|
// load, before the app's name resolved, writes under the name once it has.
|
|
248
261
|
logger.named = (name) => loggerOver(() => `${namespaceOf()}:${name}`)
|
|
249
262
|
logger.enabled = () => isEnabled(namespaceOf())
|
|
@@ -254,14 +267,15 @@ function loggerOver(namespaceOf: () => string): Logger {
|
|
|
254
267
|
/**
|
|
255
268
|
* The slot the derived app name is read through, filled by the server's `config`.
|
|
256
269
|
*
|
|
257
|
-
* 28.
|
|
270
|
+
* 28.4 — the app's name is the root every `log.named` hangs off: the environment's, or what 27.3
|
|
258
271
|
* derived, which the server's `config` fills in here.
|
|
259
272
|
*/
|
|
260
273
|
export const APP_NAME: { derived: () => string | undefined } = {
|
|
261
274
|
derived: () => undefined,
|
|
262
275
|
}
|
|
263
276
|
|
|
264
|
-
|
|
277
|
+
/** The name every `log.named` hangs off: the environment's, or the one the app derived (28.4). */
|
|
278
|
+
export function appName(): string {
|
|
265
279
|
return environment('APP_NAME') ?? APP_NAME.derived() ?? 'app'
|
|
266
280
|
}
|
|
267
281
|
|