Raise patched floors for qs, fast-uri, hono, @hono/node-server,
postcss, ip-address, body-parser, and brace-expansion so the
lockfile no longer resolves vulnerable transitive versions.
Keep hono on the 4.12 line used by WebUI. Direct core ranges now
start at the patched releases; pnpm.overrides prevent lockfile
regeneration from rolling back.
Workspace typecheck is clean. Core 1085 tests and MCP 64 tests pass.
WebUI access was a single shared password. This adds optional
Authenticator TOTP as a second factor, with one-time recovery codes
and SSH as the last-resort disable path.
Login stays two HTTP posts with no pending token: a correct password
and enabled 2FA without a second factor returns { needsTotp: true };
the client then resubmits { password, totp } or { password, recoveryCode }.
The TOTP secret is wrapped with a password-derived AES-256-GCM key.
Eight XXXX-XXXX recovery codes are stored as SHA-256 hashes and each
code is consumed once. Enabling or disabling 2FA invalidates other
sessions; disabling still requires password plus TOTP or a recovery
code. SNOWLUMA_DEV_MODE hides the feature.
The login page uses a dedicated second-factor screen (Interior OTP
cells for the 6-digit code; the same cell UI for recovery codes,
grouped as XXXX-XXXX). Settings expose enroll/confirm/disable and
one-time recovery-code display.
Fixes#351
Use safer defaults for newly created service endpoints and guide operators toward suitable access credentials without disrupting existing deployments.
Apply the same policy at the save boundary, retain deliberate local-only setups and external client compatibility, and add regression coverage for editing, recovery, and trusted access paths.
Move confirmations, editors, and help surfaces onto one consistent interaction model.
Preserve accessible focus handling while making destructive and in-progress actions clearer.
Make line endings deterministic across macOS, Windows, and Linux while preserving the native checkout format required by Windows launchers.
Pin the tested application runtime and Node type baseline, add a shared startup preflight, and keep independently published packages on their existing compatibility ranges.
Align local scripts, pull request validation, complete package builds, dependency metadata, and contributor documentation around the same Node and pnpm contract. The root test command now covers every tested workspace package while a focused Core command remains available for local iteration.
Keep per-widget configuration when persisted overview layouts are migrated against the current widget catalogue. Account shortcuts, notes, links, filters, and other configurable widgets now retain their saved values after a page reload.
Add a WebUI Vitest entry point and regression coverage for restoring an account widget with saved configuration.
Fixes#243.
Replace native range inputs with an accessible Radix-backed control that preserves keyboard and assistive-technology semantics.
Reproduce the elastic handle, track stretch, tick visibility, and reduced-motion behavior while keeping SnowLuma theme tokens. Adopt the component for interface scale and background image controls.
Keep terminal styling confined to interactive console output while storing plain structured log lines for subscribers and file persistence.
Sanitize downloads again at the WebUI boundary so cached legacy entries and fallback-formatted records cannot leak CSI, OSC, DCS, or C1 control sequences. Add regression coverage for colored output, hyperlinks, control strings, malformed sequences, and preserved multiline text.
The logs page rendered every filtered row, so a full 2000-line view mounted
~10k DOM nodes and cost ~66ms of layout per commit. Switch to a virtualized
list (@tanstack/react-virtual) that mounts only the visible window plus a small
overscan — a few dozen nodes regardless of how many lines the view holds.
Rows are dynamically measured (measureElement) so wrap mode's variable-height
lines need no fixed row-height assumption; the size cache is keyed by row id so
the maxLines front-drop doesn't mis-attribute heights across the index shift.
The column header moved just outside the scroll viewport so the virtual list is
the sole scroll content (no sticky-offset math). Auto-scroll now follows the
tail via scrollToIndex with a next-frame re-pin (freshly-appended rows carry the
size estimate until measured, which otherwise left wrap-mode tails short), and
re-runs on filter/level/wrap/maxLines changes, not just new logs. ScrollArea
gained an optional viewportRef so the virtualizer can drive off the real Radix
scroll element. The per-row entrance fade is dropped (rows mount/unmount on
scroll); divide-y became a per-row border.
Add webuiHost / webuiTls.enabled / trustProxy to RuntimeConfig as the shared
system-settings model for WebUI Wave A1 (HTTPS + self-config panel). Refactor
load into pure, tested normalizeRuntimeConfig + resolveRuntimeEnvOverrides
(env wins, in-memory only; SNOWLUMA_WEBUI_HOST added alongside the existing
PORT/TRUST_PROXY overrides). loadRuntimeConfig backfills older runtime.json
with the new defaults. Adds vitest to @snowluma/common (was typecheck-only).
The overview becomes a gridstack-backed free grid — drag, resize, and show/
hide 9 widgets (the 5 top stat tiles split out of the old single block, plus
connections/alerts/host/sessions), persisted to config/ui.json's
layout.overviewBlocks as {id,x,y,w,h,visible}.
- gridstack (zero-dep, vanilla; chosen over react-grid-layout whose
react-draggable uses the React-19-removed findDOMNode). React renders each
widget into a gridstack item via a portal (context flows through); gridstack
owns layout/drag/resize. Per-widget minW/minH prevent content collapse.
- Desktop = editable in place; narrow screens = auto single-column, read-only.
Fixed cellHeight => no ResizeObserver => the narrow column-clamp never
persists over the desktop coords.
- Edit mode shows a panel: widget show/hide + nav reorder (the phase-3 motion
list); the grid below is live-editable. Reset + empty-state preserved.
- Server UiLayoutItem gains optional x/y/w/h (clamped); the stats->5-tiles
migration + default placement live client-side (lib/dashboard-layout.ts).
Verified across 2 adversarial review rounds (gridstack+React lifecycle traced
against the library source: StrictMode-safe, no mount-persist loop, no
re-init on coord change, no narrow-visit data loss).
A new published package that exposes every OneBot action's docs + a ready-to-use
JSON Schema to LLM clients over stdio. Tools: list_actions / get_action /
search_actions / list_categories, plus the whole catalog as a resource.
The catalog is a build-time snapshot generated from @snowluma/onebot's live
action specs (collectActionDocs()) — so it auto-tracks action add/remove and the
published package carries zero runtime dependency on the private onebot package.
action-kit enrichment (the data source):
- Field.toJsonSchema() per coercer (uint→integer/minimum:1, messageId→integer/
not-const-0, int→min/max, string→minLength/maxLength, array→items, enum→enum).
- describe() composes a per-action inputSchema; ActionDoc gains inputSchema +
category. action-docs.ts tags each doc with its domain category.
Wiring: onebot exports ./action-kit + ./action-docs; root build:all includes mcp;
mcp-npm-publish.yml mirrors the sdk/ui publish workflows (triggers on changes
under packages/mcp/** or packages/onebot/**, since the catalog derives from it).
onebot typecheck + 318 tests green; mcp typecheck/build/smoke green; the 142
registered OneBot action names are unchanged.
Remove the in-tree @snowluma/sqlite wrapper and vendored better-sqlite3 prebuilts; migrate consumers to node:sqlite's synchronous API. Replaced imports and types (Database/Statement -> DatabaseSync/StatementSync), updated PRAGMA calls to use exec, and adjusted code in onebot (media/message/reaction stores) and protocol (identity service). Vite config no longer bundles sqlite native binaries and references to the sqlite workspace were removed. Also delete the packages/sqlite package and its native prebuild artifacts, and add a couple of unrelated AST improvements in proton: use a module-level shared wrapper cache and add an inheritance cache to avoid repeated scans. Minor import reordering/cleanup applied where needed.
Split the QQ NT client lifecycle / hook injection layer out of
@snowluma/core into its own self-contained package. The new bridge
package owns:
- native hook addon loader (injector)
- HookManager + per-PID HookSession orchestration
- PipeWatcher for QQ process discovery
- QqHookClient pipe protocol
- qq-port-probe for early UIN sniffing
The hook layer used to type its main collaborator as
`BridgeManager` directly. To keep the new package free of any
dependency on @snowluma/core, the cross-package coupling is now
expressed via a 3-method `BridgeManagerSink` interface that lives
in the bridge package. BridgeManager (in core) satisfies the
interface structurally with no source changes; the top-level
app/webui still inject it the same way.
Pure file relocation otherwise: git mv preserves history at 100%
on every file, the only logic edit is the interface introduction
in hook-manager.ts and an updated `nativeSearchDirs()` offset
(one fewer `..` since hook now lives at packages/bridge/src
instead of packages/core/src/hook).
The package previously named "bridge" houses the protocol-byte layer
(OIDB cmds, msg-push decoders, highway uploaders, element-builder,
event bus). The name "bridge" suggested transport/lifecycle, which it
isn't — that role lives in core/hook and core/bridge/manager. Rename
to match what's actually inside.
This is a pure rename: zero logic changes, all 73 import sites updated
mechanically and TS-verified. core/onebot dependencies key updated.
ESLint override path adjusted for the moved oidb-services directory.
Rename detection threshold: git log --find-renames=20% recommended
when following history across this commit — content-replacement
(@snowluma/bridge → @snowluma/protocol) dropped per-file similarity
below git's default 50% on import-heavy files.
The previous commit (ee0f159) regenerated the lockfile starting from
dev's pre-fix state, which diverged structurally from main's lockfile
(main carried dependabot's hono 4.12.18 bump that dev never inherited).
GitHub's auto-merger saw the two lockfiles as touching overlapping
hunks and marked PR #63 CONFLICTING, blocking the bot's auto-merge
flow into main.
Re-seed the lockfile from origin/main (`git show origin/main:pnpm-lock.yaml`)
then run `pnpm install` again — this time the regeneration starts from
main's already-bumped state, so the only delta is the 6 lines removed
from `packages/onebot`'s importer entry (no snapshot churn). PR #63
becomes a clean fast-forward for the bot to merge.
No code or package.json change here; this is purely a lockfile rebase.
Verified: `pnpm install --frozen-lockfile` passes locally against the
new lockfile.
`packages/onebot` declared `hono` and `@hono/node-server` as devDeps but
never imported either — they were leftover scaffolding from when onebot
was carved out of `@snowluma/core`. Combined with dependabot's hono
bump only landing on core's pin (4.12.14 → 4.12.18 in cb06624), this
left two distinct peer-dep resolutions of `@hono/node-server` in the
lockfile (`(hono@4.12.14)` for onebot, `(hono@4.12.18)` for core).
On the dev→main merge the auto-resolver kept main's snapshot list,
dropping the `(hono@4.12.14)` variant onebot still referenced — so
v1.9.1 shipped with an inconsistent lockfile and CI's
`pnpm install --frozen-lockfile` blew up with
`ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY: no entry for
'@hono/node-server@1.19.14(hono@4.12.14)'`.
Fix:
- Remove `hono` + `@hono/node-server` from onebot (verified zero source
imports — only matches were `honour` / `WebHonorType` false hits).
- Bump core's `hono` pin to `^4.12.18` so dev matches what dependabot
already landed on main (otherwise the drift comes back on next merge).
- Regenerate pnpm-lock.yaml — now resolves to a single
`@hono/node-server@1.19.14(hono@4.12.21)` snapshot.
Replaces "runtime npm dependency that gets `npm install`-ed into
dist/" with "TS wrapper that loads the native `.node` from
`dist/native/` like every other native binding in the project".
Why the change
--------------
The previous approach (better-sqlite3 declared in
packages/runtime/package.json, materialized by `npm install
--omit=dev` in dist/) worked but produced an artifact that needed
runtime npm install — a step the rest of SnowLuma's native bindings
don't need (websocket / snowluma-hook / ffmpeg are all standalone
`.node` files copied into `dist/native/` by vite-plugin-cp). The
asymmetry was friction: dev mode required `pnpm dist:install`,
build artifacts carried a `dist/node_modules/` tree the other
modules didn't need, and Docker images had to special-case the
glibc-vs-musl ABI lookup that prebuild-install runs.
The new shape:
- `@snowluma/sqlite` (packages/sqlite/) — TS wrapper. Has
`better-sqlite3` as a devDep (for the JS-layer types + the
actual JS wrapper code that vite inlines). At runtime, calls
`createRequire().require()` against
`<dist|repo>/native/better-sqlite3-v<abi>-<platform>-<arch>.node`
and passes the resolved addon to `new Database(path, {
nativeBinding })`.
- `packages/runtime/native/better-sqlite3-v<abi>-<triplet>.node`
— prebuilt binaries vendored into the repo. Two ABIs ship side-
by-side (v127 = Node 22 LTS used by CI, v137 = Node 24 used by
dev boxes) so `process.versions.modules` picks the matching one
at load time.
- `tools/fetch-sqlite-prebuilts.mjs` — fetches all 5 platforms ×
N ABIs from WiseLibs's GitHub releases. Idempotent: existing
`.node` files skip. Accepts extra ABI args:
`node tools/fetch-sqlite-prebuilts.mjs 12.10.0 v127 v137 v115`.
What was unwound
----------------
- packages/runtime/package.json — dependencies removed (runtime
artifact is now self-contained, no `npm install` step needed).
- root package.json — `dist:install` script removed (no node_modules
to install).
- .github/workflows/dev-build.yml — `Bundle runtime dependencies`
step removed.
- .github/workflows/release.yml — kept the `Bundle runtime
dependencies` step (was a no-op already, but harmless safety net
in case a future runtime dep shows up).
- packages/core/vite.config.ts — `better-sqlite3` no longer
external; vite inlines the JS wrapper (+~33 KB to bundle, ~2.84
MB → 2.87 MB total).
Naming
------
Mirrors the existing `websocket-${triplet}.node` convention (flat
dir under `runtime/native/`, dash-separated `<platform>-<arch>`).
Adds a `v<abi>` segment because better-sqlite3 is NOT N-API — one
binary per Node ABI. Naming:
better-sqlite3-v127-darwin-arm64.node Node 22 / macOS arm
better-sqlite3-v127-darwin-x64.node Node 22 / macOS intel
better-sqlite3-v127-linux-arm64.node Node 22 / Linux arm
better-sqlite3-v127-linux-x64.node Node 22 / Linux x64
better-sqlite3-v127-win32-x64.node Node 22 / Windows x64
better-sqlite3-v137-... (5) Node 24 / same 5 hosts
Total: 10 binaries × ~1.9 MB = ~19 MB committed. Aligned with the
existing native-vendoring footprint (websocket ~2 MB × 5, snowluma-
hook ~10 MB × Windows + Linux, ffmpeg ~10-30 MB × 4).
What changes for consumers
--------------------------
Source-side: nothing. `import Database, { type Database, type
Statement } from '@snowluma/sqlite'` is a drop-in for the previous
`import Database from 'better-sqlite3'`. The wrapper re-exports the
same types and the `Database` callable factory has the same
overload set.
Runtime: `node` finds the binding at
`<dist|repo>/native/better-sqlite3-v${process.versions.modules}-
${process.platform}-${process.arch}.node`. If the ABI doesn't
match a vendored binary, the wrapper throws a clear error pointing
at `tools/fetch-sqlite-prebuilts.mjs` with the missing ABI/triplet.
Bumped better-sqlite3 from 11.10.0 → 12.10.0 because v11 only had
prebuilt up to Node ABI v127 (no v137 = no Node 24). v12.10.0
ships both. Both versions are API-compat (the breaking changes
between v11 and v12 were either internal or in error-handling
paths SnowLuma doesn't exercise).
Verification
------------
- pnpm -r typecheck (12 projects): clean
- @snowluma/core test: 302/302
- @snowluma/onebot test: 250/250 (552/552 combined, unchanged from
the perf-series baseline)
- Bench numbers preserved — wrapper indirection is sub-μs:
storeMeta ~11 μs, storeEvent ~17 μs, findMeta hit ~1.1 μs (all
within noise of the post-RoadMap-#5 measurements).
- Vite build clean, bundle = 2.87 MB / 230 KB gzip
- `dist/index.mjs` no longer contains a `node:sqlite` import (it
did before — media-store hadn't migrated; now neither it nor
message-store reach into the Node builtin).
Bonus side-fix: `packages/onebot/src/media-store.ts` also moved
off `node:sqlite` (caught by the post-build bundle grep — the
file was using `DatabaseSync` + `StatementSync` from the builtin
even though it had already been written to cache its prepared
statements). Its hot paths now go through the same wrapper.
`pnpm install --frozen-lockfile` in CI rejected the lockfile after
998eba2 added `better-sqlite3` to packages/runtime/package.json
without bumping the lock. Running `pnpm install` regenerates the
`packages/runtime: { dependencies: { better-sqlite3: ... } }` block
plus the resolved version pin. No code change — purely the lockfile
catching up to the manifest.
Replaces the experimental `node:sqlite` builtin with the stable
`better-sqlite3` wrapper across the two hot SQLite users — the
OneBot message-store and the bridge identity-service. While the API
surface is essentially identical (sync, prepared-statement-driven,
`Database`/`Statement` shape), better-sqlite3 (a) doesn't carry the
"ExperimentalWarning: SQLite is an experimental feature" baggage
that ships with `node:sqlite`, (b) has a more mature C++ binding
that consistently outperforms the builtin by ~30-50%, and (c)
encourages reusing `Statement` objects by design — exactly the win
the second half of this commit is after.
Why both at once
----------------
Caching prepared statements (was P1 in the RoadMap-#5 analysis) is
the largest single optimization opportunity on the SQLite path. The
old shape was the textbook SQLite anti-pattern:
// every call re-parsed the SQL
this.db.prepare(BIG_SQL_STRING).run(...);
Each `prepare(sql)` runs the full SQL parser + planner — for simple
inserts/updates/selects that dominates wall-clock cost. Doing both
the driver swap AND the statement caching in one commit lets the
benchmark numbers attribute the combined effect honestly; splitting
them would have double-charged the same disruption budget.
What changed code-wise
----------------------
**messageStore** — 8 distinct SQL templates, all now `readonly`
fields prepared once in the constructor:
- stmtStoreEvent — INSERT/UPDATE event row + JSON payload
- stmtStoreMeta — INSERT/UPDATE meta-only row
- stmtFindEvent — single-row SELECT by message_hash
- stmtFindMeta — single-row SELECT (full meta columns)
- stmtResolveReplyGroup, stmtResolveReplyPrivate
— 2 variants for the reply-id lookup
- stmtListEventsAnchored, stmtListEventsLatest
— 2 variants for /get_history_msg
**identity-service** — 25+ distinct statements. Used a Map-based
`pstmt(sql)` memoization helper rather than enumerating all 25 as
named fields (call sites stay readable; Map lookup is O(1) hash +
string-eq, negligible next to the SQL parse it replaces). The
helper short-circuits to the cached `Statement` on second-and-later
calls.
API differences from node:sqlite (handled in this commit):
- `import { DatabaseSync } from 'node:sqlite'`
→ `import Database from 'better-sqlite3'`
- `new DatabaseSync(path)` → `new Database(path)`
- `db.exec('PRAGMA journal_mode = WAL')`
→ `db.pragma('journal_mode = WAL')`
- `db.exec('BEGIN' / 'COMMIT' / 'ROLLBACK')` — kept as-is; both
drivers accept raw `exec()` for transaction control. The
`db.transaction()` higher-order wrapper is more idiomatic
but the existing manual control already works and changing
it would invalidate the `transaction()` helper's matched
try/catch shape.
vite build wiring:
- `node:sqlite` removed from the externals list
- `better-sqlite3` added — must stay external because it has a
native binding (`require('./build/Release/better_sqlite3.node')`)
that Vite can't bundle. Vendoring the prebuilt `.node` into
`packages/runtime/native/` is a follow-up packaging task; the
workspace `pnpm install` already places the binding under
`node_modules` so dev + tests work today.
Bench results (RoadMap #5 baseline → after)
-------------------------------------------
@snowluma/bridge — recv pipeline (with SQLite identity writes):
group_member_join 17.5 μs/op → 4.7 μs/op (~3.7x faster)
group_message text 0.42→0.42 μs (no path change)
group_message mixed 0.67→0.67 μs (no path change)
@snowluma/onebot — send pipeline:
messageStore.storeMeta 17.0 μs/op → 10.9 μs/op (~1.6x)
messageStore.storeEvent 23.2 μs/op → 18.2 μs/op (~1.3x)
messageStore.findMeta hit 5.4 μs/op → 1.3 μs/op (~4x)
messageStore.findMeta miss 3.7 μs/op → 0.7 μs/op (~5x)
messageStore.findEvent 3.8 μs/op → 1.7 μs/op (~2.3x)
Read paths gained the most because they're parse-bound (small SQL
+ short execution → the parser was most of the cost). Writes gained
less because the fsync/journal overhead on each commit still
dominates the parse savings. Combined send hot path (storeMeta +
storeEvent, called on every outbound message + the /get_msg-fix
self-event store) drops from ~40 μs to ~29 μs — about a 27%
reduction per message sent.
Verification
------------
- pnpm -r typecheck: all 11 projects clean
- @snowluma/onebot test suite: 250/250
- @snowluma/core test suite: 302/302
- Combined: 552/552 (unchanged baseline)
- Vite build (with stubbed native binary): clean, 2.84 MB / 223 KB
gzip (matches pre-Phase-2 envelope after removing the better-
sqlite3 inline-bundle attempt — externalizing it shaves the
~30 KB regression we saw mid-flight)
Follow-up (next commits in this series)
---------------------------------------
- Skip rememberGroupMemberIdentity DB write when the (uid, uin,
nickname, card) tuple is unchanged in the in-memory cache.
- Drop the `Buffer.from(pkt.body)` allocation in msg-push context.
- Synchronous fast-path for elementsToJson when no async resolver
is wired.
- Vendor `better-sqlite3` prebuilt `.node` into the runtime/native
layout for the production package step.
vitest-driven benches at `packages/bridge/bench/recv.bench.ts` and
`packages/onebot/bench/send.bench.ts`. Picks up proton's vite plugin
so call sites get the inlined codecs (running via tsx + proton
runtime-fallback would throw without a pre-built runtime map).
Run via `pnpm -F @snowluma/bridge bench` and
`pnpm -F @snowluma/onebot bench`.
What's measured
---------------
Receive chain:
- parseMsgPush: pure decode + dispatch (no SQLite, no event emit)
* group_message text ~ 0.29 μs/op (3.4M ops/sec)
* group_message 5 mixed ~ 0.50 μs/op (2.0M ops/sec)
* group_member_join ~ 0.25 μs/op (4.1M ops/sec)
- IncomingPacketPipeline.process: full pipeline (with SQLite via
rememberGroupMemberIdentity, BridgeEventBus emit)
* group_message text ~ 0.42 μs/op (2.4M ops/sec)
* group_message 5 mixed ~ 0.67 μs/op (1.5M ops/sec)
* group_member_join ~17.5 μs/op ( 57K ops/sec) ⚠
Send chain:
- parseMessage (OneBot input → MessageElement[])
* CQ string text ~ 0.31 μs/op
* CQ string mixed ~ 1.22 μs/op
* segment array text ~ 0.32 μs/op
* segment array 5 ~ 1.07 μs/op
- messageStore SQLite writes:
* storeMeta ~17.0 μs/op ( 59K ops/sec) ⚠
* storeEvent (~250B JSON) ~23.2 μs/op ( 43K ops/sec) ⚠
- messageStore reads:
* findMeta hit ~ 5.4 μs/op (186K ops/sec)
* findMeta miss ~ 3.7 μs/op (272K ops/sec)
* findEvent hit ~ 3.8 μs/op (262K ops/sec)
Headline finding
----------------
Pure decode/encode is already cheap (sub-μs). The real cost per
sent message is the SQLite write pair (storeMeta + storeEvent =
~40 μs). On receive, group_message itself does NOT trigger
identity writes; only join/leave/admin events do — but those go
through 4-6 db.prepare(sql).run/get(...) calls each, all re-parsing
SQL on every invocation (the canonical SQLite anti-pattern).
The follow-up commits in this series target:
1. Replace `node:sqlite` (experimental, API may shift) with
`better-sqlite3` (stable, sync, ~30-50% faster wire) + cache
prepared statements as readonly fields. Single commit, both
wins together — better-sqlite3's API encourages stmt caching
by design. Expected: storeMeta / storeEvent drop to ~3-5 μs,
group_member_join drops to ~3-5 μs.
2. Skip rememberGroupMemberIdentity DB write when the in-memory
uidByUin / uinByUid cache already has the mapping unchanged.
3. Drop the `Buffer.from(pkt.body)` allocation in msg-push
context (pkt.body is already Uint8Array; proton accepts it).
4. Make `elementsToJson` sync-fast-path when no async resolvers
are wired (saves per-element microtask overhead).
Each commit re-runs these benches and the message should quote the
before/after numbers so the deltas are auditable.
Note: tsx is NOT involved — vitest's bench mode owns the run, and
the proton vite plugin is configured in each `vitest.config.ts`.
Absolute numbers reflect the production code path.
Phase 3 of the 4-package split (RoadMap #3). Moves the entire
`packages/core/src/onebot/` subtree (43 source files + 20 test files
spanning network adapters, action handlers, event converters, message
parser, message store, media store, instance + manager) into the new
`@snowluma/onebot` workspace package. The remaining `@snowluma/core`
now hosts only the api-layer (Bridge class + 13 Api classes), the
hook tooling, the WebUI server, and the runnable `index.ts`.
Why
---
- The OneBot adapter is the public-facing OneBot11 protocol surface;
it has its own version cadence and its own consumers (third-party
bots talking OneBot WS / HTTP). Splitting it lets that cadence move
independently of the Api-on-ctx layer.
- The two were sharing the `packages/core/` build pipeline only
because they happened to live in the same directory. Nothing in
the api-layer reaches into OneBot internals.
- Mirrors NapCat / Lagrange.OneBot's repo split (their OneBot adapter
is a separate assembly from the Core protocol library).
Layout
------
Moved via `git mv` (history preserved):
packages/core/src/onebot/* → @snowluma/onebot/src/<x>
packages/core/tests/onebot/* (6 files) → @snowluma/onebot/tests/
packages/core/tests/{message-parser,message-store,event-pipeline,
media-store,media-url-resolver,event-converter,
http-server-hot-reload,http-server-content-type,
api-handler-logging,message-id,group-msg-emoji-like,
config,media-indexer,network-manager}.test.ts (14 files)
→ @snowluma/onebot/tests/
What stays in @snowluma/core
----------------------------
- `bridge/{bridge,bridge-interface,bridge-context,manager}.ts`
- `bridge/apis/*` (the 13 typed Api classes)
- `hook/*` (debugger/injector — tooling for the core protocol layer)
- `webui/*` (admin UI server — application wiring, not the OneBot
adapter itself)
- `index.ts` (application entry that wires BridgeManager +
OneBotManager + HookManager + WebUI together)
Changes
-------
- New package `packages/onebot/` (package.json with subpath exports
mirroring legacy `core/src/onebot/<x>` 1:1, tsconfig.json,
vitest.config.ts with `SNOWLUMA_LOG_LEVEL=debug` env so the action-
dispatch logging tests see debug-level subscribers — copied verbatim
from core's vitest.config.ts).
- @snowluma/core grows new subpath exports for the api-layer files
OneBot's type-only imports need: `./bridge`, `./bridge-interface`,
`./bridge-context`, `./manager`, `./apis`.
- `@snowluma/onebot` depends on `@snowluma/bridge`, `@snowluma/common`,
`@snowluma/core`, `@snowluma/proto-defs`, `@snowluma/proton`,
`@snowluma/websocket`.
- `@snowluma/core` depends on `@snowluma/onebot` (for index.ts +
webui/server.ts wiring).
- 100+ import sites rewritten via sed:
* OneBot → core: `'../bridge/bridge'` → `'@snowluma/core/bridge'`,
`'../bridge/manager'` → `'@snowluma/core/manager'`,
`'../bridge/bridge-interface'` → `'@snowluma/core/bridge-interface'`,
`'@/bridge/bridge'` (vite path alias) → `'@snowluma/core/bridge'`.
* Core → OneBot: `'./onebot/manager'` → `'@snowluma/onebot/manager'`,
`'../onebot/config'` → `'@snowluma/onebot/config'`, etc.
* Test relative paths: `'../../src/onebot/<x>'` → `'../src/<x>'`
after the test file move (one level shallower).
- New `packages/onebot/src/env.d.ts` declaring `__APP_VERSION__` and
`__BUILD_WEBUI__` (the vite-injected globals `info.ts` reads).
Workspace cycle
---------------
core ↔ onebot is allowed by pnpm workspaces. The runtime direction
is one-way: core → onebot (index.ts constructs OneBotManager,
webui/server reads OneBot config). OneBot only reaches into core for
TYPES (Bridge, BridgeInterface, BridgeManager, ClientKeyInfo,
DownloadRKeyInfo); those imports use `import type` so they compile
out at runtime and don't introduce a real cycle.
Verification
------------
- Full workspace `pnpm -r typecheck`: all 11 projects clean.
- `@snowluma/core` test suite: 302/302 pass (33 test files).
- `@snowluma/onebot` test suite: 250/250 pass (20 test files;
pre-existing `network-manager.test.ts` native-addon failure
unchanged — same baseline as before the split).
- Combined: 552/552 — identical pass count to the pre-Phase-3
baseline.
- Vite build with stubbed native binary: clean, 2.84 MB bundle /
224 KB gzip (identical envelope).
Phase 2 of the 4-package split (RoadMap #3). Moves ~35 files —
events / event-bus / identity-service / packet-pipeline / element-
builder / bridge-oidb / qq-info / msg-push/* / highway/* / web/* +
the bridge-side formatters — out of `packages/core/src/bridge/`
into the new `@snowluma/bridge` workspace package. Everything that
translates QQ wire bytes ↔ typed events/elements now lives there.
Why
---
- `packages/core/src/bridge/` was actually two layers: a low-level
"protocol decoder + I/O" set (msg-push, highway, packet-pipeline,
identity, element-builder) AND the Api-class host (bridge.ts +
apis/*). The Api layer can't move without the protocol layer
moving first, but the two had grown into one directory.
- Bridge-internal consumers (highway uploaders, OIDB envelope
helper, element-builder, msg-push parsers) only ever needed
`bridge.identity` + `bridge.sendRawPacket` + the upload-metadata
cache. Splitting them out lets that minimal surface live in the
bridge package as `BridgeContext` while the api-layer's extended
context (with `apis: ApiHub`) stays in @snowluma/core.
What stays in @snowluma/core (apis layer):
- bridge.ts ── the god-class that composes the protocol
building blocks + mounts apis
- bridge-interface.ts ── public façade for OneBot consumers
- manager.ts ── per-uin BridgeManager hub
- apis/* ── the 13 typed Api classes
- bridge-context.ts (NEW) ── extends @snowluma/bridge's slim
BridgeContext with `apis: ApiHub` so
the 13 Api classes keep their typed
`this.ctx.apis.X.method()` access.
Layout
------
Moved via `git mv` (history preserved):
events / event-bus / identity-service / packet-pipeline /
element-builder / bridge-oidb / qq-info / msg-push/* /
highway/* / web/* → @snowluma/bridge/<sub>
utils/event-format.ts → @snowluma/bridge/format.ts
(the last one renamed because the destination directory only has
bridge-related files; `format.ts` reads cleaner than `event-format`.
Listed as a rename in git's `RM` output — content unchanged.)
Layering fixes (functionality preserved, just type signatures)
--------------------------------------------------------------
- `element-builder.SendContext.bridge` → typed as `BridgeContext`
instead of `Bridge`. Reads only `bridge.identity` and the
upload-cache (both on BridgeContext); the concrete `Bridge`
instance still passes via free upcast.
- `bridge-oidb.runOidb` signature widened from `Bridge` to
`BridgeContext` — same reason.
- `highway/{highway-client,pipeline,image-upload,ptt-upload,
video-upload}.ts` — same.
- `format.ts::formatReply` parameter typed as a new local
`ReplyEventLookup` structural type (`{ findEvent(id): JsonObject |
null }`) instead of `MessageStore` from @snowluma/onebot. The
OneBot `MessageStore` is a structural superset; consumers pass
it through unchanged.
- JSON value types moved to a new `@snowluma/common/json` subpath
(consumed by format.ts) — `onebot/types.ts` re-exports them so
external `import { JsonValue } from '<…>/onebot/types'` paths
keep working.
- `UploadedFileMeta` interface moved from `core/src/bridge/bridge.ts`
into `@snowluma/bridge/bridge-context.ts` — element-builder + the
forward-builder both read the upload cache through BridgeContext,
so the type has to live in @snowluma/bridge. `bridge.ts` keeps
its re-export for back-compat.
Changes
-------
- New package `packages/bridge/` (package.json with per-file subpath
exports mirroring legacy `core/src/bridge/<x>` layout 1:1,
tsconfig.json, typecheck script).
- 35 `.ts` files moved via `git mv` (3 files counted by git as
RM — rename + modify because Bridge → BridgeContext type swap).
- 150+ import sites rewritten across `packages/core/src` and
`packages/core/tests`: `'../bridge/<x>'`, `'../../bridge/<x>'`,
`'../src/bridge/<x>'`, `'@/bridge/web/<x>'`, and vitest
`vi.mock(...)` literals all collapse to `@snowluma/bridge/<x>`.
- `@snowluma/core` depends on `@snowluma/bridge` (workspace:*).
- `@snowluma/bridge` depends on `@snowluma/common`,
`@snowluma/proto-defs`, `@snowluma/proton`.
- `packages/core/src/utils/` directory removed (empty).
Verification
------------
- Full workspace `pnpm -r typecheck`: all 10 projects clean.
- core test suite: 552 passed / 552 total (pre-existing
websocket-darwin-arm64.node addon failure unchanged — same
baseline as the previous refactor commits).
- Vite build (with stubbed native binary): clean — 143 modules,
2.84 MB bundle / 224 KB gzip, identical envelope to the
pre-split build.
Phase 3 (next): OneBot + WebUI move into @snowluma/onebot. The
runnable `index.ts` stays in @snowluma/core as the application
entry, importing `OneBotManager` from `@snowluma/onebot`.
Phase 1 of the 4-package split called out in RoadMap #3. Moves the
"infrastructure" leaves — files with zero business dependencies that
@snowluma/bridge, @snowluma/core, and @snowluma/onebot all consume —
into a dedicated workspace package. Mirrors what #4 (proto-defs)
did for wire-protobuf definitions.
Why
---
- Cleaner layering: these were never bridge- or onebot-specific.
Logger / hex codec / runtime config sit at the bottom of every
module's import graph; giving them their own package makes that
graph readable and prevents future leaks the other direction.
- Sets up #3's bridge + onebot extractions: those need a clean leaf
to depend on. Without this, every cross-package import would have
to re-export through core, which defeats the split.
- Mirrors @snowluma/proto-defs layout — per-file subpath exports
(`@snowluma/common/logger`, `@snowluma/common/hex`, …) keep import
sites unambiguous and let bundlers tree-shake aggressively.
Layout
------
Moved (via `git mv` — history preserved):
packages/core/src/utils/logger.ts → @snowluma/common/logger
packages/core/src/utils/hex.ts → @snowluma/common/hex
packages/core/src/utils/log-file-transport.ts → @snowluma/common/log-file-transport
packages/core/src/utils/log-summary.ts → @snowluma/common/log-summary
packages/core/src/protocol/packet-sender.ts → @snowluma/common/packet-sender
packages/core/src/protocol/types.ts → @snowluma/common/protocol-types
packages/core/src/common/runtime.ts → @snowluma/common/runtime
Not moved (deferred to Phase 2): `packages/core/src/utils/event-format.ts`
— it has reverse imports on `bridge/events`, `bridge/identity-service`,
`onebot/message-store`, `onebot/types`. It moves into @snowluma/bridge
in Phase 2, with the MessageStore parameter decoupled via a structural
type so bridge doesn't end up depending on onebot.
Changes
-------
- New package `packages/common/` (package.json, tsconfig.json — no
src/index.ts barrel, callers use the subpath exports directly to
avoid pulling in `node:fs` when only the hex codec is needed).
- 7 `.ts` files moved via `git mv`.
- 50+ import sites rewritten via sed across `packages/core/src` and
`packages/core/tests`. Old shapes (`../utils/logger`,
`../../protocol/types`, `./common/runtime`, `../src/utils/hex`)
all collapse to `@snowluma/common/<sub>`.
- `@snowluma/core` depends on `@snowluma/common` (workspace:*).
- `packages/core/src/{protocol,common}/` directories removed (empty).
Verification
------------
- core typecheck: clean
- common typecheck: clean
- Full workspace `pnpm -r typecheck`: all 9 projects clean
- core test suite: 552 passed / 552 total (pre-existing
websocket-darwin-arm64.node addon failure unchanged — same baseline
as the previous refactor commits)
- Vite build with stubbed native binary: clean (143 modules, 2.84 MB
bundle, 224 KB gzip — same size envelope as before)
Note: `utils/event-format.ts` is the only file remaining under the
old `utils/` namespace. It moves into @snowluma/bridge alongside the
events / identity-service / packet-pipeline trio in Phase 2.
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`.
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.
Migrates a self-contained subset of bridge/proto/longmsg.ts to the new
@snowluma/proton compile-time DSL form and verifies wire-format parity
against the legacy runtime ProtoSchema encoder. End-to-end proof that
the toolchain works in core's vitest pipeline.
What's covered:
- LongMsgUid / LongMsgSettings / SendLongMsgInfo / SendLongMsgReq /
SendLongMsgResp / SendLongMsgRespResult / RecvLongMsgInfo /
RecvLongMsgReq / RecvLongMsgResp / RecvLongMsgRespResult
- The three schemas that pull in PushMsgBody (LongMsgContent /
LongMsgAction / LongMsgResult) stay on the legacy API for now, since
migrating them requires also migrating message.ts and element.ts.
Toolchain changes:
- @snowluma/proton gets a tsup build step. The ./vite subpath now
points at the built dist/index.js so it is loadable as plain Node
ESM from vite.config.ts / vitest.config.ts (the source-only .ts
layout can't be consumed from outside the Vite transform pipeline).
The . (runtime) subpath stays at TS source — it's only ever
imported from user code, which IS Vite-transformed.
- prepare script ensures dist/ exists after pnpm install.
- @snowluma/core depends on @snowluma/proton (workspace:*) and adds
protobufVitePlugin() to its vitest config so test files get
transformed.
Documented divergence:
proton follows proto3 semantics: fields equal to their default value
(0 / false / "" / empty bytes) are NOT emitted on the wire. The legacy
runtime encoder always emits them. Both forms are round-trippable
through a conforming proto3 reader. The parity test pins this
behaviour explicitly.
8/8 parity tests pass.
Vendors SnowLuma/protobuf-dsl into the monorepo as @snowluma/proton, with
edge-case fixes applied on top of the upstream source. Reference copy of
the unmodified upstream is kept under dev/protobuf-dsl/.
Edge cases fixed (each with a regression test):
- Aliased pb / pb_repeated marker imports (`import { pb as P }`)
— collector now resolves the marker name through the import resolver
- Concrete interface with a generic-instantiated message field
(`wrapped: pb<5, Wrapper<uint_32>>`) — field typeName now resolves
to the mangled form, and the inner instantiation is queued for
monomorphization
- Generic template with a generic-instantiated field
(`interface Outer<U> { x: pb<5, Wrapper<U>> }`) — the inner
instantiation is now re-substituted and re-monomorphized at outer
instantiation time
- Wrapper-binding synthetic SourceFile was empty-text — getText()
returned garbage, breaking mangled name computation downstream
- Cross-file forwarded wrappers (chain → imported base) were not
detected — parseFileForDefinitions now resolves imports recursively
(value imports only, with cycle protection)
- matchForwardedKnownWrapper did not propagate typePattern — chain
treated <X> as the encoded type instead of Wrapper<X>
- Synthetic-SF WeakMap was private to analyzer.ts — replacer could
not see it. Moved to utils.ts so typeNodeToMangledName picks it up
automatically.
Defensive guard: any field whose typeName resolves to neither a primitive
nor a known message now throws at analyze() time, instead of silently
leaking the WireType.Varint placeholder into codegen.
95/95 tests pass; tsc --noEmit clean.
Introduce a new @snowluma/protobuf package (v0.0.4) that provides a lightweight protobuf-ts wrapper (NapProto/NapProtoMsg) with type helpers, ProtoField factory, and encode/decode utilities. Adds package.json, tsconfig.json, and the implementation at src/NapProto.ts. pnpm-lock.yaml was updated to include the @protobuf-ts/runtime dependency resolution.
The four-page switch lived as a string union (Page) plus an if/else
chain in AppInner, with TopBar reverse-importing Sidebar's NAV_ITEMS
to find the icon for the active tab. Adding a page touched four
places and there was no URL, no history, no deep-linking.
Move routing behind @tanstack/react-router with a layout route
(AppLayout) that owns the polling lists, processOps, selectedUin
and the unload-failed alert; child routes render through <Outlet/>
and read shared state from a new AppStateContext. AuthBoundary still
gates login / forced-password-change above the router. Sidebar uses
<Link> + useRouterState for active state; TopBar and MainLayout look
up the current path the same way.
Behaviour-preserving:
- LoginPage / ChangePasswordPage still mount above the router.
- AppLayout never unmounts during navigation, so selectedUin,
processOps state, the polling state and the unload-failed dialog
all survive page switches just like before.
- Logout calls history.replaceState('/') so the next login lands on
the overview page, matching pre-router behaviour.
- The sidebar-active-pill layoutId animation and the page-transition
AnimatePresence are preserved, keyed off pathname instead of the
removed `active` prop.
New URLs (/, /config, /logs, /settings) work on refresh because the
backend already had SPA fallback in core/src/webui/server.ts. Bundle
grows ~84KB (gzip +27KB) for the router runtime.
Introduce a development-only WebUI auth mode controlled by SNOWLUMA_DEV_MODE. When enabled, a fixed in-memory password ("snowluma-dev") is used, config/webui.json is not read or written, and password changes are disabled. Expose WebuiAuth.isDevMode() and WebuiAuth.devPassword, add startup logs when dev mode is active, and wire up dev:mode npm scripts (root and packages/core) using cross-env to set the env flags.
- Implemented a new SettingsPage component for user preferences including appearance, behavior, and account security settings.
- Added a ThemeToggle component to switch between light, dark, and system modes.
- Created reusable UI components: AlertDialog, Avatar, Badge, Button, Card, Dialog, Input, Label, Progress, ScrollArea, Separator, Sheet, Skeleton, Tooltip.
- Introduced utility functions for class name merging and formatting bytes/uptime.
- Added a custom hook for media query handling
Introduce an in-tree TypeScript WebSocket package (@snowluma/websocket): move JS websocket implementation into packages/websocket/src (client, server, websocket, extensions, native, index) and add package.json + tsconfig. Replace direct 'ws' dependency in core with the workspace package and update runtime manifest/description. Implement a native addon loader for platform-specific binaries, add typings/refinements across client/server/websocket (more strict types, handshake parsing, permessage-deflate plumbing, buffer/raw data handling), and wire WebSocketClient as the package default export while exposing internal types. Update vite config to bundle the new package and copy only platform-target native binaries into dist/native. Remove legacy JS entrypoints and native loader, and adjust imports/usages in core (ws-transport) to accept broader raw data types.
Restructure the repository into a pnpm workspace under packages/ (core, webui, runtime). Move source files for core and webui into packages/*, add package manifests for @snowluma/core and @snowluma/runtime, and remove the old launcher package.json. Update root package.json to be a monorepo controller (scripts delegate to pnpm filters, set packageManager and Node engine). Configure build outputs to emit into the repo-root dist/ (vite config changes) so the existing release pipeline continues to work. Add .npmrc with mirrors, expand .gitignore for monorepo layout, add tsconfig base and package-level tsconfigs, and add vitest config for the core package. Adjust injector native search paths and update tests import paths to the new package layout. Small CI/workflow comment added to clarify build behavior and README updated with monorepo structure and common commands.