foldkit 0.106.0 → 0.107.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.
Files changed (41) hide show
  1. package/README.md +5 -4
  2. package/dist/devTools/overlay.d.ts +1 -1
  3. package/dist/devTools/overlay.d.ts.map +1 -1
  4. package/dist/devTools/overlay.js +11 -12
  5. package/dist/devTools/webSocketBridge.d.ts +2 -2
  6. package/dist/devTools/webSocketBridge.d.ts.map +1 -1
  7. package/dist/devTools/webSocketBridge.js +9 -0
  8. package/dist/html/index.d.ts +5 -1
  9. package/dist/html/index.d.ts.map +1 -1
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1 -0
  13. package/dist/port/index.d.ts +2 -0
  14. package/dist/port/index.d.ts.map +1 -0
  15. package/dist/port/index.js +1 -0
  16. package/dist/port/port.d.ts +143 -0
  17. package/dist/port/port.d.ts.map +1 -0
  18. package/dist/port/port.js +156 -0
  19. package/dist/port/public.d.ts +3 -0
  20. package/dist/port/public.d.ts.map +1 -0
  21. package/dist/port/public.js +1 -0
  22. package/dist/runtime/browserListeners.d.ts +3 -3
  23. package/dist/runtime/browserListeners.d.ts.map +1 -1
  24. package/dist/runtime/browserListeners.js +23 -5
  25. package/dist/runtime/crashUI.d.ts.map +1 -1
  26. package/dist/runtime/crashUI.js +1 -3
  27. package/dist/runtime/public.d.ts +2 -2
  28. package/dist/runtime/public.d.ts.map +1 -1
  29. package/dist/runtime/public.js +1 -1
  30. package/dist/runtime/runtime.d.ts +172 -28
  31. package/dist/runtime/runtime.d.ts.map +1 -1
  32. package/dist/runtime/runtime.js +407 -49
  33. package/dist/runtime/subscription.d.ts +6 -1
  34. package/dist/runtime/subscription.d.ts.map +1 -1
  35. package/dist/ui/dragAndDrop/index.d.ts +212 -246
  36. package/dist/ui/dragAndDrop/index.d.ts.map +1 -1
  37. package/dist/ui/slider/index.d.ts +124 -179
  38. package/dist/ui/slider/index.d.ts.map +1 -1
  39. package/dist/ui/virtualList/index.d.ts +28 -38
  40. package/dist/ui/virtualList/index.d.ts.map +1 -1
  41. package/package.json +5 -1
@@ -1,11 +1,12 @@
1
1
  import { BrowserRuntime } from '@effect/platform-browser';
2
- import { Array, Cause, Context, Duration, Effect, Exit, Function, Layer, Match, Option, Predicate, PubSub, Queue, Record, Ref, Scheduler, Schema, Stream, SubscriptionRef, pipe, } from 'effect';
2
+ import { Array, Cause, Context, Duration, Effect, Exit, Fiber, Function, Layer, Match, Option, Predicate, PubSub, Queue, Record, Ref, Scheduler, Schema, Stream, SubscriptionRef, pipe, } from 'effect';
3
3
  import { h } from 'snabbdom';
4
4
  import { createOverlay } from '../devTools/overlay.js';
5
5
  import { createDevToolsStore, } from '../devTools/store.js';
6
6
  import { startWebSocketBridge } from '../devTools/webSocketBridge.js';
7
7
  import { __beginRender as beginHtmlRender, __clearRuntime as clearHtmlRuntime, __createBoundaryRegistry as createHtmlBoundaryRegistry, __setRuntime as setHtmlRuntime, } from '../html/index.js';
8
8
  import { MountTracker } from '../mount/index.js';
9
+ import { __CurrentPortChannels, __makeInboundChannel, } from '../port/index.js';
9
10
  import { fromString as urlFromString } from '../url/index.js';
10
11
  import { dedupeSharedVNodes, patch, toVNode } from '../vdom.js';
11
12
  import { addBfcacheRestoreListener, addNavigationEventListeners, } from './browserListeners.js';
@@ -48,7 +49,130 @@ const defaultSlowViewCallback = (context) => {
48
49
  /** Effect service tag that provides message dispatching to the view layer. */
49
50
  export class Dispatch extends Context.Service()('@foldkit/Dispatch') {
50
51
  }
51
- const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscriptions, container, routing: routingConfig, crash, slowView, freezeModel, resources, managedResources, devTools, }) => {
52
+ const makeHostConnector = () => {
53
+ let isDisposed = false;
54
+ let maybeDeliverInbound = Option.none();
55
+ const pendingInboundSends = [];
56
+ const listenersByPort = new Map();
57
+ const sendInbound = (portName, port, value) => {
58
+ if (isDisposed) {
59
+ return Exit.void;
60
+ }
61
+ const decodeExit = Schema.decodeUnknownExit(port.schema)(value);
62
+ Exit.match(decodeExit, {
63
+ onFailure: cause => {
64
+ console.error(`[foldkit] Inbound port "${portName}" rejected a value:`, Cause.squash(cause));
65
+ },
66
+ onSuccess: decodedValue => {
67
+ Option.match(maybeDeliverInbound, {
68
+ onNone: () => {
69
+ pendingInboundSends.push({ port, value: decodedValue });
70
+ },
71
+ onSome: deliverInbound => deliverInbound(port, decodedValue),
72
+ });
73
+ },
74
+ });
75
+ return Exit.asVoid(decodeExit);
76
+ };
77
+ const addListener = (port, listener) => {
78
+ if (isDisposed) {
79
+ return Function.constVoid;
80
+ }
81
+ const listeners = listenersByPort.get(port) ?? new Set();
82
+ listenersByPort.set(port, listeners);
83
+ listeners.add(listener);
84
+ return () => {
85
+ listeners.delete(listener);
86
+ };
87
+ };
88
+ // NOTE: delivery is deferred to a microtask so a host listener never runs
89
+ // inside the runtime's Command fiber (a listener that synchronously calls
90
+ // send or dispose must not re-enter the runtime), and so a host that
91
+ // subscribes synchronously right after embed() returns still receives
92
+ // emissions from init Commands.
93
+ const deliverOutbound = (port, encodedValue) => {
94
+ if (isDisposed) {
95
+ return;
96
+ }
97
+ queueMicrotask(() => {
98
+ if (isDisposed) {
99
+ return;
100
+ }
101
+ const listeners = listenersByPort.get(port) ?? new Set();
102
+ listeners.forEach(listener => {
103
+ try {
104
+ listener(encodedValue);
105
+ }
106
+ catch (listenerError) {
107
+ console.error('[foldkit] An outbound port listener threw:', listenerError);
108
+ }
109
+ });
110
+ });
111
+ };
112
+ const bind = (deliverInbound) => {
113
+ maybeDeliverInbound = Option.some(deliverInbound);
114
+ const flushedSends = pendingInboundSends.splice(0);
115
+ flushedSends.forEach(({ port, value }) => deliverInbound(port, value));
116
+ };
117
+ const unbind = () => {
118
+ maybeDeliverInbound = Option.none();
119
+ };
120
+ const dispose = () => {
121
+ isDisposed = true;
122
+ pendingInboundSends.length = 0;
123
+ listenersByPort.forEach(listeners => listeners.clear());
124
+ listenersByPort.clear();
125
+ };
126
+ return { sendInbound, addListener, deliverOutbound, bind, unbind, dispose };
127
+ };
128
+ const makePortChannels = (ports, maybeConnector) => {
129
+ const inboundChannelsByPort = new Map();
130
+ Object.values(ports.inbound ?? {}).forEach(port => {
131
+ inboundChannelsByPort.set(port, __makeInboundChannel());
132
+ });
133
+ const outboundPorts = new Set(Object.values(ports.outbound ?? {}));
134
+ const channels = {
135
+ isConfigured: true,
136
+ lookupInbound: port => Option.fromNullishOr(inboundChannelsByPort.get(port)),
137
+ lookupOutbound: port => outboundPorts.has(port)
138
+ ? Option.some(encodedValue => Option.match(maybeConnector, {
139
+ onNone: Function.constVoid,
140
+ onSome: connector => connector.deliverOutbound(port, encodedValue),
141
+ }))
142
+ : Option.none(),
143
+ };
144
+ const deliverInbound = (port, value) => {
145
+ Option.match(Option.fromNullishOr(inboundChannelsByPort.get(port)), {
146
+ onNone: Function.constVoid,
147
+ onSome: channel => channel.deliver(value),
148
+ });
149
+ };
150
+ return { channels, deliverInbound };
151
+ };
152
+ const validatePorts = (ports) => {
153
+ const inboundEntries = Object.entries(ports.inbound ?? {});
154
+ const outboundEntries = Object.entries(ports.outbound ?? {});
155
+ const inboundNames = new Set(inboundEntries.map(([name]) => name));
156
+ outboundEntries.forEach(([name]) => {
157
+ if (inboundNames.has(name)) {
158
+ throw new Error(`[foldkit] Port name "${name}" appears in both inbound and outbound. ` +
159
+ 'Port names share one namespace on the EmbedHandle, so each name ' +
160
+ 'must be unique across both records.');
161
+ }
162
+ });
163
+ const seenPorts = new Set();
164
+ const allEntries = [...inboundEntries, ...outboundEntries];
165
+ allEntries.forEach(([name, port]) => {
166
+ if (seenPorts.has(port)) {
167
+ throw new Error(`[foldkit] The Port registered as "${name}" is also registered under ` +
168
+ 'another name. Each entry in the ports record needs its own ' +
169
+ 'Port.inbound or Port.outbound value.');
170
+ }
171
+ seenPorts.add(port);
172
+ });
173
+ };
174
+ const runtimeInternals = new WeakMap();
175
+ const makeRuntime = ({ ports, Model, flags: resolveFlags, init, update, view, manageDocument, subscriptions, container, routing: routingConfig, crash, slowView, freezeModel, resources, managedResources, devTools, }) => {
52
176
  const resolvedSlowView = pipe(slowView ?? {}, Option.liftPredicate(config => config !== false), Option.filter(config => Match.value(config.show ?? DEFAULT_SLOW_VIEW_SHOW).pipe(Match.when('Always', () => true), Match.when('Development', () => !!import.meta.hot), Match.exhaustive)), Option.map(config => ({
53
177
  thresholdMs: config.thresholdMs ?? DEFAULT_SLOW_VIEW_THRESHOLD_MS,
54
178
  onSlowView: config.onSlowView ?? defaultSlowViewCallback,
@@ -60,6 +184,9 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
60
184
  }));
61
185
  const devToolsMaxEntries = pipe(devTools ?? {}, Option.liftPredicate(config => config !== false), Option.flatMapNullishOr(config => config.maxEntries), Option.map(value => Math.max(DEV_TOOLS_MAX_ENTRIES_MIN, Math.min(DEV_TOOLS_MAX_ENTRIES_MAX, value))), Option.getOrUndefined);
62
186
  const maybeFreezeModel = (model) => isFreezeModelActive ? deepFreeze(model) : model;
187
+ if (Predicate.isNotUndefined(ports)) {
188
+ validatePorts(ports);
189
+ }
63
190
  const runtimeId = container?.id ?? '';
64
191
  // NOTE: When the message queue drains a chain of dispatches (e.g. recursive
65
192
  // Commands, websocket bursts), processing all of them inside one macrotask
@@ -77,11 +204,17 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
77
204
  const handle = requestAnimationFrame(() => resume(Effect.void));
78
205
  return Effect.sync(() => cancelAnimationFrame(handle));
79
206
  });
80
- const start = (hmrModel) => Effect.scoped(Effect.gen(function* () {
207
+ const startWith = (maybeConnector, hmrModel) => Effect.scoped(Effect.gen(function* () {
81
208
  if (runtimeId === '') {
82
209
  return yield* Effect.die(new Error('[foldkit] Runtime container must have an `id` for HMR model preservation. ' +
83
- 'Set `container.id = "app"` (or any unique string) before passing it to makeProgram.'));
210
+ 'Set `container.id = "app"` (or any unique string) before passing it to makeApplication or makeElement.'));
84
211
  }
212
+ // NOTE: every perpetual fiber (render loop, Subscription streams,
213
+ // ManagedResource lifecycles) and every Command fiber forks into the
214
+ // runtime scope, so interrupting the runtime fiber (what dispose
215
+ // does) interrupts them all and runs their finalizers. A detached
216
+ // fork would outlive the runtime.
217
+ const runtimeScope = yield* Effect.scope;
85
218
  // NOTE: one persistent MessageChannel for the runtime lifetime,
86
219
  // shared by every burst-budget yield. The queue-drain fiber is the
87
220
  // sole consumer, so a single `pendingYieldResume` slot is sufficient.
@@ -107,6 +240,14 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
107
240
  });
108
241
  });
109
242
  const maybeResourceLayer = Option.fromNullishOr(resources);
243
+ const maybePortChannels = pipe(Option.fromNullishOr(ports), Option.map(portsConfig => makePortChannels(portsConfig, maybeConnector)));
244
+ yield* Option.match(Option.all({
245
+ connector: maybeConnector,
246
+ portChannels: maybePortChannels,
247
+ }), {
248
+ onNone: () => Effect.void,
249
+ onSome: ({ connector, portChannels }) => Effect.acquireRelease(Effect.sync(() => connector.bind(portChannels.deliverInbound)), () => Effect.sync(() => connector.unbind())),
250
+ });
110
251
  // NOTE: One boundary registry per runtime instance, shared
111
252
  // across renders so Submodel wrap descriptors registered by
112
253
  // h.submodel persist between renders. The render function calls
@@ -134,13 +275,17 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
134
275
  onNone: () => effect,
135
276
  onSome: resourceLayer => Effect.provide(effect, resourceLayer),
136
277
  });
137
- return Option.match(maybeManagedResourceLayer, {
278
+ const withManagedResources = Option.match(maybeManagedResourceLayer, {
138
279
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
139
280
  onNone: () => withResources,
140
281
  onSome: managedLayer =>
141
282
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
142
283
  Effect.provide(withResources, managedLayer),
143
284
  });
285
+ return Option.match(maybePortChannels, {
286
+ onNone: () => withManagedResources,
287
+ onSome: portChannels => Effect.provideService(withManagedResources, __CurrentPortChannels, portChannels.channels),
288
+ });
144
289
  };
145
290
  const flags = yield* resolveFlags;
146
291
  const ModelJsonCodec = Schema.toCodecJson(
@@ -200,14 +345,39 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
200
345
  const modelPubSub = yield* PubSub.unbounded();
201
346
  yield* Effect.forEach(
202
347
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
203
- initCommands, command => Effect.forkDetach(command.effect.pipe(Effect.withSpan(command.name, {
348
+ initCommands, command => Effect.forkIn(runtimeScope)(command.effect.pipe(Effect.withSpan(command.name, {
204
349
  attributes: command.args ?? {},
205
350
  }), provideAllResources, Effect.flatMap(enqueueNormal))));
206
351
  if (routingConfig) {
207
- addNavigationEventListeners(enqueueHighUnsafe, routingConfig);
352
+ yield* Effect.acquireRelease(Effect.sync(() => addNavigationEventListeners(enqueueHighUnsafe, routingConfig)), removeNavigationEventListeners => Effect.sync(() => removeNavigationEventListeners()));
208
353
  }
209
354
  const modelRef = yield* Ref.make(initModel);
210
355
  const maybeCurrentVNodeRef = yield* Ref.make(Option.none());
356
+ // NOTE: registered before any perpetual fiber is forked so it runs
357
+ // after they are interrupted (scope finalizers are LIFO). Patching to
358
+ // an empty tree fires snabbdom destroy hooks, which is what releases
359
+ // Mounts; swapping the placeholder for the original container leaves
360
+ // the host DOM as it was before the first render, ready for a fresh
361
+ // embed of the same container. Gated on interruption: that is the
362
+ // dispose path. A runtime that stops because it crashed completes
363
+ // normally after rendering the crash view, and the crash view must
364
+ // stay visible.
365
+ yield* Effect.addFinalizer(exit => Effect.gen(function* () {
366
+ if (!Exit.hasInterrupts(exit)) {
367
+ return;
368
+ }
369
+ const maybeCurrentVNode = yield* Ref.get(maybeCurrentVNodeRef);
370
+ yield* Option.match(maybeCurrentVNode, {
371
+ onNone: () => Effect.void,
372
+ onSome: currentVNode => Effect.sync(() => {
373
+ const placeholderNode = patchVNode(Option.some(currentVNode), null, container).elm;
374
+ if (placeholderNode && placeholderNode.parentNode) {
375
+ placeholderNode.parentNode.replaceChild(container, placeholderNode);
376
+ container.replaceChildren();
377
+ }
378
+ }),
379
+ });
380
+ }));
211
381
  // NOTE: shared by every perpetual fiber's crash path (init render,
212
382
  // render loop, message drain). Each fiber catches its own cause so a
213
383
  // failure surfaces as the crash view instead of dying silently and
@@ -216,7 +386,7 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
216
386
  const model = yield* Ref.get(modelRef);
217
387
  const squashed = Cause.squash(cause);
218
388
  const error = squashed instanceof Error ? squashed : new Error(String(squashed));
219
- renderCrashView({ error, model, message: maybeMessage }, crash, container, maybeCurrentVNodeRef);
389
+ renderCrashView({ error, model, message: maybeMessage }, crash, container, maybeCurrentVNodeRef, manageDocument);
220
390
  });
221
391
  // NOTE: queue-drain-fiber-local state. Kept as plain closure
222
392
  // variables instead of `Ref`s because nothing else reads or writes
@@ -276,7 +446,7 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
276
446
  if (!Array.isReadonlyArrayEmpty(commands)) {
277
447
  yield* Effect.forEach(
278
448
  /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
279
- commands, command => Effect.forkDetach(command.effect.pipe(Effect.withSpan(command.name, {
449
+ commands, command => Effect.forkIn(runtimeScope)(command.effect.pipe(Effect.withSpan(command.name, {
280
450
  attributes: command.args ?? {},
281
451
  }), provideAllResources, Effect.flatMap(enqueueNormal))));
282
452
  }
@@ -336,7 +506,9 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
336
506
  const maybeCurrentVNode = yield* Ref.get(maybeCurrentVNodeRef);
337
507
  const patchedVNode = yield* Effect.sync(() => patchVNode(maybeCurrentVNode, nextVNode, container));
338
508
  yield* Ref.set(maybeCurrentVNodeRef, Option.some(patchedVNode));
339
- yield* Effect.sync(() => applyDocumentMetadata(nextDocument, patchedVNode.elm));
509
+ if (manageDocument) {
510
+ yield* Effect.sync(() => applyDocumentMetadata(nextDocument, patchedVNode.elm));
511
+ }
340
512
  }).pipe(Effect.provideService(Dispatch, dispatchService), Effect.provideService(MountTracker, mountTracker));
341
513
  const isInIframe = window.self !== window.top;
342
514
  const resolvedDevTools = pipe(devTools ?? {}, Option.liftPredicate(config => config !== false), Option.filter(config => Match.value(config.show ?? DEFAULT_DEV_TOOLS_SHOW).pipe(Match.when('Always', () => true), Match.when('Development', () => !!import.meta.hot && !isInIframe), Match.exhaustive)), Option.map(config => ({
@@ -407,7 +579,11 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
407
579
  }
408
580
  const initRenderExit = yield* Effect.exit(render(initModel, Option.none()));
409
581
  if (Exit.isFailure(initRenderExit)) {
410
- return yield* crashWith(initRenderExit.cause, Option.none());
582
+ yield* crashWith(initRenderExit.cause, Option.none());
583
+ // NOTE: suspend instead of returning. Completing would close the
584
+ // runtime scope and tear down the crash view; the scope must stay
585
+ // open until the runtime is interrupted (dispose, or page unload).
586
+ return yield* Effect.never;
411
587
  }
412
588
  const initMountEvents = drainMountEvents();
413
589
  yield* Option.match(maybeDevToolsStore, {
@@ -435,18 +611,23 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
435
611
  });
436
612
  }),
437
613
  });
438
- yield* Effect.forkDetach(renderLoop.pipe(Effect.catchCause(cause => Effect.gen(function* () {
614
+ yield* Effect.forkIn(runtimeScope)(renderLoop.pipe(Effect.catchCause(cause => Effect.gen(function* () {
439
615
  const maybeMessage = yield* Ref.get(maybeLastDirtyMessageRef);
440
616
  yield* crashWith(cause, maybeMessage);
441
617
  }))));
442
- addBfcacheRestoreListener();
618
+ // NOTE: reloading on bfcache restore is a page-level decision, so
619
+ // only a page-owning runtime installs the listener. An embedded app
620
+ // must never force the host page to reload.
621
+ if (manageDocument) {
622
+ yield* Effect.acquireRelease(Effect.sync(() => addBfcacheRestoreListener()), removeBfcacheRestoreListener => Effect.sync(() => removeBfcacheRestoreListener()));
623
+ }
443
624
  if (subscriptions) {
444
625
  yield* pipe(subscriptions, Record.toEntries, Effect.forEach(([_key, { dependenciesSchema, modelToDependencies, keepAliveEquivalence, dependenciesToStream, },]) => Effect.gen(function* () {
445
626
  const latestDependenciesRef = yield* Ref.make(modelToDependencies(initModel));
446
627
  const equivalence = keepAliveEquivalence ??
447
628
  Schema.toEquivalence(dependenciesSchema);
448
629
  const modelStream = Stream.concat(Stream.make(initModel), Stream.fromPubSub(modelPubSub));
449
- yield* Effect.forkDetach(modelStream.pipe(
630
+ yield* Effect.forkIn(runtimeScope)(modelStream.pipe(
450
631
  // NOTE: Ref.set runs upstream of Stream.changesWith on
451
632
  // every model change, so readDependencies() returns
452
633
  // current values even when the equivalence filter
@@ -489,7 +670,7 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
489
670
  const forkManagedResourceLifecycle = ({ config, ref: resourceRef, }) => Effect.gen(function* () {
490
671
  const modelStream = Stream.concat(Stream.make(initModel), Stream.fromPubSub(modelPubSub));
491
672
  const equivalence = Schema.toEquivalence(config.schema);
492
- yield* Effect.forkDetach(modelStream.pipe(Stream.map(config.modelToMaybeRequirements), Stream.changesWith(equivalence), Stream.switchMap(maybeRequirementsToLifecycle(config, resourceRef)), Stream.runForEach(Effect.flatMap(enqueueHigh))));
673
+ yield* Effect.forkIn(runtimeScope)(modelStream.pipe(Stream.map(config.modelToMaybeRequirements), Stream.changesWith(equivalence), Stream.switchMap(maybeRequirementsToLifecycle(config, resourceRef)), Stream.runForEach(Effect.flatMap(enqueueHigh))));
493
674
  });
494
675
  yield* Effect.forEach(managedResourceRefs, forkManagedResourceLifecycle, {
495
676
  concurrency: 'unbounded',
@@ -551,8 +732,20 @@ const makeRuntime = ({ Model, flags: resolveFlags, init, update, view, subscript
551
732
  yield* processBatch(Array.prepend(rest, first));
552
733
  yield* drainQueue;
553
734
  })), Effect.catchCause(cause => crashWith(cause, currentMessage)));
735
+ // NOTE: reached only after the drain loop crashed and the crash view
736
+ // rendered. Suspending keeps the runtime scope open so the crash view
737
+ // and the DevTools overlay stay up for inspection; interruption
738
+ // (dispose, or page unload) still tears everything down.
739
+ yield* Effect.never;
554
740
  }));
555
- return { runtimeId, start };
741
+ const start = (hmrModel) => startWith(Option.none(), hmrModel);
742
+ const program = { runtimeId, start, ports };
743
+ runtimeInternals.set(program, {
744
+ startWith,
745
+ isEmbedActive: false,
746
+ maybeActiveFiber: Option.none(),
747
+ });
748
+ return program;
556
749
  };
557
750
  // NOTE: exported for `patchVNode.test.ts` to assert the dedupeSharedVNodes
558
751
  // wiring; not part of the public surface (`runtime/public.ts` is curated).
@@ -594,7 +787,7 @@ const applyDocumentMetadata = (nextDocument, mountedRoot) => {
594
787
  content: ogUrl,
595
788
  });
596
789
  };
597
- const renderCrashView = (context, crash, container, maybeCurrentVNodeRef) => {
790
+ const renderCrashView = (context, crash, container, maybeCurrentVNodeRef, manageDocument) => {
598
791
  console.error('[foldkit] Application crash:', context.error);
599
792
  if (crash?.report) {
600
793
  try {
@@ -621,7 +814,10 @@ const renderCrashView = (context, crash, container, maybeCurrentVNodeRef) => {
621
814
  }
622
815
  const maybeCurrentVNode = Effect.runSync(Ref.get(maybeCurrentVNodeRef));
623
816
  const patchedVNode = patchVNode(maybeCurrentVNode, crashDocument.body, container);
624
- applyDocumentMetadata(crashDocument, patchedVNode.elm);
817
+ Effect.runSync(Ref.set(maybeCurrentVNodeRef, Option.some(patchedVNode)));
818
+ if (manageDocument) {
819
+ applyDocumentMetadata(crashDocument, patchedVNode.elm);
820
+ }
625
821
  }
626
822
  catch (viewError) {
627
823
  console.error('[foldkit] crash.view failed:', viewError);
@@ -636,14 +832,17 @@ const renderCrashView = (context, crash, container, maybeCurrentVNodeRef) => {
636
832
  }
637
833
  const maybeCurrentVNode = Effect.runSync(Ref.get(maybeCurrentVNodeRef));
638
834
  const patchedVNode = patchVNode(maybeCurrentVNode, fallbackDocument.body, container);
639
- applyDocumentMetadata(fallbackDocument, patchedVNode.elm);
835
+ Effect.runSync(Ref.set(maybeCurrentVNodeRef, Option.some(patchedVNode)));
836
+ if (manageDocument) {
837
+ applyDocumentMetadata(fallbackDocument, patchedVNode.elm);
838
+ }
640
839
  }
641
840
  };
642
- export function makeProgram(config) {
841
+ export function makeApplication(config) {
643
842
  const { container } = config;
644
843
  if (container === null) {
645
844
  throw new Error('[foldkit] Container is null. Make sure the element exists in the DOM ' +
646
- 'before calling makeProgram (e.g. that your <div id="root"></div> has ' +
845
+ 'before calling makeApplication (e.g. that your <div id="root"></div> has ' +
647
846
  'rendered, and your script runs after it).');
648
847
  }
649
848
  const hasRouting = 'routing' in config;
@@ -655,6 +854,8 @@ export function makeProgram(config) {
655
854
  Model: config.Model,
656
855
  update: config.update,
657
856
  view: config.view,
857
+ manageDocument: true,
858
+ ports: config.ports,
658
859
  ...(config.subscriptions && { subscriptions: config.subscriptions }),
659
860
  container,
660
861
  ...(hasRouting && { routing: config.routing }),
@@ -708,6 +909,79 @@ export function makeProgram(config) {
708
909
  }
709
910
  /* eslint-enable @typescript-eslint/consistent-type-assertions */
710
911
  }
912
+ const toCrashConfig = (nullableCrash) => {
913
+ if (Predicate.isUndefined(nullableCrash)) {
914
+ return undefined;
915
+ }
916
+ const elementCrashView = nullableCrash.view;
917
+ return {
918
+ ...(Predicate.isNotUndefined(elementCrashView) && {
919
+ view: (context) => ({
920
+ title: '',
921
+ body: elementCrashView(context),
922
+ }),
923
+ }),
924
+ ...(Predicate.isNotUndefined(nullableCrash.report) && {
925
+ report: nullableCrash.report,
926
+ }),
927
+ };
928
+ };
929
+ export function makeElement(config) {
930
+ const { container } = config;
931
+ if (container === null) {
932
+ throw new Error('[foldkit] Container is null. Make sure the element exists in the DOM ' +
933
+ 'before calling makeElement (e.g. that your <div id="root"></div> has ' +
934
+ 'rendered, and your script runs after it).');
935
+ }
936
+ const hasFlags = 'Flags' in config;
937
+ const elementView = config.view;
938
+ const view = (model) => ({
939
+ title: '',
940
+ body: elementView(model),
941
+ });
942
+ const nullableCrash = toCrashConfig(config.crash);
943
+ const baseConfig = {
944
+ Model: config.Model,
945
+ update: config.update,
946
+ view,
947
+ manageDocument: false,
948
+ ports: config.ports,
949
+ ...(config.subscriptions && { subscriptions: config.subscriptions }),
950
+ container,
951
+ ...(Predicate.isNotUndefined(nullableCrash) && { crash: nullableCrash }),
952
+ ...(Predicate.isNotUndefined(config.slowView) && {
953
+ slowView: config.slowView,
954
+ }),
955
+ ...(Predicate.isNotUndefined(config.freezeModel) && {
956
+ freezeModel: config.freezeModel,
957
+ }),
958
+ ...(config.resources && { resources: config.resources }),
959
+ ...(config.managedResources && {
960
+ managedResources: config.managedResources,
961
+ }),
962
+ ...(Predicate.isNotUndefined(config.devTools) && {
963
+ devTools: config.devTools,
964
+ }),
965
+ };
966
+ /* eslint-disable @typescript-eslint/consistent-type-assertions */
967
+ if (hasFlags) {
968
+ return makeRuntime({
969
+ ...baseConfig,
970
+ Flags: config.Flags,
971
+ flags: config.flags,
972
+ init: (flags) => config.init(flags),
973
+ });
974
+ }
975
+ else {
976
+ return makeRuntime({
977
+ ...baseConfig,
978
+ Flags: Schema.Void,
979
+ flags: Effect.succeed(undefined),
980
+ init: () => config.init(),
981
+ });
982
+ }
983
+ /* eslint-enable @typescript-eslint/consistent-type-assertions */
984
+ }
711
985
  const encodePreserveModelMessage = Schema.encodeUnknownSync(PreserveModelMessage);
712
986
  const encodeRequestModelMessage = Schema.encodeUnknownSync(RequestModelMessage);
713
987
  const decodeRestoreModelMessage = Schema.decodeUnknownExit(RestoreModelMessage);
@@ -735,36 +1009,120 @@ const microtaskSetImmediate = (callback) => {
735
1009
  };
736
1010
  const browserScheduler = new Scheduler.MixedScheduler('async', microtaskSetImmediate);
737
1011
  const provideBrowserScheduler = (effect) => Effect.provide(effect, Layer.succeed(Scheduler.Scheduler, browserScheduler));
738
- /** Starts a Foldkit runtime, with HMR support for development. */
1012
+ // NOTE: asks @foldkit/vite-plugin for a model preserved across the last HMR
1013
+ // reload. The plugin only serves a model whose preservation was flushed by a
1014
+ // reload, so a host-driven dispose-then-embed remount initializes fresh while
1015
+ // a code reload restores state.
1016
+ const resolveHmrModel = (runtimeId) => {
1017
+ const hot = import.meta.hot;
1018
+ if (!hot) {
1019
+ return Effect.succeed(undefined);
1020
+ }
1021
+ return pipe(Effect.callback(resume => {
1022
+ const handler = (message) => {
1023
+ Exit.match(decodeRestoreModelMessage(message), {
1024
+ onFailure: Function.constVoid,
1025
+ onSuccess: ({ id, model }) => {
1026
+ if (id === runtimeId) {
1027
+ hot.off('foldkit:restore-model', handler);
1028
+ resume(Effect.succeed(model));
1029
+ }
1030
+ },
1031
+ });
1032
+ };
1033
+ hot.on('foldkit:restore-model', handler);
1034
+ hot.send('foldkit:request-model', encodeRequestModelMessage(RequestModelMessage.make({ id: runtimeId })));
1035
+ return Effect.sync(() => hot.off('foldkit:restore-model', handler));
1036
+ }), Effect.timeout(PLUGIN_RESPONSE_TIMEOUT_MS), Effect.catchTag('TimeoutError', () => {
1037
+ console.warn('[foldkit] No response from @foldkit/vite-plugin. Add it to your vite.config.ts for HMR model preservation:\n\n' +
1038
+ " import { foldkit } from '@foldkit/vite-plugin'\n\n" +
1039
+ ' export default defineConfig({ plugins: [foldkit()] })\n\n' +
1040
+ 'Starting without HMR support.');
1041
+ return Effect.succeed(undefined);
1042
+ }));
1043
+ };
1044
+ /** Starts a Foldkit runtime that owns the page for the page's whole lifetime,
1045
+ * with HMR support for development. To start a runtime under a
1046
+ * host-controlled lifecycle instead, use `embed`. */
739
1047
  export const run = (program) => {
740
- if (import.meta.hot) {
741
- const hot = import.meta.hot;
742
- const { runtimeId, start } = program;
743
- const requestPreservedModel = pipe(Effect.callback(resume => {
744
- const handler = (message) => {
745
- Exit.match(decodeRestoreModelMessage(message), {
746
- onFailure: Function.constVoid,
747
- onSuccess: ({ id, model }) => {
748
- if (id === runtimeId) {
749
- hot.off('foldkit:restore-model', handler);
750
- resume(Effect.succeed(model));
751
- }
752
- },
753
- });
1048
+ BrowserRuntime.runMain(provideBrowserScheduler(Effect.flatMap(resolveHmrModel(program.runtimeId), program.start)));
1049
+ };
1050
+ const buildPortHandles = (ports, connector) => {
1051
+ const handles = {};
1052
+ if (Predicate.isNotUndefined(ports)) {
1053
+ Object.entries(ports.inbound ?? {}).forEach(([portName, port]) => {
1054
+ handles[portName] = {
1055
+ send: (value) => connector.sendInbound(portName, port, value),
1056
+ };
1057
+ });
1058
+ Object.entries(ports.outbound ?? {}).forEach(([portName, port]) => {
1059
+ handles[portName] = {
1060
+ subscribe: (listener) => connector.addListener(port, listener),
754
1061
  };
755
- hot.on('foldkit:restore-model', handler);
756
- hot.send('foldkit:request-model', encodeRequestModelMessage(RequestModelMessage.make({ id: runtimeId })));
757
- return Effect.sync(() => hot.off('foldkit:restore-model', handler));
758
- }), Effect.timeout(PLUGIN_RESPONSE_TIMEOUT_MS), Effect.catchTag('TimeoutError', () => {
759
- console.warn('[foldkit] No response from @foldkit/vite-plugin. Add it to your vite.config.ts for HMR model preservation:\n\n' +
760
- " import { foldkit } from '@foldkit/vite-plugin'\n\n" +
761
- ' export default defineConfig({ plugins: [foldkit()] })\n\n' +
762
- 'Starting without HMR support.');
763
- return Effect.succeed(undefined);
764
- }), Effect.flatMap(start));
765
- BrowserRuntime.runMain(provideBrowserScheduler(requestPreservedModel));
1062
+ });
766
1063
  }
767
- else {
768
- BrowserRuntime.runMain(provideBrowserScheduler(program.start()));
1064
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
1065
+ return handles;
1066
+ };
1067
+ /**
1068
+ * Starts a Foldkit runtime under a host-controlled lifecycle and returns an
1069
+ * `EmbedHandle`. This is the entry point for embedding a Foldkit app inside
1070
+ * another application: the host pushes values in through the handle's inbound
1071
+ * Ports, listens to outbound Ports, and calls `dispose` when it unmounts the
1072
+ * app. The host never touches the Model or dispatches Messages directly; the
1073
+ * Schema-typed Ports are the whole boundary.
1074
+ *
1075
+ * Works with programs from both `makeApplication` and `makeElement`; for a
1076
+ * widget on a page the host owns, `makeElement` is the natural fit.
1077
+ *
1078
+ * A program can be embedded once at a time (it owns one container). After
1079
+ * `dispose`, the same container can be embedded again with a fresh program.
1080
+ *
1081
+ * ```ts
1082
+ * const handle = Runtime.embed(element)
1083
+ *
1084
+ * handle.ports.stepChanged.send(5)
1085
+ * const unsubscribe = handle.ports.countChanged.subscribe(count => {
1086
+ * console.log(count)
1087
+ * })
1088
+ *
1089
+ * handle.dispose()
1090
+ * ```
1091
+ */
1092
+ export const embed = (program) => {
1093
+ const nullableInternals = runtimeInternals.get(program);
1094
+ if (Predicate.isUndefined(nullableInternals)) {
1095
+ throw new Error('[foldkit] embed expects a program created by makeApplication or makeElement.');
769
1096
  }
1097
+ const internals = nullableInternals;
1098
+ if (internals.isEmbedActive) {
1099
+ throw new Error('[foldkit] This program is already embedded. Dispose the existing ' +
1100
+ 'handle first, or create a separate program: each program owns one ' +
1101
+ 'container.');
1102
+ }
1103
+ internals.isEmbedActive = true;
1104
+ const connector = makeHostConnector();
1105
+ // NOTE: a dispose immediately followed by a fresh embed (React strict mode
1106
+ // runs effects exactly that way) must not start the new runtime while the
1107
+ // old one is still tearing down: the teardown finalizer is what puts the
1108
+ // container element back in the DOM. Awaiting the previous fiber's exit
1109
+ // sequences the two.
1110
+ const startEffect = pipe(Option.match(internals.maybeActiveFiber, {
1111
+ onNone: () => Effect.void,
1112
+ onSome: previousFiber => Effect.asVoid(Fiber.await(previousFiber)),
1113
+ }), Effect.andThen(resolveHmrModel(program.runtimeId)), Effect.flatMap(hmrModel => internals.startWith(Option.some(connector), hmrModel)));
1114
+ const fiber = Effect.runFork(provideBrowserScheduler(startEffect));
1115
+ internals.maybeActiveFiber = Option.some(fiber);
1116
+ let isHandleDisposed = false;
1117
+ const dispose = () => {
1118
+ if (isHandleDisposed) {
1119
+ return;
1120
+ }
1121
+ isHandleDisposed = true;
1122
+ connector.dispose();
1123
+ internals.isEmbedActive = false;
1124
+ Effect.runFork(Fiber.interrupt(fiber));
1125
+ };
1126
+ const ports = buildPortHandles(program.ports, connector);
1127
+ return { ports, dispose };
770
1128
  };
@@ -5,7 +5,12 @@ type SubscriptionBrand = {
5
5
  type DependenciesSchema<Dependencies> = Schema.Schema<Dependencies> & {
6
6
  readonly fields: Schema.Struct.Fields;
7
7
  };
8
- type EntryWithoutKeepAlive<Model, Message, Dependencies, Services> = {
8
+ /**
9
+ * The entry shape produced by helpers like `Subscription.persistent` and
10
+ * `Port.subscription` before branding. Pass values of this shape into
11
+ * `Subscription.make` as entry values.
12
+ */
13
+ export type EntryWithoutKeepAlive<Model, Message, Dependencies, Services> = {
9
14
  readonly dependenciesSchema: DependenciesSchema<Dependencies>;
10
15
  readonly modelToDependencies: (model: Model) => Dependencies;
11
16
  readonly keepAliveEquivalence?: never;