@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.
Files changed (150) hide show
  1. package/ABIDE.md +8 -5
  2. package/CHANGELOG.md +58 -0
  3. package/bin/abide.ts +36 -65
  4. package/docs/REGISTRY.md +45 -12
  5. package/docs/RULEBOOK.md +24 -14
  6. package/docs/abide.md +999 -459
  7. package/package.json +1 -1
  8. package/src/compiler/ARRAY_MUTATORS.ts +20 -0
  9. package/src/compiler/Diagnostics.ts +198 -0
  10. package/src/compiler/KeptChecker.ts +97 -0
  11. package/src/compiler/LoweredFile.ts +609 -0
  12. package/src/compiler/Lowering.ts +18 -1
  13. package/src/compiler/PositionedRefusal.ts +1 -1
  14. package/src/compiler/SHARED_NAMES.ts +2 -0
  15. package/src/compiler/ShadowHost.ts +670 -0
  16. package/src/compiler/ShadowMap.ts +1 -1
  17. package/src/compiler/TypeSession.ts +76 -1743
  18. package/src/compiler/appLayout.ts +28 -20
  19. package/src/compiler/appLints.ts +109 -0
  20. package/src/compiler/argsDefaultsIn.ts +1 -1
  21. package/src/compiler/checkApp.ts +64 -68
  22. package/src/compiler/checkedShadow.ts +2 -2
  23. package/src/compiler/classifyRead.ts +16 -1
  24. package/src/compiler/compileApp.ts +102 -71
  25. package/src/compiler/declaredExports.ts +46 -21
  26. package/src/compiler/diagnoseSources.ts +232 -0
  27. package/src/compiler/emitClient.ts +1 -1
  28. package/src/compiler/formatAbide.ts +7 -8
  29. package/src/compiler/handlersUnder.ts +32 -63
  30. package/src/compiler/lineOf.ts +0 -19
  31. package/src/compiler/matchersOf.ts +3 -3
  32. package/src/compiler/mountsOf.ts +3 -3
  33. package/src/compiler/reactiveReads.ts +235 -0
  34. package/src/compiler/reactiveTypes.ts +13 -0
  35. package/src/compiler/readSources.ts +16 -18
  36. package/src/compiler/readsIn.ts +181 -82
  37. package/src/compiler/routesDeclaration.ts +46 -0
  38. package/src/compiler/scaffold.ts +7 -2
  39. package/src/compiler/serveLanguage.ts +46 -74
  40. package/src/compiler/sharedModuleWrites.ts +1 -1
  41. package/src/compiler/splitImports.ts +2 -2
  42. package/src/compiler/templateWarnings.ts +1 -2
  43. package/src/compiler/trackingWarnings.ts +1 -1
  44. package/src/server/CLIENT_BUILD.ts +4 -32
  45. package/src/server/DesktopSession.ts +213 -0
  46. package/src/server/Followers.ts +399 -0
  47. package/src/server/MODULE_QUERIES.ts +6 -2
  48. package/src/server/PageRender.ts +201 -0
  49. package/src/server/RESUME_MILLISECONDS.ts +2 -0
  50. package/src/server/abideModules.ts +7 -7
  51. package/src/server/answerFailure.ts +3 -2
  52. package/src/server/answerRequest.ts +16 -369
  53. package/src/server/answerRpc.ts +48 -302
  54. package/src/server/answerSocket.ts +41 -490
  55. package/src/server/appBuild.ts +377 -0
  56. package/src/server/appKit.ts +1 -1
  57. package/src/server/appPlugins.ts +15 -17
  58. package/src/server/appTree.ts +6 -5
  59. package/src/server/argsContract.ts +209 -0
  60. package/src/server/bridgeRelay.ts +153 -65
  61. package/src/server/claudeCode.ts +30 -127
  62. package/src/server/config.ts +6 -7
  63. package/src/server/createApp.ts +38 -66
  64. package/src/server/csp.ts +1 -1
  65. package/src/server/deferredBuilds.ts +3 -16
  66. package/src/server/devWorker.ts +11 -15
  67. package/src/server/faceOf.ts +1 -1
  68. package/src/server/generatedSurfaces.ts +2 -1
  69. package/src/server/handlerFileMount.ts +40 -0
  70. package/src/server/index.ts +3 -3
  71. package/src/server/installServer.ts +5 -9
  72. package/src/server/invokeHandler.ts +230 -0
  73. package/src/server/mountHandlers.ts +184 -0
  74. package/src/server/openDesktopWindow.ts +308 -0
  75. package/src/server/packageDirectory.ts +14 -0
  76. package/src/server/preload.ts +21 -2
  77. package/src/server/principal.ts +5 -4
  78. package/src/server/remote.ts +2 -2
  79. package/src/server/render.ts +31 -59
  80. package/src/server/requestScope.ts +5 -36
  81. package/src/server/runCompiled.ts +1 -1
  82. package/src/server/runDesktop.ts +20 -448
  83. package/src/server/seedBuffer.ts +12 -12
  84. package/src/server/serveDev.ts +24 -58
  85. package/src/server/servedApp.ts +390 -0
  86. package/src/server/shell.ts +1 -1
  87. package/src/server/signInChanged.ts +20 -0
  88. package/src/server/surfaceOf.ts +3 -2
  89. package/src/server/versionOf.ts +7 -16
  90. package/src/shared/RPC_SERVER.ts +27 -31
  91. package/src/shared/Schema.ts +1 -2
  92. package/src/shared/applyPatch.ts +1 -1
  93. package/src/shared/index.ts +1 -1
  94. package/src/shared/{note.ts → issueReport.ts} +22 -0
  95. package/src/shared/loadCall.ts +66 -0
  96. package/src/shared/muxFrames.ts +1 -1
  97. package/src/shared/reactive/Keyed.ts +24 -16
  98. package/src/shared/reactive/Reactive.ts +37 -991
  99. package/src/shared/reactive/argsContract.ts +131 -4
  100. package/src/shared/reactive/byOf.ts +2 -5
  101. package/src/shared/reactive/callLoad.ts +8 -14
  102. package/src/shared/reactive/cellShape.ts +734 -0
  103. package/src/shared/reactive/index.ts +12 -23
  104. package/src/shared/reactive/pairReactive.ts +5 -3
  105. package/src/shared/reactive/patchLog.ts +79 -0
  106. package/src/shared/reactive/patchMarks.ts +34 -0
  107. package/src/shared/reactive/producer.ts +121 -0
  108. package/src/shared/reactive/recordWrites.ts +11 -14
  109. package/src/shared/reactive/scoped.ts +3 -2
  110. package/src/shared/reactive/selections.ts +209 -0
  111. package/src/shared/reactive/share.ts +4 -3
  112. package/src/shared/reactive/watch.ts +3 -4
  113. package/src/shared/rpc.ts +20 -24
  114. package/src/shared/validateJson.ts +1 -2
  115. package/src/shared/writePath.ts +29 -0
  116. package/src/ui/ArmRegion.ts +10 -4
  117. package/src/ui/Level.ts +11 -20
  118. package/src/ui/LevelChain.ts +272 -0
  119. package/src/ui/Router.ts +106 -313
  120. package/src/ui/Scope.ts +17 -3
  121. package/src/ui/TryRegion.ts +22 -23
  122. package/src/ui/bindSpread.ts +2 -4
  123. package/src/ui/listenForRoutes.ts +3 -10
  124. package/src/ui/outboxQueue.ts +23 -11
  125. package/src/ui/outcomeOf.ts +120 -0
  126. package/src/ui/reconcile.ts +4 -6
  127. package/src/ui/routing.ts +2 -1
  128. package/src/ui/rows.ts +2 -2
  129. package/src/ui/setAttribute.ts +12 -3
  130. package/src/ui/socketWire.ts +28 -13
  131. package/src/ui/wire.ts +9 -10
  132. package/src/compiler/Finding.ts +0 -2
  133. package/src/compiler/Refusals.ts +0 -65
  134. package/src/compiler/formatDiagnosis.ts +0 -20
  135. package/src/server/appDirectories.ts +0 -9
  136. package/src/server/argsFor.ts +0 -39
  137. package/src/server/buildDirectory.ts +0 -10
  138. package/src/server/coerceArgs.ts +0 -109
  139. package/src/server/derivedArgs.ts +0 -26
  140. package/src/server/runBuild.ts +0 -24
  141. package/src/server/serveBuild.ts +0 -144
  142. package/src/server/testBuild.ts +0 -220
  143. package/src/shared/LOAD_OFFLINE.ts +0 -15
  144. package/src/shared/LOAD_SIGNAL.ts +0 -10
  145. package/src/shared/issuesOf.ts +0 -18
  146. package/src/shared/reactive/ENTRY_LIVES.ts +0 -7
  147. package/src/shared/reactive/FOLLOWED.ts +0 -8
  148. package/src/shared/reactive/PATCHES.ts +0 -11
  149. package/src/shared/reactive/PATCH_SINKS.ts +0 -26
  150. 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
+ }
@@ -1,4 +1,4 @@
1
- // THE OBJECTIVE-C RUNTIME, over `bun:ffi` — what the desktop launcher (`runDesktop`) drives AppKit
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
@@ -1,26 +1,24 @@
1
- import { appDirectories } from './appDirectories.ts'
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 — in
8
- * `appDirectories`, as `config` finds the app's `package.json`.
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
- for (const directory of appDirectories(root)) {
12
- const file = Bun.file(`${directory}/bunfig.toml`)
13
- if (!(await file.exists())) continue
14
- const parsed = Bun.TOML.parse(await file.text()) as {
15
- serve?: { static?: { plugins?: string[] } }
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
- return []
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
  }
@@ -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, existsSync, readdirSync } from 'node:fs' // bun has no directory listing or sync existence check of its own
15
+ import { type Dirent, readdirSync } from 'node:fs' // bun has no directory listing of its own
16
16
 
17
- import type { Refusals } from '#compiler/Refusals.ts'
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: Refusals): AppFiles {
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 = existsSync(`${root}/package.json`)
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: 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
+ }