Skip to content

Commit 3a601ee

Browse files
kalwaltclaude
andcommitted
perf(build): make the bundle tree-shakeable, additively
Adds named exports beside the default namespace, which is unchanged: `import { imgproc, matrix_t, U8C1_t } from "@webarkit/jsfeat-next"`. Every namespace member is exported under the same name and is the same object (singleton state such as fast_corners' threshold is shared). A Vite 8 app that only calls imgproc.grayscale drops from 69.6 KB (23.9 KB gzip) via the default import to 22.9 KB (8.1 KB gzip) via named imports, without ORB's table, GMS, bfmatcher, the detectors or linalg. What it took, beyond adding the exports: - The namespace was the core base class that every module extends, so it was always bundled, and every `jsfeatNext.orb = ...` assignment on it kept its module alive. It is now a subclass of the base (inheriting the constants and VERSION), built in one `@__PURE__ Object.assign(...)`: a bundle that never reads it drops it whole. - The singletons are created once in src/singletons.ts, marked @__PURE__, and shared by the aggregator and the named exports. The two module-level instances in motion_model.ts and ORB's u_max table are marked too. - Two build passes: ESM from src/index.ts (default + named), UMD from the new src/umd.ts (namespace only), so the `jsfeatNext` global and require() are the namespace itself, as before. "sideEffects": false. - The declaration build now fails on a .d.ts error: an intermediate version hit TS4094 and published no jsfeatNext.d.ts while the build passed. tests/named-exports.test.ts pins identity and completeness both ways; tests/build/bundle.test.ts checks the built dist/: UMD shape via require() and as a <script> global, ESM default and named, and that a named { imgproc } bundle has none of the unused modules (verified to fail when an annotation is removed). Closes #137 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent b8329a0 commit 3a601ee

13 files changed

Lines changed: 643 additions & 123 deletions

File tree

‎AGENTS.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919

2020
## Architecture — read this before editing
2121

22-
- **One real module per algorithm** under `src/<module>/<module>.ts`, each extending the base class in **`src/core/core.ts`** (constants, data-type helpers, the shared cache). `src/jsfeatNext.ts` is a thin aggregator that only attaches modules to the namespace; `src/index.ts` default-exports the namespace directly.
22+
- **One real module per algorithm** under `src/<module>/<module>.ts`, each extending the base class in **`src/core/core.ts`** (constants, data-type helpers, the shared cache). `src/jsfeatNext.ts` is a thin aggregator that builds the namespace; `src/index.ts` default-exports the namespace directly and also exports every member by name (#137). The algorithm singletons are created once in `src/singletons.ts`, shared by both. The UMD bundle is built from `src/umd.ts` (namespace only), the ESM bundle from `src/index.ts`.
23+
- **Keep the bundle tree-shakeable (#137).** The namespace is a subclass of the `core` base, built in one `/* @__PURE__ */ Object.assign(...)`, never by assigning members one by one, and every module-level `new` (singletons, shared helper instances, typed-array tables) is marked `/* @__PURE__ */`. An unannotated one silently puts its module back into every bundle; `tests/build/bundle.test.ts` catches it against the built `dist/`.
2324
- **Calling convention (since 0.9.0, issue #41):** the 15 algorithm modules (`imgproc`, `math`, `matmath`, `linalg`, `transform`, `fast_corners`, `yape`, `yape06`, `orb`, `optical_flow_lk`, `motion_estimator`, `affine2d`, `homography2d`, `bfmatcher`, `gms`, plus the `cache` pool) are **singleton instances** on the namespace — `jsfeatNext.imgproc.grayscale(...)`, no `new` — matching original jsfeat. The data-structure classes (`matrix_t`, `keypoint_t`, `pyramid_t`, `ransac_params_t`, `match_t`, `pose_t`) remain constructors, and so does `pose_estimator`.
2425
- **`pose_estimator` is deliberately NOT a singleton and deliberately does not extend `core`.** It is *stateful* — it holds the inverted intrinsics `K⁻¹` — and the public API constructs it with a `K` (`new jsfeatNext.pose_estimator(K)`), which also makes its `intrinsics()` factory reachable as a `static` on the namespace class. It therefore belongs with the constructors above, not with the stateless algorithm singletons. Review tooling has flagged this twice as a rule violation; it is not one, and the reasoning is repeated in the class's own JSDoc.
2526
- **One shared buffer pool:** all modules borrow scratch buffers from the single `shared_cache` exported by `src/core/core.ts` (public as `jsfeatNext.cache`), exactly like jsfeat's global `jsfeat.cache`. Balance every `get_buffer` with a `put_buffer`.

‎README.md‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,21 @@ const src = new jsfeatNext.matrix_t(width, height, jsfeatNext.U8_t | jsfeatNext.
2626
jsfeatNext.imgproc.grayscale(rgbaPixelData, width, height, src);
2727
```
2828

29+
With a bundler, import only what you use by name, and the rest of the library is
30+
left out of your bundle (since 1.2.0, #137). Each name is the same object as the
31+
namespace member, so `imgproc === jsfeatNext.imgproc`:
32+
33+
```js
34+
import { imgproc, matrix_t, U8C1_t } from "@webarkit/jsfeat-next";
35+
36+
const src = new matrix_t(width, height, U8C1_t);
37+
imgproc.grayscale(rgbaPixelData, width, height, src);
38+
```
39+
40+
An app that only converts to grayscale bundles to about 23 KB this way (8 KB
41+
gzipped) instead of about 70 KB (24 KB) through the default import, which carries
42+
every module. Measured with Vite 8, minified; see #137 for the method.
43+
2944
In the browser (UMD build), the global is the namespace directly:
3045

3146
```html
@@ -59,9 +74,9 @@ These classes are attached to the `jsfeatNext` namespace (`jsfeatNext.<name>`):
5974

6075
- **Node.js** v24 (see `.nvmrc`; npm 11)
6176
- Build (UMD + ESM + type declarations): `npm install` then `npm run build-ts`
62-
- Produces `dist/jsfeatNext.js` (UMD, browser global `jsfeatNext`), `dist/jsfeatNext.mjs` (ESM), and `types/`
63-
- Built with **Vite** library mode; webpack/babel are no longer used
64-
- Watch mode: `npm run dev-ts`
77+
- Produces `dist/jsfeatNext.js` (UMD, browser global `jsfeatNext`), `dist/jsfeatNext.mjs` (ESM, default namespace plus named exports), and `types/`
78+
- Built with **Vite** library mode, in two passes: ESM from `src/index.ts`, UMD from `src/umd.ts` (namespace only); webpack/babel are no longer used
79+
- Watch mode: `npm run dev-ts` (ESM and types) · `npm run dev-ts:umd` (UMD)
6580
- Tests: `npm test` (Vitest — characterization tests against the original jsfeat)
6681
- API docs: `npm run docs` (TypeDoc, output to `docs/api/`, gitignored/local-only for now)
6782
- Benchmarks: `npm run bench` (Vitest, A/B against the vendored original jsfeat) · `npm run bench:ratios` for the ratio summary alone

‎package.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"main": "dist/jsfeatNext.js",
66
"module": "dist/jsfeatNext.mjs",
77
"types": "types/src/index.d.ts",
8+
"sideEffects": false,
89
"unpkg": "dist/jsfeatNext.js",
910
"jsdelivr": "dist/jsfeatNext.js",
1011
"exports": {
@@ -22,8 +23,9 @@
2223
],
2324
"scripts": {
2425
"prepare": "npm run build-ts",
25-
"build-ts": "vite build",
26+
"build-ts": "vite build && vite build --mode umd",
2627
"dev-ts": "vite build --watch",
28+
"dev-ts:umd": "vite build --mode umd --watch",
2729
"format-check": "prettier --check .",
2830
"format": "prettier --write .",
2931
"license-check": "node scripts/check-license-headers.mjs",

‎scripts/check-license-headers.mjs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ const EXTENDED = new Map([
4545
const ORIGINAL_SRC = new Set([
4646
"src/types.ts",
4747
"src/index.ts",
48+
// build wiring for the named exports and the UMD entry (#137): no ported code
49+
"src/singletons.ts",
50+
"src/umd.ts",
4851
// match_t is an OpenCV-shaped value type (cv::DMatch equivalent); jsfeat
4952
// never shipped a matcher or any such type, so nothing here is derived.
5053
// (bfmatcher.ts itself IS derived — it ports the sample's popcnt32/

‎src/index.ts‎

Lines changed: 87 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
*/
3838

3939
import jsfeatNext from "./jsfeatNext";
40+
import core from "./core/core";
4041

4142
/**
4243
* Package entry point. Since 0.9.0 the default export IS the `jsfeatNext`
@@ -52,28 +53,103 @@ import jsfeatNext from "./jsfeatNext";
5253
*/
5354
export default jsfeatNext;
5455

56+
/**
57+
* Named exports (issue #137), beside the default namespace, never instead of
58+
* it: every member of the namespace is also exported under the same name, and
59+
* is the very same object (`imgproc === jsfeatNext.imgproc`), so singleton
60+
* state such as `fast_corners`' threshold is shared between the two.
61+
*
62+
* They exist for bundle size. The namespace holds every module, so importing
63+
* it ships the whole library; importing by name lets a bundler drop the
64+
* modules (and tables, like ORB's) that the code never reads. The ESM build
65+
* carries them; the UMD build (`dist/jsfeatNext.js`, built from `src/umd.ts`)
66+
* still exposes only the namespace, as CDN `<script>` consumers expect.
67+
*
68+
* @example
69+
* ```ts
70+
* import { imgproc, matrix_t, U8C1_t } from "@webarkit/jsfeat-next";
71+
* const gray = new matrix_t(w, h, U8C1_t);
72+
* imgproc.grayscale(rgba, w, h, gray);
73+
* ```
74+
*/
75+
export {
76+
transform,
77+
fast_corners,
78+
imgproc,
79+
math,
80+
matmath,
81+
linalg,
82+
orb,
83+
yape,
84+
yape06,
85+
motion_estimator,
86+
affine2d,
87+
homography2d,
88+
optical_flow_lk,
89+
bfmatcher,
90+
gms,
91+
} from "./singletons";
92+
export { shared_cache as cache } from "./core/core";
93+
export { matrix_t } from "./matrix_t/matrix_t";
94+
export { keypoint_t } from "./keypoint_t/keypoint_t";
95+
export { pyramid_t } from "./pyramid_t/pyramid_t";
96+
export { ransac_params_t } from "./motion_estimator/ransac_params_t";
97+
export { match_t } from "./bfmatcher/match_t";
98+
export { data_t } from "./node_utils/data_t";
99+
export { pose_estimator, pose_t } from "./pose_estimator/pose_estimator";
100+
101+
// The namespace's constants, by the same names: the data types and channels a
102+
// `matrix_t` is built with, the colour codes, the flags and the version. Read
103+
// from the base class that defines them, so they cannot drift from
104+
// `jsfeatNext.<name>`.
105+
export const {
106+
VERSION,
107+
EPSILON,
108+
FLT_MIN,
109+
U8_t,
110+
S32_t,
111+
F32_t,
112+
S64_t,
113+
F64_t,
114+
C1_t,
115+
C2_t,
116+
C3_t,
117+
C4_t,
118+
COLOR_RGBA2GRAY,
119+
COLOR_RGB2GRAY,
120+
COLOR_BGRA2GRAY,
121+
COLOR_BGR2GRAY,
122+
BOX_BLUR_NOSCALE,
123+
SVD_U_T,
124+
SVD_V_T,
125+
NORM_HAMMING,
126+
U8C1_t,
127+
U8C3_t,
128+
U8C4_t,
129+
F32C1_t,
130+
F32C2_t,
131+
S32C1_t,
132+
S32C2_t,
133+
} = core;
134+
55135
/**
56136
* Public interfaces and shared types, re-exported from the package root so
57137
* consumers can type-annotate without deep paths into `types/src/**` (issue
58-
* #92). These are **type-only** re-exports: they are erased at build time and
59-
* do not change the UMD/ESM JavaScript bundles (whose `exports: "default"`
60-
* config exposes only the `jsfeatNext` namespace). The runtime values remain
61-
* reachable via that namespace — e.g. `new jsfeatNext.matrix_t(...)`.
138+
* #92). Type-only: erased at build time. (The classes among them, `matrix_t`,
139+
* `keypoint_t` and so on, are exported as values above, which carries their
140+
* types too.)
62141
*
63142
* @example
64143
* ```ts
65144
* import jsfeatNext, { IMatrix_T, TypedArray } from "@webarkit/jsfeat-next";
66145
* function process(img: IMatrix_T) { ... }
67146
* ```
68147
*/
69-
export type { matrix_t, IMatrix_T } from "./matrix_t/matrix_t";
70-
export type { keypoint_t } from "./keypoint_t/keypoint_t";
71-
export type { pyramid_t } from "./pyramid_t/pyramid_t";
148+
export type { IMatrix_T } from "./matrix_t/matrix_t";
72149
export type { point_t, IPoint_t } from "./point_t/point_t";
73-
export type { ransac_params_t } from "./motion_estimator/ransac_params_t";
74-
export type { match_t, IMatch_T } from "./bfmatcher/match_t";
150+
export type { IMatch_T } from "./bfmatcher/match_t";
75151
export type { IGmsSize } from "./gms/gms";
76-
export type { data_t, IData_T } from "./node_utils/data_t";
77-
export type { pose_t, IPose_T } from "./pose_estimator/pose_estimator";
152+
export type { IData_T } from "./node_utils/data_t";
153+
export type { IPose_T } from "./pose_estimator/pose_estimator";
78154
export type { ICache } from "./cache/cache";
79155
export type { TypedArray, NumericArray, MotionKernel, RandomFn } from "./types";

‎src/jsfeatNext.ts‎

Lines changed: 56 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -40,28 +40,15 @@
4040
*
4141
*/
4242

43-
import jsfeatNext, { shared_cache } from "./core/core";
44-
import { imgproc } from "./imgproc/imgproc";
45-
import { linalg } from "./linalg/linalg";
46-
import { fast_corners } from "./fast_corners/fast_corners";
47-
import { math } from "./math/math";
48-
import matmath from "./matmath/matmath";
43+
import core, { shared_cache } from "./core/core";
4944
import { matrix_t } from "./matrix_t/matrix_t";
5045
import { pyramid_t } from "./pyramid_t/pyramid_t";
51-
import { transform } from "./transform/transform";
5246
import { keypoint_t } from "./keypoint_t/keypoint_t";
53-
import { orb } from "./orb/orb";
54-
import { yape } from "./yape/yape";
55-
import { yape06 } from "./yape06/yape06";
5647
import { ransac_params_t } from "./motion_estimator/ransac_params_t";
57-
import { motion_estimator } from "./motion_estimator/motion_estimator";
58-
import { affine2d, homography2d } from "./motion_model/motion_model";
59-
import { optical_flow_lk } from "./optical_flow_lk/optical_flow_lk";
60-
import { bfmatcher } from "./bfmatcher/bfmatcher";
6148
import { match_t } from "./bfmatcher/match_t";
62-
import { gms } from "./gms/gms";
6349
import { data_t } from "./node_utils/data_t";
6450
import { pose_estimator, pose_t } from "./pose_estimator/pose_estimator";
51+
import * as singletons from "./singletons";
6552

6653
// Thin aggregator (issue #47): every algorithm lives in its own module under
6754
// src/<module>/, extending the base class from src/core/core.ts.
@@ -76,59 +63,57 @@ import { pose_estimator, pose_t } from "./pose_estimator/pose_estimator";
7663
// exactly like jsfeat's global `jsfeat.cache` — see docs/migration-0.9.md.
7764
// The data-structure classes (matrix_t, keypoint_t, pyramid_t,
7865
// ransac_params_t) remain constructors: `new jsfeatNext.matrix_t(...)`.
79-
export default jsfeatNext;
80-
81-
// the shared buffer pool itself, like original jsfeat's `jsfeat.cache`
82-
jsfeatNext.cache = shared_cache;
83-
84-
// data-structure constructors (unchanged: consumers `new` these)
85-
jsfeatNext.pyramid_t = pyramid_t;
86-
87-
jsfeatNext.matrix_t = matrix_t;
88-
89-
jsfeatNext.keypoint_t = keypoint_t;
90-
91-
jsfeatNext.ransac_params_t = ransac_params_t;
92-
93-
jsfeatNext.match_t = match_t;
94-
95-
// raw byte storage, like jsfeat's `jsfeat.data_t` (surface parity, #45)
96-
jsfeatNext.data_t = data_t;
97-
98-
// pose_estimator is stateful (holds inverted intrinsics), constructed with a
99-
// K, so it is a constructor class like matrix_t -- NOT a stateless singleton.
100-
// pose_t is its output data-struct.
101-
jsfeatNext.pose_estimator = pose_estimator;
102-
103-
jsfeatNext.pose_t = pose_t;
104-
105-
// algorithm singletons (no `new` — call methods directly)
106-
jsfeatNext.transform = new transform();
107-
108-
jsfeatNext.fast_corners = new fast_corners();
109-
110-
jsfeatNext.imgproc = new imgproc();
111-
112-
jsfeatNext.math = new math();
113-
114-
jsfeatNext.matmath = new matmath();
115-
116-
jsfeatNext.linalg = new linalg();
117-
118-
jsfeatNext.orb = new orb();
119-
120-
jsfeatNext.yape = new yape();
121-
122-
jsfeatNext.yape06 = new yape06();
123-
124-
jsfeatNext.motion_estimator = new motion_estimator();
125-
126-
jsfeatNext.affine2d = new affine2d();
127-
128-
jsfeatNext.homography2d = new homography2d();
129-
130-
jsfeatNext.optical_flow_lk = new optical_flow_lk();
131-
132-
jsfeatNext.bfmatcher = new bfmatcher();
133-
134-
jsfeatNext.gms = new gms();
66+
//
67+
// How the namespace is built matters for bundle size (#137). It is a SUBCLASS
68+
// of the base class every module extends, not that base class itself: it
69+
// inherits the base's statics (the data-type and colour constants, VERSION),
70+
// and the members attached here land on it alone. And it is built in ONE
71+
// expression marked @__PURE__, rather than by assigning members one statement
72+
// at a time: a bundler must keep every `ns.orb = orb` statement, because
73+
// writing a property of a class may run a setter, and each one would keep its
74+
// module alive. As a single pure expression, a bundle whose code never reads
75+
// the namespace (one that only uses `import { imgproc }`) drops it whole, and
76+
// every module the code does not use goes with it. (The subclass is declared
77+
// on its own line, not inline: TypeScript cannot emit a declaration for an
78+
// exported anonymous class whose base has protected members, TS4094.)
79+
class jsfeatNext extends core {}
80+
81+
const namespace = /* @__PURE__ */ Object.assign(jsfeatNext, {
82+
// the shared buffer pool itself, like original jsfeat's `jsfeat.cache`
83+
cache: shared_cache,
84+
85+
// data-structure constructors (unchanged: consumers `new` these)
86+
pyramid_t,
87+
matrix_t,
88+
keypoint_t,
89+
ransac_params_t,
90+
match_t,
91+
// raw byte storage, like jsfeat's `jsfeat.data_t` (surface parity, #45)
92+
data_t,
93+
// pose_estimator is stateful (holds inverted intrinsics), constructed with
94+
// a K, so it is a constructor class like matrix_t -- NOT a stateless
95+
// singleton. pose_t is its output data-struct.
96+
pose_estimator,
97+
pose_t,
98+
99+
// algorithm singletons (no `new` — call methods directly). Created once in
100+
// ./singletons, which src/index.ts also re-exports by name (#137), so
101+
// `jsfeatNext.imgproc` and `import { imgproc }` are the same object.
102+
transform: singletons.transform,
103+
fast_corners: singletons.fast_corners,
104+
imgproc: singletons.imgproc,
105+
math: singletons.math,
106+
matmath: singletons.matmath,
107+
linalg: singletons.linalg,
108+
orb: singletons.orb,
109+
yape: singletons.yape,
110+
yape06: singletons.yape06,
111+
motion_estimator: singletons.motion_estimator,
112+
affine2d: singletons.affine2d,
113+
homography2d: singletons.homography2d,
114+
optical_flow_lk: singletons.optical_flow_lk,
115+
bfmatcher: singletons.bfmatcher,
116+
gms: singletons.gms,
117+
});
118+
119+
export default namespace;

‎src/motion_model/motion_model.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,10 @@ import { linalg, LMCallback } from "../linalg/linalg";
5151
// One instance each, shared by every call (#169): constructing them per call
5252
// was a measurable fixed cost on the 3- and 4-point RANSAC minimal samples,
5353
// where the real arithmetic is smallest. Both classes are stateless.
54-
const shared_matmath = new matmath();
55-
const shared_linalg = new linalg();
54+
// @__PURE__ (#137): construction has no effect beyond the instance, so a
55+
// bundle that never uses motion_model drops both, and linalg with them.
56+
const shared_matmath = /* @__PURE__ */ new matmath();
57+
const shared_linalg = /* @__PURE__ */ new linalg();
5658

5759
/**
5860
* Shared base of the motion-model kernels ({@link affine2d},

‎src/orb/orb.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,9 @@ import { rectify_patch } from "./rectify_patch";
5555
* Module scope, not an instance field: it is a read-only constant shared by
5656
* every call, and hoisting it out of the method keeps the hot loop free of a
5757
* per-call allocation (the same reason `bit_pattern_31` lives in its own file).
58+
* `@__PURE__` lets a bundle that never uses ORB drop it (#137).
5859
*/
59-
const u_max = new Int32Array([15, 15, 15, 15, 14, 14, 14, 13, 13, 12, 11, 10, 9, 8, 6, 3, 0]);
60+
const u_max = /* @__PURE__ */ new Int32Array([15, 15, 15, 15, 14, 14, 14, 13, 13, 12, 11, 10, 9, 8, 6, 3, 0]);
6061

6162
/**
6263
* ORB binary descriptor extractor (Oriented FAST and Rotated BRIEF): for

0 commit comments

Comments
 (0)