@farmslot/adapter-web 0.17.7 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,10 @@ All notable changes to `@farmslot/adapter-web` are tracked here.
6
6
 
7
7
  - Active-development baseline; add user-facing changes here before release or package publication.
8
8
 
9
+ ## 0.18.0 - 2026-11-05
10
+
11
+ - Run ordinary websites in an owned extension-free browser with shared CDP actions, screenshots and whole-run recording. Existing extension launches retain their lifecycle.
12
+
9
13
  ## 0.17.7 - 2026-11-04
10
14
 
11
15
  - Check host-resolver blocking in an owned blank document so strict application CSP needs no harness-only source. Keep application HTTP and WebSocket blocking checks unchanged.
package/README.md CHANGED
@@ -108,6 +108,12 @@ const binding = createWalletRequestBinding({
108
108
  });
109
109
  ```
110
110
 
111
+ ## Plain websites
112
+
113
+ `@farmslot/adapter-web/plain-web` exports `createPlainWebAdapter({ manifestPath, url, serverLabel })`. The project supplies its action catalog and a URL function using its selected ports. The runtime owns an isolated browser and reuses shared CDP actions, screenshots and whole-run recording. The project owns its application server.
114
+
115
+ Browser stop is scoped to the selected checkout's profile and retains the application server. This path needs no extension, signer or wallet policy and makes no assumptions about the application framework or page selectors.
116
+
111
117
  ## Web-dapp adapter
112
118
 
113
119
  `@farmslot/adapter-web/web-dapp` (ESM) is the lifecycle of a web app under test whose dev server the slot owns. `createWebDappAdapter({ id, signerModule, cli, hooks })` returns an `@farmslot/adapter-sdk` `ProjectRuntime`: launch, stop, runtime status, doctor checks, logs and the wallet request-log findings. It runs one slot browser per slot, and a wallet host attached to it over CDP that records the wallet requests (and, with `signer=injected`, answers them with the strict wallet from `dapp`).
@@ -3,9 +3,9 @@ export type LaunchBrowserOptions = {
3
3
  chromeBin: string;
4
4
  profile: string;
5
5
  /**
6
- * Unpacked extension to load.
6
+ * Unpacked extension to load; omitted for a plain web page.
7
7
  */
8
- extensionDir: string;
8
+ extensionDir?: string | undefined;
9
9
  /**
10
10
  * Holds browser-resolution.json and the runtime identity.
11
11
  */
@@ -72,7 +72,7 @@ export type LaunchBrowserOptions = {
72
72
  * @property {number} cdpPort
73
73
  * @property {string} chromeBin
74
74
  * @property {string} profile
75
- * @property {string} extensionDir Unpacked extension to load.
75
+ * @property {string} [extensionDir] Unpacked extension to load; omitted for a plain web page.
76
76
  * @property {string} runtimeDir Holds browser-resolution.json and the runtime identity.
77
77
  * @property {string} chromeLog
78
78
  * @property {string} chromePid
@@ -0,0 +1,17 @@
1
+ /** Runtime files and the browser profile always belong to the selected checkout. */
2
+ export function plainWebRuntimeDir(target: any): string;
3
+ /**
4
+ * Use existing browser ownership, CDP actions and whole-run recording for a plain website.
5
+ * @param {PlainWebAdapterConfig} config
6
+ * @returns {import('@farmslot/adapter-sdk').ProjectRuntime}
7
+ */
8
+ export function createPlainWebAdapter(config: PlainWebAdapterConfig): import("@farmslot/adapter-sdk").ProjectRuntime;
9
+ /** Releases only the selected checkout's owned profile. The application server is retained. */
10
+ export function stopPlainWebBrowser(target: any): {
11
+ stopped: boolean;
12
+ };
13
+ export type PlainWebAdapterConfig = {
14
+ manifestPath: string;
15
+ url: (target: string) => string;
16
+ serverLabel?: string;
17
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@farmslot/adapter-web",
3
- "version": "0.17.7",
3
+ "version": "0.18.0",
4
4
  "description": "Web platform adapter for Farmslot recipes: resolve and launch an isolated Chromium, own its CDP port, load unpacked extensions, and keep macOS focus.",
5
5
  "type": "commonjs",
6
6
  "exports": {
@@ -68,6 +68,10 @@
68
68
  "./web-dapp": {
69
69
  "types": "./dist/web-dapp/index.d.mts",
70
70
  "default": "./src/web-dapp/index.mjs"
71
+ },
72
+ "./plain-web": {
73
+ "types": "./dist/plain-web.d.mts",
74
+ "default": "./src/plain-web.mjs"
71
75
  }
72
76
  },
73
77
  "scripts": {
@@ -67,7 +67,7 @@ const BROWSER_RESOLVER_CLI = require.resolve('./browser-resolver.cjs');
67
67
  * @property {number} cdpPort
68
68
  * @property {string} chromeBin
69
69
  * @property {string} profile
70
- * @property {string} extensionDir Unpacked extension to load.
70
+ * @property {string} [extensionDir] Unpacked extension to load; omitted for a plain web page.
71
71
  * @property {string} runtimeDir Holds browser-resolution.json and the runtime identity.
72
72
  * @property {string} chromeLog
73
73
  * @property {string} chromePid
@@ -114,7 +114,6 @@ function normalizeOptions(options) {
114
114
  const flags = {
115
115
  chromeBin: 'chrome-bin',
116
116
  profile: 'profile',
117
- extensionDir: 'extension-dir',
118
117
  runtimeDir: 'runtime-dir',
119
118
  chromeLog: 'chrome-log',
120
119
  chromePid: 'chrome-pid',
@@ -122,6 +121,10 @@ function normalizeOptions(options) {
122
121
  for (const [key, flag] of Object.entries(flags)) {
123
122
  if (!opts[key]) throw new Error(`Missing --${flag}`);
124
123
  }
124
+ if (!opts.extensionDir && !opts.stopOnly) {
125
+ if (!opts.startUrl || !/^https?:$/u.test(new URL(opts.startUrl).protocol))
126
+ throw new Error('Plain web launch requires an explicit HTTP(S) --start-url');
127
+ }
125
128
  opts.log = opts.log ?? ((line) => process.stderr.write(`${line}\n`));
126
129
  opts.progress = opts.progress ?? (() => {});
127
130
  return opts;
@@ -135,7 +138,7 @@ function runLaunch(opts, acquireLock) {
135
138
  if (!opts.stopOnly) {
136
139
  if (!fs.existsSync(opts.chromeBin))
137
140
  throw new Error(`Chrome binary not found: ${opts.chromeBin}`);
138
- if (!fs.existsSync(path.join(extensionDir, 'manifest.json'))) {
141
+ if (extensionDir && !fs.existsSync(path.join(extensionDir, 'manifest.json'))) {
139
142
  throw new Error(
140
143
  `Extension dist manifest not found: ${path.join(extensionDir, 'manifest.json')}`,
141
144
  );
@@ -189,7 +192,7 @@ function runLaunch(opts, acquireLock) {
189
192
  // run's Chrome would hold the lock and the write fails.
190
193
  if (opts.stopOnly) return { stopped: true };
191
194
  try {
192
- removeExtensionWorkerRegistrations(profile);
195
+ if (extensionDir) removeExtensionWorkerRegistrations(profile);
193
196
  } catch (error) {
194
197
  const detail = error instanceof Error ? error.message : String(error);
195
198
  throw new Error(
@@ -221,7 +224,7 @@ function runLaunch(opts, acquireLock) {
221
224
  const initialUrl =
222
225
  opts.startUrl || extensionPageUrl(extensionDir, opts.homePage) || 'chrome://extensions/';
223
226
  const browser = launchBrowserResolution(opts.browserResolution, opts.chromeBin);
224
- const loadsOverCdp = browser.extensionLoading === CDP_LOAD_UNPACKED;
227
+ const loadsOverCdp = Boolean(extensionDir) && browser.extensionLoading === CDP_LOAD_UNPACKED;
225
228
  let cdpLoad = null;
226
229
  let owner = null;
227
230
  const runtimeNonce = createRuntimeIdentityNonce();
@@ -238,10 +241,14 @@ function runLaunch(opts, acquireLock) {
238
241
  ...isolatedProfileArgs(),
239
242
  '--disable-default-apps',
240
243
  '--disable-popup-blocking',
241
- '--disable-extensions-file-access-check',
242
- '--disable-extensions-content-verification',
243
- '--disable-features=ExtensionContentVerification,DisableLoadExtensionCommandLineSwitch',
244
- ...extensionLaunchArgs(extensionDir, browser.extensionLoading),
244
+ ...(extensionDir
245
+ ? [
246
+ '--disable-extensions-file-access-check',
247
+ '--disable-extensions-content-verification',
248
+ '--disable-features=ExtensionContentVerification,DisableLoadExtensionCommandLineSwitch',
249
+ ...extensionLaunchArgs(extensionDir, browser.extensionLoading),
250
+ ]
251
+ : []),
245
252
  ]);
246
253
  // Initial tab: the caller's URL when provided, else the extension home. The
247
254
  // extension home avoids leaving disposable chrome://newtab/ or extensions
@@ -333,7 +340,7 @@ function runLaunch(opts, acquireLock) {
333
340
  cdpLoad = loadExtensionOverCdp(opts, browserPid, initialUrl, clearLaunchMarkers);
334
341
  else if (application) openBackgroundWindow(opts, browserPid, initialUrl, clearLaunchMarkers);
335
342
  owner = cdpLoad?.owner ?? ownerIdentity(cdpPort, profile, browserPid, log);
336
- for (const problem of pruneExtraHomeTabs(cdpPort, extensionDir, opts)) {
343
+ for (const problem of extensionDir ? pruneExtraHomeTabs(cdpPort, extensionDir, opts) : []) {
337
344
  log(`[launch] home-tab pruning: ${problem}`);
338
345
  }
339
346
  } finally {
@@ -658,7 +665,8 @@ function processIsOwnedBrowser(pid, profile, extensionDir, extensionOwnerRoot) {
658
665
  const command = processCommand(pid);
659
666
  if (!command) return false;
660
667
  if (command.includes(`--user-data-dir=${profile}`)) return true;
661
- const ownerRoot = extensionOwnerRoot ? extensionOwnerRoot(path.resolve(extensionDir)) : null;
668
+ const ownerRoot =
669
+ extensionDir && extensionOwnerRoot ? extensionOwnerRoot(path.resolve(extensionDir)) : null;
662
670
  return Boolean(ownerRoot && command.includes(`--load-extension=${ownerRoot}`));
663
671
  }
664
672
 
@@ -0,0 +1,219 @@
1
+ // A regular website in a slot-owned browser. The project owns its application server.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+
6
+ import { CdpWebPage, listCdpTargets } from '@farmslot/recipe-runner/runtime/cdp';
7
+
8
+ import { assertSameOwner, cdpOwner } from './browser-cdp.cjs';
9
+ import { resolveBrowser } from './browser-resolver.cjs';
10
+ import { removeRuntimeIdentity } from './chrome-args.cjs';
11
+ import { launchBrowser } from './launch-browser.cjs';
12
+ import { stopProfileProcessesSync } from './validation-process-ownership.cjs';
13
+
14
+ /** @typedef {{
15
+ * manifestPath: string,
16
+ * url: (target: string) => string,
17
+ * serverLabel?: string
18
+ * }} PlainWebAdapterConfig
19
+ */
20
+
21
+ function port(env = process.env) {
22
+ const value = Number(env.RECIPE_CDP_PORT ?? env.CDP_PORT);
23
+ if (!Number.isInteger(value) || value < 1 || value > 65535)
24
+ throw new Error('The plain web runtime requires the assigned --cdp-port.');
25
+ return value;
26
+ }
27
+
28
+ /** Runtime files and the browser profile always belong to the selected checkout. */
29
+ export function plainWebRuntimeDir(target) {
30
+ return path.join(fs.realpathSync(target), '.agent', 'web');
31
+ }
32
+
33
+ function browserOptions(target, env = process.env) {
34
+ const runtimeDir = plainWebRuntimeDir(target);
35
+ return {
36
+ cdpPort: port(env),
37
+ runtimeDir,
38
+ profile: path.join(runtimeDir, 'profile'),
39
+ chromeLog: path.join(runtimeDir, 'browser.log'),
40
+ chromePid: path.join(runtimeDir, 'browser.pid'),
41
+ };
42
+ }
43
+
44
+ function owned(target, env = process.env) {
45
+ const options = browserOptions(target, env);
46
+ return cdpOwner(options.cdpPort, options.profile);
47
+ }
48
+
49
+ /**
50
+ * Use existing browser ownership, CDP actions and whole-run recording for a plain website.
51
+ * @param {PlainWebAdapterConfig} config
52
+ * @returns {import('@farmslot/adapter-sdk').ProjectRuntime}
53
+ */
54
+ export function createPlainWebAdapter(config) {
55
+ const hint = 'Prepare the selected slot, then launch its plain web browser.';
56
+ async function page(target, env = process.env) {
57
+ const owner = owned(target, env);
58
+ const file = path.join(plainWebRuntimeDir(target), 'page.json');
59
+ const state = JSON.parse(fs.readFileSync(file, 'utf8'));
60
+ const selected = (await listCdpTargets('127.0.0.1', port(env))).find(
61
+ (entry) => entry.id === state.pageId && entry.type === 'page',
62
+ );
63
+ assertSameOwner(owner);
64
+ if (!selected) throw new Error(`The owned application page is unavailable. ${hint}`);
65
+ return selected;
66
+ }
67
+ /** @type {import('@farmslot/adapter-sdk').ProjectRuntime} */
68
+ const runtime = {
69
+ id: 'web',
70
+ sdkVersion: 1,
71
+ headless: false,
72
+ targets: ['web', 'cli'],
73
+ resolveSlotPorts() {},
74
+ async runtimeStatus(target) {
75
+ try {
76
+ await page(target);
77
+ const response = await fetch(config.url(target), { signal: AbortSignal.timeout(5000) });
78
+ await response.body?.cancel();
79
+ if (!response.ok) throw new Error(`Application returned HTTP ${response.status}.`);
80
+ return {
81
+ decision: 'ready',
82
+ reasons: [],
83
+ devServer: { label: config.serverLabel ?? 'website', status: 'up' },
84
+ };
85
+ } catch (error) {
86
+ return {
87
+ decision: 'blocked',
88
+ reasons: [error instanceof Error ? error.message : String(error)],
89
+ nextAction: hint,
90
+ };
91
+ }
92
+ },
93
+ devServer: {
94
+ label: 'browser',
95
+ describe: () => 'The owned website browser; the application server belongs to the project',
96
+ stop(target) {
97
+ stopPlainWebBrowser(target);
98
+ return {
99
+ kind: 'stopped',
100
+ status: 0,
101
+ summary: 'Website browser stopped; application server retained.',
102
+ };
103
+ },
104
+ },
105
+ logSources: (target) => [
106
+ { label: 'Browser', path: path.join(plainWebRuntimeDir(target), 'browser.log') },
107
+ ],
108
+ appLogSource: () => null,
109
+ hints: { launch: hint, relaunch: hint, runtimeProbeRecovery: () => hint },
110
+ harness: {
111
+ kind: 'built-in',
112
+ verify(target) {
113
+ return {
114
+ inProcess: async () => async (_args, io) => {
115
+ const status = await runtime.runtimeStatus(target);
116
+ if (status.decision !== 'ready') {
117
+ io.err(status.reasons.join('; '));
118
+ return 1;
119
+ }
120
+ io.out('ready');
121
+ return 0;
122
+ },
123
+ };
124
+ },
125
+ },
126
+ runtimeContext: { forbiddenFields: [] },
127
+ readiness: { captureProviders: ['cdp'], readyIndicator: () => 'ready' },
128
+ recording: {
129
+ async target(context) {
130
+ const owner = owned(context.projectRoot, context.env);
131
+ await page(context.projectRoot, context.env);
132
+ assertSameOwner(owner);
133
+ return { kind: 'pid', pid: owner.pid };
134
+ },
135
+ },
136
+ async launch({ target, jsonOutput }) {
137
+ const url = config.url(target);
138
+ const parsed = new URL(url);
139
+ if (!['http:', 'https:'].includes(parsed.protocol))
140
+ throw new Error('Expected an HTTP(S) application URL.');
141
+ const options = browserOptions(target);
142
+ const resolution = await resolveBrowser({
143
+ env: {
144
+ ...process.env,
145
+ RECIPE_HARNESS_BROWSER: process.env.RECIPE_HARNESS_BROWSER ?? 'chrome',
146
+ },
147
+ cft: () => ({
148
+ error:
149
+ 'Select installed Chrome or set RECIPE_HARNESS_CHROME_BIN to an installed Chromium executable.',
150
+ }),
151
+ cachePath: path.join(options.runtimeDir, 'browser-probe.json'),
152
+ launchMethod: process.platform === 'darwin' ? 'launch-services' : 'spawn',
153
+ display: 'headful',
154
+ });
155
+ launchBrowser({ ...options, chromeBin: resolution.bin, startUrl: url });
156
+ try {
157
+ const owner = owned(target);
158
+ const deadline = Date.now() + 15_000;
159
+ let candidates = [];
160
+ do {
161
+ candidates = (await listCdpTargets('127.0.0.1', port())).filter(
162
+ (entry) => entry.type === 'page' && entry.url.startsWith(`${parsed.origin}/`),
163
+ );
164
+ if (candidates.length) break;
165
+ await delay(100);
166
+ } while (Date.now() < deadline);
167
+ if (candidates.length !== 1)
168
+ throw new Error('Expected one application page in the owned browser.');
169
+ assertSameOwner(owner);
170
+ fs.writeFileSync(
171
+ path.join(options.runtimeDir, 'page.json'),
172
+ JSON.stringify({ pageId: candidates[0].id }),
173
+ { mode: 0o600 },
174
+ );
175
+ const status = await runtime.runtimeStatus(target);
176
+ if (status.decision !== 'ready') throw new Error(status.reasons.join('; '));
177
+ } catch (error) {
178
+ stopPlainWebBrowser(target);
179
+ throw error;
180
+ }
181
+ if (jsonOutput)
182
+ console.log(
183
+ JSON.stringify({
184
+ schemaVersion: 1,
185
+ command: 'launch',
186
+ status: 'pass',
187
+ adapter: 'web',
188
+ target,
189
+ url,
190
+ cdpPort: options.cdpPort,
191
+ }),
192
+ );
193
+ return 0;
194
+ },
195
+ actions: {
196
+ manifestPath: () => config.manifestPath,
197
+ semantic: [],
198
+ cdpTarget: { transport: 'cdp', probePath: '/json/version' },
199
+ ui: ({ createCdpWebUiTransport }) => ({
200
+ base: createCdpWebUiTransport({
201
+ getPage: async ({ context }) =>
202
+ CdpWebPage.connectToTarget(await page(context.projectRoot, context.env)),
203
+ }),
204
+ }),
205
+ },
206
+ };
207
+ return runtime;
208
+ }
209
+
210
+ /** Releases only the selected checkout's owned profile. The application server is retained. */
211
+ export function stopPlainWebBrowser(target) {
212
+ const runtimeDir = plainWebRuntimeDir(target);
213
+ // Stop also works without active port options, including a repeated closeout.
214
+ stopProfileProcessesSync(path.join(runtimeDir, 'profile'));
215
+ removeRuntimeIdentity(runtimeDir);
216
+ fs.rmSync(path.join(runtimeDir, 'browser.pid'), { force: true });
217
+ fs.rmSync(path.join(runtimeDir, 'page.json'), { force: true });
218
+ return { stopped: true };
219
+ }