@abide/abide 0.123.2 → 0.123.3
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 +27 -0
- package/bin/abide.ts +36 -65
- package/package.json +1 -1
- package/src/compiler/Diagnostics.ts +198 -0
- package/src/compiler/KeptChecker.ts +97 -0
- package/src/compiler/Lowering.ts +1 -1
- package/src/compiler/PositionedRefusal.ts +1 -1
- package/src/compiler/TypeSession.ts +62 -85
- package/src/compiler/appLayout.ts +28 -20
- package/src/compiler/argsDefaultsIn.ts +1 -1
- package/src/compiler/checkApp.ts +64 -68
- package/src/compiler/checkedShadow.ts +1 -1
- package/src/compiler/compileApp.ts +101 -70
- package/src/compiler/declaredExports.ts +2 -4
- package/src/compiler/emitClient.ts +1 -1
- package/src/compiler/formatAbide.ts +2 -2
- package/src/compiler/handlersUnder.ts +17 -29
- package/src/compiler/lineOf.ts +0 -19
- package/src/compiler/matchersOf.ts +3 -3
- package/src/compiler/mountsOf.ts +3 -3
- package/src/compiler/readSources.ts +16 -18
- package/src/compiler/readsIn.ts +1 -1
- package/src/compiler/serveLanguage.ts +23 -57
- package/src/compiler/sharedModuleWrites.ts +1 -1
- package/src/compiler/templateWarnings.ts +1 -2
- package/src/compiler/trackingWarnings.ts +1 -1
- package/src/server/CLIENT_BUILD.ts +4 -32
- package/src/server/RESUME_MILLISECONDS.ts +2 -0
- package/src/server/answerRequest.ts +11 -300
- package/src/server/answerRpc.ts +12 -54
- package/src/server/answerSocket.ts +8 -35
- package/src/server/appBuild.ts +377 -0
- package/src/server/appPlugins.ts +15 -17
- package/src/server/appTree.ts +6 -5
- package/src/server/argsContract.ts +209 -0
- package/src/server/bridgeRelay.ts +153 -65
- package/src/server/claudeCode.ts +30 -127
- package/src/server/config.ts +6 -7
- package/src/server/createApp.ts +30 -64
- package/src/server/devWorker.ts +11 -15
- package/src/server/handlerFileMount.ts +40 -0
- package/src/server/index.ts +2 -2
- package/src/server/packageDirectory.ts +14 -0
- package/src/server/preload.ts +3 -2
- package/src/server/principal.ts +4 -3
- package/src/server/remote.ts +1 -1
- package/src/server/runCompiled.ts +1 -1
- package/src/server/runDesktop.ts +1 -1
- package/src/server/seedBuffer.ts +1 -1
- package/src/server/serveDev.ts +24 -58
- package/src/server/servedApp.ts +390 -0
- package/src/server/signInChanged.ts +20 -0
- package/src/server/surfaceOf.ts +2 -1
- package/src/server/versionOf.ts +6 -15
- package/src/shared/applyPatch.ts +1 -1
- package/src/shared/muxFrames.ts +1 -1
- package/src/shared/reactive/Keyed.ts +1 -1
- package/src/shared/reactive/Reactive.ts +21 -8
- package/src/shared/reactive/argsContract.ts +131 -4
- package/src/shared/reactive/index.ts +1 -0
- package/src/shared/reactive/patchLog.ts +79 -0
- package/src/shared/reactive/patchMarks.ts +34 -0
- package/src/shared/reactive/recordWrites.ts +11 -14
- package/src/shared/reactive/share.ts +4 -3
- package/src/shared/rpc.ts +12 -19
- package/src/ui/ArmRegion.ts +10 -4
- package/src/ui/Level.ts +11 -20
- package/src/ui/LevelChain.ts +272 -0
- package/src/ui/Router.ts +54 -245
- package/src/ui/Scope.ts +17 -3
- package/src/ui/TryRegion.ts +22 -23
- package/src/ui/bindSpread.ts +2 -4
- package/src/ui/listenForRoutes.ts +3 -10
- package/src/ui/outboxQueue.ts +21 -3
- package/src/ui/reconcile.ts +4 -6
- package/src/ui/routing.ts +2 -1
- package/src/ui/rows.ts +2 -2
- package/src/ui/setAttribute.ts +12 -3
- package/src/ui/socketWire.ts +26 -11
- package/src/compiler/Finding.ts +0 -2
- package/src/compiler/Refusals.ts +0 -65
- package/src/compiler/formatDiagnosis.ts +0 -20
- package/src/server/appDirectories.ts +0 -9
- package/src/server/argsFor.ts +0 -39
- package/src/server/buildDirectory.ts +0 -10
- package/src/server/coerceArgs.ts +0 -109
- package/src/server/derivedArgs.ts +0 -26
- package/src/server/runBuild.ts +0 -24
- package/src/server/serveBuild.ts +0 -144
- package/src/server/testBuild.ts +0 -220
- package/src/shared/reactive/FOLLOWED.ts +0 -8
- package/src/shared/reactive/PATCHES.ts +0 -11
- package/src/shared/reactive/PATCH_SINKS.ts +0 -26
- package/src/shared/reactive/canonicalKey.ts +0 -95
package/src/server/answerRpc.ts
CHANGED
|
@@ -11,11 +11,11 @@ import { GLOBAL_GETS } from '#shared/GLOBAL_GETS.ts'
|
|
|
11
11
|
import { isRecord } from '#shared/isRecord.ts'
|
|
12
12
|
import type { JsonValue } from '#shared/JsonValue.ts'
|
|
13
13
|
import { mounted as underMount } from '#shared/mount.ts'
|
|
14
|
-
import type { Gated } from '#shared/reactive/argsContract.ts'
|
|
14
|
+
import type { Gated, HeldToAnnotation } from '#shared/reactive/argsContract.ts'
|
|
15
15
|
import { deadlineOf, heldGenerationOf } from '#shared/reactive/index.ts'
|
|
16
16
|
import type { Defaults } from '#shared/reactive/parameterDefaults.ts'
|
|
17
17
|
import { notFound, refuse } from '#shared/refuse.ts'
|
|
18
|
-
import type {
|
|
18
|
+
import type { Method, Rpc } from '#shared/rpc.ts'
|
|
19
19
|
import { RPC_SERVER } from '#shared/RPC_SERVER.ts'
|
|
20
20
|
import { argsInURL } from '#shared/rpcRequest.ts'
|
|
21
21
|
import { VERSION_HEADER } from '#shared/VERSION_HEADER.ts'
|
|
@@ -29,14 +29,8 @@ import {
|
|
|
29
29
|
} from './answerFailure.ts'
|
|
30
30
|
import { followAsEvents } from './answerSocket.ts'
|
|
31
31
|
import { answerValue, namesEtag, sseHeaders } from './answerValue.ts'
|
|
32
|
-
import { argsFor } from './
|
|
32
|
+
import { argsFor, collapsed, derivedArgs } from './argsContract.ts'
|
|
33
33
|
import { callerOf } from './callerOf.ts'
|
|
34
|
-
import {
|
|
35
|
-
argsDocumentOf,
|
|
36
|
-
coerceArgs,
|
|
37
|
-
collapsed,
|
|
38
|
-
type Document,
|
|
39
|
-
} from './coerceArgs.ts'
|
|
40
34
|
import { positiveSetting } from './config.ts'
|
|
41
35
|
import {
|
|
42
36
|
allows,
|
|
@@ -122,12 +116,6 @@ export type Mounted = Mount & {
|
|
|
122
116
|
*/
|
|
123
117
|
rungs: LaneRung[]
|
|
124
118
|
handler: Rpc<unknown, unknown> & Gated<unknown> & HeldToAnnotation
|
|
125
|
-
/**
|
|
126
|
-
* The args' JSON Schema, probed once at mount.
|
|
127
|
-
*
|
|
128
|
-
* 19.7, 20.11 — a third-party schema converts itself on every probe.
|
|
129
|
-
*/
|
|
130
|
-
argsDocument: Document | undefined
|
|
131
119
|
}
|
|
132
120
|
|
|
133
121
|
/**
|
|
@@ -168,7 +156,6 @@ export function mountedOwn(
|
|
|
168
156
|
args: undefined,
|
|
169
157
|
result: undefined,
|
|
170
158
|
handler: handler as Mounted['handler'],
|
|
171
|
-
argsDocument: undefined,
|
|
172
159
|
},
|
|
173
160
|
]
|
|
174
161
|
}
|
|
@@ -195,10 +182,12 @@ export async function mountHandlers(
|
|
|
195
182
|
const mount = table[at] as Mount
|
|
196
183
|
const exported = (modules[at] as Record<string, unknown>)[mount.export]
|
|
197
184
|
const handler = exported as Mounted['handler']
|
|
198
|
-
const declared = argsDocumentOf(handler)
|
|
199
185
|
// 16.9, 16.10 — the address `rpc.url` and `rpc.raw` build on, under the mount — and what a
|
|
200
186
|
// page's render turns a call it made into the browser's request by (41.20).
|
|
201
187
|
handler.address = underMount(mount.address)
|
|
188
|
+
// 19.9 — the schema the build derived off its annotation, for a handler whose module did not
|
|
189
|
+
// file it on load: a bundled executable's faces carry no `derivedArgs` call.
|
|
190
|
+
if (mount.args !== undefined) derivedArgs(handler, mount.args)
|
|
202
191
|
handlers.set(handlerKey(mount.method, mount.address), {
|
|
203
192
|
...mount,
|
|
204
193
|
// 37.1, 16.72 — the route its file's hooks are filed at, which the build read off
|
|
@@ -206,7 +195,6 @@ export async function mountHandlers(
|
|
|
206
195
|
// 37.14 — a handler's lane answers a refusal as Problem Details (15.1).
|
|
207
196
|
rungs: rungsOver(mount.scope, answered),
|
|
208
197
|
handler,
|
|
209
|
-
argsDocument: declared ?? (mount.args as Document | undefined),
|
|
210
198
|
})
|
|
211
199
|
}
|
|
212
200
|
return { handlers }
|
|
@@ -254,12 +242,15 @@ export async function answerRpc(
|
|
|
254
242
|
return answered(crossingRefusal(crossing, found.method))
|
|
255
243
|
const raw = await argsOf(request, url, inUrl, form, handler.maxBodySize)
|
|
256
244
|
if (isFailed(raw)) return answered(raw)
|
|
257
|
-
const sent = withParameters(raw, params, inUrl || form, found)
|
|
258
|
-
if (isFailed(sent)) return answered(sent)
|
|
259
245
|
let args: unknown
|
|
260
246
|
try {
|
|
261
247
|
// A URL's and a form's args are text, and the query tells a bare name from `name=` (19.7).
|
|
262
|
-
args = argsFor(
|
|
248
|
+
args = argsFor(
|
|
249
|
+
handler,
|
|
250
|
+
raw,
|
|
251
|
+
inUrl ? url.search : form ? '' : undefined,
|
|
252
|
+
params,
|
|
253
|
+
)
|
|
263
254
|
} catch (refused) {
|
|
264
255
|
if (isFailed(refused)) return answered(refused)
|
|
265
256
|
throw refused
|
|
@@ -478,39 +469,6 @@ async function argsOf(
|
|
|
478
469
|
return parsed
|
|
479
470
|
}
|
|
480
471
|
|
|
481
|
-
/**
|
|
482
|
-
* The args a request sent, with the parameters its route matched laid in.
|
|
483
|
-
*
|
|
484
|
-
* 16.65 — the path's, whatever the method. 16.66 — a parameter the query or the body names as
|
|
485
|
-
* well is refused rather than one of the two chosen. A JSON body's members are typed already, so the
|
|
486
|
-
* path's text is coerced to the declared types before it joins them (19.7); text args are coerced
|
|
487
|
-
* with the rest of the text.
|
|
488
|
-
*/
|
|
489
|
-
function withParameters(
|
|
490
|
-
sent: unknown,
|
|
491
|
-
params: Record<string, string | string[]>,
|
|
492
|
-
text: boolean,
|
|
493
|
-
found: Mounted,
|
|
494
|
-
): unknown | Failed<'HttpError', undefined> {
|
|
495
|
-
let none = true
|
|
496
|
-
for (const name in params) {
|
|
497
|
-
none = false
|
|
498
|
-
if (isRecord(sent) && name in sent)
|
|
499
|
-
return refuse(
|
|
500
|
-
400,
|
|
501
|
-
`The parameter '${name}' is in the path and in the request as well.`,
|
|
502
|
-
)
|
|
503
|
-
}
|
|
504
|
-
if (none) return sent
|
|
505
|
-
const fromPath = text
|
|
506
|
-
? params
|
|
507
|
-
: (coerceArgs(params, found.argsDocument, '') as Record<
|
|
508
|
-
string,
|
|
509
|
-
unknown
|
|
510
|
-
>)
|
|
511
|
-
return isRecord(sent) ? { ...sent, ...fromPath } : { ...fromPath }
|
|
512
|
-
}
|
|
513
|
-
|
|
514
472
|
/**
|
|
515
473
|
* The request's body, received whole, under the ceiling `maxBodySize` sets, or
|
|
516
474
|
* `ABIDE_MAX_REQUEST_BODY_SIZE` where it is unset, or a 413 refusal past it.
|
|
@@ -16,16 +16,14 @@ import { namedLogger } from '#shared/log.ts'
|
|
|
16
16
|
import { compose } from '#shared/Middleware.ts'
|
|
17
17
|
import { unmounted } from '#shared/mount.ts'
|
|
18
18
|
import type { BrowserFrame, ServerFrame } from '#shared/muxFrames.ts'
|
|
19
|
-
import {
|
|
20
|
-
import { servedCellOf } from '#shared/reactive/index.ts'
|
|
19
|
+
import { followServed } from '#shared/reactive/index.ts'
|
|
21
20
|
import { matchesPattern } from '#shared/reactive/matchesPattern.ts'
|
|
22
|
-
import { PATCH_SINKS } from '#shared/reactive/PATCH_SINKS.ts'
|
|
23
21
|
import { unowned } from '#shared/reactive/watch.ts'
|
|
24
22
|
import { refuse } from '#shared/refuse.ts'
|
|
25
23
|
import { rpcRequest } from '#shared/rpcRequest.ts'
|
|
26
24
|
|
|
27
25
|
import { handlerKey, type Mounted } from './answerRpc.ts'
|
|
28
|
-
import { argsFor } from './
|
|
26
|
+
import { argsFor } from './argsContract.ts'
|
|
29
27
|
import { positiveSetting } from './config.ts'
|
|
30
28
|
import { closedTo, crossingOrigin, crossingRefusal } from './crossingOrigin.ts'
|
|
31
29
|
import { failureOf } from './hooks.ts'
|
|
@@ -182,7 +180,7 @@ export function broadcast(
|
|
|
182
180
|
for (const follower of followed.all) {
|
|
183
181
|
if (!matchesPattern(follower.args, pattern)) continue
|
|
184
182
|
// A fed copy is refreshed by the production the reload mints (7.18), and invalidated through
|
|
185
|
-
// the cell it is fed from (`
|
|
183
|
+
// the cell it is fed from (`followServed`), whichever trigger reached it — where the entry it is
|
|
186
184
|
// fed from is still the one standing.
|
|
187
185
|
if (attach(rpc as Mounted['handler'], follower) !== undefined) continue
|
|
188
186
|
follower.peer.send(follower.id, TRIGGERS[member])
|
|
@@ -409,7 +407,7 @@ export class MuxConnection {
|
|
|
409
407
|
const pending = (): void => {}
|
|
410
408
|
try {
|
|
411
409
|
// 18.3 — the args a `GET` over http is answered with, sent as JSON.
|
|
412
|
-
const args = argsFor(followed, frame.args, undefined)
|
|
410
|
+
const args = argsFor(followed.handler, frame.args, undefined)
|
|
413
411
|
// 18.8 — A GUARDED `GET`'s RUNGS RUN ONCE, as the subscription opens, in this
|
|
414
412
|
// connection's request: one they admit is fed the value, and one they refuse is sent its
|
|
415
413
|
// triggers alone.
|
|
@@ -622,19 +620,12 @@ function feed(serving: unknown, fed: Follower): () => void {
|
|
|
622
620
|
}
|
|
623
621
|
|
|
624
622
|
function startFeed(serving: unknown): Feed {
|
|
625
|
-
const past = servedCellOf(serving)
|
|
626
|
-
const release = sinkFor(past)
|
|
627
623
|
const fed = new Set<Follower>()
|
|
628
|
-
//
|
|
629
|
-
|
|
624
|
+
// 18.11 — A VALUE FED TO A FOLLOWER KEEPS ITS PATCHES AS DATA while anyone is fed it, and 7.10 —
|
|
625
|
+
// an invalidate of the value, however it was spelled, reaches every copy it feeds.
|
|
626
|
+
const release = followServed(serving, () => {
|
|
630
627
|
for (const one of fed) one.peer.send(one.id, TRIGGERS.invalidate)
|
|
631
|
-
}
|
|
632
|
-
let followed = FOLLOWED.get(past)
|
|
633
|
-
if (followed === undefined) {
|
|
634
|
-
followed = new Set()
|
|
635
|
-
FOLLOWED.set(past, followed)
|
|
636
|
-
}
|
|
637
|
-
followed.add(invalidated)
|
|
628
|
+
})
|
|
638
629
|
const shared: Feed = {
|
|
639
630
|
fed,
|
|
640
631
|
landed: false,
|
|
@@ -642,8 +633,6 @@ function startFeed(serving: unknown): Feed {
|
|
|
642
633
|
stopWatch()
|
|
643
634
|
release()
|
|
644
635
|
FEEDS.delete(serving as object)
|
|
645
|
-
followed.delete(invalidated)
|
|
646
|
-
if (followed.size === 0) FOLLOWED.delete(past)
|
|
647
636
|
},
|
|
648
637
|
}
|
|
649
638
|
FEEDS.set(serving as object, shared)
|
|
@@ -698,19 +687,3 @@ function addressIn(named: string): string {
|
|
|
698
687
|
const path = URL.parse(named)?.pathname ?? named
|
|
699
688
|
return unmounted(path) ?? path
|
|
700
689
|
}
|
|
701
|
-
|
|
702
|
-
/**
|
|
703
|
-
* Holds a patch sink on `cell`; returns what releases it.
|
|
704
|
-
*
|
|
705
|
-
* 18.11 — A VALUE FED TO A FOLLOWER KEEPS ITS PATCHES AS DATA while anyone is fed it, and stops
|
|
706
|
-
* when the last one goes: a sink nobody drains grows with every patch.
|
|
707
|
-
*/
|
|
708
|
-
function sinkFor(cell: object): () => void {
|
|
709
|
-
const sink = PATCH_SINKS.get(cell) ?? { ops: [], lost: false, fed: 0 }
|
|
710
|
-
PATCH_SINKS.set(cell, sink)
|
|
711
|
-
sink.fed += 1
|
|
712
|
-
return () => {
|
|
713
|
-
sink.fed -= 1
|
|
714
|
-
if (sink.fed === 0) PATCH_SINKS.delete(cell)
|
|
715
|
-
}
|
|
716
|
-
}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// THE BUILD OF THE APP UNDER A ROOT, ON DISK: where it lives (38.27), what is in it, whether there
|
|
2
|
+
// is one, and making one where there is not (38.100). `abide build` writes it, and `abide start`,
|
|
3
|
+
// `createApp`, the test preload and the CLI's own reads take it — every one through here, so the
|
|
4
|
+
// layout is spelled once and "is there a build" has one answer.
|
|
5
|
+
//
|
|
6
|
+
// THE COMPILATION IS THE MARKER. A build removes it before writing anything and writes it last, so a
|
|
7
|
+
// build that failed partway leaves no build — `abide start` builds again and says why, rather than
|
|
8
|
+
// booting what is left (38.100).
|
|
9
|
+
//
|
|
10
|
+
// 42.16 — UNDER `bun test`, THE BUILD IS ONE PER STATE OF THE APP'S SOURCES: its directory is named
|
|
11
|
+
// after a hash of what made it, so a build either matches the sources or does not exist, and every
|
|
12
|
+
// test process — every `--parallel` worker, every spawned child — reuses the one that does. Eight
|
|
13
|
+
// workers each building their own copy of a 1.3 s build waited 7–10 s apiece.
|
|
14
|
+
|
|
15
|
+
import { mkdir, rename, rm, utimes, writeFile } from 'node:fs/promises' // bun exposes no directory creation, rename, removal, mtime write or exclusive create of its own
|
|
16
|
+
import { dirname } from 'node:path' // bun exposes no path helpers of its own
|
|
17
|
+
|
|
18
|
+
import type { Compilation } from './abideModules.ts'
|
|
19
|
+
import type { AppSources } from './answerRequest.ts'
|
|
20
|
+
import type { Manifest } from './manifestOf.ts'
|
|
21
|
+
import { namesUnder } from './namesUnder.ts'
|
|
22
|
+
import { packageDirectory } from './packageDirectory.ts'
|
|
23
|
+
import { builtSources, serveApp, type ServedApp } from './servedApp.ts'
|
|
24
|
+
|
|
25
|
+
/** What builds the app under `root` into `out` where nobody has: spawned, or the CLI's own. */
|
|
26
|
+
export type Builder = (root: string, out: string) => Promise<unknown>
|
|
27
|
+
|
|
28
|
+
/** Where each part of the build in `out` is. */
|
|
29
|
+
export type BuildFiles = {
|
|
30
|
+
/** The compilation — every `.abide` compiled, and the build's tables — and the marker. */
|
|
31
|
+
compilation: string
|
|
32
|
+
/** 38.5 — beside the bundle rather than in it: everything under `client` is the browser's. */
|
|
33
|
+
manifest: string
|
|
34
|
+
/** The browser bundle, each file beside its precompressed copies (38.5). */
|
|
35
|
+
client: string
|
|
36
|
+
/** 38.18 — every `public` directory, each file at its address. */
|
|
37
|
+
published: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The CLI, spawned rather than imported: the compiler is not reachable from the server seam.
|
|
41
|
+
const ABIDE_BIN = Bun.fileURLToPath(
|
|
42
|
+
new URL('../../bin/abide.ts', import.meta.url),
|
|
43
|
+
)
|
|
44
|
+
// The framework's own source is part of what made a build: a compiler change is a new build.
|
|
45
|
+
const FRAMEWORK = Bun.fileURLToPath(new URL('../..', import.meta.url))
|
|
46
|
+
const POLL_MS = 50
|
|
47
|
+
// A test build no process has reused for this long is swept by the next one that builds.
|
|
48
|
+
const UNUSED_FOR_MS = 24 * 60 * 60 * 1000
|
|
49
|
+
|
|
50
|
+
// One lookup per app per test process, however many files in it call `createApp`.
|
|
51
|
+
const TEST_BUILDS = new Map<string, Promise<string>>()
|
|
52
|
+
|
|
53
|
+
/** Where the build of the app under `root` goes: `.abide` in its package. 38.27. */
|
|
54
|
+
export function buildDirectory(root: string): string {
|
|
55
|
+
return `${packageDirectory(root)}/.abide`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function buildFiles(out: string): BuildFiles {
|
|
59
|
+
return {
|
|
60
|
+
compilation: `${out}/compilation.json`,
|
|
61
|
+
manifest: `${out}/manifest.json`,
|
|
62
|
+
client: `${out}/client`,
|
|
63
|
+
published: `${out}/public`,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The files a build into `out` writes, its previous marker removed first — so that from here until
|
|
69
|
+
* `markBuild`, `out` holds no build.
|
|
70
|
+
*/
|
|
71
|
+
export async function unmarkedBuild(out: string): Promise<BuildFiles> {
|
|
72
|
+
const files = buildFiles(out)
|
|
73
|
+
await rm(files.compilation, { force: true })
|
|
74
|
+
return files
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Ends a build: its compilation, written whole and last, is what says there is one. */
|
|
78
|
+
export function markBuild(
|
|
79
|
+
files: BuildFiles,
|
|
80
|
+
compilation: Compilation,
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
return writeWhole(files.compilation, JSON.stringify(compilation))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Writes `path` whole: to a name of its own beside it, then renamed over it.
|
|
87
|
+
*
|
|
88
|
+
* 38.66 — a reader, or a second build, finds the last complete file there and never a partial one.
|
|
89
|
+
*/
|
|
90
|
+
export async function writeWhole(
|
|
91
|
+
path: string,
|
|
92
|
+
content: string | Blob,
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
const partial = `${path}.${crypto.randomUUID()}.partial`
|
|
95
|
+
await Bun.write(partial, content)
|
|
96
|
+
await rename(partial, path)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** What a build in `out` holds beside its compilation: the manifest, and every file it serves. */
|
|
100
|
+
export async function builtFiles(out: string): Promise<{
|
|
101
|
+
manifest: Manifest
|
|
102
|
+
client: Map<string, string>
|
|
103
|
+
published: Map<string, string>
|
|
104
|
+
}> {
|
|
105
|
+
const files = buildFiles(out)
|
|
106
|
+
return {
|
|
107
|
+
manifest: (await Bun.file(files.manifest).json()) as Manifest,
|
|
108
|
+
client: pathsUnder(files.client),
|
|
109
|
+
published: pathsUnder(files.published),
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The build in `out` served to this process's imports, with the sources an `App` is booted over —
|
|
115
|
+
* or `undefined` where `out` holds no build and `build` made none.
|
|
116
|
+
*
|
|
117
|
+
* 38.6 — what `abide start` boots: no compiler is loaded, and every `.abide` the app imports is
|
|
118
|
+
* answered out of what the build compiled. 38.100 — AN APP NOBODY BUILT IS BUILT, by `build`,
|
|
119
|
+
* rather than refused; `undefined` builds nothing, for a reader aimed at a build already there.
|
|
120
|
+
*/
|
|
121
|
+
export async function servedBuild(
|
|
122
|
+
root: string,
|
|
123
|
+
out: string,
|
|
124
|
+
build: Builder | undefined,
|
|
125
|
+
): Promise<(ServedApp & { load: () => Promise<AppSources> }) | undefined> {
|
|
126
|
+
const marker = buildFiles(out).compilation
|
|
127
|
+
if (!(await Bun.file(marker).exists())) {
|
|
128
|
+
if (build === undefined) return undefined
|
|
129
|
+
await build(root, out)
|
|
130
|
+
// A FRESH HANDLE: one taken before the build wrote the file reads it as empty.
|
|
131
|
+
if (!(await Bun.file(marker).exists())) return undefined
|
|
132
|
+
}
|
|
133
|
+
// Read beside the table's import rather than after it; a boot that never loads drops it.
|
|
134
|
+
const files = builtFiles(out)
|
|
135
|
+
files.catch(() => {})
|
|
136
|
+
const served = await serveApp(
|
|
137
|
+
root,
|
|
138
|
+
(await Bun.file(marker).json()) as Compilation,
|
|
139
|
+
)
|
|
140
|
+
return {
|
|
141
|
+
...served,
|
|
142
|
+
load: async () =>
|
|
143
|
+
builtSources({
|
|
144
|
+
table: served.table,
|
|
145
|
+
handlers: served.table.handlers,
|
|
146
|
+
...(await files),
|
|
147
|
+
}),
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** `abide build` of the app under `root` into `out`, run as the CLI. */
|
|
152
|
+
export async function spawnedBuild(root: string, out: string): Promise<void> {
|
|
153
|
+
const child = Bun.spawn(
|
|
154
|
+
[process.execPath, ABIDE_BIN, 'build', '--root', root, '--out', out],
|
|
155
|
+
{ stdout: 'ignore', stderr: 'pipe' },
|
|
156
|
+
)
|
|
157
|
+
const [exitCode, said] = await Promise.all([
|
|
158
|
+
child.exited,
|
|
159
|
+
new Response(child.stderr).text(),
|
|
160
|
+
])
|
|
161
|
+
if (exitCode !== 0)
|
|
162
|
+
throw new Error(
|
|
163
|
+
`abide: could not build the app under ${root}.\n${said}`,
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Where this process reads the build of the app under `root` from: its `buildDirectory`, or — `fresh`,
|
|
169
|
+
* which `bun test` is (42.16) — the build of its sources as they stand, made if no process has.
|
|
170
|
+
*/
|
|
171
|
+
export async function processBuild(
|
|
172
|
+
root: string,
|
|
173
|
+
fresh = process.env.NODE_ENV === 'test',
|
|
174
|
+
): Promise<string> {
|
|
175
|
+
if (!fresh) return buildDirectory(root)
|
|
176
|
+
let built = TEST_BUILDS.get(root)
|
|
177
|
+
if (built === undefined) {
|
|
178
|
+
// UNDER `node_modules`, so a bare import in the built modules resolves as it does from the
|
|
179
|
+
// app, and so it is ignored wherever `node_modules` is.
|
|
180
|
+
built = sourceHash(root).then((key) =>
|
|
181
|
+
sharedBuild(
|
|
182
|
+
`${packageDirectory(root)}/node_modules/.cache/abide`,
|
|
183
|
+
`build-${key}`,
|
|
184
|
+
(out) => spawnedBuild(root, out),
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
TEST_BUILDS.set(root, built)
|
|
188
|
+
}
|
|
189
|
+
return built
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Every file under `directory`, by the name below it to its path. */
|
|
193
|
+
function pathsUnder(directory: string): Map<string, string> {
|
|
194
|
+
const paths = new Map<string, string>()
|
|
195
|
+
for (const name of namesUnder(directory))
|
|
196
|
+
paths.set(name, `${directory}/${name}`)
|
|
197
|
+
return paths
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* `builds/key`, made by `build` in exactly one process however many ask at once.
|
|
202
|
+
*
|
|
203
|
+
* THE FIRST TO CREATE `key.lock` BUILDS — an exclusive create is atomic, so one process wins — into
|
|
204
|
+
* a staging directory renamed into place, so nothing reads a half-written build. The rest wait for
|
|
205
|
+
* it, and take the lock over if the process holding it has ended.
|
|
206
|
+
*/
|
|
207
|
+
export async function sharedBuild(
|
|
208
|
+
builds: string,
|
|
209
|
+
key: string,
|
|
210
|
+
build: (out: string) => Promise<void>,
|
|
211
|
+
): Promise<string> {
|
|
212
|
+
const out = `${builds}/${key}`
|
|
213
|
+
const lock = `${out}.lock`
|
|
214
|
+
await mkdir(builds, { recursive: true })
|
|
215
|
+
for (;;) {
|
|
216
|
+
if (await isDirectory(out)) {
|
|
217
|
+
const now = new Date()
|
|
218
|
+
await utimes(out, now, now)
|
|
219
|
+
return out
|
|
220
|
+
}
|
|
221
|
+
if (await claim(lock)) {
|
|
222
|
+
try {
|
|
223
|
+
if (!(await isDirectory(out))) await buildInto(out, build)
|
|
224
|
+
} finally {
|
|
225
|
+
await rm(lock, { force: true })
|
|
226
|
+
}
|
|
227
|
+
await sweep(builds, key)
|
|
228
|
+
return out
|
|
229
|
+
}
|
|
230
|
+
if (await holderEnded(lock)) await rm(lock, { force: true })
|
|
231
|
+
else await Bun.sleep(POLL_MS)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function buildInto(
|
|
236
|
+
out: string,
|
|
237
|
+
build: (out: string) => Promise<void>,
|
|
238
|
+
): Promise<void> {
|
|
239
|
+
const staging = `${out}.tmp-${process.pid}`
|
|
240
|
+
await rm(staging, { recursive: true, force: true })
|
|
241
|
+
try {
|
|
242
|
+
await build(staging)
|
|
243
|
+
await rename(staging, out)
|
|
244
|
+
} catch (thrown) {
|
|
245
|
+
await rm(staging, { recursive: true, force: true })
|
|
246
|
+
// TWO HOLDERS, where one took over a lock the other had just taken over: the first rename
|
|
247
|
+
// stands, and it is the same build.
|
|
248
|
+
if (await isDirectory(out)) return
|
|
249
|
+
throw thrown
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* A hash of everything a build of the app under `root` is made from.
|
|
255
|
+
*
|
|
256
|
+
* The app's files, its package's manifest and tsconfig and the lockfile above them, the framework's
|
|
257
|
+
* own source, and `root` itself, since a build carries the paths it was made at.
|
|
258
|
+
*/
|
|
259
|
+
export async function sourceHash(root: string): Promise<string> {
|
|
260
|
+
const app = packageDirectory(root)
|
|
261
|
+
const paths = [
|
|
262
|
+
...filesUnder(root),
|
|
263
|
+
...filesUnder(`${FRAMEWORK}src`),
|
|
264
|
+
...filesUnder(`${FRAMEWORK}bin`),
|
|
265
|
+
`${app}/package.json`,
|
|
266
|
+
`${app}/tsconfig.json`,
|
|
267
|
+
]
|
|
268
|
+
const lockfile = await lockfileAbove(app)
|
|
269
|
+
if (lockfile !== undefined) paths.push(lockfile)
|
|
270
|
+
const contents = await Promise.all(
|
|
271
|
+
paths.map((path) =>
|
|
272
|
+
Bun.file(path)
|
|
273
|
+
.bytes()
|
|
274
|
+
.catch(() => undefined),
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
const hasher = new Bun.CryptoHasher('sha256')
|
|
278
|
+
hasher.update(root)
|
|
279
|
+
for (let at = 0; at < paths.length; at += 1) {
|
|
280
|
+
const bytes = contents[at]
|
|
281
|
+
if (bytes === undefined) continue
|
|
282
|
+
hasher.update(paths[at] as string)
|
|
283
|
+
hasher.update(bytes)
|
|
284
|
+
}
|
|
285
|
+
return hasher.digest('hex').slice(0, 16)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Sorted, so the hash does not depend on the order a directory lists in. Dot entries and
|
|
289
|
+
// `node_modules` are skipped: neither is the app's source, and the builds live in one.
|
|
290
|
+
function filesUnder(directory: string): string[] {
|
|
291
|
+
const found: string[] = []
|
|
292
|
+
for (const name of namesUnder(directory))
|
|
293
|
+
if (!`/${name}`.includes('/node_modules/'))
|
|
294
|
+
found.push(`${directory}/${name}`)
|
|
295
|
+
return found.sort()
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function lockfileAbove(directory: string): Promise<string | undefined> {
|
|
299
|
+
for (let at = directory; ; at = dirname(at)) {
|
|
300
|
+
for (const name of ['bun.lock', 'bun.lockb'])
|
|
301
|
+
if (await Bun.file(`${at}/${name}`).exists()) return `${at}/${name}`
|
|
302
|
+
if (dirname(at) === at) return undefined
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function claim(lock: string): Promise<boolean> {
|
|
307
|
+
try {
|
|
308
|
+
await writeFile(lock, String(process.pid), { flag: 'wx' })
|
|
309
|
+
return true
|
|
310
|
+
} catch (thrown) {
|
|
311
|
+
if ((thrown as { code?: string }).code === 'EEXIST') return false
|
|
312
|
+
throw thrown
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function holderEnded(lock: string): Promise<boolean> {
|
|
317
|
+
const text = await Bun.file(lock)
|
|
318
|
+
.text()
|
|
319
|
+
.catch(() => undefined)
|
|
320
|
+
// Gone is not ended: the holder finished, and the next pass finds its build.
|
|
321
|
+
if (text === undefined) return false
|
|
322
|
+
// Empty is a holder between creating the lock and writing its pid.
|
|
323
|
+
return text !== '' && !isRunning(Number(text))
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Removes builds unused for a day, and what an ended process left half-made. */
|
|
327
|
+
async function sweep(builds: string, kept: string): Promise<void> {
|
|
328
|
+
const names = new Bun.Glob('*').scanSync({ cwd: builds, onlyFiles: false })
|
|
329
|
+
const removals: Promise<void>[] = []
|
|
330
|
+
const now = Date.now()
|
|
331
|
+
for (const name of names) {
|
|
332
|
+
if (name === kept) continue
|
|
333
|
+
const staged = /\.tmp-(\d+)$/.exec(name)
|
|
334
|
+
if (staged !== null) {
|
|
335
|
+
if (!isRunning(Number(staged[1])))
|
|
336
|
+
removals.push(
|
|
337
|
+
rm(`${builds}/${name}`, { recursive: true, force: true }),
|
|
338
|
+
)
|
|
339
|
+
continue
|
|
340
|
+
}
|
|
341
|
+
if (name.endsWith('.lock')) continue
|
|
342
|
+
removals.push(
|
|
343
|
+
Bun.file(`${builds}/${name}`)
|
|
344
|
+
.stat()
|
|
345
|
+
.then((found) =>
|
|
346
|
+
now - found.mtimeMs > UNUSED_FOR_MS
|
|
347
|
+
? rm(`${builds}/${name}`, {
|
|
348
|
+
recursive: true,
|
|
349
|
+
force: true,
|
|
350
|
+
})
|
|
351
|
+
: undefined,
|
|
352
|
+
)
|
|
353
|
+
.catch(() => undefined),
|
|
354
|
+
)
|
|
355
|
+
}
|
|
356
|
+
await Promise.all(removals)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function isDirectory(path: string): Promise<boolean> {
|
|
360
|
+
return (
|
|
361
|
+
(
|
|
362
|
+
await Bun.file(path)
|
|
363
|
+
.stat()
|
|
364
|
+
.catch(() => undefined)
|
|
365
|
+
)?.isDirectory() === true
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function isRunning(pid: number): boolean {
|
|
370
|
+
try {
|
|
371
|
+
process.kill(pid, 0)
|
|
372
|
+
return true
|
|
373
|
+
} catch (thrown) {
|
|
374
|
+
// EPERM is a process that exists and is someone else's.
|
|
375
|
+
return (thrown as { code?: string }).code === 'EPERM'
|
|
376
|
+
}
|
|
377
|
+
}
|
package/src/server/appPlugins.ts
CHANGED
|
@@ -1,26 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { packageDirectory } from './packageDirectory.ts'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The app's own bundler plugins, loaded from `bunfig.toml`'s `[serve.static] plugins`.
|
|
5
5
|
*
|
|
6
6
|
* 38.43 — declared where bun already has an app declare them, the key `bun-plugin-tailwind`
|
|
7
|
-
* documents. `Bun.build` reads no bunfig, so both client builds read this one —
|
|
8
|
-
*
|
|
7
|
+
* documents. `Bun.build` reads no bunfig, so both client builds read this one — the app's
|
|
8
|
+
* package's (`packageDirectory`), beside the `package.json` `config` reads.
|
|
9
9
|
*/
|
|
10
10
|
export async function appPlugins(root: string): Promise<Bun.BunPlugin[]> {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
const plugins: Bun.BunPlugin[] = []
|
|
18
|
-
for (const specifier of parsed.serve?.static?.plugins ?? [])
|
|
19
|
-
plugins.push(
|
|
20
|
-
(await import(Bun.resolveSync(specifier, directory)))
|
|
21
|
-
.default as Bun.BunPlugin,
|
|
22
|
-
)
|
|
23
|
-
return plugins
|
|
11
|
+
const directory = packageDirectory(root)
|
|
12
|
+
const file = Bun.file(`${directory}/bunfig.toml`)
|
|
13
|
+
if (!(await file.exists())) return []
|
|
14
|
+
const parsed = Bun.TOML.parse(await file.text()) as {
|
|
15
|
+
serve?: { static?: { plugins?: string[] } }
|
|
24
16
|
}
|
|
25
|
-
|
|
17
|
+
const plugins: Bun.BunPlugin[] = []
|
|
18
|
+
for (const specifier of parsed.serve?.static?.plugins ?? [])
|
|
19
|
+
plugins.push(
|
|
20
|
+
(await import(Bun.resolveSync(specifier, directory)))
|
|
21
|
+
.default as Bun.BunPlugin,
|
|
22
|
+
)
|
|
23
|
+
return plugins
|
|
26
24
|
}
|
package/src/server/appTree.ts
CHANGED
|
@@ -12,15 +12,16 @@
|
|
|
12
12
|
// A WALK RATHER THAN A GLOB: a glob descends `node_modules` and the build output before a filter
|
|
13
13
|
// can drop them.
|
|
14
14
|
|
|
15
|
-
import { type Dirent,
|
|
15
|
+
import { type Dirent, readdirSync } from 'node:fs' // bun has no directory listing of its own
|
|
16
16
|
|
|
17
|
-
import type {
|
|
17
|
+
import type { Diagnostics } from '#compiler/Diagnostics.ts'
|
|
18
18
|
import { ABIDE_ADDRESS } from '#shared/ADDRESSES.ts'
|
|
19
19
|
import {
|
|
20
20
|
PUBLIC_DIRECTORY,
|
|
21
21
|
RESERVED_DIRECTORIES,
|
|
22
22
|
} from '#shared/RESERVED_DIRECTORIES.ts'
|
|
23
23
|
|
|
24
|
+
import { packageDirectory } from './packageDirectory.ts'
|
|
24
25
|
import {
|
|
25
26
|
DIRECTORY_FILES,
|
|
26
27
|
isBenchFile,
|
|
@@ -54,7 +55,7 @@ type AppFiles = {
|
|
|
54
55
|
*
|
|
55
56
|
* A missing root is an app with nothing in it, not a fault.
|
|
56
57
|
*/
|
|
57
|
-
export function appFiles(root: string, refusals:
|
|
58
|
+
export function appFiles(root: string, refusals: Diagnostics): AppFiles {
|
|
58
59
|
const files: AppFiles = {
|
|
59
60
|
abide: [],
|
|
60
61
|
handlers: [],
|
|
@@ -64,7 +65,7 @@ export function appFiles(root: string, refusals: Refusals): AppFiles {
|
|
|
64
65
|
}
|
|
65
66
|
// 23.22, 38.25 — a root that is its package's directory holds the package's own files beside its
|
|
66
67
|
// pages.
|
|
67
|
-
const packaged =
|
|
68
|
+
const packaged = packageDirectory(root) === root
|
|
68
69
|
walk(root, '', undefined, files, refusals, packaged)
|
|
69
70
|
files.abide.sort()
|
|
70
71
|
files.handlers.sort()
|
|
@@ -81,7 +82,7 @@ function walk(
|
|
|
81
82
|
relative: string,
|
|
82
83
|
reserved: { path: string; name: string } | undefined,
|
|
83
84
|
files: AppFiles,
|
|
84
|
-
refusals:
|
|
85
|
+
refusals: Diagnostics,
|
|
85
86
|
packaged = false,
|
|
86
87
|
): void {
|
|
87
88
|
let entries: Dirent[]
|