@moonsurf/sdk 0.0.0-stage → 0.1.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/README.md +96 -2
- package/dist/index.d.ts +74 -0
- package/dist/index.js +175 -0
- package/package.json +26 -5
package/README.md
CHANGED
|
@@ -1,3 +1,97 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @moonsurf/sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Run Moonsurf recipes in the person's own browser from your web app, through the Moonsurf
|
|
4
|
+
extension. The person needs only the extension: no desktop app, no sign-in.
|
|
5
|
+
|
|
6
|
+
How it works (plan `2026-11-09-web-driven-runs`, in `chrome-extension/plans/`):
|
|
7
|
+
|
|
8
|
+
1. The person trusts your site once: from the Moonsurf button in their toolbar, or when your page
|
|
9
|
+
calls `requestTrust()` and they accept in the tab the extension opens.
|
|
10
|
+
2. Your page calls `run()` from a click. The extension opens a tab next to yours showing your site,
|
|
11
|
+
the sites the recipe opens, each step, and what it sends back.
|
|
12
|
+
3. If they choose **Allow and Run**, that same tab runs the steps, then closes, and your tab comes
|
|
13
|
+
back with the result. They can tick **Always allow this version** so the same recipe runs
|
|
14
|
+
without asking next time; any change to the recipe asks again.
|
|
15
|
+
|
|
16
|
+
Chromium browsers only (Chrome, Edge, Brave, …): the extension drives the tab with
|
|
17
|
+
`chrome.debugger`, which Firefox and Safari don't have.
|
|
18
|
+
|
|
19
|
+
## Use
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { moonsurf } from "@moonsurf/sdk";
|
|
23
|
+
|
|
24
|
+
const recipe = {
|
|
25
|
+
id: "crm-update-phone",
|
|
26
|
+
version: 3,
|
|
27
|
+
name: "Update a contact's phone in the CRM",
|
|
28
|
+
inputs: {
|
|
29
|
+
contactId: { type: "string", pattern: "^[0-9]{1,12}$" },
|
|
30
|
+
phone: { type: "string", maxLength: 40 },
|
|
31
|
+
},
|
|
32
|
+
steps: [
|
|
33
|
+
{ open: "https://crm.example.com/contacts/{contactId}" },
|
|
34
|
+
{ check: { selector: "form#contact", notSelector: "#login" } },
|
|
35
|
+
{ fill: { selector: "#phone", value: "{phone}" } },
|
|
36
|
+
{ click: { selector: "button[type=submit]" } },
|
|
37
|
+
{ wait: { selector: ".toast-success" } },
|
|
38
|
+
{ read: { selector: ".updated-at", as: "updatedAt" } },
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
button.onclick = async () => {
|
|
43
|
+
const status = await moonsurf.status(); // "missing" | "untrusted" | "ready"
|
|
44
|
+
if (status === "missing") return showInstallLink();
|
|
45
|
+
if (status === "untrusted") {
|
|
46
|
+
await moonsurf.requestTrust(); // "trusted" | "denied" | "blocked" | "dismissed"
|
|
47
|
+
return; // the next click runs: each run needs its own click
|
|
48
|
+
}
|
|
49
|
+
const r = await moonsurf.run({
|
|
50
|
+
requestId: `upd-${contactId}-${Date.now()}`,
|
|
51
|
+
recipe,
|
|
52
|
+
inputs: { contactId, phone },
|
|
53
|
+
onProgress: (p) => show(p), // { state: "awaiting_approval" } then { state: "running", step: 3, of: 6 }
|
|
54
|
+
});
|
|
55
|
+
if (r.status === "succeeded") save(r.outputs);
|
|
56
|
+
else toast(moonsurf.describe(r, "crm.example.com"));
|
|
57
|
+
};
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
- **Recipes** are Moonsurf's format v1 (`relay/protocol/PROTOCOL.md` section 11): 8 step kinds
|
|
61
|
+
(`open`, `wait`, `check`, `fill`, `select`, `click`, `press`, `read`), https sites only, at most
|
|
62
|
+
100 steps and 2 minutes. There are no scripts, cookies, screenshots or downloads.
|
|
63
|
+
- **`run()` and `requestTrust()` need a click or key press** just before (the page's user
|
|
64
|
+
activation). Without one the answer is `needs_user_gesture`.
|
|
65
|
+
- **`requestId`** is yours: `[A-Za-z0-9_-]{1,128}`, unique for your site. A repeat never runs
|
|
66
|
+
twice; it gets the first result. `result(requestId)` gets a result back for 24 hours, e.g. after
|
|
67
|
+
a reload.
|
|
68
|
+
- **One run at a time** per browser; another gets `busy`.
|
|
69
|
+
- A refusal or a failure is a **result**, not an exception. `MoonsurfError` means the extension
|
|
70
|
+
isn't there or was updated since the page loaded.
|
|
71
|
+
|
|
72
|
+
## Results
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{ "v": 1, "requestId": "upd-8812-1731122400", "status": "succeeded", "recipeHash": "sha256-…",
|
|
76
|
+
"outputs": { "updatedAt": "Updated 10:42" }, "startedAt": "…", "finishedAt": "…" }
|
|
77
|
+
{ "v": 1, "requestId": "…", "status": "failed", "recipeHash": "…", "failedStep": 1, "code": "needs_sign_in", "startedAt": "…", "finishedAt": "…" }
|
|
78
|
+
{ "v": 1, "requestId": "…", "status": "refused", "reason": "user_denied", "finishedAt": "…" }
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Failure codes: `needs_sign_in`, `check_failed`, `not_found`, `step_timeout`, `job_timeout`,
|
|
82
|
+
`left_allowed_site`, `browser_unavailable`, `tool_error`, `outputs_too_large`, `tab_closed`,
|
|
83
|
+
`detached`. Refusals: `not_trusted`, `needs_user_gesture`, `request_invalid`, `busy`,
|
|
84
|
+
`recipe_invalid`, `host_not_allowed`, `inputs_invalid`, `input_not_allowed`, `user_denied`,
|
|
85
|
+
`prompt_expired`, `unsupported_version`. `describe(result, site)` turns each into a sentence.
|
|
86
|
+
|
|
87
|
+
## Develop
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
npm install
|
|
91
|
+
npm run build # dist/
|
|
92
|
+
npm run vendor # copies src/index.ts into moonsurf-web for its /extension/demo page
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`demo/index.html` is a plain page for trying it with a development build of the extension
|
|
96
|
+
(`npm run build:dev` in `chrome-extension`, which also accepts `http://localhost`); the extension's
|
|
97
|
+
`npm run test:e2e` serves it and drives it in Chromium.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export type Status = 'missing' | 'untrusted' | 'ready';
|
|
2
|
+
export type TrustResult = 'trusted' | 'denied' | 'blocked' | 'dismissed' | 'needs_user_gesture';
|
|
3
|
+
export type FailureCode = 'needs_sign_in' | 'check_failed' | 'not_found' | 'step_timeout' | 'job_timeout' | 'left_allowed_site' | 'browser_unavailable' | 'tool_error' | 'outputs_too_large' | 'tab_closed' | 'detached';
|
|
4
|
+
export type RefusalReason = 'not_trusted' | 'needs_user_gesture' | 'request_invalid' | 'job_seen' | 'busy' | 'recipe_invalid' | 'host_not_allowed' | 'inputs_invalid' | 'input_not_allowed' | 'user_denied' | 'prompt_expired' | 'unsupported_version';
|
|
5
|
+
export type RunResult = {
|
|
6
|
+
v: 1;
|
|
7
|
+
requestId: string;
|
|
8
|
+
status: 'succeeded';
|
|
9
|
+
recipeHash: string;
|
|
10
|
+
outputs: Record<string, string>;
|
|
11
|
+
startedAt: string;
|
|
12
|
+
finishedAt: string;
|
|
13
|
+
} | {
|
|
14
|
+
v: 1;
|
|
15
|
+
requestId: string;
|
|
16
|
+
status: 'failed';
|
|
17
|
+
recipeHash: string;
|
|
18
|
+
failedStep: number;
|
|
19
|
+
code: FailureCode | (string & {});
|
|
20
|
+
startedAt: string;
|
|
21
|
+
finishedAt: string;
|
|
22
|
+
} | {
|
|
23
|
+
v: 1;
|
|
24
|
+
requestId: string;
|
|
25
|
+
status: 'refused';
|
|
26
|
+
reason: RefusalReason | (string & {});
|
|
27
|
+
recipeHash?: string;
|
|
28
|
+
finishedAt: string;
|
|
29
|
+
};
|
|
30
|
+
export interface Progress {
|
|
31
|
+
state: 'awaiting_approval' | 'running';
|
|
32
|
+
/** 1-based step being run, with `of` steps in all (while running). */
|
|
33
|
+
step?: number;
|
|
34
|
+
of?: number;
|
|
35
|
+
}
|
|
36
|
+
export interface RunOptions {
|
|
37
|
+
/** Your own ID for this run, `[A-Za-z0-9_-]{1,128}`, unique for your site. A repeat never runs twice: it gets the first result. */
|
|
38
|
+
requestId: string;
|
|
39
|
+
/** A recipe in Moonsurf's format v1. */
|
|
40
|
+
recipe: unknown;
|
|
41
|
+
/** The recipe's inputs, all strings. */
|
|
42
|
+
inputs?: Record<string, string>;
|
|
43
|
+
onProgress?: (p: Progress) => void;
|
|
44
|
+
}
|
|
45
|
+
/** The extension isn't there, was updated since the page loaded, or gave an answer this SDK doesn't know. */
|
|
46
|
+
export declare class MoonsurfError extends Error {
|
|
47
|
+
readonly code: string;
|
|
48
|
+
constructor(code: string);
|
|
49
|
+
}
|
|
50
|
+
/** Whether the extension is installed, and whether it trusts this site. Resolves in at most `timeoutMs`. */
|
|
51
|
+
declare function status(timeoutMs?: number): Promise<Status>;
|
|
52
|
+
/**
|
|
53
|
+
* Asks the person to trust this site: the extension opens a tab next to this one. Call it from a
|
|
54
|
+
* click or key handler. Resolves with their answer once they give it.
|
|
55
|
+
*/
|
|
56
|
+
declare function requestTrust(): Promise<TrustResult>;
|
|
57
|
+
/** A run's stored result (kept 24 hours), e.g. after a reload. Null when the extension doesn't know it. */
|
|
58
|
+
declare function result(requestId: string): Promise<RunResult | null>;
|
|
59
|
+
/**
|
|
60
|
+
* Runs a recipe. Call it from a click or key handler: the extension shows its approval tab (unless
|
|
61
|
+
* the person always allows this version), runs the steps in that tab, closes it and resolves with
|
|
62
|
+
* the result. A refusal or a failure is a result, not an exception.
|
|
63
|
+
*/
|
|
64
|
+
declare function run(opts: RunOptions): Promise<RunResult>;
|
|
65
|
+
/** A sentence for the person about a result. `{site}` becomes the recipe's site when you pass it. */
|
|
66
|
+
declare function describe(r: RunResult, site?: string): string;
|
|
67
|
+
export declare const moonsurf: {
|
|
68
|
+
status: typeof status;
|
|
69
|
+
requestTrust: typeof requestTrust;
|
|
70
|
+
run: typeof run;
|
|
71
|
+
result: typeof result;
|
|
72
|
+
describe: typeof describe;
|
|
73
|
+
};
|
|
74
|
+
export default moonsurf;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// @moonsurf/sdk: lets a web page run recipes in the person's browser through the Moonsurf
|
|
2
|
+
// extension (plan 2026-11-09-web-driven-runs). No dependencies; talks to the extension's bridge
|
|
3
|
+
// content script with window.postMessage.
|
|
4
|
+
//
|
|
5
|
+
// const s = await moonsurf.status(); // 'missing' | 'untrusted' | 'ready'
|
|
6
|
+
// await moonsurf.requestTrust(); // from a click: opens the extension's trust tab
|
|
7
|
+
// const r = await moonsurf.run({ requestId, recipe, inputs, onProgress }); // from a click
|
|
8
|
+
// moonsurf.describe(r) // a sentence for the person
|
|
9
|
+
/** The extension isn't there, was updated since the page loaded, or gave an answer this SDK doesn't know. */
|
|
10
|
+
export class MoonsurfError extends Error {
|
|
11
|
+
constructor(code) {
|
|
12
|
+
super(`moonsurf: ${code}`);
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.name = 'MoonsurfError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const pending = new Map();
|
|
18
|
+
const listeners = new Set();
|
|
19
|
+
let counter = 0;
|
|
20
|
+
let listening = false;
|
|
21
|
+
function listen() {
|
|
22
|
+
if (listening || typeof window === 'undefined')
|
|
23
|
+
return;
|
|
24
|
+
listening = true;
|
|
25
|
+
window.addEventListener('message', (event) => {
|
|
26
|
+
if (event.source !== window || event.origin !== location.origin)
|
|
27
|
+
return;
|
|
28
|
+
const d = event.data;
|
|
29
|
+
if (d?.moonsurf !== 1 || d.dir !== 'ext')
|
|
30
|
+
return;
|
|
31
|
+
if (typeof d.re === 'string') {
|
|
32
|
+
const done = pending.get(d.re);
|
|
33
|
+
if (done) {
|
|
34
|
+
pending.delete(d.re);
|
|
35
|
+
done((d.reply ?? {}));
|
|
36
|
+
}
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (d.event && typeof d.event === 'object')
|
|
40
|
+
for (const l of [...listeners])
|
|
41
|
+
l(d.event);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/** Sends one message to the bridge; resolves with its reply, or null after `timeoutMs`. */
|
|
45
|
+
function send(type, fields = {}, timeoutMs) {
|
|
46
|
+
listen();
|
|
47
|
+
const id = `m${Date.now().toString(36)}${(counter++).toString(36)}`;
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
let timer;
|
|
50
|
+
pending.set(id, (reply) => {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
resolve(reply);
|
|
53
|
+
});
|
|
54
|
+
if (timeoutMs !== undefined) {
|
|
55
|
+
timer = setTimeout(() => {
|
|
56
|
+
pending.delete(id);
|
|
57
|
+
resolve(null);
|
|
58
|
+
}, timeoutMs);
|
|
59
|
+
}
|
|
60
|
+
window.postMessage({ moonsurf: 1, dir: 'page', id, type, ...fields }, location.origin);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function check(reply) {
|
|
64
|
+
if (reply === null)
|
|
65
|
+
throw new MoonsurfError('extension_missing');
|
|
66
|
+
if (typeof reply.error === 'string')
|
|
67
|
+
throw new MoonsurfError(reply.error);
|
|
68
|
+
return reply;
|
|
69
|
+
}
|
|
70
|
+
/** Whether the extension is installed, and whether it trusts this site. Resolves in at most `timeoutMs`. */
|
|
71
|
+
async function status(timeoutMs = 300) {
|
|
72
|
+
const r = await send('hello', {}, timeoutMs);
|
|
73
|
+
if (r === null || typeof r.error === 'string')
|
|
74
|
+
return 'missing';
|
|
75
|
+
return r.trusted === true ? 'ready' : 'untrusted';
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Asks the person to trust this site: the extension opens a tab next to this one. Call it from a
|
|
79
|
+
* click or key handler. Resolves with their answer once they give it.
|
|
80
|
+
*/
|
|
81
|
+
async function requestTrust() {
|
|
82
|
+
listen();
|
|
83
|
+
let decided;
|
|
84
|
+
const decision = new Promise((resolve) => (decided = resolve));
|
|
85
|
+
const onEvent = (e) => {
|
|
86
|
+
if (e.kind === 'trust' && e.origin === location.origin)
|
|
87
|
+
decided(e.status);
|
|
88
|
+
};
|
|
89
|
+
listeners.add(onEvent);
|
|
90
|
+
try {
|
|
91
|
+
const r = check(await send('requestTrust'));
|
|
92
|
+
if (r.status === 'pending')
|
|
93
|
+
return await decision;
|
|
94
|
+
return r.status;
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
listeners.delete(onEvent);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** A run's stored result (kept 24 hours), e.g. after a reload. Null when the extension doesn't know it. */
|
|
101
|
+
async function result(requestId) {
|
|
102
|
+
const r = check(await send('result', { requestId }, 5000));
|
|
103
|
+
return r.result ?? null;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Runs a recipe. Call it from a click or key handler: the extension shows its approval tab (unless
|
|
107
|
+
* the person always allows this version), runs the steps in that tab, closes it and resolves with
|
|
108
|
+
* the result. A refusal or a failure is a result, not an exception.
|
|
109
|
+
*/
|
|
110
|
+
async function run(opts) {
|
|
111
|
+
listen();
|
|
112
|
+
let finish;
|
|
113
|
+
const finished = new Promise((resolve) => (finish = resolve));
|
|
114
|
+
const onEvent = (e) => {
|
|
115
|
+
if (e.kind !== 'run' || e.requestId !== opts.requestId)
|
|
116
|
+
return;
|
|
117
|
+
if (e.event === 'result')
|
|
118
|
+
finish(e.result);
|
|
119
|
+
else
|
|
120
|
+
opts.onProgress?.({ state: e.state, ...(e.step !== undefined ? { step: e.step, of: e.of } : {}) });
|
|
121
|
+
};
|
|
122
|
+
listeners.add(onEvent);
|
|
123
|
+
let poll;
|
|
124
|
+
try {
|
|
125
|
+
const r = check(await send('run', { requestId: opts.requestId, recipe: opts.recipe, inputs: opts.inputs ?? {} }));
|
|
126
|
+
if (r.result)
|
|
127
|
+
return r.result;
|
|
128
|
+
if (!r.accepted && !r.inProgress)
|
|
129
|
+
throw new MoonsurfError('unexpected_reply');
|
|
130
|
+
if (r.accepted)
|
|
131
|
+
opts.onProgress?.({ state: r.state === 'running' ? 'running' : 'awaiting_approval' });
|
|
132
|
+
// The result comes as an event; asking now and then covers a missed one.
|
|
133
|
+
poll = setInterval(() => {
|
|
134
|
+
void result(opts.requestId).then((x) => x && finish(x), () => { });
|
|
135
|
+
}, 3000);
|
|
136
|
+
return await finished;
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
clearInterval(poll);
|
|
140
|
+
listeners.delete(onEvent);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const SENTENCES = {
|
|
144
|
+
succeeded: 'Done.',
|
|
145
|
+
needs_sign_in: 'Sign in to {site}, then try again.',
|
|
146
|
+
check_failed: '{site} didn’t show the page the automation expected.',
|
|
147
|
+
not_found: 'Something the automation needed wasn’t on the page at {site}.',
|
|
148
|
+
step_timeout: '{site} took too long to respond.',
|
|
149
|
+
job_timeout: 'The automation took longer than 2 minutes and was stopped.',
|
|
150
|
+
left_allowed_site: 'The page went to a site the automation isn’t allowed to use, so it stopped.',
|
|
151
|
+
browser_unavailable: 'The browser tab went away before the automation finished.',
|
|
152
|
+
tool_error: 'The browser couldn’t do one of the steps.',
|
|
153
|
+
outputs_too_large: 'The automation read more than it may send back.',
|
|
154
|
+
tab_closed: 'The tab was closed before the automation finished.',
|
|
155
|
+
detached: 'The automation was stopped from Chrome’s debugging bar.',
|
|
156
|
+
not_trusted: 'Moonsurf doesn’t trust this site yet.',
|
|
157
|
+
needs_user_gesture: 'Start the automation with a click.',
|
|
158
|
+
busy: 'Another automation is running in this browser. Try again when it ends.',
|
|
159
|
+
user_denied: 'The automation wasn’t allowed.',
|
|
160
|
+
prompt_expired: 'The request waited 5 minutes without an answer.',
|
|
161
|
+
recipe_invalid: 'This automation isn’t valid.',
|
|
162
|
+
host_not_allowed: 'This automation uses a site Moonsurf doesn’t allow.',
|
|
163
|
+
inputs_invalid: 'Some of the values given to the automation aren’t valid.',
|
|
164
|
+
input_not_allowed: 'Some of the values given to the automation aren’t valid.',
|
|
165
|
+
request_invalid: 'The request ID isn’t valid.',
|
|
166
|
+
job_seen: 'This request already ran.',
|
|
167
|
+
unsupported_version: 'This version of the extension can’t run this request.',
|
|
168
|
+
};
|
|
169
|
+
/** A sentence for the person about a result. `{site}` becomes the recipe's site when you pass it. */
|
|
170
|
+
function describe(r, site = 'the site') {
|
|
171
|
+
const key = r.status === 'succeeded' ? 'succeeded' : r.status === 'failed' ? r.code : r.reason;
|
|
172
|
+
return (SENTENCES[key] ?? 'The automation didn’t finish.').replace('{site}', site);
|
|
173
|
+
}
|
|
174
|
+
export const moonsurf = { status, requestTrust, run, result, describe };
|
|
175
|
+
export default moonsurf;
|
package/package.json
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@moonsurf/sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Run Moonsurf recipes in the person's browser from your web app, through the Moonsurf extension, with their approval.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"license": "UNLICENSED",
|
|
19
|
+
"homepage": "https://moonsurf.ai",
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p .",
|
|
22
|
+
"vendor": "node scripts/vendor.mjs"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"typescript": "5.7.3"
|
|
26
|
+
}
|
|
27
|
+
}
|