@whaletech/pet 0.2.1 → 0.2.2

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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/main.js +205 -79
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -22,7 +22,7 @@ pet
22
22
 
23
23
  **Server** (self-hosted): Bun + SQLite backend handling state, AI chat, and payments. Deploy separately.
24
24
 
25
- The client connects to `https://pet.whaletech.ai` by default. Override with:
25
+ The client connects to `https://api.pet.whaletech.app` by default, with a fallback to `https://pet-api.whaletech.app`. Override with:
26
26
 
27
27
  ```
28
28
  WHALE_API_URL=http://localhost:3001 pet
package/dist/main.js CHANGED
@@ -409,35 +409,142 @@ function getCompanionFromStored(userId, stored) {
409
409
  }
410
410
 
411
411
  // src/api.ts
412
- var API_URL = process.env["WHALE_API_URL"] ?? "https://api.pet.whaletech.app";
412
+ import { spawn } from "node:child_process";
413
+ var PRIMARY_API_URL = "https://api.pet.whaletech.app";
414
+ var FALLBACK_API_URL = "https://pet-api.whaletech.app";
415
+ var API_URLS = [
416
+ process.env["WHALE_API_URL"],
417
+ PRIMARY_API_URL,
418
+ FALLBACK_API_URL
419
+ ].filter((url, index, urls) => Boolean(url) && urls.indexOf(url) === index);
420
+ var CURL = process.platform === "win32" ? "curl.exe" : "curl";
413
421
  var _token = null;
414
422
  function setToken(token) {
415
423
  _token = token;
416
424
  }
417
- async function post(path2, body) {
418
- try {
419
- if (_token)
420
- body.token = _token;
421
- const res = await fetch(`${API_URL}${path2}`, {
422
- method: "POST",
423
- headers: { "Content-Type": "application/json" },
424
- body: JSON.stringify(body)
425
+ function isRetryableNetworkError(err) {
426
+ if (!(err instanceof Error))
427
+ return false;
428
+ const text = [err.message, err.cause instanceof Error ? err.cause.message : "", String(err.cause?.code ?? "")].join(" ").toUpperCase();
429
+ return ["FETCH FAILED", "ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED", "ECONNRESET", "ETIMEDOUT"].some((token) => text.includes(token));
430
+ }
431
+ function runCurl(args, input) {
432
+ return new Promise((resolve, reject) => {
433
+ const child = spawn(CURL, args, { stdio: ["pipe", "pipe", "pipe"] });
434
+ let stdout = "";
435
+ let stderr = "";
436
+ child.stdout.on("data", (chunk) => {
437
+ stdout += String(chunk);
425
438
  });
426
- const data = await res.json();
427
- if (!res.ok) {
428
- const isQuotaExceeded = data.error === "quota_exceeded";
429
- return {
430
- ok: false,
431
- error: String(data.error ?? `HTTP ${res.status}`),
432
- quotaExceeded: isQuotaExceeded,
433
- paymentType: isQuotaExceeded ? String(data.paymentType) : undefined
434
- };
439
+ child.stderr.on("data", (chunk) => {
440
+ stderr += String(chunk);
441
+ });
442
+ child.on("error", reject);
443
+ child.on("close", (code) => {
444
+ if (code !== 0) {
445
+ reject(new Error(stderr.trim() || `${CURL} exited with code ${code}`));
446
+ return;
447
+ }
448
+ const marker = `
449
+ __CURL_STATUS__:`;
450
+ const index = stdout.lastIndexOf(marker);
451
+ if (index === -1) {
452
+ reject(new Error("curl response missing status marker"));
453
+ return;
454
+ }
455
+ const body = stdout.slice(0, index);
456
+ const status = Number(stdout.slice(index + marker.length).trim());
457
+ resolve({ status, body });
458
+ });
459
+ child.stdin.end(input);
460
+ });
461
+ }
462
+ async function postWithCurl(apiUrl, path2, body) {
463
+ const requestBody = _token ? { ...body, token: _token } : body;
464
+ const response = await runCurl(["-sS", "-X", "POST", `${apiUrl}${path2}`, "-H", "Content-Type: application/json", "--data-binary", "@-", "-w", `
465
+ __CURL_STATUS__:%{http_code}`], JSON.stringify(requestBody));
466
+ const data = JSON.parse(response.body);
467
+ if (response.status < 200 || response.status >= 300) {
468
+ const isQuotaExceeded = data.error === "quota_exceeded";
469
+ return {
470
+ ok: false,
471
+ error: String(data.error ?? `HTTP ${response.status}`),
472
+ quotaExceeded: isQuotaExceeded,
473
+ paymentType: isQuotaExceeded ? String(data.paymentType) : undefined
474
+ };
475
+ }
476
+ return { ok: true, data };
477
+ }
478
+ async function sendChatStreamWithCurl(apiUrl, body, onToken) {
479
+ const requestBody = _token ? { ...body, token: _token } : body;
480
+ const response = await runCurl(["-sS", "-N", "-X", "POST", `${apiUrl}/api/chat`, "-H", "Content-Type: application/json", "--data-binary", "@-", "-w", `
481
+ __CURL_STATUS__:%{http_code}`], JSON.stringify(requestBody));
482
+ if (response.status < 200 || response.status >= 300) {
483
+ const data = JSON.parse(response.body);
484
+ const isQuotaExceeded = data.error === "quota_exceeded";
485
+ return {
486
+ ok: false,
487
+ error: String(data.error ?? `HTTP ${response.status}`),
488
+ quotaExceeded: isQuotaExceeded,
489
+ paymentType: isQuotaExceeded ? String(data.paymentType) : undefined
490
+ };
491
+ }
492
+ let finalState = null;
493
+ let finalQuota = null;
494
+ for (const line of response.body.split(`
495
+ `)) {
496
+ const trimmed = line.trim();
497
+ if (!trimmed || !trimmed.startsWith("data: "))
498
+ continue;
499
+ try {
500
+ const data = JSON.parse(trimmed.slice(6));
501
+ if (data.token)
502
+ onToken(data.token);
503
+ if (data.done && data.state && data.quota) {
504
+ finalState = data.state;
505
+ finalQuota = data.quota;
506
+ }
507
+ } catch {}
508
+ }
509
+ if (finalState && finalQuota) {
510
+ return { ok: true, state: finalState, quota: finalQuota };
511
+ }
512
+ return { ok: false, error: "stream ended without state" };
513
+ }
514
+ async function post(path2, body) {
515
+ let lastError = "unknown error";
516
+ for (const apiUrl of API_URLS) {
517
+ try {
518
+ const requestBody = _token ? { ...body, token: _token } : body;
519
+ const res = await fetch(`${apiUrl}${path2}`, {
520
+ method: "POST",
521
+ headers: { "Content-Type": "application/json" },
522
+ body: JSON.stringify(requestBody)
523
+ });
524
+ const data = await res.json();
525
+ if (!res.ok) {
526
+ const isQuotaExceeded = data.error === "quota_exceeded";
527
+ return {
528
+ ok: false,
529
+ error: String(data.error ?? `HTTP ${res.status}`),
530
+ quotaExceeded: isQuotaExceeded,
531
+ paymentType: isQuotaExceeded ? String(data.paymentType) : undefined
532
+ };
533
+ }
534
+ return { ok: true, data };
535
+ } catch (err) {
536
+ if (isRetryableNetworkError(err)) {
537
+ try {
538
+ return await postWithCurl(apiUrl, path2, body);
539
+ } catch (curlErr) {
540
+ lastError = curlErr instanceof Error ? curlErr.message : "unknown error";
541
+ continue;
542
+ }
543
+ }
544
+ lastError = err instanceof Error ? err.message : "unknown error";
435
545
  }
436
- return { ok: true, data };
437
- } catch (err) {
438
- const msg = err instanceof Error ? err.message : "unknown error";
439
- return { ok: false, error: msg };
440
546
  }
547
+ return { ok: false, error: lastError };
441
548
  }
442
549
  function syncState(userId, localState) {
443
550
  return post("/api/sync", { userId, localState });
@@ -452,69 +559,88 @@ async function persistState(userId, state, quota) {
452
559
  return result;
453
560
  }
454
561
  async function sendChatStream(userId, companion, state, message, onToken) {
455
- try {
456
- const body = {
457
- userId,
458
- name: companion.name,
459
- personality: companion.personality,
460
- species: companion.species,
461
- mood: deriveMood(state),
462
- needs: state.needs,
463
- message
464
- };
465
- if (_token)
466
- body.token = _token;
467
- const res = await fetch(`${API_URL}/api/chat`, {
468
- method: "POST",
469
- headers: { "Content-Type": "application/json" },
470
- body: JSON.stringify(body)
471
- });
472
- if (!res.ok) {
473
- const data = await res.json();
474
- const isQuotaExceeded = data.error === "quota_exceeded";
475
- return {
476
- ok: false,
477
- error: String(data.error ?? `HTTP ${res.status}`),
478
- quotaExceeded: isQuotaExceeded,
479
- paymentType: isQuotaExceeded ? String(data.paymentType) : undefined
562
+ let lastError = "unknown error";
563
+ for (const apiUrl of API_URLS) {
564
+ try {
565
+ const body = {
566
+ userId,
567
+ name: companion.name,
568
+ personality: companion.personality,
569
+ species: companion.species,
570
+ mood: deriveMood(state),
571
+ needs: state.needs,
572
+ message
480
573
  };
481
- }
482
- const reader = res.body.getReader();
483
- const decoder = new TextDecoder;
484
- let buffer = "";
485
- let finalState = null;
486
- let finalQuota = null;
487
- while (true) {
488
- const { done, value } = await reader.read();
489
- if (done)
490
- break;
491
- buffer += decoder.decode(value, { stream: true });
492
- const lines = buffer.split(`
574
+ if (_token)
575
+ body.token = _token;
576
+ const res = await fetch(`${apiUrl}/api/chat`, {
577
+ method: "POST",
578
+ headers: { "Content-Type": "application/json" },
579
+ body: JSON.stringify(body)
580
+ });
581
+ if (!res.ok) {
582
+ const data = await res.json();
583
+ const isQuotaExceeded = data.error === "quota_exceeded";
584
+ return {
585
+ ok: false,
586
+ error: String(data.error ?? `HTTP ${res.status}`),
587
+ quotaExceeded: isQuotaExceeded,
588
+ paymentType: isQuotaExceeded ? String(data.paymentType) : undefined
589
+ };
590
+ }
591
+ const reader = res.body.getReader();
592
+ const decoder = new TextDecoder;
593
+ let buffer = "";
594
+ let finalState = null;
595
+ let finalQuota = null;
596
+ while (true) {
597
+ const { done, value } = await reader.read();
598
+ if (done)
599
+ break;
600
+ buffer += decoder.decode(value, { stream: true });
601
+ const lines = buffer.split(`
493
602
  `);
494
- buffer = lines.pop();
495
- for (const line of lines) {
496
- const trimmed = line.trim();
497
- if (!trimmed || !trimmed.startsWith("data: "))
498
- continue;
603
+ buffer = lines.pop();
604
+ for (const line of lines) {
605
+ const trimmed = line.trim();
606
+ if (!trimmed || !trimmed.startsWith("data: "))
607
+ continue;
608
+ try {
609
+ const data = JSON.parse(trimmed.slice(6));
610
+ if (data.token)
611
+ onToken(data.token);
612
+ if (data.done && data.state && data.quota) {
613
+ finalState = data.state;
614
+ finalQuota = data.quota;
615
+ }
616
+ } catch {}
617
+ }
618
+ }
619
+ if (finalState && finalQuota) {
620
+ return { ok: true, state: finalState, quota: finalQuota };
621
+ }
622
+ return { ok: false, error: "stream ended without state" };
623
+ } catch (err) {
624
+ if (isRetryableNetworkError(err)) {
499
625
  try {
500
- const data = JSON.parse(trimmed.slice(6));
501
- if (data.token)
502
- onToken(data.token);
503
- if (data.done && data.state && data.quota) {
504
- finalState = data.state;
505
- finalQuota = data.quota;
506
- }
507
- } catch {}
626
+ return await sendChatStreamWithCurl(apiUrl, {
627
+ userId,
628
+ name: companion.name,
629
+ personality: companion.personality,
630
+ species: companion.species,
631
+ mood: deriveMood(state),
632
+ needs: state.needs,
633
+ message
634
+ }, onToken);
635
+ } catch (curlErr) {
636
+ lastError = curlErr instanceof Error ? curlErr.message : "unknown error";
637
+ continue;
638
+ }
508
639
  }
640
+ lastError = err instanceof Error ? err.message : "unknown error";
509
641
  }
510
- if (finalState && finalQuota) {
511
- return { ok: true, state: finalState, quota: finalQuota };
512
- }
513
- return { ok: false, error: "stream ended without state" };
514
- } catch (err) {
515
- const msg = err instanceof Error ? err.message : "unknown error";
516
- return { ok: false, error: msg };
517
642
  }
643
+ return { ok: false, error: lastError };
518
644
  }
519
645
  function sendInteraction(userId, type) {
520
646
  return post("/api/interaction", { userId, type });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whaletech/pet",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Terminal pet companion — hatch, feed, and chat with your ASCII whale",
5
5
  "type": "module",
6
6
  "license": "MIT",