Folds the entire `actions/interaction.ts` (sendPoke / sendLike /
setGroupReaction / getEmojiLikes) plus the lone-survivor
`setGroupEssence` from `actions/group-message.ts` into
`apis/interaction.ts::InteractionApi`. Both action files deleted —
the recall + markRead halves of group-message were already absorbed
into MessageApi back in commit 1.
Skipping the planned standalone GroupMessageApi commit: with only one
method left (`setGroupEssence`), giving it its own Api class would be
pure overhead. Essence is conceptually an "interaction" anyway (mark
a message as essence is parallel to "react with an emoji").
Method renaming (Api namespace already says "interaction"):
sendPoke → sendPoke (no rename — already minimal)
sendLike → sendLike
setGroupReaction → setReaction
setGroupEssence → setEssence
getEmojiLikes → getEmojiLikes (no rename — read-side, fine)
Callers updated: `instance-context.ts`, `modules/message-actions.ts`,
`actions/extended.ts`. Tests `interaction.test.ts` rewritten to use
`new InteractionApi(bridge as any).method(...)` and gained a
`setEssence` test. `group-message.test.ts` rewritten to target
MessageApi.recall* / markRead* methods that were already moved.
587/587 tests pass.
Inlines `actions/group-album.ts` (OIDB sendRawPacket via QunAlbum.trpc.*)
and `web-actions/group-album.ts` (HTTP cookie-based) into
`apis/group-album.ts::GroupAlbumApi`. Both action files deleted. Pure
HTTP helpers in `web/group-album.ts` stay where they are — they're
transport-layer code.
Method renaming follows the now-standard "drop the redundant suffix"
rule (the Api namespace already says `groupAlbum`):
getGroupAlbumList → list (HTTP)
uploadImageToGroupAlbum → upload (HTTP slice-upload)
getGroupAlbumMediaList → getMediaList
commentGroupAlbumMedia → comment
likeGroupAlbumMedia → like
deleteGroupAlbumMedia → delete
Callers updated: `instance-context.ts`. `web-actions/index.ts` no
longer re-exports the deleted album functions. No tests existed for
this surface area, so no test churn.
587/587 tests pass.
Inlines `actions/group-file.ts` into `apis/group-file.ts::GroupFileApi`,
deletes the old action file, removes the matching 17 facade methods
off `Bridge`, and slims `BridgeInterface` accordingly.
Method renaming follows the same "strip the redundant suffix" rule we
applied to GroupAdminApi:
uploadGroupFile → upload
uploadPrivateFile → uploadPrivate
sendGroupFileMessage → publish (OIDB 0x6d9_4)
fetchGroupFiles → list
fetchGroupFileCount → getCount
fetchGroupFileUrl → getUrl
fetchPrivateFileUrl → getPrivateUrl
fetchGroupPttUrlByNode → getPttUrl
fetchPrivatePttUrlByNode → getPrivatePttUrl
fetchGroupVideoUrlByNode → getVideoUrl
fetchPrivateVideoUrlByNode → getPrivateVideoUrl
deleteGroupFile → delete
moveGroupFile → move
createGroupFileFolder → createFolder
deleteGroupFileFolder → deleteFolder
renameGroupFileFolder → renameFolder
`upload`'s atomic upload+publish flow now calls `this.publish(...)`
directly instead of going through `bridge.sendGroupFileMessage`, and
`uploadPrivate` calls `this.ctx.apis.message.sendC2cFile(...)`.
Callers updated: `instance-context.ts`, `media-url-resolver.ts`,
`actions/group-file.ts` (onebot), `actions/extended.ts`,
`modules/{contact,media,message}-actions.ts`.
Tests: `group-file.test.ts` rewritten to use
`new GroupFileApi(bridge as any).method(...)`. The "verify publish was
called" assertions now use `vi.spyOn(api, 'publish').mockResolvedValue()`
since `publish` is an instance method on the same Api. `_helpers.ts`
gains `mockGroupFileApi()` for cross-Api callers and drops the flat
`sendGroupFileMessage` shim. `media-url-resolver.test.ts` and
`send-group-file.test.ts` switched to `bridge.apis.groupFile.*`; the
extended-actions `APIS_ROUTING` map now carries a `[area, newName]`
tuple so renamed methods auto-promote.
587/587 tests pass. The network-manager file remains as the only
failing test file (pre-existing infrastructure issue unrelated to #6).
Third of 13 commits. Folds the previous `actions/group-admin.ts`
plus the matching Bridge facade methods into a typed `apis.groupAdmin`
hub.
Methods (14, all renamed to drop the redundant `Group` prefix once
the `apis.groupAdmin` namespace makes it implicit):
muteGroupMember → apis.groupAdmin.muteMember
muteGroupAll → apis.groupAdmin.muteAll
setGroupAddOption → apis.groupAdmin.setAddOption
setGroupSearch → apis.groupAdmin.setSearch
setGroupAddRequest → apis.groupAdmin.setAddRequest
kickGroupMember → apis.groupAdmin.kickMember
kickGroupMembers → apis.groupAdmin.kickMembers
leaveGroup → apis.groupAdmin.leave
setGroupAdmin → apis.groupAdmin.setAdmin
setGroupCard → apis.groupAdmin.setCard
setGroupName → apis.groupAdmin.setName
setGroupSpecialTitle→ apis.groupAdmin.setSpecialTitle
setGroupRemark → apis.groupAdmin.setRemark
getGroupAtAllRemain → apis.groupAdmin.getAtAllRemain
actions/group-admin.ts deleted. The corresponding `*_` alias imports
and `async xxx() { return xxx_(this, …); }` delegate methods on
Bridge are gone.
Caller updates
--------------
* onebot/instance-context.ts — 6 sites (the multi-method binding for
the OneBot action layer).
* onebot/actions/extended.ts — 8 sites including `getAtAllRemain`.
* onebot/actions/group-admin.ts — all 6 onebot action handlers.
* onebot/modules/request-actions.ts — `setGroupAddRequest`.
Test updates
------------
* tests/actions/group-admin.test.ts — was importing the deleted
`* as admin` module and calling `admin.muteGroupMember(bridge, ...)`.
Switched to `new GroupAdminApi(bridge as any).muteMember(...)` —
same wire validation, just constructs the real Api class once per
call (the Api is stateless so it's effectively free).
* tests/onebot/request-actions.test.ts — was stubbing
`setGroupAddRequest` flat on bridge. Updated to
`apis: { groupAdmin: { setAddRequest } } as any`.
Tests: 587/587 pass (network-manager native-addon failure unchanged).
Second of 13 commits decomposing Bridge. Moves the six "fetch the
roster / a profile / outstanding requests / download rkeys" methods
off Bridge and onto a new `apis.contacts.*`.
Methods moved
-------------
* fetchFriendList — OIDB 0xfd4_1 + identity.rememberFriends
* fetchGroupList — OIDB 0xfe5_2 + identity.rememberGroups
* fetchGroupMemberList — OIDB 0xfe7_3 + identity.rememberGroupMembers
(includes the 60s TTL + per-group inflight
coalesce cache that prevents Tencent
risk-control from triggering)
* fetchUserProfile — OIDB 0xfe1_2 + identity.rememberUserProfile
* fetchGroupRequests — OIDB 0x10c0_{1,2}
* fetchDownloadRKeys — OIDB 0x9067_202
Cache fields moved
------------------
The two member-list throttle maps (`memberListInflight_`,
`memberListLastFetch_`) move from Bridge to `ContactsApi` as private
fields. Same TTL, same semantics — just owned by the Api class that
exposes the method.
Constructor ordering
--------------------
`buildApiHub(this)` now runs BEFORE `identity.setFetcher(...)` in the
Bridge constructor because the `fetchProfile` / `fetchGroupMemberList`
callbacks installed there reach into `this.apis.contacts.*`. With
eager construction this is safe; the alternative (pass a thunk that
forwards once apis is built) was strictly worse for readability.
bridge-contacts.ts removed
--------------------------
The file existed solely as the previous module-function delegate
target; ContactsApi inlines every function body so there's nothing
left to keep.
Caller-site updates
-------------------
* `bridge/actions/friend.ts` — `bridge.fetchFriendList()` →
`bridge.apis.contacts.fetchFriendList()`.
* `onebot/manager.ts`, `onebot/instance-rkey.ts`,
`onebot/modules/contact-actions.ts`, `modules/request-actions.ts`,
`actions/extended.ts` — same shape rewrite (6 method names).
Test plumbing
-------------
* `tests/actions/_helpers.ts` — `mockApiHub()` grows a `contacts`
field built by `mockContactsApi()`.
* `tests/bridge-member-identity.test.ts::RefreshingBridge` — was
`override async fetchGroupMemberList()`. After the move the method
lives on `this.apis.contacts`, not `this`. Switched to patching the
method on the constructed ContactsApi instance directly.
* `tests/actions/friend.test.ts` — `bridge.fetchFriendList` →
`bridge.apis.contacts.fetchFriendList`; the "refresh throws" branch
overrides via `bridge.apis.contacts.fetchFriendList = vi.fn(…)`.
* `tests/onebot/contact-actions.test.ts`, `extended-actions.test.ts`,
`request-actions.test.ts` — local `fakeBridge` helpers gained an
auto-promotion shim that rewrites flat method stubs (the pre-#6
test API) into the new `apis.<area>.method` shape. Keeps existing
test setup readable while the production code uses the new path.
Tests: 587/587 pass (same baseline; pre-existing network-manager
native-addon failure unchanged).
First of 13 commits decomposing the Bridge god-object into typed Api
classes following NapCat's `ctx.apis.XxxApi.method()` pattern. This
commit lays the foundation (BridgeContext, ApiHub, eager construction)
plus moves the message-send/recall/mark-read methods onto MessageApi.
Subsequent commits will move the other 12 areas (group admin, files,
profile, …) similarly.
Architecture
------------
* `bridge-context.ts` — new interface, the surface Api classes see.
Carries identity / events / apis / sendRawPacket / resolveUserUid /
next{Message,Client}Sequence helpers / upload meta cache. Replaces
the previous `BridgeInterface` for Api-class consumption (the
legacy interface still exists, just slimmed; full removal happens
in commit 13).
* `apis/index.ts` — `ApiHub` interface + `buildApiHub(ctx)` factory.
Each commit grows `ApiHub` by one entry; today only `message` is
populated.
* `apis/message.ts` — `MessageApi` with seven methods inlined from
the previous Bridge implementation:
sendGroup ← Bridge.sendGroupMessage
sendPrivate ← Bridge.sendPrivateMessage
sendC2cFile ← Bridge.sendC2cFileMessage
recallGroup ← actions/group-message.ts::recallGroupMessage
recallPrivate ← actions/group-message.ts::recallPrivateMessage
markGroupRead ← actions/group-message.ts::markGroupMessageRead
markPrivateRead ← actions/group-message.ts::markPrivateMessageRead
(Names trimmed: the `apis.message` prefix already signals the
context, so `apis.message.sendGroupMessage` would be redundant.)
Bridge changes
--------------
* Removed the 7 methods listed above (moved to MessageApi).
* Removed unused imports + `SEND_MSG_CMD` constant + `buildSendElems`
+ `protobuf_encode/decode` (now used only inside MessageApi).
* Added `readonly apis: ApiHub` field, built eagerly in constructor.
* Promoted `nextMessageRandom` / `nextClientSequence` from private →
public (Api classes need them to build SendMessageRequest packets).
* `BridgeInterface` now also lists `apis: ApiHub` + the two
generators; the 7 deleted method signatures are gone.
Caller-site updates (~9 files)
------------------------------
* `bridge/actions/group-file.ts` — `bridge.sendC2cFileMessage(...)`
→ `bridge.apis.message.sendC2cFile(...)`.
* `onebot/instance-context.ts` — markGroupMsgAsRead / markPrivateMsgAsRead
bindings now route through `apis.message.markGroup/PrivateRead`.
* `onebot/modules/message-actions.ts` — every send/recall site
rerouted; `typeof ref.bridge.sendXxx` type refs also updated.
* `onebot/actions/extended.ts` — same.
Tests (~5 files)
----------------
* `tests/actions/_helpers.ts` — `mockBridge()` grows an `apis` field
built by `mockApiHub()`; the previous flat `sendGroupMessage` etc.
vi.fn stubs are replaced with `mockMessageApi()`.
* All test files using direct `bridge.sendXxx` / `bridge.recallXxx` /
`bridge.markXxxAsRead` calls or stubs updated to `bridge.apis.message.*`.
* No behaviour changes — 587/587 pass (same baseline; pre-existing
network-manager native-addon failure unchanged).
The `markGroupMsgAsRead` / `markPrivateMsgAsRead` Bridge methods were
calling the wrong underlying implementations because the alias names
on the import line were swapped:
- `markPrivateMessageRead as markGroupMsgAsRead_` meant calling
`bridge.markGroupMsgAsRead(groupId, …)` actually fired the c2c
(private) read-report wire shape with `groupId` shoved into the
`userId` slot — the server then resolved a uid for what was
really a groupId and posted to the wrong route.
- `markGroupMessageRead as markPrivateMsgAsRead_` was the mirror:
`bridge.markPrivateMsgAsRead(userId, …)` fired the group read-
report shape with `userId` in the `groupUin` field.
Realigned to the intended pairing. The underlying implementations
in `actions/group-message.ts` are already covered by
`tests/actions/group-message.test.ts` (asserts both hit
`SsoReadedReport` with their proper wire shapes); they pass
unchanged because they always tested the action-layer functions
directly, never the Bridge facade where the swap lived.
Caught while planning the #6 Api-on-ctx refactor — preserving the
swap into the new MessageApi would have carried the bug forward.
Move `packages/core/src/bridge/proto/proton/*.ts` (13 files, ~3.3k
LOC) into a new `@snowluma/proto-defs` workspace package. The proton
spike landed in the previous commit means bare-specifier imports
(`@snowluma/proto-defs/oidb`, `…/message`, …) now resolve correctly
through proton's vite plugin, so call-site replacement still fires.
Why
---
- Lets downstream consumers share the same wire
definitions without copy-pasting them — multiple workspace packages
can now depend on `@snowluma/proto-defs` directly.
- Trims `@snowluma/core`'s surface: the bridge no longer owns the
protocol schema, only its consumers.
- Sets up future #5 (`message-parsing perf`) and #6 (`Api-on-ctx
refactor`) — both want a thinner core.
Layout
------
Per-file subpath exports (mirrors the legacy `proto/proton/<sub>`
shape 1:1). The barrel `index.ts` intentionally skips `highway` and
`oidb-actions/media` because the two files share six NTV2-prefixed
type names — `export *` would silently drop one side. Subpath imports
are unambiguous, which is also why every call site in core uses them.
Changes
-------
- New package `packages/proto-defs/` (package.json, tsconfig.json,
src/index.ts).
- 13 `.ts` files moved via `git mv` to preserve history.
- 44 import sites across 26 source files + 15 tests rewritten via sed
from `./[…/]proto/proton/<sub>` → `@snowluma/proto-defs/<sub>`.
- `@snowluma/core` depends on `@snowluma/proto-defs` (workspace:*).
Verification
------------
- proto-defs typecheck: clean
- core typecheck: clean
- core test suite: 587 passed (same baseline as before the refactor;
one failure is the pre-existing websocket native addon, unrelated)
- proton test suite: 99 passed (8 files including the new
cross-package.test.ts from the spike)
- Full workspace `pnpm -r typecheck`: all 8 projects clean
Note: proton's `dist/index.js` MUST be rebuilt for the vite plugin to
pick up the bare-specifier resolution. The previous commit's source
change is only effective after `pnpm -F @snowluma/proton build`.
`resolveModulePath` used to skip any specifier not starting with '.'
which meant proton's vite plugin only saw proto definitions that lived
in the same package as the call site. Trying to move definitions into
a sibling workspace package (e.g. `@snowluma/proto-defs`) caused those
imports to silently fail — the call-site type lookup found nothing
and the protobuf_encode<T> replacement never fired.
Add a bare-specifier branch that delegates to `ts.resolveModuleName`
with `moduleResolution: Bundler`. That covers monorepo workspaces,
npm packages, package.json `exports`, and tsconfig path aliases with
zero extra user configuration.
The relative-path branch is preserved byte-for-byte so existing
single-package consumers see identical behaviour. Resolution is
constrained to `.ts` source files (not `.d.ts`) because proton walks
interface AST to collect `pb<>` field tags, and generated declaration
files typically strip those generic arguments.
Per-process `ModuleResolutionCache` keeps the hot path (HMR rebuild)
from re-scanning node_modules every call.
Tests: new `cross-package.test.ts` builds a fake monorepo under
`os.tmpdir()` and exercises smoke / transform / round-trip / negative
paths. 99/99 proton tests pass; SnowLuma core regression unchanged
(587 baseline ⇄ 587 with change).
Delete an outdated comment block in fetchPrivateFileUrl that described receiverUid behavior (Lagrange.Core/NapCat and NTQQ fulllink timeout). No functional code changes were made aside from removing the explanatory comment; the surrounding logic (resolveSelfUid, void userId, and OIDB envelope creation) remains unchanged.
Update the debug optimization item to specify a Proton fallback plan: implement unpackaged debug with correct data directory and use TypeScript + map-based type extraction for tsx and Node.js, which will be stripped during normal Vite builds.
Standardize and reorganize imports, type usages and minor formatting across the bridge modules. This commit moves protobuf helpers, makeOidbEnvelope/runOidb, OidbBase and logger imports into consistent places, reorders and groups action/contacts exports in Bridge, removes or trims file-level comments, and fixes whitespace/logic ordering in several highway, element-builder and msg-push files. These are refactors and import/type adjustments across many files under packages/core/src (bridge, highway, msg-push, webui, utils, etc.) with no intended behavioral changes.
Add consistent indent rule to ESLint and normalize formatting/indentation across multiple files. Refactor RequestUtil: improve cookie extraction, redirect handling, and HttpGetJson/HttpGetText request flow; fix cookie string builder formatting. Fix uploadGroupNoticeImage promise/error handling and network request flow. Also apply minor style/refactor tweaks in bridge web APIs, onebot handlers, message-store SQL alignment, and Proton AST collector utilities to improve readability and correctness.
Add a repository .editorconfig and a root eslint.config.ts to standardize formatting and linting. Migrate packages/webui ESLint config to TypeScript (eslint.config.ts) with project-specific rules and tsconfigRootDir handling, and remove the old JS config. Update package.json to run ESLint directly (add lint and lint:fix) and add required ESLint/dev dependencies; also add jiti to packages/webui/package.json. Include a small formatting tweak to tsconfig.base.json.
Add support for resolving types re-exported with `export * from '...'`. Introduces extractExportAllSpecifiers and a new exportAllTypeSources map on ParsedFileEntry, populates it while parsing (recursively following export-all chains using the cache and an inProgress set to avoid cycles), and falls back to exportAllTypeSources when resolving imported type names. This allows types and generic templates exported through export-all declarations to be discovered and resolved.
Delete proto schema modules under packages/core/src/bridge/proto (action.ts, element.ts, highway.ts, longmsg.ts, message.ts, notify.ts, oidb-action.ts, oidb.ts) and the related test packages/core/tests/proton-parity.test.ts. These files contained protobuf schema definitions for the bridge layer; remove them as part of a cleanup/refactor. Update any imports or references to these modules to their new locations or replacements.
Import protobuf_decode from @snowluma/proton in packages/core/src/bridge/actions/interaction.ts to enable decoding of protobuf messages within interaction-related bridge actions. No other changes to logic in this diff.
Replace the encodeOidbEnv wrapper with direct protobuf_encode<OidbBase<T>> calls across bridge modules. Updated many bridge action files (extras, friend, group-admin, group-file, group-message, interaction, misc, profile, bridge-contacts, highway pipeline, etc.) to import protobuf_encode and OidbBase and to pass encoded OIDB envelopes via protobuf_encode. Removed the encodeOidbEnv implementation from bridge-oidb and restructured proton OIDB definitions by adding new proto/proton/oidb-actions modules (base, group-album, group-file, media, index) and adjusting proton proto usage. This centralizes envelope encoding for better monomorphization and simplifies the OIDB encoding flow.
Refine and clarify inline documentation across proton schema files (action.ts, element.ts, highway.ts, longmsg.ts, message.ts, notify.ts). Replaces verbose legacy preambles with concise comments, adds concrete notes about server expectations (e.g. Trans0x211 routing for C2C files, required NotOnlineFile fields on send, NTV2 upload extBizInfo video scene fields), and documents GroupFileExtra/GroupFileInfo field alignment and magic tags to explain past send/receive failures. Changes are purely comment and schema documentation updates to improve maintainability and explain interoperability pitfalls with the server; no runtime API/signature changes were introduced.
Remove long legacy/explanatory comment blocks and ASCII-art dividers, replacing them with shorter, consistent section headers (e.g. "Oidb.0xFD4_1 Friend list", "Oidb.0xFE5_2 Group list"). Trim redundant commentary about OidbBase/OidbBaseMeta while keeping all type/interface definitions unchanged. This cleans up the file for readability without altering the public types or runtime behavior.
fetchPrivateFileUrl now uses the current account UID (resolveSelfUid) as receiverUid instead of resolving the peer UID, matching Lagrange.Core/NapCat behavior and avoiding NTQQ fulllink handler timeouts. The userId parameter is intentionally unused. runOidb gained an optional timeoutMs argument and the private-file request uses a 5000ms timeout; bridge.sendRawPacket is forwarded this timeout.
CI tsc flagged two strict-mode issues in d1aade6:
* element-builder: `ctx?.forwardFake` doesn't carry the non-null
narrowing to a subsequent `ctx.groupId` access — replace with an
explicit `ctx && ctx.forwardFake` guard.
* forward-file.test: `vi.fn(async () => [])` infers `mock.calls`
as `[][]`, so `const [, ctx] = mock.calls[0]!` is "Tuple of length
'0' has no element at index '1'". Declare the mock's parameter
types (`unknown[], Record<string, unknown>?`) so the call tuple
has the right arity, and use `ctx?.groupId` since the ctx slot
is now typed as optional.
Previous fix split file segments off at the OneBot live-send layer
(c2c → sendC2cFileMessage, group → sendGroupFileMessage) and made
element-builder drop any `{type:'file'}` segment that reached it.
That broke forward messages: send_forward_msg encodes each node
through buildSendElems for the long-msg upload, so file segments
inside a forward node hit the drop-with-warn path and the forward
bubble shipped without the file.
Forward upload is NOT a live PbSendMsg — it stores the gzipped
protobuf verbatim through `trpc.group.long_msg_interface.MsgService.
SsoSendLongMsg`, and the recipient decodes file entities through the
normal msg-push path (transElem(24) → GroupFileExtra; msgContent →
FileExtra { file: NotOnlineFile }). So `result=79` doesn't apply here.
Add a `forwardFake?: true` flag on SendContext; when set:
* group file → element-builder emits transElem(24) again (restored
makeGroupFileElem, exactly the receive-side wire shape).
* c2c file → element-builder still skips; forward-builder pulls the
segment off the node and writes `body.msgContent` as
FileExtra { file: NotOnlineFile }, with file metadata hydrated
from the upload cache when inline fields are missing (same
pattern as send_private_msg).
Mirrors NapCat's PacketMsgFileElement.{buildElement,buildContent}
split (dev/napcatQQ/.../packet/message/element.ts:530-610) and
Lagrange.Core V2's MultiMsgUploadService.cs.
`vi.fn(async () => undefined)` infers `mock.calls: [][]`, so
destructuring `const [groupId, fileId] = ...mock.calls[0]!` fails
under TS strict tuple checks (`error TS2493: Tuple type '[]' of
length '0' has no element at index '0'`). Declare the param types
inline so the call-args tuple is `[number, string]`.
The chat-publish step after upload_group_file used to ship a PbSendMsg
with a transElem(elemType=24) — but that wire shape is RECEIVE-side
only, and current QQ-NT rejects it on send with `result=79`. Lagrange.
Core V2 uses a dedicated `OidbSvcTrpcTcp.0x6d9_4` for the publish
(`GroupSendFileService.cs`); mirror that. Send_group_msg with a
`{type:'file', file_id}` segment now routes through the same path
after the OneBot layer splits file segments out (matching the c2c
file split pattern).
Separately, send_private_msg with just `{file_id}` was shipping a 0 B
file because the wire packet for c2c file send needs fileSize/fileMd5/
fileName/fileHash but the OneBot caller only echoes the file_id from
upload_private_file. Add a bounded (1024-entry, FIFO) bridge cache
populated at upload time so the send-message path can rehydrate the
full tuple; inline segment fields still take precedence so callers
that track the metadata themselves can bypass the cache. Parser now
also accepts `file_hash`/`fileHash` on file segments.
Also: file segments no longer get a `[空消息]` log line — the format
string now shows `[文件:<name-or-id>]`.
Include optional element.subType and element.summary in the image segment payload (mapped to sub_type and summary) so custom emoji/sticker metadata is preserved. Adds a unit test to verify these fields are forwarded.
The c2c file send path was wrong on three independent axes — every
c2c file the bot tried to deliver was getting rejected by the QQ-NT
server. Aligned to `dev/Lagrange.Core` (V2 branch) which is the only
direct-protocol reference for c2c files; NapCat goes through QQ-NT
IPC and never builds this packet itself.
Three wire-shape fixes:
1. **Routing**: `routingHead.c2c { uin, uid }` → `routingHead.trans0x211
{ ccCmd: 4, uid }`. RoutingHead field 15 is the c2c-file scene's
slot. `MessagePacker.BuildPacketBase` makes this conditional on
`chain.HasTypeOf<FileEntity>()`: when a file is present, C2C goes
nil and Trans0X211 carries the routing.
2. **File payload location**: `messageBody.richText.notOnlineFile {...}`
→ `messageBody.msgContent = serialize(FileExtra{file: NotOnlineFile})`.
The server doesn't read c2c file metadata from `richText` at all —
`MessageBody.cs:12` even has the comment "Offline file is now put
here(?". Confirmed by `FileEntity.PackElement()` returning empty
and `PackMessageContent()` returning the FileExtra wrapper.
3. **contentHead.c2cCmd**: `11` → `0` (default/omitted). `11` was a
stale go-cqhttp value the QQ-NT server doesn't recognise on the
c2c-file dispatcher. Lagrange leaves it at the C# default (0).
Schema fixes (proton + legacy, both directions):
- `RoutingHead` gains `trans0x211?: pb<15, RoutingTrans0x211>`. The
new message has `toUin@1`, `ccCmd@2`, `uid@8`.
- `SendMessageBody` gains `msgContent?: pb<2, bytes>` (the receive-side
`MessageBody` already had it).
- `FileExtraInfo` deleted — was wrong on every tag (fileSize=1,
fileName=2, fileMd5=3, fileUuid=4, fileHash=5, none of which match
the wire). `FileExtra { file: pb<1, NotOnlineFile> }` now wraps the
same `NotOnlineFile` used on the receive path. This is why c2c file
PUSHES were also silently failing the four-field truthy check in
`extractMsgContent` — the schema couldn't decode anything.
- `NotOnlineFile` gains the three intake-validator fields the server
requires on send: `subcmd@9 = 1`, `dangerEvel@50 = 0`, `expireTime@55`
= now+7d.
Receive side (`rich-body-decoder.ts::extractMsgContent`): drop the
"all four fields must be truthy" guard (it was a workaround for the
broken schema) and accept any payload that decodes a fileUuid.
Tests:
- `bridge-private-routing.test.ts`: new regression that round-trips
`sendC2cFileMessage` through `protoDecode` and asserts trans0x211
routing, msgContent FileExtra wrapper, NotOnlineFile field tags,
c2cCmd=0. Catches each of the three wire-shape regressions
independently.
- `send-private-c2c-file.test.ts`: vi.fn arg signatures explicit for
CI typecheck.
647/647 tests pass.
User report: `send_private_msg` with a `{type:'file', file_id}` segment
fired `[ElemBuilder] file send via elems[] is group-only; use bridge.
sendC2cFileMessage for c2c` and the message went out as "[空消息]".
Root cause: c2c files live on `RichText.notOnlineFile` (parallel to the
elems array, see proto/proton/message.ts), not inside `elems[]`. The
element-builder explicitly drops c2c `{type:'file'}` elements with a
warning. `sendPrivateMessage` was passing the file straight to
`bridge.sendPrivateMessage(userId, [fileElement])` which then ran
through `buildSendElems`, lost the file at the drop, and shipped a
0-element wire send — rendered as "[空消息]" by every receiver.
Fix: split at the OneBot layer in `sendPrivateMessage`. Pull file
elements out of the batch and dispatch each via
`bridge.sendC2cFileMessage`; route the rest through the regular
elems[] path. Matches NapCat's `dev/NapCatQQ/.../SendMsg.ts:404-415`
where FILE / VIDEO / ARK / PTT each get their own sendMsg call and
never mix with the regular elems[] batch.
- File-only message: only the c2c file API is called (single roundtrip)
- Mixed text + file: two sends, text first then file
- File without file_id: dropped with a warn (was silently shipping
empty messages)
- UID resolution failure: surfaced as throw instead of empty send
Also rewrote the element-builder fallback warn from "use
sendC2cFileMessage for c2c" (now stale advice) to "BUG: c2c file
reached element-builder — should be split out at the OneBot layer".
Tests: 4 new regression cases under
`tests/onebot/send-private-c2c-file.test.ts`. 646/646 pass.
Cross-referenced `dev/NapCatQQ/.../SendMsg.uploadForwardedNodesPacket`
(208-347): every layer of a nested forward attaches its own msgBody to
the *outer* long-msg upload under a uuid `actionCommand` slot. Receiver
walks the whole tree from one server fetch instead of resolving each
layer's res_id separately.
`dev/Lagrange.Core` (V2 branch on `linwenxuan04/v2`,
`MultiMsgUploadService.cs:23-31`) only writes a single MultiMsg action
— that's why NapCat-style receivers couldn't follow our previous
nested-forward sends past the first level.
Refactor:
1. `ForwardNodePayload` gets an optional `innerForward?:
ForwardNodePayload[]` field. `parseForwardNodes` no longer eagerly
uploads inner chains — it just attaches them here.
2. `uploadForwardNodes` now drives the whole recursion. The new
`uploadForwardNodesRecursive`:
- walks each node, recursively uploads any `innerForward` first
- replaces that node's `elements` with a real `{type:'forward'}`
preview pointing at the inner res_id
- piggybacks the inner level's `{uuid, msgBody}` + every deeper
level it surfaced into this level's `myInnerActions`
- encodes the long-msg payload as
`[{MultiMsg, ownMsgBody}, ...innerActions.map(uuid+msgBody)]`
- returns `{resId, msgBody, uuid, innerActions}` so an outer call
can keep climbing
Top-level public signature unchanged: `Promise<string>`.
3. New `deriveInnerSource` / `previewLinesFromNodes` /
`previewFromElements` helpers synthesise the ARK preview metadata
the inner bubble shows before the user taps to expand. They handle
text/image/record/video/file/forward/face element previews.
4. `parseForwardNodes`: when `content` is an all-node array, attach
the parsed inner chain to `node.innerForward` and let the upload
pipeline take over. `elements = []` placeholder is replaced by
`uploadForwardNodes` once it has the real res_id.
Tests:
- `actions/forward.test.ts`:
- `uploadForwardNodes recursively uploads nested innerForward chains
(NapCat piggyback model)` — asserts two SsoSendLongMsg roundtrips
(inner first, then outer) and the outer res_id propagates up
- `flat sends still take a single roundtrip` — backwards-compat
- `onebot/forward-nested.test.ts`: rewritten — mock now sees exactly
one `uploadForwardNodes` call with `innerForward` attached, instead
of two sequential calls; depth-limit + mixed-content tests
unchanged.
The NTV2 upload request for video carried `time: 0` on the main mp4
sub-file, mirroring NapCat's transformer. But NapCat lives on top of
the desktop QQ-NT client's IPC layer, which patches the duration into
the resulting MsgInfo before the wire send — we don't have that layer,
so the `time: 0` we passed in ended up baked into the `VideoFile.
fileTime` field of every video the bot sent. Receivers then rendered
every video bubble as "00:00".
Cross-checked acidify's `RichMediaUpload.kt::buildVideoUploadInfoList`
(2026-04 refactor) — they're a protocol-direct client too and write
`time = payload.videoDuration` for exactly this reason. We already
have `video.duration` from `loadVideo` (probed via ffprobe), so it's
a one-liner.
Width/height kept at 0 deliberately — acidify fills them, NapCat
zeros them, and our earlier comment recorded "QQ NT server treats
non-zero values here as schema mismatch on c2c sends". Out of scope
for this fix; revisit if a c2c regression surfaces.
Test asserts `fileInfo.time === 10` for the main sub-file (matches
FINGERPRINT.duration) and stays 0 on the thumb.
Two off-by-one bugs in the group-file chat-post element that combined
to make the QQ-NT server reject every group file send with result=79
(visible as "group file uploaded but chat post failed" in the logs):
1. Inner GroupFileInfo fields were shifted one tag left:
- fileSha was field 5, should be field 6
- extInfo was field 6, should be field 7
- fileMd5 was field 7, should be field 8
Field 5 is actually a uint32 placeholder (`field5` in both NapCat
and acidify). With the bug, our fileSha bytes were landing where
the server expected a varint and the rest of the message got
skipped as garbage.
2. Outer GroupFileExtra was missing the magic field1=6 + fileName
slots that NapCat's encoder writes
(`packet/message/element.ts:589`). Acidify's schema also models
them as required even though it serialises with defaults. The
current server build rejects sends without these slots populated.
Cross-referenced:
- NapCat `GroupFileExtraInfo` (component.ts:146-155) — matches our
new field numbers exactly
- acidify `GroupFileExtra.Inner.Info` (proto/message/extra/GroupFileExtra.kt)
— same field numbers; their schema uses generic `field5`/`field7`
names but the wire layout is identical
Changes:
- Proton + legacy schemas: shift inner fields, add outer field1/
fileName/display
- `makeGroupFileElem`: write field1=6 + fileName on the outer wrapper
- Regression test asserts each inner tag lands at 50/58/66 (not the
old 42/50/58) and verifies the new outer field1 + fileName bytes
are present
The 2026 QQ-NT server has stopped populating *every* legacy host field
(uploadIp / uploadDomain / uploadIpList / uploadHttpsDomain / uploadDns).
The host now arrives in the new field 210 `rtpMediaPlatformUploadAddress`
— a repeated `IPv4` message carrying `inIP`/`inPort` (LAN) and
`outIP`/`outPort` (WAN). Coworker's debugging hint "host 好像是 fix64"
was the 8-12-byte length-delimited IPv4 message looking like a
fixed-width field on the wire.
Cross-referenced acidify (https://github.com/LagrangeDev/acidify) —
the only NT-protocol fork that's tracked this change (refactored in
their 2026-04 protobuf rewrite). They read `inIP`+`inPort` exclusively
because that's the address the highway PUT actually needs to reach
(same DC as the OIDB endpoint). LagrangeGo / Lagrange.Core / NapCat
still ship the old schema and would fail the same way against this
server build.
Changes:
- Proton schema: add `IPv4` message (fields 1-5: outIP/outPort/inIP/
inPort/iPType, all int32 varint) + field 210 on the upload response
- Consumer: prefer `rtpMediaPlatformUploadAddress[0].inIP`+`inPort`
before falling back through the legacy host chain
- New helper `int32ToIpv4Dotted` reverses acidify's `Int.toIpString()`
(little-endian byte order, `>>>` for unsigned shift so high-bit IPs
don't render as negative octets)
- Diagnostic warn-log now dumps the rtp address array too
- Regression test: 192.168.1.1 (LE-packed 0x0101A8C0 = 16885952) +
inPort=8080 should reach highway PUT
Surveyed Lagrange.Core (PushMessageService.cs), LagrangeGo
(client/listener.go), lagrange-python (server_push/msg.py), mania
(push_msg.rs), and the tsuzcx/qq_apk decompile — zero handlers for
380 / 0x17C across the lot. Pattern matches subType=38 (also a
QQ-client-internal state push with no OneBot mapping); drop it
through the same silent-acknowledge path so we stop spamming
`MsgPush.Unknown` at debug level on every push.
User report: `upload_private_file` was failing with "private file upload
host is invalid" on a fresh upload (boolFileExist=false). The OIDB call
succeeded but `uploadIp` (field 60) came back empty, and we treated
that single field as the only source of truth.
Cross-referenced LagrangeGo's `OidbSvcTrpcTcp0xE37_800.proto` + NapCat's
`Oidb.0XE37_800.ts`: the server has six parallel host slots on this
response (uploadIp / uploadDomain / uploadIpList[0] / uploadHttpsDomain
/ uploadDns / uploadLanip). LagrangeGo + Lagrange.Core only read field
60 and would hit the same dead-end — we walk the chain instead. Also
log the full slot set when we genuinely can't find a host so future
reports can name which field the server actually populated.
Adds two regression tests: one proves the fallback to `uploadDomain`
goes to highway successfully, one proves we still throw when every
slot is empty.
Delete the "Bundle runtime dependencies" step from .github/workflows/dev-build.yml. The removed step changed to working-directory: dist and ran npm install --omit=dev, so CI will no longer install runtime deps into dist during the dev build, reducing build time and avoiding bundling node modules into the dev artifacts.
Close three coverage gaps surfaced by the cross-Lagrange-family
audit (Lagrange V2 / LagrangeGo / acidify / lagrange-python):
- **0x210 subType 179 + 226 (NewFriend)** — mutual-accept friend
notice. 179 fires when bot sent a request and the other side
accepted; 226 fires for the opposite direction. Both decode
through the same wire shape. LagrangeGo handles the pair via a
`case 226: case 179:` fallthrough in `client/listener.go:248`.
We surface them as `kind: 'friend_add'`, which the existing
converter routes straight to OneBot `notice.friend_add` — bot
frameworks that wait for "auto-add a friend after they accept"
were previously stuck because nothing arrived.
- **0x210 subType 139 (self-recall)** — bot recalled its own
message sent to a friend. Same `FriendRecall` wire shape as 138
but the peer is on `toUid` instead of `fromUid` — acidify's
`parseFriendRecall:380` splits exactly this way. Without 139,
self-recalls disappeared and downstream `notice.friend_recall`
handlers thought the bot had never recalled anything.
- **PkgType 85 (bot self-joined a group)** — fires on the bot's
session once `set_group_add_request` is approved or an invite is
accepted, ported from lagrange-python's `server_push/msg.py:108`.
PkgType 33 (GroupMemberIncreaseNotice) only fires for *other*
members joining, so without 85 the bot never received its own
`notice.group_increase` until the first message arrived. We
decode `SelfJoinInGroup` and emit `kind: 'group_member_join'`
with `userUin = selfUin`; the existing converter picks
`sub_type: 'approve' | 'invite'` from the operator/user equality
so consumers see the standard OneBot shape.
While wiring 139, also fix a *pre-existing* prod bug spotted via
proton call-site analysis on the resulting bundle: `event-0x210.ts`
imported `FriendRecall` from both `proto/proton/notify` (as
`FriendRecallNotify`) *and* `events` (as the event interface).
Proton's import-resolver registers `importedTypeSources` keyed on
the *imported* name (last-write-wins), so the events.ts import
shadowed the proto one — `protobuf_decode<FriendRecall>(...)` lost
its codec, fell through to the runtime fallback throw, and was
silently swallowed by `MsgPushRegistry.decode`'s catch. Verified by
the substituted codec list in `dist/index.mjs`: pre-fix it had
`FriendRequest` / `GeneralGrayTipInfo` but no `FriendRecall`;
post-fix `FriendRecall` / `NewFriend` / `SelfJoinInGroup` are all
emitted as expected. Side-step the collision by importing the
proto types unaliased and re-deriving the colliding event type via
`Extract<QQEventVariant, { kind: 'friend_recall' }>`.
New proto interfaces in `proto/proton/notify.ts`:
- `NewFriend` + `NewFriendInfo` (uid/time/message/nickName fields,
fields 1-9; ported from
`LagrangeGo/.../notify.proto:25 NewFriend`)
- `SelfJoinInGroup` (groupUin / operatorUid + a few placeholder
fields; ported from
`lagrange-python/pb/status/group.py:170 PBSelfJoinInGroup`)
Regression coverage in `tests/msg-push-handler.test.ts`:
- subType 179/226 both produce identical friend_add events
- falls back to `routingHead.fromUin` when the new friend's uid
isn't in the local cache (matches LagrangeGo's
"emit-then-resolve" pattern)
- subType 138 keeps fromUid as the peer (other recalled their
own msg to us); subType 139 picks toUid (we recalled ours
sent to them)
- PkgType 85 emits group_member_join with userUin == selfUin,
operator resolved through identity when known
core: 635/635 tests pass (+7), typecheck clean. Production bundle
now contains substituted codecs for FriendRecall, NewFriend,
SelfJoinInGroup (cross-checked via `grep protobuf_decode_X` on
`dist/index.mjs`).
Earlier this commit acknowledged subType 38 as a placeholder
"FriendGrayTipInfo" because none of the LagrangeDev-family projects
(Lagrange V2 / lagrange-python / LagrangeGo / acidify) have a
handler for it — they all just fall through with a generic
"unknown subType" debug log. The "best guess" gray-tip framing was
wrong.
A contributor traced it through the decompiled stock QQ Android
client to `com.tencent.imcore.message.ext.codec.decoder.msgType0x210
.SubType0x26` (tsuzcx/qq_apk @ afe46ef). The payload is
`submsgtype0x26.MsgBody` dispatching on `uint32_sub_cmd`:
- 0x1 → `UpdateAppUnreadNum`: a list of
`{group_code, app_id, unread_num}` keeping group-internal
"mini-app" badge counts (e.g. `appId=101846662` / `101896870`
for the troop shortcut bar) in sync.
- 0x3 → `UpdateDiscussAppInfo`: `{conf_uin, app_tip_notify.text}`
discussion-group app tips, routed through `getGAudioHandler()`.
- 0x4 → delegated to the troop online-push handler.
None of these have a meaningful OneBot mapping — they're
QQ-client-UI state pushes (badge counts, in-app tips), not
conversation events. Rename the enum entry to `GroupAppStatePush`
to reflect the actual semantics, cite the decompiled source
inline, and keep the silent-drop behaviour from the previous
attempt. Tests + decoder routing follow the rename.
628/628 tests pass (unchanged), typecheck clean.
CI's `tsc --noEmit` was rejecting `forward-nested.test.ts` because the
`vi.fn(async () => 'RES')` mocks for `bridge.uploadForwardNodes` only
inferred zero or one arg from the lambda — so
`uploadForwardNodes.mock.calls[0]` came out typed as `[]` or
`[nodes: any[]]`, and indexing `[1]` / `[2]` for groupId / userId
hit `Tuple type ... has no element at index 'N'`.
Local `tsc --noEmit` happened to be looser about this in my version,
which is why the bad assertions slipped past `e637647`.
Declare the full 3-arg signature on every `uploadForwardNodes` mock
— `(_nodes, _groupId?, _userId?)` — matching the real
`Bridge.uploadForwardNodes` contract. `mock.calls[i]` now types as a
3-tuple, the index accesses check out, and the assertions stay
unchanged. No production code touched, no test behaviour change.
626/626 still passing, typecheck clean.
A custom forward node's `content` field can itself be a list of
`{type:'node'}` entries — that's the nested-forward case where one
forward bubble contains another. `parseForwardNodes` used to pass
the whole `content` straight into `parseMessage`, which routed
each inner `{type:'node'}` through `message-parser.ts:217 case
'node':` and turned them into opaque
`MessageElement{type:'node', resId: JSON.stringify(content)}`
placeholders. `element-builder.buildSendElems` then dropped those
at its default branch — so the outer forward shipped with either
zero elements (pure-nested → server rejects with "message is
empty") or text-only elements (mixed content → silently truncated
chat bubble). User-facing symptom: "嵌套转发解析后发不出去".
Add depth-aware recursion to `parseForwardNodes`, modelled on
NapCat's `SendMsg.uploadForwardedNodesPacket` (3-level cap):
- When a node's `content` is *all* `{type:'node'}` entries,
recurse: parse the inner chain into ForwardNodePayload[],
upload it via `bridge.uploadForwardNodes` to mint an
inner res_id, then embed a `{type:'forward', resId}` element
in the outer node. The receive-side rendering pipeline already
builds the ARK preview card for that element shape, so the
QQ client renders a tap-to-expand nested bubble.
- Mixed content (text + node siblings) deliberately stays on the
flat path — the meaning is ambiguous, and forcing a recursion
would silently drop the non-node siblings.
- Hard cap at depth 3; deeper attempts throw "depth exceeds 3".
Matches NapCat and prevents one slow inner upload from
aborting an unreasonably deep tree.
Threading: each forward send entry point passes its destination
into the parser (`{groupId}` from group-send, `{userId}` from
private-send) so the inner forward uploads against the same
res_id namespace.
Test coverage in `tests/onebot/forward-nested.test.ts`:
- group: inner uploads first, outer wraps inner res_id in a
forward element, both calls happen in order
- private: c2c path threads userId (not groupId) into the
inner uploadForwardNodes
- depth cap rejects 4-level nesting with /depth/
- flat (non-nested) content still uploads exactly once
- mixed content stays on the legacy parseMessage path
core: 626/626 tests pass (+5), typecheck clean, prod build clean.
The companion to `e637647`. That commit fixed
`upload_group_file` / `upload_private_file` to publish the file as
a chat message atomically (matching napcat). This one fixes the
OTHER half of the same bug: when a bot framework does
send_msg([{type:'file', file_id:'<previously uploaded>'}])
— attaching an already-uploaded file as a message segment — the
parser used to fall through to its default warn-and-drop branch
(`message-parser.ts:340`). The dropped segment then either:
- became "message is empty" if it was the only segment, or
- silently disappeared if it shared the payload with text,
producing a chat bubble missing the file.
Adds a `case 'file':` that maps the OneBot segment shape to the
existing `MessageElement{type:'file', fileId, fileName, fileSize,
md5Hex, sha1Hex}` that `element-builder` already knows how to
emit as `TransElem(24)`. Accepts the common alias spellings
(`file_id`/`fileId`, `name`/`filename`/`fileName`,
`size`/`fileSize`, `md5`/`md5Hex`, `sha1`/`sha1Hex`).
Deliberately rejects `{file: '/path'}` form — kicking off an
upload from inside the synchronous parser would either swallow
errors or restructure the whole parseMessage signature, neither
worth it. Callers wanting upload-and-send should keep using
`upload_group_file` / `upload_private_file`, which now does both
atomically.
core: 621/621 tests pass (+3), typecheck clean.
`upload_group_file` / `upload_private_file` used to do only the
upload half of the napcat-equivalent flow:
OIDB 0x6D6_0 alloc → highway PUT → return fileId
The fileId was real, the bytes were on QQ's CDN, the logs said
"uploaded" — but the chat showed nothing because no message was
ever sent referring to the upload. NapCat's `UploadGroupFile`
action (`dev/napcatQQ/.../UploadGroupFile.ts:54-58`) does the
upload AND immediately calls `MsgApi.sendMsg(peer, [fileElement])`
in the same action; that second step is what makes the file
visible in the chat.
Add the missing send step. Two paths because the wire shape
differs:
- **Group files** ride on `Elem.transElem` (type 24) in the
`RichText.elems` array. Add a `makeGroupFileElem()` helper +
`case 'file':` in `element-builder.buildSendElems`, then
`bridge.sendGroupMessage(groupId, [fileElement])` after a
successful upload. The TransElem wraps a `GroupFileExtra`
protobuf in `0x01 | BE16(len) | payload`, byte-symmetric with
the receive-side decoder at `rich-body-decoder.ts:171-185`.
- **C2C (private) files** live on `RichText.notOnlineFile`
(field 3 on RichText, parallel to `elems`), so they can't go
through `buildSendElems` at all. Add `Bridge.sendC2cFileMessage`
that builds the `SendMessageRequest` with `notOnlineFile`
directly and dispatches via `sendRawPacket`. The decoder side
that lifts these back to `{type:'file'}` segments has lived
at `rich-body-decoder.ts:425-433` the whole time.
Behavioural detail: both upload paths call the chat-publish step
*only* when `uploadFile=true` (the default). `uploadFile=false`
keeps the upload-only semantics for callers that just want a
fileId without dropping a message in the chat. If the chat post
fails for any reason (server rejection / network), the action
still returns the fileId with a warning log — the file is on QQ's
side, callers can re-publish if they want.
Regression coverage in `tests/actions/group-file.test.ts`:
- publishes the file as `{type:'file'}` via sendGroupMessage on
group upload
- skips the post when uploadFile=false
- returns success even when the chat post fails
- same trio for private upload going through sendC2cFileMessage
- and confirms private path does NOT route through the
`sendPrivateMessage` elems[] pipeline
core: 618/618 tests pass (+5), typecheck clean. Production build
contains 8 substituted call sites for the new GroupFileExtra +
SendMessageRequest paths under proton.
Vendored in 6294e29, used by every protobuf call site in `core`
since the OIDB migration landed (5b55f5b). Add a one-liner to the
workspace table next to the other infrastructure packages.
`vite build` was emitting a single ~606 kB chunk for the whole SPA
(Vite even printed its >500 kB warning). Every route — overview,
config (with its full edit-dialog form surface), logs, settings —
was eagerly imported in `router/index.tsx`, so users paid the full
parse + execute cost before the login redirect even resolved.
That's the most plausible cause of the "loading node config is
somewhat slow" report.
Switch each route to `lazyRouteComponent(() => import('…'), '<Name>')`
so the page chunk is fetched on demand, and wrap `<Outlet />` in
`AppLayout` with a `<Suspense>` boundary backed by a small skeleton
fallback. `defaultPreload: 'intent'` is already set on the router,
so hovering a sidebar link warms the next chunk before the click —
navigation still feels instant after the first idle moment.
Build now emits:
index.js 606 kB → 284 kB (-53%)
overview-page 15 kB
config-page 25 kB
logs-page 5.6 kB
settings-page 5.8 kB
button (shared primitives) 164 kB
+ smaller per-component chunks Vite shook out
And the >500 kB warning is gone.
Bonus: replace the per-render
`list.filter(...).map(...).includes(item.name)` O(n²) duplicate-name
check in `NetworkTabView` with a one-shot `Map<name, count>` —
matters every time the user types in the edit dialog and the tab
underneath re-renders.
The proton plugin was only in `vitest.config.ts`, so tests and
typecheck passed but the bundled `dist/index.mjs` shipped with the
unsubstituted generic shells — every `protobuf_encode<T>()` /
`protobuf_decode<T>()` call hit the runtime fallback and threw:
"protobuf_encode<T>() was not transformed by the
@snowluma/proton Vite plugin. …"
at every msg-push, rkey/friend/group/profile warmup, etc. on the
Win/Linux releases.
Add `protobufVitePlugin()` to the shared `BaseConfigPlugin` so the
transform runs in both dev (`vite build --mode development`) and
prod (`vite build`). Sanity-check on a fresh `vite build` shows the
bundle now contains 481 distinct monomorphized codecs (704 encode +
511 decode call-site replacements), and the only remaining
"was not transformed" string is the dead runtime fallback that no
longer has a caller.
`HttpServerAdapter.startServer` baked `accessToken` into the
`createServer` callback's closure, then relied on
`bindingSignature` including the token to trigger a
`close()`+`open()` round-trip on every change. Two problems:
1. The three sibling adapters (`ws-server`, `http-post`,
`ws-client`) all read `this.config.accessToken` inline at the
point of use, so the HTTP-server's closure-capture pattern was
the odd one out.
2. `server.close()` is asynchronous; the immediately-following
`server.listen()` on the same port can race with the OS
releasing it. When it loses, the new listener fails with
EADDRINUSE (logged via `server.on('error', …)` but not bubbled
up), the old server is gone, and the operator's `reload` looks
successful while the endpoint is actually broken — which lines
up with the "token didn't hot-reload" reports.
Drop the closure capture: `handleRequest` reads
`this.config.path` and `this.config.accessToken` fresh per request
(`reload()` already replaces `this.config` in place via
`structuredClone(next)`). Remove `accessToken` from
`bindingSignature` so a token change no longer triggers a port
re-bind. `host`/`port`/`path` stay in the signature because Node's
`Server.listen()` binds the socket on those.
Four hot-reload tests pin the new contract: token swap, clearing
to empty, setting from empty, and "token-only change returns
`NetworkReloadType.Normal`" (no Reopened/Closed). All four hit a
single OS-assigned port and verify the port is unchanged after
reload — a re-bind would either land on a different port or fail.
core: 613/613 tests pass (+4), typecheck clean.