Skip to content

v2: runtime-agnostic TypeScript rewrite#1318

Draft
yagop wants to merge 67 commits into
masterfrom
feat/v2-core
Draft

v2: runtime-agnostic TypeScript rewrite#1318
yagop wants to merge 67 commits into
masterfrom
feat/v2-core

Conversation

@yagop

@yagop yagop commented Jun 16, 2026

Copy link
Copy Markdown
Owner

Draft WPI

@yagop

yagop commented Jun 19, 2026

Copy link
Copy Markdown
Owner Author

Code review against master (merge base 827d89d). Full suite of unit tests, typecheck, lint:core, and check:edge all pass locally. Below are the actionable findings; the rest of the rewrite looks solid.

Findings

[P2] Context.chat / Context.from miss several Update variants — src/core/context.ts:69-104

The chat getter handles 9 of the 16+ variants that actually carry a chat, omitting business_message, edited_business_message, deleted_business_messages, guest_message, message_reaction_count, chat_boost, and removed_chat_boost (all generated types with a chat field). The from getter has analogous gaps (business_connection.user, business_message, managed_bot.user, etc.). The result is that bot.on("business_message", ctx => ctx.reply(...)) (and similar) will throw "ctx.reply: cannot infer a chat id from this update" for an update that does carry a chat — and the docstring on line 65 enumerates the supported types as if the omission were intentional, hiding the gap.

Strongly recommend a typed resolver table keyed by UpdateType so a new generator variant becomes a compile error here instead of a silent undefined:

const chatOf: {
  [K in UpdateType]: (u: Extract<Update, Record<K, unknown>>) => Chat | undefined;
} = {
  message:                 (u) => u.message.chat,
  business_message:        (u) => u.business_message.chat,
  // ...one row per UpdateType, including explicit () => undefined rows
};

Extract<Update, Record<K, unknown>> narrows each row to its single variant, so wrong field paths are also compile errors. The generator's existing _AssertNever exhaustiveness check on UpdateType makes this watertight.

[P3] Webhook earlyAck silently drops handler errors when no bot.catch() is registered — src/core/webhook.ts:107-110

const work = Promise.resolve(bot.handleUpdate(update)).catch(() => {
  /* swallowed: handleUpdate has its own error boundary */
});

The comment ("handleUpdate has its own error boundary") is only true when the user registered bot.catch(); Bot.handleUpdate (src/core/bot.ts:103-110) rethrows otherwise. In fastAck/waitUntil mode a handler bug becomes invisible — no log, no surfaced error. At minimum the swallow should log() the error, or the comment should state the bot.catch() requirement.

[P3] RateLimiter per-chat buckets are never evicted — src/core/ratelimiter.ts:79-86

if (!bucket) { bucket = new TokenBucket(...); this.chats.set(key, bucket); }

The chats Map grows without bound for the life of the Transport. For a long-lived bot interacting with many distinct chats (support bot, inline mode, etc.) under rateLimit.perChat, this leaks one TokenBucket per unique chat id forever. Consider a TTL/size cap on the map.

[P3] TokenBucket.take can briefly oversell under concurrent takes — src/core/ratelimiter.ts:42-50

When two take() calls both see tokens < 1, both compute the same waitMs() from the same refill(), both await delay(wait), and on resume both call refill() and both decrement — tokens goes to -1. The math self-corrects on the next call (longer wait), but under burst contention the configured rate can be transiently exceeded. If a strict ceiling matters, decrement optimistically before awaiting, or recheck tokens >= 1 after the wait.

[P3] Context.from ignores poll_answer.voter_chatsrc/core/context.ts:96

PollAnswer (src/types/schemas.ts:596-602) has both user? and voter_chat?; for anonymous chat votes only voter_chat is set. from returns poll_answer.user unconditionally, so it is undefined for legitimate poll answers. Worth a comment or a fallback so consumers do not assume from is always set when the variant is handled.

Verdict

needs attention — one P2 (the Context getter gaps are a real defect that breaks ctx.reply for several update types the rest of the library explicitly supports), plus a handful of low-severity items.

Human Reviewer Callouts (Non-Blocking)

  • This change introduces backwards-incompatible public schema/API/contract changes: Full v2 from-scratch rewrite. No v1 compatibility: no TelegramBot class, no default export, no EventEmitter, single-argument methods, ESM-only, structured fields are plain objects (no JSON.stringify at call sites), bare strings are always file_id/URL (no options.filepath). Migration guide in redesign/MIGRATION.md. The package name is intentionally retained (node-telegram-bot-api v2.0.0-alpha.0).
  • This change changes a dependency (or the lockfile): package-lock.json deleted, bun.lock added; toolchain switched to Bun. New devDependencies: @biomejs/biome, @types/node, bun, tsx, typescript. All prior runtime/production dependencies removed (the library is now zero-dep at runtime).
  • This change modifies auth/permission behavior: Webhook authentication is via X-Telegram-Bot-Api-Secret-Token constant-time compare in webhookCallback (src/core/webhook.ts); when secretToken is unset, NO authentication is performed on incoming webhooks (documented as a required-in-production setting).
  • This change changes configuration defaults: Retry/backoff/timeout defaults introduced (maxRetries: 2, retryBackoffMs: 300, timeoutMs: 30_000, maxRetryAfterMs: 60_000); default request body format is now always form-encoded (x-www-form-urlencoded / multipart/form-data); long-poll default timeout: 30, retry: true, maxBackoffMs: 60_000.

yagop added a commit that referenced this pull request Jun 19, 2026
[ P2 ] Context.chat / Context.from exhaustive over Update variants
  Replace the hand-written if-chains with a `satisfies Record<UpdateType, …>`
  resolver table per getter, so a newly-generated Update variant becomes a
  compile error instead of silently returning undefined. Adds the 7 chat
  variants and 4 from variants the chain used to miss (business_message,
  edited_business_message, deleted_business_messages, guest_message,
  message_reaction_count, chat_boost, removed_chat_boost,
  business_connection, managed_bot) - ctx.reply no longer throws for
  handlers on those update types. Regression tests added.

[ P3 ] webhook earlyAck no longer drops handler errors silently
  When no bot.catch() boundary is registered, handleUpdate rethrows and
  the defensive .catch used to swallow it with no trace. Now logs the
  message + stack via the debug sink (rendering the Error explicitly,
  since %o would JSON-stringify an Error to "{}"). Test added.

[ P3 ] TokenBucket.take closes the concurrent-oversell race
  Post-wait recheck loop: two takes that both see an empty bucket no
  longer both decrement on wake; the second sees the bucket drained
  again and waits another cycle. Aborted takes still consume no token.
  Regression test added.

[ P3 ] RateLimiter per-chat map is now a bounded LRU
  maxChatBuckets option (default 10_000); the least-recently-used chat's
  bucket is evicted on insert, bounding memory for long-lived bots that
  talk to many chats. LRU ordering test added.

[ P3 ] poll_answer.from documents the voter_chat case
  fromOf.poll_answer resolves to .user with an inline note that
  voter_chat (a Chat, not a User) carries anonymous chat votes.

No public API breakage from these fixes. typecheck, lint:core, check:edge,
and the full unit suite (86 tests) pass on both Bun and Node.
yagop added a commit that referenced this pull request Jun 22, 2026
[ P2 ] Context.chat / Context.from exhaustive over Update variants
  Replace the hand-written if-chains with a `satisfies Record<UpdateType, …>`
  resolver table per getter, so a newly-generated Update variant becomes a
  compile error instead of silently returning undefined. Adds the 7 chat
  variants and 4 from variants the chain used to miss (business_message,
  edited_business_message, deleted_business_messages, guest_message,
  message_reaction_count, chat_boost, removed_chat_boost,
  business_connection, managed_bot) - ctx.reply no longer throws for
  handlers on those update types. Regression tests added.

[ P3 ] webhook earlyAck no longer drops handler errors silently
  When no bot.catch() boundary is registered, handleUpdate rethrows and
  the defensive .catch used to swallow it with no trace. Now logs the
  message + stack via the debug sink (rendering the Error explicitly,
  since %o would JSON-stringify an Error to "{}"). Test added.

[ P3 ] TokenBucket.take closes the concurrent-oversell race
  Post-wait recheck loop: two takes that both see an empty bucket no
  longer both decrement on wake; the second sees the bucket drained
  again and waits another cycle. Aborted takes still consume no token.
  Regression test added.

[ P3 ] RateLimiter per-chat map is now a bounded LRU
  maxChatBuckets option (default 10_000); the least-recently-used chat's
  bucket is evicted on insert, bounding memory for long-lived bots that
  talk to many chats. LRU ordering test added.

[ P3 ] poll_answer.from documents the voter_chat case
  fromOf.poll_answer resolves to .user with an inline note that
  voter_chat (a Chat, not a User) carries anonymous chat votes.

No public API breakage from these fixes. typecheck, lint:core, check:edge,
and the full unit suite (86 tests) pass on both Bun and Node.
yagop and others added 24 commits June 25, 2026 10:05
… adapters, tests

- single Api class (ADR-001), encodeForm 3-branch / no serialization (ADR-002/010/011)
- Json<T> builders: InlineKeyboard, fmt()/EntityBuilder, mediaGroup() FormPart
- dispatch: compose, Context, longPoll generator, webhookCallback
- adapters: Express/Next mounts (core), fromPath + createWebhookServer (node subpath)
- subpath exports ./ ./node ./types (ADR-009)
- bonus: scripts/schemas-to-v2.ts emits generated-v2.ts (180 methods, discriminated Update)
- 15 bun unit tests green; tsc + build clean
From-scratch v2 of the library, executing the architecture design. Breaking,
no backward compatibility with the v1 surface.

Core (src/core, zero node: imports — CI-enforced):
- Transport: injectable fetch, AbortSignal merge + timeout, {ok,result}
  envelope unwrap, 429 retry honoring retry_after (ADR-005/008)
- encodeForm: three-branch encoder (file part / form-part composite / string),
  urlencoded unless an InputFile is present (ADR-002/010/011); library
  serializes nothing in the pipeline
- Errors: TelegramBotError base + NetworkError/TimeoutError/ParseError/
  TelegramApiError with structured fields (ADR-008)
- Single generated Api class: one concrete single-argument method per Bot API
  method, no Proxy, no Raw/Api split (ADR-001)
- Builders: json(), InlineKeyboard/ReplyKeyboard, EntityType + fmt(), and
  mediaGroup() form-part builder minting attach:// refs at the call site
- Dispatch: typed koa-compose, per-update Context, Bot (use/on/command/hears/
  catch/start/stop), longPoll async generator, webhookCallback as a pure
  (Request)=>Response, plus Express/Next.js adapters (ADR-003/004/005)

Types (src/types): generator (scripts/api-parser.ts) reworked to emit a
discriminated Update union (ADR-007), branded Json<T> structured params
(ADR-002), InputFile|string file params (ADR-006), expanded MessageEntity, and
the generated Api class. 327 objects / 25 unions / 180 methods.

Node helpers (src/node, only folder allowed node:*): fromPath (fs uploads),
createWebhookServer (node:http -> core callback), run (managed polling).

Packaging: ESM-only, subpath exports (./, ./node, ./types); tsconfig (strict).
Tests: 43 unit tests (injected fetch, no network). check script gates on
tsc --strict + a no-node:-in-src/core lint + the suite. Docs: README usage
examples and redesign/MIGRATION.md (v1->v2 cheatsheet).
Address the redesign/ACTIONABLES.md red-team list. Decision on A1: keep
serialization model E and document the justification (no model switch).

Code:
- A2: restore the "Json<T> is always a string at runtime" invariant. Nested-file
  params (sendMediaGroup/sendPaidMedia/editMessageMedia .media) are typed
  `Json<X> | FormPart`; mediaGroup().build() returns a real FormPart (no
  cast-through-Json); FormPart.writeTo(sink, key) receives the bound field key
  (no hardcoded "media"). Generator updated + schemas regenerated.
- M2: transport retries network/timeout/5xx (not just 429) with exponential
  backoff + jitter (retryBackoffMs, bounded by maxRetries); isTransientError()
  classifier; longPoll resilient loop (retry/maxBackoffMs/onError) that backs
  off and resumes on transient errors, rethrows fatal 4xx, returns on abort.
- M3: opt-in token-bucket rate limiting (TokenBucket, RateLimiter; global +
  per-chat) via TransportOptions.rateLimit; default off, zero overhead.
- M6: webhook early-ACK (fastAck / waitUntil) and constant-time secret compare
  (safeEqual); update-dedup guidance.
- M5: core-isolation lint now also flags Node-only globals; new check:edge
  bundles src/core for a browser/edge target and fails on any reachable Node
  builtin; wired into `npm run check`.

Docs:
- A1 "Why E over D" (serialize-once/reuse-many axis); A3 drop false
  tree-shake-per-method claim; A4 single-params-arg + optional AbortSignal;
  M1 §6.8 real signal mechanism + runtime support matrix; M4 fix ADR-010
  rationale; L1 qualify "serializes nothing"; L2 json() brand limitation;
  L3 Update-union caveats; §10 open questions updated.
- MIGRATION.md: ESM-only / name-retention / CJS interop (L5).
- README: runtime matrix, resilience + rate-limit + fastAck docs.
- Add redesign/RESEARCH-go-rust-clients.md (L4); tick redesign/ACTIONABLES.md.

Verified: tsc --strict + lint:core (imports + globals) + check:edge + 62 unit
tests, all green.
Per request, undo only the A1 and A2 changes from commit e9328cc; keep A3,
A4, and all Medium/Low items.

A2 (code): the generator emits structured params as `Json<T>` only — no
`Json<X> | FormPart` union. `mediaGroup().build()` returns its value via the
`as unknown as Json<…>` cast again, and `FormPart.writeTo(sink)` drops the
field-key parameter (back to the hardcoded "media"). Restored
scripts/api-parser.ts, src/core/{files,encode,media}.ts from 8bff718 and
regenerated src/types/schemas.ts.

A1/A2 (docs): removed the ADR-002 "Why E over D" subsection and the
"serialization frequency" table note (A1); reverted the `Json<T>`-always-a-
string / distinct-`FormPart` wording in §6.2, §6.4, ADR-002 and ADR-011 (A2).
Kept A3 (no per-method tree-shaking), A4 (single params arg + optional
AbortSignal), M1/M4 and L1/L2/L3 doc edits, and the §10 updates.

Verified: tsc --strict + lint:core (imports + globals) + check:edge + 62 unit
tests, all green.
- e2e (bun, live-gated on NODE_TELEGRAM_TOKEN): test/e2e/ adds an attempt-all
  wiring matrix — one entry per generated Api method (180), each called
  arg-less and asserted to produce a real round-trip (resolve OR
  TelegramApiError), failing on NetworkError/ParseError/other (a client-side
  wiring bug). 10 dangerous arg-less mutators (logOut/close/deleteWebhook/
  getUpdates/deleteMyCommands/setMy{Name,Description,ShortDescription,
  DefaultAdministratorRights}/setChatMenuButton) are skipped with reasons. Plus
  a happy-path suite (send→edit→delete, chat action, inline keyboard, dice)
  against TEST_GROUP_ID with cleanup. Live run: matrix 170 pass / 10 skip / 0
  fail; happy-path 6/6.
- examples/: 12 runnable, idiomatic .ts examples (polling, API client, webhook
  Workers/Express/Next, keyboards, formatting, uploads+media, middleware,
  resilience+rate-limit, longPoll stream, conversation) + README index. They
  import the package name and typecheck against src via tsconfig.examples.json
  (path map).
- Removed redesign/RESEARCH-go-rust-clients.md and its links/citations in
  ARCHITECTURE.md (§6.1, ADR-001, ADR-010); gotgbot/teloxide mentions kept.
- package.json: add test:e2e + typecheck:examples; check now also runs the
  examples typecheck.

Static gate green: tsc --strict (src + examples) + lint:core + check:edge + 62
unit tests.
- e2e: replace the multi-file suite (matrix/happy-path/_env) with ONE
  test/e2e/methods.test.ts — a describe per Bot API method (all 180) + a
  coverage parity test, logOut/close last. Strict model: no roundTrip, no
  shared beforeAll; each test does any setup inline and calls the method for
  real — a rejection FAILS the test (env-limited methods will legitimately fail
  live; that is intended). No DANGEROUS_ARGLESS denylist (droppable test bot).
- api: remove the fmt() and mediaGroup() factory functions; the EntityBuilder
  and MediaGroup classes are the public surface (use `new EntityBuilder()` /
  `new MediaGroup()`). Updated all consumers: unit tests, examples, README,
  ARCHITECTURE, MIGRATION, and src doc comments.
- tsconfig: move tsconfig.test.json -> test/tsconfig.json and
  tsconfig.examples.json -> examples/tsconfig.json; add typecheck:test +
  typecheck:examples scripts; `check` now runs both. test:e2e uses
  --timeout 300000.
- remove redesign/ACTIONABLES.md (no longer needed).

Static gate green: tsc (src node-only) + typecheck:test + typecheck:examples +
lint:core + check:edge + 62 unit tests. E2E not run live (bot flood-limited;
run `npm run test:e2e` after it idles ~30 min per the run-tests skill).
Replace every U+2014 "—" with "-" and U+2026 "…" with "..." across all tracked
files (source, tests, examples, docs, scripts). Add an ASCII sanitizer to
scripts/api-parser.ts so a future `generate:types` can't reintroduce them into
the generated src/types/schemas.ts and src/core/api.ts.

(The requested webhook unit test - wrong secret token -> 401 with handleUpdate
not invoked - already exists in test/unit/webhook.test.ts, so no duplicate was
added.)

Gate green: tsc + typecheck:test + typecheck:examples + lint:core + check:edge +
62 unit tests.
…mics

- files/media: generalize FormPart.writeTo(sink, key); add per-structure
  attach:// builders (StickerSetBuilder, inputSticker, profilePhoto,
  storyContent); drop the inputFile() factory for `new InputFile()`
- transport: bound the retry loop to maxRetries+1 (behavior unchanged)
- debug: DEBUG="node-telegram-bot-api:*" tracing to stderr - edge-safe pluggable
  core sink wired to env/stderr by the /node entry; traces in transport,
  longpoll, webhook; bun test preload so `DEBUG=... bun test` prints
- bot: rename start() -> startPolling(); add /node startWebhook() runner and a
  raw node:http webhook example (13); document webhook auth limits
- types: prefer `satisfies` over `as` where it applies (bot.ts + e2e literals)
- e2e: always run (drop the live skip guard); forge real minimal media fixtures
  (gif/mp4/mp3/ogg/jpeg) so uploads are well-formed; sticker/profile/story tests
  use the new builders

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace placeholder/invalid params that could never pass live with valid
data, cross-referenced against the v1 integration suite:

- getStickerSet: assert stickers.length > 0, not the case-sensitive name
  (Telegram echoes canonical casing, e.g. "Pusheen").
- setMyName: capture + restore a real name (empty {} -> BOT_TITLE_INVALID).
- setMyProfilePhoto: real 640px JPEG (1x1 PNG -> PHOTO_CROP_SIZE_SMALL).
- editMessageMedia: start from a different image so the edit is a real
  change (identical media -> "message is not modified").
- savePreparedInlineMessage: add allow_user_chats.
- setPassportDataErrors: base64 element_hash sized to 32 bytes (SHA-256).
- answerShippingQuery: add required error_message (stays env-limited).
- sticker-set lifecycle: valid `_by_<username>` set names + a withOwnedSet
  helper (regular/mask/custom_emoji) that creates a real owned set, runs the
  op, and deletes it. Kept self-contained per method; dense runs flood
  Telegram's set-creation cap (429), an accepted env limit.

Fixtures: add Telegram-blue STICKER_PNG (512x512), STICKER_EMOJI_PNG
(100x100), PROFILE_JPEG (640x640) at the exact dimensions Telegram requires.

Also skip logOut (terminates the session, ~10 min token lockout); the
describe stays registered so the coverage guard still sees it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Skip the `close` terminator too (alongside the already-skipped `logOut`):
  both end the bot session, so running the suite never bricks the shared test
  bot. Both stay registered so the coverage guard still sees them.

- Consolidate the sticker-set lifecycle onto a few SHARED owned sets (one each
  of regular/mask/custom_emoji), created lazily and deleted in afterAll, rather
  than one fresh set per test. createNewStickerSet is flood-capped per bot
  (429 retry-after ~176s); creating ~13 sets per run hit the cap and the tail
  tests blew the per-test timeout. Now ~5 creations total. Verified live:
  13/13 lifecycle tests pass in ~31s (was 644s + 2 timeouts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dia engine; PaidMediaGroup

The nested-file builders used to cast their FormPart output through
`as unknown as Json<T>` - a lie: build() returned an object, not a branded
string, and the encoder only worked by sniffing isFormPart first. Replace
that with honest types:

- FilePart<T> = FormPart & { __files: T } - what build() actually returns
  (a FormPart at runtime; the only cast now is attaching that phantom brand).
- JsonWithInputFiles<T> = Json<T> | FilePart<T> - the honest type for a
  structured field that may carry uploads. The encoder already dispatches
  string | FormPart, so no pipeline change.
- CarriedBy<J> extracts a field's payload type so a builder returns the exact
  FilePart<T> without respelling the wire union (e.g. sendMediaGroup).

Generator (api-parser.ts) now emits JsonWithInputFiles<T> for fields whose
type reaches a file-bearing Input* type (FILE_BEARING_TYPES); schemas.ts has
the identical change hand-applied (the generator fetches live docs, so it
was not run). 10 fields widened; everything else stays a pure Json<T> string.

Builders build plain typed objects (no Record<string, unknown> bag); a single
AttachedMedia engine walks the graph and lets each InputFile.build(index)
mint its own attach://media_<index> ref while the bytes are collected as the
matching part.

Add PaidMediaGroup (sendPaidMedia): InputPaidMedia is file-bearing on the wire
exactly like InputMedia, so it now has a builder and JsonWithInputFiles field
rather than an arbitrary Json<T> exception. Named ...Group because PaidMedia
is already a Bot API response type.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both sendMediaGroup and sendPaidMedia accept a live_photo album item
(InputMediaLivePhoto / InputPaidMediaLivePhoto), which carries TWO uploadable
files - the live photo (`media`) and its still cover (`photo`). The builders
were missing it, an arbitrary gap. Add livePhoto(media, photo, options?); the
AttachedMedia walk already handles multi-file items, so each file takes the
next attach slot (media -> media_0, photo -> media_1). Unit test per builder.

Note: the wire `type` discriminant ("live_photo") follows the Bot API doc
convention; the generated types only carry `type: string`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…les + the generator allow-list

Simplify the structured-field type vocabulary (research: S2). Split the old
Json<T> brand into:

- JsonString<T> = string & { __json: T }  - the JSON-string arm (json() and the
  file-free builders InlineKeyboard/EntityBuilder produce it; still a string, so
  JSON.parse() on a built value keeps compiling).
- Json<T> = JsonString<T> | FilePart<T>   - what EVERY structured field accepts.

Because the file-carrying arm now lives inside Json<T>, every structured field
is just Json<T> - there is no separate "this field can upload" type. So:

- DELETE JsonWithInputFiles<T> (== Json<T> now).
- DELETE the generator's FILE_BEARING_TYPES allow-list and its membership scan
  (transformParamType now always emits Json<T> for structured fields). This
  removes the silent-under-widening maintenance footgun: a new upload type needs
  only a builder, never an allow-list edit.

Json/JsonString/CarriedBy live in brand.ts (type-only import of FilePart from
core/files.js; erased, no runtime cycle - edge bundle stays Node-free). The
file-free builders and caption_entities return JsonString<T> (honest: they are
strings), which avoids the JSON.parse breakage a blanket Json<T> would cause.

Guarantees preserved (verified by @ts-expect-error probe): a bare string / plain
object in a structured field is still a type error, and cross-field precision
holds - a MediaGroup result still cannot be assigned to a sticker field (the
FilePart<T> brand survives the fold). Full check green; no test changes needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Structured request fields (reply_markup, entities, media, ...) are now
plain typed objects/arrays instead of branded Json<T> wire-ready strings.
A single serializeParams() (src/core/serialize.ts), called once in the
generated Api.request before the retry loop, does the JSON.stringify plus
the recursive attach:// walk. Nested InputFile is resolved at depth >= 1
(top-level files still attach under their field name), with one per-call
counter so sendPoll's two file-capable fields mint media_0/media_1.

Deletes the 4-construct brand vocabulary (Json<T>, JsonString<T>,
FilePart<T>, CarriedBy<J>), the json() helper, src/types/brand.ts,
src/core/json.ts, and the generator's param-only mapping path
(transformParamType/isPrimitiveType). The generator now has one mapping
path; InputFile maps to "InputFile | string" and the nested file-target
members (media/thumbnail/photo/cover/animation/video/sticker on Input*
types) widen string -> "InputFile | string", making nested uploads
expressible without builder machinery.

Builders (InlineKeyboard, EntityBuilder, MediaGroup, ...) are kept as
optional sugar and now return plain shapes; the AttachedMedia walk was
promoted into serialize.ts.

Edge-neutral: serialize.ts is pure JS, core bundle stays Node-free.
Gates: 0 Json< / 44 InputFile | string / 182 Result = in schemas; full
npm run check green (tsc src+test+examples, core lint, edge bundle, 75
unit tests). Live wire smoke (8 methods) + live nested sendMediaGroup
upload (plain literal InputFile -> attach://media_0) accepted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ADR-002 docs

Pipeline hardening (from the encodeForm/Transport.request review):

- Encode the request body ONCE, before the retry loop. Re-encoding per attempt
  re-consumed a one-shot ReadableStream InputFile, so any retry (429/5xx/network)
  of a streamed upload threw a raw TypeError out of request(). A FormData/
  URLSearchParams body is safe to send repeatedly. Proven with a red/green test.
- Read response.text() inside the fetch try, so a mid-stream body-read failure is
  classified (NetworkError) and retried as transient instead of escaping raw.

Simplify encodeForm + tighten types:

- New WireValue = string | number | boolean | InputFile | FormPart. serializeParams
  -> Transport.request -> encodeForm now all speak Record<string, WireValue>; the
  unknown is gone from the wire path.
- FormPart becomes a plain data record ({ json, files }) instead of a behavior
  object; the FormSink interface and the writeTo indirection are deleted, so
  encodeForm spreads a FormPart inline. null-stripping is owned solely by
  serializeParams (its rightful place); URLSearchParams built from entries.

Cleanup:

- Remove the dead InputProfilePhotoInput prelude type (generator + schemas); the
  live type is InputProfilePhoto.

Docs:

- Migrate the ARCHITECTURE.md narrative tier (principles, goals, the diagram,
  package layout, sec 6.3/6.4, breaking-changes, ADR-010/011 rationale, roadmap,
  summary, risks) off the superseded Json<T> model to Option D. The deliberate
  history blocks (sec 6.2, ADR-002, both banner'd) are left intact.

check green (tsc src+test+examples, core lint, edge bundle Node-free at 49908 B,
77 unit tests incl. 2 new transport regressions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…params

Every media.ts builder is now `new XBuilder(...).build()`:
- Suffix all builders with `Builder` (MediaGroupBuilder, PaidMediaGroupBuilder,
  StaticProfilePhotoBuilder, AnimatedProfilePhotoBuilder, PhotoStoryBuilder,
  VideoStoryBuilder; StickerSetBuilder already suffixed).
- Every method takes ONE object using the Bot API's own field names, typed
  straight from the generated types via Omit<Input*, "type"> - the builder only
  adds the `type` discriminant. Deletes ~13 hand-rolled option types (Media,
  PhotoOptions, PaidVideoOptions, StoryVideoOptions, GroupItem, ...).
- Drop StickerBuilder: under API-exact field names it was pure identity, so
  addStickerToSet/replaceStickerInSet take the plain InputSticker object.

media.ts 273 -> 193 lines. Call sites (e2e, examples, README, ARCHITECTURE,
MIGRATION) updated. check green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ove WireValue

- Cap 429 retry_after at maxRetryAfterMs (TransportOptions, default 60s; 0 disables).
  A longer flood-wait surfaces the TelegramApiError immediately (err.retryAfter
  intact) instead of hanging - the per-request timeout never bounded that sleep
  (review finding #2). New unit test (returns instantly, no retry).
- serializeParams: replace the unchecked `as string|number|boolean` cast with a
  real typeof guard; a bigint/symbol/function (never emitted by the generated
  types) now throws a clear TypeError instead of corrupting the wire.
- Relocate WireValue from files.ts (file primitives) to serialize.ts (its
  producer/owner); encode.ts and transport.ts import the type from there.

check green (78 unit tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- add @biomejs/biome devDependency and a `biome` script running
  `biome check --write` with style flags (2-space, double quotes, semicolons,
  trailing commas, line-width 120) so no biome.json is needed
- replace the vestigial `/* eslint-disable */` header in the two generated
  files with biome file-level suppression (lint + format + organizeImports),
  emitted from scripts/api-parser.ts so regen stays in sync

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Apply `npm run biome` (biome check --write) across the hand-written sources.
Mechanical only: 2-space indent, double quotes, semicolons, trailing commas,
line-width 120, plus biome safe lint fixes and import sorting. The two generated
files (src/core/api.ts, src/types/schemas.ts) are biome-suppressed and untouched.

npm run check green: tsc (src/test/examples), lint:core, edge bundle, 78 unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- biome script targets ./src (was .) with explicit expand=auto, so it formats
  only sources and leaves examples/tests/scripts untouched
- examples/06-keyboards.ts: inline the ReplyKeyboard builder into reply_markup,
  one call per line for readability

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
yagop and others added 23 commits June 25, 2026 10:06
The two timeout-classification tests built a fake fetch that settled only
when the request's AbortSignal.timeout() fired. That timer is unref'd, and
with no real I/O nothing kept the event loop alive, so Node 22's node:test
runner drained the loop with the test promise still pending ("Promise
resolution is still pending but the event loop has already resolved"),
cancelling the rest of the Transport suite (9 cancelled -> non-zero exit).
Node 24/26 hold the loop open and passed.

Reject directly with the same named errors the catch-block classification
keys on (AbortError, and the "TimeoutError" DOMException shape), matching
the network-failure/caller-abort tests. Deterministic on every Node
version; assertions unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
[ P2 ] Context.chat / Context.from exhaustive over Update variants
  Replace the hand-written if-chains with a `satisfies Record<UpdateType, …>`
  resolver table per getter, so a newly-generated Update variant becomes a
  compile error instead of silently returning undefined. Adds the 7 chat
  variants and 4 from variants the chain used to miss (business_message,
  edited_business_message, deleted_business_messages, guest_message,
  message_reaction_count, chat_boost, removed_chat_boost,
  business_connection, managed_bot) - ctx.reply no longer throws for
  handlers on those update types. Regression tests added.

[ P3 ] webhook earlyAck no longer drops handler errors silently
  When no bot.catch() boundary is registered, handleUpdate rethrows and
  the defensive .catch used to swallow it with no trace. Now logs the
  message + stack via the debug sink (rendering the Error explicitly,
  since %o would JSON-stringify an Error to "{}"). Test added.

[ P3 ] TokenBucket.take closes the concurrent-oversell race
  Post-wait recheck loop: two takes that both see an empty bucket no
  longer both decrement on wake; the second sees the bucket drained
  again and waits another cycle. Aborted takes still consume no token.
  Regression test added.

[ P3 ] RateLimiter per-chat map is now a bounded LRU
  maxChatBuckets option (default 10_000); the least-recently-used chat's
  bucket is evicted on insert, bounding memory for long-lived bots that
  talk to many chats. LRU ordering test added.

[ P3 ] poll_answer.from documents the voter_chat case
  fromOf.poll_answer resolves to .user with an inline note that
  voter_chat (a Chat, not a User) carries anonymous chat votes.

No public API breakage from these fixes. typecheck, lint:core, check:edge,
and the full unit suite (86 tests) pass on both Bun and Node.
The v2 redesign squash (8eabd5c) deleted appveyor.yml, package-lock.json,
and the Actions workflow. AppVeyor's GitHub integration can't be removed,
so the MR ran without a config and fell back to stale project settings
wired for the old JS/lockfile layout -> failure.

Restore appveyor.yml as the Windows CI (Actions covers Linux on Bun +
Node). The repo now ships bun.lock, not package-lock.json, so the
historical 'npm ci' hard-errors; switch to 'npm install --no-audit
--no-fund', matching the Actions unit-node job. Same gate: typecheck,
then the Node (tsx) unit runner on Node 22 + 20.

Verified locally: typecheck passes; test:node:unit (86 tests) passes;
the test/unit/*.test.ts glob expands under node --test itself, so the
lack of shell globbing on cmd.exe/PowerShell is a non-issue.
test/tsconfig.json declares "types": ["node", "bun"] but @types/bun was
never a dependency (only the bun runtime), so 'npm run typecheck:test'
failed with TS2688 'Cannot find type definition file for bun' on a fresh
clone, breaking 'npm run check' - the gate CLAUDE.md tells contributors
to run.

Add @types/bun@^1.3.14 (matches the bun runtime) to devDependencies and
regenerate bun.lock. Also run typecheck:test and typecheck:examples in
the CI typecheck job so test/ and examples/ are actually type-checked
(CI previously ran only 'npm run typecheck' over src, leaving test drift
invisible - this is how the missing dep went unnoticed).

Verified: 'npm run check' (typecheck src+test+examples, lint:core,
check:edge, 86 unit tests) passes.
The v2 redesign squash (8eabd5c) deleted the community/health files that
had survived every prior rewrite. Restore them so contributors and the
release/update-bot-api skills (which depend on CHANGELOG.md) work again.

- CHANGELOG.md: restored verbatim from master (the v1 history through
  1.1.0, with the [Unreleased] section + link-def convention intact).
- CODE_OF_CONDUCT.md: restored from 0c63283 (Contributor Covenant),
  the version the maintainer selected; it differs from master's copy.
The release skill described the v1 flow. Update it for the v2 repo:

- Lockfile: v2 uses bun.lock, not package-lock.json. bun.lock pins deps
  only, not the root version, so 'npm version' changes package.json and
  nothing else -> the release commit is 2 files (CHANGELOG.md, package.json),
  with no lockfile to regenerate. Documented and verified.
- Sanity gate: 'npm run check' (the real gate: typecheck src+test+examples,
  lint:core, check:edge, unit) + 'npm run build'; fix the stale
  test:bun:unit and 'prepare runs on publish' (it's prepublishOnly now).
- Remove the 'Regenerate docs' step: generate:docs / doc/api.md /
  scripts/api-doc.ts were all deleted in the redesign; v2's API surface is
  the generated type defs, not a doc file.
- CI checks: Node 22/24/26 + Bun (AppVeyor covers Windows 20/22), not the
  v1 20/22/24/26 matrix.
- Example version 1.0.0 -> 2.0.0; add a prerelease-tagging note.
… + auto-serialize)

The update-bot-api skill described the v1 model ('types generated, methods
hand-written'). v2 inverts it: scripts/api-parser.ts now regenerates BOTH
the type surface (src/types/schemas.ts) AND the Api client (src/core/api.ts),
so adding a Bot API version is regenerate + test + CHANGELOG - no method
bodies to write and no per-field serialization to wire.

Rewrite the skill for the actual v2 mechanics (all verified against source):

- Methods are generated onto the Api class as a single params object
  (sendMessage(params, signal?)) - no positional args, no Omit<> options
  split. Bot holds this.api: Api (does not extend Api), so it never needs
  per-method edits; new methods surface on bot.api.* automatically.
- Serialization is automatic and universal via serializeParams (called once
  from Api.request): it walks every param, JSON-stringifies objects/arrays,
  hoists nested InputFiles to attach://. The v1 _fix* pipeline
  (_fixJsonFields/_fixReplyMarkup/...) is gone - a new structured field needs
  zero work. Drop that whole step.
- Unit tests are per module (serialize.test.ts / encode.test.ts /
  transport.test.ts), not per method; fetch is injected via
  new Transport(token, { fetch }), not globalThis monkeypatching.
- E2e is test/e2e/methods.test.ts (not test/integration/), one describe per
  method, strict 'call resolving is the assertion' model.
- Drop the docs/coverage step: generate:docs, doc/api.md, scripts/api-doc.ts,
  scripts/coverage-audit.mjs were all deleted in the redesign.
- Gate is 'npm run check' (typecheck src+test+examples + lint:core + check:edge
  + unit), not 'npm run typecheck' over src+test.
- Keep the timeless advice: read the RAW changelog, RETURN_OVERRIDES /
  mapScalar/mapType extensions, benign-deletion diff check, ASCII-only hook,
  probe-before-assert for e2e. Note Context shortcuts (reply,
  answerCallbackQuery) are curated - add one only for an ubiquitous per-update
  helper.
Restore from master, then fix the instructions that the v2 redesign made
stale:

- Drop the 'Updating the API Reference' (doc/api.md) section: generate:docs,
  doc/api.md, and scripts/api-doc.ts were all deleted in the redesign. v2's
  API surface is the generated type defs, not a doc build.
- 'Regenerating the types' -> 'Keeping up with Bot API releases': v2 generates
  BOTH src/types/schemas.ts and src/core/api.ts from one 'npm run
  generate:types' (bun scripts/api-parser.ts); note the strict generator
  (hard error on unmapped types, boolean fallback / RETURN_OVERRIDES) and
  point at the update-bot-api skill.
- Rewrite 'Running tests' for v2: the gate is 'npm run check' (typecheck
  src+test+examples + lint:core + check:edge + unit), not src-only
  typecheck with 'no separate lint step' (v2 does have lint:core/check:edge).
  e2e is 'npm run test:e2e' reading NODE_TELEGRAM_TOKEN/TEST_GROUP_ID/
  TEST_USER_ID from .env, not the deleted test/README.md / test:node:integration.
  Point at the run-tests skill for scoping.

Verified: every command and file referenced resolves; no stale v1 references
remain.
The v2 redesign squash (8eabd5c) deleted both .github templates. Restore
from master, then fix every stale v1 instruction:

PULL_REQUEST_TEMPLATE.md:
- Checklist: 'npm run typecheck clean' + 'doc/api.md up to date
  (generate:docs)' -> 'npm run check clean' + 'npm run build clean' +
  generated-files note (schemas.ts AND api.ts come from generate:types;
  never hand-edit). generate:docs / doc/api.md / scripts/api-doc.ts were
  all deleted in v2.
- Unit section: test:bun:unit -> 'npm test' (Bun) + test:node:unit; name
  'npm run check' as the full local gate.
- E2E section: test:node:integration / test:bun:integration -> 'npm run
  test:e2e'. Drop the v1 TEST_STICKER_SET_NAME / TEST_CUSTOM_EMOJI_ID vars
  (v2 generates synthetic fixtures, not a real public sticker set). Fix the
  skip list: logOut/close/forum-topic ops are test.skip'd; deleteStickerSet
  is NOT skipped (it self-reverts). Note the no-skip-if-no-token design.

ISSUE_TEMPLATE.md:
- Bug-report reading list: deleted doc/usage.md + doc/help.md -> README +
  redesign/MIGRATION.md (the v1->v2 guide, since many incoming bugs will be
  v1 users hitting the incompatibility).

Verified: every npm script and linked doc resolves; no stale v1 references
remain; the skipped-method examples match test/e2e/methods.test.ts.
The type generator now emits a TSDoc comment above every generated Api method
that links to the corresponding Telegram Bot API page, e.g.

  /** {@link https://core.telegram.org/bots/api#sendmessage sendMessage} */
  sendMessage(params, signal?) { ... }

The anchor is the method name lowercased (Telegram's docs convention). This
makes the source self-documenting and, via TypeDoc, becomes the 'Bot API' link
in the generated API reference (see scripts/generate-docs.ts).

Regenerated src/core/api.ts from the updated generator (180 link comments). The
regen is purely additive (0 deletions): it also reconciles a pre-existing
staleness where the committed api.ts was missing the ADR-002 comment block its
own generator already emits.
Add a doc generator (scripts/generate-docs.ts) and an npm script
'npm run generate:docs'. The pipeline has two stages:

  Stage 1: TypeDoc -> doc/api.json (the full serialized project reflection,
           booted on src/core/index.ts + src/node/index.ts; private, protected,
           and @internal members excluded).
  Stage 2: JSON -> doc/api.md (a hand-written renderer: a methods table per
           class with params/returns and a per-method Bot API link, param tables
           for functions, property tables for interfaces, and a section per type
           alias / variable / enum). TypeDoc's ReflectionKind enum is imported so
           the kind numbers track the installed version.

Why split: TypeDoc owns type-graph parsing; the script owns presentation. The
JSON stays on disk as a gitignored inspectable intermediate.

- typedoc added as a devDependency.
- doc/api.md (the reference) is checked in; doc/api.json is gitignored.
- Every in-page cross-reference resolves (verified: 1313 links, 0 dangling);
  slug() matches GitHub's anchor algorithm (underscores preserved).
- CONTRIBUTING.md: add a 'Generating the API reference' section.
- .claude/skills/generate-docs/SKILL.md: rewritten for the new pipeline.

Verified: idempotent (re-run leaves no diff); npm run check (86 tests) and
npm run build both pass.
Adversarial review of scripts/generate-docs.ts found two real bugs and two
latent renderers that were wrong but unexercised.

Bug 1 (accessors render as 'void'): TypeDoc 0.28 stores an accessor's
.getSignature as a SINGLE signature object, not an array, and the accessor
has no own .type. emitClass read getSignature[0] (undefined) -> collapsed to
undefined -> 'void'. All 11 Context accessors were wrong in the doc:
callbackQuery/channelPost/chat/etc. showed 'void' instead of their real
'T | undefined'. Guard both shapes (single object + older array) and route
the result through escCell (union types carry a raw '|' that would split the
table cell).

Bug 2 (locale-dependent sort): byName used localeCompare, whose order varies
by runtime/locale (verified en-US puts 'apfel' first, sv-SE last). For a
committed generated file regenerated in CI incl. the AppVeyor Windows runner
(uncontrolled locale), two machines could emit declarations in different
orders -> spurious doc diffs and a flaky 'docs up to date' check. Switch to a
codepoint comparator for deterministic, environment-independent ordering.

Latent renderers (0 occurrences today, now correct + tested):
- mapped: was a hardcoded '{ [K in keyof T]: ... }' placeholder ignoring the
  constraint/modifier/template. TypeDoc shape is { parameter, parameterType,
  templateType, readonlyModifier?, optionalModifier?, nameType? }; render it
  faithfully.
- template-literal: guessed the tail shape. TypeDoc shape is
  { head: string, tail: [[type, sep], ...] } (confirmed via the deserializer);
  render substitutions + separators correctly.

Also: annotate the 4 .filter((m) => ...) callbacks (implicit any, surfaced now
that typecheck:test covers scripts/ via the new test import).

Add test/unit/generate-docs.test.ts covering mapped/template-literal/conditional
rendering, slug underscore-preservation, and codepoint sort order (6 tests,
pass under both Bun and Node). Export __test from the script for the tests.

Regenerated doc/api.md: 11 accessor rows corrected, plus locale->codepoint
reordering. All 1321 in-page links still resolve; idempotent.
…r prose

The 'Bot API' column only made sense for the Api class (it held the telegram.org
link). For every other class (Bot, Context, builders) it was always '-', and
worse: their hand-written prose descriptions (Bot.command, Context.reply, ...)
were silently dropped.

Switch the column to render the full comment summary via renderSummary:
- Api methods: the {@link} renders to the official Bot API link (unchanged
  output, just under a 'Description' header). All 180 external links preserved.
- Library methods: now show their actual prose ('Match a message starting with
  /name ...', 'Send a message to the inferred chat ...', etc.) instead of '-'.

Remove the now-dead telegramLink() helper and its BASE_URL constant.

Verified: 1321 in-page links resolve, 180 telegram links preserved, idempotent,
npm run check (92 tests) + build pass.
    - webhookCallback now requires `secretToken` at setup unless the caller
      passes `allowUnauthenticated: true` (auth enforced at another layer), so
      an unauthenticated endpoint is never created by accident. A provided
      secret is validated against Telegram's format (1-256 of [A-Za-z0-9_-]),
      failing fast instead of 401-ing every real update.
    - nodeFrameworkWebhook now prefers a pre-parsed `req.body` (object / string
      / Buffer) and only drains the raw stream when absent, so the Express
      adapter works whether or not a body parser (e.g. express.json()) ran
      upstream and consumed the stream.
    - Docs: README upload examples carry an explicit InputFile `filename`
      (the core does no content sniffing) plus a short note.
    - Tests: new test/unit/adapters.test.ts covers the body-source branches;
      webhook tests cover require/opt-out/format validation.'
The Windows job runs node-only (tsc + the tsx unit runner) and never
invokes bun, but `bun` is a devDependency whose npm postinstall
(`node install.js`) downloads a platform binary and hard-fails on the
AppVeyor VM (Failed to find package @oven/bun-windows-x64-baseline),
aborting the whole `npm install` and the build with it.

Add `--ignore-scripts` to the install step. tsc is pure JS and
tsx/esbuild still work because esbuild resolves its native binary from
an optionalDependency package (@esbuild/win32-x64), which is installed
regardless of lifecycle scripts -> only the failing bun postinstall is
skipped. Also drop a stray non-ASCII em dash from the file comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… Windows)

`test:node:unit` was `node --test --import tsx test/unit/*.test.ts`. On
Windows the shell does not expand the glob, so it relied on Node's own
`--test` globbing - which only exists in Node 21+. Node 22 passed, but
Node 20 received the literal `test/unit/*.test.ts`, matched nothing, and
failed with "Could not find ...*.test.ts".

Add scripts/run-node-unit.mjs: it expands the glob itself (readdirSync)
and hands `node --test --import tsx` explicit file paths, which works on
every supported Node and OS. Keeps Node 20 in the AppVeyor matrix; no new
dependencies. The bun runner (`npm test`) is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Revert the run-node-unit.mjs launcher: instead of expanding the glob in
JS to support Node 20 on Windows, drop Node 20 from the AppVeyor matrix
(now 22 + 24, matching the Actions 22/24/26 set) and run the test glob
directly again. Every version in the matrix is now >=21, where Node's own
`--test` globbing expands `test/unit/*.test.ts`, so no launcher is needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Build 294's Node 24 job failed at Install-Product with "Node.js is only
supported on Windows 10, Windows Server 2016, or higher" - AppVeyor's
default image is Windows Server 2012 R2, which Node 24 dropped. Node 22
still runs there, which is why only the 24 job broke. Pin the Visual
Studio 2022 image (Server 2022) so both matrix versions install.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The typecheck job ran tsc x3 but not `lint:core` (src/core must not touch
node:* or Node globals) or `check:edge` (src/core must bundle with no Node
builtin reachable) - the two invariants the v2 redesign exists to uphold.
They only ran in the local `npm run check` gate, so CI stayed green even
if core regressed. Add both as steps to the typecheck job, which already
has bun + node and installed deps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror the v1 zshy setup (now an ancestor after rebasing feat/v2-core onto
master) for v2's three subpath exports (., ./node, ./types):

- build via `zshy --project tsconfig.build.json`; it emits ESM (*.js/*.d.ts)
  and CJS (*.cjs/*.d.cts) for every entrypoint. `noEdit: true` keeps the
  hand-written `exports` map (condition-specific `types`) authoritative.
- the `exports` map gains a `require` condition per subpath; add `module`
  and point `main` at the CJS entry.
- wire the `postbuild` source-map repair (scripts/fix-cjs-sourcemaps.mjs,
  carried over from master). It is REQUIRED: stock zshy 0.7.3 mislabels every
  CJS artifact's source-map reference - the *.cjs / *.d.cts point at the ESM
  *.js.map / *.d.ts.map, and the companion maps carry the ESM `file` field -
  so the postbuild rewrites all of them (104 refs on a clean build) to point
  at their own .cjs.map / .d.cts.map. It is idempotent (re-running patches 0).

src/core stays Node-free (lint:core + check:edge still pass), so the
edge/runtime-agnostic story is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
yagop and others added 4 commits June 25, 2026 11:30
Adding the zshy dual-module build means v2 now ships a CommonJS entry, but
several docs still claimed "ESM-only, no CommonJS build" and told CJS users to
fall back to a dynamic import(). Correct them:

- MIGRATION.md: the v1->v2 table and the "Runtime & module format" section now
  state the module system is not a migration blocker - `require()` works - and
  the dynamic-import() workaround is dropped. The real break is the API surface.
- ARCHITECTURE.md: breaking-change item 10 reworded from "ESM-only / no CommonJS
  build" to "web-standard core, dual ESM+CJS package" (core stays Node-free).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Fold the current-state architecture from redesign/ARCHITECTURE.md into
  CLAUDE.md (present tense; drop the ADRs and explicit breaking-changes list).
- Move the v1->v2 migration cheatsheet from redesign/MIGRATION.md into the
  CHANGELOG [Unreleased] section; remove the redesign/ folder.
- Restore the 1.1.1 and 1.1.2 release sections a stale restore had dropped,
  and fix the [Unreleased] compare link (v1.1.2...master).
- Repoint redesign/ references in README, package.json, and the issue
  template; update CONTRIBUTING's build section to the zshy dual ESM+CJS build.
- Correct the 'full e2e suite logs the bot out' claim (logOut/close are
  test.skip-ed) in CLAUDE.md, CONTRIBUTING.md, and the e2e file header.
- Rewrite the run-tests skill for v2: test/e2e paths, commands, bun:test vs
  node:test, v2 error/transport names, core internals, in-suite coverage guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
longPoll previously rethrew a surfaced 429, killing the poll loop on a flood.
Now a 429 is classified transient (isTransientError) and the loop waits the
error's retry_after - or retryDelayMs (default 1s) when none is given - and
re-polls WITHOUT advancing the offset. The outer exponential backoff is gone:
the transport already owns the bounded per-request exponential retry, so the
loop only needs a light wait (this also removes the two layers' compounding).

Supporting changes:
- LongPollOptions: drop maxBackoffMs, add retryDelayMs.
- Extract the jittered backoff formula into delay.ts (transport is now its only
  consumer); rename longpoll's poll-timeout const to DEFAULT_POLL_TIMEOUT.
- Extract HTTP_STATUS_TOO_MANY_REQUESTS (429) in errors.ts, used by errors +
  transport (mirrors node:http2's constant; src/core stays Node-free, so it
  cannot import it).
- Tidy longpoll comments: concise function JSDoc, leading body comments.
- Tests: longPoll resumes after a 429; isTransientError coverage. Regenerated
  doc/api.md for the new constant + option rename.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Run `bun run format` (biome): import sort and minor formatting in context.ts,
longpoll.ts, webhook.ts. Plus two non-cosmetic lint fixes so the run exits clean:
- transport.ts: combineSignals cleanup uses `for...of` instead of a forEach
  callback that implicitly returned a value (useIterableCallbackReturn).
- debug.ts: `active?.enabled(...)` optional chain (useOptionalChain).

Remaining biome warnings (the Express-adapter `any`, keyboard `!` assertions,
a useTemplate suggestion) are intentional and left as-is; biome is not part of
the `npm run check` gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant