@on-the-ground/proxxy-js 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 on-the-ground
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # @on-the-ground/proxxy-js
2
+
3
+ Async proxies that shard an object across N independently-constructed instances and dispatch
4
+ method calls onto per-shard [`@on-the-ground/daemonizer`](https://github.com/on-the-ground/daemonizer)
5
+ `Daemon` queues โ€” a JS/TS counterpart to [proxxy](https://github.com/on-the-ground/proxxy) (JVM).
6
+
7
+ ## ๐Ÿ“ Directory Structure
8
+
9
+ ```
10
+ src/
11
+ โ”œโ”€โ”€ index.ts # Entry point
12
+ โ””โ”€โ”€ proxxy.ts # startProxy
13
+ ```
14
+
15
+ ## โœ… Features
16
+
17
+ - Wraps a `constructor` โ€” not a live instance โ€” and builds one independent instance per shard (via `new constructor()`), so shards never share mutable state
18
+ - A caller-supplied `keyExtractor` routes each call to a shard; calls routed to the same shard are strictly ordered, calls on different shards run concurrently
19
+ - Every call returns `Promise<Awaited<R>>`, propagating the method's result or thrown error straight back to the caller โ€” proxxy never swallows or redirects errors, that's the caller's call
20
+ - Cancellation via `AbortSignal`; `close()` returns a `Promise<void>` that resolves once every shard has fully drained
21
+ - Only methods are proxied โ€” plain property access (get and set) throws, since a single field has no coherent value once state is sharded
22
+ - Proxied methods must use regular method syntax (`method() {}`), not arrow-function class fields (`method = () => {}`) โ€” see [Method syntax](#method-syntax) below
23
+
24
+ ## Usage
25
+
26
+ ```ts
27
+ import { startProxy } from "@on-the-ground/proxxy-js";
28
+
29
+ class Account {
30
+ private balance = 0;
31
+ // first arg is only used for routing below, the method itself doesn't need it
32
+ async deposit(_accountId: string, amount: number) {
33
+ return (this.balance += amount);
34
+ }
35
+ }
36
+
37
+ const controller = new AbortController();
38
+ const { proxy, close } = startProxy(Account, controller.signal, {
39
+ partitionCount: 4,
40
+ // calls for the same accountId must land on the same shard to observe each other
41
+ keyExtractor: (_method, args) => hashOf(args[0] as string),
42
+ });
43
+
44
+ await proxy.deposit("acct-1", 100); // 100
45
+ await proxy.deposit("acct-1", 50); // 150 โ€” same shard, ordered after the first call
46
+ await proxy.deposit("acct-2", 10); // runs concurrently with acct-1's calls, different shard
47
+
48
+ await close();
49
+ ```
50
+
51
+ Wrapping a single object with no sharding at all is just the default: omit `partitionCount`
52
+ (defaults to `1`) and `keyExtractor` (defaults to always routing to shard 0).
53
+
54
+ ## Method syntax
55
+
56
+ Define proxied methods with regular method syntax:
57
+
58
+ ```ts
59
+ class Account {
60
+ async deposit(id: string, amount: number) { /* ... */ } // โœ… visible to startProxy
61
+ }
62
+ ```
63
+
64
+ Not as arrow-function class fields:
65
+
66
+ ```ts
67
+ class Account {
68
+ deposit = async (id: string, amount: number) => { /* ... */ }; // โŒ not recognized
69
+ }
70
+ ```
71
+
72
+ `startProxy` tells methods apart from plain properties by checking `constructor.prototype`,
73
+ without constructing a throwaway instance just to inspect its shape. Regular methods live on
74
+ the prototype; arrow-function class fields are assigned per-instance in the constructor, so
75
+ they're invisible until an instance already exists. This costs nothing in practice โ€” proxxy
76
+ always invokes methods via `fn.apply(instance, args)`, so the usual reason to reach for an
77
+ arrow field (auto-bound `this`) doesn't apply here.
78
+
79
+ ## ๐Ÿ”ง Build
80
+
81
+ ```bash
82
+ yarn build
83
+ ```
84
+
85
+ ## ๐Ÿงช Testing
86
+
87
+ ```bash
88
+ yarn test # vitest
89
+ yarn typecheck # tsc --noEmit, covers src/ and test/
90
+ ```
package/dist/index.cjs ADDED
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ var daemonizer = require('@on-the-ground/daemonizer');
4
+
5
+ /**
6
+ * Wraps `partitionCount` independent instances (one per shard, built via `constructor`) behind a
7
+ * single proxy. Method calls are routed to a shard by `keyExtractor`; calls on the same shard
8
+ * are strictly ordered, calls on different shards run concurrently. Every call returns a
9
+ * Promise that resolves with the method's result or rejects with whatever it threw.
10
+ *
11
+ * `constructor`'s methods must be defined with regular method syntax (`method() {}`), not as
12
+ * arrow-function class fields (`method = () => {}`) โ€” only the former is visible on
13
+ * `constructor.prototype`, which is how proxied methods are told apart from plain properties
14
+ * without needing a throwaway instance. This isn't a functional loss here: every call is
15
+ * dispatched via `fn.apply(instance, args)` regardless of method style, so arrow fields'
16
+ * usual benefit (auto-bound `this`) is moot inside proxxy.
17
+ */
18
+ function startProxy(constructor, signal, options = {}) {
19
+ const partitionCount = options.partitionCount ?? 1;
20
+ const keyExtractor = options.keyExtractor ?? (() => 0);
21
+ const router = new daemonizer.PartitionedDaemon(() => {
22
+ // Built once per partition, not per call โ€” calls routed to this shard must observe
23
+ // each other's effects on the same instance, not start over from scratch every time.
24
+ const instance = new constructor();
25
+ return new daemonizer.Daemon(signal, async (_ctx, call) => {
26
+ const fn = instance[call.method];
27
+ if (typeof fn !== "function") {
28
+ call.reject(new TypeError(`'${String(call.method)}' is not a method`));
29
+ return;
30
+ }
31
+ try {
32
+ call.resolve(await fn.apply(instance, call.args));
33
+ }
34
+ catch (e) {
35
+ call.reject(e);
36
+ }
37
+ }, options.bufferSizePerPartition);
38
+ }, partitionCount, (call) => keyExtractor(call.method, call.args));
39
+ const dispatch = (method, args) => new Promise((resolve, reject) => {
40
+ router.pushEvent({ method, args, resolve, reject }).then((accepted) => {
41
+ if (!accepted)
42
+ reject(new Error(`proxxy: cannot dispatch '${String(method)}', proxy is closed`));
43
+ }, reject);
44
+ });
45
+ const proxy = new Proxy({}, {
46
+ get(_target, method) {
47
+ // State lives per-shard now, so there's no single coherent value to hand back for a
48
+ // plain field read โ€” only methods (routed and queued per shard) are supported. The
49
+ // shape comes straight from the class prototype, so no throwaway instance is needed
50
+ // just to answer "is this a method". This only sees conventional prototype methods โ€”
51
+ // arrow-function class fields live on the instance, not the prototype, and won't be
52
+ // recognized here.
53
+ const shape = constructor.prototype[method];
54
+ if (typeof shape !== "function") {
55
+ throw new TypeError(`proxxy: '${String(method)}' is not a method โ€” direct property access isn't supported on a sharded proxy`);
56
+ }
57
+ return (...args) => dispatch(method, args);
58
+ },
59
+ set(_target, prop) {
60
+ // Without a `set` trap, an unhandled writing would silently succeed against the
61
+ // internal dummy target instead of touching any real shard โ€” worse than a no-op,
62
+ // since it looks like it worked. Fail loudly instead, same as get().
63
+ throw new TypeError(`proxxy: '${String(prop)}' cannot be set โ€” direct property access isn't supported on a sharded proxy`);
64
+ },
65
+ });
66
+ return {
67
+ proxy,
68
+ close: () => router.close(),
69
+ };
70
+ }
71
+
72
+ exports.startProxy = startProxy;
@@ -0,0 +1,38 @@
1
+ type KeyExtractor = (method: string | symbol, args: unknown[]) => number;
2
+ type Proxied<T> = {
3
+ [K in keyof T]: T[K] extends (...args: infer A) => infer R ? (...args: A) => Promise<Awaited<R>> : never;
4
+ };
5
+ type ProxyHandle<T> = {
6
+ proxy: T;
7
+ close: () => Promise<void>;
8
+ };
9
+ type ProxyOptions = {
10
+ /** Number of independent shards, each backed by its own instance of `constructor`. Defaults to 1 (no sharding). */
11
+ partitionCount?: number;
12
+ /**
13
+ * Routes a call to a shard by hashing a key derived from it (e.g. an entity id in `args`).
14
+ * Calls that must observe each other's effects (same underlying entity) need to map to the
15
+ * same shard โ€” otherwise they land on independently constructed, mutually invisible instances.
16
+ * Defaults to always routing to shard 0, which is only correct when `partitionCount` is 1.
17
+ */
18
+ keyExtractor?: KeyExtractor;
19
+ /** Queue capacity per shard. Defaults to the underlying daemon's default (10). */
20
+ bufferSizePerPartition?: number;
21
+ };
22
+ /**
23
+ * Wraps `partitionCount` independent instances (one per shard, built via `constructor`) behind a
24
+ * single proxy. Method calls are routed to a shard by `keyExtractor`; calls on the same shard
25
+ * are strictly ordered, calls on different shards run concurrently. Every call returns a
26
+ * Promise that resolves with the method's result or rejects with whatever it threw.
27
+ *
28
+ * `constructor`'s methods must be defined with regular method syntax (`method() {}`), not as
29
+ * arrow-function class fields (`method = () => {}`) โ€” only the former is visible on
30
+ * `constructor.prototype`, which is how proxied methods are told apart from plain properties
31
+ * without needing a throwaway instance. This isn't a functional loss here: every call is
32
+ * dispatched via `fn.apply(instance, args)` regardless of method style, so arrow fields'
33
+ * usual benefit (auto-bound `this`) is moot inside proxxy.
34
+ */
35
+ declare function startProxy<T extends object>(constructor: new () => T, signal: AbortSignal, options?: ProxyOptions): ProxyHandle<Proxied<T>>;
36
+
37
+ export { startProxy };
38
+ export type { KeyExtractor, Proxied, ProxyHandle, ProxyOptions };
package/dist/index.mjs ADDED
@@ -0,0 +1,70 @@
1
+ import { PartitionedDaemon, Daemon } from '@on-the-ground/daemonizer';
2
+
3
+ /**
4
+ * Wraps `partitionCount` independent instances (one per shard, built via `constructor`) behind a
5
+ * single proxy. Method calls are routed to a shard by `keyExtractor`; calls on the same shard
6
+ * are strictly ordered, calls on different shards run concurrently. Every call returns a
7
+ * Promise that resolves with the method's result or rejects with whatever it threw.
8
+ *
9
+ * `constructor`'s methods must be defined with regular method syntax (`method() {}`), not as
10
+ * arrow-function class fields (`method = () => {}`) โ€” only the former is visible on
11
+ * `constructor.prototype`, which is how proxied methods are told apart from plain properties
12
+ * without needing a throwaway instance. This isn't a functional loss here: every call is
13
+ * dispatched via `fn.apply(instance, args)` regardless of method style, so arrow fields'
14
+ * usual benefit (auto-bound `this`) is moot inside proxxy.
15
+ */
16
+ function startProxy(constructor, signal, options = {}) {
17
+ const partitionCount = options.partitionCount ?? 1;
18
+ const keyExtractor = options.keyExtractor ?? (() => 0);
19
+ const router = new PartitionedDaemon(() => {
20
+ // Built once per partition, not per call โ€” calls routed to this shard must observe
21
+ // each other's effects on the same instance, not start over from scratch every time.
22
+ const instance = new constructor();
23
+ return new Daemon(signal, async (_ctx, call) => {
24
+ const fn = instance[call.method];
25
+ if (typeof fn !== "function") {
26
+ call.reject(new TypeError(`'${String(call.method)}' is not a method`));
27
+ return;
28
+ }
29
+ try {
30
+ call.resolve(await fn.apply(instance, call.args));
31
+ }
32
+ catch (e) {
33
+ call.reject(e);
34
+ }
35
+ }, options.bufferSizePerPartition);
36
+ }, partitionCount, (call) => keyExtractor(call.method, call.args));
37
+ const dispatch = (method, args) => new Promise((resolve, reject) => {
38
+ router.pushEvent({ method, args, resolve, reject }).then((accepted) => {
39
+ if (!accepted)
40
+ reject(new Error(`proxxy: cannot dispatch '${String(method)}', proxy is closed`));
41
+ }, reject);
42
+ });
43
+ const proxy = new Proxy({}, {
44
+ get(_target, method) {
45
+ // State lives per-shard now, so there's no single coherent value to hand back for a
46
+ // plain field read โ€” only methods (routed and queued per shard) are supported. The
47
+ // shape comes straight from the class prototype, so no throwaway instance is needed
48
+ // just to answer "is this a method". This only sees conventional prototype methods โ€”
49
+ // arrow-function class fields live on the instance, not the prototype, and won't be
50
+ // recognized here.
51
+ const shape = constructor.prototype[method];
52
+ if (typeof shape !== "function") {
53
+ throw new TypeError(`proxxy: '${String(method)}' is not a method โ€” direct property access isn't supported on a sharded proxy`);
54
+ }
55
+ return (...args) => dispatch(method, args);
56
+ },
57
+ set(_target, prop) {
58
+ // Without a `set` trap, an unhandled writing would silently succeed against the
59
+ // internal dummy target instead of touching any real shard โ€” worse than a no-op,
60
+ // since it looks like it worked. Fail loudly instead, same as get().
61
+ throw new TypeError(`proxxy: '${String(prop)}' cannot be set โ€” direct property access isn't supported on a sharded proxy`);
62
+ },
63
+ });
64
+ return {
65
+ proxy,
66
+ close: () => router.close(),
67
+ };
68
+ }
69
+
70
+ export { startProxy };
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@on-the-ground/proxxy-js",
3
+ "version": "0.1.0",
4
+ "description": "Async proxies that route method calls through partitioned daemon queues, with per-key ordering and cross-key concurrency.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "exports": {
11
+ ".": {
12
+ "import": "./dist/index.mjs",
13
+ "require": "./dist/index.cjs"
14
+ }
15
+ },
16
+ "main": "./dist/index.cjs",
17
+ "module": "./dist/index.mjs",
18
+ "types": "./dist/index.d.ts",
19
+ "files": [
20
+ "./dist/index.d.ts",
21
+ "./dist/index.cjs",
22
+ "./dist/index.mjs",
23
+ "./LICENSE"
24
+ ],
25
+ "scripts": {
26
+ "clean": "rm -rf dist",
27
+ "build": "yarn clean && rollup -c",
28
+ "test": "vitest run",
29
+ "typecheck": "tsc --noEmit -p tsconfig.test.json",
30
+ "prepublishOnly": "yarn typecheck && yarn test && yarn build"
31
+ },
32
+ "keywords": [
33
+ "proxy",
34
+ "daemon",
35
+ "async",
36
+ "partitioned",
37
+ "actor",
38
+ "queue"
39
+ ],
40
+ "dependencies": {
41
+ "@on-the-ground/daemonizer": "^0.2.1"
42
+ },
43
+ "devDependencies": {
44
+ "@rollup/plugin-typescript": "^12.1.4",
45
+ "rollup": "^4.46.0",
46
+ "rollup-plugin-dts": "^6.2.1",
47
+ "typescript": "^5.8.3",
48
+ "vitest": "^3.2.4"
49
+ },
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "https://github.com/on-the-ground/proxxy-js"
53
+ }
54
+ }