@playfast/reform 1.0.1 → 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 +103 -56
- package/package.json +17 -17
- package/src/boundary/boundary.test.ts +63 -49
- package/src/boundary/boundary.ts +144 -113
- package/src/calc/asyncCalc.invalidate.test.ts +518 -32
- package/src/calc/asyncCalc.test.ts +207 -213
- package/src/calc/asyncCalc.ts +131 -154
- package/src/calc/asyncData.ts +0 -37
- package/src/calc/calc.test.ts +7 -33
- package/src/calc/calc.ts +44 -58
- package/src/calc/calcFamily.test.ts +80 -34
- package/src/calc/calcFamily.ts +54 -65
- package/src/calc/compose.test.ts +1 -12
- package/src/calc/compose.ts +1 -36
- package/src/calc/queryState.ts +0 -23
- package/src/channel/channel.ts +126 -111
- package/src/compose/composition.test.ts +19 -0
- package/src/compose/composition.ts +351 -127
- package/src/compose/host.ts +0 -6
- package/src/compose/props.ts +13 -12
- package/src/compose/provide.ts +189 -62
- package/src/compose/slot.ts +158 -49
- package/src/compose/structure.test.ts +6 -9
- package/src/compose/structure.ts +108 -79
- package/src/compose/ui.test.ts +19 -7
- package/src/compose/ui.ts +155 -156
- package/src/compose/ui.typecheck.ts +40 -18
- package/src/definition/definition.ts +22 -41
- package/src/event/event.fromSource.test.ts +172 -0
- package/src/event/event.test.ts +10 -3
- package/src/event/event.ts +102 -27
- package/src/event/eventGroup.ts +0 -1
- package/src/feature/feature.mount.test.ts +122 -43
- package/src/feature/feature.test.ts +46 -21
- package/src/feature/feature.ts +1347 -256
- package/src/feature/feature.typecheck.ts +273 -68
- package/src/graph/closure.ts +403 -0
- package/src/index.ts +68 -126
- package/src/internal/bucketCache.ts +39 -0
- package/src/internal/capture.ts +9 -24
- package/src/internal/env.ts +7 -0
- package/src/internal/errors.test.ts +0 -6
- package/src/internal/errors.ts +37 -60
- package/src/internal/inspect.test.ts +0 -6
- package/src/internal/inspect.ts +0 -12
- package/src/internal/queryDriver.ts +105 -201
- package/src/internal/queryDriverStore.ts +156 -0
- package/src/internal/queryDriverTypes.ts +59 -0
- package/src/internal/queryEvents.ts +0 -12
- package/src/internal/queryStore.ts +0 -14
- package/src/internal/reuse.test.ts +10 -14
- package/src/internal/reuse.ts +5 -30
- package/src/internal/scheduler.ts +4 -44
- package/src/internal/seeds.ts +0 -14
- package/src/internal/sources.ts +26 -49
- package/src/internal/stateRegistry.ts +0 -14
- package/src/internal/store.test.ts +1 -3
- package/src/internal/store.ts +0 -37
- package/src/internal/track.ts +3 -20
- package/src/internal/variance.ts +5 -0
- package/src/internal.ts +222 -0
- package/src/namespace/namespace.test.ts +46 -0
- package/src/namespace/namespace.ts +106 -0
- package/src/procedure/procedure.ts +40 -35
- package/src/reducer/reducer.ts +78 -52
- package/src/remote/remoteState.test.ts +590 -397
- package/src/remote/remoteState.ts +425 -347
- package/src/remote/remoteState.typecheck.ts +47 -56
- package/src/runtime/appRuntime.activation.test.ts +100 -0
- package/src/runtime/appRuntime.test.ts +249 -0
- package/src/runtime/appRuntime.ts +415 -97
- package/src/runtime/bus.ts +2 -15
- package/src/runtime/eventBudget.test.ts +152 -0
- package/src/runtime/eventBudget.ts +120 -0
- package/src/runtime/hardening.test.ts +73 -13
- package/src/runtime/instrumentation.test.ts +55 -52
- package/src/runtime/instrumentation.ts +6 -60
- package/src/runtime/loop.test.ts +36 -17
- package/src/runtime/loop.ts +87 -88
- package/src/runtime/queries.ts +0 -17
- package/src/scene/featureScene.test.ts +61 -0
- package/src/scene/scene.ts +247 -104
- package/src/scene/seedScene.test.ts +42 -79
- package/src/state/state.nominal.typecheck.ts +68 -0
- package/src/state/state.test.ts +17 -0
- package/src/state/state.ts +80 -46
- package/src/state/stateFamily.test.ts +16 -11
- package/src/state/stateFamily.ts +55 -65
- package/src/state/stateGroup.ts +53 -64
- package/src/state/token.ts +40 -23
- package/src/synced/syncedStore.ts +46 -58
- package/src/testkit/flight.testkit.ts +75 -0
- package/src/ui/node.ts +0 -6
- package/src/ui/trigger.ts +0 -5
- package/src/wire/tree.test.ts +8 -7
- package/src/wire/tree.ts +0 -39
- package/src/wire/triggers.test.ts +6 -6
- package/src/wire/triggers.ts +14 -32
- package/src/internal/ctx.ts +0 -16
|
@@ -1,140 +1,417 @@
|
|
|
1
|
-
import { Cause, Context, Effect, Exit, Layer, ManagedRuntime, Option, Scope } from 'effect'
|
|
1
|
+
import { Cause, Context, Effect, Exit, Fiber, Layer, ManagedRuntime, Option, Scope } from 'effect'
|
|
2
|
+
import type {
|
|
3
|
+
CompositionActivation,
|
|
4
|
+
CompositionId,
|
|
5
|
+
RequiredSlotIdentities,
|
|
6
|
+
} from '../compose/composition'
|
|
7
|
+
import type { UiContract } from '../compose/ui'
|
|
8
|
+
import {
|
|
9
|
+
type AnyFeatureBinding,
|
|
10
|
+
type CapturedFeatureBinding,
|
|
11
|
+
type FeatureBinding,
|
|
12
|
+
FeatureInput,
|
|
13
|
+
type FeatureRequirement,
|
|
14
|
+
} from '../feature/feature'
|
|
15
|
+
import type {
|
|
16
|
+
AnyFeatureGraphSummary,
|
|
17
|
+
FeatureSummaryCompositionIdentity,
|
|
18
|
+
FeatureSummaryContract,
|
|
19
|
+
FeatureSummaryInput,
|
|
20
|
+
FeatureSummaryMountProps,
|
|
21
|
+
FeatureSummaryName,
|
|
22
|
+
FeatureSummaryOpenServices,
|
|
23
|
+
FeatureSummaryUsesInput,
|
|
24
|
+
} from '../graph/closure'
|
|
2
25
|
import { FeatureLoadFailed, forceSync } from '../internal/errors'
|
|
3
|
-
import { type FeatureBinding, mountFeature } from '../feature/feature'
|
|
4
26
|
import { type Node } from '../ui/node'
|
|
5
|
-
import { type
|
|
6
|
-
import { publish, type Priority, type Tagged } from './bus'
|
|
27
|
+
import { type AnyScene, type CapturedScene, type Scene, sceneInstrumentation } from '../scene/scene'
|
|
28
|
+
import { Bus, publish, type Priority, type Tagged } from './bus'
|
|
29
|
+
import { CurrentEventBudget, type ReformOptions, resolveEventBudget } from './eventBudget'
|
|
7
30
|
import { type Instrumentation } from './instrumentation'
|
|
31
|
+
import { EngineRequirements, type EngineServices } from './loop'
|
|
8
32
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
33
|
+
export const UnspecifiedRuntimeServicesTypeId: unique symbol = Symbol.for(
|
|
34
|
+
'reform/UnspecifiedRuntimeServices',
|
|
35
|
+
)
|
|
36
|
+
export type UnspecifiedRuntimeServicesTypeId = typeof UnspecifiedRuntimeServicesTypeId
|
|
37
|
+
export interface UnspecifiedRuntimeServices {
|
|
38
|
+
readonly [UnspecifiedRuntimeServicesTypeId]: UnspecifiedRuntimeServicesTypeId
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type FeatureMountHandlersExternalApi<UsesInput extends boolean, Input, Services> = {
|
|
42
|
+
readonly onLive: (runtime: RuntimeHandle<Services>) => void
|
|
43
|
+
readonly onFailed: (error: FeatureLoadFailed) => void
|
|
44
|
+
} & (UsesInput extends true ? { readonly props: Input } : { readonly props?: never })
|
|
45
|
+
|
|
46
|
+
export type FeatureMountHandlers<
|
|
47
|
+
UsesInput extends boolean,
|
|
48
|
+
Input,
|
|
49
|
+
Services,
|
|
50
|
+
> = FeatureMountHandlersExternalApi<UsesInput, Input, Services>
|
|
14
51
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
52
|
+
export type RuntimeFeatureAvailability<Services, OpenServices> = [
|
|
53
|
+
EngineServices | OpenServices,
|
|
54
|
+
] extends [Services]
|
|
55
|
+
? object
|
|
56
|
+
: {
|
|
57
|
+
readonly missingFeatureServices: Exclude<EngineServices | OpenServices, Services>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface AnyFeatureMountHandlers {
|
|
61
|
+
readonly props: unknown
|
|
62
|
+
readonly onLive: <Services>(runtime: RuntimeHandle<Services>) => void
|
|
18
63
|
readonly onFailed: (error: FeatureLoadFailed) => void
|
|
19
64
|
}
|
|
20
65
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
66
|
+
export interface CapturedFeatureMountHandlers<
|
|
67
|
+
Props,
|
|
68
|
+
HostServices,
|
|
69
|
+
FeatureServices,
|
|
70
|
+
C extends UiContract,
|
|
71
|
+
N extends string,
|
|
72
|
+
Identity,
|
|
73
|
+
> {
|
|
74
|
+
readonly props: Props
|
|
75
|
+
readonly onLive: (
|
|
76
|
+
runtime: RuntimeHandle<
|
|
77
|
+
HostServices | FeatureServices | CompositionId<N, Identity, RequiredSlotIdentities<C>>
|
|
78
|
+
>,
|
|
79
|
+
) => void
|
|
80
|
+
readonly onFailed: (error: FeatureLoadFailed) => void
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const isObjectLike = (candidate: unknown): candidate is object =>
|
|
84
|
+
(typeof candidate === 'object' || typeof candidate === 'function') && candidate !== null
|
|
85
|
+
|
|
86
|
+
const isContextTag = (candidate: unknown): candidate is Context.Tag<unknown, unknown> =>
|
|
87
|
+
isObjectLike(candidate) &&
|
|
88
|
+
Context.TagTypeId in candidate &&
|
|
89
|
+
'key' in candidate &&
|
|
90
|
+
typeof candidate.key === 'string'
|
|
91
|
+
|
|
92
|
+
const isUnknownArray = (candidate: unknown): candidate is ReadonlyArray<unknown> =>
|
|
93
|
+
Array.isArray(candidate)
|
|
94
|
+
|
|
95
|
+
const requirementAvailable = <Services>(
|
|
96
|
+
context: Context.Context<Services>,
|
|
97
|
+
requirement: unknown,
|
|
98
|
+
): boolean => {
|
|
99
|
+
if (isContextTag(requirement)) {
|
|
100
|
+
return Option.isSome(Context.getOption(context, requirement))
|
|
101
|
+
}
|
|
102
|
+
if (!isObjectLike(requirement)) {
|
|
103
|
+
return false
|
|
104
|
+
}
|
|
105
|
+
if ('store' in requirement) {
|
|
106
|
+
return requirementAvailable(context, requirement.store)
|
|
107
|
+
}
|
|
108
|
+
if ('members' in requirement && isUnknownArray(requirement.members)) {
|
|
109
|
+
return requirement.members.every((member) => requirementAvailable(context, member))
|
|
110
|
+
}
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const hasFeatureRequirements = <
|
|
115
|
+
Services,
|
|
116
|
+
OpenServices,
|
|
117
|
+
const DirectRequires extends ReadonlyArray<FeatureRequirement>,
|
|
118
|
+
>(
|
|
119
|
+
context: Context.Context<Services>,
|
|
120
|
+
requires: DirectRequires,
|
|
121
|
+
): context is Context.Context<Services | EngineServices | OpenServices> =>
|
|
122
|
+
EngineRequirements.every((tag) => requirementAvailable(context, tag)) &&
|
|
123
|
+
requires.every((requirement) => requirementAvailable(context, requirement))
|
|
124
|
+
|
|
125
|
+
const featureMountFailure = (message: string): FeatureLoadFailed =>
|
|
126
|
+
new FeatureLoadFailed({ cause: message })
|
|
127
|
+
|
|
128
|
+
export interface RuntimeHandle<Services = UnspecifiedRuntimeServices> {
|
|
129
|
+
read<Identifier extends Services, Service>(tag: Context.Tag<Identifier, Service>): Service
|
|
130
|
+
readOption<Identifier, Service>(tag: Context.Tag<Identifier, Service>): Option.Option<Service>
|
|
34
131
|
runRender(effect: Effect.Effect<Node, never, never>): Node
|
|
35
|
-
/** Dispatch a runtime/boot event onto the bus. */
|
|
36
132
|
dispatch(priority: Priority, event: Tagged): void
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
133
|
+
mountScoped(activation: CompositionActivation): () => void
|
|
134
|
+
mountFeature<
|
|
135
|
+
Summary extends AnyFeatureGraphSummary,
|
|
136
|
+
ROut,
|
|
137
|
+
const DirectRequires extends ReadonlyArray<FeatureRequirement>,
|
|
138
|
+
>(
|
|
139
|
+
binding: [EngineServices | FeatureSummaryOpenServices<Summary>] extends [Services]
|
|
140
|
+
? FeatureBinding<Summary, ROut, DirectRequires>
|
|
141
|
+
: never,
|
|
142
|
+
handlers: FeatureMountHandlers<
|
|
143
|
+
FeatureSummaryUsesInput<Summary>,
|
|
144
|
+
FeatureSummaryInput<Summary>,
|
|
145
|
+
Services | ROut
|
|
146
|
+
>,
|
|
147
|
+
): () => void
|
|
148
|
+
mountCapturedFeature<
|
|
149
|
+
Summary extends AnyFeatureGraphSummary,
|
|
150
|
+
ROut,
|
|
151
|
+
const DirectRequires extends ReadonlyArray<FeatureRequirement>,
|
|
152
|
+
>(
|
|
153
|
+
binding: CapturedFeatureBinding<Summary, ROut, DirectRequires> &
|
|
154
|
+
RuntimeFeatureAvailability<Services, FeatureSummaryOpenServices<Summary>>,
|
|
155
|
+
handlers: CapturedFeatureMountHandlers<
|
|
156
|
+
FeatureSummaryMountProps<Summary>,
|
|
157
|
+
Services,
|
|
158
|
+
ROut,
|
|
159
|
+
FeatureSummaryContract<Summary>,
|
|
160
|
+
FeatureSummaryName<Summary>,
|
|
161
|
+
FeatureSummaryCompositionIdentity<Summary>
|
|
162
|
+
>,
|
|
163
|
+
): () => void
|
|
164
|
+
mountAnyFeature(binding: AnyFeatureBinding, handlers: AnyFeatureMountHandlers): () => void
|
|
52
165
|
readonly instrumentation: Instrumentation
|
|
53
166
|
}
|
|
54
167
|
|
|
55
|
-
|
|
56
|
-
export interface AppRuntime extends RuntimeHandle {
|
|
57
|
-
/** Dispatch the scene's boot events, once — later calls are no-ops. */
|
|
168
|
+
export interface AppRuntime<Services = UnspecifiedRuntimeServices> extends RuntimeHandle<Services> {
|
|
58
169
|
boot(): void
|
|
59
|
-
/** Interrupt the runtime and latch the handle inert. Immediate and idempotent. */
|
|
60
170
|
dispose(): void
|
|
61
|
-
/** True once disposed, so a host can rebuild rather than reuse a dead runtime. */
|
|
62
171
|
isDisposed(): boolean
|
|
63
172
|
}
|
|
64
173
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
174
|
+
const makeCapturedAppRuntime = <
|
|
175
|
+
C extends UiContract,
|
|
176
|
+
S extends ReadonlyArray<unknown>,
|
|
177
|
+
Services,
|
|
178
|
+
P,
|
|
179
|
+
N extends string,
|
|
180
|
+
Identity,
|
|
181
|
+
>(
|
|
182
|
+
scene: CapturedScene<C, S, Services, P, N, Identity>,
|
|
183
|
+
options: ReformOptions = {},
|
|
184
|
+
): AppRuntime<Services | CompositionId<N, Identity, RequiredSlotIdentities<C>>> => {
|
|
71
185
|
const instrumentation = sceneInstrumentation(scene)
|
|
72
|
-
|
|
73
|
-
const layer =
|
|
186
|
+
const provide = scene.provide.map(Layer.locally(CurrentEventBudget, resolveEventBudget(options)))
|
|
187
|
+
const layer = provide.reduce((accLayer, nextLayer) => Layer.merge(accLayer, nextLayer))
|
|
74
188
|
const runtime = ManagedRuntime.make(layer)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
//
|
|
84
|
-
// of throwing into a render. A `const` latch mutated in place.
|
|
189
|
+
const capturedContext = forceSync(() => runtime.runSync(Effect.context<Services>()))
|
|
190
|
+
const rootCompositionService = Option.getOrThrow(
|
|
191
|
+
Context.getOption(capturedContext, scene.composition.tag),
|
|
192
|
+
)
|
|
193
|
+
// Re-add the exact tag so the captured context retains the root's nominal identity.
|
|
194
|
+
const rootContext = Context.add(capturedContext, scene.composition.tag, rootCompositionService)
|
|
195
|
+
const rootBus = Context.getOption(rootContext, Bus)
|
|
196
|
+
|
|
197
|
+
// Hosts can still render tearing-down trees after the managed runtime is disposed.
|
|
85
198
|
const status = { disposed: false, booted: false }
|
|
199
|
+
const activationDisposers = new Set<() => void>()
|
|
86
200
|
|
|
87
|
-
// A render
|
|
88
|
-
// so once the managed runtime is gone it still runs on the default runtime —
|
|
89
|
-
// yielding a real node from the current store values, never a throw mid-render.
|
|
201
|
+
// A closed render Effect remains safe on the default runtime after disposal.
|
|
90
202
|
const runRender = (effect: Effect.Effect<Node, never, never>): Node =>
|
|
91
203
|
status.disposed ? Effect.runSync(effect) : runtime.runSync(effect)
|
|
92
204
|
|
|
93
|
-
// A dispatch past dispose has no live bus to reach; drop it.
|
|
94
205
|
const dispatch = (priority: Priority, event: Tagged): void => {
|
|
95
|
-
if (status.disposed) {
|
|
206
|
+
if (status.disposed || Option.isNone(rootBus)) {
|
|
96
207
|
return
|
|
97
208
|
}
|
|
98
|
-
runtime.runFork(publish(priority, event))
|
|
209
|
+
runtime.runFork(publish(priority, event).pipe(Effect.provideService(Bus, rootBus.value)))
|
|
99
210
|
}
|
|
100
211
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
212
|
+
const mountScoped = (activation: CompositionActivation): (() => void) => {
|
|
213
|
+
if (status.disposed) {
|
|
214
|
+
return () => {}
|
|
215
|
+
}
|
|
216
|
+
const scope = Effect.runSync(Scope.make())
|
|
217
|
+
const liveness = { active: true }
|
|
218
|
+
const fiber = runtime.runFork(
|
|
219
|
+
Layer.build(activation).pipe(Effect.provideService(Scope.Scope, scope)),
|
|
220
|
+
)
|
|
221
|
+
const dispose = (): void => {
|
|
222
|
+
if (!liveness.active) {
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
liveness.active = false
|
|
226
|
+
fiber.unsafeInterruptAsFork(fiber.id())
|
|
227
|
+
const close = Fiber.await(fiber).pipe(
|
|
228
|
+
Effect.zipRight(Scope.close(scope, Exit.succeed(undefined))),
|
|
229
|
+
Effect.ensuring(Effect.sync(() => activationDisposers.delete(dispose))),
|
|
230
|
+
)
|
|
231
|
+
Effect.runFork(close)
|
|
232
|
+
}
|
|
233
|
+
activationDisposers.add(dispose)
|
|
234
|
+
return dispose
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const makeChildRuntime = <ChildServices>(
|
|
238
|
+
context: Context.Context<ChildServices>,
|
|
239
|
+
): RuntimeHandle<ChildServices> => ({
|
|
240
|
+
read: <Identifier extends ChildServices, Service>(
|
|
241
|
+
tag: Context.Tag<Identifier, Service>,
|
|
242
|
+
): Service => Context.get(context, tag),
|
|
243
|
+
readOption: <Identifier, Service>(
|
|
244
|
+
tag: Context.Tag<Identifier, Service>,
|
|
245
|
+
): Option.Option<Service> => Context.getOption(context, tag),
|
|
106
246
|
runRender,
|
|
107
247
|
dispatch,
|
|
108
|
-
|
|
248
|
+
mountScoped,
|
|
249
|
+
mountFeature: (binding, handlers) => mountFeatureOnto(context, binding, handlers),
|
|
250
|
+
mountCapturedFeature: (binding, handlers) =>
|
|
251
|
+
mountCapturedFeatureOnto(context, binding, handlers),
|
|
252
|
+
mountAnyFeature: (binding, handlers) => mountAnyFeatureOnto(context, binding, handlers),
|
|
109
253
|
instrumentation,
|
|
110
254
|
})
|
|
111
255
|
|
|
112
|
-
const mountFeatureOnto =
|
|
113
|
-
|
|
114
|
-
|
|
256
|
+
const mountFeatureOnto = <
|
|
257
|
+
HostServices,
|
|
258
|
+
Summary extends AnyFeatureGraphSummary,
|
|
259
|
+
ROut,
|
|
260
|
+
const DirectRequires extends ReadonlyArray<FeatureRequirement>,
|
|
261
|
+
>(
|
|
262
|
+
hostContext: Context.Context<HostServices>,
|
|
263
|
+
binding: FeatureBinding<Summary, ROut, DirectRequires>,
|
|
264
|
+
handlers: FeatureMountHandlers<
|
|
265
|
+
FeatureSummaryUsesInput<Summary>,
|
|
266
|
+
FeatureSummaryInput<Summary>,
|
|
267
|
+
HostServices | ROut
|
|
268
|
+
>,
|
|
269
|
+
): (() => void) => {
|
|
270
|
+
if (status.disposed) {
|
|
271
|
+
return () => {}
|
|
272
|
+
}
|
|
273
|
+
const scope = Effect.runSync(Scope.make())
|
|
274
|
+
const mount = Effect.flatMap(binding.load, (loaded) => {
|
|
275
|
+
if (
|
|
276
|
+
!hasFeatureRequirements<HostServices, FeatureSummaryOpenServices<Summary>, DirectRequires>(
|
|
277
|
+
hostContext,
|
|
278
|
+
loaded.requires,
|
|
279
|
+
)
|
|
280
|
+
) {
|
|
281
|
+
return Effect.fail(
|
|
282
|
+
featureMountFailure(
|
|
283
|
+
`Feature ${binding.manifest.name} requires services missing from the host runtime`,
|
|
284
|
+
),
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
return Effect.gen(function* () {
|
|
288
|
+
const bus = Context.get(hostContext, Bus)
|
|
289
|
+
const featureContext = Context.add(hostContext, FeatureInput, handlers.props)
|
|
290
|
+
const context = yield* Layer.build(
|
|
291
|
+
Layer.provide(loaded.layer, Layer.succeedContext(featureContext)),
|
|
292
|
+
)
|
|
293
|
+
yield* Effect.forEach(binding.boot, (event) => publish('High', event), {
|
|
294
|
+
discard: true,
|
|
295
|
+
}).pipe(Effect.provideService(Bus, bus))
|
|
296
|
+
const merged = Context.merge(hostContext, context)
|
|
297
|
+
yield* Effect.sync(() => handlers.onLive(makeChildRuntime<HostServices | ROut>(merged)))
|
|
298
|
+
})
|
|
299
|
+
})
|
|
300
|
+
const fiber = runtime.runFork(
|
|
301
|
+
mount.pipe(
|
|
302
|
+
Effect.provideService(Scope.Scope, scope),
|
|
303
|
+
Effect.matchCause({
|
|
304
|
+
onFailure: (cause) => {
|
|
305
|
+
// Disposer interruption is cancellation, not a failed feature.
|
|
306
|
+
if (!Cause.isInterruptedOnly(cause)) {
|
|
307
|
+
handlers.onFailed(
|
|
308
|
+
Option.getOrElse(
|
|
309
|
+
Cause.failureOption(cause),
|
|
310
|
+
() => new FeatureLoadFailed({ cause }),
|
|
311
|
+
),
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
},
|
|
315
|
+
onSuccess: () => undefined,
|
|
316
|
+
}),
|
|
317
|
+
),
|
|
318
|
+
)
|
|
319
|
+
return () => {
|
|
320
|
+
// Interrupt first, then await finalizer registration before closing the scope.
|
|
321
|
+
fiber.unsafeInterruptAsFork(fiber.id())
|
|
322
|
+
const close = Fiber.await(fiber).pipe(
|
|
323
|
+
Effect.zipRight(Scope.close(scope, Exit.succeed(undefined))),
|
|
324
|
+
)
|
|
325
|
+
if (status.disposed) {
|
|
326
|
+
Effect.runFork(close)
|
|
327
|
+
} else {
|
|
328
|
+
runtime.runFork(close)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const mountCapturedFeatureOnto = <
|
|
334
|
+
HostServices,
|
|
335
|
+
Summary extends AnyFeatureGraphSummary,
|
|
336
|
+
ROut,
|
|
337
|
+
const DirectRequires extends ReadonlyArray<FeatureRequirement>,
|
|
338
|
+
>(
|
|
339
|
+
hostContext: Context.Context<HostServices>,
|
|
340
|
+
binding: CapturedFeatureBinding<Summary, ROut, DirectRequires>,
|
|
341
|
+
handlers: CapturedFeatureMountHandlers<
|
|
342
|
+
FeatureSummaryMountProps<Summary>,
|
|
343
|
+
HostServices,
|
|
344
|
+
ROut,
|
|
345
|
+
FeatureSummaryContract<Summary>,
|
|
346
|
+
FeatureSummaryName<Summary>,
|
|
347
|
+
FeatureSummaryCompositionIdentity<Summary>
|
|
348
|
+
>,
|
|
115
349
|
): (() => void) => {
|
|
116
|
-
// Don't mount onto a runtime that's already torn down — the tree is unmounting.
|
|
117
350
|
if (status.disposed) {
|
|
118
351
|
return () => {}
|
|
119
352
|
}
|
|
120
353
|
const scope = Effect.runSync(Scope.make())
|
|
121
|
-
|
|
122
|
-
|
|
354
|
+
const mount = Effect.flatMap(binding.load, (loaded) => {
|
|
355
|
+
if (
|
|
356
|
+
!hasFeatureRequirements<HostServices, FeatureSummaryOpenServices<Summary>, DirectRequires>(
|
|
357
|
+
hostContext,
|
|
358
|
+
loaded.requires,
|
|
359
|
+
)
|
|
360
|
+
) {
|
|
361
|
+
return Effect.fail(
|
|
362
|
+
featureMountFailure(
|
|
363
|
+
`Feature ${binding.manifest.name} requires services missing from the host runtime`,
|
|
364
|
+
),
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
const input = loaded.usesInput ? binding.parseInput(handlers.props) : Option.some(undefined)
|
|
368
|
+
return Option.match(input, {
|
|
369
|
+
onNone: () =>
|
|
370
|
+
Effect.fail(
|
|
371
|
+
featureMountFailure(`Feature ${binding.manifest.name} received invalid props`),
|
|
372
|
+
),
|
|
373
|
+
onSome: (props) =>
|
|
374
|
+
Effect.gen(function* () {
|
|
375
|
+
const bus = Context.get(hostContext, Bus)
|
|
376
|
+
const featureContext = Context.add(hostContext, FeatureInput, props)
|
|
377
|
+
const context = yield* Layer.build(
|
|
378
|
+
Layer.provide(loaded.layer, Layer.succeedContext(featureContext)),
|
|
379
|
+
)
|
|
380
|
+
yield* Effect.forEach(binding.boot, (event) => publish('High', event), {
|
|
381
|
+
discard: true,
|
|
382
|
+
}).pipe(Effect.provideService(Bus, bus))
|
|
383
|
+
const merged = Context.merge(hostContext, context)
|
|
384
|
+
const compositionService = Option.getOrThrow(
|
|
385
|
+
Context.getOption(merged, binding.composition.tag),
|
|
386
|
+
)
|
|
387
|
+
const rooted = Context.add(merged, binding.composition.tag, compositionService)
|
|
388
|
+
yield* Effect.sync(() => handlers.onLive(makeChildRuntime(rooted)))
|
|
389
|
+
}),
|
|
390
|
+
})
|
|
391
|
+
})
|
|
392
|
+
const fiber = runtime.runFork(
|
|
393
|
+
mount.pipe(
|
|
123
394
|
Effect.provideService(Scope.Scope, scope),
|
|
124
395
|
Effect.matchCause({
|
|
125
|
-
onFailure: (cause) =>
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
396
|
+
onFailure: (cause) => {
|
|
397
|
+
if (!Cause.isInterruptedOnly(cause)) {
|
|
398
|
+
handlers.onFailed(
|
|
399
|
+
Option.getOrElse(
|
|
400
|
+
Cause.failureOption(cause),
|
|
401
|
+
() => new FeatureLoadFailed({ cause }),
|
|
402
|
+
),
|
|
403
|
+
)
|
|
404
|
+
}
|
|
405
|
+
},
|
|
406
|
+
onSuccess: () => undefined,
|
|
130
407
|
}),
|
|
131
408
|
),
|
|
132
409
|
)
|
|
133
410
|
return () => {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
411
|
+
fiber.unsafeInterruptAsFork(fiber.id())
|
|
412
|
+
const close = Fiber.await(fiber).pipe(
|
|
413
|
+
Effect.zipRight(Scope.close(scope, Exit.succeed(undefined))),
|
|
414
|
+
)
|
|
138
415
|
if (status.disposed) {
|
|
139
416
|
Effect.runFork(close)
|
|
140
417
|
} else {
|
|
@@ -143,14 +420,35 @@ export const makeAppRuntime = (scene: Scene): AppRuntime => {
|
|
|
143
420
|
}
|
|
144
421
|
}
|
|
145
422
|
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
423
|
+
const mountAnyFeatureOnto = <HostServices>(
|
|
424
|
+
hostContext: Context.Context<HostServices>,
|
|
425
|
+
binding: AnyFeatureBinding,
|
|
426
|
+
handlers: AnyFeatureMountHandlers,
|
|
427
|
+
): (() => void) =>
|
|
428
|
+
binding.capture((exactBinding) =>
|
|
429
|
+
mountCapturedFeatureOnto(hostContext, exactBinding, {
|
|
430
|
+
props: handlers.props,
|
|
431
|
+
onLive: (childRuntime) => handlers.onLive(childRuntime),
|
|
432
|
+
onFailed: handlers.onFailed,
|
|
433
|
+
}),
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
type RootServices = Services | CompositionId<N, Identity, RequiredSlotIdentities<C>>
|
|
437
|
+
const app: AppRuntime<RootServices> = {
|
|
438
|
+
// Captured reads stay available while the managed runtime is tearing down.
|
|
439
|
+
read: <Identifier extends RootServices, Service>(
|
|
440
|
+
tag: Context.Tag<Identifier, Service>,
|
|
441
|
+
): Service => Context.get(rootContext, tag),
|
|
442
|
+
readOption: <Identifier, Service>(
|
|
443
|
+
tag: Context.Tag<Identifier, Service>,
|
|
444
|
+
): Option.Option<Service> => Context.getOption(rootContext, tag),
|
|
151
445
|
runRender,
|
|
152
446
|
dispatch,
|
|
153
|
-
|
|
447
|
+
mountScoped,
|
|
448
|
+
mountFeature: (binding, handlers) => mountFeatureOnto(rootContext, binding, handlers),
|
|
449
|
+
mountCapturedFeature: (binding, handlers) =>
|
|
450
|
+
mountCapturedFeatureOnto(rootContext, binding, handlers),
|
|
451
|
+
mountAnyFeature: (binding, handlers) => mountAnyFeatureOnto(rootContext, binding, handlers),
|
|
154
452
|
boot: () => {
|
|
155
453
|
if (status.disposed || status.booted) {
|
|
156
454
|
return
|
|
@@ -165,6 +463,7 @@ export const makeAppRuntime = (scene: Scene): AppRuntime => {
|
|
|
165
463
|
return
|
|
166
464
|
}
|
|
167
465
|
status.disposed = true
|
|
466
|
+
activationDisposers.forEach((dispose) => dispose())
|
|
168
467
|
void runtime.dispose()
|
|
169
468
|
},
|
|
170
469
|
isDisposed: () => status.disposed,
|
|
@@ -172,3 +471,22 @@ export const makeAppRuntime = (scene: Scene): AppRuntime => {
|
|
|
172
471
|
}
|
|
173
472
|
return app
|
|
174
473
|
}
|
|
474
|
+
|
|
475
|
+
export function makeAppRuntime<
|
|
476
|
+
C extends UiContract,
|
|
477
|
+
S extends ReadonlyArray<unknown>,
|
|
478
|
+
Services,
|
|
479
|
+
P,
|
|
480
|
+
N extends string,
|
|
481
|
+
Identity,
|
|
482
|
+
>(
|
|
483
|
+
scene: CapturedScene<C, S, Services, P, N, Identity>,
|
|
484
|
+
options?: ReformOptions,
|
|
485
|
+
): AppRuntime<Services | CompositionId<N, Identity, RequiredSlotIdentities<C>>>
|
|
486
|
+
export function makeAppRuntime<C extends UiContract, S extends ReadonlyArray<unknown>>(
|
|
487
|
+
scene: Scene<C, S>,
|
|
488
|
+
options?: ReformOptions,
|
|
489
|
+
): AppRuntime<UnspecifiedRuntimeServices>
|
|
490
|
+
export function makeAppRuntime(scene: AnyScene, options: ReformOptions = {}): unknown {
|
|
491
|
+
return scene.captureAny<unknown>((exactScene) => makeCapturedAppRuntime(exactScene, options))
|
|
492
|
+
}
|
package/src/runtime/bus.ts
CHANGED
|
@@ -1,22 +1,14 @@
|
|
|
1
1
|
import { Context, Effect, Layer, PubSub } from 'effect'
|
|
2
2
|
|
|
3
|
-
/** A dispatched event: a tagged data record (`{ _tag, ...payload }`). */
|
|
4
3
|
export interface Tagged {
|
|
5
4
|
readonly _tag: string
|
|
6
5
|
}
|
|
7
6
|
|
|
8
|
-
|
|
9
|
-
* Re-narrow an erased `Tagged` envelope to a handler's declared event union. The
|
|
10
|
-
* loop and channels only route events whose tag is in a handler's `handles`, so
|
|
11
|
-
* at runtime the envelope IS one of those events; the registries store it as the
|
|
12
|
-
* generic `Tagged`, and this restores the typed view. The one documented home
|
|
13
|
-
* for that boundary cast, shared by `Reducer.live` and `Procedure.live`.
|
|
14
|
-
*/
|
|
7
|
+
// Re-narrow routed Tagged to handler event union (handles set guarantees membership)
|
|
15
8
|
export const narrowHandled = <E>(event: Tagged): E =>
|
|
16
9
|
// oxlint-disable-next-line reform-rules/no-type-assertion -- documented runtime re-narrow of a routed Tagged envelope to its handler's declared event union (see jsdoc above)
|
|
17
10
|
event as never
|
|
18
11
|
|
|
19
|
-
/** UI-originated events are High; procedure-emitted follow-ups are Normal (DESIGN #46). */
|
|
20
12
|
export type Priority = 'High' | 'Normal'
|
|
21
13
|
|
|
22
14
|
export interface Envelope {
|
|
@@ -28,14 +20,9 @@ const BusBase: Context.TagClass<Bus, 'reform/Bus', PubSub.PubSub<Envelope>> = Co
|
|
|
28
20
|
'reform/Bus',
|
|
29
21
|
)<Bus, PubSub.PubSub<Envelope>>()
|
|
30
22
|
|
|
31
|
-
/** The single dispatch bus: an unbounded PubSub fanned out to the loop and procedures. */
|
|
32
23
|
export class Bus extends BusBase {}
|
|
33
24
|
|
|
34
|
-
// Unbounded so
|
|
35
|
-
// (`Runtime.runSync`, no fiber fork per event), and an event storm can't apply
|
|
36
|
-
// backpressure to the UI thread. The single drain loop empties the bus every
|
|
37
|
-
// microtask, so it does not accumulate. Swap to `PubSub.dropping(N)` if bounded
|
|
38
|
-
// memory is preferred over never dropping (publish stays synchronous either way).
|
|
25
|
+
// Unbounded so publish never suspends (UI runSync); drain empties every microtask
|
|
39
26
|
export const busLayer: Layer.Layer<Bus> = Layer.scoped(Bus, PubSub.unbounded<Envelope>())
|
|
40
27
|
|
|
41
28
|
export const publish = (priority: Priority, event: Tagged): Effect.Effect<void, never, Bus> =>
|