@runeditrun/rer 0.1.0 → 0.2.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 (109) hide show
  1. package/README.md +115 -2
  2. package/cli/README.md +28 -3
  3. package/cli/contract.mjs +4 -4
  4. package/cli/dev-identity.mjs +2 -1
  5. package/cli/door-mode.mjs +134 -0
  6. package/cli/door.mjs +263 -0
  7. package/cli/hosted-output.mjs +15 -5
  8. package/cli/hydration/browser.mjs +91 -0
  9. package/cli/hydration/cases.mjs +106 -0
  10. package/cli/hydration/check.mjs +105 -0
  11. package/cli/hydration/probe.mjs +164 -0
  12. package/cli/hydration/scripts.mjs +39 -0
  13. package/cli/hydration/verdict.mjs +85 -0
  14. package/cli/inspect.mjs +56 -9
  15. package/cli/lifecycle.mjs +36 -12
  16. package/cli/local.mjs +9 -3
  17. package/cli/main.mjs +2 -0
  18. package/cli/managed-server.mjs +52 -18
  19. package/cli/package-calls.mjs +81 -0
  20. package/cli/package-mode.mjs +46 -0
  21. package/cli/parse.mjs +14 -4
  22. package/cli/pnpm-runtime.mjs +96 -0
  23. package/cli/portability-worker.mjs +2 -2
  24. package/cli/runtime-front-door.mjs +4 -2
  25. package/cli/server-proxy.mjs +69 -5
  26. package/cli/server-registry.mjs +19 -11
  27. package/cli/settings.mjs +14 -0
  28. package/cli/site-check.mjs +96 -0
  29. package/cli/site-preview.mjs +53 -0
  30. package/cli/site-render.mjs +77 -0
  31. package/cli/site.mjs +109 -0
  32. package/global/CHANGELOG.md +63 -0
  33. package/global/README.md +115 -2
  34. package/global/checkout.mjs +24 -10
  35. package/global/cli/install.mjs +6 -2
  36. package/global/delegate.mjs +9 -7
  37. package/global/door/command.mjs +254 -0
  38. package/global/door/control.mjs +65 -0
  39. package/global/door/cookies.mjs +41 -0
  40. package/global/door/pages.mjs +49 -0
  41. package/global/door/policy.mjs +49 -0
  42. package/global/door/routes.mjs +45 -0
  43. package/global/door/server.mjs +255 -0
  44. package/global/door/sites.mjs +192 -0
  45. package/global/launch/dev.mjs +25 -23
  46. package/global/launch/door.mjs +36 -0
  47. package/global/launch/facts.mjs +3 -2
  48. package/global/launch/install-pinned.mjs +22 -0
  49. package/global/launch/prepare.mjs +41 -6
  50. package/global/launch/render.mjs +6 -1
  51. package/global/launch/state.mjs +10 -1
  52. package/global/launch/steps.mjs +24 -10
  53. package/global/launch/stop.mjs +89 -0
  54. package/global/launch/worker.mjs +30 -11
  55. package/global/list.mjs +6 -3
  56. package/global/main.mjs +37 -3
  57. package/global/version.mjs +8 -8
  58. package/modules/dependencies/protocol.mjs +2 -1
  59. package/modules/deploy-kit/README.md +34 -1
  60. package/modules/deploy-kit/artifact.mjs +10 -0
  61. package/modules/deploy-kit/owner.mjs +5 -4
  62. package/modules/deploy-kit/prepare.mjs +3 -2
  63. package/modules/deploy-kit/provider.mjs +6 -3
  64. package/modules/deploy-kit/site.mjs +135 -0
  65. package/modules/erasure/CONTRACT.md +6 -3
  66. package/modules/erasure/README.md +8 -1
  67. package/modules/erasure/audit.mjs +2 -2
  68. package/modules/erasure/cascade.mjs +52 -11
  69. package/modules/erasure/order.mjs +29 -22
  70. package/modules/erasure/plan.mjs +4 -2
  71. package/modules/erasure/queue.mjs +41 -0
  72. package/modules/erasure/scope.mjs +3 -3
  73. package/modules/erasure/verification.mjs +4 -2
  74. package/modules/mail/README.md +1 -1
  75. package/modules/mail/index.mjs +1 -1
  76. package/modules/mail/src/core/errors.mjs +9 -0
  77. package/modules/mail/src/development/index.d.mts +6 -0
  78. package/modules/mail/src/development/index.mjs +6 -3
  79. package/modules/mail/src/development/viewer-html.mjs +3 -3
  80. package/modules/mail/src/development/viewer.mjs +8 -1
  81. package/modules/operator-auth/dev/index.mjs +15 -5
  82. package/modules/operator-auth/http.mjs +2 -3
  83. package/modules/operator-auth/loopback.mjs +17 -0
  84. package/modules/owner-claim/verify.mjs +2 -1
  85. package/modules/portability/README.md +8 -0
  86. package/modules/portability/hash.mjs +6 -2
  87. package/modules/portability/import.mjs +1 -1
  88. package/modules/portability/legacy-files.mjs +4 -4
  89. package/modules/portability/legacy-safety.mjs +51 -10
  90. package/modules/portability/migration-history.mjs +15 -0
  91. package/modules/release/README.md +5 -3
  92. package/modules/release/produce.mjs +5 -2
  93. package/modules/release/source.mjs +15 -4
  94. package/onboarding/adapter-auth.mjs +4 -3
  95. package/onboarding/adapter-settings.mjs +21 -5
  96. package/onboarding/adapter.mjs +2 -1
  97. package/onboarding/app-fetch.mjs +79 -0
  98. package/onboarding/archive-output.d.mts +4 -2
  99. package/onboarding/archive-output.mjs +22 -10
  100. package/onboarding/dependency-approval.mjs +4 -3
  101. package/onboarding/local/environment.mjs +7 -2
  102. package/onboarding/operator-install.mjs +8 -2
  103. package/onboarding/operator-prerequisites.mjs +5 -2
  104. package/onboarding/queued-entry.mjs +9 -5
  105. package/operations/operations.mjs +11 -2
  106. package/package.json +75 -1
  107. package/scripts/publish-check/github_refs.py +28 -2
  108. package/scripts/publish-check/public_hygiene.py +35 -4
  109. package/scripts/publish-check/publication-config.json +14 -1
package/global/README.md CHANGED
@@ -97,6 +97,10 @@ RER_OPEN http://127.0.0.1:8787/__dev/key#...
97
97
  ```
98
98
 
99
99
  or `RER_SIGN_IN <outbox>` for a seed that signs in through its development outbox.
100
+ A seed that declares a run site prints one more line after those,
101
+ `RER_SITE http://127.0.0.1:8788`: a local preview of its static run site (its
102
+ marketing homepage, `/run` and docs) on the first free port above the app's. It
103
+ stops with the app.
100
104
  It never prompts and never re-runs setup on a checkout that is already set up. A
101
105
  setup a launch started that was stopped or failed is run again by the next
102
106
  `rer dev`, keeping the owner and origin it saved, unless a setup has finished
@@ -117,7 +121,10 @@ override the choice, and `--json` prints `progress` events and the ready event.
117
121
  first launch can take minutes: it prints a progress line at least every 30
118
122
  seconds, and if the calling command is killed the launch carries on; run
119
123
  `rer dev` again to reattach. A second `rer dev` reprints the ready lines.
120
- `rer dev --stop` stops the server.
124
+ `rer dev --stop` stops the server, or a launch still preparing (which then
125
+ reports that it was stopped), waits until every process they started has
126
+ exited and says what it stopped; if any is still running it exits non-zero
127
+ naming their PIDs.
121
128
 
122
129
  Servers started through `rer` (`rer dev`, `rer preview`) get a 10-server limit and a 12-hour idle timeout
123
130
  unless you set `RER_SERVER_LIMIT`, `RER_SERVER_IDLE_SECONDS` or `--idle-timeout`.
@@ -127,6 +134,27 @@ checkout never records a limit you did not set.
127
134
  A start is never refused for machine load alone unless `RER_SERVERS_LOAD_THRESHOLD`
128
135
  or `RER_JOBS_LOAD_THRESHOLD` is set; memory and disk guards stay.
129
136
 
137
+ ## The front door
138
+
139
+ ```sh
140
+ rer door enable # opt this computer in: port 8700, 10 servers, 12 hours idle
141
+ rer door add ~/review/* # register checkouts; prints each http://NAME.localhost:8700
142
+ rer door status --links # every checkout, its state, and sign-in links
143
+ ```
144
+
145
+ One process on one port serves every registered checkout at
146
+ `http://NAME.localhost:PORT` (NAME is its folder name, unique and stable);
147
+ `http://localhost:PORT` lists them. A stopped checkout starts when you open it
148
+ in a browser and stops when idle; subresources, API calls and WebSockets never
149
+ start one. `rer door enable` stores the policy (`--port`, `--server-limit`,
150
+ `--idle-timeout`) and the `RER_*` variables in effect, so whoever later starts
151
+ the door cannot change what its checkouts run with. While it is enabled, `rer
152
+ dev` sets fresh checkouts up behind it; `rer dev --door` moves an existing one
153
+ (keeping its data), `rer dev --direct` moves it back, and `--port N` always
154
+ serves directly. `rer door start`, `stop`, `restart`, `disable` and `remove
155
+ NAME` do what they say; after a reboot or logout the door needs `rer door
156
+ start`. `rer door --help` has the details.
157
+
130
158
  ## Find what to launch
131
159
 
132
160
  ```sh
@@ -141,6 +169,91 @@ Independent projects (an `OPERATIONS.json` version 2 without a seed contract) ru
141
169
  their declared commands with `rer <verb> [-- args]`; zero-setup launching does not
142
170
  apply to them. `OPERATIONS.json` version 1 is not supported.
143
171
 
172
+ ## Package mode: a checkout that pins `@runeditrun/rer`
173
+
174
+ A checkout runs one of two copies of rer. In **vendored mode** it carries
175
+ `scripts/rer.mjs`, `scripts/base-cli` and `scripts/base-onboarding`, and `rer`
176
+ runs that copy. In **package mode** it carries none of them and pins this package
177
+ as an exact devDependency instead (`"@runeditrun/rer": "0.2.0"`, locked in
178
+ `pnpm-lock.yaml`): its `OPERATIONS.json` argv and package scripts are `rer VERB`,
179
+ which inside `pnpm run` is the pinned copy in `node_modules/.bin`, so
180
+ `pnpm install --frozen-lockfile && pnpm run build` needs no global `rer`. A global
181
+ `rer` hands every verb to the pinned package, launching included. Until
182
+ `node_modules` holds the pinned version, `rer install` and `rer dev` install it
183
+ first (the frozen `pnpm install`) and every other verb says to run `rer install`.
184
+
185
+ Heavy package scripts wait for this computer's job queue with
186
+ `rer queue NAME -- COMMAND [ARGS]` (in vendored mode,
187
+ `node scripts/base-onboarding/queued-entry.mjs NAME -- COMMAND`).
188
+
189
+ `rer check --profile first-party` accepts either mode. In package mode it asks for
190
+ the exact devDependency (`SC-13.package.pin`), its lockfile entry
191
+ (`SC-13.package.lockfile`), no vendored copy (`SC-13.package.vendored.*`) and `rer
192
+ VERB` targets and aliases (`SC-02.target.*`, `SC-02.alias.*`); in vendored mode for
193
+ the copy and its recorded inventory, as before.
194
+
195
+ Convert a vendored seed from a Base checkout with
196
+
197
+ ```sh
198
+ node scripts/export-cli.mjs --to-package SEED_ROOT [--version X]
199
+ ```
200
+
201
+ (the default version is the latest published one, read with the seed's own npm
202
+ configuration). It refuses, listing them, when the vendored copy differs from its
203
+ recorded inventory: carry those edits with `pnpm patch @runeditrun/rer` once
204
+ converted, or upstream them to Base. Otherwise it pins and locks the package
205
+ (`pnpm install --lockfile-only`), rewrites imports into the copy to the exports
206
+ below (anything else to `@runeditrun/rer/internal/PATH`, which promises nothing
207
+ beyond the pinned version), rewrites the package scripts and `OPERATIONS.json`
208
+ argv to `rer VERB` and the delivery operations (`rer-project.json` and the
209
+ `scripts/delivery/base/packaging.json` it is generated from), the shell scripts
210
+ they and CI workflows run, and workflow lines to `pnpm exec rer VERB` (a frozen
211
+ `pnpm install` first where nothing has installed yet), drops the copy's own records from `base-copies.json` (the
212
+ app-runtime copies it records stay as they are), removes the copy and lists every
213
+ line that still names it. A later refresh is a version bump:
214
+ `pnpm add -D -E @runeditrun/rer@X`.
215
+
216
+ ### Exports
217
+
218
+ | Import | What it gives | Vendored path it replaces |
219
+ | --- | --- | --- |
220
+ | `@runeditrun/rer/adapter` | `commandAdapter` | `scripts/base-cli/cli/adapter.mjs` |
221
+ | `@runeditrun/rer/deploy` | `ownerDeploy` | `scripts/base-cli/cli/deploy.mjs` |
222
+ | `@runeditrun/rer/portability` | `localPortability` | `scripts/base-cli/cli/portability.mjs` |
223
+ | `@runeditrun/rer/errors` | the CLI's error helpers | `scripts/base-cli/cli/errors.mjs` |
224
+ | `@runeditrun/rer/jsonc` | JSONC reading | `scripts/base-cli/cli/jsonc.mjs` |
225
+ | `@runeditrun/rer/parse`, `/scheduled` | CLI argument parsing, scheduled-job helpers | `scripts/base-cli/cli/parse.mjs`, `scheduled.mjs` |
226
+ | `@runeditrun/rer/dev-identity` | dev identity helpers | `scripts/base-cli/cli/dev-identity.mjs` |
227
+ | `@runeditrun/rer/operator-installer` | `operatorInstaller`, `developmentTarget`, `previewTarget` | `scripts/base-onboarding/operator-installer.mjs` |
228
+ | `@runeditrun/rer/local-installer` | `localInstaller` | `scripts/base-onboarding/adapter.mjs` |
229
+ | `@runeditrun/rer/operator-install` | `installOperator` | `scripts/base-onboarding/operator-install.mjs` |
230
+ | `@runeditrun/rer/operator-prerequisites` | `operatorPrerequisites` | `scripts/base-onboarding/operator-prerequisites.mjs` |
231
+ | `@runeditrun/rer/local/state` | `selectLocalState` | `scripts/base-onboarding/local/state.mjs` |
232
+ | `@runeditrun/rer/local/build` | local build step | `scripts/base-onboarding/local/build.mjs` |
233
+ | `@runeditrun/rer/state`, `/commands` | onboarding state and command runners | `scripts/base-onboarding/state.mjs`, `commands.mjs` |
234
+ | `@runeditrun/rer/local/environment`, `/local/files`, `/local/operation`, `/local/recovery` | local setup helpers | `scripts/base-onboarding/local/*.mjs` |
235
+ | `@runeditrun/rer/private-files` | private file helpers | `scripts/base-onboarding/private-files.mjs` |
236
+ | `@runeditrun/rer/adapter-settings` | adapter settings helpers | `scripts/base-onboarding/adapter-settings.mjs` |
237
+ | `@runeditrun/rer/adapter-auth`, `/adapter-hosting` | adapter auth and hosting helpers | `scripts/base-onboarding/adapter-auth.mjs`, `adapter-hosting.mjs` |
238
+ | `@runeditrun/rer/process-diagnostics` | process diagnostics | `scripts/base-onboarding/process-diagnostics.mjs` |
239
+ | `@runeditrun/rer/process-cleanup`, `/process-scope` | process cleanup and scope | `scripts/base-onboarding/process-cleanup.mjs`, `process-scope.mjs` |
240
+ | `@runeditrun/rer/failures`, `/redaction` | failure and redaction helpers | `scripts/base-onboarding/failures.mjs`, `redaction.mjs` |
241
+ | `@runeditrun/rer/setup-manifest`, `/setup-schema` | setup manifest loading and its JSON schema | `scripts/base-onboarding/setup-manifest.mjs`, `schema/SETUP.schema.json` |
242
+ | `@runeditrun/rer/production-runtime`, `/production-modules` | production runtime helpers | `scripts/base-onboarding/production-runtime.mjs`, `production-modules.mjs` |
243
+ | `@runeditrun/rer/queued-entry` | the job-queue entry | `scripts/base-onboarding/queued-entry.mjs` |
244
+ | `@runeditrun/rer/archive-output` | `archiveOutputGuard` (with types) | `scripts/base-onboarding/archive-output.mjs` |
245
+ | `@runeditrun/rer/launcher` | the browser installer launcher | `scripts/base-onboarding/launcher.mjs` |
246
+ | `@runeditrun/rer/installer-server` | the browser installer's server (installer journeys) | `scripts/base-onboarding/server.mjs` |
247
+ | `@runeditrun/rer/server-resources` | managed server resource readings (runtime journeys) | `scripts/base-cli/cli/server-resources.mjs` |
248
+ | `@runeditrun/rer/operations` | OPERATIONS describe and check | `scripts/base-cli/operations/operations.mjs` |
249
+ | `@runeditrun/rer/modules/portability`, `/catalogue`, `/cli`, `/cli-options`, `/files`, `/format`, `/http`, `/import`, `/journal`, `/legacy-safety`, `/migration-history`, `/storage` | portability module | `scripts/base-cli/modules/portability/*.mjs` |
250
+ | `@runeditrun/rer/modules/release/export`, `/publication-scan`, `/source` | release export and publication scan | `scripts/base-cli/modules/release/*.mjs` |
251
+ | `@runeditrun/rer/modules/deploy-kit/owner`, `/owner-cli`, `/prepare`, `/provider` | deploy kit | `scripts/base-cli/modules/deploy-kit/*.mjs` |
252
+ | `@runeditrun/rer/modules/operator-auth`, `/dev`, `/dev/badge`, `/dev-admission`, `/accounts/native`, `/identity/email-link` | operator auth | `scripts/base-cli/modules/operator-auth/index.mjs`, `dev/index.mjs`, `dev/badge.mjs`, `dev-admission.mjs`, `accounts/native.mjs`, `identity/email-link/index.mjs` |
253
+ | `@runeditrun/rer/modules/erasure/fence` | erasure fence statements | `scripts/base-cli/modules/erasure/fence.mjs` |
254
+ | `@runeditrun/rer/modules/owner-claim`, `/modules/dependencies` | owner claim, dependency links | `scripts/base-cli/modules/owner-claim/index.mjs`, `dependencies/index.mjs` |
255
+ | `@runeditrun/rer/internal/PATH` | any other package file, for tests | `scripts/base-cli/PATH`, and `scripts/base-onboarding/PATH` with PATH under `onboarding/` |
256
+
144
257
  ## Verbs
145
258
 
146
259
  In a checkout, every verb runs that checkout's own pinned `rer`; `rer VERB --help`
@@ -150,6 +263,6 @@ there lists its flags:
150
263
  `export`, `import`, `outbox`, `doctor`, `describe`, `check`, `release`.
151
264
 
152
265
  Anywhere: `rer list [DIR]`, `rer cli status|install|uninstall`, `rer update`,
153
- `rer --version` and `rer --help`.
266
+ `rer queue NAME -- COMMAND`, `rer --version` and `rer --help`.
154
267
 
155
268
  Licence: MIT.
@@ -2,6 +2,7 @@
2
2
  // above the working directory, or exactly --cwd DIR. Then pick whose code runs it.
3
3
  import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
4
4
  import { dirname, join, resolve } from 'node:path';
5
+ import { exactVersion } from '../cli/package-mode.mjs';
5
6
  import { ownRoot, packageName } from './package-info.mjs';
6
7
 
7
8
  // The value of --cwd among the wrapper flags (never after a -- separator).
@@ -53,19 +54,32 @@ export function unsupportedMessage(root, version) {
53
54
  return `${join(root, 'OPERATIONS.json')} is an OPERATIONS version ${version ?? '(none)'} descriptor, which rer does not run. Use the commands that project documents, or move it to OPERATIONS version 2.`;
54
55
  }
55
56
 
56
- function declaresPackage(root) {
57
- try {
58
- const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
59
- return Boolean(manifest.dependencies?.[packageName] ?? manifest.devDependencies?.[packageName]);
60
- } catch { return false; }
57
+ // The rer package a checkout pins in package.json (package mode): the declared
58
+ // specifier, the version node_modules holds, and whether that installed copy is
59
+ // the one to run (`ready`: present, and equal to an exact pin). undefined for a
60
+ // checkout that does not declare it.
61
+ export function pinnedPackage(root) {
62
+ let manifest;
63
+ try { manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); } catch { return undefined; }
64
+ const declared = manifest.devDependencies?.[packageName] ?? manifest.dependencies?.[packageName];
65
+ if (!declared) return undefined;
66
+ let installed;
67
+ try { installed = JSON.parse(readFileSync(join(root, 'node_modules', packageName, 'package.json'), 'utf8')).version; } catch {}
68
+ return { declared, installed, ready: installed !== undefined && (installed === declared || !exactVersion.test(declared)) };
61
69
  }
62
70
 
63
- // 1. the checkout's own pinned package, 2. its vendored scripts/rer.mjs,
64
- // 3. this package's bundled dispatcher.
71
+ // What runs a checkout's verbs: `entry` is the dispatcher (the verbs
72
+ // themselves) and, for a pinned package, `launcher` is its own rer command.
73
+ // 1. the checkout's own pinned package, once node_modules holds the version
74
+ // package.json pins (see pinnedPackage), 2. its vendored scripts/rer.mjs, 3. this package's
75
+ // bundled dispatcher.
65
76
  export function dispatcher(root) {
66
- if (root && declaresPackage(root)) {
67
- const pinned = join(root, 'node_modules', packageName);
68
- if (existsSync(join(pinned, 'global/rer.mjs')) && realpathSync(pinned) !== ownRoot) return { mode: 'package', entry: join(realpathSync(pinned), 'global/rer.mjs') };
77
+ const pinned = root ? pinnedPackage(root) : undefined;
78
+ if (pinned?.ready) {
79
+ const location = realpathSync(join(root, 'node_modules', packageName));
80
+ // This package is that pin: it runs the checkout itself, whatever else the checkout holds.
81
+ if (location === ownRoot) return { mode: 'bundled', entry: join(ownRoot, 'bin/rer.mjs') };
82
+ if (existsSync(join(location, 'global/rer.mjs'))) return { mode: 'package', entry: join(location, 'bin/rer.mjs'), launcher: join(location, 'global/rer.mjs') };
69
83
  }
70
84
  if (root && existsSync(join(root, 'scripts/rer.mjs'))) return { mode: 'vendored', entry: join(root, 'scripts/rer.mjs') };
71
85
  return { mode: 'bundled', entry: join(ownRoot, 'bin/rer.mjs') };
@@ -2,10 +2,11 @@
2
2
  // version directory, switch `current` atomically, keep the previous version,
3
3
  // prune older ones and write the launcher. Never edits shell profiles.
4
4
  import { randomUUID } from 'node:crypto';
5
- import { chmod, cp, mkdir, readdir, readlink, rename, rm, symlink, writeFile } from 'node:fs/promises';
5
+ import { chmod, cp, mkdir, readdir, readlink, realpath, rename, rm, symlink, writeFile } from 'node:fs/promises';
6
6
  import { basename, join, resolve } from 'node:path';
7
7
  import { ownManifest, ownRoot, packageDigest, packageFileList } from '../package-info.mjs';
8
8
  import { installRecord, launcherOwner, launcherScript, layout } from './layout.mjs';
9
+ import { runningDoor } from '../../cli/door.mjs';
9
10
 
10
11
  async function readLink(path) { try { return await readlink(path); } catch (error) { if (['ENOENT', 'EINVAL'].includes(error.code)) return undefined; throw error; } }
11
12
 
@@ -66,7 +67,10 @@ export async function install() {
66
67
  if (!present) { await rm(join(paths.versions, name), { recursive: true, force: true }); await copyVersion(paths, name); }
67
68
  const previousName = current?.startsWith('versions/') && current !== join('versions', name) ? current.slice('versions/'.length) : record?.previous;
68
69
  if (current !== join('versions', name)) await switchCurrent(paths, name);
69
- const keep = new Set([name, previousName].filter(Boolean));
70
+ // A running front door keeps running from its own version: never prune it.
71
+ const door = await runningDoor().catch(() => undefined);
72
+ const doorVersion = door?.versionDir && resolve(door.versionDir, '..') === await realpath(paths.versions).catch(() => resolve(paths.versions)) ? basename(door.versionDir) : undefined;
73
+ const keep = new Set([name, previousName, doorVersion].filter(Boolean));
70
74
  for (const entry of await readdir(paths.versions)) if (!keep.has(entry) && !entry.startsWith('.staging-')) await rm(join(paths.versions, entry), { recursive: true, force: true });
71
75
  await writeFile(paths.record, JSON.stringify({ channel, previous: previousName && keep.has(previousName) ? previousName : null }, null, 2) + '\n', { mode: 0o644 });
72
76
  if (launcher.text !== script) await writeLauncher(paths);
@@ -1,13 +1,15 @@
1
- // Run an invocation with the checkout's own dispatcher, in this process, exactly
2
- // as `node scripts/rer.mjs ...` would from the project root: process.argv[1] is
3
- // that file, so its Node-pin re-exec and queued child spawns behave as they do
4
- // when it is run directly. The dispatcher sets process.exitCode itself.
1
+ // Run an invocation with the checkout's own rer, in this process. A pinned
2
+ // package runs it with its own rer command, so launching is its own too. A
3
+ // vendored or bundled dispatcher runs it exactly as `node scripts/rer.mjs ...`
4
+ // would from the project root: process.argv[1] is that file, so its Node-pin
5
+ // re-exec and queued child spawns behave as they do when it is run directly.
6
+ // The dispatcher sets process.exitCode itself.
5
7
  import { pathToFileURL } from 'node:url';
6
8
  import { dispatcher } from './checkout.mjs';
7
9
 
8
10
  export async function delegate(target, args) {
9
- const { entry } = dispatcher(target.root);
11
+ const { entry, launcher } = dispatcher(target.root);
10
12
  if (target.root) process.chdir(target.root);
11
- process.argv = [process.argv[0], entry, ...args];
12
- await import(pathToFileURL(entry).href);
13
+ process.argv = [process.argv[0], launcher ?? entry, ...args];
14
+ await import(pathToFileURL(launcher ?? entry).href);
13
15
  }
@@ -0,0 +1,254 @@
1
+ // rer door: the opt-in front door. One process on one port serves every
2
+ // registered checkout at http://NAME.localhost:PORT, starting it on its first
3
+ // visit and stopping it when idle.
4
+ import { readFile, realpath } from 'node:fs/promises';
5
+ import { join, resolve } from 'node:path';
6
+ import { changeDoorState, checkoutIdentity, claimEntry, defaultDoorPort, doorOrigin, doorProtocol, doorSite, ensureToken, prepareDoorDirectory, readDoorState, runningDoor, saveCheckoutIdentity, verifyDoor } from '../../cli/door.mjs';
7
+ import { openUrl, readDevKey } from '../../cli/dev-identity.mjs';
8
+ import { descriptorProfile, dispatcher, unsupportedMessage } from '../checkout.mjs';
9
+ import { describeCheckout } from '../launch/facts.mjs';
10
+ import { ownManifest } from '../package-info.mjs';
11
+ import { DoorFailure, startDoor, stopDoor } from './control.mjs';
12
+ import { rebootNote } from './pages.mjs';
13
+ import { enabledPolicy } from './policy.mjs';
14
+ import { doorSites } from './sites.mjs';
15
+
16
+ export const doorHelp = `Usage: rer door VERB
17
+
18
+ The front door serves every registered checkout at http://NAME.localhost:PORT
19
+ from one process on one port. A checkout starts on its first visit in a browser
20
+ and stops when idle. It is opt-in for this computer.
21
+
22
+ rer door enable [--port N] [--server-limit N] [--idle-timeout S]
23
+ Opt this computer in, record the policy and start the door.
24
+ Defaults: port 8700, 10 servers, 43200 s (12 hours) idle.
25
+ The policy and the RER_* variables in effect now are stored;
26
+ sites the door starts get those, never another shell's.
27
+ rer door disable Stop the door and opt this computer out.
28
+ rer door start Start the enabled door (needed after a reboot or logout).
29
+ rer door stop Stop the door. Running sites keep running until idle.
30
+ rer door restart Drain and restart the door; open tabs reconnect.
31
+ rer door add [PATH ...]
32
+ Register checkouts (default: here) for lazy start, printing
33
+ each URL and, for a set-up door-mode checkout with dev
34
+ identity, a sign-in link that starts it and signs in.
35
+ rer door remove NAME Unregister one checkout. Its name stays reserved for it;
36
+ rer dev there, or rer door add, registers it again.
37
+ rer door status [--json] [--links]
38
+ The door, its policy and every site with its state and URL;
39
+ --links adds sign-in links.
40
+
41
+ Fresh checkouts launched with rer dev while the door is enabled are set up at
42
+ their door URL. Move an existing one with rer dev --door (rer dev --direct moves
43
+ it back); rer dev --port N always serves directly.
44
+ `;
45
+
46
+ const out = text => process.stdout.write(text + '\n');
47
+ const err = text => process.stderr.write(text + '\n');
48
+
49
+ function flags(args, valued, booleans = []) {
50
+ const result = { positional: [] };
51
+ for (let i = 0; i < args.length; i++) {
52
+ const arg = args[i];
53
+ if (valued.includes(arg)) { const value = args[++i]; if (value === undefined || value.startsWith('--')) throw new DoorFailure(2, `${arg} needs a value.`); result[arg] = value; }
54
+ else if (booleans.includes(arg)) result[arg] = true;
55
+ else if (arg.startsWith('-')) throw new DoorFailure(2, `Unsupported flag ${arg}. Run rer door --help.`);
56
+ else result.positional.push(arg);
57
+ }
58
+ return result;
59
+ }
60
+
61
+ async function enabledState(env) {
62
+ const state = await readDoorState(env);
63
+ if (!state?.door?.enabled) throw new DoorFailure(3, 'The front door is not enabled on this computer. Enable it with: rer door enable');
64
+ return state;
65
+ }
66
+
67
+ function runningLine(door, record) {
68
+ return `Front door running at http://localhost:${record.port} (PID ${record.pid}, rer ${record.version}); limit ${door.policy.serverLimit} servers, idle stop after ${door.policy.idleSeconds} s.`;
69
+ }
70
+
71
+ // Replace a running door with a new one: the old one stops accepting, hands
72
+ // the starts under way to the new one and gives up its port at once, then
73
+ // finishes its requests; the new one carries those starts on (each one's own
74
+ // rer dev waits for it before its key is printed) and open tabs reconnect.
75
+ // `change` edits the state between the two (rer door enable's new policy).
76
+ async function replaceDoor(env, change) {
77
+ const running = await runningDoor(env);
78
+ if (running) await changeDoorState(env, state => { if (state.door) state.door.restarting = Date.now(); });
79
+ try {
80
+ const stopped = running ? await stopDoor(env, { released: true }) : undefined;
81
+ if (change) await changeDoorState(env, change);
82
+ const { door } = await startDoor(env);
83
+ return { stopped, door };
84
+ } finally { if (running) await changeDoorState(env, state => { if (state.door) delete state.door.restarting; }); }
85
+ }
86
+
87
+ async function enable(args, env) {
88
+ const given = flags(args, ['--port', '--server-limit', '--idle-timeout']);
89
+ if (given.positional.length) throw new DoorFailure(2, 'rer door enable takes only flags. Run rer door --help.');
90
+ const chosen = enabledPolicy({ port: given['--port'], serverLimit: given['--server-limit'], idleTimeout: given['--idle-timeout'] }, env);
91
+ const previous = (await readDoorState(env))?.door;
92
+ const port = chosen.port ?? previous?.port ?? defaultDoorPort;
93
+ const directory = await prepareDoorDirectory(env);
94
+ await ensureToken(directory);
95
+ const { door: record } = await replaceDoor(env, state => {
96
+ state.door = { protocol: doorProtocol, enabled: true, port, policy: chosen.policy, directory: chosen.directory, serversDir: chosen.serversDir, environment: chosen.environment, started: state.door?.started ?? [], ...(state.door?.handoff ? { handoff: state.door.handoff } : {}), ...(state.door?.restarting ? { restarting: state.door.restarting } : {}), enabledAt: new Date().toISOString() };
97
+ });
98
+ out(runningLine({ policy: chosen.policy }, record));
99
+ if (previous?.port && previous.port !== port) out(`Checkouts already moved to the door keep their saved port ${previous.port}; move each again with rer dev --direct, then rer dev --door.`);
100
+ out(`Register checkouts with rer door add PATH. ${rebootNote.replace(/<\/?code>/g, '')}`);
101
+ return 0;
102
+ }
103
+
104
+ async function disable(args, env) {
105
+ if (args.length) throw new DoorFailure(2, 'rer door disable takes no arguments.');
106
+ const stopped = await stopDoor(env);
107
+ if (await readDoorState(env)) await changeDoorState(env, state => { if (state.door) state.door.enabled = false; });
108
+ out(`${stopped ? `Stopped the front door (PID ${stopped.pid}). ` : ''}The front door is disabled on this computer. Registered checkouts stay registered; rer door enable turns it back on.`);
109
+ return 0;
110
+ }
111
+
112
+ async function start(env) {
113
+ const state = await enabledState(env);
114
+ const { door: record, already } = await startDoor(env);
115
+ out(already ? `The front door is already running (PID ${record.pid}).` : runningLine(state.door, record));
116
+ return 0;
117
+ }
118
+
119
+ async function stop(env) {
120
+ const stopped = await stopDoor(env);
121
+ out(stopped ? `Stopped the front door (PID ${stopped.pid}). Sites it started keep running until idle; rer door start brings it back.` : 'The front door is not running.');
122
+ return 0;
123
+ }
124
+
125
+ async function restart(env) {
126
+ const state = await enabledState(env);
127
+ const { stopped, door: record } = await replaceDoor(env);
128
+ out(`${stopped ? `Drained the front door (PID ${stopped.pid}); open tabs reconnect. ` : ''}${runningLine(state.door, record)}`);
129
+ return 0;
130
+ }
131
+
132
+ // The sign-in link for a set-up door-mode checkout with dev identity, after the
133
+ // door proves it is this account's. It carries the checkout's dev key in its
134
+ // fragment: printed for the person, never given to the door.
135
+ async function signInLink(site, port) {
136
+ if (site.mode !== 'door') return undefined;
137
+ let key; try { key = await readDevKey(site.path); } catch { return undefined; }
138
+ return key ? openUrl(doorOrigin(site.name, port), key, { path: '/__dev/key', as: 'owner' }) : undefined;
139
+ }
140
+
141
+ // A pin from before the door does not describe its saved origin; its direct
142
+ // link is the AUTH_ORIGIN its settings file holds (read only for the link).
143
+ async function oldPinOrigin(root, described) {
144
+ try {
145
+ const text = await readFile(join(root, described.local?.settings?.[0] ?? '.dev.vars'), 'utf8');
146
+ const origin = /^AUTH_ORIGIN=(http:\/\/(?:127\.0\.0\.1|localhost|\[::1\]):\d+)\s*$/m.exec(text)?.[1];
147
+ return origin ?? null;
148
+ } catch { return null; }
149
+ }
150
+
151
+ class NameHeld extends Error {}
152
+
153
+ async function add(args, env) {
154
+ const given = flags(args, []);
155
+ const state = await enabledState(env), port = state.door.port;
156
+ let verified, verifyError;
157
+ try { verified = await verifyDoor(env, port); } catch (error) { verifyError = error; }
158
+ let skipped = 0;
159
+ for (const path of given.positional.length ? given.positional : ['.']) {
160
+ let root;
161
+ try { root = await realpath(resolve(path)); } catch { out(`skipped ${path}: no such folder`); skipped++; continue; }
162
+ const facts = descriptorProfile(join(root, 'OPERATIONS.json'));
163
+ if (facts.profile !== 'first-party') {
164
+ out(`skipped ${path}: ${facts.profile === 'independent' ? 'an independent project; the front door serves first-party checkouts' : facts.profile === 'unreadable' ? 'no readable OPERATIONS.json, so not a first-party checkout' : unsupportedMessage(root, facts.version)}`);
165
+ skipped++; continue;
166
+ }
167
+ let described;
168
+ try { described = await describeCheckout(root, dispatcher(root).entry, env); } catch (failure) { out(`skipped ${path}: rer describe fails here: ${failure.message.split('\n')[0]}`); skipped++; continue; }
169
+ const saved = described.local && 'origin' in described.local ? described.local.origin : await oldPinOrigin(root, described), site = saved && doorSite(saved), mode = site && site.port === port ? 'door' : 'direct';
170
+ // A door-mode checkout's saved origin names its site, so it registers under
171
+ // that name or not at all; another checkout keeps a name it holds.
172
+ // A moved or renamed checkout is found by the identity its folder holds.
173
+ let name, id;
174
+ try {
175
+ ({ name, id } = await changeDoorState(env, state => {
176
+ const { entry, holder } = claimEntry(state, root, site?.name, checkoutIdentity(root));
177
+ // Thrown, so the state is left as it was.
178
+ if (mode === 'door' && entry.name !== site.name) throw new NameHeld(holder?.checkout ?? `this checkout under ${entry.name}`);
179
+ Object.assign(entry, { mode, door: Boolean(described.door) });
180
+ if (saved && mode === 'direct') entry.origin = saved; else delete entry.origin;
181
+ return { name: entry.name, id: entry.id };
182
+ }));
183
+ await saveCheckoutIdentity(root, id);
184
+ } catch (error) {
185
+ if (!(error instanceof NameHeld)) throw error;
186
+ out(`skipped ${path}: its saved origin names ${site.name}.localhost, which the front door has registered for ${error.message}. Give it its own name with rer dev --direct, then rer dev --door, in ${root}.`);
187
+ skipped++; continue;
188
+ }
189
+ const url = doorOrigin(name, port);
190
+ out(`${name} ${url} (${mode === 'door' ? 'door mode' : `direct${saved ? ` at ${saved}` : ''}; opening the door URL starts it and redirects there`}) ${root}`);
191
+ if (mode === 'door' && described.local?.devIdentity) {
192
+ const link = verified && await signInLink({ name, path: root, mode }, port);
193
+ if (link) out(` sign in: ${link}`);
194
+ else if (verifyError) out(` no sign-in link: ${verifyError.message}`);
195
+ }
196
+ }
197
+ return skipped && skipped === (given.positional.length || 1) ? 3 : 0;
198
+ }
199
+
200
+ // The checkout keeps its name reserved: its saved origin still names it, so no
201
+ // other checkout may take it, and its own next rer dev (or rer door add)
202
+ // registers it again.
203
+ async function remove(args, env) {
204
+ const given = flags(args, []);
205
+ if (given.positional.length !== 1) throw new DoorFailure(2, 'Usage: rer door remove NAME');
206
+ const [name] = given.positional;
207
+ const removed = await changeDoorState(env, state => { const site = state.sites.find(item => item.name === name && !item.removed); if (site) site.removed = true; return site; });
208
+ if (!removed) { err(`No checkout is registered as ${name}. rer door status lists them.`); return 3; }
209
+ out(`Unregistered ${name} (${removed.checkout}). Its checkout and any running server are untouched, and the name stays reserved for it: rer dev there, or rer door add, registers it again.`);
210
+ return 0;
211
+ }
212
+
213
+ async function status(args, env) {
214
+ const given = flags(args, [], ['--json', '--links']);
215
+ if (given.positional.length) throw new DoorFailure(2, 'Usage: rer door status [--json] [--links]');
216
+ const state = await readDoorState(env), record = await runningDoor(env), version = record?.version ?? ownManifest().version;
217
+ const door = state?.door;
218
+ const views = door ? await doorSites({ env, door }).views() : [];
219
+ let linkNote;
220
+ if (given['--links'] && door) {
221
+ try {
222
+ await verifyDoor(env, door.port);
223
+ for (const view of views) { const link = await signInLink(view, door.port); if (link) view.signIn = link; }
224
+ } catch (error) { linkNote = error.message; }
225
+ }
226
+ const sites = views.map(view => ({ name: view.name, path: view.path, mode: view.mode, state: view.state === 'failed' ? 'stopped' : view.state, url: view.url, ...(view.proxyPort && view.state === 'running' ? { proxyPort: view.proxyPort } : {}), ...(view.signIn ? { signIn: view.signIn } : {}) }));
227
+ const result = { running: Boolean(record), enabled: Boolean(door?.enabled), port: door?.port ?? record?.port ?? null, pid: record?.pid ?? null, version, policy: door ? { serverLimit: door.policy.serverLimit, idleSeconds: door.policy.idleSeconds } : null, sites };
228
+ if (given['--json']) { out(JSON.stringify({ ...result, ...(linkNote ? { linksUnavailable: linkNote } : {}), ...(!record && door?.enabled ? { note: 'After a reboot or logout the front door needs rer door start.' } : {}) })); return 0; }
229
+ if (!door) { out('The front door is not enabled on this computer. Enable it with: rer door enable'); return 0; }
230
+ out(record ? runningLine(door, record) : door.enabled ? `The front door is enabled on port ${door.port} but not running. Start it with: rer door start` : 'The front door is disabled on this computer. Enable it with: rer door enable');
231
+ if (sites.length) {
232
+ const width = Math.max(4, ...sites.map(site => site.name.length));
233
+ out(`${'NAME'.padEnd(width)} STATE MODE URL`);
234
+ for (const site of sites) {
235
+ out(`${site.name.padEnd(width)} ${site.state.padEnd(8)} ${site.mode.padEnd(6)} ${site.url}${site.proxyPort ? ` (proxy 127.0.0.1:${site.proxyPort})` : ''}`);
236
+ if (site.signIn) out(`${' '.repeat(width)} sign in: ${site.signIn}`);
237
+ }
238
+ } else out('No checkouts are registered. Add one with: rer door add PATH');
239
+ if (linkNote) out(`No sign-in links: ${linkNote}`);
240
+ if (!record && door.enabled) out('After a reboot or logout the front door needs rer door start (or any rer dev in a door-mode checkout).');
241
+ return 0;
242
+ }
243
+
244
+ export async function doorCommand(args, env = process.env) {
245
+ // A reader that stops early (rer door status | grep -q) is not a failure.
246
+ process.stdout.on('error', error => { if (error.code === 'EPIPE') process.exit(0); throw error; });
247
+ const [verb, ...rest] = args;
248
+ if (!verb || ['--help', '-h', 'help'].includes(verb) || rest.includes('--help')) { process.stdout.write(doorHelp); return 0; }
249
+ const verbs = { enable, disable, add, remove, status, start: (_, e) => start(e), stop: (_, e) => stop(e), restart: (_, e) => restart(e) };
250
+ if (!verbs[verb]) { err(`Unknown rer door command: ${verb}. Run rer door --help.`); return 2; }
251
+ if (['start', 'stop', 'restart'].includes(verb) && rest.length) { err(`rer door ${verb} takes no arguments.`); return 2; }
252
+ try { return await verbs[verb](rest, env); }
253
+ catch (error) { if (error instanceof DoorFailure || error.exitCode) { err(error.message); return error.exitCode ?? 1; } throw error; }
254
+ }
@@ -0,0 +1,65 @@
1
+ // Start and stop the door process. It runs detached from the installed
2
+ // version's own files, with the allowlisted base environment and RER_DOOR_DIR
3
+ // only; its policy comes from the state `rer door enable` recorded.
4
+ import { spawn } from 'node:child_process';
5
+ import { open, readFile } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+ import { setTimeout as delay } from 'node:timers/promises';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { processInfo } from '../../modules/job-queue/processes.mjs';
10
+ import { doorProtocol, prepareDoorDirectory, readDoorState, runningDoor, verifyDoor } from '../../cli/door.mjs';
11
+ import { doorProcessEnvironment } from './policy.mjs';
12
+
13
+ const serverPath = fileURLToPath(new URL('./server.mjs', import.meta.url));
14
+
15
+ export class DoorFailure extends Error { constructor(exitCode, message) { super(message); this.exitCode = exitCode; } }
16
+
17
+ export async function startDoor(env = process.env) {
18
+ const state = await readDoorState(env);
19
+ if (!state?.door?.enabled) throw new DoorFailure(3, 'The front door is not enabled on this computer. Enable it with: rer door enable');
20
+ const running = await runningDoor(env);
21
+ if (running) {
22
+ if (running.protocol !== doorProtocol) throw new DoorFailure(4, `A front door speaking protocol ${running.protocol} is running (PID ${running.pid}); this rer speaks protocol ${doorProtocol}. Run rer door restart.`);
23
+ return { door: running, already: true };
24
+ }
25
+ const door = state.door, directory = await prepareDoorDirectory(env), logPath = join(directory, 'logs', 'door.log');
26
+ const log = await open(logPath, 'a', 0o600), from = (await log.stat()).size;
27
+ let child, exited, failed;
28
+ // Listening before anything is awaited: a spawn that fails reports it on the next tick.
29
+ try {
30
+ child = spawn(process.execPath, [serverPath], { cwd: directory, env: doorProcessEnvironment(door, env), detached: true, stdio: ['ignore', log.fd, log.fd] });
31
+ child.once('error', error => { failed = error; exited ??= 1; });
32
+ child.once('exit', code => { exited = code ?? 1; });
33
+ } finally { await log.close(); }
34
+ for (let waited = 0; waited < 20000; waited += 100) {
35
+ const record = await runningDoor(env);
36
+ if (record && record.pid === child.pid) { child.unref(); await verifyDoor(env, record.port); return { door: record }; }
37
+ if (exited !== undefined) {
38
+ if (failed) throw new DoorFailure(1, `The front door could not be started: ${failed.code ?? failed.message}.`);
39
+ // Another start (a rer dev bringing the door back) won the port: use that door.
40
+ if (record?.protocol === doorProtocol) { await verifyDoor(env, record.port); return { door: record, already: true }; }
41
+ const text = (await readFile(logPath, 'utf8')).slice(from).split('\n').filter(line => line.startsWith('rer door: ')).map(line => line.slice('rer door: '.length));
42
+ throw new DoorFailure(exited === 4 ? 4 : 1, text.join('\n') || `The front door stopped while starting (exit ${exited}); see ${logPath}.`);
43
+ }
44
+ await delay(100);
45
+ }
46
+ child.kill('SIGTERM');
47
+ throw new DoorFailure(1, `The front door did not start within 20 seconds; see ${logPath}.`);
48
+ }
49
+
50
+ // Stop the recorded door by its PID (after checking its start time) and wait
51
+ // until it has drained and exited, or with `released` (a restart) only until
52
+ // it has given up its port and process record, which it does before draining.
53
+ export async function stopDoor(env = process.env, { timeoutMs = 30000, released = false } = {}) {
54
+ const running = await runningDoor(env);
55
+ if (!running) return undefined;
56
+ try { process.kill(running.pid, 'SIGTERM'); } catch (error) { if (error.code !== 'ESRCH') throw error; }
57
+ for (let waited = 0; waited < timeoutMs; waited += 100) {
58
+ const current = await processInfo(running.pid);
59
+ // The door removes its own process record as it drains; a stale one names a dead PID.
60
+ if (!current || current.zombie || current.start !== running.start) return running;
61
+ if (released && (await runningDoor(env))?.pid !== running.pid) return running;
62
+ await delay(100);
63
+ }
64
+ throw new DoorFailure(1, `The front door (PID ${running.pid}) did not stop within ${timeoutMs / 1000} seconds.`);
65
+ }
@@ -0,0 +1,41 @@
1
+ // The door makes every cookie a site sets host-only when its Domain names
2
+ // localhost itself: WebKit stores Domain=localhost (and its dotted spellings,
3
+ // such as localhost. and .localhost.) for every *.localhost site and plain
4
+ // localhost, so one site's cookie would reach every other. It applies to every
5
+ // reply that can carry Set-Cookie: HTTP replies and WebSocket handshake replies.
6
+
7
+ // A Domain value as a browser reads it: whitespace and quotes trimmed, leading
8
+ // and trailing dots ignored, any letter case.
9
+ const namesLocalhost = value => value.trim().replace(/^"(.*)"$/, '$1').trim().replace(/^\.+|\.+$/g, '').toLowerCase() === 'localhost';
10
+
11
+ // One Set-Cookie value with each Domain attribute that names localhost removed.
12
+ export function hostOnlyCookie(value) {
13
+ const [pair, ...attributes] = String(value).split(';');
14
+ const kept = attributes.filter(attribute => {
15
+ const at = attribute.indexOf('=');
16
+ return at < 0 || attribute.slice(0, at).trim().toLowerCase() !== 'domain' || !namesLocalhost(attribute.slice(at + 1));
17
+ });
18
+ return [pair, ...kept].join(';');
19
+ }
20
+
21
+ // An HTTP response head (status line and headers, without the blank line) as
22
+ // bytes, read the way browsers read it (CRLF or bare LF line ends, folded
23
+ // continuation lines joined), with every Set-Cookie made host-only.
24
+ export function hostOnlyHead(head) {
25
+ const lines = [];
26
+ for (const line of head.toString('latin1').split(/\r?\n/)) {
27
+ if (/^[ \t]/.test(line) && lines.length > 1) lines[lines.length - 1] += ' ' + line.trim();
28
+ else lines.push(line);
29
+ }
30
+ return Buffer.from(lines.map((line, index) => {
31
+ const at = line.indexOf(':');
32
+ return index > 0 && at > 0 && line.slice(0, at).trim().toLowerCase() === 'set-cookie' ? `${line.slice(0, at)}: ${hostOnlyCookie(line.slice(at + 1).trim())}` : line;
33
+ }).join('\r\n') + '\r\n\r\n', 'latin1');
34
+ }
35
+
36
+ // The end of a response head in `buffer`: { head, rest }, or undefined while
37
+ // the blank line has not arrived.
38
+ export function splitHead(buffer) {
39
+ const match = /\r?\n\r?\n/.exec(buffer.toString('latin1'));
40
+ return match && { head: buffer.subarray(0, match.index), rest: buffer.subarray(match.index + match[0].length) };
41
+ }