@voxgig/station 0.0.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 +21 -0
- package/README.md +88 -0
- package/dist/src/Station.d.ts +238 -0
- package/dist/src/Station.js +1213 -0
- package/dist/src/Station.js.map +1 -0
- package/dist/src/adapter.d.ts +9 -0
- package/dist/src/adapter.js +171 -0
- package/dist/src/adapter.js.map +1 -0
- package/dist/src/config-shape.d.ts +1 -0
- package/dist/src/config-shape.js +329 -0
- package/dist/src/config-shape.js.map +1 -0
- package/dist/src/descriptor.d.ts +8 -0
- package/dist/src/descriptor.js +170 -0
- package/dist/src/descriptor.js.map +1 -0
- package/dist/src/error.d.ts +8 -0
- package/dist/src/error.js +60 -0
- package/dist/src/error.js.map +1 -0
- package/dist/src/events.d.ts +15 -0
- package/dist/src/events.js +46 -0
- package/dist/src/events.js.map +1 -0
- package/dist/src/factory.d.ts +24 -0
- package/dist/src/factory.js +79 -0
- package/dist/src/factory.js.map +1 -0
- package/dist/src/feature.d.ts +106 -0
- package/dist/src/feature.js +324 -0
- package/dist/src/feature.js.map +1 -0
- package/dist/src/index.browser.d.ts +14 -0
- package/dist/src/index.browser.js +64 -0
- package/dist/src/index.browser.js.map +1 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +56 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/loader.d.ts +45 -0
- package/dist/src/loader.js +181 -0
- package/dist/src/loader.js.map +1 -0
- package/dist/src/profile.d.ts +6 -0
- package/dist/src/profile.js +77 -0
- package/dist/src/profile.js.map +1 -0
- package/dist/src/profilecore.d.ts +10 -0
- package/dist/src/profilecore.js +180 -0
- package/dist/src/profilecore.js.map +1 -0
- package/dist/src/secrets.d.ts +12 -0
- package/dist/src/secrets.js +93 -0
- package/dist/src/secrets.js.map +1 -0
- package/dist/src/shape.d.ts +42 -0
- package/dist/src/shape.js +444 -0
- package/dist/src/shape.js.map +1 -0
- package/dist/src/types.d.ts +138 -0
- package/dist/src/types.js +5 -0
- package/dist/src/types.js.map +1 -0
- package/package.json +60 -0
- package/src/Station.ts +1310 -0
- package/src/adapter.ts +183 -0
- package/src/config-shape.ts +326 -0
- package/src/descriptor.ts +179 -0
- package/src/error.ts +64 -0
- package/src/events.ts +45 -0
- package/src/factory.ts +93 -0
- package/src/feature.ts +321 -0
- package/src/index.browser.ts +56 -0
- package/src/index.ts +50 -0
- package/src/loader.ts +188 -0
- package/src/profile.ts +67 -0
- package/src/profilecore.ts +201 -0
- package/src/secrets.ts +96 -0
- package/src/shape.ts +435 -0
- package/src/types.ts +185 -0
package/src/error.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Error codes follow the SDKs' house grammar (design §14):
|
|
2
|
+
// <subject>_<condition>, absence as no_<thing>, gates as _allow.
|
|
3
|
+
// The `errors` corpus section pins the exact strings.
|
|
4
|
+
|
|
5
|
+
const CODES = [
|
|
6
|
+
'station_no_proxy',
|
|
7
|
+
'station_secret_no_value',
|
|
8
|
+
'station_secret_error',
|
|
9
|
+
'station_secret_name',
|
|
10
|
+
'station_host_allow',
|
|
11
|
+
'station_grant_expired',
|
|
12
|
+
'station_wrap_order',
|
|
13
|
+
'station_protocol',
|
|
14
|
+
'station_no_plugin',
|
|
15
|
+
'station_no_entity',
|
|
16
|
+
'station_no_op',
|
|
17
|
+
'station_agent_allow',
|
|
18
|
+
'station_body_limit',
|
|
19
|
+
'station_replay_lossy',
|
|
20
|
+
'station_open_conflict',
|
|
21
|
+
'station_bound_twice',
|
|
22
|
+
|
|
23
|
+
// Declarative config (station-declarative-config.md §6.4). Stage 1
|
|
24
|
+
// raises the first three; `station_feature_reserved` is the pure-data
|
|
25
|
+
// half of §8.4/§8.6, checkable before feature.ts exists because both
|
|
26
|
+
// rules are lexical - a `station` key in a feature map, a `feature`
|
|
27
|
+
// key inside `options`.
|
|
28
|
+
'station_config_invalid',
|
|
29
|
+
'station_config_secret',
|
|
30
|
+
'station_secret_collision',
|
|
31
|
+
'station_feature_reserved',
|
|
32
|
+
|
|
33
|
+
// Instances (design §6.4). `as` is a tag, not a free name: a full ref
|
|
34
|
+
// whose name is not the SDK's api slug is this.
|
|
35
|
+
'station_instance_api',
|
|
36
|
+
'station_no_instance',
|
|
37
|
+
'station_instance_inactive',
|
|
38
|
+
'station_sdk_load',
|
|
39
|
+
'station_no_factory',
|
|
40
|
+
'station_factory_conflict',
|
|
41
|
+
|
|
42
|
+
// Features (design §8.4, §8.5). The checker is derived from the SDK's
|
|
43
|
+
// own declaration, so a feature typo is a CI failure rather than a
|
|
44
|
+
// setting that quietly did nothing in production.
|
|
45
|
+
'station_feature_unknown',
|
|
46
|
+
'station_feature_option',
|
|
47
|
+
'station_feature_order',
|
|
48
|
+
] as const
|
|
49
|
+
|
|
50
|
+
export type StationErrorCode = typeof CODES[number]
|
|
51
|
+
|
|
52
|
+
export class StationError extends Error {
|
|
53
|
+
code: StationErrorCode
|
|
54
|
+
|
|
55
|
+
constructor(code: StationErrorCode, message: string) {
|
|
56
|
+
super(code + ': ' + message)
|
|
57
|
+
this.name = 'StationError'
|
|
58
|
+
this.code = code
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isKnownCode(code: string): boolean {
|
|
63
|
+
return (CODES as readonly string[]).includes(code)
|
|
64
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { StationEvent } from './types'
|
|
2
|
+
|
|
3
|
+
// The solo event surface (design §6): a bounded ring buffer plus a live
|
|
4
|
+
// tap with serialized callbacks. Events never fail an operation; overflow
|
|
5
|
+
// drops oldest and the drop count is visible in status().
|
|
6
|
+
|
|
7
|
+
export class EventBuffer {
|
|
8
|
+
private ring: StationEvent[] = []
|
|
9
|
+
private max: number
|
|
10
|
+
private drops = 0
|
|
11
|
+
private taps: ((ev: StationEvent) => void)[] = []
|
|
12
|
+
|
|
13
|
+
constructor(max?: number) {
|
|
14
|
+
this.max = null == max ? 1000 : max
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
emit(ev: StationEvent): void {
|
|
18
|
+
this.ring.push(ev)
|
|
19
|
+
if (this.ring.length > this.max) {
|
|
20
|
+
this.ring.shift()
|
|
21
|
+
this.drops++
|
|
22
|
+
}
|
|
23
|
+
// Serialized, and a throwing tap must not fail the operation that
|
|
24
|
+
// emitted the event.
|
|
25
|
+
for (const fn of this.taps) {
|
|
26
|
+
try { fn(ev) } catch (_e) { }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
events(): StationEvent[] {
|
|
31
|
+
return this.ring.slice()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
tap(fn: (ev: StationEvent) => void): () => void {
|
|
35
|
+
this.taps.push(fn)
|
|
36
|
+
return () => {
|
|
37
|
+
const i = this.taps.indexOf(fn)
|
|
38
|
+
if (-1 !== i) { this.taps.splice(i, 1) }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
status(): { buffered: number, dropped: number } {
|
|
43
|
+
return { buffered: this.ring.length, dropped: this.drops }
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/factory.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/* The factory table (design §6.2).
|
|
2
|
+
*
|
|
3
|
+
* A FACTORY IS A CONSTRUCTOR *PLUS* THE SDK'S STATIC CONFIG, not a bare
|
|
4
|
+
* function, and leaving the second half out was a hole in the first
|
|
5
|
+
* draft of §6.2.
|
|
6
|
+
*
|
|
7
|
+
* Station composes the ordered feature array FOR the constructor, so it
|
|
8
|
+
* needs the transport roles and the feature option schemas BEFORE
|
|
9
|
+
* construction — but §7.5 has the adapter build and register its
|
|
10
|
+
* descriptor DURING construction. Nothing would be known in time.
|
|
11
|
+
*
|
|
12
|
+
* The config is available, though: `configDefinition` emits it as a
|
|
13
|
+
* module-level constant in the generated package, so it exists as soon
|
|
14
|
+
* as the package is linked and long before any instance is built.
|
|
15
|
+
* Station normalizes the descriptor AT PROVIDE TIME, and three things
|
|
16
|
+
* follow:
|
|
17
|
+
*
|
|
18
|
+
* - §7.4's per-api descriptor cache is populated at registration
|
|
19
|
+
* rather than on first construction;
|
|
20
|
+
* - `check()` can validate every instance's feature config WITHOUT
|
|
21
|
+
* constructing anything;
|
|
22
|
+
* - the adapter's registration during construction becomes a
|
|
23
|
+
* reconciliation — same descriptor, now bound to a live client —
|
|
24
|
+
* rather than the first sighting.
|
|
25
|
+
*
|
|
26
|
+
* The table is PROCESS-GLOBAL because path 1 of §6.2 is module
|
|
27
|
+
* self-registration: a generated package registers itself when it is
|
|
28
|
+
* linked, which happens once per process and not once per Station. */
|
|
29
|
+
|
|
30
|
+
import { StationError } from './error'
|
|
31
|
+
import { normalizeDescriptor } from './descriptor'
|
|
32
|
+
|
|
33
|
+
export type Factory = {
|
|
34
|
+
construct: (options: any) => any
|
|
35
|
+
config: any
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type FactoryEntry = {
|
|
39
|
+
api: string
|
|
40
|
+
construct: (options: any) => any
|
|
41
|
+
config: any
|
|
42
|
+
descriptor: any
|
|
43
|
+
warnings: string[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const TABLE = new Map<string, FactoryEntry>()
|
|
47
|
+
|
|
48
|
+
/** Register an api's `{construct, config}` pair.
|
|
49
|
+
*
|
|
50
|
+
* Idempotent per api: registering the SAME pair twice is a no-op,
|
|
51
|
+
* because module self-registration and an explicit `provide` for one
|
|
52
|
+
* api is an ordinary thing for an application to end up with. A second
|
|
53
|
+
* registration with a DIFFERENT factory is `station_factory_conflict` —
|
|
54
|
+
* silently picking one of two SDK builds is not a thing to do quietly. */
|
|
55
|
+
export function provide(api: string, factory: Factory): FactoryEntry {
|
|
56
|
+
const slug = String(api)
|
|
57
|
+
const prior = TABLE.get(slug)
|
|
58
|
+
if (null != prior) {
|
|
59
|
+
if (prior.construct === factory.construct && prior.config === factory.config) {
|
|
60
|
+
return prior
|
|
61
|
+
}
|
|
62
|
+
throw new StationError('station_factory_conflict',
|
|
63
|
+
'two different factories registered for api "' + slug + '"; a ' +
|
|
64
|
+
'process has one build of an SDK, and picking between two ' +
|
|
65
|
+
'silently is not a thing to do quietly')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// AT PROVIDE TIME, which is the whole point of carrying `config`.
|
|
69
|
+
const { descriptor, warnings } = normalizeDescriptor(factory.config, undefined)
|
|
70
|
+
const entry: FactoryEntry = {
|
|
71
|
+
api: slug,
|
|
72
|
+
construct: factory.construct,
|
|
73
|
+
config: factory.config,
|
|
74
|
+
descriptor,
|
|
75
|
+
warnings,
|
|
76
|
+
}
|
|
77
|
+
TABLE.set(slug, entry)
|
|
78
|
+
return entry
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function factoryFor(api: string): FactoryEntry | undefined {
|
|
82
|
+
return TABLE.get(String(api))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function provided(): string[] {
|
|
86
|
+
return Array.from(TABLE.keys()).sort()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Test seam. The table is process-global by design, so a suite that
|
|
90
|
+
* registers factories has to be able to put the process back. */
|
|
91
|
+
export function resetFactories(): void {
|
|
92
|
+
TABLE.clear()
|
|
93
|
+
}
|
package/src/feature.ts
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/* Feature management (design §8): the three-level merge, the
|
|
2
|
+
* constraint-and-band resolver, and the descriptor-derived checker.
|
|
3
|
+
*
|
|
4
|
+
* The resolver is written to voxgig/plugin's §7 semantics so plugin can
|
|
5
|
+
* extract it — this is one of the pieces the joint plan means by
|
|
6
|
+
* "station builds natively to plugin's semantics". Keeping the two
|
|
7
|
+
* spellings identical is what C4 holds station to. */
|
|
8
|
+
|
|
9
|
+
import { StationError } from './error'
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------
|
|
12
|
+
// §8.3 — the merge
|
|
13
|
+
// ---------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
/** Reserved on a feature entry: not options, and never passed through
|
|
16
|
+
* to the SDK's own option map. */
|
|
17
|
+
export const RESERVED_KEYS = ['active', 'order']
|
|
18
|
+
|
|
19
|
+
/** The six sources, in §3.3's order extended by the profile level:
|
|
20
|
+
*
|
|
21
|
+
* 1 base.feature 4 overlay.feature
|
|
22
|
+
* 2 base.api[<api>].feature 5 overlay.api[<api>].feature
|
|
23
|
+
* 3 base.sdk[<ref>].feature 6 overlay.sdk[<ref>].feature
|
|
24
|
+
*
|
|
25
|
+
* PROFILE SPECIFICITY OUTRANKS BLOCK SPECIFICITY, and within a profile
|
|
26
|
+
* the narrower block wins — the same principle as §3.3, one level down.
|
|
27
|
+
*
|
|
28
|
+
* `feature` is the ONE key where §3.3's shallow-per-key rule is wrong:
|
|
29
|
+
* composition is the entire point, a fleet default plus a per-instance
|
|
30
|
+
* tweak. So it is a TWO-LEVEL merge — per feature name, then per option
|
|
31
|
+
* key — and no deeper. A map-valued option REPLACES wholesale, which is
|
|
32
|
+
* what `{"$MERGE": {"deep": 2}}` states and what a port defaulting to a
|
|
33
|
+
* deep merge would silently get wrong.
|
|
34
|
+
*
|
|
35
|
+
* Same defaults-after-merge rule as §3.3, one level down: an entry
|
|
36
|
+
* mentioned at one level with only a tuning key must NOT synthesize
|
|
37
|
+
* `active` and switch on a feature a broader level turned off. That is
|
|
38
|
+
* the §3.3 defect one level down, and it is why the caller passes RAW
|
|
39
|
+
* blocks here. */
|
|
40
|
+
export function mergefeatures(sources: any[]): { [name: string]: any } {
|
|
41
|
+
const out: { [name: string]: any } = {}
|
|
42
|
+
for (const src of sources) {
|
|
43
|
+
if (!ismap(src)) { continue }
|
|
44
|
+
for (const name of Object.keys(src)) {
|
|
45
|
+
const entry = src[name]
|
|
46
|
+
if (!ismap(entry)) { out[name] = entry; continue }
|
|
47
|
+
// Per option key, and NOT deeper.
|
|
48
|
+
out[name] = { ...(ismap(out[name]) ? out[name] : {}), ...entry }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The six sources for one instance, in order. Assembled here rather
|
|
55
|
+
* than at the call site so the order lives in exactly one place. */
|
|
56
|
+
export function featuresources(
|
|
57
|
+
base: any, overlay: any, api: string, ref: string
|
|
58
|
+
): any[] {
|
|
59
|
+
return [
|
|
60
|
+
base?.feature,
|
|
61
|
+
base?.api?.[api]?.feature,
|
|
62
|
+
base?.sdk?.[ref]?.feature,
|
|
63
|
+
overlay?.feature,
|
|
64
|
+
overlay?.api?.[api]?.feature,
|
|
65
|
+
overlay?.sdk?.[ref]?.feature,
|
|
66
|
+
]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------
|
|
70
|
+
// §8.4 — activation and order
|
|
71
|
+
// ---------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
/** `test` substitutes the base transport, so it takes the innermost
|
|
74
|
+
* band; `station` sits immediately outside it, pinned; everything else
|
|
75
|
+
* is band 0, outside station.
|
|
76
|
+
*
|
|
77
|
+
* THE DEFAULT IS TODAY'S BEHAVIOUR EXPRESSED IN THE NEW MODEL rather
|
|
78
|
+
* than as a special case: a project that writes no `order` anywhere
|
|
79
|
+
* sees exactly today's nesting, and sdkgen's two `makeOptions` special
|
|
80
|
+
* cases become two band values rather than two branches. */
|
|
81
|
+
export const BAND_DEFAULT = 0
|
|
82
|
+
export const BAND_STATION = 100
|
|
83
|
+
export const BAND_TEST = 200
|
|
84
|
+
|
|
85
|
+
/** Higher is further IN. */
|
|
86
|
+
export function defaultband(name: string): number {
|
|
87
|
+
if ('test' === name) { return BAND_TEST }
|
|
88
|
+
if ('station' === name) { return BAND_STATION }
|
|
89
|
+
return BAND_DEFAULT
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type Ordered = { name: string, band: number, entry: any }
|
|
93
|
+
|
|
94
|
+
/** Resolve the activation order: constraints, then bands, then the
|
|
95
|
+
* feature's position in the merged map.
|
|
96
|
+
*
|
|
97
|
+
* `before`/`after` take a feature name or a list of them and are
|
|
98
|
+
* SATISFIED VACUOUSLY when the named feature is absent — `after: 'test'`
|
|
99
|
+
* loads fine in a project with no test feature, which is sdkgen's
|
|
100
|
+
* `__after__` behaviour kept rather than reinvented.
|
|
101
|
+
*
|
|
102
|
+
* Constraints beat bands; bands break ties no constraint decides;
|
|
103
|
+
* remaining ties break by declaration position, so the result is a
|
|
104
|
+
* stable topological sort with no alphabetical accident in it.
|
|
105
|
+
*
|
|
106
|
+
* Returns OUTERMOST FIRST, which is the array form the constructor
|
|
107
|
+
* takes and the direction plugin's chain composes in. */
|
|
108
|
+
export function resolveorder(merged: { [name: string]: any }): Ordered[] {
|
|
109
|
+
const names = Object.keys(merged).filter((n) => active(merged[n]))
|
|
110
|
+
const pos = new Map<string, number>()
|
|
111
|
+
names.forEach((n, i) => pos.set(n, i))
|
|
112
|
+
|
|
113
|
+
const band = new Map<string, number>()
|
|
114
|
+
for (const n of names) {
|
|
115
|
+
const o = ismap(merged[n]) ? merged[n].order : undefined
|
|
116
|
+
const b = ismap(o) && 'number' === typeof o.band ? o.band : defaultband(n)
|
|
117
|
+
band.set(n, b)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// edges: from OUTER to INNER. `after: X` means "further in than X".
|
|
121
|
+
const inner = new Map<string, Set<string>>()
|
|
122
|
+
for (const n of names) { inner.set(n, new Set()) }
|
|
123
|
+
|
|
124
|
+
const listof = (v: any): string[] =>
|
|
125
|
+
null == v ? [] : (Array.isArray(v) ? v : [v]).map(String)
|
|
126
|
+
|
|
127
|
+
for (const n of names) {
|
|
128
|
+
const o = ismap(merged[n]) ? merged[n].order : undefined
|
|
129
|
+
if (!ismap(o)) { continue }
|
|
130
|
+
// Vacuous when absent: an unknown name is not an error here.
|
|
131
|
+
for (const other of listof(o.after)) {
|
|
132
|
+
if (inner.has(other)) { inner.get(other)!.add(n) }
|
|
133
|
+
}
|
|
134
|
+
for (const other of listof(o.before)) {
|
|
135
|
+
if (inner.has(other)) { inner.get(n)!.add(other) }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const indeg = new Map<string, number>()
|
|
140
|
+
for (const n of names) { indeg.set(n, 0) }
|
|
141
|
+
for (const n of names) {
|
|
142
|
+
for (const m of inner.get(n)!) { indeg.set(m, (indeg.get(m) || 0) + 1) }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Kahn, picking the lowest band first (outermost), then declaration
|
|
146
|
+
// position — so ties break the same way in every port.
|
|
147
|
+
const ready: string[] = names.filter((n) => 0 === indeg.get(n))
|
|
148
|
+
const out: Ordered[] = []
|
|
149
|
+
const pick = (): string => {
|
|
150
|
+
ready.sort((a, b) => {
|
|
151
|
+
const d = band.get(a)! - band.get(b)!
|
|
152
|
+
return 0 !== d ? d : pos.get(a)! - pos.get(b)!
|
|
153
|
+
})
|
|
154
|
+
return ready.shift()!
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
while (0 < ready.length) {
|
|
158
|
+
const n = pick()
|
|
159
|
+
out.push({ name: n, band: band.get(n)!, entry: merged[n] })
|
|
160
|
+
for (const m of inner.get(n)!) {
|
|
161
|
+
indeg.set(m, indeg.get(m)! - 1)
|
|
162
|
+
if (0 === indeg.get(m)) { ready.push(m) }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (out.length !== names.length) {
|
|
167
|
+
const stuck = names.filter((n) => !out.some((o) => o.name === n)).sort()
|
|
168
|
+
throw new StationError('station_feature_order',
|
|
169
|
+
'feature ordering constraints form a cycle among [' +
|
|
170
|
+
stuck.join(', ') + ']')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return out
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** A feature named in the config is one you are ASKING for, so an entry
|
|
177
|
+
* with no `active` is active. */
|
|
178
|
+
function active(entry: any): boolean {
|
|
179
|
+
if (!ismap(entry)) { return false !== entry }
|
|
180
|
+
return false !== entry.active
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Station's own position is PINNED and not orderable (§8.4): an order
|
|
184
|
+
* that moves `station` away from immediately-outside-the-base is
|
|
185
|
+
* REJECTED, not honoured.
|
|
186
|
+
*
|
|
187
|
+
* The pin is `innermost`, and the spelling matters. A chain composes
|
|
188
|
+
* with the FIRST binding outermost, so a pin written in sort terms —
|
|
189
|
+
* "station first" — would place every other wrapper between the adapter
|
|
190
|
+
* and the base: the exact inversion of the invariant, and one that
|
|
191
|
+
* would leave station's wire-truth events observing the wrong boundary
|
|
192
|
+
* while still looking ordered. */
|
|
193
|
+
export function checkpin(ordered: Ordered[]): void {
|
|
194
|
+
const i = ordered.findIndex((o) => 'station' === o.name)
|
|
195
|
+
if (-1 === i) { return }
|
|
196
|
+
|
|
197
|
+
const base = ordered.findIndex((o) => 'test' === o.name)
|
|
198
|
+
// station must be the innermost wrapper: last, or immediately
|
|
199
|
+
// outside the base-transport feature when one is active.
|
|
200
|
+
const want = -1 === base ? ordered.length - 1 : base - 1
|
|
201
|
+
if (i !== want) {
|
|
202
|
+
throw new StationError('station_feature_order',
|
|
203
|
+
'an ordering would move `station` away from immediately outside ' +
|
|
204
|
+
'the base transport; its position is pinned innermost and is not ' +
|
|
205
|
+
'orderable (§8.4)')
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------
|
|
210
|
+
// §8.5 — the checker, derived from the descriptor
|
|
211
|
+
// ---------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
export type FeatureFault = {
|
|
214
|
+
code: string
|
|
215
|
+
feature: string
|
|
216
|
+
key?: string
|
|
217
|
+
message: string
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Check a merged feature map against the SDK'S OWN DECLARATION.
|
|
221
|
+
*
|
|
222
|
+
* The schema arrives with the FACTORY rather than with a live client
|
|
223
|
+
* (§6.2), so this needs no construction and no network — which is what
|
|
224
|
+
* lets `check()` run it for every instance in CI.
|
|
225
|
+
*
|
|
226
|
+
* Derived from the descriptor, never hand-written, so it cannot drift:
|
|
227
|
+
* when a feature gains an option, the next regeneration teaches station
|
|
228
|
+
* about it with no station change.
|
|
229
|
+
*
|
|
230
|
+
* SCALARS AGREE BY CONSTRUCTION; COMPOUND OPTIONS ARE KIND-CHECKED, and
|
|
231
|
+
* that limit is real. An empty list default says nothing reliable about
|
|
232
|
+
* its element type and a nested map default says nothing about its
|
|
233
|
+
* value shapes, so `methods: [{}]` against a `['GET']` default is
|
|
234
|
+
* caught while `noProxy: []` accepts anything list-shaped. Closing that
|
|
235
|
+
* needs the feature model to declare an explicit option schema rather
|
|
236
|
+
* than only a default. */
|
|
237
|
+
export function checkfeatures(
|
|
238
|
+
merged: { [name: string]: any }, descriptor: any
|
|
239
|
+
): FeatureFault[] {
|
|
240
|
+
const faults: FeatureFault[] = []
|
|
241
|
+
const declared: any[] = descriptor?.features || []
|
|
242
|
+
const byname = new Map<string, any>()
|
|
243
|
+
for (const f of declared) { byname.set(String(f.name), f) }
|
|
244
|
+
|
|
245
|
+
for (const name of Object.keys(merged).sort()) {
|
|
246
|
+
const spec = byname.get(name)
|
|
247
|
+
if (null == spec) {
|
|
248
|
+
faults.push({
|
|
249
|
+
code: 'station_feature_unknown',
|
|
250
|
+
feature: name,
|
|
251
|
+
message: 'the SDK has no feature "' + name + '"; it declares [' +
|
|
252
|
+
Array.from(byname.keys()).sort().join(', ') + ']',
|
|
253
|
+
})
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const entry = merged[name]
|
|
258
|
+
if (!ismap(entry)) { continue }
|
|
259
|
+
const defaults = ismap(spec.options) ? spec.options : {}
|
|
260
|
+
|
|
261
|
+
for (const key of Object.keys(entry).sort()) {
|
|
262
|
+
if (-1 !== RESERVED_KEYS.indexOf(key)) { continue }
|
|
263
|
+
|
|
264
|
+
if (!(key in defaults)) {
|
|
265
|
+
// THE CASE THAT ACTUALLY BITES: `retry.retires: 5` is accepted
|
|
266
|
+
// and silently ignored today, because makeOptions' feature spec
|
|
267
|
+
// is `$OPEN` per feature so the SDK cannot catch it and nothing
|
|
268
|
+
// else looks.
|
|
269
|
+
faults.push({
|
|
270
|
+
code: 'station_feature_option',
|
|
271
|
+
feature: name,
|
|
272
|
+
key,
|
|
273
|
+
message: 'feature "' + name + '" declares no option "' + key +
|
|
274
|
+
'"; it declares [' + Object.keys(defaults).sort().join(', ') + ']',
|
|
275
|
+
})
|
|
276
|
+
continue
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const want = kindof(defaults[key])
|
|
280
|
+
const got = kindof(entry[key])
|
|
281
|
+
if (want !== got) {
|
|
282
|
+
faults.push({
|
|
283
|
+
code: 'station_feature_option',
|
|
284
|
+
feature: name,
|
|
285
|
+
key,
|
|
286
|
+
message: 'feature "' + name + '" option "' + key + '" expects ' +
|
|
287
|
+
want + ', but found ' + got + ': ' + JSON.stringify(entry[key]),
|
|
288
|
+
})
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return faults
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Compose the merged map into the ORDERED ARRAY FORM the constructor
|
|
297
|
+
* takes. No new seam: it is what `connect()` already does for station's
|
|
298
|
+
* own placement, with more in it. */
|
|
299
|
+
export function composefeatures(ordered: Ordered[]): any[] {
|
|
300
|
+
return ordered.map((o) => {
|
|
301
|
+
const entry = ismap(o.entry) ? o.entry : {}
|
|
302
|
+
const out: any = { name: o.name, active: true }
|
|
303
|
+
for (const k of Object.keys(entry)) {
|
|
304
|
+
if (-1 !== RESERVED_KEYS.indexOf(k)) { continue }
|
|
305
|
+
out[k] = entry[k]
|
|
306
|
+
}
|
|
307
|
+
return out
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function kindof(v: any): string {
|
|
312
|
+
if (null === v || undefined === v) { return 'null' }
|
|
313
|
+
if (Array.isArray(v)) { return 'list' }
|
|
314
|
+
if ('number' === typeof v) { return 'number' }
|
|
315
|
+
if ('object' === typeof v) { return 'map' }
|
|
316
|
+
return typeof v
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function ismap(v: any): boolean {
|
|
320
|
+
return null != v && 'object' === typeof v && !Array.isArray(v)
|
|
321
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// @voxgig/station - one control surface for outbound integrations.
|
|
2
|
+
//
|
|
3
|
+
// THE BROWSER ENTRY (§2.2, §17 Phase 1), selected by the `browser`
|
|
4
|
+
// condition in package.json `exports`. The same surface as `index.ts`
|
|
5
|
+
// MINUS the file-loading half - `loadConfig`, `findConfigFile` and
|
|
6
|
+
// `configScope` are node-only (`profile.ts`, the one module in this
|
|
7
|
+
// package with top-level `node:` imports) and are simply not here, so
|
|
8
|
+
// this entry's module graph pulls no `node:` builtin
|
|
9
|
+
// (`test/browser.test.ts` walks the compiled graph and asserts it).
|
|
10
|
+
//
|
|
11
|
+
// `Station.open()` on this entry takes `config` as an OBJECT - the
|
|
12
|
+
// pre-existing explicit-config path, which skips file loading entirely.
|
|
13
|
+
// With no `config` at all it behaves exactly as node does when no
|
|
14
|
+
// station.json exists: null config, repo-scoped. The ConfigFileIO seam
|
|
15
|
+
// stays unregistered here; a host that genuinely has a config store can
|
|
16
|
+
// register its own through `setConfigFileIO`.
|
|
17
|
+
//
|
|
18
|
+
// §2.2's honesty note stands: in the browser R1/R2 isolation does not
|
|
19
|
+
// exist - an injected credential is readable by page code and DevTools
|
|
20
|
+
// - so browser station is observability-only with app-held credentials
|
|
21
|
+
// until a same-origin proxy endpoint exists.
|
|
22
|
+
|
|
23
|
+
export { Station, instanceRef, setConfigFileIO } from './Station'
|
|
24
|
+
export type { ConfigFileIO } from './Station'
|
|
25
|
+
export { provide, factoryFor, provided, resetFactories } from './factory'
|
|
26
|
+
export type { Factory, FactoryEntry } from './factory'
|
|
27
|
+
export {
|
|
28
|
+
DEFAULT_EXPORT, camelify, checkPackage, factoryFromModule, loadAsync, loadSync,
|
|
29
|
+
} from './loader'
|
|
30
|
+
export { adapterFeature, featureBinding } from './adapter'
|
|
31
|
+
export type { FeatureBinding } from './adapter'
|
|
32
|
+
export { StationError } from './error'
|
|
33
|
+
export {
|
|
34
|
+
canonicalSerialize,
|
|
35
|
+
envtoken,
|
|
36
|
+
normalizeDescriptor,
|
|
37
|
+
secretnameDefault,
|
|
38
|
+
} from './descriptor'
|
|
39
|
+
export { placeholderFor } from './secrets'
|
|
40
|
+
export { refapi, resolveProfile, selectProfile } from './profilecore'
|
|
41
|
+
export type { ResolvedProfile } from './profilecore'
|
|
42
|
+
export {
|
|
43
|
+
BLOCK_DEFAULTS, MERGE_SENSITIVE, PROFILE_DEFAULTS,
|
|
44
|
+
configShape, normalizeConfig, validateConfig,
|
|
45
|
+
} from './shape'
|
|
46
|
+
export type {
|
|
47
|
+
Binding,
|
|
48
|
+
Descriptor,
|
|
49
|
+
PolicyBlock,
|
|
50
|
+
Profile,
|
|
51
|
+
ResolvedInstance,
|
|
52
|
+
SdkBlock,
|
|
53
|
+
StationConfig,
|
|
54
|
+
StationEvent,
|
|
55
|
+
StationOptions,
|
|
56
|
+
} from './types'
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// @voxgig/station - one control surface for outbound integrations.
|
|
2
|
+
//
|
|
3
|
+
// THE NODE ENTRY. `index.browser.ts` is the browser condition's entry
|
|
4
|
+
// (§2.2, §17 Phase 1): same surface MINUS the file-loading half
|
|
5
|
+
// (loadConfig/findConfigFile/configScope) and this registration, which
|
|
6
|
+
// is what hands Station the node-only file half without Station itself
|
|
7
|
+
// importing a `node:` module.
|
|
8
|
+
|
|
9
|
+
import { configScope, loadConfig } from './profile'
|
|
10
|
+
import { setConfigFileIO } from './Station'
|
|
11
|
+
|
|
12
|
+
setConfigFileIO({ loadConfig, configScope })
|
|
13
|
+
|
|
14
|
+
export { Station, instanceRef, setConfigFileIO } from './Station'
|
|
15
|
+
export type { ConfigFileIO } from './Station'
|
|
16
|
+
export { provide, factoryFor, provided, resetFactories } from './factory'
|
|
17
|
+
export type { Factory, FactoryEntry } from './factory'
|
|
18
|
+
export {
|
|
19
|
+
DEFAULT_EXPORT, camelify, checkPackage, factoryFromModule, loadAsync, loadSync,
|
|
20
|
+
} from './loader'
|
|
21
|
+
export { adapterFeature, featureBinding } from './adapter'
|
|
22
|
+
export type { FeatureBinding } from './adapter'
|
|
23
|
+
export { StationError } from './error'
|
|
24
|
+
export {
|
|
25
|
+
canonicalSerialize,
|
|
26
|
+
envtoken,
|
|
27
|
+
normalizeDescriptor,
|
|
28
|
+
secretnameDefault,
|
|
29
|
+
} from './descriptor'
|
|
30
|
+
export { placeholderFor } from './secrets'
|
|
31
|
+
export {
|
|
32
|
+
configScope, findConfigFile, loadConfig, refapi, resolveProfile,
|
|
33
|
+
selectProfile,
|
|
34
|
+
} from './profile'
|
|
35
|
+
export type { ResolvedProfile } from './profile'
|
|
36
|
+
export {
|
|
37
|
+
BLOCK_DEFAULTS, MERGE_SENSITIVE, PROFILE_DEFAULTS,
|
|
38
|
+
configShape, normalizeConfig, validateConfig,
|
|
39
|
+
} from './shape'
|
|
40
|
+
export type {
|
|
41
|
+
Binding,
|
|
42
|
+
Descriptor,
|
|
43
|
+
PolicyBlock,
|
|
44
|
+
Profile,
|
|
45
|
+
ResolvedInstance,
|
|
46
|
+
SdkBlock,
|
|
47
|
+
StationConfig,
|
|
48
|
+
StationEvent,
|
|
49
|
+
StationOptions,
|
|
50
|
+
} from './types'
|