@playfast/reform 1.1.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 +85 -55
- package/package.json +17 -17
- package/src/boundary/boundary.test.ts +62 -16
- package/src/boundary/boundary.ts +141 -32
- package/src/calc/asyncCalc.invalidate.test.ts +516 -18
- package/src/calc/asyncCalc.test.ts +207 -175
- package/src/calc/asyncCalc.ts +168 -39
- package/src/calc/calc.test.ts +3 -5
- package/src/calc/calc.ts +44 -18
- package/src/calc/calcFamily.test.ts +80 -22
- package/src/calc/calcFamily.ts +56 -25
- package/src/calc/compose.ts +1 -14
- package/src/channel/channel.ts +128 -11
- package/src/compose/composition.test.ts +19 -0
- package/src/compose/composition.ts +346 -62
- package/src/compose/props.ts +13 -6
- package/src/compose/provide.ts +187 -46
- package/src/compose/slot.ts +156 -19
- package/src/compose/structure.test.ts +6 -3
- package/src/compose/structure.ts +106 -13
- package/src/compose/ui.test.ts +19 -3
- package/src/compose/ui.ts +147 -54
- package/src/compose/ui.typecheck.ts +40 -4
- package/src/definition/definition.ts +22 -9
- package/src/event/event.fromSource.test.ts +172 -0
- package/src/event/event.test.ts +10 -0
- package/src/event/event.ts +102 -18
- package/src/feature/feature.mount.test.ts +123 -56
- package/src/feature/feature.test.ts +67 -63
- package/src/feature/feature.ts +1317 -197
- package/src/feature/feature.typecheck.ts +253 -61
- package/src/graph/closure.ts +403 -0
- package/src/index.ts +171 -245
- package/src/internal/bucketCache.ts +39 -0
- package/src/internal/capture.ts +9 -4
- package/src/internal/env.ts +1 -3
- package/src/internal/errors.ts +21 -10
- package/src/internal/queryDriver.ts +120 -15
- package/src/internal/queryDriverStore.ts +8 -5
- package/src/internal/queryDriverTypes.ts +3 -1
- package/src/internal/reuse.test.ts +10 -3
- package/src/internal/reuse.ts +5 -2
- package/src/internal/scheduler.ts +4 -1
- package/src/internal/sources.ts +25 -11
- package/src/internal/track.ts +3 -2
- package/src/internal.ts +222 -0
- package/src/namespace/namespace.ts +46 -25
- package/src/procedure/procedure.ts +38 -11
- package/src/reducer/reducer.ts +78 -34
- package/src/remote/remoteState.test.ts +515 -339
- package/src/remote/remoteState.ts +572 -107
- package/src/remote/remoteState.typecheck.ts +47 -23
- package/src/runtime/appRuntime.activation.test.ts +100 -0
- package/src/runtime/appRuntime.test.ts +157 -105
- package/src/runtime/appRuntime.ts +394 -33
- package/src/runtime/eventBudget.test.ts +1 -4
- package/src/runtime/eventBudget.ts +9 -8
- package/src/runtime/hardening.test.ts +4 -9
- package/src/runtime/instrumentation.test.ts +174 -192
- package/src/runtime/instrumentation.ts +6 -11
- package/src/runtime/loop.test.ts +36 -0
- package/src/runtime/loop.ts +69 -40
- package/src/scene/featureScene.test.ts +4 -2
- package/src/scene/scene.ts +234 -64
- package/src/scene/seedScene.test.ts +69 -86
- package/src/state/state.nominal.typecheck.ts +68 -0
- package/src/state/state.test.ts +17 -0
- package/src/state/state.ts +79 -26
- package/src/state/stateFamily.test.ts +14 -0
- package/src/state/stateFamily.ts +55 -22
- package/src/state/stateGroup.ts +53 -17
- package/src/state/token.ts +40 -10
- package/src/synced/syncedStore.ts +46 -23
- package/src/testkit/flight.testkit.ts +75 -0
- package/src/wire/tree.test.ts +8 -4
- package/src/wire/triggers.test.ts +6 -2
- package/src/wire/triggers.ts +14 -10
- package/src/calc/asyncCalcDefinitions.ts +0 -48
- package/src/channel/procedureRegistry.ts +0 -90
- package/src/feature/featureBinding.ts +0 -48
- package/src/internal/ctx.ts +0 -9
- package/src/remote/remoteStateDefinition.ts +0 -135
- package/src/remote/remoteStateLayers.ts +0 -118
- package/src/remote/remoteStateLiveTypes.ts +0 -59
- package/src/remote/remoteStateSend.ts +0 -64
|
@@ -1,78 +1,308 @@
|
|
|
1
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'
|
|
7
29
|
import { CurrentEventBudget, type ReformOptions, resolveEventBudget } from './eventBudget'
|
|
8
30
|
import { type Instrumentation } from './instrumentation'
|
|
31
|
+
import { EngineRequirements, type EngineServices } from './loop'
|
|
9
32
|
|
|
10
|
-
export
|
|
11
|
-
|
|
12
|
-
|
|
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>
|
|
51
|
+
|
|
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
|
|
63
|
+
readonly onFailed: (error: FeatureLoadFailed) => void
|
|
64
|
+
}
|
|
65
|
+
|
|
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
|
|
13
80
|
readonly onFailed: (error: FeatureLoadFailed) => void
|
|
14
81
|
}
|
|
15
|
-
export type FeatureMountHandlers = FeatureMountHandlersExternalApi
|
|
16
82
|
|
|
17
|
-
|
|
18
|
-
|
|
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>
|
|
19
131
|
runRender(effect: Effect.Effect<Node, never, never>): Node
|
|
20
132
|
dispatch(priority: Priority, event: Tagged): void
|
|
21
|
-
|
|
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
|
|
22
165
|
readonly instrumentation: Instrumentation
|
|
23
166
|
}
|
|
24
167
|
|
|
25
|
-
export interface AppRuntime extends RuntimeHandle {
|
|
168
|
+
export interface AppRuntime<Services = UnspecifiedRuntimeServices> extends RuntimeHandle<Services> {
|
|
26
169
|
boot(): void
|
|
27
170
|
dispose(): void
|
|
28
171
|
isDisposed(): boolean
|
|
29
172
|
}
|
|
30
173
|
|
|
31
|
-
|
|
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>>> => {
|
|
32
185
|
const instrumentation = sceneInstrumentation(scene)
|
|
33
186
|
const provide = scene.provide.map(Layer.locally(CurrentEventBudget, resolveEventBudget(options)))
|
|
34
187
|
const layer = provide.reduce((accLayer, nextLayer) => Layer.merge(accLayer, nextLayer))
|
|
35
188
|
const runtime = ManagedRuntime.make(layer)
|
|
36
|
-
const
|
|
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)
|
|
37
196
|
|
|
38
|
-
//
|
|
197
|
+
// Hosts can still render tearing-down trees after the managed runtime is disposed.
|
|
39
198
|
const status = { disposed: false, booted: false }
|
|
199
|
+
const activationDisposers = new Set<() => void>()
|
|
40
200
|
|
|
41
|
-
//
|
|
201
|
+
// A closed render Effect remains safe on the default runtime after disposal.
|
|
42
202
|
const runRender = (effect: Effect.Effect<Node, never, never>): Node =>
|
|
43
203
|
status.disposed ? Effect.runSync(effect) : runtime.runSync(effect)
|
|
44
204
|
|
|
45
205
|
const dispatch = (priority: Priority, event: Tagged): void => {
|
|
46
|
-
if (status.disposed) {
|
|
206
|
+
if (status.disposed || Option.isNone(rootBus)) {
|
|
47
207
|
return
|
|
48
208
|
}
|
|
49
|
-
runtime.runFork(publish(priority, event))
|
|
209
|
+
runtime.runFork(publish(priority, event).pipe(Effect.provideService(Bus, rootBus.value)))
|
|
50
210
|
}
|
|
51
211
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
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),
|
|
55
246
|
runRender,
|
|
56
247
|
dispatch,
|
|
248
|
+
mountScoped,
|
|
57
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),
|
|
58
253
|
instrumentation,
|
|
59
254
|
})
|
|
60
255
|
|
|
61
|
-
const mountFeatureOnto = <
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
>,
|
|
65
269
|
): (() => void) => {
|
|
66
270
|
if (status.disposed) {
|
|
67
271
|
return () => {}
|
|
68
272
|
}
|
|
69
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
|
+
})
|
|
70
300
|
const fiber = runtime.runFork(
|
|
71
|
-
|
|
301
|
+
mount.pipe(
|
|
72
302
|
Effect.provideService(Scope.Scope, scope),
|
|
73
303
|
Effect.matchCause({
|
|
74
304
|
onFailure: (cause) => {
|
|
75
|
-
// Disposer
|
|
305
|
+
// Disposer interruption is cancellation, not a failed feature.
|
|
76
306
|
if (!Cause.isInterruptedOnly(cause)) {
|
|
77
307
|
handlers.onFailed(
|
|
78
308
|
Option.getOrElse(
|
|
@@ -82,14 +312,13 @@ export const makeAppRuntime = (scene: Scene, options: ReformOptions = {}): AppRu
|
|
|
82
312
|
)
|
|
83
313
|
}
|
|
84
314
|
},
|
|
85
|
-
onSuccess: (
|
|
315
|
+
onSuccess: () => undefined,
|
|
86
316
|
}),
|
|
87
317
|
),
|
|
88
318
|
)
|
|
89
319
|
return () => {
|
|
90
|
-
// Interrupt
|
|
320
|
+
// Interrupt first, then await finalizer registration before closing the scope.
|
|
91
321
|
fiber.unsafeInterruptAsFork(fiber.id())
|
|
92
|
-
// Await mount so mid-build finalizers register before scope reclaim; close on default runtime if disposed
|
|
93
322
|
const close = Fiber.await(fiber).pipe(
|
|
94
323
|
Effect.zipRight(Scope.close(scope, Exit.succeed(undefined))),
|
|
95
324
|
)
|
|
@@ -101,13 +330,125 @@ export const makeAppRuntime = (scene: Scene, options: ReformOptions = {}): AppRu
|
|
|
101
330
|
}
|
|
102
331
|
}
|
|
103
332
|
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
+
>,
|
|
349
|
+
): (() => void) => {
|
|
350
|
+
if (status.disposed) {
|
|
351
|
+
return () => {}
|
|
352
|
+
}
|
|
353
|
+
const scope = Effect.runSync(Scope.make())
|
|
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(
|
|
394
|
+
Effect.provideService(Scope.Scope, scope),
|
|
395
|
+
Effect.matchCause({
|
|
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,
|
|
407
|
+
}),
|
|
408
|
+
),
|
|
409
|
+
)
|
|
410
|
+
return () => {
|
|
411
|
+
fiber.unsafeInterruptAsFork(fiber.id())
|
|
412
|
+
const close = Fiber.await(fiber).pipe(
|
|
413
|
+
Effect.zipRight(Scope.close(scope, Exit.succeed(undefined))),
|
|
414
|
+
)
|
|
415
|
+
if (status.disposed) {
|
|
416
|
+
Effect.runFork(close)
|
|
417
|
+
} else {
|
|
418
|
+
runtime.runFork(close)
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
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),
|
|
108
445
|
runRender,
|
|
109
446
|
dispatch,
|
|
447
|
+
mountScoped,
|
|
110
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),
|
|
111
452
|
boot: () => {
|
|
112
453
|
if (status.disposed || status.booted) {
|
|
113
454
|
return
|
|
@@ -122,6 +463,7 @@ export const makeAppRuntime = (scene: Scene, options: ReformOptions = {}): AppRu
|
|
|
122
463
|
return
|
|
123
464
|
}
|
|
124
465
|
status.disposed = true
|
|
466
|
+
activationDisposers.forEach((dispose) => dispose())
|
|
125
467
|
void runtime.dispose()
|
|
126
468
|
},
|
|
127
469
|
isDisposed: () => status.disposed,
|
|
@@ -129,3 +471,22 @@ export const makeAppRuntime = (scene: Scene, options: ReformOptions = {}): AppRu
|
|
|
129
471
|
}
|
|
130
472
|
return app
|
|
131
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
|
+
}
|
|
@@ -112,10 +112,7 @@ it.live('crossing the hard limit halts the runtime with EventLoopOverflow', () =
|
|
|
112
112
|
yield* publish('Normal', Event.construct(Bumped, { to: 9_999 }))
|
|
113
113
|
yield* Effect.sleep(Duration.millis(20))
|
|
114
114
|
expect(store.get()).toBe(before)
|
|
115
|
-
}).pipe(
|
|
116
|
-
Effect.provide(TestLayer),
|
|
117
|
-
Logger.withMinimumLogLevel(LogLevel.None),
|
|
118
|
-
)
|
|
115
|
+
}).pipe(Effect.provide(TestLayer), Logger.withMinimumLogLevel(LogLevel.None))
|
|
119
116
|
})
|
|
120
117
|
|
|
121
118
|
it.live('the window resets when the event loop yields — bursty traffic never trips', () => {
|
|
@@ -2,16 +2,17 @@ import { Boolean as Bool, Effect, FiberRef, GlobalValue, MutableRef, Option } fr
|
|
|
2
2
|
import { isDev } from '../internal/env'
|
|
3
3
|
import { EventLoopOverflow } from '../internal/errors'
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
readonly
|
|
8
|
-
// oxlint-disable-next-line reform-rules/no-optional-fields -- presence-optional public config; absent fields fall back to the built-in defaults
|
|
9
|
-
readonly errorAt?: number
|
|
10
|
-
// oxlint-disable-next-line reform-rules/no-optional-fields -- presence-optional public config; absent fields fall back to the built-in defaults
|
|
11
|
-
readonly warningAt?: number
|
|
12
|
-
}
|
|
5
|
+
interface EventLimitFields {
|
|
6
|
+
readonly errorAt: number
|
|
7
|
+
readonly warningAt: number
|
|
13
8
|
}
|
|
14
9
|
|
|
10
|
+
interface ReformOptionFields {
|
|
11
|
+
readonly maxEventsPerJsTick: Partial<EventLimitFields>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ReformOptions extends Partial<ReformOptionFields> {}
|
|
15
|
+
|
|
15
16
|
export interface EventBudget {
|
|
16
17
|
readonly errorAt: number
|
|
17
18
|
readonly warningAt: Option.Option<number>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { expect, it } from '@effect/vitest'
|
|
2
2
|
import { Cause, Duration, Effect, Exit, Layer, LogLevel, Logger, Schema as S, Scope } from 'effect'
|
|
3
|
-
import { Channel,
|
|
3
|
+
import { Channel, Engine, Event, Procedure, publish, Reducer, State } from '../index'
|
|
4
|
+
import { Channels } from '../channel/channel'
|
|
4
5
|
|
|
5
6
|
it.live('a reducer that throws is isolated; the loop keeps draining', () => {
|
|
6
7
|
class Counter extends State.make('counter', S.Number) {}
|
|
@@ -27,10 +28,7 @@ it.live('a reducer that throws is isolated; the loop keeps draining', () => {
|
|
|
27
28
|
yield* publish('Normal', Event.construct(Good, {}))
|
|
28
29
|
yield* Effect.sleep(Duration.millis(10))
|
|
29
30
|
expect(store.get()).toBe(1)
|
|
30
|
-
}).pipe(
|
|
31
|
-
Effect.provide(TestLayer),
|
|
32
|
-
Logger.withMinimumLogLevel(LogLevel.None),
|
|
33
|
-
)
|
|
31
|
+
}).pipe(Effect.provide(TestLayer), Logger.withMinimumLogLevel(LogLevel.None))
|
|
34
32
|
})
|
|
35
33
|
|
|
36
34
|
it.effect('two different channels registered under one name are rejected', () => {
|
|
@@ -91,10 +89,7 @@ it.live('remount overlap keeps the channel alive', () => {
|
|
|
91
89
|
const secondScope = yield* Scope.make()
|
|
92
90
|
|
|
93
91
|
yield* Layer.buildWithScope(Channel.live(FirstLane), firstScope)
|
|
94
|
-
yield* Layer.buildWithScope(
|
|
95
|
-
Layer.mergeAll(Channel.live(SecondLane), WorkLive),
|
|
96
|
-
secondScope,
|
|
97
|
-
)
|
|
92
|
+
yield* Layer.buildWithScope(Layer.mergeAll(Channel.live(SecondLane), WorkLive), secondScope)
|
|
98
93
|
yield* Scope.close(firstScope, Exit.void)
|
|
99
94
|
|
|
100
95
|
yield* publish('Normal', Event.construct(Fired, {}))
|