@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
@@ -1,18 +1,23 @@
1
- import { existsSync, type FSWatcher, watch } from 'node:fs' // bun exposes no file watcher or sync existence check of its own
1
+ import { existsSync } from 'node:fs' // bun exposes no sync existence check of its own
2
2
  import { dirname } from 'node:path' // bun exposes no path helpers of its own
3
3
 
4
4
  import { realOrAsGiven } from '#server/realOrAsGiven.ts'
5
5
 
6
6
  import { checkSource, lowerApp } from './checkApp.ts'
7
7
  import { citedClauses } from './citedClauses.ts'
8
- import { type AppChecker, openChecker } from './compileApp.ts'
8
+ import { type Diagnosis, formatDiagnosis } from './Diagnostics.ts'
9
9
  import { DOCS_SITE } from './DOCS_SITE.ts'
10
10
  import { formatAbide } from './formatAbide.ts'
11
- import { formatDiagnosis } from './formatDiagnosis.ts'
12
11
  import { isNamedAlone } from './isNamedAlone.ts'
12
+ import { KeptChecker } from './KeptChecker.ts'
13
13
  import { lineOf, offsetOf } from './lineOf.ts'
14
+ import {
15
+ completionAt,
16
+ type Inspection,
17
+ type LoweredFile,
18
+ type Place,
19
+ } from './LoweredFile.ts'
14
20
  import { nearest } from './nearest.ts'
15
- import type { Diagnosis, Inspection, Place } from './TypeSession.ts'
16
21
 
17
22
  // 38.11 — `abide lsp`: THE `.abide` LANGUAGE SERVER, OVER STDIO. It is `abide check` (38.3) asked
18
23
  // about one open document at a time, so the squiggle an editor draws and the line CI fails on are
@@ -21,8 +26,8 @@ import type { Diagnosis, Inspection, Place } from './TypeSession.ts'
21
26
  // WHAT IT SPEAKS is the part of the protocol a diagnostic needs: the handshake, a document opened,
22
27
  // changed and closed, and `publishDiagnostics` back. The document is synced whole (`Full`), so what
23
28
  // is checked is exactly the editor's text, saved or not. A hover, a jump to a definition, a
24
- // completion, the references to a name and a rename of it are asked of the program that check
25
- // registered (`TypeSession`), so they answer about the same text.
29
+ // completion, the references to a name and a rename of it are asked of the lowering that check
30
+ // registered (`LoweredFile`), so they answer about the same text.
26
31
  //
27
32
  // THE APP IS THE DOCUMENT'S. An editor starts this at the root of whatever it has open, which in a
28
33
  // monorepo is not an app at all, so each document is checked against the root of the NEAREST
@@ -64,8 +69,7 @@ export async function serveLanguage(
64
69
  ): Promise<void> {
65
70
  const documents = new Map<string, Document>()
66
71
  const timers = new Map<string, ReturnType<typeof setTimeout>>()
67
- const checkers = new Map<string, AppChecker>()
68
- const watchers = new Map<string, FSWatcher>()
72
+ const checkers = new Map<string, KeptChecker>()
69
73
  // Each app's real root, to the root the editor reached it by.
70
74
  const links = new Map<string, string>()
71
75
  let queue: Promise<void> = Promise.resolve()
@@ -133,37 +137,19 @@ export async function serveLanguage(
133
137
  const held = (path: string): string | undefined =>
134
138
  documents.get(Bun.pathToFileURL(spelled(path)).href)?.text
135
139
 
136
- // An app's checker, opened on first use and kept for the session — and TOLD WHAT MOVED ON DISK,
137
- // or it answers every check from the text it read when it opened: an export added to a `lib`
138
- // module the page imports is "no exported member" until the editor restarts this. A project whose
139
- // tsconfig or package.json moved is opened again.
140
- const checkerOf = async (app: string): Promise<AppChecker> => {
140
+ // An app's checker, kept for the session — and TOLD WHAT MOVED ON DISK, or it answers every
141
+ // check from the text it read when it opened: an export added to a `lib` module the page
142
+ // imports is "no exported member" until the editor restarts this (`KeptChecker`).
143
+ const checkerOf = (app: string): KeptChecker => {
141
144
  let checker = checkers.get(app)
142
- if (checker !== undefined && !checker.current()) {
143
- checkers.delete(app)
144
- await checker.close().catch(() => {})
145
- checker = undefined
146
- }
147
145
  if (checker === undefined) {
148
- checker = await openChecker(app)
146
+ checker = new KeptChecker(app, () => {
147
+ // What an open page says may be decided by the file that moved.
148
+ for (const uri of documents.keys())
149
+ if (placed(uri)?.app === app) check(uri)
150
+ })
149
151
  checkers.set(app, checker)
150
152
  }
151
- if (!watchers.has(app))
152
- watchers.set(
153
- app,
154
- watch(app, { recursive: true }, (_event, filename) => {
155
- checkers
156
- .get(app)
157
- ?.saw(
158
- filename === null
159
- ? undefined
160
- : `${app}/${filename}`,
161
- )
162
- // What an open page says may be decided by the file that moved.
163
- for (const uri of documents.keys())
164
- if (placed(uri)?.app === app) check(uri)
165
- }),
166
- )
167
153
  return checker
168
154
  }
169
155
 
@@ -172,11 +158,9 @@ export async function serveLanguage(
172
158
  // keeps one for the session. Queued behind the checks, so none is in flight when it closes.
173
159
  const closeIfUnopened = async (app: string): Promise<void> => {
174
160
  for (const uri of documents.keys()) if (placed(uri)?.app === app) return
175
- watchers.get(app)?.close()
176
- watchers.delete(app)
177
161
  const checker = checkers.get(app)
178
162
  checkers.delete(app)
179
- await checker?.close().catch(() => {})
163
+ await checker?.close()
180
164
  }
181
165
 
182
166
  const run = async (uri: string): Promise<void> => {
@@ -189,17 +173,11 @@ export async function serveLanguage(
189
173
  const { app, path } = at
190
174
  let found: Diagnosis[]
191
175
  try {
192
- found = await checkSource(
193
- await checkerOf(app),
194
- path,
195
- document.text,
196
- held,
176
+ found = await checkerOf(app).run((checker) =>
177
+ checkSource(checker, path, document.text, held),
197
178
  )
198
179
  } catch (thrown) {
199
180
  // The checker's own failure, not the page's: the next check opens another.
200
- const failed = checkers.get(app)
201
- checkers.delete(app)
202
- await failed?.close().catch(() => {})
203
181
  found = [
204
182
  {
205
183
  path,
@@ -230,29 +208,30 @@ export async function serveLanguage(
230
208
  )
231
209
  }
232
210
 
233
- // A question about a document is about the text the editor shows, so a check still settling
234
- // runs now rather than after the answer; and it is answered in the queue, after that check.
235
211
  const inspect = (params: unknown): Promise<Inspection | undefined> =>
236
- ask(params, (checker, path, text, offset) =>
237
- checker.session.inspect(path, text, offset),
238
- )
212
+ ask(params, (lowered, text, offset) => lowered?.inspect(text, offset))
239
213
 
240
214
  // 38.116 — asked after every page of the app is lowered, which is what a page this one never
241
215
  // imports needs to be among the references.
242
216
  const references = (params: unknown) =>
243
- ask(params, async (checker, path, text, offset) => {
244
- await lowerApp(checker, held)
245
- return checker.session.references(path, text, offset)
246
- })
217
+ ask(
218
+ params,
219
+ (lowered, text, offset) => lowered?.references(text, offset),
220
+ true,
221
+ )
247
222
 
223
+ // A QUESTION ABOUT A DOCUMENT IS ASKED OF THE LOWERING ITS CHECK REGISTERED (`LoweredFile`), of
224
+ // the text the editor shows: a check still settling runs now rather than after the answer, and
225
+ // the question is answered in the queue, after that check. A lowering of another text answers
226
+ // nothing at an offset into this one, save the props a tag being typed offers (38.117).
248
227
  const ask = <Answer>(
249
228
  params: unknown,
250
229
  question: (
251
- checker: AppChecker,
252
- path: string,
230
+ lowered: LoweredFile | undefined,
253
231
  text: string,
254
232
  offset: number,
255
- ) => Promise<Answer | undefined>,
233
+ ) => Promise<Answer | undefined> | undefined,
234
+ everyPage = false,
256
235
  ): Promise<Answer | undefined> => {
257
236
  const { textDocument, position } = params as {
258
237
  textDocument: { uri: string }
@@ -268,9 +247,10 @@ export async function serveLanguage(
268
247
  const document = documents.get(uri)
269
248
  const at = placed(uri)
270
249
  if (document === undefined || at === undefined) return undefined
250
+ const checker = await checkerOf(at.app).live()
251
+ if (everyPage) await lowerApp(checker, held)
271
252
  return question(
272
- await checkerOf(at.app),
273
- at.path,
253
+ checker.session.lowered(at.path),
274
254
  document.text,
275
255
  offsetOf(document.text, position),
276
256
  )
@@ -363,8 +343,8 @@ export async function serveLanguage(
363
343
  const trigger = (
364
344
  params?.context as { triggerCharacter?: string } | undefined
365
345
  )?.triggerCharacter
366
- const found = await ask(params, (checker, path, text, offset) =>
367
- checker.session.complete(path, text, offset, trigger),
346
+ const found = await ask(params, (lowered, text, offset) =>
347
+ completionAt(lowered, text, offset, trigger),
368
348
  )
369
349
  const items: object[] = []
370
350
  for (const entry of found?.entries ?? [])
@@ -544,24 +524,16 @@ export async function serveLanguage(
544
524
  ) as Message
545
525
  buffer = buffer.slice(start + length)
546
526
  if (!(await handle(message))) {
547
- await closeAll(checkers, watchers)
527
+ await closeAll(checkers)
548
528
  return
549
529
  }
550
530
  }
551
531
  }
552
- await closeAll(checkers, watchers)
532
+ await closeAll(checkers)
553
533
  }
554
534
 
555
- async function closeAll(
556
- checkers: Map<string, AppChecker>,
557
- watchers: Map<string, FSWatcher>,
558
- ): Promise<void> {
559
- for (const watcher of watchers.values()) watcher.close()
560
- await Promise.all(
561
- [...checkers.values()].map((checker) =>
562
- checker.close().catch(() => {}),
563
- ),
564
- )
535
+ async function closeAll(checkers: Map<string, KeptChecker>): Promise<void> {
536
+ await Promise.all([...checkers.values()].map((checker) => checker.close()))
565
537
  }
566
538
 
567
539
  /**
@@ -9,7 +9,7 @@ import type {
9
9
  } from 'typescript/unstable/async'
10
10
 
11
11
  import { classifyRead, FUNCTIONS } from './classifyRead.ts'
12
- import type { Finding } from './Finding.ts'
12
+ import type { Finding } from './Diagnostics.ts'
13
13
  import type { Span } from './projectShadow.ts'
14
14
 
15
15
  // 34.18 — A PLAIN `let` IN A `<script module>` THAT A `<script>` WRITES AS IT SETS UP. The block is
@@ -1,6 +1,6 @@
1
1
  import { SyntaxKind } from 'typescript/unstable/ast'
2
2
 
3
- import { COMPONENT_QUERY, TEXT_QUERY } from '#server/MODULE_QUERIES.ts'
3
+ import { COMPONENT_QUERY, TEXT_SUFFIX } from '#server/MODULE_QUERIES.ts'
4
4
 
5
5
  import { isContextualKeyword, type Token, tokenize } from './tokenize.ts'
6
6
 
@@ -473,7 +473,7 @@ export function importedNames(
473
473
  */
474
474
  function facedSpecifier(specifier: string, lane: Lane): string | undefined {
475
475
  if (specifier.endsWith(SOURCE_SUFFIX))
476
- return `${specifier.slice(0, -SOURCE_SUFFIX.length)}${TEXT_QUERY}`
476
+ return `${specifier.slice(0, -SOURCE_SUFFIX.length)}${TEXT_SUFFIX}`
477
477
  if (lane === 'component' && specifier.endsWith('.abide'))
478
478
  return `${specifier}${COMPONENT_QUERY}`
479
479
  return undefined
@@ -1,8 +1,8 @@
1
1
  import type { CheckerAnswer } from './CheckerAnswer.ts'
2
2
  import type { Read } from './classifyRead.ts'
3
3
  import { isComponentTag } from './classifyTemplate.ts'
4
+ import { type Diagnosis, diagnosisAt } from './Diagnostics.ts'
4
5
  import { isNamedAlone } from './isNamedAlone.ts'
5
- import { diagnosisAt } from './lineOf.ts'
6
6
  import { Lowering } from './Lowering.ts'
7
7
  import type { AbideFile } from './parseAbide.ts'
8
8
  import {
@@ -12,7 +12,6 @@ import {
12
12
  type TemplateNode,
13
13
  visitBodies,
14
14
  } from './TemplateNode.ts'
15
- import type { Diagnosis } from './TypeSession.ts'
16
15
 
17
16
  // 32.14 — A `{#for}` WITH NO KEY OVER A BODY THAT HOLDS STATE. Unkeyed, a row is matched to the
18
17
  // item at its index, so a reorder hands one row's state — a component's own values, a field's text,
@@ -16,7 +16,7 @@ import {
16
16
  } from 'typescript/unstable/ast/is'
17
17
 
18
18
  import { FUNCTIONS, TRANSPARENT } from './classifyRead.ts'
19
- import type { Finding } from './Finding.ts'
19
+ import type { Finding } from './Diagnostics.ts'
20
20
  import type { PositionKind, PositionTable } from './PositionTable.ts'
21
21
 
22
22
  // 14.14, 14.15, 14.16 — WHERE A READ STANDS IN A BODY THAT TRACKS, and the three ways it can stand
@@ -1,13 +1,9 @@
1
- // WHERE THE BROWSER BUNDLE LANDS, named once for the commands that have to agree about it:
2
- // `abide build` writes it, `abide start` answers it, and `abide dev` builds it into memory
3
- // (38.16, 38.20). A leaf, with no imports of its own, for the reason every constant crossing a
4
- // seam is one — the build is in `bin/` and the server is in `src/server/`, and a string spelled out
5
- // in both is a build that silently serves nothing. The addresses it is answered at are
1
+ // WHAT THE BROWSER BUNDLE IS NAMED, once for the seams that have to agree about it: the compiler
2
+ // writes the entry, `abide build` precompresses every file, and the server answers both (38.5,
3
+ // 38.20). A leaf, with no imports of its own, for the reason every constant crossing a seam is one.
4
+ // Where the bundle lands in a build is `appBuild.ts`'s; the addresses it is answered at are
6
5
  // `#shared/ADDRESSES.ts`.
7
6
 
8
- /** The directory the bundle lands in, below the build's own output directory. */
9
- export const CLIENT_DIRECTORY = 'client'
10
-
11
7
  /**
12
8
  * The module that mounts the app — the one bundle file whose name the shell writes out.
13
9
  *
@@ -19,21 +15,6 @@ export const CLIENT_DIRECTORY = 'client'
19
15
  */
20
16
  export const CLIENT_ENTRY = 'entry'
21
17
 
22
- /**
23
- * The manifest file `abide build` writes beside the client directory.
24
- *
25
- * 38.5 — the manifest `manifestOf` reads off the bundle, below the build's own output directory
26
- * and BESIDE the client directory rather than in it: everything in there is answered under
27
- * `CLIENT_ADDRESS` (38.20), and the manifest is the server's, not the browser's.
28
- */
29
- export const MANIFEST = 'manifest.json'
30
-
31
- /**
32
- * The compilation `abide build` keeps beside the client directory: every `.abide` compiled, and
33
- * the build's own tables (`Compilation`), which `abide start` serves without the compiler.
34
- */
35
- export const COMPILATION = 'compilation.json'
36
-
37
18
  /**
38
19
  * The encodings a response can be compressed in, each with its precompressed file's suffix.
39
20
  *
@@ -43,12 +24,3 @@ export const COMPILATION = 'compilation.json'
43
24
  * a build that precompresses into files nothing ever serves.
44
25
  */
45
26
  export const ENCODING_SUFFIX = { br: '.br', gzip: '.gz' } as const
46
-
47
- /**
48
- * Where `abide build` copies every `public` directory, each file at its address, below its own
49
- * output directory. The source's own name for one is `#shared/RESERVED_DIRECTORIES.ts`'s.
50
- *
51
- * 38.18 — a directory rather than the source tree: `abide start` boots what the build produced and
52
- * reaches for nothing an app might have moved since.
53
- */
54
- export const PUBLIC_DIRECTORY = 'public'
@@ -0,0 +1,213 @@
1
+ // 38.71-38.75 — WHAT THE DESKTOP WINDOW SHOWS, apart from the window: the app booted here, one
2
+ // connected to elsewhere, or neither — and what the launcher did last, kept across launches.
3
+
4
+ import { mkdir } from 'node:fs/promises' // bun exposes no directory creation of its own
5
+
6
+ import { isFailed } from '#shared/Failed.ts'
7
+
8
+ import { configuredMount } from './config.ts'
9
+ import type { App } from './createApp.ts'
10
+ import { reportUnexpected } from './hooks.ts'
11
+ import type { Launcher } from './launcherOptions.ts'
12
+ import type { Launch } from './launcherPage.ts'
13
+ import { LISTENING } from './requestScope.ts'
14
+
15
+ /** What the launcher did last, and what its forms held, kept across launches. 38.73. */
16
+ type Remembered = {
17
+ last: { start: Record<string, string> } | { connect: string } | null
18
+ values: Record<string, string>
19
+ url: string
20
+ port: number
21
+ }
22
+
23
+ type Failure = NonNullable<Launch['failure']>
24
+
25
+ const LOOPBACK = '127.0.0.1'
26
+ const CONNECT_TIMEOUT_MILLISECONDS = 5000
27
+
28
+ /**
29
+ * The launcher's state machine: start an app here, connect to one elsewhere, disconnect, and
30
+ * remember which in `desktop.json` under `directory`.
31
+ *
32
+ * `boot` boots the app a start serves — `bootEntered` in the executable, anything an `App` in a test.
33
+ */
34
+ export class DesktopSession {
35
+ private readonly stateFile: string
36
+ private readonly directory: string
37
+ private readonly boot: () => Promise<App>
38
+ readonly remembered: Remembered
39
+ /** What the window shows: the app served here, one elsewhere, or neither. */
40
+ private current: { url: string; app: App | undefined } | undefined
41
+ /** The last start or connect that was refused, until the page shows it. */
42
+ private failure: Failure | undefined
43
+
44
+ private constructor(
45
+ directory: string,
46
+ boot: () => Promise<App>,
47
+ remembered: Remembered,
48
+ ) {
49
+ this.directory = directory
50
+ this.stateFile = `${directory}/desktop.json`
51
+ this.boot = boot
52
+ this.remembered = remembered
53
+ this.current = undefined
54
+ this.failure = undefined
55
+ }
56
+
57
+ /** The session `directory`'s `desktop.json` remembers, or a fresh one where there is none. */
58
+ static async open(
59
+ directory: string,
60
+ boot: () => Promise<App>,
61
+ ): Promise<DesktopSession> {
62
+ const remembered: Remembered = {
63
+ last: null,
64
+ values: {},
65
+ url: '',
66
+ port: 0,
67
+ ...(await Bun.file(`${directory}/desktop.json`)
68
+ .json()
69
+ .catch(() => ({}))),
70
+ }
71
+ return new DesktopSession(directory, boot, remembered)
72
+ }
73
+
74
+ /** The address the window shows, `undefined` where it shows the launcher. */
75
+ get shown(): string | undefined {
76
+ return this.current?.url
77
+ }
78
+
79
+ /** The refusal the page has yet to show, handed over once. */
80
+ takeFailure(): Failure | undefined {
81
+ const shown = this.failure
82
+ this.failure = undefined
83
+ return shown
84
+ }
85
+
86
+ // 38.72 — the form's values are the environment the app is booted under, so 27.8 coerces them
87
+ // and 27.7's schema refuses them as it would any other.
88
+ async start(values: Record<string, string>): Promise<Failure | undefined> {
89
+ await this.disconnect()
90
+ this.remembered.values = values
91
+ for (const [name, value] of Object.entries(values))
92
+ if (value === '') delete process.env[name]
93
+ else process.env[name] = value
94
+ let app: App
95
+ try {
96
+ app = await this.boot()
97
+ } catch (thrown) {
98
+ return this.refuse(refused('start', thrown))
99
+ }
100
+ try {
101
+ await app
102
+ // 38.75.
103
+ .listen({ hostname: LOOPBACK, port: this.remembered.port })
104
+ // The port the last launch served on, so the app's origin — and its storage with
105
+ // it — is the one it had; any free one where that is taken.
106
+ .catch(() => app.listen({ hostname: LOOPBACK, port: 0 }))
107
+ } catch (thrown) {
108
+ await app.stop().catch(reportUnexpected)
109
+ return this.refuse(refused('start', thrown))
110
+ }
111
+ const server = LISTENING.server as Bun.Server<unknown>
112
+ this.remembered.port = server.port ?? 0
113
+ this.remembered.last = { start: values }
114
+ this.current = {
115
+ url: `${server.url.origin}${configuredMount()}/`,
116
+ app,
117
+ }
118
+ await this.remember()
119
+ return undefined
120
+ }
121
+
122
+ async connect(given: string): Promise<Failure | undefined> {
123
+ const text = given.trim()
124
+ this.remembered.url = text
125
+ const url = URL.parse(
126
+ /^[a-z][a-z0-9+.-]*:/i.test(text) ? text : `https://${text}`,
127
+ )
128
+ if (
129
+ url === null ||
130
+ (url.protocol !== 'http:' && url.protocol !== 'https:')
131
+ )
132
+ return this.refuse({
133
+ form: 'connect',
134
+ message: `${text} is not an address.`,
135
+ issues: {},
136
+ })
137
+ try {
138
+ await fetch(url, {
139
+ method: 'HEAD',
140
+ redirect: 'manual',
141
+ signal: AbortSignal.timeout(CONNECT_TIMEOUT_MILLISECONDS),
142
+ })
143
+ } catch {
144
+ return this.refuse({
145
+ form: 'connect',
146
+ message: `Nothing answered at ${url.origin}.`,
147
+ issues: {},
148
+ })
149
+ }
150
+ await this.disconnect()
151
+ this.remembered.last = { connect: url.href }
152
+ this.current = { url: url.href, app: undefined }
153
+ await this.remember()
154
+ return undefined
155
+ }
156
+
157
+ /** Stops the app served here, if one is, and shows neither. */
158
+ async disconnect(): Promise<void> {
159
+ const leaving = this.current
160
+ this.current = undefined
161
+ if (leaving?.app !== undefined)
162
+ await leaving.app.stop().catch(reportUnexpected)
163
+ }
164
+
165
+ /** 38.74 — disconnects, and forgets what was done last so the next launch does not redo it. */
166
+ async forget(): Promise<void> {
167
+ this.remembered.last = null
168
+ await Promise.all([this.remember(), this.disconnect()])
169
+ }
170
+
171
+ /** 38.73 — WHAT IT DID LAST, done again, where the launcher still offers it. */
172
+ async resume(options: Launcher): Promise<void> {
173
+ const last = this.remembered.last
174
+ if (options.start && last !== null && 'start' in last)
175
+ await this.start(last.start)
176
+ else if (options.connect && last !== null && 'connect' in last)
177
+ await this.connect(last.connect)
178
+ }
179
+
180
+ private refuse(failure: Failure): Failure {
181
+ this.failure = failure
182
+ return failure
183
+ }
184
+
185
+ private async remember(): Promise<void> {
186
+ await mkdir(this.directory, { recursive: true })
187
+ await Bun.write(
188
+ this.stateFile,
189
+ JSON.stringify(this.remembered, null, 4),
190
+ )
191
+ }
192
+ }
193
+
194
+ /** A refusal as the form it came back to: 27.7's issues by field, or the error's message. */
195
+ function refused(form: 'start' | 'connect', thrown: unknown): Failure {
196
+ if (
197
+ isFailed(thrown) &&
198
+ thrown.data !== null &&
199
+ typeof thrown.data === 'object'
200
+ )
201
+ return {
202
+ form,
203
+ message: 'The configuration was refused.',
204
+ issues: Array.isArray(thrown.data)
205
+ ? { '': thrown.data as string[] }
206
+ : (thrown.data as Record<string, string[]>),
207
+ }
208
+ return {
209
+ form,
210
+ message: thrown instanceof Error ? thrown.message : String(thrown),
211
+ issues: {},
212
+ }
213
+ }