@abide/abide 0.123.2 → 0.124.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/ABIDE.md +8 -5
- package/CHANGELOG.md +58 -0
- package/bin/abide.ts +36 -65
- package/docs/REGISTRY.md +45 -12
- package/docs/RULEBOOK.md +24 -14
- package/docs/abide.md +999 -459
- package/package.json +1 -1
- package/src/compiler/ARRAY_MUTATORS.ts +20 -0
- package/src/compiler/Diagnostics.ts +198 -0
- package/src/compiler/KeptChecker.ts +97 -0
- package/src/compiler/LoweredFile.ts +609 -0
- package/src/compiler/Lowering.ts +18 -1
- package/src/compiler/PositionedRefusal.ts +1 -1
- package/src/compiler/SHARED_NAMES.ts +2 -0
- package/src/compiler/ShadowHost.ts +670 -0
- package/src/compiler/ShadowMap.ts +1 -1
- package/src/compiler/TypeSession.ts +76 -1743
- package/src/compiler/appLayout.ts +28 -20
- package/src/compiler/appLints.ts +109 -0
- package/src/compiler/argsDefaultsIn.ts +1 -1
- package/src/compiler/checkApp.ts +64 -68
- package/src/compiler/checkedShadow.ts +2 -2
- package/src/compiler/classifyRead.ts +16 -1
- package/src/compiler/compileApp.ts +102 -71
- package/src/compiler/declaredExports.ts +46 -21
- package/src/compiler/diagnoseSources.ts +232 -0
- package/src/compiler/emitClient.ts +1 -1
- package/src/compiler/formatAbide.ts +7 -8
- package/src/compiler/handlersUnder.ts +32 -63
- package/src/compiler/lineOf.ts +0 -19
- package/src/compiler/matchersOf.ts +3 -3
- package/src/compiler/mountsOf.ts +3 -3
- package/src/compiler/reactiveReads.ts +235 -0
- package/src/compiler/reactiveTypes.ts +13 -0
- package/src/compiler/readSources.ts +16 -18
- package/src/compiler/readsIn.ts +181 -82
- package/src/compiler/routesDeclaration.ts +46 -0
- package/src/compiler/scaffold.ts +7 -2
- package/src/compiler/serveLanguage.ts +46 -74
- package/src/compiler/sharedModuleWrites.ts +1 -1
- package/src/compiler/splitImports.ts +2 -2
- 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/DesktopSession.ts +213 -0
- package/src/server/Followers.ts +399 -0
- package/src/server/MODULE_QUERIES.ts +6 -2
- package/src/server/PageRender.ts +201 -0
- package/src/server/RESUME_MILLISECONDS.ts +2 -0
- package/src/server/abideModules.ts +7 -7
- package/src/server/answerFailure.ts +3 -2
- package/src/server/answerRequest.ts +16 -369
- package/src/server/answerRpc.ts +48 -302
- package/src/server/answerSocket.ts +41 -490
- package/src/server/appBuild.ts +377 -0
- package/src/server/appKit.ts +1 -1
- 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 +38 -66
- package/src/server/csp.ts +1 -1
- package/src/server/deferredBuilds.ts +3 -16
- package/src/server/devWorker.ts +11 -15
- package/src/server/faceOf.ts +1 -1
- package/src/server/generatedSurfaces.ts +2 -1
- package/src/server/handlerFileMount.ts +40 -0
- package/src/server/index.ts +3 -3
- package/src/server/installServer.ts +5 -9
- package/src/server/invokeHandler.ts +230 -0
- package/src/server/mountHandlers.ts +184 -0
- package/src/server/openDesktopWindow.ts +308 -0
- package/src/server/packageDirectory.ts +14 -0
- package/src/server/preload.ts +21 -2
- package/src/server/principal.ts +5 -4
- package/src/server/remote.ts +2 -2
- package/src/server/render.ts +31 -59
- package/src/server/requestScope.ts +5 -36
- package/src/server/runCompiled.ts +1 -1
- package/src/server/runDesktop.ts +20 -448
- package/src/server/seedBuffer.ts +12 -12
- package/src/server/serveDev.ts +24 -58
- package/src/server/servedApp.ts +390 -0
- package/src/server/shell.ts +1 -1
- package/src/server/signInChanged.ts +20 -0
- package/src/server/surfaceOf.ts +3 -2
- package/src/server/versionOf.ts +7 -16
- package/src/shared/RPC_SERVER.ts +27 -31
- package/src/shared/Schema.ts +1 -2
- package/src/shared/applyPatch.ts +1 -1
- package/src/shared/index.ts +1 -1
- package/src/shared/{note.ts → issueReport.ts} +22 -0
- package/src/shared/loadCall.ts +66 -0
- package/src/shared/muxFrames.ts +1 -1
- package/src/shared/reactive/Keyed.ts +24 -16
- package/src/shared/reactive/Reactive.ts +37 -991
- package/src/shared/reactive/argsContract.ts +131 -4
- package/src/shared/reactive/byOf.ts +2 -5
- package/src/shared/reactive/callLoad.ts +8 -14
- package/src/shared/reactive/cellShape.ts +734 -0
- package/src/shared/reactive/index.ts +12 -23
- package/src/shared/reactive/pairReactive.ts +5 -3
- package/src/shared/reactive/patchLog.ts +79 -0
- package/src/shared/reactive/patchMarks.ts +34 -0
- package/src/shared/reactive/producer.ts +121 -0
- package/src/shared/reactive/recordWrites.ts +11 -14
- package/src/shared/reactive/scoped.ts +3 -2
- package/src/shared/reactive/selections.ts +209 -0
- package/src/shared/reactive/share.ts +4 -3
- package/src/shared/reactive/watch.ts +3 -4
- package/src/shared/rpc.ts +20 -24
- package/src/shared/validateJson.ts +1 -2
- package/src/shared/writePath.ts +29 -0
- 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 +106 -313
- 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 +23 -11
- package/src/ui/outcomeOf.ts +120 -0
- 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 +28 -13
- package/src/ui/wire.ts +9 -10
- 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/LOAD_OFFLINE.ts +0 -15
- package/src/shared/LOAD_SIGNAL.ts +0 -10
- package/src/shared/issuesOf.ts +0 -18
- package/src/shared/reactive/ENTRY_LIVES.ts +0 -7
- 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
|
@@ -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/appKit.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// THE OBJECTIVE-C RUNTIME, over `bun:ffi` — what the desktop
|
|
1
|
+
// THE OBJECTIVE-C RUNTIME, over `bun:ffi` — what the desktop window (`openDesktopWindow`) drives AppKit
|
|
2
2
|
// and WebKit with. Bun's own `WebView` is headless, so a window and a menu bar are this module's.
|
|
3
3
|
//
|
|
4
4
|
// NO STRUCT IS PASSED BY VALUE. A `CGRect` is four doubles in registers on arm64 and a block on the
|
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[]
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// WHAT A CALL'S ARGS ARE BEFORE ITS HANDLER SEES THEM, on a server: the schema a handler's args are
|
|
2
|
+
// read by, out of the sources it has, and the args a call off the wire is answered with — over http
|
|
3
|
+
// (16.2) and a `GET` followed over the socket mux (18.3). Each lane reads the args off its own
|
|
4
|
+
// transport and answers a refusal in its own form; what every way of calling a handler shares, in
|
|
5
|
+
// process too, is `acceptedArgs` (`#shared/reactive/argsContract.ts`), which this ends in.
|
|
6
|
+
//
|
|
7
|
+
// 19.7 — A URL CARRIES TEXT, so `?id=42` is `'42'` until the schema says the member is a number.
|
|
8
|
+
// Coercion is the pipeline's and not the schema's (19.6): the schema is handed what this made of the
|
|
9
|
+
// text, and refuses what would not coerce. A schema with no document has declared no types, and its
|
|
10
|
+
// args stay the text they arrived as.
|
|
11
|
+
|
|
12
|
+
import { DERIVED_ARGS } from '#shared/DERIVED_ARGS.ts'
|
|
13
|
+
import { isRecord } from '#shared/isRecord.ts'
|
|
14
|
+
import type { JsonValue } from '#shared/JsonValue.ts'
|
|
15
|
+
import { numberFromText } from '#shared/numberFromText.ts'
|
|
16
|
+
import { bareName } from '#shared/queryText.ts'
|
|
17
|
+
import {
|
|
18
|
+
acceptedArgs,
|
|
19
|
+
type Gated,
|
|
20
|
+
gateOf,
|
|
21
|
+
type HeldToAnnotation,
|
|
22
|
+
} from '#shared/reactive/argsContract.ts'
|
|
23
|
+
import { refuse } from '#shared/refuse.ts'
|
|
24
|
+
import { validateJson } from '#shared/validateJson.ts'
|
|
25
|
+
|
|
26
|
+
import type { Mounted } from './mountHandlers.ts'
|
|
27
|
+
|
|
28
|
+
export type Document = {
|
|
29
|
+
type?: string | string[]
|
|
30
|
+
properties?: Record<string, Document>
|
|
31
|
+
items?: Document
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Each handler's document, read once: 19.7, 20.11 — a third-party schema converts itself on every
|
|
35
|
+
// probe. 19.9 — the one its annotation derives is filed here by `derivedArgs`.
|
|
36
|
+
const DOCUMENTS = new WeakMap<object, Document | undefined>()
|
|
37
|
+
|
|
38
|
+
const NO_PARAMETERS: Record<string, string | string[]> = {}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The document a handler's args are read by: the one its `args` schema declares, or else the one the
|
|
42
|
+
* build derived off its annotation (`derivedArgs`), or `undefined`.
|
|
43
|
+
*/
|
|
44
|
+
export function argsDocumentOf(handler: Gated<unknown>): Document | undefined {
|
|
45
|
+
if (DOCUMENTS.has(handler)) return DOCUMENTS.get(handler)
|
|
46
|
+
const document = declaredDocument(handler)
|
|
47
|
+
DOCUMENTS.set(handler, document)
|
|
48
|
+
return document
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function declaredDocument(handler: Gated<unknown>): Document | undefined {
|
|
52
|
+
const gate = gateOf(handler) as
|
|
53
|
+
{ '~standard'?: { jsonSchema?: { input?: () => unknown } } } | undefined
|
|
54
|
+
const document = gate?.['~standard']?.jsonSchema?.input?.()
|
|
55
|
+
return document !== null && typeof document === 'object'
|
|
56
|
+
? (document as Document)
|
|
57
|
+
: undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Holds `handler`'s args to `schema`, the one the build derived off its annotation, for every call
|
|
62
|
+
* of it from now on: in process and over the wire (15.8, 19.9).
|
|
63
|
+
*
|
|
64
|
+
* What the module a handler is loaded through calls once its handler is built — a handler file's
|
|
65
|
+
* face and a `<script server>`'s module — so the schema is there wherever the handler can be
|
|
66
|
+
* called, whether or not an app mounted it. Not where its `args` gate publishes a document of its
|
|
67
|
+
* own, which holds the args itself (19.7). Args that did not come are held as an empty record: a
|
|
68
|
+
* schema is only derived where the handler's args are a required record, so a required member
|
|
69
|
+
* missing is refused, not read off nothing.
|
|
70
|
+
*/
|
|
71
|
+
export function derivedArgs(
|
|
72
|
+
handler: HeldToAnnotation & Gated<unknown>,
|
|
73
|
+
schema: JsonValue,
|
|
74
|
+
): void {
|
|
75
|
+
// Held already, by the module it loaded through, to the schema its row names as well.
|
|
76
|
+
if (handler[DERIVED_ARGS] !== undefined) return
|
|
77
|
+
const declared = declaredDocument(handler)
|
|
78
|
+
DOCUMENTS.set(handler, declared ?? (schema as Document))
|
|
79
|
+
if (declared === undefined)
|
|
80
|
+
handler[DERIVED_ARGS] = (args) => validateJson(schema, args ?? {})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Name and value pairs as args: a name given more than once is a list, one given once its text.
|
|
85
|
+
*
|
|
86
|
+
* 16.18 — a query's and a form's members, and 38.61 — `abide call`'s `name=value` words, alike.
|
|
87
|
+
*/
|
|
88
|
+
export function collapsed(
|
|
89
|
+
entries: URLSearchParams | FormData,
|
|
90
|
+
): Record<string, string | string[]> {
|
|
91
|
+
const args: Record<string, string | string[]> = {}
|
|
92
|
+
for (const name of new Set(entries.keys())) {
|
|
93
|
+
const values = entries.getAll(name) as string[]
|
|
94
|
+
args[name] = values.length === 1 ? (values[0] as string) : values
|
|
95
|
+
}
|
|
96
|
+
return args
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The args a call over the wire is answered with, out of the args it sent: the parameters its route
|
|
101
|
+
* matched laid in, the text a URL or a form carried coerced where `query` says the args are text,
|
|
102
|
+
* then `acceptedArgs`. Throws the refusal where they are not accepted.
|
|
103
|
+
*
|
|
104
|
+
* `query` is the raw `?…` the args were read from — `''` for a form, `undefined` for JSON.
|
|
105
|
+
* 16.65 — the path's parameters, whatever the method. 16.66 — one the query or the body names as well
|
|
106
|
+
* is refused rather than one of the two chosen. A JSON body's members are typed already, so the
|
|
107
|
+
* path's text is coerced to the declared types before it joins them (19.7); text args are coerced
|
|
108
|
+
* with the rest of the text.
|
|
109
|
+
*/
|
|
110
|
+
export function argsFor(
|
|
111
|
+
handler: Mounted['handler'],
|
|
112
|
+
sent: unknown,
|
|
113
|
+
query: string | undefined,
|
|
114
|
+
parameters: Record<string, string | string[]> = NO_PARAMETERS,
|
|
115
|
+
): unknown {
|
|
116
|
+
const document = argsDocumentOf(handler)
|
|
117
|
+
let args = sent
|
|
118
|
+
let none = true
|
|
119
|
+
for (const name in parameters) {
|
|
120
|
+
none = false
|
|
121
|
+
if (isRecord(sent) && name in sent)
|
|
122
|
+
throw refuse(
|
|
123
|
+
400,
|
|
124
|
+
`The parameter '${name}' is in the path and in the request as well.`,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
if (!none) {
|
|
128
|
+
const fromPath =
|
|
129
|
+
query === undefined
|
|
130
|
+
? (coerceArgs(parameters, document, '') as Record<
|
|
131
|
+
string,
|
|
132
|
+
unknown
|
|
133
|
+
>)
|
|
134
|
+
: parameters
|
|
135
|
+
args = isRecord(sent) ? { ...sent, ...fromPath } : { ...fromPath }
|
|
136
|
+
}
|
|
137
|
+
if (query !== undefined)
|
|
138
|
+
args = coerceArgs(
|
|
139
|
+
args as Record<string, string | string[]> | undefined,
|
|
140
|
+
document,
|
|
141
|
+
query,
|
|
142
|
+
)
|
|
143
|
+
return acceptedArgs(handler, args)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The text args, each member coerced to the type `document` declares.
|
|
148
|
+
*
|
|
149
|
+
* `URLSearchParams` reads `rpcRequest`'s empty list, a bare name, as `''` — the same as `tags=`, the
|
|
150
|
+
* list `['']` — so a declared array tells the two apart by `query`. A text member keeps the `''`.
|
|
151
|
+
*/
|
|
152
|
+
function coerceArgs(
|
|
153
|
+
args: Record<string, string | string[]> | undefined,
|
|
154
|
+
document: Document | undefined,
|
|
155
|
+
query: string,
|
|
156
|
+
): unknown {
|
|
157
|
+
if (args === undefined || document?.properties === undefined) return args
|
|
158
|
+
const coerced: Record<string, unknown> = {}
|
|
159
|
+
for (const [name, text] of Object.entries(args)) {
|
|
160
|
+
const member = document.properties[name]
|
|
161
|
+
if (member === undefined) coerced[name] = text
|
|
162
|
+
else if (
|
|
163
|
+
text === '' &&
|
|
164
|
+
typesOf(member).includes('array') &&
|
|
165
|
+
`&${query.slice(1)}&`.includes(`&${bareName(name)}&`)
|
|
166
|
+
)
|
|
167
|
+
coerced[name] = []
|
|
168
|
+
else coerced[name] = coerceMember(text, member)
|
|
169
|
+
}
|
|
170
|
+
return coerced
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function typesOf(member: Document): string[] {
|
|
174
|
+
if (member.type === undefined) return []
|
|
175
|
+
return Array.isArray(member.type) ? member.type : [member.type]
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function coerceMember(text: string | string[], member: Document): unknown {
|
|
179
|
+
const types = typesOf(member)
|
|
180
|
+
// A list is its member repeated, and one given once still answers a declared array.
|
|
181
|
+
if (types.includes('array')) {
|
|
182
|
+
const items = Array.isArray(text) ? text : [text]
|
|
183
|
+
const item = member.items
|
|
184
|
+
return item === undefined
|
|
185
|
+
? items
|
|
186
|
+
: items.map((each) => coerceScalar(each, item))
|
|
187
|
+
}
|
|
188
|
+
if (Array.isArray(text)) return text
|
|
189
|
+
return coerceScalar(text, member)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The text as the first declared type it reads as, in the order the schema lists them.
|
|
194
|
+
*
|
|
195
|
+
* Text that reads as none stays text, for the schema to refuse.
|
|
196
|
+
*/
|
|
197
|
+
function coerceScalar(text: string, member: Document): unknown {
|
|
198
|
+
for (const type of typesOf(member)) {
|
|
199
|
+
if (type === 'string') return text
|
|
200
|
+
if (type === 'number' || type === 'integer') {
|
|
201
|
+
const number = numberFromText(text)
|
|
202
|
+
if (number !== undefined) return number
|
|
203
|
+
}
|
|
204
|
+
if (type === 'boolean' && (text === 'true' || text === 'false'))
|
|
205
|
+
return text === 'true'
|
|
206
|
+
if (type === 'null' && text === 'null') return null
|
|
207
|
+
}
|
|
208
|
+
return text
|
|
209
|
+
}
|