@statespace-tech/sdk 0.0.0-stage → 0.1.1
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 +202 -0
- package/NOTICE +4 -0
- package/README.md +118 -2
- package/dist/assignment.d.ts +25 -0
- package/dist/assignment.js +31 -0
- package/dist/client.d.ts +44 -0
- package/dist/client.js +237 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.js +12 -0
- package/dist/experiment.d.ts +59 -0
- package/dist/experiment.js +190 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +34 -0
- package/dist/memory.d.ts +4 -0
- package/dist/memory.js +85 -0
- package/dist/runtime.d.ts +7 -0
- package/dist/runtime.js +123 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +34 -0
- package/package.json +55 -4
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type JsonValue = null | boolean | number | string | JsonValue[] | {
|
|
2
|
+
[key: string]: JsonValue;
|
|
3
|
+
};
|
|
4
|
+
export type JsonObject = {
|
|
5
|
+
[key: string]: JsonValue;
|
|
6
|
+
};
|
|
7
|
+
/** A request to Statespace failed or the client is misconfigured. */
|
|
8
|
+
export declare class StatespaceError extends Error {
|
|
9
|
+
}
|
|
10
|
+
/** A request that retrying cannot fix. */
|
|
11
|
+
export declare class PermanentError extends StatespaceError {
|
|
12
|
+
}
|
|
13
|
+
/** A component trapped or returned an invalid value. */
|
|
14
|
+
export declare class FunctionError extends StatespaceError {
|
|
15
|
+
}
|
|
16
|
+
/** A component exceeded its timeout. */
|
|
17
|
+
export declare class FunctionTimeout extends FunctionError {
|
|
18
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** A request to Statespace failed or the client is misconfigured. */
|
|
2
|
+
export class StatespaceError extends Error {
|
|
3
|
+
}
|
|
4
|
+
/** A request that retrying cannot fix. */
|
|
5
|
+
export class PermanentError extends StatespaceError {
|
|
6
|
+
}
|
|
7
|
+
/** A component trapped or returned an invalid value. */
|
|
8
|
+
export class FunctionError extends StatespaceError {
|
|
9
|
+
}
|
|
10
|
+
/** A component exceeded its timeout. */
|
|
11
|
+
export class FunctionTimeout extends FunctionError {
|
|
12
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type Parameter } from './assignment.js';
|
|
2
|
+
import type { Client } from './client.js';
|
|
3
|
+
import { type JsonObject, type JsonValue } from './errors.js';
|
|
4
|
+
/** The outcome recorded when a parameter falls back to the application's default. */
|
|
5
|
+
export declare const ERROR_OUTCOME = "statespace.error";
|
|
6
|
+
export interface AssignOptions {
|
|
7
|
+
/** The JSON the experiment's eligibility rule reads. */
|
|
8
|
+
context?: JsonObject;
|
|
9
|
+
}
|
|
10
|
+
export interface FunctionOptions {
|
|
11
|
+
/** The time limit for one call. Defaults to 5000. */
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
}
|
|
14
|
+
/** One experiment. Get one with `experiment(name)`. */
|
|
15
|
+
export declare class Experiment {
|
|
16
|
+
#private;
|
|
17
|
+
readonly name: string;
|
|
18
|
+
private constructor();
|
|
19
|
+
/** @internal */
|
|
20
|
+
static load(client: Client, name: string): Promise<Experiment>;
|
|
21
|
+
/** @internal Keep serving the last configuration when a refresh fails. */
|
|
22
|
+
refresh(): Promise<void>;
|
|
23
|
+
/**
|
|
24
|
+
* Assign a subject to a group and record the assignment. The same subject
|
|
25
|
+
* always gets the same group. While the experiment is not running, or for
|
|
26
|
+
* ineligible subjects, the group has no parameters, so every read returns
|
|
27
|
+
* the application's default.
|
|
28
|
+
*/
|
|
29
|
+
assign(subjectId: string, options?: AssignOptions): Group;
|
|
30
|
+
/**
|
|
31
|
+
* Record an outcome, such as a click or a purchase, for a subject. Outcomes
|
|
32
|
+
* can come from any process; results count each one for the group the
|
|
33
|
+
* subject was assigned to before it.
|
|
34
|
+
*/
|
|
35
|
+
log(subjectId: string, name: string, data?: JsonObject): void;
|
|
36
|
+
/** @internal */
|
|
37
|
+
execute(sha256: string, input: JsonValue, timeoutMs: number): Promise<JsonValue>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The group a subject was assigned to. `name` is the group's name,
|
|
41
|
+
* `"control"`, or `null` when the subject is not in the experiment. Every read
|
|
42
|
+
* takes the application's default, which is returned for control, for
|
|
43
|
+
* parameters the group does not set, and when a value has the wrong type or
|
|
44
|
+
* a function fails.
|
|
45
|
+
*/
|
|
46
|
+
export declare class Group {
|
|
47
|
+
#private;
|
|
48
|
+
readonly name: string | null;
|
|
49
|
+
/** @internal */
|
|
50
|
+
constructor(experiment: Experiment, subjectId: string, name: string | null, parameters: Record<string, Parameter>);
|
|
51
|
+
/** The group's value for `name`, or `fallback`. */
|
|
52
|
+
value<T>(name: string, fallback: T): T;
|
|
53
|
+
/**
|
|
54
|
+
* The group's function for `name`, or `fallback`. The function takes and
|
|
55
|
+
* returns JSON values, runs locally in a sandbox, and falls back to
|
|
56
|
+
* `fallback` if it fails. Calls always return a promise.
|
|
57
|
+
*/
|
|
58
|
+
function<I extends JsonValue, O extends JsonValue>(name: string, fallback: (input: I) => O | Promise<O>, options?: FunctionOptions): (input: I) => Promise<O>;
|
|
59
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Experiments, assignments, and the parameter values a group receives.
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { bucket, choose, } from './assignment.js';
|
|
4
|
+
import { FunctionTimeout, PermanentError, } from './errors.js';
|
|
5
|
+
/** The outcome recorded when a parameter falls back to the application's default. */
|
|
6
|
+
export const ERROR_OUTCOME = 'statespace.error';
|
|
7
|
+
const DEFAULT_TIMEOUT_MILLISECONDS = 5_000;
|
|
8
|
+
/** One experiment. Get one with `experiment(name)`. */
|
|
9
|
+
export class Experiment {
|
|
10
|
+
name;
|
|
11
|
+
#client;
|
|
12
|
+
#config;
|
|
13
|
+
constructor(client, name, config) {
|
|
14
|
+
this.name = name;
|
|
15
|
+
this.#client = client;
|
|
16
|
+
this.#config = config;
|
|
17
|
+
}
|
|
18
|
+
/** @internal */
|
|
19
|
+
static async load(client, name) {
|
|
20
|
+
if (name.length === 0)
|
|
21
|
+
throw new RangeError('experiment name must not be empty');
|
|
22
|
+
try {
|
|
23
|
+
return new Experiment(client, name, await client.load(name));
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error instanceof PermanentError)
|
|
27
|
+
throw error;
|
|
28
|
+
console.warn(`statespace: serving defaults for ${name}: ${String(error)}`);
|
|
29
|
+
return new Experiment(client, name, undefined);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** @internal Keep serving the last configuration when a refresh fails. */
|
|
33
|
+
async refresh() {
|
|
34
|
+
try {
|
|
35
|
+
this.#config = await this.#client.load(this.name);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
console.warn(`statespace: could not refresh ${this.name}: ${String(error)}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Assign a subject to a group and record the assignment. The same subject
|
|
43
|
+
* always gets the same group. While the experiment is not running, or for
|
|
44
|
+
* ineligible subjects, the group has no parameters, so every read returns
|
|
45
|
+
* the application's default.
|
|
46
|
+
*/
|
|
47
|
+
assign(subjectId, options = {}) {
|
|
48
|
+
if (subjectId.length === 0)
|
|
49
|
+
throw new RangeError('subjectId must not be empty');
|
|
50
|
+
const context = structuredClone(options.context ?? {});
|
|
51
|
+
const config = this.#config;
|
|
52
|
+
if (config === undefined || config.status !== 'running') {
|
|
53
|
+
return new Group(this, subjectId, null, {});
|
|
54
|
+
}
|
|
55
|
+
if (Date.now() - config.fetchedAt > config.staleAfter) {
|
|
56
|
+
console.warn(`statespace: the ${this.name} configuration is stale; serving defaults`);
|
|
57
|
+
return new Group(this, subjectId, null, {});
|
|
58
|
+
}
|
|
59
|
+
let reason;
|
|
60
|
+
try {
|
|
61
|
+
reason = config.eligibility.evaluate(context) ? 'assigned' : 'ineligible';
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
console.warn(`statespace: eligibility failed for ${this.name}: ${String(error)}`);
|
|
65
|
+
reason = 'eligibility-error';
|
|
66
|
+
}
|
|
67
|
+
const assigned = reason === 'assigned'
|
|
68
|
+
? choose(config.groups, bucket(config.salt, subjectId))
|
|
69
|
+
: undefined;
|
|
70
|
+
this.#client.enqueue('run', {
|
|
71
|
+
id: `run_${randomUUID().replaceAll('-', '')}`,
|
|
72
|
+
experiment: config.name,
|
|
73
|
+
version: config.version,
|
|
74
|
+
subject_id: subjectId,
|
|
75
|
+
group: assigned?.name ?? null,
|
|
76
|
+
reason,
|
|
77
|
+
context,
|
|
78
|
+
timestamp: new Date().toISOString(),
|
|
79
|
+
});
|
|
80
|
+
return new Group(this, subjectId, assigned?.name ?? null, assigned?.parameters ?? {});
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Record an outcome, such as a click or a purchase, for a subject. Outcomes
|
|
84
|
+
* can come from any process; results count each one for the group the
|
|
85
|
+
* subject was assigned to before it.
|
|
86
|
+
*/
|
|
87
|
+
log(subjectId, name, data = {}) {
|
|
88
|
+
if (subjectId.length === 0 || name.length === 0) {
|
|
89
|
+
throw new RangeError('subjectId and name must not be empty');
|
|
90
|
+
}
|
|
91
|
+
this.#client.enqueue('outcome', {
|
|
92
|
+
id: `out_${randomUUID().replaceAll('-', '')}`,
|
|
93
|
+
experiment: this.name,
|
|
94
|
+
subject_id: subjectId,
|
|
95
|
+
name,
|
|
96
|
+
data,
|
|
97
|
+
timestamp: new Date().toISOString(),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/** @internal */
|
|
101
|
+
execute(sha256, input, timeoutMs) {
|
|
102
|
+
return this.#client.execute(sha256, input, timeoutMs);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The group a subject was assigned to. `name` is the group's name,
|
|
107
|
+
* `"control"`, or `null` when the subject is not in the experiment. Every read
|
|
108
|
+
* takes the application's default, which is returned for control, for
|
|
109
|
+
* parameters the group does not set, and when a value has the wrong type or
|
|
110
|
+
* a function fails.
|
|
111
|
+
*/
|
|
112
|
+
export class Group {
|
|
113
|
+
name;
|
|
114
|
+
#experiment;
|
|
115
|
+
#subjectId;
|
|
116
|
+
#parameters;
|
|
117
|
+
/** @internal */
|
|
118
|
+
constructor(experiment, subjectId, name, parameters) {
|
|
119
|
+
this.name = name;
|
|
120
|
+
this.#experiment = experiment;
|
|
121
|
+
this.#subjectId = subjectId;
|
|
122
|
+
this.#parameters = parameters;
|
|
123
|
+
}
|
|
124
|
+
/** The group's value for `name`, or `fallback`. */
|
|
125
|
+
value(name, fallback) {
|
|
126
|
+
const parameter = this.#parameters[name];
|
|
127
|
+
if (parameter === undefined)
|
|
128
|
+
return fallback;
|
|
129
|
+
if (parameter.kind !== 'value') {
|
|
130
|
+
this.#error(name, 'type', 'is a function; read it with function()');
|
|
131
|
+
return fallback;
|
|
132
|
+
}
|
|
133
|
+
if (!matches(parameter.value, fallback)) {
|
|
134
|
+
this.#error(name, 'type', `is not a ${kind(fallback)}`);
|
|
135
|
+
return fallback;
|
|
136
|
+
}
|
|
137
|
+
return parameter.value;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The group's function for `name`, or `fallback`. The function takes and
|
|
141
|
+
* returns JSON values, runs locally in a sandbox, and falls back to
|
|
142
|
+
* `fallback` if it fails. Calls always return a promise.
|
|
143
|
+
*/
|
|
144
|
+
function(name, fallback, options = {}) {
|
|
145
|
+
const parameter = this.#parameters[name];
|
|
146
|
+
if (parameter === undefined)
|
|
147
|
+
return async (input) => fallback(input);
|
|
148
|
+
if (parameter.kind !== 'function' || parameter.sha256 === undefined) {
|
|
149
|
+
this.#error(name, 'type', 'is a value; read it with value()');
|
|
150
|
+
return async (input) => fallback(input);
|
|
151
|
+
}
|
|
152
|
+
const sha256 = parameter.sha256;
|
|
153
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MILLISECONDS;
|
|
154
|
+
return async (input) => {
|
|
155
|
+
const started = performance.now();
|
|
156
|
+
try {
|
|
157
|
+
return (await this.#experiment.execute(sha256, input, timeoutMs));
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
const failure = error instanceof FunctionTimeout ? 'timeout' : 'failed';
|
|
161
|
+
this.#error(name, failure, String(error), performance.now() - started);
|
|
162
|
+
return fallback(input);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
#error(parameter, error, detail, durationMs) {
|
|
167
|
+
console.warn(`statespace: ${parameter} in ${this.name}: ${error} ${detail}`);
|
|
168
|
+
this.#experiment.log(this.#subjectId, ERROR_OUTCOME, {
|
|
169
|
+
group: this.name,
|
|
170
|
+
parameter,
|
|
171
|
+
error,
|
|
172
|
+
...(durationMs === undefined
|
|
173
|
+
? {}
|
|
174
|
+
: { duration_ms: Math.round(durationMs * 1000) / 1000 }),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function kind(value) {
|
|
179
|
+
if (value === null)
|
|
180
|
+
return 'null';
|
|
181
|
+
if (Array.isArray(value))
|
|
182
|
+
return 'array';
|
|
183
|
+
return typeof value;
|
|
184
|
+
}
|
|
185
|
+
/** Whether a JSON value can stand in for the fallback's type. */
|
|
186
|
+
function matches(value, fallback) {
|
|
187
|
+
return (fallback === null ||
|
|
188
|
+
fallback === undefined ||
|
|
189
|
+
kind(value) === kind(fallback));
|
|
190
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run Statespace experiments on parameter values and functions.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import { experiment } from '@statespace-tech/sdk';
|
|
6
|
+
*
|
|
7
|
+
* const ranking = await experiment('ranking');
|
|
8
|
+
* const group = ranking.assign('u_42', { context: { country: 'US' } });
|
|
9
|
+
* const rank = group.function('ranker', rerank);
|
|
10
|
+
* const topK = group.value('top_k', 20);
|
|
11
|
+
* ranking.log('u_42', 'click');
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
import type { Experiment } from './experiment.js';
|
|
15
|
+
export { Client, type ClientOptions } from './client.js';
|
|
16
|
+
export { type JsonObject, type JsonValue, StatespaceError } from './errors.js';
|
|
17
|
+
export { type AssignOptions, ERROR_OUTCOME, Experiment, type FunctionOptions, Group, } from './experiment.js';
|
|
18
|
+
/**
|
|
19
|
+
* The experiment `name` from the default client, which reads `SSP_API_KEY`
|
|
20
|
+
* and `STATESPACE_URL`, or the session saved by `ssp login`. Handles are
|
|
21
|
+
* cached, so call this anywhere.
|
|
22
|
+
*/
|
|
23
|
+
export declare function experiment(name: string): Promise<Experiment>;
|
|
24
|
+
/** Wait until queued events are delivered. Resolves to false on timeout or loss. */
|
|
25
|
+
export declare function flush(timeoutMs?: number): Promise<boolean>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run Statespace experiments on parameter values and functions.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import { experiment } from '@statespace-tech/sdk';
|
|
6
|
+
*
|
|
7
|
+
* const ranking = await experiment('ranking');
|
|
8
|
+
* const group = ranking.assign('u_42', { context: { country: 'US' } });
|
|
9
|
+
* const rank = group.function('ranker', rerank);
|
|
10
|
+
* const topK = group.value('top_k', 20);
|
|
11
|
+
* ranking.log('u_42', 'click');
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
import { Client } from './client.js';
|
|
15
|
+
export { Client } from './client.js';
|
|
16
|
+
export { StatespaceError } from './errors.js';
|
|
17
|
+
export { ERROR_OUTCOME, Experiment, Group, } from './experiment.js';
|
|
18
|
+
let defaultClient;
|
|
19
|
+
function client() {
|
|
20
|
+
defaultClient ??= new Client();
|
|
21
|
+
return defaultClient;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The experiment `name` from the default client, which reads `SSP_API_KEY`
|
|
25
|
+
* and `STATESPACE_URL`, or the session saved by `ssp login`. Handles are
|
|
26
|
+
* cached, so call this anywhere.
|
|
27
|
+
*/
|
|
28
|
+
export function experiment(name) {
|
|
29
|
+
return client().experiment(name);
|
|
30
|
+
}
|
|
31
|
+
/** Wait until queued events are delivered. Resolves to false on timeout or loss. */
|
|
32
|
+
export function flush(timeoutMs) {
|
|
33
|
+
return client().flush(timeoutMs);
|
|
34
|
+
}
|
package/dist/memory.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Limit each guest linear memory before native WebAssembly compilation. */
|
|
2
|
+
export declare function memoryLimitPages(): number;
|
|
3
|
+
export declare function capModuleMemory(bytes: Uint8Array, pages: number): Uint8Array<ArrayBuffer>;
|
|
4
|
+
export declare function installImportedMemoryLimit(pages: number): void;
|
package/dist/memory.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Limit each guest linear memory before native WebAssembly compilation. */
|
|
2
|
+
export function memoryLimitPages() {
|
|
3
|
+
const raw = process.env.STATESPACE_MAX_MEMORY_BYTES;
|
|
4
|
+
const bytes = raw === undefined ? NaN : Number(raw);
|
|
5
|
+
return Number.isSafeInteger(bytes) && bytes >= 65536
|
|
6
|
+
? Math.min(65536, Math.floor(bytes / 65536))
|
|
7
|
+
: 4096;
|
|
8
|
+
}
|
|
9
|
+
export function capModuleMemory(bytes, pages) {
|
|
10
|
+
let offset = 8;
|
|
11
|
+
const read = () => {
|
|
12
|
+
let value = 0;
|
|
13
|
+
for (let index = 0; index < 5; index += 1) {
|
|
14
|
+
const byte = bytes[offset++];
|
|
15
|
+
if (byte === undefined)
|
|
16
|
+
throw new Error('truncated Wasm module');
|
|
17
|
+
value += (byte & 127) * 2 ** (7 * index);
|
|
18
|
+
if ((byte & 128) === 0) {
|
|
19
|
+
if (value > 0xffffffff)
|
|
20
|
+
throw new Error('invalid Wasm integer');
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
throw new Error('invalid Wasm integer');
|
|
25
|
+
};
|
|
26
|
+
const encode = (value) => {
|
|
27
|
+
const result = [];
|
|
28
|
+
do {
|
|
29
|
+
const byte = value % 128;
|
|
30
|
+
value = Math.floor(value / 128);
|
|
31
|
+
result.push(byte | (value > 0 ? 128 : 0));
|
|
32
|
+
} while (value > 0);
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
const chunks = [bytes.slice(0, 8)];
|
|
36
|
+
while (offset < bytes.length) {
|
|
37
|
+
const start = offset;
|
|
38
|
+
const id = bytes[offset++];
|
|
39
|
+
const size = read();
|
|
40
|
+
const end = offset + size;
|
|
41
|
+
if (end > bytes.length)
|
|
42
|
+
throw new Error('truncated Wasm section');
|
|
43
|
+
if (id === 5) {
|
|
44
|
+
const count = read();
|
|
45
|
+
const payload = encode(count);
|
|
46
|
+
for (let index = 0; index < count; index += 1) {
|
|
47
|
+
const flags = read();
|
|
48
|
+
if (flags !== 0 && flags !== 1)
|
|
49
|
+
throw new Error('unsupported Wasm memory type');
|
|
50
|
+
const minimum = read();
|
|
51
|
+
const maximum = flags === 1 ? read() : pages;
|
|
52
|
+
if (minimum > pages)
|
|
53
|
+
throw new Error('Wasm memory exceeds configured limit');
|
|
54
|
+
payload.push(1, ...encode(minimum), ...encode(Math.min(maximum, pages)));
|
|
55
|
+
}
|
|
56
|
+
if (offset !== end)
|
|
57
|
+
throw new Error('invalid Wasm memory section');
|
|
58
|
+
chunks.push(Uint8Array.from([5, ...encode(payload.length), ...payload]));
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
offset = end;
|
|
62
|
+
chunks.push(bytes.slice(start, end));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const result = new Uint8Array(chunks.reduce((size, chunk) => size + chunk.length, 0));
|
|
66
|
+
let destination = 0;
|
|
67
|
+
for (const chunk of chunks) {
|
|
68
|
+
result.set(chunk, destination);
|
|
69
|
+
destination += chunk.length;
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
export function installImportedMemoryLimit(pages) {
|
|
74
|
+
const OriginalMemory = WebAssembly.Memory;
|
|
75
|
+
WebAssembly.Memory = class extends OriginalMemory {
|
|
76
|
+
constructor(descriptor) {
|
|
77
|
+
if (descriptor.initial > pages)
|
|
78
|
+
throw new Error('Wasm memory exceeds configured limit');
|
|
79
|
+
super({
|
|
80
|
+
...descriptor,
|
|
81
|
+
maximum: Math.min(descriptor.maximum ?? pages, pages),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type JsonValue } from './errors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Run a component's `execute` once in a worker thread, which is terminated
|
|
4
|
+
* when the timeout elapses. The guest has no filesystem, network, or
|
|
5
|
+
* environment access.
|
|
6
|
+
*/
|
|
7
|
+
export declare function executeComponent(bytes: Uint8Array, inputs: JsonValue, timeout: number): Promise<JsonValue>;
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Transpile Statespace components with jco and run them in worker threads.
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { rmSync } from 'node:fs';
|
|
4
|
+
import { mkdtemp, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { Worker } from 'node:worker_threads';
|
|
8
|
+
import { transpileBytes } from '@bytecodealliance/jco-transpile';
|
|
9
|
+
import { FunctionError, FunctionTimeout } from './errors.js';
|
|
10
|
+
const modules = new Map();
|
|
11
|
+
const directories = new Set();
|
|
12
|
+
process.once('exit', () => {
|
|
13
|
+
for (const directory of directories) {
|
|
14
|
+
rmSync(directory, { recursive: true, force: true });
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
const SUPPORTED_WASI_IMPORTS = new Set([
|
|
18
|
+
'wasi:cli/environment',
|
|
19
|
+
'wasi:cli/exit',
|
|
20
|
+
'wasi:cli/stdin',
|
|
21
|
+
'wasi:cli/stdout',
|
|
22
|
+
'wasi:cli/stderr',
|
|
23
|
+
'wasi:cli/terminal-input',
|
|
24
|
+
'wasi:cli/terminal-output',
|
|
25
|
+
'wasi:cli/terminal-stdin',
|
|
26
|
+
'wasi:cli/terminal-stdout',
|
|
27
|
+
'wasi:cli/terminal-stderr',
|
|
28
|
+
'wasi:io/error',
|
|
29
|
+
'wasi:io/poll',
|
|
30
|
+
'wasi:io/streams',
|
|
31
|
+
'wasi:clocks/monotonic-clock',
|
|
32
|
+
'wasi:clocks/wall-clock',
|
|
33
|
+
'wasi:random/random',
|
|
34
|
+
'wasi:filesystem/types',
|
|
35
|
+
'wasi:filesystem/preopens',
|
|
36
|
+
]);
|
|
37
|
+
async function modulePath(bytes) {
|
|
38
|
+
const hash = createHash('sha256').update(bytes).digest('hex');
|
|
39
|
+
let pending = modules.get(hash);
|
|
40
|
+
if (pending === undefined) {
|
|
41
|
+
pending = (async () => {
|
|
42
|
+
const result = await transpileBytes(bytes, {
|
|
43
|
+
name: 'component',
|
|
44
|
+
wasiShim: false,
|
|
45
|
+
instantiation: 'async',
|
|
46
|
+
});
|
|
47
|
+
for (const name of result.imports) {
|
|
48
|
+
if (!supportedWasiImport(name)) {
|
|
49
|
+
throw new FunctionError(`component imports unsupported host capability: ${name}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const directory = await mkdtemp(join(tmpdir(), 'statespace-component-'));
|
|
53
|
+
directories.add(directory);
|
|
54
|
+
for (const [name, content] of Object.entries(result.files)) {
|
|
55
|
+
if (name.endsWith('.d.ts'))
|
|
56
|
+
continue;
|
|
57
|
+
if (name.includes('/') || name.includes('\\') || name === '..') {
|
|
58
|
+
throw new FunctionError('invalid transpiled component path');
|
|
59
|
+
}
|
|
60
|
+
await writeFile(join(directory, name), content);
|
|
61
|
+
}
|
|
62
|
+
const file = Object.keys(result.files).find((name) => name.endsWith('.js'));
|
|
63
|
+
if (file === undefined)
|
|
64
|
+
throw new FunctionError('component has no JavaScript binding');
|
|
65
|
+
await writeFile(join(directory, 'package.json'), '{"type":"module"}');
|
|
66
|
+
return join(directory, file);
|
|
67
|
+
})();
|
|
68
|
+
modules.set(hash, pending);
|
|
69
|
+
pending.catch(() => modules.delete(hash));
|
|
70
|
+
}
|
|
71
|
+
return pending;
|
|
72
|
+
}
|
|
73
|
+
function supportedWasiImport(name) {
|
|
74
|
+
return SUPPORTED_WASI_IMPORTS.has(name);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Run a component's `execute` once in a worker thread, which is terminated
|
|
78
|
+
* when the timeout elapses. The guest has no filesystem, network, or
|
|
79
|
+
* environment access.
|
|
80
|
+
*/
|
|
81
|
+
export async function executeComponent(bytes, inputs, timeout) {
|
|
82
|
+
const started = performance.now();
|
|
83
|
+
const path = await modulePath(bytes);
|
|
84
|
+
const remaining = timeout - (performance.now() - started);
|
|
85
|
+
if (remaining <= 0) {
|
|
86
|
+
throw new FunctionTimeout('component exceeded its timeout');
|
|
87
|
+
}
|
|
88
|
+
return new Promise((resolve, reject) => {
|
|
89
|
+
const worker = new Worker(new URL('./worker.js', import.meta.url), {
|
|
90
|
+
workerData: { path, input: JSON.stringify(inputs) },
|
|
91
|
+
execArgv: [],
|
|
92
|
+
stdin: true,
|
|
93
|
+
stdout: true,
|
|
94
|
+
stderr: true,
|
|
95
|
+
});
|
|
96
|
+
worker.stdin?.end();
|
|
97
|
+
worker.stdout.resume();
|
|
98
|
+
worker.stderr.resume();
|
|
99
|
+
let settled = false;
|
|
100
|
+
const finish = (error, value) => {
|
|
101
|
+
if (settled)
|
|
102
|
+
return;
|
|
103
|
+
settled = true;
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
void worker.terminate();
|
|
106
|
+
if (error !== undefined)
|
|
107
|
+
reject(error);
|
|
108
|
+
else
|
|
109
|
+
resolve(value ?? null);
|
|
110
|
+
};
|
|
111
|
+
const timer = setTimeout(() => finish(new FunctionTimeout('component exceeded its timeout')), remaining);
|
|
112
|
+
worker.once('message', (message) => {
|
|
113
|
+
if (message.error !== undefined)
|
|
114
|
+
finish(new FunctionError(message.error));
|
|
115
|
+
else
|
|
116
|
+
finish(undefined, message.value);
|
|
117
|
+
});
|
|
118
|
+
worker.once('error', (error) => finish(new FunctionError(error.message)));
|
|
119
|
+
worker.once('exit', (code) => {
|
|
120
|
+
finish(new FunctionError(`component worker exited with code ${code}`));
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
package/dist/worker.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/worker.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { parentPort, workerData } from 'node:worker_threads';
|
|
5
|
+
import { WASIShim } from '@bytecodealliance/preview2-shim/instantiation';
|
|
6
|
+
import { capModuleMemory, installImportedMemoryLimit, memoryLimitPages, } from './memory.js';
|
|
7
|
+
async function run(task) {
|
|
8
|
+
const pages = memoryLimitPages();
|
|
9
|
+
installImportedMemoryLimit(pages);
|
|
10
|
+
const bindings = await import(pathToFileURL(task.path).href);
|
|
11
|
+
const getCoreModule = async (path) => {
|
|
12
|
+
if (path.includes('/') || path.includes('\\') || path === '..') {
|
|
13
|
+
throw new Error('invalid core module path');
|
|
14
|
+
}
|
|
15
|
+
return WebAssembly.compile(capModuleMemory(await readFile(join(dirname(task.path), path)), pages));
|
|
16
|
+
};
|
|
17
|
+
const wasi = new WASIShim({
|
|
18
|
+
sandbox: { preopens: {}, env: {}, args: [], enableNetwork: false },
|
|
19
|
+
});
|
|
20
|
+
const imports = wasi.getImportObject();
|
|
21
|
+
imports['wasi:cli/environment'] = {
|
|
22
|
+
getEnvironment: () => [],
|
|
23
|
+
getArguments: () => [],
|
|
24
|
+
initialCwd: () => undefined,
|
|
25
|
+
};
|
|
26
|
+
const instance = await bindings.instantiate(getCoreModule, imports);
|
|
27
|
+
if (typeof instance.execute !== 'function')
|
|
28
|
+
throw new Error('component has no execute export');
|
|
29
|
+
const output = await instance.execute(task.input);
|
|
30
|
+
if (typeof output !== 'string')
|
|
31
|
+
throw new Error('component returned a non-string value');
|
|
32
|
+
return JSON.parse(output);
|
|
33
|
+
}
|
|
34
|
+
void run(workerData).then((value) => parentPort?.postMessage({ value }), (error) => parentPort?.postMessage({ error: String(error) }));
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statespace-tech/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Run Statespace A/B tests on functions and values in TypeScript and JavaScript applications",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"NOTICE"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.json",
|
|
22
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
23
|
+
"test": "npm run build && vitest run",
|
|
24
|
+
"format": "prettier --write src test",
|
|
25
|
+
"format:check": "prettier --check src test",
|
|
26
|
+
"prepare": "npm run build"
|
|
27
|
+
},
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/statespace-tech/typescript-sdk.git"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://statespace.com",
|
|
33
|
+
"keywords": [
|
|
34
|
+
"ab-testing",
|
|
35
|
+
"experimentation",
|
|
36
|
+
"webassembly",
|
|
37
|
+
"agents"
|
|
38
|
+
],
|
|
39
|
+
"license": "Apache-2.0",
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=20"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@bytecodealliance/jco-transpile": "^0.15.0",
|
|
48
|
+
"@bytecodealliance/preview2-shim": "^0.26.0",
|
|
49
|
+
"@marcbachmann/cel-js": "^8.0.0"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/node": "^24.0.0",
|
|
53
|
+
"prettier": "3.9.6",
|
|
54
|
+
"typescript": "^5.9.0",
|
|
55
|
+
"vitest": "^4.0.0"
|
|
56
|
+
}
|
|
57
|
+
}
|