@flowscripter/runtime 1.1.2 → 1.2.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/README.md +17 -7
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/src/config.d.ts +18 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/config.js +82 -0
- package/dist/src/runtime.d.ts +36 -0
- package/dist/src/runtime.d.ts.map +1 -0
- package/dist/src/runtime.js +165 -0
- package/index.ts +3 -1
- package/package.json +5 -3
- package/src/config.ts +103 -0
- package/src/runtime.ts +223 -0
- package/dist/src/world.d.ts +0 -5
- package/dist/src/world.d.ts.map +0 -1
- package/dist/src/world.js +0 -6
- package/src/world.ts +0 -6
package/README.md
CHANGED
|
@@ -19,9 +19,17 @@ Add the module:
|
|
|
19
19
|
Use the module:
|
|
20
20
|
|
|
21
21
|
```typescript
|
|
22
|
-
import {
|
|
22
|
+
import { createRuntime } from "@flowscripter/runtime";
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
const runtime = createRuntime({
|
|
25
|
+
config: { logLevel: "info", components: { myComponent: { retries: 3 } } },
|
|
26
|
+
logger, // optional: a plugin-api Logger
|
|
27
|
+
output, // optional: a plugin-api Output
|
|
28
|
+
onProgress: (event) => {},
|
|
29
|
+
onMetric: (event) => {},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
runtime.updateConfig({ logLevel: "debug" });
|
|
25
33
|
```
|
|
26
34
|
|
|
27
35
|
## Development
|
|
@@ -54,12 +62,14 @@ Generate HTML API Documentation:
|
|
|
54
62
|
|
|
55
63
|
### Overview
|
|
56
64
|
|
|
57
|
-
|
|
65
|
+
`createRuntime(options)` returns an independent runtime instance with no module-level state and no platform-specific imports, so it runs under Bun and in browsers. The host supplies an already parsed config object and the `Logger`, `Output`, progress and metric sinks defined in `@flowscripter/plugin-api`.
|
|
58
66
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
67
|
+
- Log level comes from `config.logLevel` (`debug`, `info`, `warn`, `error`, `silent`; default `info`) and is applied by the runtime before the host logger is called.
|
|
68
|
+
- `updateConfig` replaces the whole config. Listeners registered with `onConfigChange` receive the new and previous config, and only when something changed.
|
|
69
|
+
- Component config is returned opaque by `getComponentConfig(handle)`; each component validates its own config.
|
|
70
|
+
- Unknown top-level config keys are ignored with a single warning.
|
|
71
|
+
- Failures in host supplied loggers, outputs, callbacks and listeners never propagate to the caller.
|
|
72
|
+
- `runtime.context` is the `RuntimeContext` passed to components. It requires the `session`, `registry`, `datastoreManager` and `sessionManager` options.
|
|
63
73
|
|
|
64
74
|
### API
|
|
65
75
|
|
package/dist/index.d.ts
CHANGED
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA,cAAc,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACjD,YAAY,EAAE,oBAAoB,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AACtF,YAAY,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export
|
|
1
|
+
export { createRuntime } from "./src/runtime.js";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { LogLevel } from "@flowscripter/plugin-api";
|
|
2
|
+
export declare const LOG_LEVELS: ReadonlyArray<LogLevel>;
|
|
3
|
+
export interface RuntimeConfig {
|
|
4
|
+
readonly logLevel?: LogLevel;
|
|
5
|
+
/** Opaque per-component config keyed by component handle; validated by each component. */
|
|
6
|
+
readonly components?: Readonly<Record<string, unknown>>;
|
|
7
|
+
}
|
|
8
|
+
export interface ResolvedConfig {
|
|
9
|
+
readonly config: Readonly<RuntimeConfig>;
|
|
10
|
+
readonly unknownKeys: ReadonlyArray<string>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Validate and deep-copy a host supplied config into a deep-frozen snapshot.
|
|
14
|
+
* The input object is never retained or mutated.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveConfig(input: unknown): ResolvedConfig;
|
|
17
|
+
export declare function deepEqual(a: unknown, b: unknown): boolean;
|
|
18
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAC;AAEzD,eAAO,MAAM,UAAU,EAAE,aAAa,CAAC,QAAQ,CAAgD,CAAC;AAIhG,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC;IAC7B,0FAA0F;IAC1F,QAAQ,CAAC,UAAU,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACzD;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,aAAa,CAAC,CAAC;IACzC,QAAQ,CAAC,WAAW,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;CAC7C;AAyCD;;;GAGG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,cAAc,CAyB5D;AAED,wBAAgB,SAAS,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,OAAO,GAAG,OAAO,CAezD"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export const LOG_LEVELS = ["debug", "info", "warn", "error", "silent"];
|
|
2
|
+
const KNOWN_KEYS = ["logLevel", "components"];
|
|
3
|
+
function isPlainObject(value) {
|
|
4
|
+
if (typeof value !== "object" || value === null) {
|
|
5
|
+
return false;
|
|
6
|
+
}
|
|
7
|
+
const proto = Object.getPrototypeOf(value);
|
|
8
|
+
return proto === Object.prototype || proto === null;
|
|
9
|
+
}
|
|
10
|
+
function copyAndFreeze(value, path, ancestors) {
|
|
11
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
if (typeof value === "number") {
|
|
15
|
+
if (!Number.isFinite(value)) {
|
|
16
|
+
throw new TypeError(`Config value at ${path} must be a finite number`);
|
|
17
|
+
}
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
if (Array.isArray(value) || isPlainObject(value)) {
|
|
21
|
+
if (ancestors.includes(value)) {
|
|
22
|
+
throw new TypeError(`Config value at ${path} is circular`);
|
|
23
|
+
}
|
|
24
|
+
ancestors.push(value);
|
|
25
|
+
let copy;
|
|
26
|
+
if (Array.isArray(value)) {
|
|
27
|
+
copy = value.map((item, i) => copyAndFreeze(item, `${path}[${i}]`, ancestors));
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
const obj = {};
|
|
31
|
+
for (const key of Object.keys(value)) {
|
|
32
|
+
obj[key] = copyAndFreeze(value[key], `${path}.${key}`, ancestors);
|
|
33
|
+
}
|
|
34
|
+
copy = obj;
|
|
35
|
+
}
|
|
36
|
+
ancestors.pop();
|
|
37
|
+
return Object.freeze(copy);
|
|
38
|
+
}
|
|
39
|
+
throw new TypeError(`Config value at ${path} must be plain data (got ${typeof value})`);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Validate and deep-copy a host supplied config into a deep-frozen snapshot.
|
|
43
|
+
* The input object is never retained or mutated.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveConfig(input) {
|
|
46
|
+
if (input === undefined) {
|
|
47
|
+
return { config: Object.freeze({}), unknownKeys: [] };
|
|
48
|
+
}
|
|
49
|
+
if (!isPlainObject(input)) {
|
|
50
|
+
throw new TypeError("Runtime config must be a plain object");
|
|
51
|
+
}
|
|
52
|
+
const { logLevel, components } = input;
|
|
53
|
+
if (logLevel !== undefined && !LOG_LEVELS.includes(logLevel)) {
|
|
54
|
+
throw new TypeError(`Invalid logLevel ${JSON.stringify(logLevel)}; allowed values: ${LOG_LEVELS.join(", ")}`);
|
|
55
|
+
}
|
|
56
|
+
if (components !== undefined && !isPlainObject(components)) {
|
|
57
|
+
throw new TypeError("Runtime config 'components' must be a plain object keyed by handle");
|
|
58
|
+
}
|
|
59
|
+
const config = {};
|
|
60
|
+
if (logLevel !== undefined) {
|
|
61
|
+
config.logLevel = logLevel;
|
|
62
|
+
}
|
|
63
|
+
if (components !== undefined) {
|
|
64
|
+
config.components = copyAndFreeze(components, "components", []);
|
|
65
|
+
}
|
|
66
|
+
const unknownKeys = Object.keys(input).filter((key) => !KNOWN_KEYS.includes(key));
|
|
67
|
+
return { config: Object.freeze(config), unknownKeys };
|
|
68
|
+
}
|
|
69
|
+
export function deepEqual(a, b) {
|
|
70
|
+
if (a === b) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
if (Array.isArray(a)) {
|
|
74
|
+
return Array.isArray(b) && a.length === b.length && a.every((item, i) => deepEqual(item, b[i]));
|
|
75
|
+
}
|
|
76
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
77
|
+
const keys = Object.keys(a);
|
|
78
|
+
return (keys.length === Object.keys(b).length &&
|
|
79
|
+
keys.every((key) => key in b && deepEqual(a[key], b[key])));
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { DatastoreManager, Logger, MetricEvent, Output, ProgressEvent, Registry, RuntimeContext, Session, SessionManager } from "@flowscripter/plugin-api";
|
|
2
|
+
import type { RuntimeConfig } from "./config.ts";
|
|
3
|
+
export type ConfigChangeListener = (next: Readonly<RuntimeConfig>, prev: Readonly<RuntimeConfig>) => void;
|
|
4
|
+
export interface RuntimeOptions {
|
|
5
|
+
/** Already parsed plain object; the runtime performs no I/O. */
|
|
6
|
+
config?: RuntimeConfig;
|
|
7
|
+
logger?: Logger;
|
|
8
|
+
output?: Output;
|
|
9
|
+
onProgress?: (event: ProgressEvent) => void;
|
|
10
|
+
onMetric?: (event: MetricEvent) => void;
|
|
11
|
+
session?: Session;
|
|
12
|
+
registry?: Registry;
|
|
13
|
+
datastoreManager?: DatastoreManager;
|
|
14
|
+
sessionManager?: SessionManager;
|
|
15
|
+
}
|
|
16
|
+
export interface Runtime {
|
|
17
|
+
/** Level-filtered wrapper around the injected logger. */
|
|
18
|
+
readonly logger: Logger;
|
|
19
|
+
readonly output: Output;
|
|
20
|
+
/**
|
|
21
|
+
* The context passed to components. Throws if `session`, `registry`, `datastoreManager` or
|
|
22
|
+
* `sessionManager` were not supplied in {@link RuntimeOptions}.
|
|
23
|
+
*/
|
|
24
|
+
readonly context: RuntimeContext;
|
|
25
|
+
reportProgress(event: ProgressEvent): void;
|
|
26
|
+
reportMetric(event: MetricEvent): void;
|
|
27
|
+
getConfig(): Readonly<RuntimeConfig>;
|
|
28
|
+
getComponentConfig<T = unknown>(handle: string): T | undefined;
|
|
29
|
+
/** Full replacement. Throws `TypeError` on invalid config, keeping the previous config. */
|
|
30
|
+
updateConfig(next: RuntimeConfig): void;
|
|
31
|
+
/** Returns an unsubscribe function. */
|
|
32
|
+
onConfigChange(listener: ConfigChangeListener): () => void;
|
|
33
|
+
dispose(): void;
|
|
34
|
+
}
|
|
35
|
+
export declare function createRuntime(options?: RuntimeOptions): Runtime;
|
|
36
|
+
//# sourceMappingURL=runtime.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/runtime.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,gBAAgB,EAEhB,MAAM,EAEN,WAAW,EACX,MAAM,EACN,aAAa,EACb,QAAQ,EACR,cAAc,EACd,OAAO,EACP,cAAc,EAEf,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,MAAM,MAAM,oBAAoB,GAAG,CACjC,IAAI,EAAE,QAAQ,CAAC,aAAa,CAAC,EAC7B,IAAI,EAAE,QAAQ,CAAC,aAAa,CAAC,KAC1B,IAAI,CAAC;AAEV,MAAM,WAAW,cAAc;IAC7B,gEAAgE;IAChE,MAAM,CAAC,EAAE,aAAa,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IAC5C,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAC;IACxC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IACpC,cAAc,CAAC,EAAE,cAAc,CAAC;CACjC;AAED,MAAM,WAAW,OAAO;IACtB,yDAAyD;IACzD,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,QAAQ,CAAC,OAAO,EAAE,cAAc,CAAC;IACjC,cAAc,CAAC,KAAK,EAAE,aAAa,GAAG,IAAI,CAAC;IAC3C,YAAY,CAAC,KAAK,EAAE,WAAW,GAAG,IAAI,CAAC;IACvC,SAAS,IAAI,QAAQ,CAAC,aAAa,CAAC,CAAC;IACrC,kBAAkB,CAAC,CAAC,GAAG,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,CAAC,GAAG,SAAS,CAAC;IAC/D,2FAA2F;IAC3F,YAAY,CAAC,IAAI,EAAE,aAAa,GAAG,IAAI,CAAC;IACxC,uCAAuC;IACvC,cAAc,CAAC,QAAQ,EAAE,oBAAoB,GAAG,MAAM,IAAI,CAAC;IAC3D,OAAO,IAAI,IAAI,CAAC;CACjB;AAoBD,wBAAgB,aAAa,CAAC,OAAO,GAAE,cAAmB,GAAG,OAAO,CAqJnE"}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { deepEqual, resolveConfig } from "./config.js";
|
|
2
|
+
const LEVEL_RANK = {
|
|
3
|
+
debug: 0,
|
|
4
|
+
info: 1,
|
|
5
|
+
warn: 2,
|
|
6
|
+
error: 3,
|
|
7
|
+
silent: 4,
|
|
8
|
+
};
|
|
9
|
+
const DEFAULT_LOG_LEVEL = "info";
|
|
10
|
+
function attempt(fn) {
|
|
11
|
+
try {
|
|
12
|
+
fn();
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// failures in host supplied sinks must never break callers
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function createRuntime(options = {}) {
|
|
19
|
+
const hostLogger = options.logger;
|
|
20
|
+
const hostOutput = options.output;
|
|
21
|
+
let disposed = false;
|
|
22
|
+
let config = Object.freeze({});
|
|
23
|
+
const listeners = new Set();
|
|
24
|
+
const pending = [];
|
|
25
|
+
let notifying = false;
|
|
26
|
+
const enabled = (level) => !disposed &&
|
|
27
|
+
hostLogger !== undefined &&
|
|
28
|
+
LEVEL_RANK[level] >= LEVEL_RANK[config.logLevel ?? DEFAULT_LOG_LEVEL];
|
|
29
|
+
const logger = {
|
|
30
|
+
debug(message, fields) {
|
|
31
|
+
if (enabled("debug"))
|
|
32
|
+
attempt(() => hostLogger.debug(message, fields));
|
|
33
|
+
},
|
|
34
|
+
info(message, fields) {
|
|
35
|
+
if (enabled("info"))
|
|
36
|
+
attempt(() => hostLogger.info(message, fields));
|
|
37
|
+
},
|
|
38
|
+
warn(message, fields) {
|
|
39
|
+
if (enabled("warn"))
|
|
40
|
+
attempt(() => hostLogger.warn(message, fields));
|
|
41
|
+
},
|
|
42
|
+
error(message, fields, cause) {
|
|
43
|
+
if (enabled("error"))
|
|
44
|
+
attempt(() => hostLogger.error(message, fields, cause));
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
const output = {
|
|
48
|
+
write(value) {
|
|
49
|
+
if (!disposed && hostOutput)
|
|
50
|
+
attempt(() => hostOutput.write(value));
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
const forward = (name, callback, event) => {
|
|
54
|
+
if (disposed || !callback)
|
|
55
|
+
return;
|
|
56
|
+
try {
|
|
57
|
+
callback(event);
|
|
58
|
+
}
|
|
59
|
+
catch (cause) {
|
|
60
|
+
logger.error(`${name} callback threw`, { callback: name }, cause);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const warnUnknown = (unknownKeys) => {
|
|
64
|
+
if (unknownKeys.length > 0) {
|
|
65
|
+
logger.warn("Ignoring unknown runtime config keys", { keys: [...unknownKeys] });
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const apply = (next) => {
|
|
69
|
+
const prev = config;
|
|
70
|
+
if (deepEqual(prev, next))
|
|
71
|
+
return;
|
|
72
|
+
config = next;
|
|
73
|
+
for (const listener of Array.from(listeners)) {
|
|
74
|
+
try {
|
|
75
|
+
listener(next, prev);
|
|
76
|
+
}
|
|
77
|
+
catch (cause) {
|
|
78
|
+
logger.error("Config change listener threw", { event: "configChange" }, cause);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
const initial = resolveConfig(options.config);
|
|
83
|
+
config = initial.config;
|
|
84
|
+
warnUnknown(initial.unknownKeys);
|
|
85
|
+
let context;
|
|
86
|
+
const runtime = {
|
|
87
|
+
logger,
|
|
88
|
+
output,
|
|
89
|
+
get context() {
|
|
90
|
+
if (!context) {
|
|
91
|
+
const { session, registry, datastoreManager, sessionManager } = options;
|
|
92
|
+
const missing = [
|
|
93
|
+
["session", session],
|
|
94
|
+
["registry", registry],
|
|
95
|
+
["datastoreManager", datastoreManager],
|
|
96
|
+
["sessionManager", sessionManager],
|
|
97
|
+
]
|
|
98
|
+
.filter(([, value]) => value === undefined)
|
|
99
|
+
.map(([name]) => name);
|
|
100
|
+
if (missing.length > 0) {
|
|
101
|
+
throw new Error(`Runtime context unavailable; options not supplied: ${missing.join(", ")}`);
|
|
102
|
+
}
|
|
103
|
+
context = {
|
|
104
|
+
logger,
|
|
105
|
+
output,
|
|
106
|
+
reportProgress: runtime.reportProgress,
|
|
107
|
+
reportMetric: runtime.reportMetric,
|
|
108
|
+
registry: registry,
|
|
109
|
+
datastoreManager: datastoreManager,
|
|
110
|
+
sessionManager: sessionManager,
|
|
111
|
+
session: session,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return context;
|
|
115
|
+
},
|
|
116
|
+
reportProgress(event) {
|
|
117
|
+
forward("onProgress", options.onProgress, event);
|
|
118
|
+
},
|
|
119
|
+
reportMetric(event) {
|
|
120
|
+
forward("onMetric", options.onMetric, event);
|
|
121
|
+
},
|
|
122
|
+
getConfig: () => config,
|
|
123
|
+
getComponentConfig(handle) {
|
|
124
|
+
const components = config.components;
|
|
125
|
+
return components && Object.hasOwn(components, handle)
|
|
126
|
+
? components[handle]
|
|
127
|
+
: undefined;
|
|
128
|
+
},
|
|
129
|
+
updateConfig(next) {
|
|
130
|
+
if (disposed)
|
|
131
|
+
throw new Error("Runtime is disposed");
|
|
132
|
+
const resolved = resolveConfig(next);
|
|
133
|
+
warnUnknown(resolved.unknownKeys);
|
|
134
|
+
if (notifying) {
|
|
135
|
+
pending.push(resolved.config);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
notifying = true;
|
|
139
|
+
try {
|
|
140
|
+
apply(resolved.config);
|
|
141
|
+
while (pending.length > 0 && !disposed) {
|
|
142
|
+
apply(pending.shift());
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
pending.length = 0;
|
|
147
|
+
notifying = false;
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
onConfigChange(listener) {
|
|
151
|
+
if (disposed)
|
|
152
|
+
throw new Error("Runtime is disposed");
|
|
153
|
+
listeners.add(listener);
|
|
154
|
+
return () => {
|
|
155
|
+
listeners.delete(listener);
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
dispose() {
|
|
159
|
+
disposed = true;
|
|
160
|
+
listeners.clear();
|
|
161
|
+
pending.length = 0;
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
return runtime;
|
|
165
|
+
}
|
package/index.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowscripter/runtime",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Flowscripter runtime library",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -38,11 +38,13 @@
|
|
|
38
38
|
"build": "tsc -p tsconfig.build.json"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
+
"@flowscripter/plugin-api": "^2.0.0",
|
|
41
42
|
"@types/bun": "^1.4.3",
|
|
42
|
-
"oxfmt": "0.
|
|
43
|
-
"oxlint": "1.
|
|
43
|
+
"oxfmt": "0.74.0",
|
|
44
|
+
"oxlint": "1.89.0"
|
|
44
45
|
},
|
|
45
46
|
"peerDependencies": {
|
|
47
|
+
"@flowscripter/plugin-api": "^2.0.0",
|
|
46
48
|
"typescript": "^7.0.2"
|
|
47
49
|
},
|
|
48
50
|
"typedocOptions": {
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { LogLevel } from "@flowscripter/plugin-api";
|
|
2
|
+
|
|
3
|
+
export const LOG_LEVELS: ReadonlyArray<LogLevel> = ["debug", "info", "warn", "error", "silent"];
|
|
4
|
+
|
|
5
|
+
const KNOWN_KEYS = ["logLevel", "components"];
|
|
6
|
+
|
|
7
|
+
export interface RuntimeConfig {
|
|
8
|
+
readonly logLevel?: LogLevel;
|
|
9
|
+
/** Opaque per-component config keyed by component handle; validated by each component. */
|
|
10
|
+
readonly components?: Readonly<Record<string, unknown>>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ResolvedConfig {
|
|
14
|
+
readonly config: Readonly<RuntimeConfig>;
|
|
15
|
+
readonly unknownKeys: ReadonlyArray<string>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
19
|
+
if (typeof value !== "object" || value === null) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
const proto = Object.getPrototypeOf(value);
|
|
23
|
+
return proto === Object.prototype || proto === null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function copyAndFreeze(value: unknown, path: string, ancestors: unknown[]): unknown {
|
|
27
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
if (typeof value === "number") {
|
|
31
|
+
if (!Number.isFinite(value)) {
|
|
32
|
+
throw new TypeError(`Config value at ${path} must be a finite number`);
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
if (Array.isArray(value) || isPlainObject(value)) {
|
|
37
|
+
if (ancestors.includes(value)) {
|
|
38
|
+
throw new TypeError(`Config value at ${path} is circular`);
|
|
39
|
+
}
|
|
40
|
+
ancestors.push(value);
|
|
41
|
+
let copy: unknown;
|
|
42
|
+
if (Array.isArray(value)) {
|
|
43
|
+
copy = value.map((item, i) => copyAndFreeze(item, `${path}[${i}]`, ancestors));
|
|
44
|
+
} else {
|
|
45
|
+
const obj: Record<string, unknown> = {};
|
|
46
|
+
for (const key of Object.keys(value)) {
|
|
47
|
+
obj[key] = copyAndFreeze(value[key], `${path}.${key}`, ancestors);
|
|
48
|
+
}
|
|
49
|
+
copy = obj;
|
|
50
|
+
}
|
|
51
|
+
ancestors.pop();
|
|
52
|
+
return Object.freeze(copy);
|
|
53
|
+
}
|
|
54
|
+
throw new TypeError(`Config value at ${path} must be plain data (got ${typeof value})`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Validate and deep-copy a host supplied config into a deep-frozen snapshot.
|
|
59
|
+
* The input object is never retained or mutated.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveConfig(input: unknown): ResolvedConfig {
|
|
62
|
+
if (input === undefined) {
|
|
63
|
+
return { config: Object.freeze({}), unknownKeys: [] };
|
|
64
|
+
}
|
|
65
|
+
if (!isPlainObject(input)) {
|
|
66
|
+
throw new TypeError("Runtime config must be a plain object");
|
|
67
|
+
}
|
|
68
|
+
const { logLevel, components } = input;
|
|
69
|
+
if (logLevel !== undefined && !LOG_LEVELS.includes(logLevel as LogLevel)) {
|
|
70
|
+
throw new TypeError(
|
|
71
|
+
`Invalid logLevel ${JSON.stringify(logLevel)}; allowed values: ${LOG_LEVELS.join(", ")}`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (components !== undefined && !isPlainObject(components)) {
|
|
75
|
+
throw new TypeError("Runtime config 'components' must be a plain object keyed by handle");
|
|
76
|
+
}
|
|
77
|
+
const config: { logLevel?: LogLevel; components?: Readonly<Record<string, unknown>> } = {};
|
|
78
|
+
if (logLevel !== undefined) {
|
|
79
|
+
config.logLevel = logLevel as LogLevel;
|
|
80
|
+
}
|
|
81
|
+
if (components !== undefined) {
|
|
82
|
+
config.components = copyAndFreeze(components, "components", []) as Record<string, unknown>;
|
|
83
|
+
}
|
|
84
|
+
const unknownKeys = Object.keys(input).filter((key) => !KNOWN_KEYS.includes(key));
|
|
85
|
+
return { config: Object.freeze(config), unknownKeys };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function deepEqual(a: unknown, b: unknown): boolean {
|
|
89
|
+
if (a === b) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(a)) {
|
|
93
|
+
return Array.isArray(b) && a.length === b.length && a.every((item, i) => deepEqual(item, b[i]));
|
|
94
|
+
}
|
|
95
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
96
|
+
const keys = Object.keys(a);
|
|
97
|
+
return (
|
|
98
|
+
keys.length === Object.keys(b).length &&
|
|
99
|
+
keys.every((key) => key in b && deepEqual(a[key], b[key]))
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DatastoreManager,
|
|
3
|
+
LogFields,
|
|
4
|
+
Logger,
|
|
5
|
+
LogLevel,
|
|
6
|
+
MetricEvent,
|
|
7
|
+
Output,
|
|
8
|
+
ProgressEvent,
|
|
9
|
+
Registry,
|
|
10
|
+
RuntimeContext,
|
|
11
|
+
Session,
|
|
12
|
+
SessionManager,
|
|
13
|
+
StructuredValue,
|
|
14
|
+
} from "@flowscripter/plugin-api";
|
|
15
|
+
import { deepEqual, resolveConfig } from "./config.ts";
|
|
16
|
+
import type { RuntimeConfig } from "./config.ts";
|
|
17
|
+
|
|
18
|
+
export type ConfigChangeListener = (
|
|
19
|
+
next: Readonly<RuntimeConfig>,
|
|
20
|
+
prev: Readonly<RuntimeConfig>,
|
|
21
|
+
) => void;
|
|
22
|
+
|
|
23
|
+
export interface RuntimeOptions {
|
|
24
|
+
/** Already parsed plain object; the runtime performs no I/O. */
|
|
25
|
+
config?: RuntimeConfig;
|
|
26
|
+
logger?: Logger;
|
|
27
|
+
output?: Output;
|
|
28
|
+
onProgress?: (event: ProgressEvent) => void;
|
|
29
|
+
onMetric?: (event: MetricEvent) => void;
|
|
30
|
+
session?: Session;
|
|
31
|
+
registry?: Registry;
|
|
32
|
+
datastoreManager?: DatastoreManager;
|
|
33
|
+
sessionManager?: SessionManager;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Runtime {
|
|
37
|
+
/** Level-filtered wrapper around the injected logger. */
|
|
38
|
+
readonly logger: Logger;
|
|
39
|
+
readonly output: Output;
|
|
40
|
+
/**
|
|
41
|
+
* The context passed to components. Throws if `session`, `registry`, `datastoreManager` or
|
|
42
|
+
* `sessionManager` were not supplied in {@link RuntimeOptions}.
|
|
43
|
+
*/
|
|
44
|
+
readonly context: RuntimeContext;
|
|
45
|
+
reportProgress(event: ProgressEvent): void;
|
|
46
|
+
reportMetric(event: MetricEvent): void;
|
|
47
|
+
getConfig(): Readonly<RuntimeConfig>;
|
|
48
|
+
getComponentConfig<T = unknown>(handle: string): T | undefined;
|
|
49
|
+
/** Full replacement. Throws `TypeError` on invalid config, keeping the previous config. */
|
|
50
|
+
updateConfig(next: RuntimeConfig): void;
|
|
51
|
+
/** Returns an unsubscribe function. */
|
|
52
|
+
onConfigChange(listener: ConfigChangeListener): () => void;
|
|
53
|
+
dispose(): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const LEVEL_RANK: Record<LogLevel, number> = {
|
|
57
|
+
debug: 0,
|
|
58
|
+
info: 1,
|
|
59
|
+
warn: 2,
|
|
60
|
+
error: 3,
|
|
61
|
+
silent: 4,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const DEFAULT_LOG_LEVEL: LogLevel = "info";
|
|
65
|
+
|
|
66
|
+
function attempt(fn: () => void): void {
|
|
67
|
+
try {
|
|
68
|
+
fn();
|
|
69
|
+
} catch {
|
|
70
|
+
// failures in host supplied sinks must never break callers
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createRuntime(options: RuntimeOptions = {}): Runtime {
|
|
75
|
+
const hostLogger = options.logger;
|
|
76
|
+
const hostOutput = options.output;
|
|
77
|
+
let disposed = false;
|
|
78
|
+
let config = Object.freeze({}) as Readonly<RuntimeConfig>;
|
|
79
|
+
const listeners = new Set<ConfigChangeListener>();
|
|
80
|
+
const pending: Readonly<RuntimeConfig>[] = [];
|
|
81
|
+
let notifying = false;
|
|
82
|
+
|
|
83
|
+
const enabled = (level: LogLevel): boolean =>
|
|
84
|
+
!disposed &&
|
|
85
|
+
hostLogger !== undefined &&
|
|
86
|
+
LEVEL_RANK[level] >= LEVEL_RANK[config.logLevel ?? DEFAULT_LOG_LEVEL];
|
|
87
|
+
|
|
88
|
+
const logger: Logger = {
|
|
89
|
+
debug(message: string | (() => string), fields?: LogFields) {
|
|
90
|
+
if (enabled("debug")) attempt(() => hostLogger!.debug(message, fields));
|
|
91
|
+
},
|
|
92
|
+
info(message: string, fields?: LogFields) {
|
|
93
|
+
if (enabled("info")) attempt(() => hostLogger!.info(message, fields));
|
|
94
|
+
},
|
|
95
|
+
warn(message: string, fields?: LogFields) {
|
|
96
|
+
if (enabled("warn")) attempt(() => hostLogger!.warn(message, fields));
|
|
97
|
+
},
|
|
98
|
+
error(message: string, fields?: LogFields, cause?: unknown) {
|
|
99
|
+
if (enabled("error")) attempt(() => hostLogger!.error(message, fields, cause));
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const output: Output = {
|
|
104
|
+
write(value: string | StructuredValue) {
|
|
105
|
+
if (!disposed && hostOutput) attempt(() => hostOutput.write(value));
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const forward = <E>(name: string, callback: ((event: E) => void) | undefined, event: E) => {
|
|
110
|
+
if (disposed || !callback) return;
|
|
111
|
+
try {
|
|
112
|
+
callback(event);
|
|
113
|
+
} catch (cause) {
|
|
114
|
+
logger.error(`${name} callback threw`, { callback: name }, cause);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const warnUnknown = (unknownKeys: ReadonlyArray<string>) => {
|
|
119
|
+
if (unknownKeys.length > 0) {
|
|
120
|
+
logger.warn("Ignoring unknown runtime config keys", { keys: [...unknownKeys] });
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const apply = (next: Readonly<RuntimeConfig>) => {
|
|
125
|
+
const prev = config;
|
|
126
|
+
if (deepEqual(prev, next)) return;
|
|
127
|
+
config = next;
|
|
128
|
+
for (const listener of Array.from(listeners)) {
|
|
129
|
+
try {
|
|
130
|
+
listener(next, prev);
|
|
131
|
+
} catch (cause) {
|
|
132
|
+
logger.error("Config change listener threw", { event: "configChange" }, cause);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const initial = resolveConfig(options.config);
|
|
138
|
+
config = initial.config;
|
|
139
|
+
warnUnknown(initial.unknownKeys);
|
|
140
|
+
|
|
141
|
+
let context: RuntimeContext | undefined;
|
|
142
|
+
|
|
143
|
+
const runtime: Runtime = {
|
|
144
|
+
logger,
|
|
145
|
+
output,
|
|
146
|
+
get context() {
|
|
147
|
+
if (!context) {
|
|
148
|
+
const { session, registry, datastoreManager, sessionManager } = options;
|
|
149
|
+
const missing = (
|
|
150
|
+
[
|
|
151
|
+
["session", session],
|
|
152
|
+
["registry", registry],
|
|
153
|
+
["datastoreManager", datastoreManager],
|
|
154
|
+
["sessionManager", sessionManager],
|
|
155
|
+
] as const
|
|
156
|
+
)
|
|
157
|
+
.filter(([, value]) => value === undefined)
|
|
158
|
+
.map(([name]) => name);
|
|
159
|
+
if (missing.length > 0) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`Runtime context unavailable; options not supplied: ${missing.join(", ")}`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
context = {
|
|
165
|
+
logger,
|
|
166
|
+
output,
|
|
167
|
+
reportProgress: runtime.reportProgress,
|
|
168
|
+
reportMetric: runtime.reportMetric,
|
|
169
|
+
registry: registry!,
|
|
170
|
+
datastoreManager: datastoreManager!,
|
|
171
|
+
sessionManager: sessionManager!,
|
|
172
|
+
session: session!,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
return context;
|
|
176
|
+
},
|
|
177
|
+
reportProgress(event) {
|
|
178
|
+
forward("onProgress", options.onProgress, event);
|
|
179
|
+
},
|
|
180
|
+
reportMetric(event) {
|
|
181
|
+
forward("onMetric", options.onMetric, event);
|
|
182
|
+
},
|
|
183
|
+
getConfig: () => config,
|
|
184
|
+
getComponentConfig<T = unknown>(handle: string): T | undefined {
|
|
185
|
+
const components = config.components;
|
|
186
|
+
return components && Object.hasOwn(components, handle)
|
|
187
|
+
? (components[handle] as T)
|
|
188
|
+
: undefined;
|
|
189
|
+
},
|
|
190
|
+
updateConfig(next) {
|
|
191
|
+
if (disposed) throw new Error("Runtime is disposed");
|
|
192
|
+
const resolved = resolveConfig(next);
|
|
193
|
+
warnUnknown(resolved.unknownKeys);
|
|
194
|
+
if (notifying) {
|
|
195
|
+
pending.push(resolved.config);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
notifying = true;
|
|
199
|
+
try {
|
|
200
|
+
apply(resolved.config);
|
|
201
|
+
while (pending.length > 0 && !disposed) {
|
|
202
|
+
apply(pending.shift()!);
|
|
203
|
+
}
|
|
204
|
+
} finally {
|
|
205
|
+
pending.length = 0;
|
|
206
|
+
notifying = false;
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
onConfigChange(listener) {
|
|
210
|
+
if (disposed) throw new Error("Runtime is disposed");
|
|
211
|
+
listeners.add(listener);
|
|
212
|
+
return () => {
|
|
213
|
+
listeners.delete(listener);
|
|
214
|
+
};
|
|
215
|
+
},
|
|
216
|
+
dispose() {
|
|
217
|
+
disposed = true;
|
|
218
|
+
listeners.clear();
|
|
219
|
+
pending.length = 0;
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
return runtime;
|
|
223
|
+
}
|
package/dist/src/world.d.ts
DELETED
package/dist/src/world.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"world.d.ts","sourceRoot":"","sources":["../../src/world.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,wBAAgB,KAAK,IAAI,IAAI,CAE5B"}
|
package/dist/src/world.js
DELETED