@zfadhli/tokrec 0.13.0 → 0.15.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 (126) hide show
  1. package/README.md +99 -403
  2. package/dist/app/abort.mjs +14 -0
  3. package/dist/app/abort.mjs.map +1 -0
  4. package/dist/{lib → app}/config.d.mts +73 -71
  5. package/dist/app/config.d.mts.map +1 -0
  6. package/dist/app/config.mjs +26 -0
  7. package/dist/app/config.mjs.map +1 -0
  8. package/dist/app/events.mjs +36 -0
  9. package/dist/app/events.mjs.map +1 -0
  10. package/dist/app/job.mjs +190 -0
  11. package/dist/app/job.mjs.map +1 -0
  12. package/dist/{lib → app}/logger.mjs +1 -1
  13. package/dist/app/logger.mjs.map +1 -0
  14. package/dist/{core/monitor.mjs → app/loop.mjs} +19 -9
  15. package/dist/app/loop.mjs.map +1 -0
  16. package/dist/app/recorder.d.mts +7 -0
  17. package/dist/app/recorder.d.mts.map +1 -0
  18. package/dist/app/recorder.mjs +233 -0
  19. package/dist/app/recorder.mjs.map +1 -0
  20. package/dist/core/errors.d.mts +16 -0
  21. package/dist/core/errors.d.mts.map +1 -0
  22. package/dist/core/errors.mjs +15 -0
  23. package/dist/core/errors.mjs.map +1 -0
  24. package/dist/core/format.d.mts +12 -0
  25. package/dist/core/format.d.mts.map +1 -0
  26. package/dist/{lib/utils.mjs → core/format.mjs} +6 -41
  27. package/dist/core/format.mjs.map +1 -0
  28. package/dist/core/plan.mjs +52 -0
  29. package/dist/core/plan.mjs.map +1 -0
  30. package/dist/core/verdict.d.mts +44 -0
  31. package/dist/core/verdict.d.mts.map +1 -0
  32. package/dist/core/verdict.mjs +47 -0
  33. package/dist/core/verdict.mjs.map +1 -0
  34. package/dist/index.d.mts +6 -3
  35. package/dist/index.mjs +5 -2
  36. package/dist/{core/api/client.mjs → io/http.mjs} +38 -10
  37. package/dist/io/http.mjs.map +1 -0
  38. package/dist/io/media/convert.mjs +60 -0
  39. package/dist/io/media/convert.mjs.map +1 -0
  40. package/dist/{core/recorder → io/media}/download.mjs +8 -7
  41. package/dist/io/media/download.mjs.map +1 -0
  42. package/dist/{core/recorder/ffmpeg-utils.mjs → io/media/ffmpeg.mjs} +45 -5
  43. package/dist/io/media/ffmpeg.mjs.map +1 -0
  44. package/dist/{core/recorder → io/media}/normalize.mjs +1 -1
  45. package/dist/io/media/normalize.mjs.map +1 -0
  46. package/dist/io/tiktok/client.mjs +77 -0
  47. package/dist/io/tiktok/client.mjs.map +1 -0
  48. package/dist/io/tiktok/discovery/chain.mjs +117 -0
  49. package/dist/io/tiktok/discovery/chain.mjs.map +1 -0
  50. package/dist/io/tiktok/discovery/fallback.mjs +64 -0
  51. package/dist/io/tiktok/discovery/fallback.mjs.map +1 -0
  52. package/dist/io/tiktok/discovery/page.mjs +102 -0
  53. package/dist/io/tiktok/discovery/page.mjs.map +1 -0
  54. package/dist/io/tiktok/page.mjs +34 -0
  55. package/dist/io/tiktok/page.mjs.map +1 -0
  56. package/dist/io/tiktok/parse.mjs +110 -0
  57. package/dist/io/tiktok/parse.mjs.map +1 -0
  58. package/dist/io/tiktok/webcast.mjs +132 -0
  59. package/dist/io/tiktok/webcast.mjs.map +1 -0
  60. package/package.json +5 -26
  61. package/src/app/abort.ts +18 -0
  62. package/src/app/config.ts +178 -0
  63. package/src/app/events.ts +46 -0
  64. package/src/app/job.ts +228 -0
  65. package/src/app/logger.ts +133 -0
  66. package/src/app/loop.ts +84 -0
  67. package/src/app/recorder.ts +288 -0
  68. package/src/core/errors.ts +24 -0
  69. package/src/core/format.ts +50 -0
  70. package/src/core/log.ts +13 -0
  71. package/src/core/plan.ts +49 -0
  72. package/src/core/verdict.ts +79 -0
  73. package/src/index.ts +38 -0
  74. package/src/io/http.ts +167 -0
  75. package/src/io/media/convert.ts +69 -0
  76. package/src/io/media/download.ts +120 -0
  77. package/src/io/media/ffmpeg.ts +201 -0
  78. package/src/io/media/normalize.ts +64 -0
  79. package/src/io/tiktok/client.ts +114 -0
  80. package/src/io/tiktok/discovery/chain.ts +146 -0
  81. package/src/io/tiktok/discovery/fallback.ts +88 -0
  82. package/src/io/tiktok/discovery/page.ts +142 -0
  83. package/src/io/tiktok/page.ts +42 -0
  84. package/src/io/tiktok/parse.ts +171 -0
  85. package/src/io/tiktok/webcast.ts +162 -0
  86. package/dist/bin.d.mts +0 -1
  87. package/dist/bin.mjs +0 -215
  88. package/dist/bin.mjs.map +0 -1
  89. package/dist/core/api/client.mjs.map +0 -1
  90. package/dist/core/api/rate-limiter.mjs +0 -29
  91. package/dist/core/api/rate-limiter.mjs.map +0 -1
  92. package/dist/core/api/tiktok.mjs +0 -394
  93. package/dist/core/api/tiktok.mjs.map +0 -1
  94. package/dist/core/monitor.mjs.map +0 -1
  95. package/dist/core/recorder/convert.mjs +0 -55
  96. package/dist/core/recorder/convert.mjs.map +0 -1
  97. package/dist/core/recorder/download.mjs.map +0 -1
  98. package/dist/core/recorder/ffmpeg-utils.mjs.map +0 -1
  99. package/dist/core/recorder/index.d.mts +0 -7
  100. package/dist/core/recorder/index.d.mts.map +0 -1
  101. package/dist/core/recorder/index.mjs +0 -205
  102. package/dist/core/recorder/index.mjs.map +0 -1
  103. package/dist/core/recorder/normalize.mjs.map +0 -1
  104. package/dist/core/recorder/post-processing.mjs +0 -154
  105. package/dist/core/recorder/post-processing.mjs.map +0 -1
  106. package/dist/core/recorder/recorder-events.mjs +0 -34
  107. package/dist/core/recorder/recorder-events.mjs.map +0 -1
  108. package/dist/core/recorder/recorder-state.mjs +0 -38
  109. package/dist/core/recorder/recorder-state.mjs.map +0 -1
  110. package/dist/core/recorder/stream.mjs +0 -25
  111. package/dist/core/recorder/stream.mjs.map +0 -1
  112. package/dist/lib/browser-cookies.mjs +0 -113
  113. package/dist/lib/browser-cookies.mjs.map +0 -1
  114. package/dist/lib/cli.mjs +0 -137
  115. package/dist/lib/cli.mjs.map +0 -1
  116. package/dist/lib/config.d.mts.map +0 -1
  117. package/dist/lib/config.mjs +0 -47
  118. package/dist/lib/config.mjs.map +0 -1
  119. package/dist/lib/logger.mjs.map +0 -1
  120. package/dist/lib/tmux.mjs +0 -84
  121. package/dist/lib/tmux.mjs.map +0 -1
  122. package/dist/lib/ui.mjs +0 -136
  123. package/dist/lib/ui.mjs.map +0 -1
  124. package/dist/lib/utils.mjs.map +0 -1
  125. package/dist/package.mjs +0 -6
  126. package/dist/package.mjs.map +0 -1
@@ -0,0 +1,171 @@
1
+ export interface SigiState {
2
+ LiveRoom?: {
3
+ liveRoomStatus?: number
4
+ liveRoomUserInfo?: {
5
+ user?: {
6
+ id?: string | number
7
+ roomId?: string | number
8
+ }
9
+ liveRoom?: {
10
+ status?: number // 2 = live, 4 = offline
11
+ title?: string
12
+ startTime?: number
13
+ streamData?: {
14
+ pull_data?: {
15
+ stream_data?: string // JSON string with FLV/HLS URLs
16
+ }
17
+ }
18
+ }
19
+ }
20
+ }
21
+ UserModule?: {
22
+ users?: {
23
+ [username: string]: {
24
+ id?: string // numeric user ID
25
+ uniqueId?: string
26
+ roomId?: string | { id: string }
27
+ }
28
+ }
29
+ }
30
+ }
31
+
32
+ // ─── HTML parsing ──────────────────────────────────────────
33
+
34
+ /**
35
+ * @public Exported for testing.
36
+ */
37
+ export function extractSigiState(html: string): SigiState | null {
38
+ const match = html.match(/<script\s+id="SIGI_STATE"\s+type="application\/json">(.*?)<\/script>/)
39
+ if (!match?.[1]) return null
40
+ try {
41
+ return JSON.parse(match[1]) as SigiState
42
+ } catch {
43
+ return null
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Extract the numeric user ID and optional room ID from TikTok's new
49
+ * page structure (__UNIVERSAL_DATA_FOR_REHYDRATION__).
50
+ *
51
+ * The profile page (/@user) includes "webapp.user-detail" which
52
+ * contains the user's numeric ID. When the user is live, the roomId
53
+ * is also embedded here.
54
+ *
55
+ * Returns { userId, roomId } where userId is always set when the data
56
+ * is found, and roomId is set only when the user is currently live.
57
+ *
58
+ * @public Exported for testing and fixture capture.
59
+ */
60
+ export function extractUserFromUniversalData(html: string): {
61
+ userId: string | null
62
+ roomId: string | null
63
+ } {
64
+ const match = html.match(
65
+ /<script\s+id="__UNIVERSAL_DATA_FOR_REHYDRATION__"\s+type="application\/json">(.*?)<\/script>/,
66
+ )
67
+ if (!match?.[1]) return { userId: null, roomId: null }
68
+ try {
69
+ const data = JSON.parse(match[1])
70
+ const scope = (data as Record<string, unknown>).__DEFAULT_SCOPE__ ?? data
71
+ const userDetail = scope?.["webapp.user-detail"]
72
+ const user = userDetail?.userInfo?.user
73
+ const userId = user?.id ? String(user.id) : null
74
+ const roomId = user?.roomId ? String(user.roomId) : null
75
+ return { userId, roomId }
76
+ } catch {
77
+ return { userId: null, roomId: null }
78
+ }
79
+ }
80
+
81
+ // ─── Stream URL extraction ─────────────────────────────────
82
+
83
+ /**
84
+ * Fast-path: extract stream URL from the known SIGI_STATE location.
85
+ * This covers ~95% of cases without needing a full recursive search.
86
+ *
87
+ * Prefers FLV (lower latency) over HLS/other protocols.
88
+ */
89
+ export function extractStreamUrlFast(
90
+ liveRoom: NonNullable<NonNullable<SigiState["LiveRoom"]>["liveRoomUserInfo"]>["liveRoom"],
91
+ ): string | null {
92
+ try {
93
+ const raw = liveRoom?.streamData?.pull_data?.stream_data
94
+ if (!raw) return null
95
+ const parsed = JSON.parse(raw) as Record<string, unknown>
96
+ const data = (parsed as Record<string, unknown>)?.data as Record<string, unknown> | undefined
97
+ if (!data) return null
98
+
99
+ // Try all known quality keys, preferring higher quality.
100
+ // FLV is preferred (lower latency); HLS is the fallback.
101
+ const qualities = ["origin", "fhd", "uhd", "hd", "sd", "ld"]
102
+ for (const q of qualities) {
103
+ const entry = data[q] as { main?: { flv?: string; hls?: string } } | undefined
104
+ // Prefer FLV over HLS when both are available
105
+ const url = entry?.main?.flv ?? entry?.main?.hls
106
+ if (url) return url
107
+ }
108
+ return null
109
+ } catch {
110
+ return null
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Brute-force recursive search for a stream URL anywhere in a JSON value.
116
+ * Survives TikTok changing key names or nesting structure.
117
+ *
118
+ * Prefers FLV over HLS when both are present in the response.
119
+ *
120
+ * Inspired by PR #430 (Michele0303/tiktok-live-recorder).
121
+ */
122
+ export function findStreamUrlRecursively(obj: unknown): string | null {
123
+ // Collect all candidate URLs with their preference
124
+ const flvUrls: string[] = []
125
+ const m3u8Urls: string[] = []
126
+
127
+ function walk(value: unknown): void {
128
+ if (typeof value === "string") {
129
+ // Direct URL match
130
+ if (
131
+ (value.startsWith("http://") || value.startsWith("https://")) &&
132
+ (value.includes(".flv") || value.includes(".m3u8"))
133
+ ) {
134
+ if (value.includes(".flv")) {
135
+ flvUrls.push(value)
136
+ } else {
137
+ m3u8Urls.push(value)
138
+ }
139
+ return
140
+ }
141
+ // JSON-encoded string — parse and recurse
142
+ if (value.startsWith("{") || value.startsWith("[")) {
143
+ try {
144
+ walk(JSON.parse(value))
145
+ } catch {
146
+ // skip
147
+ }
148
+ }
149
+ return
150
+ }
151
+
152
+ if (value && typeof value === "object") {
153
+ if (Array.isArray(value)) {
154
+ for (const item of value) {
155
+ walk(item)
156
+ }
157
+ } else {
158
+ for (const val of Object.values(value as Record<string, unknown>)) {
159
+ walk(val)
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ walk(obj)
166
+
167
+ // Prefer FLV (lower latency) over HLS
168
+ if (flvUrls.length > 0) return flvUrls[0] as string
169
+ if (m3u8Urls.length > 0) return m3u8Urls[0] as string
170
+ return null
171
+ }
@@ -0,0 +1,162 @@
1
+ import type { RoomRef, StreamRef } from "../../core/verdict"
2
+ import type { HttpClient } from "../http"
3
+ import { findStreamUrlRecursively } from "./parse"
4
+
5
+ const WEBCAST_BASE = "https://webcast.tiktok.com"
6
+
7
+ /**
8
+ * Discriminated room/info result: refusals (notably status_code 4003110)
9
+ * and transport failures surface as `{ ok: false }` instead of null.
10
+ * `statusCode: 0` means the request threw before a status was read.
11
+ */
12
+ export type RoomApiResult<T> =
13
+ | { ok: true; value: T | null }
14
+ | { ok: false; statusCode: number; body: string }
15
+
16
+ /** Authoritative room/info facts: the room plus its stream URL when present. */
17
+ export interface RoomFacts {
18
+ readonly room: RoomRef
19
+ readonly stream: StreamRef | null
20
+ }
21
+
22
+ /** What discovery sources consume. `createWebcastApi` is the only implementation. */
23
+ export interface WebcastReader {
24
+ fetchFromRoomApi: <T>(
25
+ roomId: string,
26
+ extract: (data: Record<string, unknown>) => T | null,
27
+ ) => Promise<RoomApiResult<T>>
28
+ fetchCheckAlive: (roomId: string) => Promise<boolean>
29
+ fetchStreamUrlFromApi: (roomId: string) => Promise<RoomApiResult<string>>
30
+ /** room/info as domain facts; 4003110 comes back as `{ ok: false }`. */
31
+ roomInfo: (roomId: string) => Promise<RoomApiResult<RoomFacts>>
32
+ /** Resolve a 4003110 refusal: alive → restricted candidate, else offline. */
33
+ refusedRoom: (roomId: string, title: string | null) => Promise<RoomRef>
34
+ }
35
+
36
+ export function createWebcastApi(
37
+ http: HttpClient,
38
+ debug: (msg: string) => void,
39
+ signal?: AbortSignal,
40
+ ): WebcastReader {
41
+ /**
42
+ * Shared boilerplate for Webcast room/info API calls.
43
+ * Handles URL construction, HTTP GET, JSON parsing, and error handling.
44
+ * Room-gate refusals (status_code 4003110) are reported to the caller
45
+ * instead of being collapsed into null; callers map failures.
46
+ * The caller provides an extractor for the response data.
47
+ */
48
+ async function fetchFromRoomApi<T>(
49
+ roomId: string,
50
+ extract: (data: Record<string, unknown>) => T | null,
51
+ ): Promise<RoomApiResult<T>> {
52
+ try {
53
+ const url = `${WEBCAST_BASE}/webcast/room/info/?aid=1988&room_id=${roomId}&type=live`
54
+ const res = await http.get(url, signal)
55
+ debug(`fetchFromRoomApi: roomId=${roomId} status=${res.status}, ok=${res.ok}`)
56
+ if (!res.ok) return { ok: false, statusCode: res.status, body: "" }
57
+
58
+ const text = await res.text()
59
+ debug(`fetchFromRoomApi: roomId=${roomId} returned ${text.length} bytes`)
60
+ if (text.length === 0) return { ok: true, value: null }
61
+
62
+ const data = JSON.parse(text) as Record<string, unknown>
63
+
64
+ // status_code 4003110 = age-protected/restricted room: report it
65
+ if ((data as { status_code?: unknown }).status_code === 4003110) {
66
+ return { ok: false, statusCode: 4003110, body: text }
67
+ }
68
+
69
+ return { ok: true, value: extract(data) }
70
+ } catch {
71
+ // statusCode 0 = transport/parse failure before a status was available.
72
+ return { ok: false, statusCode: 0, body: "" }
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Check if a room is currently alive using the lightweight
78
+ * /webcast/room/check_alive/ endpoint.
79
+ *
80
+ * This is faster and cheaper than fetching the full room/info response.
81
+ * Returns `true` if the room is live, `false` otherwise (including errors).
82
+ *
83
+ * Endpoint: /webcast/room/check_alive/?aid=1988&region=CH&room_ids={id}&user_is_login=true
84
+ * Response: { "data": [{ "alive": true/false, "room_id": "..." }] }
85
+ */
86
+ async function fetchCheckAlive(roomId: string): Promise<boolean> {
87
+ try {
88
+ const url = `${WEBCAST_BASE}/webcast/room/check_alive/?aid=1988&region=CH&room_ids=${roomId}&user_is_login=true`
89
+ const res = await http.get(url, signal)
90
+ debug(`fetchCheckAlive: roomId=${roomId} status=${res.status}, ok=${res.ok}`)
91
+ if (!res.ok) return false
92
+
93
+ const text = await res.text()
94
+ debug(`fetchCheckAlive: roomId=${roomId} returned ${text.length} bytes`)
95
+ if (text.length === 0) return false
96
+
97
+ const data = JSON.parse(text) as Record<string, unknown>
98
+ const items = data?.data as Array<Record<string, unknown>> | undefined
99
+ const alive = items?.[0]?.alive === true
100
+ debug(`fetchCheckAlive: roomId=${roomId} alive=${alive}`)
101
+ return alive
102
+ } catch (err) {
103
+ debug(
104
+ `fetchCheckAlive: roomId=${roomId} error — ${err instanceof Error ? err.message : String(err)}`,
105
+ )
106
+ return false
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Resolve a 4003110 room-gate refusal via check_alive: the room is live
112
+ * (restricted) when alive, offline otherwise. Single home for this mapping.
113
+ */
114
+ async function refusedRoom(roomId: string, title: string | null): Promise<RoomRef> {
115
+ debug(`refusedRoom: room/info refused roomId=${roomId} (status_code 4003110)`)
116
+ const live = await fetchCheckAlive(roomId)
117
+ return { id: roomId, title, live }
118
+ }
119
+
120
+ /**
121
+ * Fetch room/info as domain facts. Requires a known roomId. A 4003110
122
+ * refusal stays `{ ok: false, statusCode: 4003110 }` for the caller to map
123
+ * via `refusedRoom`.
124
+ */
125
+ async function roomInfo(roomId: string): Promise<RoomApiResult<RoomFacts>> {
126
+ const res = await fetchFromRoomApi<Record<string, unknown>>(
127
+ roomId,
128
+ (data) => (data as { data?: Record<string, unknown> }).data ?? null,
129
+ )
130
+ if (!res.ok) return res
131
+ const roomData = res.value
132
+ if (!roomData) return { ok: true, value: null }
133
+
134
+ const title = (roomData.title as string | undefined) ?? null
135
+ const streamUrl = findStreamUrlRecursively(roomData)
136
+ return {
137
+ ok: true,
138
+ value: {
139
+ room: { id: roomId, title, live: (roomData.status ?? 4) === 2 },
140
+ stream: streamUrl ? { url: streamUrl } : null,
141
+ },
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Fetch stream URL from the Webcast API.
147
+ * Covers cases where SIGI_STATE doesn't include stream data.
148
+ * Refusals (4003110) and transport failures are reported so callers
149
+ * can distinguish restricted rooms from missing URLs.
150
+ */
151
+ async function fetchStreamUrlFromApi(roomId: string): Promise<RoomApiResult<string>> {
152
+ return fetchFromRoomApi(roomId, (data) => findStreamUrlRecursively(data))
153
+ }
154
+
155
+ return {
156
+ fetchFromRoomApi,
157
+ fetchCheckAlive,
158
+ fetchStreamUrlFromApi,
159
+ roomInfo,
160
+ refusedRoom,
161
+ }
162
+ }
package/dist/bin.d.mts DELETED
@@ -1 +0,0 @@
1
- export { };
package/dist/bin.mjs DELETED
@@ -1,215 +0,0 @@
1
- #!/usr/bin/env bun
2
- import { TikTokError } from "./lib/config.mjs";
3
- import { bytesToHuman, relativeTime } from "./lib/utils.mjs";
4
- import { createRecorder } from "./core/recorder/index.mjs";
5
- import "./index.mjs";
6
- import { extractTikTokCookiesFromFirefox } from "./lib/browser-cookies.mjs";
7
- import { parseArgs } from "./lib/cli.mjs";
8
- import { tmuxStart, tmuxStatus, tmuxStop } from "./lib/tmux.mjs";
9
- import { createDisplay } from "./lib/ui.mjs";
10
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
11
- import { join } from "node:path";
12
- import { color } from "@zfadhli/koko-cli";
13
- //#region src/bin.ts
14
- /**
15
- * Entry point — parses CLI args, creates the recorder, and handles signals.
16
- * Uses the terminal Display manager for beautiful console output.
17
- */
18
- async function loadCookies(cookiesPath) {
19
- const path = cookiesPath ?? join(process.cwd(), "cookies.json");
20
- if (!existsSync(path)) return;
21
- try {
22
- const content = readFileSync(path, "utf-8");
23
- const data = JSON.parse(content);
24
- if (data.sessionid_ss && data.sessionid_ss.length > 0) return data;
25
- return;
26
- } catch {
27
- return;
28
- }
29
- }
30
- async function main() {
31
- let result;
32
- try {
33
- result = parseArgs();
34
- } catch (err) {
35
- if (err instanceof TikTokError && err.kind === "config-error") {
36
- console.error(`${color.red("Error:")} ${err.message}`);
37
- process.exit(1);
38
- }
39
- throw err;
40
- }
41
- if (result.command === "start") {
42
- const userMatch = result.args.find((_, i, a) => a[i - 1] === "--user" || a[i - 1] === "-u");
43
- if (!userMatch) {
44
- console.error(`${color.red("Error:")} --user is required for "start" command`);
45
- process.exit(1);
46
- }
47
- const cmd = `tokrec ${result.args.join(" ")}`;
48
- try {
49
- await tmuxStart(userMatch, cmd);
50
- console.log(`${color.green("✓")} Session "tokrec-${userMatch}" started in background`);
51
- console.log(` tmux attach -t tokrec-${userMatch} — view live output`);
52
- console.log(` tokrec stop -u ${userMatch} — stop recording`);
53
- } catch (err) {
54
- console.error(`${color.red("Error:")} ${err instanceof Error ? err.message : String(err)}`);
55
- process.exit(1);
56
- }
57
- return;
58
- }
59
- if (result.command === "stop") {
60
- try {
61
- await tmuxStop(result.user);
62
- if (result.user) console.log(`${color.green("✓")} Session "tokrec-${result.user}" stopped`);
63
- else console.log(`${color.green("✓")} All tokrec sessions stopped`);
64
- } catch (err) {
65
- console.error(`${color.red("Error:")} ${err instanceof Error ? err.message : String(err)}`);
66
- process.exit(1);
67
- }
68
- return;
69
- }
70
- if (result.command === "status") {
71
- const sessions = await tmuxStatus(result.user);
72
- if (sessions.length === 0) console.log(`${color.gray("○")} No active session${result.user ? ` for "${result.user}"` : ""}`);
73
- else for (const s of sessions) {
74
- console.log(`${color.green("●")} tokrec-${s.user}`);
75
- console.log(` tmux attach -t ${s.session} — view live output`);
76
- }
77
- return;
78
- }
79
- if (result.command === "setup") {
80
- const firefoxCookies = extractTikTokCookiesFromFirefox();
81
- if (firefoxCookies) {
82
- const names = Object.keys(firefoxCookies);
83
- const detail = names.length > 1 ? ` (${names.length} cookies)` : "";
84
- console.log(`${color.green("✓")} Firefox cookies detected${detail}`);
85
- console.log(` No setup needed — tokrec auto-detects Firefox cookies.`);
86
- return;
87
- }
88
- const cookiesPath = join(process.cwd(), "cookies.json");
89
- if (existsSync(cookiesPath)) try {
90
- if (JSON.parse(readFileSync(cookiesPath, "utf-8")).sessionid_ss) {
91
- console.log(`${color.green("✓")} cookies.json found at ./cookies.json`);
92
- return;
93
- }
94
- } catch {}
95
- console.log(`${color.yellow("⚠")} No TikTok cookies found.`);
96
- console.log();
97
- console.log(`You need a valid sessionid_ss cookie to bypass TikTok's WAF.`);
98
- console.log(`Either log in to TikTok in Firefox (auto-detected), or enter manually:`);
99
- console.log();
100
- const sessionid = prompt(" sessionid_ss cookie value:");
101
- if (!sessionid || sessionid.trim().length === 0) {
102
- console.log(`${color.red("✗")} No cookie provided. Setup cancelled.`);
103
- return;
104
- }
105
- const cookies = { sessionid_ss: sessionid.trim() };
106
- const idc = prompt(" tt-target-idc (optional, press Enter to skip):");
107
- if (idc && idc.trim().length > 0) cookies["tt-target-idc"] = idc.trim();
108
- writeFileSync(cookiesPath, `${JSON.stringify(cookies, null, 2)}\n`);
109
- console.log();
110
- console.log(`${color.green("✓")} cookies.json created at ./cookies.json`);
111
- console.log(` You can now run: tokrec -u <username>`);
112
- return;
113
- }
114
- const config = result.config;
115
- const firefoxCookies = extractTikTokCookiesFromFirefox();
116
- config.cookies = firefoxCookies ?? await loadCookies(config.cookiesPath);
117
- config.logConsole = false;
118
- const display = createDisplay();
119
- if (config.cookies) {
120
- const source = firefoxCookies ? "Firefox" : "cookies.json";
121
- const names = Object.keys(config.cookies);
122
- const detail = names.length > 1 ? ` (${names.length} cookies)` : "";
123
- display.showInfo(`${source} cookies loaded${detail}`);
124
- } else display.showWarning("No TikTok cookies found — log in at tiktok.com in Firefox or create cookies.json");
125
- const recorder = createRecorder(config);
126
- let lastWasLive = null;
127
- let firstOfflineTime = 0;
128
- recorder.on("checking", (info) => {
129
- display.checkingUser(info.user);
130
- });
131
- recorder.on("tick", (info) => {
132
- if (info.isLive) display.userLive(info.user, info.roomId ?? "?");
133
- else if (lastWasLive === null || lastWasLive === true) {
134
- display.userOffline(info.user);
135
- firstOfflineTime = Date.now();
136
- } else {
137
- const elapsed = Date.now() - firstOfflineTime;
138
- display.userOfflineRepeat(info.user, relativeTime(elapsed));
139
- }
140
- lastWasLive = info.isLive;
141
- });
142
- recorder.on("recording:start", () => {
143
- display.startRecording();
144
- });
145
- recorder.on("download:end", (info) => {
146
- const parsed = info.file.split("/").pop() ?? info.file;
147
- const sizeStr = bytesToHuman(info.size);
148
- display.finishRecording(parsed, info.duration, sizeStr);
149
- });
150
- recorder.on("segmenting:start", () => {
151
- display.startSegmenting();
152
- });
153
- recorder.on("segmenting:end", (info) => {
154
- display.segmentsCreated(info.segments);
155
- });
156
- recorder.on("converting:start", () => {
157
- display.startConverting();
158
- });
159
- recorder.on("converted", (info) => {
160
- const parsed = info.output.split("/").pop() ?? info.output;
161
- display.conversionDone(parsed);
162
- });
163
- recorder.on("recording:end", () => {});
164
- recorder.on("error", (err) => {
165
- display.showError(`[${err.kind}] ${err.message}`);
166
- });
167
- recorder.on("normalize:start", () => {
168
- display.normalizeStart();
169
- });
170
- recorder.on("normalize:progress", (info) => {
171
- display.normalizeProgress(info.percent, info.phase);
172
- });
173
- recorder.on("normalize:end", () => {
174
- display.normalizeComplete();
175
- });
176
- recorder.on("normalize:error", (info) => {
177
- display.normalizeError(info.error);
178
- });
179
- let stopping = false;
180
- const handleSignal = async () => {
181
- if (stopping) return;
182
- stopping = true;
183
- process.off("SIGINT", handleSignal);
184
- process.off("SIGTERM", handleSignal);
185
- process.off("SIGHUP", handleSignal);
186
- display.showInfo("Shutting down gracefully...");
187
- await recorder.stop();
188
- display.stop();
189
- process.exit(0);
190
- };
191
- process.on("SIGINT", handleSignal);
192
- process.on("SIGTERM", handleSignal);
193
- process.on("SIGHUP", handleSignal);
194
- display.pollingStarted(config.interval ?? 3);
195
- try {
196
- await recorder.start();
197
- } catch (err) {
198
- if (err instanceof TikTokError) display.showError(`[${err.kind}] ${err.message}`);
199
- else display.showError(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`);
200
- process.off("SIGINT", handleSignal);
201
- process.off("SIGTERM", handleSignal);
202
- process.off("SIGHUP", handleSignal);
203
- await recorder.stop();
204
- display.stop();
205
- process.exit(1);
206
- }
207
- }
208
- main().catch((err) => {
209
- console.error("Fatal:", err instanceof Error ? err.message : String(err));
210
- process.exit(1);
211
- });
212
- //#endregion
213
- export {};
214
-
215
- //# sourceMappingURL=bin.mjs.map
package/dist/bin.mjs.map DELETED
@@ -1 +0,0 @@
1
- {"version":3,"file":"bin.mjs","names":[],"sources":["../src/bin.ts"],"sourcesContent":["#!/usr/bin/env bun\n/**\n * Entry point — parses CLI args, creates the recorder, and handles signals.\n * Uses the terminal Display manager for beautiful console output.\n */\n\nimport { existsSync, readFileSync, writeFileSync } from \"node:fs\"\nimport { join } from \"node:path\"\nimport { color } from \"@zfadhli/koko-cli\"\nimport { createRecorder } from \"./index\"\nimport { extractTikTokCookiesFromFirefox } from \"./lib/browser-cookies\"\nimport { type CLIResult, parseArgs } from \"./lib/cli\"\nimport type { RecorderConfig } from \"./lib/config\"\nimport { TikTokError } from \"./lib/config\"\nimport { tmuxStart, tmuxStatus, tmuxStop } from \"./lib/tmux\"\nimport { createDisplay } from \"./lib/ui\"\nimport { bytesToHuman, relativeTime } from \"./lib/utils\"\n\nasync function loadCookies(cookiesPath?: string): Promise<Record<string, string> | undefined> {\n const path = cookiesPath ?? join(process.cwd(), \"cookies.json\")\n if (!existsSync(path)) {\n return undefined\n }\n try {\n const content = readFileSync(path, \"utf-8\")\n const data = JSON.parse(content) as Record<string, string | undefined>\n if (data.sessionid_ss && data.sessionid_ss.length > 0) {\n return data as Record<string, string>\n }\n return undefined\n } catch {\n return undefined\n }\n}\n\nasync function main(): Promise<void> {\n let result: CLIResult\n\n try {\n result = parseArgs()\n } catch (err) {\n if (err instanceof TikTokError && err.kind === \"config-error\") {\n console.error(`${color.red(\"Error:\")} ${err.message}`)\n process.exit(1)\n }\n throw err\n }\n\n // Handle tmux subcommands\n if (result.command === \"start\") {\n // Extract user from args to build session name\n const userMatch = result.args.find((_, i, a) => a[i - 1] === \"--user\" || a[i - 1] === \"-u\")\n if (!userMatch) {\n console.error(`${color.red(\"Error:\")} --user is required for \"start\" command`)\n process.exit(1)\n }\n const cmd = `tokrec ${result.args.join(\" \")}`\n try {\n await tmuxStart(userMatch, cmd)\n console.log(`${color.green(\"✓\")} Session \"tokrec-${userMatch}\" started in background`)\n console.log(` tmux attach -t tokrec-${userMatch} — view live output`)\n console.log(` tokrec stop -u ${userMatch} — stop recording`)\n } catch (err) {\n console.error(`${color.red(\"Error:\")} ${err instanceof Error ? err.message : String(err)}`)\n process.exit(1)\n }\n return\n }\n\n if (result.command === \"stop\") {\n try {\n await tmuxStop(result.user)\n if (result.user) {\n console.log(`${color.green(\"✓\")} Session \"tokrec-${result.user}\" stopped`)\n } else {\n console.log(`${color.green(\"✓\")} All tokrec sessions stopped`)\n }\n } catch (err) {\n console.error(`${color.red(\"Error:\")} ${err instanceof Error ? err.message : String(err)}`)\n process.exit(1)\n }\n return\n }\n\n if (result.command === \"status\") {\n const sessions = await tmuxStatus(result.user)\n if (sessions.length === 0) {\n console.log(\n `${color.gray(\"○\")} No active session${result.user ? ` for \"${result.user}\"` : \"\"}`,\n )\n } else {\n for (const s of sessions) {\n console.log(`${color.green(\"●\")} tokrec-${s.user}`)\n console.log(` tmux attach -t ${s.session} — view live output`)\n }\n }\n return\n }\n\n if (result.command === \"setup\") {\n // 1. Check Firefox cookies\n const firefoxCookies = extractTikTokCookiesFromFirefox()\n if (firefoxCookies) {\n const names = Object.keys(firefoxCookies)\n const detail = names.length > 1 ? ` (${names.length} cookies)` : \"\"\n console.log(`${color.green(\"✓\")} Firefox cookies detected${detail}`)\n console.log(` No setup needed — tokrec auto-detects Firefox cookies.`)\n return\n }\n\n // 2. Check cookies.json\n const cookiesPath = join(process.cwd(), \"cookies.json\")\n if (existsSync(cookiesPath)) {\n try {\n const data = JSON.parse(readFileSync(cookiesPath, \"utf-8\"))\n if (data.sessionid_ss) {\n console.log(`${color.green(\"✓\")} cookies.json found at ./cookies.json`)\n return\n }\n } catch {\n // invalid JSON, fall through to setup\n }\n }\n\n // 3. No cookies found — interactive setup\n console.log(`${color.yellow(\"⚠\")} No TikTok cookies found.`)\n console.log()\n console.log(`You need a valid sessionid_ss cookie to bypass TikTok's WAF.`)\n console.log(`Either log in to TikTok in Firefox (auto-detected), or enter manually:`)\n console.log()\n\n const sessionid = prompt(\" sessionid_ss cookie value:\")\n if (!sessionid || sessionid.trim().length === 0) {\n console.log(`${color.red(\"✗\")} No cookie provided. Setup cancelled.`)\n return\n }\n\n const cookies: Record<string, string> = { sessionid_ss: sessionid.trim() }\n\n const idc = prompt(\" tt-target-idc (optional, press Enter to skip):\")\n if (idc && idc.trim().length > 0) {\n cookies[\"tt-target-idc\"] = idc.trim()\n }\n\n writeFileSync(cookiesPath, `${JSON.stringify(cookies, null, 2)}\\n`)\n console.log()\n console.log(`${color.green(\"✓\")} cookies.json created at ./cookies.json`)\n console.log(` You can now run: tokrec -u <username>`)\n return\n }\n\n // Record command — full recorder flow\n const config: RecorderConfig = result.config\n\n // Load cookies: try Firefox browser first, then fall back to cookies.json\n const firefoxCookies = extractTikTokCookiesFromFirefox()\n config.cookies = firefoxCookies ?? (await loadCookies(config.cookiesPath))\n\n // Suppress internal logger's console output — the Display owns the terminal\n config.logConsole = false\n\n // Beautiful terminal display\n const display = createDisplay()\n\n if (config.cookies) {\n const source = firefoxCookies ? \"Firefox\" : \"cookies.json\"\n const names = Object.keys(config.cookies)\n const detail = names.length > 1 ? ` (${names.length} cookies)` : \"\"\n display.showInfo(`${source} cookies loaded${detail}`)\n } else {\n display.showWarning(\n \"No TikTok cookies found — log in at tiktok.com in Firefox or create cookies.json\",\n )\n }\n\n const recorder = createRecorder(config)\n\n let lastWasLive: boolean | null = null\n let firstOfflineTime = 0\n\n // Subscribe to events for beautiful console output\n recorder.on(\"checking\", (info) => {\n display.checkingUser(info.user)\n })\n\n recorder.on(\"tick\", (info) => {\n if (info.isLive) {\n display.userLive(info.user, info.roomId ?? \"?\")\n } else if (lastWasLive === null || lastWasLive === true) {\n display.userOffline(info.user)\n firstOfflineTime = Date.now()\n } else {\n const elapsed = Date.now() - firstOfflineTime\n display.userOfflineRepeat(info.user, relativeTime(elapsed))\n }\n lastWasLive = info.isLive\n })\n\n recorder.on(\"recording:start\", () => {\n display.startRecording()\n })\n\n recorder.on(\"download:end\", (info) => {\n const parsed = info.file.split(\"/\").pop() ?? info.file\n const sizeStr = bytesToHuman(info.size)\n display.finishRecording(parsed, info.duration, sizeStr)\n })\n\n recorder.on(\"segmenting:start\", () => {\n display.startSegmenting()\n })\n\n recorder.on(\"segmenting:end\", (info) => {\n display.segmentsCreated(info.segments)\n })\n\n recorder.on(\"converting:start\", () => {\n display.startConverting()\n })\n\n recorder.on(\"converted\", (info) => {\n const parsed = info.output.split(\"/\").pop() ?? info.output\n display.conversionDone(parsed)\n })\n\n recorder.on(\"recording:end\", () => {\n // For simple conversion (no segmenting), recording:end fires once.\n // For segmenting path, segmenting:end already acknowledges the result.\n // This is a no-op since we handle the terminal output through download:end\n // and segmenting:end / converted events.\n })\n\n recorder.on(\"error\", (err) => {\n display.showError(`[${err.kind}] ${err.message}`)\n })\n\n recorder.on(\"normalize:start\", () => {\n display.normalizeStart()\n })\n\n recorder.on(\"normalize:progress\", (info) => {\n display.normalizeProgress(info.percent, info.phase)\n })\n\n recorder.on(\"normalize:end\", () => {\n display.normalizeComplete()\n })\n\n recorder.on(\"normalize:error\", (info) => {\n display.normalizeError(info.error)\n })\n\n // Signal handling\n let stopping = false\n const handleSignal = async () => {\n if (stopping) return // ignore duplicate signals (e.g. SIGINT + SIGTERM from one Ctrl-C)\n stopping = true\n // Remove signal handlers so they don't accumulate if main() is called again\n process.off(\"SIGINT\", handleSignal)\n process.off(\"SIGTERM\", handleSignal)\n process.off(\"SIGHUP\", handleSignal)\n display.showInfo(\"Shutting down gracefully...\")\n await recorder.stop()\n display.stop()\n process.exit(0)\n }\n\n process.on(\"SIGINT\", handleSignal)\n process.on(\"SIGTERM\", handleSignal)\n process.on(\"SIGHUP\", handleSignal)\n\n // Show initial status and start the recorder\n display.pollingStarted(config.interval ?? 3)\n try {\n await recorder.start()\n } catch (err) {\n if (err instanceof TikTokError) {\n display.showError(`[${err.kind}] ${err.message}`)\n } else {\n display.showError(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`)\n }\n process.off(\"SIGINT\", handleSignal)\n process.off(\"SIGTERM\", handleSignal)\n process.off(\"SIGHUP\", handleSignal)\n await recorder.stop()\n display.stop()\n process.exit(1)\n }\n}\n\nmain().catch((err) => {\n console.error(\"Fatal:\", err instanceof Error ? err.message : String(err))\n process.exit(1)\n})\n"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"client.mjs","names":[],"sources":["../../../src/core/api/client.ts"],"sourcesContent":["/**\n * HTTP client — thin wrapper around wreq-js createSession.\n * Provides TLS fingerprint impersonation, cookie jar, proxy support,\n * and configurable rate limiting to prevent WAF triggering.\n */\n\nimport { createSession, type Session } from \"wreq-js\"\nimport type { RecorderConfig } from \"../../lib/config\"\nimport { createRateLimiter } from \"./rate-limiter\"\n\n// BodyInit is only available inside declare module \"bun\" in bun-types, not as a global\n// under the ES2022 lib. We explicitly import it to keep lib focused on ES2022.\ntype _BodyInit = import(\"bun\").BodyInit\n\nexport interface HttpClient {\n get: (url: string) => Promise<Response>\n post: (url: string, body?: _BodyInit, headers?: Record<string, string>) => Promise<Response>\n close: () => Promise<void>\n}\n\nconst REQUEST_TIMEOUT_MS = 15_000\n\n/** Standard browser headers to avoid TikTok WAF detection. */\nconst BROWSER_HEADERS: Record<string, string> = {\n \"User-Agent\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:149.0) Gecko/20100101 Firefox/149.0\",\n Accept: \"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8\",\n \"Accept-Language\": \"en-US,en;q=0.5\",\n \"Accept-Encoding\": \"gzip, deflate, br\",\n \"Upgrade-Insecure-Requests\": \"1\",\n \"Sec-Fetch-Dest\": \"document\",\n \"Sec-Fetch-Mode\": \"navigate\",\n \"Sec-Fetch-Site\": \"none\",\n \"Sec-Fetch-User\": \"?1\",\n}\n\nexport async function createHttpClient(config: RecorderConfig): Promise<HttpClient> {\n const session: Session = await createSession({\n browser: \"firefox_149\",\n os: \"windows\",\n proxy: config.proxy,\n })\n\n // Seed cookies into the session jar so all subsequent requests are authenticated.\n // NOTE: Using a Cookie header does NOT populate wreq-js's cookie jar —\n // we must use session.setCookie() instead.\n // Seed for all TikTok domains since cookies may not propagate across\n // subdomains automatically in wreq-js's cookie jar.\n if (config.cookies && \"sessionid_ss\" in config.cookies) {\n const domains = [\"https://www.tiktok.com\", \"https://webcast.tiktok.com\", \"https://m.tiktok.com\"]\n for (const domain of domains) {\n for (const [name, value] of Object.entries(config.cookies)) {\n session.setCookie(name, value, domain)\n }\n }\n }\n\n const rateLimiter = createRateLimiter(config.ratePerSecond ?? 5)\n\n async function rateLimitedFetch(\n url: string,\n options?: { method?: string; body?: unknown; headers?: Record<string, string> },\n ): Promise<Response> {\n const controller = new AbortController()\n const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)\n try {\n await rateLimiter.acquire(controller.signal)\n const res = await session.fetch(url, {\n method: options?.method,\n body: options?.body as never,\n headers: {\n ...BROWSER_HEADERS,\n ...options?.headers,\n },\n signal: controller.signal,\n })\n return res as unknown as Response\n } finally {\n clearTimeout(timeout)\n }\n }\n\n return {\n get: async (url: string) => rateLimitedFetch(url),\n post: async (url: string, body?: _BodyInit, headers?: Record<string, string>) =>\n rateLimitedFetch(url, {\n method: \"POST\",\n body: body as never,\n headers: {\n \"Content-Type\": \"application/x-www-form-urlencoded; charset=UTF-8\",\n ...headers,\n },\n }),\n close: async () => {\n await session.close()\n },\n }\n}\n"],"mappings":";;;;;;;;AAoBA,MAAM,qBAAqB;;AAG3B,MAAM,kBAA0C;CAC9C,cAAc;CACd,QAAQ;CACR,mBAAmB;CACnB,mBAAmB;CACnB,6BAA6B;CAC7B,kBAAkB;CAClB,kBAAkB;CAClB,kBAAkB;CAClB,kBAAkB;AACpB;AAEA,eAAsB,iBAAiB,QAA6C;CAClF,MAAM,UAAmB,MAAM,cAAc;EAC3C,SAAS;EACT,IAAI;EACJ,OAAO,OAAO;CAChB,CAAC;CAOD,IAAI,OAAO,WAAW,kBAAkB,OAAO,SAE7C,KAAK,MAAM,UAAU;EADJ;EAA0B;EAA8B;CAC9C,GACzB,KAAK,MAAM,CAAC,MAAM,UAAU,OAAO,QAAQ,OAAO,OAAO,GACvD,QAAQ,UAAU,MAAM,OAAO,MAAM;CAK3C,MAAM,cAAc,kBAAkB,OAAO,iBAAiB,CAAC;CAE/D,eAAe,iBACb,KACA,SACmB;EACnB,MAAM,aAAa,IAAI,gBAAgB;EACvC,MAAM,UAAU,iBAAiB,WAAW,MAAM,GAAG,kBAAkB;EACvE,IAAI;GACF,MAAM,YAAY,QAAQ,WAAW,MAAM;GAU3C,OAAO,MATW,QAAQ,MAAM,KAAK;IACnC,QAAQ,SAAS;IACjB,MAAM,SAAS;IACf,SAAS;KACP,GAAG;KACH,GAAG,SAAS;IACd;IACA,QAAQ,WAAW;GACrB,CAAC;EAEH,UAAU;GACR,aAAa,OAAO;EACtB;CACF;CAEA,OAAO;EACL,KAAK,OAAO,QAAgB,iBAAiB,GAAG;EAChD,MAAM,OAAO,KAAa,MAAkB,YAC1C,iBAAiB,KAAK;GACpB,QAAQ;GACF;GACN,SAAS;IACP,gBAAgB;IAChB,GAAG;GACL;EACF,CAAC;EACH,OAAO,YAAY;GACjB,MAAM,QAAQ,MAAM;EACtB;CACF;AACF"}
@@ -1,29 +0,0 @@
1
- //#region src/core/api/rate-limiter.ts
2
- /**
3
- * Create a token-bucket rate limiter.
4
- *
5
- * @param tokensPerSecond Max sustained requests per second.
6
- * 0 or negative = unlimited (no waiting).
7
- */
8
- function createRateLimiter(tokensPerSecond) {
9
- if (tokensPerSecond <= 0) return { acquire: async () => {} };
10
- const MIN_INTERVAL_MS = 1e3 / tokensPerSecond;
11
- let lastTokenTime = 0;
12
- return { async acquire(signal) {
13
- if (signal?.aborted) return;
14
- const now = Date.now();
15
- const waitTime = Math.max(0, lastTokenTime + MIN_INTERVAL_MS - now);
16
- if (waitTime > 0) await new Promise((resolve) => {
17
- const timer = setTimeout(resolve, waitTime);
18
- if (signal) signal.addEventListener("abort", () => {
19
- clearTimeout(timer);
20
- resolve();
21
- }, { once: true });
22
- });
23
- lastTokenTime = Date.now();
24
- } };
25
- }
26
- //#endregion
27
- export { createRateLimiter };
28
-
29
- //# sourceMappingURL=rate-limiter.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"rate-limiter.mjs","names":[],"sources":["../../../src/core/api/rate-limiter.ts"],"sourcesContent":["/**\n * Token-bucket rate limiter — limits API request frequency to prevent\n * TikTok WAF triggering on rapid polling or URL refresh loops.\n *\n * Design:\n * - Token bucket with configurable refill rate (tokens/sec)\n * - AbortSignal-aware wait so cancellation works through the queue\n * - Thread-safe for cooperative JS concurrency (all awaiters share\n * the same monotonically-increasing last-token timestamp)\n */\n\nexport interface RateLimiter {\n /** Wait until a rate-limit token is available, then consume it. */\n acquire(signal?: AbortSignal): Promise<void>\n}\n\n/**\n * Create a token-bucket rate limiter.\n *\n * @param tokensPerSecond Max sustained requests per second.\n * 0 or negative = unlimited (no waiting).\n */\nexport function createRateLimiter(tokensPerSecond: number): RateLimiter {\n // Unlimited mode — no-op acquire\n if (tokensPerSecond <= 0) {\n return { acquire: async () => {} }\n }\n\n const MIN_INTERVAL_MS = 1000 / tokensPerSecond\n let lastTokenTime = 0\n\n return {\n async acquire(signal?: AbortSignal): Promise<void> {\n if (signal?.aborted) return\n\n const now = Date.now()\n const waitTime = Math.max(0, lastTokenTime + MIN_INTERVAL_MS - now)\n\n if (waitTime > 0) {\n await new Promise<void>((resolve) => {\n const timer = setTimeout(resolve, waitTime)\n if (signal) {\n signal.addEventListener(\n \"abort\",\n () => {\n clearTimeout(timer)\n resolve()\n },\n { once: true },\n )\n }\n })\n }\n\n lastTokenTime = Date.now()\n },\n }\n}\n"],"mappings":";;;;;;;AAsBA,SAAgB,kBAAkB,iBAAsC;CAEtE,IAAI,mBAAmB,GACrB,OAAO,EAAE,SAAS,YAAY,CAAC,EAAE;CAGnC,MAAM,kBAAkB,MAAO;CAC/B,IAAI,gBAAgB;CAEpB,OAAO,EACL,MAAM,QAAQ,QAAqC;EACjD,IAAI,QAAQ,SAAS;EAErB,MAAM,MAAM,KAAK,IAAI;EACrB,MAAM,WAAW,KAAK,IAAI,GAAG,gBAAgB,kBAAkB,GAAG;EAElE,IAAI,WAAW,GACb,MAAM,IAAI,SAAe,YAAY;GACnC,MAAM,QAAQ,WAAW,SAAS,QAAQ;GAC1C,IAAI,QACF,OAAO,iBACL,eACM;IACJ,aAAa,KAAK;IAClB,QAAQ;GACV,GACA,EAAE,MAAM,KAAK,CACf;EAEJ,CAAC;EAGH,gBAAgB,KAAK,IAAI;CAC3B,EACF;AACF"}