@wilm-ai/wilma-client 1.6.1 → 1.7.1
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 +48 -1
- package/dist/client.d.ts +76 -4
- package/dist/client.js +114 -9
- package/dist/core.d.ts +16 -0
- package/dist/core.js +11 -0
- package/dist/downloads.d.ts +11 -0
- package/dist/downloads.js +21 -0
- package/dist/external-fetch.d.ts +2 -8
- package/dist/external-fetch.js +2 -6
- package/dist/index.d.ts +3 -11
- package/dist/index.js +14 -9
- package/dist/internal.d.ts +3 -0
- package/dist/internal.js +2 -0
- package/dist/parsers/exams.d.ts +10 -1
- package/dist/parsers/exams.js +91 -0
- package/dist/parsers/forms.d.ts +40 -0
- package/dist/parsers/forms.js +93 -0
- package/dist/parsers/html-text.d.ts +4 -3
- package/dist/parsers/html-text.js +41 -10
- package/dist/parsers/invitations.d.ts +13 -0
- package/dist/parsers/invitations.js +121 -0
- package/dist/parsers/messages.js +4 -0
- package/dist/parsers/news.js +8 -4
- package/dist/parsers/overview.js +1 -0
- package/dist/parsers/recipients.d.ts +16 -0
- package/dist/parsers/recipients.js +170 -0
- package/dist/parsers/students.js +8 -2
- package/dist/session.d.ts +102 -10
- package/dist/session.js +241 -34
- package/dist/timeouts.d.ts +17 -2
- package/dist/timeouts.js +23 -14
- package/dist/transport-node.d.ts +2 -0
- package/dist/transport-node.js +19 -0
- package/dist/transport.d.ts +21 -0
- package/dist/transport.js +19 -0
- package/dist/types.d.ts +111 -0
- package/dist/write.d.ts +143 -0
- package/dist/write.js +427 -0
- package/package.json +11 -3
package/dist/session.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import * as cheerio from "cheerio";
|
|
1
2
|
import { CookieJar } from "tough-cookie";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
3
|
+
import { IdleTimer, asNetworkError, watchedResponse } from "./timeouts.js";
|
|
4
|
+
import { getDefaultTransport } from "./transport.js";
|
|
4
5
|
export class AuthenticationError extends Error {
|
|
5
6
|
constructor(message) {
|
|
6
7
|
super(message);
|
|
@@ -23,6 +24,17 @@ export class APIError extends Error {
|
|
|
23
24
|
this.status = status;
|
|
24
25
|
}
|
|
25
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Wilma ended the session (another login on the account, or it expired) and
|
|
29
|
+
* this client doesn't log in again by itself (no saved credentials, or
|
|
30
|
+
* `relogin: false`). Nothing was sent; log in again and retry.
|
|
31
|
+
*/
|
|
32
|
+
export class SessionEndedError extends APIError {
|
|
33
|
+
constructor(message = "Wilma ended the session; log in again") {
|
|
34
|
+
super(message, 401);
|
|
35
|
+
this.name = "SessionEndedError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
26
38
|
/** How long Wilma may go silent (before answering, or mid-body) before a request fails. */
|
|
27
39
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
28
40
|
const MAX_REDIRECTS = 10;
|
|
@@ -36,10 +48,24 @@ export class WilmaSession {
|
|
|
36
48
|
debug = false;
|
|
37
49
|
constructor(baseUrl, opts) {
|
|
38
50
|
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
39
|
-
this.auth = {
|
|
51
|
+
this.auth = {
|
|
52
|
+
cookieJar: new CookieJar(),
|
|
53
|
+
transport: opts?.transport ?? getDefaultTransport(),
|
|
54
|
+
reloginAllowed: opts?.relogin ?? true,
|
|
55
|
+
shared: opts?.shared,
|
|
56
|
+
loggedIn: false,
|
|
57
|
+
endedByWilma: false,
|
|
58
|
+
relogin: null,
|
|
59
|
+
generation: 0,
|
|
60
|
+
failed: null,
|
|
61
|
+
};
|
|
40
62
|
this.studentNumber = opts?.studentNumber ?? null;
|
|
41
63
|
this.debug = Boolean(opts?.debug);
|
|
42
64
|
}
|
|
65
|
+
/** The address of this Wilma, e.g. "https://example.inschool.fi". */
|
|
66
|
+
get origin() {
|
|
67
|
+
return new URL(this.baseUrl).origin;
|
|
68
|
+
}
|
|
43
69
|
/** The login's cookies, so another process can resume the session (see resumeState). */
|
|
44
70
|
exportState() {
|
|
45
71
|
return JSON.stringify(this.auth.cookieJar.serializeSync());
|
|
@@ -47,8 +73,9 @@ export class WilmaSession {
|
|
|
47
73
|
/**
|
|
48
74
|
* Continue a session saved by exportState() instead of logging in. If Wilma
|
|
49
75
|
* has ended it meanwhile, the first request notices (401/403 or the login
|
|
50
|
-
* page) and logs in again with these credentials
|
|
51
|
-
* state holds no
|
|
76
|
+
* page) and logs in again with these credentials; without credentials it
|
|
77
|
+
* fails with SessionEndedError. Returns false when the state holds no
|
|
78
|
+
* session to resume.
|
|
52
79
|
*/
|
|
53
80
|
resumeState(state, username, password) {
|
|
54
81
|
let jar;
|
|
@@ -58,19 +85,27 @@ export class WilmaSession {
|
|
|
58
85
|
catch {
|
|
59
86
|
return false;
|
|
60
87
|
}
|
|
61
|
-
if (!jar
|
|
88
|
+
if (!holdsSession(jar, this.baseUrl))
|
|
62
89
|
return false;
|
|
63
90
|
this.auth.cookieJar = jar;
|
|
64
91
|
this.auth.loggedIn = true;
|
|
92
|
+
this.auth.endedByWilma = false;
|
|
65
93
|
this.auth.generation += 1;
|
|
66
94
|
this.auth.username = username;
|
|
67
95
|
this.auth.password = password;
|
|
68
96
|
return true;
|
|
69
97
|
}
|
|
70
98
|
/** Called after every successful login (first or again). */
|
|
99
|
+
/** Share this login's session with the other WilmAI programs (see SharedSession). */
|
|
100
|
+
setShared(shared) {
|
|
101
|
+
this.auth.shared = shared;
|
|
102
|
+
}
|
|
71
103
|
onLogin(callback) {
|
|
72
104
|
this.auth.onLogin = callback;
|
|
73
105
|
}
|
|
106
|
+
onSessionEvent(callback) {
|
|
107
|
+
this.auth.onEvent = callback;
|
|
108
|
+
}
|
|
74
109
|
/** Used to answer the two-step verification challenge when the session has to log in again. */
|
|
75
110
|
setMfaCallback(onMfa) {
|
|
76
111
|
this.auth.onMfa = onMfa;
|
|
@@ -81,7 +116,7 @@ export class WilmaSession {
|
|
|
81
116
|
* Wilma picks the student from the "/!<number>/" path prefix.
|
|
82
117
|
*/
|
|
83
118
|
forStudent(studentNumber) {
|
|
84
|
-
const session = new WilmaSession(this.baseUrl, { studentNumber, debug: this.debug });
|
|
119
|
+
const session = new WilmaSession(this.baseUrl, { studentNumber, debug: this.debug, transport: this.auth.transport });
|
|
85
120
|
session.auth = this.auth;
|
|
86
121
|
return session;
|
|
87
122
|
}
|
|
@@ -157,11 +192,12 @@ export class WilmaSession {
|
|
|
157
192
|
// would otherwise pass the text check, accepting any username and password.
|
|
158
193
|
if (resp.status < 400 && (hasSessionCookie || resp.status < 300)) {
|
|
159
194
|
this.auth.loggedIn = true;
|
|
195
|
+
this.auth.endedByWilma = false;
|
|
160
196
|
this.auth.generation += 1;
|
|
161
197
|
this.auth.failed = null;
|
|
162
198
|
this.auth.username = username;
|
|
163
199
|
this.auth.password = password;
|
|
164
|
-
this.
|
|
200
|
+
this.loggedInNow();
|
|
165
201
|
return;
|
|
166
202
|
}
|
|
167
203
|
// Not Wilma's "login failed" answer (a rate limit, a firewall, a
|
|
@@ -206,13 +242,67 @@ export class WilmaSession {
|
|
|
206
242
|
throw new AuthenticationError("MFA verification failed: invalid OTP code");
|
|
207
243
|
}
|
|
208
244
|
this.auth.loggedIn = true;
|
|
245
|
+
this.auth.endedByWilma = false;
|
|
209
246
|
this.auth.generation += 1;
|
|
210
247
|
this.auth.failed = null;
|
|
211
|
-
this.
|
|
248
|
+
this.loggedInNow();
|
|
212
249
|
if (this.debug) {
|
|
213
250
|
console.error(`[wilmai] MFA verification successful`);
|
|
214
251
|
}
|
|
215
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* End this login's session in Wilma, as its "Log out" button does: the
|
|
255
|
+
* logout form on the front page posts its formkey to /logout. Never logs
|
|
256
|
+
* in; a session Wilma has already ended needs nothing. The session is
|
|
257
|
+
* shared with every per-student session made from this one, and afterwards
|
|
258
|
+
* none of them logs in again by itself (call login() for a new session).
|
|
259
|
+
* Returns true when a live session was ended, false when there was none.
|
|
260
|
+
* If Wilma can't be reached, throws and keeps the session, so logging out
|
|
261
|
+
* can be tried again.
|
|
262
|
+
*/
|
|
263
|
+
async logout() {
|
|
264
|
+
const auth = this.auth;
|
|
265
|
+
// A re-login in progress makes a new session: let it finish, then end that one.
|
|
266
|
+
if (auth.relogin)
|
|
267
|
+
await auth.relogin.catch(() => { });
|
|
268
|
+
const ended = holdsSession(auth.cookieJar, this.baseUrl) ? await this.endSession() : false;
|
|
269
|
+
auth.cookieJar.removeAllCookiesSync();
|
|
270
|
+
auth.loggedIn = false;
|
|
271
|
+
auth.username = undefined;
|
|
272
|
+
auth.password = undefined;
|
|
273
|
+
return ended;
|
|
274
|
+
}
|
|
275
|
+
async endSession() {
|
|
276
|
+
// Only raw requests: request() would log in again if the session has ended.
|
|
277
|
+
const home = await this.rawRequest("/", { method: "GET", stopAtLogin: true });
|
|
278
|
+
if (home.status >= 500) {
|
|
279
|
+
await home.body?.cancel();
|
|
280
|
+
throw new APIError(`Wilma answered HTTP ${home.status} while logging out`, home.status);
|
|
281
|
+
}
|
|
282
|
+
let formkey = null;
|
|
283
|
+
if (home.status === 200)
|
|
284
|
+
formkey = logoutFormkey(await home.text());
|
|
285
|
+
else
|
|
286
|
+
await home.body?.cancel();
|
|
287
|
+
if (!formkey) {
|
|
288
|
+
// The login page, or a 401/403: nothing to end. A live session without
|
|
289
|
+
// the form is kept rather than forgotten while still open.
|
|
290
|
+
if (await this.sessionAlive())
|
|
291
|
+
throw new APIError("Wilma's front page had no logout form", home.status);
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
const resp = await this.rawRequest("/logout", {
|
|
295
|
+
method: "POST",
|
|
296
|
+
body: new URLSearchParams({ formkey }).toString(),
|
|
297
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
298
|
+
redirect: "manual",
|
|
299
|
+
});
|
|
300
|
+
await resp.body?.cancel();
|
|
301
|
+
if (resp.status >= 500 || (resp.status >= 400 && (await this.sessionAlive()))) {
|
|
302
|
+
throw new APIError(`Wilma didn't log out (HTTP ${resp.status})`, resp.status);
|
|
303
|
+
}
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
216
306
|
async getLoginFormFields() {
|
|
217
307
|
const resp = await this.rawRequest("/login", { method: "GET" });
|
|
218
308
|
if (resp.status >= 500) {
|
|
@@ -246,10 +336,11 @@ export class WilmaSession {
|
|
|
246
336
|
// A re-login in progress: wait for it (and share its outcome).
|
|
247
337
|
if (auth.relogin)
|
|
248
338
|
await auth.relogin;
|
|
339
|
+
const canRelogin = () => auth.reloginAllowed && Boolean(auth.username && auth.password);
|
|
249
340
|
if (!auth.loggedIn) {
|
|
250
|
-
//
|
|
251
|
-
if (
|
|
252
|
-
await this.
|
|
341
|
+
// Ended earlier (or an earlier re-login failed): another program may have a session now, or try logging in again.
|
|
342
|
+
if (canRelogin() || auth.endedByWilma)
|
|
343
|
+
await this.recover();
|
|
253
344
|
else
|
|
254
345
|
throw new AuthenticationError("WilmaSession not logged in – call login() first");
|
|
255
346
|
}
|
|
@@ -264,13 +355,16 @@ export class WilmaSession {
|
|
|
264
355
|
// another login on the same account cancelled this session, or just this
|
|
265
356
|
// one item is off-limits.
|
|
266
357
|
const loggedOut = (r) => r.status === 401 || this.isLoginRedirect(r);
|
|
267
|
-
if (
|
|
268
|
-
|
|
269
|
-
|
|
358
|
+
if (loggedOut(resp) || resp.status === 403) {
|
|
359
|
+
// Another request may have got a session back meanwhile (generation bumped, or recovering now).
|
|
360
|
+
const recoveredElsewhere = () => auth.generation !== generation || auth.relogin !== null;
|
|
361
|
+
let retry = recoveredElsewhere();
|
|
362
|
+
// Tell an ended session apart from one item that's off-limits (403 with the session alive).
|
|
270
363
|
if (!retry && (loggedOut(resp) || !(await this.sessionAlive()))) {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
364
|
+
await resp.body?.cancel();
|
|
365
|
+
// Check again: another request may have recovered while we probed.
|
|
366
|
+
if (!recoveredElsewhere())
|
|
367
|
+
await this.recover();
|
|
274
368
|
retry = true;
|
|
275
369
|
}
|
|
276
370
|
if (retry) {
|
|
@@ -350,11 +444,24 @@ export class WilmaSession {
|
|
|
350
444
|
}
|
|
351
445
|
}
|
|
352
446
|
}
|
|
353
|
-
/**
|
|
354
|
-
|
|
447
|
+
/**
|
|
448
|
+
* A live session after Wilma ended this one, once for everyone sharing this
|
|
449
|
+
* login even if many requests notice at the same time: the newest session
|
|
450
|
+
* another program saved (no login), else a login when this client may log
|
|
451
|
+
* in again. Without either, SessionEndedError.
|
|
452
|
+
*/
|
|
453
|
+
async recover() {
|
|
355
454
|
if (!this.auth.relogin) {
|
|
356
455
|
const auth = this.auth;
|
|
357
456
|
const attempt = (async () => {
|
|
457
|
+
if (await this.adoptShared())
|
|
458
|
+
return;
|
|
459
|
+
if (!(auth.reloginAllowed && auth.username && auth.password)) {
|
|
460
|
+
auth.loggedIn = false;
|
|
461
|
+
auth.endedByWilma = true;
|
|
462
|
+
auth.onEvent?.("ended");
|
|
463
|
+
throw new SessionEndedError();
|
|
464
|
+
}
|
|
358
465
|
auth.loggedIn = false;
|
|
359
466
|
// Start from a clean jar: a leftover session cookie must not make a
|
|
360
467
|
// failed login look successful.
|
|
@@ -389,12 +496,99 @@ export class WilmaSession {
|
|
|
389
496
|
}
|
|
390
497
|
await this.auth.relogin;
|
|
391
498
|
}
|
|
499
|
+
/** Continue the session another program saved, if it differs from ours and Wilma still accepts it. */
|
|
500
|
+
async adoptShared() {
|
|
501
|
+
const auth = this.auth;
|
|
502
|
+
if (!auth.shared)
|
|
503
|
+
return false;
|
|
504
|
+
let jar;
|
|
505
|
+
try {
|
|
506
|
+
const state = await auth.shared.load();
|
|
507
|
+
if (!state)
|
|
508
|
+
return false;
|
|
509
|
+
jar = CookieJar.deserializeSync(JSON.parse(state));
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
return false;
|
|
513
|
+
}
|
|
514
|
+
const theirs = sessionCookie(jar, this.baseUrl);
|
|
515
|
+
if (!theirs || theirs === sessionCookie(auth.cookieJar, this.baseUrl))
|
|
516
|
+
return false;
|
|
517
|
+
const ours = auth.cookieJar;
|
|
518
|
+
auth.cookieJar = jar;
|
|
519
|
+
if (!(await this.sessionAlive())) {
|
|
520
|
+
auth.cookieJar = ours;
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
if (this.debug)
|
|
524
|
+
console.error("[wilmai] continuing the session another program saved");
|
|
525
|
+
auth.loggedIn = true;
|
|
526
|
+
auth.endedByWilma = false;
|
|
527
|
+
auth.failed = null;
|
|
528
|
+
auth.generation += 1;
|
|
529
|
+
auth.onEvent?.("adopted");
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
532
|
+
/** After a login: tell the host (onLogin) and save the session for the other programs. */
|
|
533
|
+
loggedInNow() {
|
|
534
|
+
this.auth.onLogin?.();
|
|
535
|
+
this.auth.onEvent?.("login");
|
|
536
|
+
void this.auth.shared?.save(this.exportState()).catch(() => { });
|
|
537
|
+
}
|
|
538
|
+
/** Changes whenever the session behind this client changes (a login, or another program's session continued). */
|
|
539
|
+
get sessionMark() {
|
|
540
|
+
return this.auth.generation;
|
|
541
|
+
}
|
|
392
542
|
async get(path, init, opts) {
|
|
393
543
|
return this.request(path, { ...init, method: "GET" }, opts);
|
|
394
544
|
}
|
|
395
545
|
async post(path, init) {
|
|
396
546
|
return this.request(path, { ...init, method: "POST" });
|
|
397
547
|
}
|
|
548
|
+
/**
|
|
549
|
+
* Send a form that changes something in Wilma (a reply, a sign-up, an
|
|
550
|
+
* absence report): one POST, never repeated. Unlike request(), it doesn't
|
|
551
|
+
* log in again and retry when the session has ended — after a new login
|
|
552
|
+
* the form's key is stale, and a write must not be sent twice — it fails
|
|
553
|
+
* with SessionEndedError instead (nothing was sent; prepare the form
|
|
554
|
+
* again). Redirects within Wilma are followed; the answer is returned for
|
|
555
|
+
* the caller to check.
|
|
556
|
+
*/
|
|
557
|
+
async submit(path, form, opts = {}) {
|
|
558
|
+
const auth = this.auth;
|
|
559
|
+
if (auth.failed)
|
|
560
|
+
throw auth.failed;
|
|
561
|
+
if (auth.relogin)
|
|
562
|
+
await auth.relogin.catch(() => { });
|
|
563
|
+
if (!auth.loggedIn)
|
|
564
|
+
throw new SessionEndedError("Not logged in to Wilma; nothing was sent");
|
|
565
|
+
const prefixedPath = this.getPrefixedPath(path);
|
|
566
|
+
const body = (form instanceof URLSearchParams ? form : new URLSearchParams(form)).toString();
|
|
567
|
+
const generation = auth.generation;
|
|
568
|
+
const resp = await this.rawRequest(prefixedPath, {
|
|
569
|
+
method: "POST",
|
|
570
|
+
body,
|
|
571
|
+
headers: { ...opts.headers, "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" },
|
|
572
|
+
stopAtLogin: true,
|
|
573
|
+
});
|
|
574
|
+
// 403 is also how Wilma answers a session another login cancelled.
|
|
575
|
+
const ended = resp.status === 401 ||
|
|
576
|
+
this.isLoginRedirect(resp) ||
|
|
577
|
+
(resp.status === 403 && auth.generation === generation && !(await this.sessionAlive()));
|
|
578
|
+
if (ended) {
|
|
579
|
+
await resp.body?.cancel();
|
|
580
|
+
auth.loggedIn = false;
|
|
581
|
+
auth.endedByWilma = true;
|
|
582
|
+
auth.onEvent?.("ended");
|
|
583
|
+
// The form belonged to the ended session. Preparing again (a read) continues another program's session, or logs in when allowed.
|
|
584
|
+
throw new SessionEndedError("Wilma ended the session; nothing was sent. Prepare it again.");
|
|
585
|
+
}
|
|
586
|
+
if (resp.status >= 300 && resp.status < 400) {
|
|
587
|
+
await resp.body?.cancel();
|
|
588
|
+
throw new APIError(`Wilma redirected to another site at ${redactPath(prefixedPath)}`, resp.status);
|
|
589
|
+
}
|
|
590
|
+
return resp;
|
|
591
|
+
}
|
|
398
592
|
async getLoginToken() {
|
|
399
593
|
const resp = await this.rawRequest("/token", { method: "GET" });
|
|
400
594
|
if (resp.status !== 200) {
|
|
@@ -456,17 +650,20 @@ export class WilmaSession {
|
|
|
456
650
|
}
|
|
457
651
|
baseHeaders.set("User-Agent", USER_AGENT);
|
|
458
652
|
baseHeaders.set("Referer", `${this.baseUrl}/`);
|
|
459
|
-
const
|
|
653
|
+
const stopAtLogin = init?.stopAtLogin;
|
|
460
654
|
const follow = init?.redirect !== "manual";
|
|
461
655
|
const idle = new IdleTimer(timeoutMs);
|
|
462
656
|
const signal = init?.signal ? AbortSignal.any([init.signal, idle.signal]) : idle.signal;
|
|
463
657
|
let method = (init?.method ?? "GET").toUpperCase();
|
|
464
658
|
let body = init?.body;
|
|
465
659
|
for (let hop = 0;; hop += 1) {
|
|
466
|
-
const headers =
|
|
660
|
+
const headers = {};
|
|
661
|
+
baseHeaders.forEach((value, key) => {
|
|
662
|
+
headers[key] = value;
|
|
663
|
+
});
|
|
467
664
|
const cookieHeader = this.auth.cookieJar.getCookieStringSync(url.href);
|
|
468
665
|
if (cookieHeader)
|
|
469
|
-
headers
|
|
666
|
+
headers["cookie"] = cookieHeader;
|
|
470
667
|
if (this.debug) {
|
|
471
668
|
// Method and path only: never headers, bodies or query strings.
|
|
472
669
|
console.error(`[wilmai] ${method} ${redactPath(url.pathname)}`);
|
|
@@ -474,30 +671,28 @@ export class WilmaSession {
|
|
|
474
671
|
let resp;
|
|
475
672
|
try {
|
|
476
673
|
idle.touch();
|
|
477
|
-
|
|
674
|
+
const reply = await this.auth.transport({ url: url.href, method, headers, body, signal });
|
|
675
|
+
for (const cookie of reply.setCookies) {
|
|
676
|
+
// A cookie Wilma isn't allowed to set (e.g. for another domain) is dropped, not fatal.
|
|
677
|
+
this.auth.cookieJar.setCookieSync(cookie, url.href, { ignoreError: true });
|
|
678
|
+
}
|
|
679
|
+
resp = watchedResponse({ status: reply.status, headers: reply.headers, body: reply.body, url: url.href }, idle, origin);
|
|
478
680
|
}
|
|
479
681
|
catch (err) {
|
|
480
682
|
idle.stop();
|
|
481
683
|
// Timeouts, and `fetch failed` with its cause code and remediation attached.
|
|
482
684
|
throw asNetworkError(err, origin);
|
|
483
685
|
}
|
|
484
|
-
const headersAny = resp.headers;
|
|
485
|
-
const setCookies = headersAny.getSetCookie?.() ?? [];
|
|
486
|
-
const single = setCookies.length ? [] : [resp.headers.get("set-cookie")].filter((c) => !!c);
|
|
487
|
-
for (const cookie of [...setCookies, ...single]) {
|
|
488
|
-
// A cookie Wilma isn't allowed to set (e.g. for another domain) is dropped, not fatal.
|
|
489
|
-
this.auth.cookieJar.setCookieSync(cookie, url.href, { ignoreError: true });
|
|
490
|
-
}
|
|
491
686
|
const location = resp.headers.get("location");
|
|
492
687
|
if (!follow || resp.status < 300 || resp.status >= 400 || !location) {
|
|
493
|
-
return
|
|
688
|
+
return resp;
|
|
494
689
|
}
|
|
495
690
|
const next = new URL(location, url);
|
|
496
691
|
if (next.origin !== origin) {
|
|
497
|
-
return
|
|
692
|
+
return resp; // hand the off-site redirect back to the caller
|
|
498
693
|
}
|
|
499
694
|
if (stopAtLogin && /^\/login\/?$/i.test(next.pathname)) {
|
|
500
|
-
return
|
|
695
|
+
return resp; // logged out: the caller decides
|
|
501
696
|
}
|
|
502
697
|
if (hop >= MAX_REDIRECTS) {
|
|
503
698
|
idle.stop();
|
|
@@ -532,6 +727,18 @@ function isPermanentLoginFailure(err) {
|
|
|
532
727
|
return (err instanceof MfaRequiredError ||
|
|
533
728
|
(err instanceof AuthenticationError && /^Wilma login failed/.test(err.message)));
|
|
534
729
|
}
|
|
730
|
+
/** Does the jar hold a Wilma session cookie for this Wilma? */
|
|
731
|
+
function holdsSession(jar, baseUrl) {
|
|
732
|
+
return sessionCookie(jar, baseUrl) !== null;
|
|
733
|
+
}
|
|
734
|
+
/** The Wilma session cookie's value, telling sessions apart. */
|
|
735
|
+
function sessionCookie(jar, baseUrl) {
|
|
736
|
+
return jar.getCookiesSync(baseUrl).find((cookie) => cookie.key === "Wilma2SID")?.value ?? null;
|
|
737
|
+
}
|
|
738
|
+
/** The formkey of Wilma's logout form (`<form id="logout-form" action="/logout">`, on every page of a live session). */
|
|
739
|
+
function logoutFormkey(html) {
|
|
740
|
+
return cheerio.load(html)('form#logout-form input[name="formkey"]').attr("value") || null;
|
|
741
|
+
}
|
|
535
742
|
function isLoginOk(text) {
|
|
536
743
|
return !LOGIN_FAIL_RE.test(text);
|
|
537
744
|
}
|
package/dist/timeouts.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { Response } from "undici";
|
|
2
1
|
/** An abort signal that fires after `ms` without progress. */
|
|
3
2
|
export declare class IdleTimer {
|
|
4
3
|
private readonly ms;
|
|
@@ -17,4 +16,20 @@ export declare function asNetworkError(err: unknown, origin: string): unknown;
|
|
|
17
16
|
* restarts the wait, and a stall or broken connection while reading surfaces
|
|
18
17
|
* as a NetworkError, like one before the response.
|
|
19
18
|
*/
|
|
20
|
-
export declare function watchBody(response:
|
|
19
|
+
export declare function watchBody(response: {
|
|
20
|
+
status: number;
|
|
21
|
+
statusText?: string;
|
|
22
|
+
headers: unknown;
|
|
23
|
+
body: unknown;
|
|
24
|
+
url: string;
|
|
25
|
+
}, idle: IdleTimer, origin: string): Response;
|
|
26
|
+
export interface ResponseParts {
|
|
27
|
+
status: number;
|
|
28
|
+
statusText?: string;
|
|
29
|
+
headers: Headers;
|
|
30
|
+
body: ReadableStream<Uint8Array> | string | null;
|
|
31
|
+
/** The address that answered: parsers resolve links against it. */
|
|
32
|
+
url: string;
|
|
33
|
+
}
|
|
34
|
+
/** A standard Response from a transport's reply, its body read under the idle timer. */
|
|
35
|
+
export declare function watchedResponse(parts: ResponseParts, idle: IdleTimer, origin: string): Response;
|
package/dist/timeouts.js
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
* read — not after a fixed total, so a large attachment on a slow connection
|
|
5
5
|
* still finishes while a stalled one doesn't hang forever.
|
|
6
6
|
*/
|
|
7
|
-
import { Response } from "undici";
|
|
8
7
|
import { NetworkError, wrapNetworkError } from "./network-error.js";
|
|
9
8
|
/** An abort signal that fires after `ms` without progress. */
|
|
10
9
|
export class IdleTimer {
|
|
@@ -22,8 +21,8 @@ export class IdleTimer {
|
|
|
22
21
|
touch() {
|
|
23
22
|
clearTimeout(this.timer);
|
|
24
23
|
this.timer = setTimeout(() => this.controller.abort(new DOMException("The server stopped answering", "TimeoutError")), this.ms);
|
|
25
|
-
// A response nobody reads must not keep the process alive.
|
|
26
|
-
this.timer
|
|
24
|
+
// A response nobody reads must not keep the process alive (Node only).
|
|
25
|
+
this.timer?.unref?.();
|
|
27
26
|
}
|
|
28
27
|
stop() {
|
|
29
28
|
clearTimeout(this.timer);
|
|
@@ -44,11 +43,28 @@ const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]);
|
|
|
44
43
|
* as a NetworkError, like one before the response.
|
|
45
44
|
*/
|
|
46
45
|
export function watchBody(response, idle, origin) {
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
return watchedResponse({
|
|
47
|
+
status: response.status,
|
|
48
|
+
statusText: response.statusText,
|
|
49
|
+
headers: response.headers,
|
|
50
|
+
body: response.body,
|
|
51
|
+
url: response.url,
|
|
52
|
+
}, idle, origin);
|
|
53
|
+
}
|
|
54
|
+
/** A standard Response from a transport's reply, its body read under the idle timer. */
|
|
55
|
+
export function watchedResponse(parts, idle, origin) {
|
|
56
|
+
const make = (body) => {
|
|
57
|
+
const response = new Response(body, { status: parts.status, statusText: parts.statusText, headers: parts.headers });
|
|
58
|
+
Object.defineProperty(response, "url", { value: parts.url });
|
|
49
59
|
return response;
|
|
60
|
+
};
|
|
61
|
+
if (parts.body === null || typeof parts.body === "string" || NULL_BODY_STATUSES.has(parts.status)) {
|
|
62
|
+
idle.stop();
|
|
63
|
+
if (parts.body && typeof parts.body !== "string")
|
|
64
|
+
void parts.body.cancel().catch(() => { });
|
|
65
|
+
return make(NULL_BODY_STATUSES.has(parts.status) ? null : parts.body);
|
|
50
66
|
}
|
|
51
|
-
const reader =
|
|
67
|
+
const reader = parts.body.getReader();
|
|
52
68
|
const body = new ReadableStream({
|
|
53
69
|
async pull(controller) {
|
|
54
70
|
try {
|
|
@@ -71,12 +87,5 @@ export function watchBody(response, idle, origin) {
|
|
|
71
87
|
return reader.cancel(reason);
|
|
72
88
|
},
|
|
73
89
|
});
|
|
74
|
-
|
|
75
|
-
status: response.status,
|
|
76
|
-
statusText: response.statusText,
|
|
77
|
-
headers: response.headers,
|
|
78
|
-
});
|
|
79
|
-
// Parsers resolve links against the final address.
|
|
80
|
-
Object.defineProperty(watched, "url", { value: response.url });
|
|
81
|
-
return watched;
|
|
90
|
+
return make(body);
|
|
82
91
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/* The Node transport: undici's fetch, redirects not followed. */
|
|
2
|
+
import { fetch } from "undici";
|
|
3
|
+
export const nodeTransport = async (request) => {
|
|
4
|
+
const resp = await fetch(request.url, {
|
|
5
|
+
method: request.method,
|
|
6
|
+
headers: request.headers,
|
|
7
|
+
body: request.body,
|
|
8
|
+
signal: request.signal,
|
|
9
|
+
redirect: "manual",
|
|
10
|
+
});
|
|
11
|
+
const headers = resp.headers;
|
|
12
|
+
const setCookies = headers.getSetCookie?.() ?? [resp.headers.get("set-cookie")].filter((c) => Boolean(c));
|
|
13
|
+
return {
|
|
14
|
+
status: resp.status,
|
|
15
|
+
headers,
|
|
16
|
+
setCookies,
|
|
17
|
+
body: resp.body,
|
|
18
|
+
};
|
|
19
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface WilmaRequest {
|
|
2
|
+
url: string;
|
|
3
|
+
method: string;
|
|
4
|
+
headers: Record<string, string>;
|
|
5
|
+
body?: string;
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
}
|
|
8
|
+
export interface WilmaReply {
|
|
9
|
+
status: number;
|
|
10
|
+
/** Response headers; Set-Cookie may be missing here (browsers hide it), see setCookies. */
|
|
11
|
+
headers: Headers;
|
|
12
|
+
/** Every Set-Cookie header the response had, as sent. */
|
|
13
|
+
setCookies: string[];
|
|
14
|
+
/** The body as a stream, or a string when the transport already has it all; null when there is none. */
|
|
15
|
+
body: ReadableStream<Uint8Array> | string | null;
|
|
16
|
+
}
|
|
17
|
+
/** Send one request. Must not follow redirects: the session follows them itself, hop by hop. */
|
|
18
|
+
export type WilmaTransport = (request: WilmaRequest) => Promise<WilmaReply>;
|
|
19
|
+
/** Set the transport sessions use when none is given (the Node entry point sets undici). */
|
|
20
|
+
export declare function setDefaultTransport(transport: WilmaTransport | null): void;
|
|
21
|
+
export declare function getDefaultTransport(): WilmaTransport;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* How the client talks to a Wilma: one HTTP exchange at a time, redirects not
|
|
3
|
+
* followed, cookies handled by the session. Node uses undici (the default from
|
|
4
|
+
* the main entry point); a browser or desktop app passes its own transport to
|
|
5
|
+
* the "core" entry point, for example a native command that sends the request,
|
|
6
|
+
* because web pages can't call Wilma directly (no CORS) and can't read
|
|
7
|
+
* Set-Cookie headers.
|
|
8
|
+
*/
|
|
9
|
+
let defaultTransport = null;
|
|
10
|
+
/** Set the transport sessions use when none is given (the Node entry point sets undici). */
|
|
11
|
+
export function setDefaultTransport(transport) {
|
|
12
|
+
defaultTransport = transport;
|
|
13
|
+
}
|
|
14
|
+
export function getDefaultTransport() {
|
|
15
|
+
if (!defaultTransport) {
|
|
16
|
+
throw new Error("No transport for Wilma: pass `transport` in the client options (or use @wilm-ai/wilma-client, which uses undici on Node).");
|
|
17
|
+
}
|
|
18
|
+
return defaultTransport;
|
|
19
|
+
}
|