@raidiant/notifai 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/LICENSE +202 -0
- package/README.md +76 -0
- package/dist/client.d.ts +40 -0
- package/dist/client.js +118 -0
- package/dist/client.js.map +1 -0
- package/dist/commands.d.ts +225 -0
- package/dist/commands.js +1855 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.d.ts +138 -0
- package/dist/config.js +224 -0
- package/dist/config.js.map +1 -0
- package/dist/credentials.d.ts +31 -0
- package/dist/credentials.js +94 -0
- package/dist/credentials.js.map +1 -0
- package/dist/hooks.d.ts +250 -0
- package/dist/hooks.js +792 -0
- package/dist/hooks.js.map +1 -0
- package/dist/idle.d.ts +16 -0
- package/dist/idle.js +44 -0
- package/dist/idle.js.map +1 -0
- package/dist/install-hooks.d.ts +194 -0
- package/dist/install-hooks.js +579 -0
- package/dist/install-hooks.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +259 -0
- package/dist/main.js.map +1 -0
- package/dist/opencode-plugin.d.ts +65 -0
- package/dist/opencode-plugin.js +181 -0
- package/dist/opencode-plugin.js.map +1 -0
- package/dist/readiness.d.ts +87 -0
- package/dist/readiness.js +40 -0
- package/dist/readiness.js.map +1 -0
- package/dist/send.d.ts +86 -0
- package/dist/send.js +202 -0
- package/dist/send.js.map +1 -0
- package/package.json +56 -0
- package/src/client.test.ts +83 -0
- package/src/client.ts +183 -0
- package/src/commands.test.ts +1199 -0
- package/src/commands.ts +2201 -0
- package/src/config.test.ts +243 -0
- package/src/config.ts +347 -0
- package/src/credentials.ts +119 -0
- package/src/hooks.test.ts +1030 -0
- package/src/hooks.ts +1099 -0
- package/src/idle.test.ts +32 -0
- package/src/idle.ts +41 -0
- package/src/install-hooks.test.ts +500 -0
- package/src/install-hooks.ts +708 -0
- package/src/main.ts +315 -0
- package/src/opencode-plugin.ts +203 -0
- package/src/readiness.ts +88 -0
- package/src/send.ts +272 -0
- package/tsconfig.json +10 -0
package/dist/send.d.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { type LifecycleT, type NotificationDraftT, type Platform, type SubmissionReceipt } from '@raidiant/notifai-protocol';
|
|
2
|
+
import type { CliConfig } from './config.js';
|
|
3
|
+
export interface SendFlags {
|
|
4
|
+
title: string;
|
|
5
|
+
body: string;
|
|
6
|
+
subtitle?: string;
|
|
7
|
+
/** Long-form markdown for the app's detail view; never on the banner. */
|
|
8
|
+
detail?: string;
|
|
9
|
+
event?: string;
|
|
10
|
+
/** What this notification is: update (default), done, or question. */
|
|
11
|
+
kind?: string;
|
|
12
|
+
project?: string;
|
|
13
|
+
session?: string;
|
|
14
|
+
device?: string[];
|
|
15
|
+
all?: boolean;
|
|
16
|
+
ttl?: number;
|
|
17
|
+
collapseKey?: string;
|
|
18
|
+
sound?: string;
|
|
19
|
+
badge?: number;
|
|
20
|
+
threadId?: string;
|
|
21
|
+
level?: string;
|
|
22
|
+
relevance?: number;
|
|
23
|
+
targetContentId?: string;
|
|
24
|
+
data?: string[];
|
|
25
|
+
image?: string;
|
|
26
|
+
/** Enable the inline reply action for this Notification Request. */
|
|
27
|
+
reply?: boolean;
|
|
28
|
+
/** How long the server accepts a reply after submission. */
|
|
29
|
+
replyWindow?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Answer labels; turns the reply into a closed question. One value splits on
|
|
32
|
+
* commas; repeating the flag passes each label through verbatim.
|
|
33
|
+
*/
|
|
34
|
+
replyChoice?: string | string[];
|
|
35
|
+
/** Platform whose optional notification fields these flags configure. */
|
|
36
|
+
platform?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Lifecycle position of this send; not a user-facing flag. Hooks set it so
|
|
39
|
+
* questions and their retirements ride the wire as what they are.
|
|
40
|
+
*/
|
|
41
|
+
lifecycle?: LifecycleT;
|
|
42
|
+
}
|
|
43
|
+
export type DraftBuild = {
|
|
44
|
+
ok: true;
|
|
45
|
+
draft: NotificationDraftT;
|
|
46
|
+
platform: Platform;
|
|
47
|
+
} | {
|
|
48
|
+
ok: false;
|
|
49
|
+
error: string;
|
|
50
|
+
};
|
|
51
|
+
/** Merge flags over resolved config into a Notification Request draft. */
|
|
52
|
+
export declare function buildDraft(config: CliConfig, flags: SendFlags): DraftBuild;
|
|
53
|
+
/** Human one-screen receipt summary; agents use --json instead. */
|
|
54
|
+
export declare function formatReceipt(receipt: SubmissionReceipt): string;
|
|
55
|
+
/** Stable exit codes: 0 ok/pending, 1 rejected everywhere. */
|
|
56
|
+
export declare function receiptExitCode(receipt: SubmissionReceipt): number;
|
|
57
|
+
/**
|
|
58
|
+
* `"Staging,Production"` -> `[{id:'staging',label:'Staging'}, ...]`.
|
|
59
|
+
*
|
|
60
|
+
* A single occurrence splits on commas, which keeps the common one-liner
|
|
61
|
+
* short. Repeating the flag is the escape hatch, and the only way to write a
|
|
62
|
+
* label that itself contains a comma: passing `"Yes, ship it,No"` as one value
|
|
63
|
+
* silently yields three choices, and nothing downstream can detect that the
|
|
64
|
+
* agent meant two.
|
|
65
|
+
*/
|
|
66
|
+
export declare function parseChoices(value: string | string[] | undefined): {
|
|
67
|
+
id: string;
|
|
68
|
+
label: string;
|
|
69
|
+
}[] | null | 'invalid';
|
|
70
|
+
/** Message shared by every surface that accepts choice labels. */
|
|
71
|
+
export declare const CHOICE_USAGE: string;
|
|
72
|
+
/**
|
|
73
|
+
* Catches the split that looked like it worked.
|
|
74
|
+
*
|
|
75
|
+
* `--choice "macOS parity,Long-form detail,Nothing, I'll review"` produced four
|
|
76
|
+
* buttons rather than three, because the third label contained a comma. Nothing
|
|
77
|
+
* downstream can detect it — the labels are all well-formed — and the CLI
|
|
78
|
+
* cheerfully echoed the wrong list back, so the user saw buttons the agent
|
|
79
|
+
* never intended.
|
|
80
|
+
*
|
|
81
|
+
* The tell is a comma followed by a space. A delimiter list is written
|
|
82
|
+
* "Yes,No"; prose is written "Nothing, I'll review". That is a heuristic and it
|
|
83
|
+
* will occasionally be wrong, which is exactly why this warns and names the
|
|
84
|
+
* escape hatch rather than rejecting: the split may well have been intended.
|
|
85
|
+
*/
|
|
86
|
+
export declare function ambiguousChoiceSplit(value: string | string[] | undefined): string | null;
|
package/dist/send.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { CLI_SOUNDS, INTERRUPTION_LEVELS, NOTIFICATION_SCHEMA_VERSION, NOTIFICATION_KINDS, PLATFORMS, } from '@raidiant/notifai-protocol';
|
|
2
|
+
/** Merge flags over resolved config into a Notification Request draft. */
|
|
3
|
+
export function buildDraft(config, flags) {
|
|
4
|
+
if (!flags.title || !flags.body)
|
|
5
|
+
return { ok: false, error: 'Both --title and --body are required.' };
|
|
6
|
+
const platform = resolvePlatform(flags.platform);
|
|
7
|
+
if (!platform) {
|
|
8
|
+
return { ok: false, error: `Unknown platform "${flags.platform}" — use ${PLATFORMS.join(' or ')}.` };
|
|
9
|
+
}
|
|
10
|
+
const deviceIds = flags.all ? null : (flags.device?.length ? flags.device : config.devices.value);
|
|
11
|
+
const targets = deviceIds && deviceIds.length > 0 ? { mode: 'selected', device_ids: deviceIds } : { mode: 'all' };
|
|
12
|
+
const sound = flags.sound ?? config.sound.value;
|
|
13
|
+
if (sound !== null && sound !== undefined && !CLI_SOUNDS.includes(sound)) {
|
|
14
|
+
return {
|
|
15
|
+
ok: false,
|
|
16
|
+
error: `Unknown sound "${sound}" — supported: ${CLI_SOUNDS.map((value) => `"${value}"`).join(', ')}.`,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
const level = flags.level ?? config.interruption_level.value;
|
|
20
|
+
if (level !== null &&
|
|
21
|
+
level !== undefined &&
|
|
22
|
+
!INTERRUPTION_LEVELS.includes(level)) {
|
|
23
|
+
return { ok: false, error: `Unknown interruption level "${level}".` };
|
|
24
|
+
}
|
|
25
|
+
const options = {};
|
|
26
|
+
if (sound === 'none')
|
|
27
|
+
options.sound = null;
|
|
28
|
+
else if (sound !== null && sound !== undefined) {
|
|
29
|
+
options.sound = sound;
|
|
30
|
+
}
|
|
31
|
+
if (flags.badge !== undefined)
|
|
32
|
+
options.badge = flags.badge;
|
|
33
|
+
if (flags.threadId !== undefined)
|
|
34
|
+
options.thread_id = flags.threadId;
|
|
35
|
+
if (level !== null && level !== undefined) {
|
|
36
|
+
options.interruption_level = level;
|
|
37
|
+
}
|
|
38
|
+
if (flags.relevance !== undefined)
|
|
39
|
+
options.relevance_score = flags.relevance;
|
|
40
|
+
if (flags.targetContentId !== undefined)
|
|
41
|
+
options.target_content_id = flags.targetContentId;
|
|
42
|
+
if (flags.data?.length) {
|
|
43
|
+
const data = {};
|
|
44
|
+
for (const pair of flags.data) {
|
|
45
|
+
const eq = pair.indexOf('=');
|
|
46
|
+
if (eq <= 0)
|
|
47
|
+
return { ok: false, error: `--data expects key=value, got "${pair}".` };
|
|
48
|
+
data[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
49
|
+
}
|
|
50
|
+
options.custom_data = data;
|
|
51
|
+
}
|
|
52
|
+
if (flags.kind !== undefined &&
|
|
53
|
+
!NOTIFICATION_KINDS.includes(flags.kind)) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
error: `Unknown kind "${flags.kind}" — supported: ${NOTIFICATION_KINDS.map((kind) => `"${kind}"`).join(', ')}.`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const ttl = flags.ttl ?? config.ttl_seconds.value;
|
|
60
|
+
const collapse = flags.collapseKey ?? config.collapse_key.value;
|
|
61
|
+
const project = flags.project ?? config.project.value;
|
|
62
|
+
// Harnesses can export NOTIFAI_SESSION once instead of passing the flag
|
|
63
|
+
// on every send.
|
|
64
|
+
const session = flags.session ?? process.env['NOTIFAI_SESSION'];
|
|
65
|
+
// Ids are derived from labels so an agent writing a one-liner does not have
|
|
66
|
+
// to invent them, but they stay the stable thing it branches on afterwards.
|
|
67
|
+
const choices = parseChoices(flags.replyChoice);
|
|
68
|
+
if (choices === 'invalid') {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
error: CHOICE_USAGE,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const draft = {
|
|
75
|
+
schema_version: NOTIFICATION_SCHEMA_VERSION,
|
|
76
|
+
...(flags.event !== undefined ? { event: flags.event } : {}),
|
|
77
|
+
...(flags.kind !== undefined
|
|
78
|
+
? { kind: flags.kind }
|
|
79
|
+
: {}),
|
|
80
|
+
...(flags.lifecycle !== undefined ? { lifecycle: flags.lifecycle } : {}),
|
|
81
|
+
...(project !== null && project !== undefined ? { project } : {}),
|
|
82
|
+
...(session !== undefined && session !== '' ? { session } : {}),
|
|
83
|
+
presentation: {
|
|
84
|
+
title: flags.title,
|
|
85
|
+
body: flags.body,
|
|
86
|
+
...(flags.subtitle !== undefined ? { subtitle: flags.subtitle } : {}),
|
|
87
|
+
...(flags.detail !== undefined && flags.detail.trim() !== ''
|
|
88
|
+
? { detail: flags.detail }
|
|
89
|
+
: {}),
|
|
90
|
+
...(flags.image !== undefined ? { image: { media_id: flags.image } } : {}),
|
|
91
|
+
},
|
|
92
|
+
targets,
|
|
93
|
+
delivery: { ttl_seconds: ttl, collapse_key: collapse },
|
|
94
|
+
...(flags.reply
|
|
95
|
+
? {
|
|
96
|
+
reply: {
|
|
97
|
+
expires_in_seconds: flags.replyWindow ?? 86400,
|
|
98
|
+
...(choices !== null ? { kind: 'choice', choices } : {}),
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
: {}),
|
|
102
|
+
...(Object.keys(options).length > 0
|
|
103
|
+
? platform === 'ios'
|
|
104
|
+
? { platform: { ios: options } }
|
|
105
|
+
: { platform: { macos: options } }
|
|
106
|
+
: {}),
|
|
107
|
+
};
|
|
108
|
+
return { ok: true, draft, platform };
|
|
109
|
+
}
|
|
110
|
+
function resolvePlatform(value) {
|
|
111
|
+
if (value === undefined)
|
|
112
|
+
return 'ios';
|
|
113
|
+
return PLATFORMS.includes(value) ? value : null;
|
|
114
|
+
}
|
|
115
|
+
/** Human one-screen receipt summary; agents use --json instead. */
|
|
116
|
+
export function formatReceipt(receipt) {
|
|
117
|
+
const lines = [];
|
|
118
|
+
const overall = receipt.overall === 'provider_accepted_all'
|
|
119
|
+
? 'accepted by the push provider for every device'
|
|
120
|
+
: receipt.overall === 'provider_accepted_partial'
|
|
121
|
+
? 'accepted for some devices'
|
|
122
|
+
: receipt.overall === 'provider_rejected_all'
|
|
123
|
+
? 'rejected for every device'
|
|
124
|
+
: 'still in flight';
|
|
125
|
+
lines.push(`request ${receipt.request_id}${receipt.replayed ? ' (replayed)' : ''}: ${overall}`);
|
|
126
|
+
for (const d of receipt.deliveries) {
|
|
127
|
+
const detail = d.provider_reason ? ` (${d.provider_reason})` : '';
|
|
128
|
+
lines.push(` ${d.device_name}: ${d.state}${detail}`);
|
|
129
|
+
}
|
|
130
|
+
for (const w of receipt.warnings)
|
|
131
|
+
lines.push(` warning ${w.path}: ${w.message}`);
|
|
132
|
+
return lines.join('\n');
|
|
133
|
+
}
|
|
134
|
+
/** Stable exit codes: 0 ok/pending, 1 rejected everywhere. */
|
|
135
|
+
export function receiptExitCode(receipt) {
|
|
136
|
+
return receipt.overall === 'provider_rejected_all' ? 1 : 0;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* `"Staging,Production"` -> `[{id:'staging',label:'Staging'}, ...]`.
|
|
140
|
+
*
|
|
141
|
+
* A single occurrence splits on commas, which keeps the common one-liner
|
|
142
|
+
* short. Repeating the flag is the escape hatch, and the only way to write a
|
|
143
|
+
* label that itself contains a comma: passing `"Yes, ship it,No"` as one value
|
|
144
|
+
* silently yields three choices, and nothing downstream can detect that the
|
|
145
|
+
* agent meant two.
|
|
146
|
+
*/
|
|
147
|
+
export function parseChoices(value) {
|
|
148
|
+
if (value === undefined)
|
|
149
|
+
return null;
|
|
150
|
+
const raw = Array.isArray(value) ? value : [value];
|
|
151
|
+
if (raw.length === 0)
|
|
152
|
+
return null;
|
|
153
|
+
const labels = (raw.length === 1 ? (raw[0] ?? '').split(',') : raw)
|
|
154
|
+
.map((part) => part.trim())
|
|
155
|
+
.filter((part) => part.length > 0);
|
|
156
|
+
if (labels.length < 2 || labels.length > 6)
|
|
157
|
+
return 'invalid';
|
|
158
|
+
const choices = labels.map((label) => ({ id: slugify(label), label }));
|
|
159
|
+
if (choices.some((choice) => choice.id === ''))
|
|
160
|
+
return 'invalid';
|
|
161
|
+
if (new Set(choices.map((choice) => choice.id)).size !== choices.length)
|
|
162
|
+
return 'invalid';
|
|
163
|
+
return choices;
|
|
164
|
+
}
|
|
165
|
+
/** Message shared by every surface that accepts choice labels. */
|
|
166
|
+
export const CHOICE_USAGE = 'Choices must be 2-6 non-empty labels, unique once slugified. One value splits on ' +
|
|
167
|
+
'commas ("Staging,Production"); repeat the flag when a label contains a comma.';
|
|
168
|
+
/**
|
|
169
|
+
* Catches the split that looked like it worked.
|
|
170
|
+
*
|
|
171
|
+
* `--choice "macOS parity,Long-form detail,Nothing, I'll review"` produced four
|
|
172
|
+
* buttons rather than three, because the third label contained a comma. Nothing
|
|
173
|
+
* downstream can detect it — the labels are all well-formed — and the CLI
|
|
174
|
+
* cheerfully echoed the wrong list back, so the user saw buttons the agent
|
|
175
|
+
* never intended.
|
|
176
|
+
*
|
|
177
|
+
* The tell is a comma followed by a space. A delimiter list is written
|
|
178
|
+
* "Yes,No"; prose is written "Nothing, I'll review". That is a heuristic and it
|
|
179
|
+
* will occasionally be wrong, which is exactly why this warns and names the
|
|
180
|
+
* escape hatch rather than rejecting: the split may well have been intended.
|
|
181
|
+
*/
|
|
182
|
+
export function ambiguousChoiceSplit(value) {
|
|
183
|
+
if (value === undefined || Array.isArray(value))
|
|
184
|
+
return null;
|
|
185
|
+
if (!/,\s/.test(value))
|
|
186
|
+
return null;
|
|
187
|
+
const parts = value
|
|
188
|
+
.split(',')
|
|
189
|
+
.map((part) => part.trim())
|
|
190
|
+
.filter((part) => part.length > 0);
|
|
191
|
+
return (`Heads up: "${value}" was split on commas into ${parts.length} answers ` +
|
|
192
|
+
`(${parts.join(' / ')}). If one of those labels was meant to contain a comma, ` +
|
|
193
|
+
'pass --choice once per answer instead.');
|
|
194
|
+
}
|
|
195
|
+
function slugify(label) {
|
|
196
|
+
return label
|
|
197
|
+
.toLowerCase()
|
|
198
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
199
|
+
.replace(/^-+|-+$/g, '')
|
|
200
|
+
.slice(0, 32);
|
|
201
|
+
}
|
|
202
|
+
//# sourceMappingURL=send.js.map
|
package/dist/send.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"send.js","sourceRoot":"","sources":["../src/send.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@raidiant/notifai",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Send native phone notifications from agents and local programs",
|
|
6
|
+
"bin": {
|
|
7
|
+
"notifai": "dist/main.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"src",
|
|
12
|
+
"tsconfig.json"
|
|
13
|
+
],
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@clack/prompts": "^1.7.0",
|
|
16
|
+
"commander": "^14.0.0",
|
|
17
|
+
"smol-toml": "^1.4.0",
|
|
18
|
+
"@raidiant/notifai-protocol": "0.1.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"typescript": "^5.8.0",
|
|
22
|
+
"vitest": "^3.2.0"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20"
|
|
26
|
+
},
|
|
27
|
+
"license": "Apache-2.0",
|
|
28
|
+
"author": "Rafael Vidaurre",
|
|
29
|
+
"homepage": "https://github.com/RafaelVidaurre/notifai#readme",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/RafaelVidaurre/notifai/issues"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/RafaelVidaurre/notifai.git",
|
|
39
|
+
"directory": "apps/cli"
|
|
40
|
+
},
|
|
41
|
+
"keywords": [
|
|
42
|
+
"notifications",
|
|
43
|
+
"cli",
|
|
44
|
+
"agents",
|
|
45
|
+
"apns",
|
|
46
|
+
"push",
|
|
47
|
+
"ai",
|
|
48
|
+
"claude",
|
|
49
|
+
"codex"
|
|
50
|
+
],
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsc -p tsconfig.json",
|
|
53
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
54
|
+
"test": "vitest run"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createServer, type Server } from 'node:http'
|
|
2
|
+
import type { AddressInfo } from 'node:net'
|
|
3
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
4
|
+
import { NetworkError, createClient } from './client.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* These run against a real socket rather than a stubbed fetch, because the
|
|
8
|
+
* failure being fixed is a property of the transport: a server that accepts the
|
|
9
|
+
* connection and then says nothing. A mock that resolves or rejects cannot
|
|
10
|
+
* reproduce it.
|
|
11
|
+
*/
|
|
12
|
+
let server: Server | undefined
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
if (server) {
|
|
16
|
+
const closing = server
|
|
17
|
+
server = undefined
|
|
18
|
+
await new Promise<void>((resolve) => closing.close(() => resolve()))
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
/** Starts a server with the given behaviour and returns its base URL. */
|
|
23
|
+
async function serving(handler: Parameters<typeof createServer>[1]): Promise<string> {
|
|
24
|
+
server = createServer(handler)
|
|
25
|
+
await new Promise<void>((resolve) => server?.listen(0, '127.0.0.1', resolve))
|
|
26
|
+
const { port } = server.address() as AddressInfo
|
|
27
|
+
return `http://127.0.0.1:${port}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('a server that never answers', () => {
|
|
31
|
+
it('gives up instead of hanging until the harness kills the hook', async () => {
|
|
32
|
+
// Accepts the request, holds the socket open, writes nothing — ever.
|
|
33
|
+
const held: unknown[] = []
|
|
34
|
+
const baseUrl = await serving((_request, response) => {
|
|
35
|
+
held.push(response)
|
|
36
|
+
})
|
|
37
|
+
const client = createClient(baseUrl, null, { timeoutMs: 300 })
|
|
38
|
+
|
|
39
|
+
const started = Date.now()
|
|
40
|
+
await expect(client.listDevices()).rejects.toBeInstanceOf(NetworkError)
|
|
41
|
+
// The point is that it returns at all; the bound is loose so a busy CI box
|
|
42
|
+
// does not make this flaky.
|
|
43
|
+
expect(Date.now() - started).toBeLessThan(5_000)
|
|
44
|
+
expect(held).toHaveLength(1)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('says the server went quiet rather than reporting a bare abort', async () => {
|
|
48
|
+
const baseUrl = await serving(() => {})
|
|
49
|
+
const client = createClient(baseUrl, null, { timeoutMs: 300 })
|
|
50
|
+
|
|
51
|
+
await expect(client.listDevices()).rejects.toThrow(/did not respond within/)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('allows for the long poll it asked the server to hold', async () => {
|
|
55
|
+
// The deadline is per request, so a 25s long poll must not be cut off by
|
|
56
|
+
// the ordinary budget — otherwise waiting for a reply could never work.
|
|
57
|
+
let seen: string | undefined
|
|
58
|
+
const baseUrl = await serving((request, response) => {
|
|
59
|
+
seen = request.url
|
|
60
|
+
response.setHeader('content-type', 'application/json')
|
|
61
|
+
response.end(JSON.stringify({ request_id: 'req_1', reply_expires_at: null, replies: [] }))
|
|
62
|
+
})
|
|
63
|
+
const client = createClient(baseUrl, null, { timeoutMs: 300 })
|
|
64
|
+
|
|
65
|
+
const result = await client.replies('req_1', { waitSeconds: 25, afterSeq: 0 })
|
|
66
|
+
|
|
67
|
+
expect(seen).toContain('wait_seconds=25')
|
|
68
|
+
expect(result.replies).toEqual([])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('treats a truncated body as a transport failure, not a crash', async () => {
|
|
72
|
+
// A body that stops mid-JSON used to escape as a raw parse error outside
|
|
73
|
+
// the retry path, so nothing backed off and retried it.
|
|
74
|
+
const baseUrl = await serving((_request, response) => {
|
|
75
|
+
response.setHeader('content-type', 'application/json')
|
|
76
|
+
response.write('{"devices": [')
|
|
77
|
+
response.destroy()
|
|
78
|
+
})
|
|
79
|
+
const client = createClient(baseUrl, null, { timeoutMs: 500 })
|
|
80
|
+
|
|
81
|
+
await expect(client.listDevices()).rejects.toBeInstanceOf(NetworkError)
|
|
82
|
+
})
|
|
83
|
+
})
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ApiErrorBody,
|
|
3
|
+
BeginPairingResponse,
|
|
4
|
+
CapabilityDocument,
|
|
5
|
+
CreateMediaUploadRequestT,
|
|
6
|
+
CreateMediaUploadResponse,
|
|
7
|
+
EvidenceSnapshot,
|
|
8
|
+
ListDevicesResponse,
|
|
9
|
+
ListRepliesResponse,
|
|
10
|
+
Platform,
|
|
11
|
+
PollPairingResponse,
|
|
12
|
+
SubmissionReceipt,
|
|
13
|
+
SubmitNotificationRequestT,
|
|
14
|
+
} from '@raidiant/notifai-protocol'
|
|
15
|
+
|
|
16
|
+
export class ApiCallError extends Error {
|
|
17
|
+
constructor(
|
|
18
|
+
public status: number,
|
|
19
|
+
public code: string,
|
|
20
|
+
message: string,
|
|
21
|
+
public nextAction: string | null = null,
|
|
22
|
+
public details: unknown = null,
|
|
23
|
+
) {
|
|
24
|
+
super(message)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class NetworkError extends Error {}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A deadline that fired reads as an abort, which says nothing useful on its
|
|
32
|
+
* own. Naming the timeout is what lets someone tell a hung server apart from a
|
|
33
|
+
* refused connection.
|
|
34
|
+
*/
|
|
35
|
+
function networkFailure(err: unknown, root: string, limitMs: number): NetworkError {
|
|
36
|
+
const name = err instanceof Error ? err.name : ''
|
|
37
|
+
if (name === 'TimeoutError' || name === 'AbortError') {
|
|
38
|
+
return new NetworkError(`${root} did not respond within ${Math.round(limitMs / 1000)}s`)
|
|
39
|
+
}
|
|
40
|
+
return new NetworkError(`Could not reach ${root}: ${String(err)}`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ApiClient {
|
|
44
|
+
beginPairing(body: {
|
|
45
|
+
machine_name: string
|
|
46
|
+
credential_hash: string
|
|
47
|
+
poll_verifier_hash: string
|
|
48
|
+
}): Promise<BeginPairingResponse>
|
|
49
|
+
pollPairing(pairingId: string, pollVerifier: string): Promise<PollPairingResponse>
|
|
50
|
+
listDevices(): Promise<ListDevicesResponse>
|
|
51
|
+
capabilities(platform?: Platform): Promise<CapabilityDocument>
|
|
52
|
+
submit(body: SubmitNotificationRequestT, waitSeconds: number): Promise<SubmissionReceipt>
|
|
53
|
+
evidence(requestId: string): Promise<EvidenceSnapshot>
|
|
54
|
+
replies(
|
|
55
|
+
requestId: string,
|
|
56
|
+
options: { waitSeconds: number; afterSeq: number },
|
|
57
|
+
): Promise<ListRepliesResponse>
|
|
58
|
+
/** Retire a question so a late device answer is rejected rather than lost. */
|
|
59
|
+
closeReplies(requestId: string): Promise<void>
|
|
60
|
+
createMediaUpload(body: CreateMediaUploadRequestT): Promise<CreateMediaUploadResponse>
|
|
61
|
+
uploadMedia(grant: CreateMediaUploadResponse, bytes: Uint8Array): Promise<void>
|
|
62
|
+
health(): Promise<boolean>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ClientOptions {
|
|
66
|
+
/**
|
|
67
|
+
* Ceiling for a single request, on top of any long poll the server has been
|
|
68
|
+
* asked to hold. Callers running inside a harness hook shrink this so their
|
|
69
|
+
* whole network path stays inside the budget the harness allows them.
|
|
70
|
+
*/
|
|
71
|
+
timeoutMs?: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Generous enough for a slow link, short enough that nothing hangs for ever. */
|
|
75
|
+
const DEFAULT_TIMEOUT_MS = 20_000
|
|
76
|
+
|
|
77
|
+
export function createClient(
|
|
78
|
+
baseUrl: string,
|
|
79
|
+
bearer: string | null,
|
|
80
|
+
options: ClientOptions = {},
|
|
81
|
+
): ApiClient {
|
|
82
|
+
const root = baseUrl.replace(/\/$/, '')
|
|
83
|
+
const budgetMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
84
|
+
|
|
85
|
+
async function call<T>(
|
|
86
|
+
method: string,
|
|
87
|
+
apiPath: string,
|
|
88
|
+
body?: unknown,
|
|
89
|
+
/** Seconds the server was asked to hold the connection open. */
|
|
90
|
+
serverWaitSeconds = 0,
|
|
91
|
+
): Promise<T> {
|
|
92
|
+
// Without this, a server that accepts the connection and then never answers
|
|
93
|
+
// hangs until the harness kills the whole hook — and the reply-poll deadline
|
|
94
|
+
// cannot interrupt an individual fetch. The signal covers the
|
|
95
|
+
// body read too, not just the response headers.
|
|
96
|
+
const limitMs = budgetMs + serverWaitSeconds * 1000
|
|
97
|
+
const signal = AbortSignal.timeout(limitMs)
|
|
98
|
+
let response: Response
|
|
99
|
+
try {
|
|
100
|
+
response = await fetch(`${root}${apiPath}`, {
|
|
101
|
+
method,
|
|
102
|
+
headers: {
|
|
103
|
+
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
104
|
+
...(bearer ? { authorization: bearer } : {}),
|
|
105
|
+
},
|
|
106
|
+
signal,
|
|
107
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
108
|
+
})
|
|
109
|
+
} catch (err) {
|
|
110
|
+
throw networkFailure(err, root, limitMs)
|
|
111
|
+
}
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
let parsed: ApiErrorBody | null = null
|
|
114
|
+
try {
|
|
115
|
+
parsed = (await response.json()) as ApiErrorBody
|
|
116
|
+
} catch {
|
|
117
|
+
// non-JSON body
|
|
118
|
+
}
|
|
119
|
+
throw new ApiCallError(
|
|
120
|
+
response.status,
|
|
121
|
+
parsed?.error.code ?? 'internal_error',
|
|
122
|
+
parsed?.error.message ?? `Request failed with status ${response.status}`,
|
|
123
|
+
parsed?.error.next_action ?? null,
|
|
124
|
+
parsed?.error.details ?? null,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
if (response.status === 204) return undefined as T
|
|
128
|
+
try {
|
|
129
|
+
return (await response.json()) as T
|
|
130
|
+
} catch (err) {
|
|
131
|
+
// A truncated or abandoned body is a transport failure, not a protocol
|
|
132
|
+
// one: it must be retryable like any other, not surface as a raw abort.
|
|
133
|
+
throw networkFailure(err, root, limitMs)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
beginPairing: (body) => call('POST', '/api/v1/pairings', body),
|
|
139
|
+
pollPairing: (pairingId, pollVerifier) =>
|
|
140
|
+
call('POST', `/api/v1/pairings/${pairingId}/poll`, { poll_verifier: pollVerifier }),
|
|
141
|
+
listDevices: () => call('GET', '/api/v1/devices'),
|
|
142
|
+
capabilities: (platform = 'ios') => call('GET', `/api/v1/capabilities/${platform}`),
|
|
143
|
+
submit: (body, waitSeconds) =>
|
|
144
|
+
call('POST', `/api/v1/notifications?wait_seconds=${waitSeconds}`, body, waitSeconds),
|
|
145
|
+
evidence: (requestId) => call('GET', `/api/v1/notifications/${requestId}`),
|
|
146
|
+
replies: (requestId, { waitSeconds, afterSeq }) =>
|
|
147
|
+
call(
|
|
148
|
+
'GET',
|
|
149
|
+
`/api/v1/notifications/${encodeURIComponent(requestId)}/replies?wait_seconds=${waitSeconds}&after_seq=${afterSeq}`,
|
|
150
|
+
undefined,
|
|
151
|
+
waitSeconds,
|
|
152
|
+
),
|
|
153
|
+
closeReplies: (requestId) =>
|
|
154
|
+
call('POST', `/api/v1/notifications/${encodeURIComponent(requestId)}/replies/close`),
|
|
155
|
+
createMediaUpload: (body) => call('POST', '/api/v1/media', body),
|
|
156
|
+
uploadMedia: async (grant, bytes) => {
|
|
157
|
+
let response: Response
|
|
158
|
+
try {
|
|
159
|
+
response = await fetch(grant.upload_url, {
|
|
160
|
+
method: 'PUT',
|
|
161
|
+
headers: grant.upload_headers,
|
|
162
|
+
body: bytes,
|
|
163
|
+
// Media can be large, so this gets its own allowance rather than the
|
|
164
|
+
// per-request budget — but still a finite one.
|
|
165
|
+
signal: AbortSignal.timeout(Math.max(budgetMs, 60_000)),
|
|
166
|
+
})
|
|
167
|
+
} catch (err) {
|
|
168
|
+
throw new NetworkError(`Upload failed: ${String(err)}`)
|
|
169
|
+
}
|
|
170
|
+
if (!response.ok) {
|
|
171
|
+
throw new NetworkError(`Upload rejected with status ${response.status}`)
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
health: async () => {
|
|
175
|
+
try {
|
|
176
|
+
await call('GET', '/healthz')
|
|
177
|
+
return true
|
|
178
|
+
} catch {
|
|
179
|
+
return false
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
}
|