A library package shared by one author's dsh plugin family. It is only imported, never enabled by users.
package.jsoncarries nodshfield and the package directory has nocordis.patch.yml, so sitting innode_modulesit is never registered as a plugin.- No settings card, no client half, no
/_dshroute of its own. - Admission rule: a piece of boilerplate is only pulled in when it repeats verbatim in two or more places. Domain judgement (which tools count as edits, path filtering, budgets, gate logic) stays in the plugins — this layer marks, it does not decide.
Published on the public npm registry, so installing needs no credentials.
npm install @jayyuen66/dsh-plugin-shared
# or
pnpm add @jayyuen66/dsh-plugin-sharedDeclare it as an ordinary dependency of your plugin package and import it by bare-package subpath:
import { readBody, sendJson } from "@jayyuen66/dsh-plugin-shared/lib/http";End users do not install or enable it separately: any consumer package pulls it in. When it is missing the failure is ERR_MODULE_NOT_FOUND on a @jayyuen66/dsh-plugin-shared/... specifier, never a silent downgrade.
| Node.js | ^22.19.0 || >=24.0.0, the same clause as the harness root |
| dsh | >= 0.2.1-alpha.1, declared as an optional peer (this package needs no host at import time) |
| Module format | ESM only. require() works only where Node supports require(esm) |
| Tree shaking | "sideEffects": false |
| Behaviour | Where | Scope |
|---|---|---|
Reads a directory entry through realpathSync |
lib/project-key.ts |
Only the path handed to deriveProjectKey; a failure (ENOENT/EACCES) falls back to path.resolve and never throws |
Enumerates this machine's network interfaces via os.networkInterfaces() |
lib/trust.ts |
Only when the caller passes servingNonLoopback: true; used to check whether the request's Host is really an address this machine holds. Nothing leaves the process |
| Reads nothing else | — | No other file access in lib/**, no writes at all, no environment variables read, no outbound network requests, no eval/new Function/dynamic import |
lib/card-apply.ts reads and writes one globalThis key by design — that is what survives a duplicated module instance under HMR.
The subpath column is the in-package specifier; value imports must prefix it with the bare package name. exports also carries ./package.json, which the host reads for display metadata.
| Subpath | Provides |
|---|---|
. |
Barrel over the twelve runtime facets (30 exports). canonicalize-region-paths deliberately stays out |
./lib/http |
sendJson (no-op once headers are sent or the response ended, plus no-store; it serializes before writing headers, so a payload that fails JSON.stringify leaves the response still writable), CROSS_ORIGIN_TEXT, isCrossOrigin / queryParam / checkCsrf (take HttpRequest, the header-reading face), readBody with BodyRead (limit counted in UTF-8 bytes, content-length pre-checked; takes IncomingMessage), guardBody |
./lib/tool-events |
scanToolEvents → the calls/results tables, toolEventRowsOf (its single-event step, same code), parseToolArguments, toolArgumentsBad (the pair a self-folding consumer needs, so it never re-reads the arguments fields), editPathOf and EditTarget (discriminated on kind, only write / read-view; path is undefined when no path could be read); every field of the three ledger interfaces is readonly; re-exports the official SessionEvent and the two PTC event types |
./lib/card-apply |
claimApply + CardApplyCtx: apply idempotency guard (globalThis flag plus ctx.effect cleanup) |
./lib/project-key |
deriveProjectKey + ProjectKeyOptions: cwd → last-segment-<first 8 hex of sha256(norm)>, fallback bucket default |
./lib/locale |
Locale (an alias of the official BuiltInLocaleId), DEFAULT_LOCALE, resolveLocale, resolveLocalePreference, messagesFor, MessagesCatalog, plus LOCALE_SETTINGS_NAMESPACE / LOCALE_PREFERENCE_FIELD |
./lib/lesson-bus |
settleLessonCall: routes synchronous throws and asynchronous rejections into one onFailure exit; function-thenables count as awaitable (missing them leaves a rejection unhandled); an onFailure that itself throws is contained, so it never forks into "thrown at the caller" or "unhandled rejection" |
./lib/text |
truncateEnd (delegates to the official truncateWithoutSplittingSurrogatePair) and truncateStart (back cut; the official surface has no back-cut form) |
./lib/record |
isRecord, fieldOf |
./lib/errors |
errorText: semantics aligned with the official @deepseek-ai/dsh-llm errorChain (cause chain, AggregateError members, empty message falling back to name, cross-realm message read off the value itself, hostile getters degraded), implemented locally with zero dependencies |
./lib/jsonl |
shrinkJsonlTail: the pure decision half of JSONL tail halving; trigger policy and error exit stay with the caller |
./lib/trust |
requestTrust / guardTrust / trustRejectionText: request-trust predicate for /_dsh/* endpoints (Host authority → sec-fetch-site allow-list → byte-exact Origin) |
./lib/job-outcome |
jobOutcomeOf + SettledProcess + JobOutcome: maps a settled subprocess onto the official job registry's outcome |
./lib/canonicalize-region-paths |
Build-time string helper that folds rolldown's //#region <path> markers into a process.cwd()-independent form. Exported as a subpath precisely because runtime consumers should not pay an import for it |
requestTrust exists because sec-fetch-site alone does not survive DNS rebinding: a hostile page that resolves its own hostname to 127.0.0.1 genuinely is same-origin as far as the browser is concerned, so both the sec-fetch-site leg and the Origin leg pass together. Only the Host leg can deny it. Four deliberate choices:
- A missing
Hostpasses only for a loopback peer — HTTP/1.1 forcesHost, so reaching this branch means HTTP/1.0 or a raw socket, i.e. a local caller. sec-fetch-siteis an allow-list (same-origin,none, absent), not a deny-list, so a same-site-but-different-port request is not waved through.- A non-loopback serving surface is accepted only if the address really belongs to one of this machine's interfaces.
.local/.lansuffixes are never trusted, because an mDNS name can be claimed by any local process. guardTrustrefuses to no-op:sendJsonsilently skips once headers are sent, so if someone moves the gate afterawait readBody()it would silently become a pass. It logs instead.
readBody counts UTF-8 bytes, pre-checks content-length, and accumulates Buffers before decoding — cutting per chunk would turn a multi-byte character straddling a chunk boundary into U+FFFD while still being valid JSON. A budget that is not a finite non-negative number is refused as bad-budget (HTTP 500) rather than silently becoming unlimited: every comparison against NaN is false, so an unclamped NaN budget disables the limit entirely.
These are lint/test baselines for plugin authors, not runtime code. They pull in tooling your package must provide itself:
| Subpath | Needs installed |
|---|---|
./config/oxlint |
oxlint, and eslint-plugin-sonarjs if you want the sonarjs rule set mounted |
./config/vitest.base |
vitest, plus @vitest/coverage-v8 because the baseline sets coverage.provider: "v8" |
./config/tsconfig.base.json, ./config/tsconfig.client.base.json |
Nothing — plain JSON for extends |
Those three tools are not declared as peerDependencies. npm 7+ tries to satisfy optional peers too, and oxlint's own peerOptional vite-plus pins vitest to a version disjoint from >=5.0.2, which made a plain npm install of this package fail with ERESOLVE. The requirement is therefore stated here instead of in the manifest.
The sonarjs entry point is resolved lazily, inside definePluginConfig(). Importing ./config/oxlint without sonarjs installed succeeds; calling definePluginConfig() throws a message naming both specifiers it tried and the jsPlugins escape hatch.
definePackageConfig imposes 100% coverage thresholds on four metrics and a 20-second test timeout. It will not relax them for you: an exception belongs in the package that needs it, with a reason.
dependencies is empty; the seven @deepseek-ai/* packages appear only in peerDependencies and devDependencies. test/publish-manifest.test.ts enforces this — it is not a style preference:
- Putting them in
dependenciesdisables the consumer's host. Consumers are dsh plugins installed into a profile (nodeLinker: hoisted,autoInstallPeers: false). Any host package listed underdependenciesis written into the profile as a real directory, coexisting with the copy the host process already loaded.@deepseek-ai/dsh-toolskeys itsTOOL_RUNTIME_SCHEDULERwith a plainSymbol(notSymbol.for), so symbol identity is per module instance: the host'sdsh-agent-loopreads aToolRuntimemounted from the duplicate using its own copy's symbol, getsundefined, and every native tool call throwsCannot read properties of undefined (reading 'prepare')— the whole profile loses its tools. This package's build, typecheck and tests all stay green right up to that point, so the invariant has to live in the manifest. @deepseek-ai/dsh-output-retentionis a value import (truncateEnd) and is provided by the host at runtime, so it is a required peer.- The other six are referenced only by the published
.d.ts, so they are optional peers plus devDependencies: the peer keeps the type surface honest —lib/job-outcomeused to takeShellProcessandJobOutcomefrom packages no consumer tree was required to contain, so withskipLibCheck: truea wrongstatusorexitCodeproduced no error at all andjobOutcomeOf's return type collapsed toany— while devDependencies let this repository compile and test. Profiles never install peers, so no duplicate appears. - Versions follow the host: exact pins throughout the
dsh-*family and forcordis, matching what the host bundle itself declares so the same copy is reused rather than duplicated.
Known upstream limitation: @deepseek-ai/dsh-llm's own declarations import @deepseek-ai/dsh-attachment, which it does not declare. It is reached transitively through dsh-session, so a consumer running with skipLibCheck: false sees TS2307 from that package, not from this one.
main and exports point at dist/ only. There is no second, source-shaped manifest.
publishConfigcarriesaccessandregistryand nothing else. Puttingmain/exportsthere was wrong: pnpm merges those onto the top level when publishing and npm does not, so publishing with npm shipped a package whose fourteen entry points named files that were not in the tarball.filesisicon.svg,dist,config/*.json,README.zh-CN.md. npm always addspackage.json,LICENSEandREADME.md;README.zh-CN.mdis listed explicitly so the Chinese copy ships too.buildstarts by deletingdist/, becausefilestakes the whole directory and a renamed facet would otherwise keep publishing its orphan artifact.preparerunsbuild. It does not run when a consumer installs from the registry; it exists so packing, publishing and workspace links always ship a freshdist/.- Node throws
ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPINGfor.tsinsidenode_modules, which is exactly where a published package is loaded from, so an artifact is mandatory. The barrel's declaration file is generated bybuild-host.mjsbecause source-formexport * from "./http.ts"specifiers are not resolvable by consumers. - Adding a facet means touching three places:
exports, thefacetsarray inbuild-host.mjs,includeintsconfig.build.json, plustest/<module>.test.ts.
- Semantic versioning applies to the public surface: subpaths, exported value symbols, exported types, and error/reason enums.
0.xranges admit patches only, so touching any of those is already breaking under the ranges consumers wrote. deriveProjectKey's hash algorithm, the 8-hex slice and the trailing-segment whitespace cleaning are the untouchable part: changing one digit moves existing lessons and memories out of their buckets.- Windows path normalization changed the bucket key shape. On Windows the same directory now yields
last-segment-<hash>regardless of whether it is spelled with\or/, and a drive root falls intodefault. Windows buckets written before normalization no longer match; POSIX buckets are byte-for-byte unchanged and are pinned by literal assertions intest/project-key.test.ts. BodyRead'sreasongained"bad-budget". A consumer switching exhaustively over it will get a compile error, which is the point.Localeis now an alias of the officialBuiltInLocaleIdinstead of a hand-written union, so it tracks the host'sLOCALE_IDS.errorTextnow returns more text (same name and signature). It went from a singlemessagelayer to the officialerrorChainsemantics: a cause chain renders asouter: inner, anAggregateErrorappends[e1; e2], an empty message falls back toError.name, and a cross-realmErroryields its ownmessageinstead ofString()'s"Error: ...". Callers that regex the old text must re-check (aexecthat pulls a number out of parentheses, for example).editPathOfreturns akind-discriminatedEditTargetwith onlywrite/read-view:"skip"was never produced, and leaving it in the type only pushed consumers into writing unreachable branches. When no path can be read it still returns the matching arm withpathset toundefined.isCrossOrigin/queryParam/checkCsrf/requestTrust/guardTrustwidened their first parameter fromIncomingMessagetoHttpRequest.readBody/guardBodystill takeIncomingMessagebecause they consume the request body andPartialRequestdoes not guarantee async iteration.
npm run check= typecheck → lint → build → test (coverage thresholds 100 for lines, statements, functions and branches) → format check.test/publish-manifest.test.tsis the release-shape gate: everyexportstarget must be inside whatfilesactually ships, entries must point at build output rather than source, andpublishConfigmust not carry entry fields. It also pins facet/exportsparity in both directions: everylib/*.tsneeds a matching subpath (a missing one means that facet is not importable by any of the nine consumer packages, while the build — which follows the builder's own entries array — stays green), and no dangling subpath may outlive its facet../lib/canonicalize-region-pathsis the one facet that lives inexportsbut not in the barrel, and that subpath is load-bearing: nine sibling packages'build-*.mjsimport it, so removing it breaks all of their builds at once. One set of "internal reference" patterns scans both planes: artifacts must contain no absolute paths, home-directory references, dates, or sibling-package identifiers; source and docs must contain no dates and no "this round / next round" wording that only made sense during collaboration. The artifact pass cannot see comments, so the source pass is what keeps that cleanup from being a one-off.test/build-host.test.tscompares the bytes on disk against an in-memory build, so editinglib/*.tswithout rebuilding goes red.test/setup-logs.ts(a vitest setup file) is the ledger for runtime logs: it takes overconsole.*, so the twoconsole.errorcalls inlib/neither leak into the test report (that stack trace is pure noise) nor go unclaimed — every[shared/*]line must match a template fragment intest/log-templates.tsor the run goes red. Do not substitute vitest'ssilent: it only hides, and a newly added log would still go unnoticed..github/workflows/ci.ymlruns the gate on push and pull request across a Node matrix and on Windows; the install matrix there additionally packs, installs into a clean directory with no flags, imports every subpath and type-checks a consumer probe.
- Should I
dsh plugin addit? No. It has neither a config layer nor a client half, so there is nothing to enable; declare it as a dependency. 401/403when publishing usually means the npm token lacks publish rights.404means the package or version is not there yet.- I changed this package and nothing happened. In local development through a workspace link, consumers load
dist/, so rebuild first (npm run build); a stale artifact is what the freshness gate catches. - Want something shared here? First confirm the duplication really is verbatim. Anything carrying domain judgement stays in the plugins.
MIT. LICENSE ships with the package.