lauther-client 0.2.0 → 0.3.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 +50 -3
- package/package.json +1 -1
- package/src/index.d.ts +80 -0
- package/src/index.js +109 -2
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Push notifications to your phone from any web app or script — and questions
|
|
4
4
|
that **wait for a yes** before something irreversible happens.
|
|
5
5
|
|
|
6
|
-
Zero dependencies. Node 18+.
|
|
6
|
+
Zero dependencies. Node 18+.
|
|
7
7
|
|
|
8
8
|
```bash
|
|
9
9
|
npm install lauther-client
|
|
@@ -80,6 +80,50 @@ const { response } = await lauther.approve({
|
|
|
80
80
|
// response is "approve", "deny", or null when nobody answered in time.
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
+
## Requests
|
|
84
|
+
|
|
85
|
+
A request is an approval whose "yes" carries something back: the person taps
|
|
86
|
+
**Share**, the phone does one thing while they watch, and the result rides
|
|
87
|
+
home on the answer. Two kinds: where they are, and one picture.
|
|
88
|
+
|
|
89
|
+
```js
|
|
90
|
+
const where = await lauther.requestLocation({
|
|
91
|
+
title: "Share your location so I can find the nearest pharmacy?",
|
|
92
|
+
timeoutSeconds: 300,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (where.response === "approve") {
|
|
96
|
+
const { lat, lng, accuracy, at } = where.result;
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
```js
|
|
101
|
+
const shot = await lauther.requestPhoto({ title: "Photo of the printer display?" });
|
|
102
|
+
|
|
103
|
+
if (shot.response === "approve") {
|
|
104
|
+
const { url, bytes, width, height } = shot.result; // a JPEG download link
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Both return `{ id, response, confirmedBy, result, waitedSeconds, expired }`.
|
|
109
|
+
`response` is `"approve"`, `"deny"` or `null`, and `result` is `null` unless
|
|
110
|
+
the person approved. They wait the way `requestApproval()` does: past the
|
|
111
|
+
server's 25 seconds on the **same** request, so the phone still shows one.
|
|
112
|
+
Silence is a returned outcome rather than a thrown error. `expired: true`
|
|
113
|
+
means the request lapsed on the phone (10 minutes by default; set
|
|
114
|
+
`expiresIn`, 60 to 3600 seconds) and the wait ends early.
|
|
115
|
+
|
|
116
|
+
- **Location** is one reading, taken when they tap Share and sent once:
|
|
117
|
+
`{ lat, lng, accuracy, at }`, with `accuracy` in metres.
|
|
118
|
+
- **Photo** is a JPEG they take or pick, at most 2 MB. You receive a link,
|
|
119
|
+
and **the link works for 7 days**; download it if you want to keep it.
|
|
120
|
+
|
|
121
|
+
Options for both: `title`, `message`, `requireBiometric`, `priority`,
|
|
122
|
+
`expiresIn`, plus `timeoutSeconds` and `pollIntervalMs` for the wait. For
|
|
123
|
+
full control, `request(kind, options)` is the single call behind them (it
|
|
124
|
+
answers within `wait` seconds, 0 to 25), and `poll(id)` also returns `result`
|
|
125
|
+
and `expired`.
|
|
126
|
+
|
|
83
127
|
## Handling failure
|
|
84
128
|
|
|
85
129
|
```js
|
|
@@ -119,7 +163,10 @@ reported separately rather than collapsed into one flag.
|
|
|
119
163
|
| `push(options)` | `title`, `message`, `priority`, `url`, `path`, `tag`, `icon`, `color`, `sound`, `image` |
|
|
120
164
|
| `approve(options)` | `title`, `message`, `wait` (0–25s), `approveTitle`, `denyTitle`, `requireBiometric`, `priority` |
|
|
121
165
|
| `requestApproval(question, options?)` | Ask and wait, past the 25s cap, on one notification |
|
|
122
|
-
| `
|
|
166
|
+
| `request(kind, options?)` | Ask for `"location"` or `"photo"`; `title`, `message`, `requireBiometric`, `priority`, `wait`, `expiresIn` |
|
|
167
|
+
| `requestLocation(options?)` | Ask for a position and wait, past the 25s cap, on one request |
|
|
168
|
+
| `requestPhoto(options?)` | Ask for one picture and wait, past the 25s cap |
|
|
169
|
+
| `poll(id)` | Ask about an approval or request already started (carries `result` and `expired`) |
|
|
123
170
|
| `ask(question, options?)` | `requestApproval()` reduced to a boolean |
|
|
124
171
|
|
|
125
172
|
`priority` runs `-2` to `2`. **`2` is the only level that crosses quiet
|
|
@@ -133,7 +180,7 @@ TypeScript types are bundled; there is no `@types` package to install.
|
|
|
133
180
|
|
|
134
181
|
## You do not need this package
|
|
135
182
|
|
|
136
|
-
The whole client is `fetch` against
|
|
183
|
+
The whole client is `fetch` against a few documented endpoints. If you would
|
|
137
184
|
rather not add a dependency, this is the entire integration:
|
|
138
185
|
|
|
139
186
|
```js
|
package/package.json
CHANGED
package/src/index.d.ts
CHANGED
|
@@ -69,6 +69,13 @@ export interface PollResult {
|
|
|
69
69
|
response: "approve" | "deny" | null;
|
|
70
70
|
/** How the answer was confirmed, e.g. a biometric check. `null` if not. */
|
|
71
71
|
confirmedBy: string | null;
|
|
72
|
+
/**
|
|
73
|
+
* What a request carried back: a `LocationResult` or `PhotoResult` once
|
|
74
|
+
* approved, `null` until then, when denied, and for plain approvals.
|
|
75
|
+
*/
|
|
76
|
+
result?: LocationResult | PhotoResult | null;
|
|
77
|
+
/** `true` once a request has lapsed unanswered; there is no point polling on. */
|
|
78
|
+
expired?: boolean;
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
export interface WaitOptions extends Omit<ApproveOptions, "title" | "wait"> {
|
|
@@ -90,6 +97,73 @@ export interface ApprovalOutcome {
|
|
|
90
97
|
waitedSeconds: number;
|
|
91
98
|
}
|
|
92
99
|
|
|
100
|
+
/** The kinds of thing a person can be asked to share. */
|
|
101
|
+
export type RequestKind = "location" | "photo";
|
|
102
|
+
|
|
103
|
+
export interface RequestOptions {
|
|
104
|
+
/** The question. Defaults to "Share your location?" / "Take a photo?". */
|
|
105
|
+
title?: string;
|
|
106
|
+
message?: string;
|
|
107
|
+
/** Demand a fingerprint or face before the share goes out. */
|
|
108
|
+
requireBiometric?: boolean;
|
|
109
|
+
priority?: Priority;
|
|
110
|
+
/** Seconds to wait for an answer, 0-25. Defaults to 25. */
|
|
111
|
+
wait?: number;
|
|
112
|
+
/** Seconds the request stays answerable, 60-3600. Defaults to 600. */
|
|
113
|
+
expiresIn?: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** One position, as the phone read it a moment before sending. */
|
|
117
|
+
export interface LocationResult {
|
|
118
|
+
lat: number;
|
|
119
|
+
lng: number;
|
|
120
|
+
/** Metres. Absent when the phone did not report one. */
|
|
121
|
+
accuracy?: number;
|
|
122
|
+
/** ISO 8601 time of the reading. */
|
|
123
|
+
at: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** One picture. Shared photos are kept for 7 days, then deleted. */
|
|
127
|
+
export interface PhotoResult {
|
|
128
|
+
/** Download link for the JPEG. */
|
|
129
|
+
url: string;
|
|
130
|
+
bytes: number;
|
|
131
|
+
width?: number;
|
|
132
|
+
height?: number;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface RequestResult {
|
|
136
|
+
ok: true;
|
|
137
|
+
id: string;
|
|
138
|
+
/** `null` means nobody answered in time. Silence is not consent. */
|
|
139
|
+
response: "approve" | "deny" | null;
|
|
140
|
+
/** What was shared. `null` unless the person approved. */
|
|
141
|
+
result: LocationResult | PhotoResult | null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface RequestWaitOptions extends Omit<RequestOptions, "wait"> {
|
|
145
|
+
/**
|
|
146
|
+
* How long to wait in total, in seconds. The server long-polls for the
|
|
147
|
+
* first 25s; beyond that the client polls the same request, so the person
|
|
148
|
+
* still sees exactly one.
|
|
149
|
+
*/
|
|
150
|
+
timeoutSeconds?: number;
|
|
151
|
+
/** Gap between polls once past the server's 25s window. Defaults to 3000. */
|
|
152
|
+
pollIntervalMs?: number;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface RequestOutcome<R = LocationResult | PhotoResult> {
|
|
156
|
+
id: string;
|
|
157
|
+
/** `null` means nobody answered, or the request expired. */
|
|
158
|
+
response: "approve" | "deny" | null;
|
|
159
|
+
confirmedBy: string | null;
|
|
160
|
+
/** What was shared. `null` unless `response` is `"approve"`. */
|
|
161
|
+
result: R | null;
|
|
162
|
+
waitedSeconds: number;
|
|
163
|
+
/** `true` when the request lapsed on the phone before anyone answered. */
|
|
164
|
+
expired: boolean;
|
|
165
|
+
}
|
|
166
|
+
|
|
93
167
|
export interface LautherOptions {
|
|
94
168
|
baseUrl?: string;
|
|
95
169
|
fetch?: typeof globalThis.fetch;
|
|
@@ -117,6 +191,12 @@ export declare class Lauther {
|
|
|
117
191
|
poll(id: string): Promise<PollResult>;
|
|
118
192
|
/** Ask and wait, past the server's 25s cap, on one notification. */
|
|
119
193
|
requestApproval(question: string, options?: WaitOptions): Promise<ApprovalOutcome>;
|
|
194
|
+
/** Ask the person to share `"location"` or a `"photo"`; answers within `wait` seconds. */
|
|
195
|
+
request(kind: RequestKind, options?: RequestOptions): Promise<RequestResult>;
|
|
196
|
+
/** Ask for the person's position and wait, past the server's 25s cap. */
|
|
197
|
+
requestLocation(options?: RequestWaitOptions): Promise<RequestOutcome<LocationResult>>;
|
|
198
|
+
/** Ask for one picture and wait, past the server's 25s cap. */
|
|
199
|
+
requestPhoto(options?: RequestWaitOptions): Promise<RequestOutcome<PhotoResult>>;
|
|
120
200
|
/** True only on an explicit Approve; a denial and a timeout are both false. */
|
|
121
201
|
ask(question: string, options?: WaitOptions): Promise<boolean>;
|
|
122
202
|
}
|
package/src/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Lauther — push notifications and human approvals for anything that can make
|
|
3
3
|
* an HTTP request.
|
|
4
4
|
*
|
|
5
|
-
* Zero dependencies on purpose. The whole client is `fetch` against
|
|
5
|
+
* Zero dependencies on purpose. The whole client is `fetch` against a few
|
|
6
6
|
* documented endpoints, so there is no SDK to go stale against the API and
|
|
7
7
|
* nothing to audit before you install it. If this package ever disappears,
|
|
8
8
|
* the four lines it replaces are in the README.
|
|
@@ -116,7 +116,10 @@ export class Lauther {
|
|
|
116
116
|
* Note this endpoint takes the token in the body rather than as a bearer
|
|
117
117
|
* header — that is the API's shape, not a mistake here.
|
|
118
118
|
*
|
|
119
|
-
*
|
|
119
|
+
* For a request, the answer also carries `result` (what was shared, or
|
|
120
|
+
* `null`) and `expired` (true once the request has lapsed unanswered).
|
|
121
|
+
*
|
|
122
|
+
* @param {string} id The `id` returned by `approve()` or `request()`.
|
|
120
123
|
* @returns {Promise<import("./index.d.ts").PollResult>}
|
|
121
124
|
*/
|
|
122
125
|
async poll(id) {
|
|
@@ -176,6 +179,110 @@ export class Lauther {
|
|
|
176
179
|
};
|
|
177
180
|
}
|
|
178
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Ask the person to share something from their phone: where they are, or a
|
|
184
|
+
* picture. The "yes" carries the payload.
|
|
185
|
+
*
|
|
186
|
+
* Like `approve()` this blocks server-side for up to 25 seconds, and
|
|
187
|
+
* `response` is `"approve"`, `"deny"` or `null`. On an approve, `result`
|
|
188
|
+
* holds what was shared (see `LocationResult` and `PhotoResult`); on
|
|
189
|
+
* anything else it is `null`.
|
|
190
|
+
*
|
|
191
|
+
* @param {"location" | "photo"} kind
|
|
192
|
+
* @param {import("./index.d.ts").RequestOptions} [options]
|
|
193
|
+
* @returns {Promise<import("./index.d.ts").RequestResult>}
|
|
194
|
+
*/
|
|
195
|
+
async request(kind, options) {
|
|
196
|
+
if (!kind) {
|
|
197
|
+
throw new LautherError('request() needs a kind: "location" or "photo".');
|
|
198
|
+
}
|
|
199
|
+
const {wait = MAX_WAIT_SECONDS, ...rest} = options ?? {};
|
|
200
|
+
return this.#request("/v1/request", {
|
|
201
|
+
kind,
|
|
202
|
+
...rest,
|
|
203
|
+
wait: Math.min(Math.max(Number(wait) || 0, 0), MAX_WAIT_SECONDS),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Ask for the person's position, once, and wait for the answer as long as
|
|
209
|
+
* you are willing. Same waiting rules as `requestApproval()`: one request
|
|
210
|
+
* on the phone, polled past the server's 25s cap, never thrown on timeout.
|
|
211
|
+
*
|
|
212
|
+
* @param {import("./index.d.ts").RequestWaitOptions} [options]
|
|
213
|
+
* @returns {Promise<import("./index.d.ts").RequestOutcome<import("./index.d.ts").LocationResult>>}
|
|
214
|
+
*/
|
|
215
|
+
requestLocation(options = {}) {
|
|
216
|
+
return this.#requestAndWait("location", options);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Ask for one picture and wait for it. Same waiting rules as
|
|
221
|
+
* `requestApproval()`. `result.url` is a download link that expires after
|
|
222
|
+
* 7 days.
|
|
223
|
+
*
|
|
224
|
+
* @param {import("./index.d.ts").RequestWaitOptions} [options]
|
|
225
|
+
* @returns {Promise<import("./index.d.ts").RequestOutcome<import("./index.d.ts").PhotoResult>>}
|
|
226
|
+
*/
|
|
227
|
+
requestPhoto(options = {}) {
|
|
228
|
+
return this.#requestAndWait("photo", options);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Shared by `requestLocation()` and `requestPhoto()`: start the request,
|
|
233
|
+
* then keep asking about the SAME notification until it is answered,
|
|
234
|
+
* expires, or `timeoutSeconds` runs out.
|
|
235
|
+
*
|
|
236
|
+
* @param {"location" | "photo"} kind
|
|
237
|
+
* @param {import("./index.d.ts").RequestWaitOptions} options
|
|
238
|
+
*/
|
|
239
|
+
async #requestAndWait(kind, options) {
|
|
240
|
+
const {
|
|
241
|
+
timeoutSeconds = MAX_WAIT_SECONDS,
|
|
242
|
+
pollIntervalMs = 3000,
|
|
243
|
+
...rest
|
|
244
|
+
} = options ?? {};
|
|
245
|
+
const startedAt = Date.now();
|
|
246
|
+
const elapsed = () => (Date.now() - startedAt) / 1000;
|
|
247
|
+
|
|
248
|
+
const started = await this.request(kind, {
|
|
249
|
+
...rest,
|
|
250
|
+
wait: Math.min(timeoutSeconds, MAX_WAIT_SECONDS),
|
|
251
|
+
});
|
|
252
|
+
if (started.response) {
|
|
253
|
+
return {
|
|
254
|
+
id: started.id, response: started.response,
|
|
255
|
+
confirmedBy: started.confirmedBy ?? null, result: started.result ?? null,
|
|
256
|
+
waitedSeconds: Math.round(elapsed()), expired: false,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
while (elapsed() < timeoutSeconds) {
|
|
261
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
262
|
+
const {response, confirmedBy, result, expired} = await this.poll(started.id);
|
|
263
|
+
if (response) {
|
|
264
|
+
return {
|
|
265
|
+
id: started.id, response, confirmedBy: confirmedBy ?? null,
|
|
266
|
+
result: result ?? null, waitedSeconds: Math.round(elapsed()),
|
|
267
|
+
expired: false,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
// The request lapsed on the phone: nothing more can arrive, so stop
|
|
271
|
+
// waiting rather than idling out the rest of the timeout.
|
|
272
|
+
if (expired === true) {
|
|
273
|
+
return {
|
|
274
|
+
id: started.id, response: null, confirmedBy: null, result: null,
|
|
275
|
+
waitedSeconds: Math.round(elapsed()), expired: true,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// Nobody answered. As with requestApproval(): an outcome, not an error.
|
|
280
|
+
return {
|
|
281
|
+
id: started.id, response: null, confirmedBy: null, result: null,
|
|
282
|
+
waitedSeconds: Math.round(elapsed()), expired: false,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
179
286
|
/**
|
|
180
287
|
* `requestApproval()` reduced to a boolean, for the common `if` around
|
|
181
288
|
* something irreversible.
|