@wilm-ai/wilma-client 1.6.1 → 1.7.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/dist/session.js CHANGED
@@ -1,6 +1,7 @@
1
+ import * as cheerio from "cheerio";
1
2
  import { CookieJar } from "tough-cookie";
2
- import { fetch } from "undici";
3
- import { IdleTimer, asNetworkError, watchBody } from "./timeouts.js";
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 = { cookieJar: new CookieJar(), loggedIn: false, relogin: null, generation: 0, failed: null };
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. Returns false when the
51
- * state holds no session to resume.
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.getCookiesSync(this.baseUrl).some((cookie) => cookie.key === "Wilma2SID"))
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.auth.onLogin?.();
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.auth.onLogin?.();
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
- // An earlier re-login failed (Wilma down, network blip): try again now.
251
- if (auth.username && auth.password)
252
- await this.relogin();
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 ((loggedOut(resp) || resp.status === 403) && auth.username && auth.password) {
268
- const someoneElseLoggedIn = () => auth.generation !== generation || auth.relogin !== null;
269
- let retry = someoneElseLoggedIn();
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
- // Check again: another request may have logged in while we probed.
272
- if (!someoneElseLoggedIn())
273
- await this.relogin();
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
- /** Log in again, once for everyone sharing this login even if many requests notice at the same time. */
354
- async relogin() {
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 { stopAtLogin, ...fetchInit } = init ?? {};
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 = new Headers(baseHeaders);
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.set("Cookie", cookieHeader);
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
- resp = await fetch(url, { ...fetchInit, method, body, headers, signal, redirect: "manual" });
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 watchBody(resp, idle, origin);
688
+ return resp;
494
689
  }
495
690
  const next = new URL(location, url);
496
691
  if (next.origin !== origin) {
497
- return watchBody(resp, idle, origin); // hand the off-site redirect back to the caller
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 watchBody(resp, idle, origin); // logged out: the caller decides
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
  }
@@ -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: Response, idle: IdleTimer, origin: string): 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.unref?.();
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
- if (!response.body || NULL_BODY_STATUSES.has(response.status)) {
48
- idle.stop();
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 = response.body.getReader();
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
- const watched = new Response(body, {
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,2 @@
1
+ import type { WilmaTransport } from "./transport.js";
2
+ export declare const nodeTransport: WilmaTransport;
@@ -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
+ }