cursedbelt-core 1.0.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/LICENSE +15 -0
- package/dist/client/wsWire.d.ts +49 -0
- package/dist/client/wsWire.js +57 -0
- package/dist/config/appConfig.d.ts +361 -0
- package/dist/config/appConfig.js +308 -0
- package/dist/core/activity/index.d.ts +8 -0
- package/dist/core/activity/index.js +7 -0
- package/dist/core/activity/model.d.ts +115 -0
- package/dist/core/activity/model.js +63 -0
- package/dist/core/analytics/index.d.ts +18 -0
- package/dist/core/analytics/index.js +18 -0
- package/dist/core/analytics/range.d.ts +186 -0
- package/dist/core/analytics/range.js +271 -0
- package/dist/core/analytics/schedule.d.ts +301 -0
- package/dist/core/analytics/schedule.js +372 -0
- package/dist/core/category/index.d.ts +3 -0
- package/dist/core/category/index.js +6 -0
- package/dist/core/category/memoryStore.d.ts +21 -0
- package/dist/core/category/memoryStore.js +97 -0
- package/dist/core/category/tree.d.ts +26 -0
- package/dist/core/category/tree.js +113 -0
- package/dist/core/category/types.d.ts +48 -0
- package/dist/core/category/types.js +8 -0
- package/dist/core/color.d.ts +14 -0
- package/dist/core/color.js +32 -0
- package/dist/core/colorScheme/colorSchemeStore.d.ts +70 -0
- package/dist/core/colorScheme/colorSchemeStore.js +123 -0
- package/dist/core/colorScheme/index.d.ts +7 -0
- package/dist/core/colorScheme/index.js +7 -0
- package/dist/core/domainOwners.d.ts +19 -0
- package/dist/core/domainOwners.js +15 -0
- package/dist/core/download.d.ts +14 -0
- package/dist/core/download.js +37 -0
- package/dist/core/events/ccEventBus.d.ts +44 -0
- package/dist/core/events/ccEventBus.js +70 -0
- package/dist/core/file-tree/fileTreeModel.d.ts +276 -0
- package/dist/core/file-tree/fileTreeModel.js +312 -0
- package/dist/core/folder-tree/actions.d.ts +84 -0
- package/dist/core/folder-tree/actions.js +119 -0
- package/dist/core/folder-tree/index.d.ts +3 -0
- package/dist/core/folder-tree/index.js +20 -0
- package/dist/core/folder-tree/path.d.ts +76 -0
- package/dist/core/folder-tree/path.js +146 -0
- package/dist/core/folder-tree/tree.d.ts +86 -0
- package/dist/core/folder-tree/tree.js +184 -0
- package/dist/core/layout/index.d.ts +1 -0
- package/dist/core/layout/index.js +7 -0
- package/dist/core/master-lock/index.d.ts +14 -0
- package/dist/core/master-lock/index.js +14 -0
- package/dist/core/master-lock/kdf.d.ts +63 -0
- package/dist/core/master-lock/kdf.js +117 -0
- package/dist/core/master-lock/policy.d.ts +32 -0
- package/dist/core/master-lock/policy.js +52 -0
- package/dist/core/master-lock/presence.d.ts +52 -0
- package/dist/core/master-lock/presence.js +61 -0
- package/dist/core/master-lock/wire.d.ts +139 -0
- package/dist/core/master-lock/wire.js +81 -0
- package/dist/core/media/bgRemoval.d.ts +27 -0
- package/dist/core/media/bgRemoval.js +101 -0
- package/dist/core/media/ffmpeg.d.ts +57 -0
- package/dist/core/media/ffmpeg.js +126 -0
- package/dist/core/media/format.d.ts +20 -0
- package/dist/core/media/format.js +54 -0
- package/dist/core/media/index.d.ts +12 -0
- package/dist/core/media/index.js +18 -0
- package/dist/core/media/mediaItem.d.ts +15 -0
- package/dist/core/media/mediaItem.js +34 -0
- package/dist/core/media/renderPlan.d.ts +63 -0
- package/dist/core/media/renderPlan.js +232 -0
- package/dist/core/media/segment.d.ts +79 -0
- package/dist/core/media/segment.js +247 -0
- package/dist/core/media/size.d.ts +1 -0
- package/dist/core/media/size.js +3 -0
- package/dist/core/media/trim.d.ts +21 -0
- package/dist/core/media/trim.js +40 -0
- package/dist/core/media/types.d.ts +148 -0
- package/dist/core/media/types.js +11 -0
- package/dist/core/media/uploadLimits.d.ts +7 -0
- package/dist/core/media/uploadLimits.js +16 -0
- package/dist/core/media/validate.d.ts +9 -0
- package/dist/core/media/validate.js +121 -0
- package/dist/core/media/videoCuts.d.ts +152 -0
- package/dist/core/media/videoCuts.js +284 -0
- package/dist/core/navigation/breadcrumbCompat.d.ts +24 -0
- package/dist/core/navigation/breadcrumbCompat.js +21 -0
- package/dist/core/navigation/coverageTest.d.ts +24 -0
- package/dist/core/navigation/coverageTest.js +48 -0
- package/dist/core/navigation/index.d.ts +6 -0
- package/dist/core/navigation/index.js +10 -0
- package/dist/core/navigation/migrateNavPreferences.d.ts +55 -0
- package/dist/core/navigation/migrateNavPreferences.js +63 -0
- package/dist/core/navigation/navigationModel.d.ts +165 -0
- package/dist/core/navigation/navigationModel.js +14 -0
- package/dist/core/navigation/navigationSelectors.d.ts +63 -0
- package/dist/core/navigation/navigationSelectors.js +167 -0
- package/dist/core/navigation/searchCatalogue.d.ts +23 -0
- package/dist/core/navigation/searchCatalogue.js +76 -0
- package/dist/core/net/reconnectSchedule.d.ts +46 -0
- package/dist/core/net/reconnectSchedule.js +38 -0
- package/dist/core/palette/ShortcutRegistry.d.ts +33 -0
- package/dist/core/palette/ShortcutRegistry.js +52 -0
- package/dist/core/palette/fuzzyRank.d.ts +18 -0
- package/dist/core/palette/fuzzyRank.js +73 -0
- package/dist/core/palette/hotkeys.d.ts +28 -0
- package/dist/core/palette/hotkeys.js +118 -0
- package/dist/core/palette/index.d.ts +3 -0
- package/dist/core/palette/index.js +8 -0
- package/dist/core/schema/projection.d.ts +84 -0
- package/dist/core/schema/projection.js +184 -0
- package/dist/core/session/claims.d.ts +145 -0
- package/dist/core/session/claims.js +157 -0
- package/dist/core/sharing/index.d.ts +5 -0
- package/dist/core/sharing/index.js +4 -0
- package/dist/core/sharing/model.d.ts +195 -0
- package/dist/core/sharing/model.js +150 -0
- package/dist/core/site/siteCopy.d.ts +92 -0
- package/dist/core/site/siteCopy.js +91 -0
- package/dist/core/slots.d.ts +22 -0
- package/dist/core/slots.js +31 -0
- package/dist/core/styles/constructableSheet.d.ts +51 -0
- package/dist/core/styles/constructableSheet.js +80 -0
- package/dist/core/tokens/registry.d.ts +119 -0
- package/dist/core/tokens/registry.js +447 -0
- package/dist/core/uiScale/index.d.ts +8 -0
- package/dist/core/uiScale/index.js +8 -0
- package/dist/core/uiScale/scale.d.ts +36 -0
- package/dist/core/uiScale/scale.js +47 -0
- package/dist/core/uiScale/uiScaleStore.d.ts +47 -0
- package/dist/core/uiScale/uiScaleStore.js +63 -0
- package/dist/core/wire/__fixtures__/rowsMarshalWorker.d.ts +1 -0
- package/dist/core/wire/__fixtures__/rowsMarshalWorker.js +23 -0
- package/dist/core/wire/index.d.ts +17 -0
- package/dist/core/wire/index.js +17 -0
- package/dist/core/wire/ingress.d.ts +44 -0
- package/dist/core/wire/ingress.js +134 -0
- package/dist/core/wire/marshalWorker.d.ts +70 -0
- package/dist/core/wire/marshalWorker.js +273 -0
- package/dist/core/wire/marshalling.d.ts +122 -0
- package/dist/core/wire/marshalling.js +126 -0
- package/dist/core/wire/wireHub.d.ts +80 -0
- package/dist/core/wire/wireHub.js +116 -0
- package/dist/ctgr/base64url.d.ts +8 -0
- package/dist/ctgr/base64url.js +39 -0
- package/dist/ctgr/chunkAccumulator.d.ts +214 -0
- package/dist/ctgr/chunkAccumulator.js +307 -0
- package/dist/ctgr/chunkStore.d.ts +24 -0
- package/dist/ctgr/chunkStore.js +125 -0
- package/dist/ctgr/codec.d.ts +44 -0
- package/dist/ctgr/codec.js +153 -0
- package/dist/ctgr/compress.d.ts +2 -0
- package/dist/ctgr/compress.js +20 -0
- package/dist/ctgr/crc32.d.ts +2 -0
- package/dist/ctgr/crc32.js +23 -0
- package/dist/ctgr/digest.d.ts +1 -0
- package/dist/ctgr/digest.js +15 -0
- package/dist/ctgr/index.d.ts +9 -0
- package/dist/ctgr/index.js +17 -0
- package/dist/ctgr/types.d.ts +64 -0
- package/dist/ctgr/types.js +21 -0
- package/dist/ctgr/v0compat.d.ts +27 -0
- package/dist/ctgr/v0compat.js +128 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +17 -0
- package/dist/shared/errors.d.ts +101 -0
- package/dist/shared/errors.js +99 -0
- package/dist/testing/bytes.d.ts +32 -0
- package/dist/testing/bytes.js +33 -0
- package/dist/testing/domInspect.d.ts +59 -0
- package/dist/testing/domInspect.js +115 -0
- package/dist/testing/fakeFetch.d.ts +23 -0
- package/dist/testing/fakeFetch.js +21 -0
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.js +11 -0
- package/package.json +373 -0
- package/src/client/wsWire.ts +83 -0
- package/src/config/appConfig.spec.ts +158 -0
- package/src/config/appConfig.ts +372 -0
- package/src/core/activity/index.ts +21 -0
- package/src/core/activity/model.ts +160 -0
- package/src/core/analytics/index.ts +18 -0
- package/src/core/analytics/range.spec.ts +280 -0
- package/src/core/analytics/range.ts +383 -0
- package/src/core/analytics/schedule.spec.ts +311 -0
- package/src/core/analytics/schedule.ts +624 -0
- package/src/core/category/category.spec.ts +144 -0
- package/src/core/category/index.ts +19 -0
- package/src/core/category/memoryStore.ts +104 -0
- package/src/core/category/tree.ts +118 -0
- package/src/core/category/types.ts +59 -0
- package/src/core/color.spec.ts +35 -0
- package/src/core/color.ts +35 -0
- package/src/core/colorScheme/colorScheme.spec.ts +127 -0
- package/src/core/colorScheme/colorSchemeStore.ts +184 -0
- package/src/core/colorScheme/index.ts +23 -0
- package/src/core/domainOwners.ts +32 -0
- package/src/core/download.spec.ts +38 -0
- package/src/core/download.ts +35 -0
- package/src/core/events/ccEventBus.spec.ts +90 -0
- package/src/core/events/ccEventBus.ts +120 -0
- package/src/core/file-tree/fileTreeModel.spec.ts +462 -0
- package/src/core/file-tree/fileTreeModel.ts +511 -0
- package/src/core/folder-tree/actions.spec.ts +103 -0
- package/src/core/folder-tree/actions.ts +153 -0
- package/src/core/folder-tree/index.ts +46 -0
- package/src/core/folder-tree/path.spec.ts +113 -0
- package/src/core/folder-tree/path.ts +148 -0
- package/src/core/folder-tree/tree.spec.ts +157 -0
- package/src/core/folder-tree/tree.ts +244 -0
- package/src/core/layout/index.ts +7 -0
- package/src/core/master-lock/index.ts +50 -0
- package/src/core/master-lock/kdf.spec.ts +95 -0
- package/src/core/master-lock/kdf.ts +145 -0
- package/src/core/master-lock/policy.ts +55 -0
- package/src/core/master-lock/presence.spec.ts +73 -0
- package/src/core/master-lock/presence.ts +71 -0
- package/src/core/master-lock/wire.ts +155 -0
- package/src/core/media/bgRemoval.spec.ts +120 -0
- package/src/core/media/bgRemoval.ts +123 -0
- package/src/core/media/ffmpeg.spec.ts +135 -0
- package/src/core/media/ffmpeg.ts +162 -0
- package/src/core/media/format.spec.ts +41 -0
- package/src/core/media/format.ts +58 -0
- package/src/core/media/index.ts +105 -0
- package/src/core/media/mediaItem.spec.ts +46 -0
- package/src/core/media/mediaItem.ts +40 -0
- package/src/core/media/renderPlan.spec.ts +140 -0
- package/src/core/media/renderPlan.ts +318 -0
- package/src/core/media/segment.spec.ts +159 -0
- package/src/core/media/segment.ts +352 -0
- package/src/core/media/size.spec.ts +19 -0
- package/src/core/media/size.ts +3 -0
- package/src/core/media/trim.spec.ts +75 -0
- package/src/core/media/trim.ts +60 -0
- package/src/core/media/types.ts +147 -0
- package/src/core/media/uploadLimits.ts +18 -0
- package/src/core/media/validate.spec.ts +106 -0
- package/src/core/media/validate.ts +131 -0
- package/src/core/media/videoCuts.spec.ts +327 -0
- package/src/core/media/videoCuts.ts +355 -0
- package/src/core/navigation/breadcrumbCompat.ts +44 -0
- package/src/core/navigation/coverageTest.ts +54 -0
- package/src/core/navigation/index.ts +50 -0
- package/src/core/navigation/migrateNavPreferences.ts +119 -0
- package/src/core/navigation/navigation.spec.ts +388 -0
- package/src/core/navigation/navigationModel.ts +185 -0
- package/src/core/navigation/navigationSelectors.ts +206 -0
- package/src/core/navigation/searchCatalogue.ts +89 -0
- package/src/core/net/reconnectSchedule.spec.ts +49 -0
- package/src/core/net/reconnectSchedule.ts +78 -0
- package/src/core/palette/ShortcutRegistry.ts +69 -0
- package/src/core/palette/fuzzyRank.ts +78 -0
- package/src/core/palette/hotkeys.ts +132 -0
- package/src/core/palette/index.ts +22 -0
- package/src/core/palette/palette.spec.ts +160 -0
- package/src/core/schema/projection.spec.ts +122 -0
- package/src/core/schema/projection.ts +225 -0
- package/src/core/session/claims.ts +181 -0
- package/src/core/sharing/index.ts +33 -0
- package/src/core/sharing/model.spec.ts +104 -0
- package/src/core/sharing/model.ts +296 -0
- package/src/core/site/siteCopy.ts +115 -0
- package/src/core/slots.ts +38 -0
- package/src/core/styles/constructableSheet.ts +86 -0
- package/src/core/tokens/registry.spec.ts +160 -0
- package/src/core/tokens/registry.ts +536 -0
- package/src/core/uiScale/index.ts +32 -0
- package/src/core/uiScale/scale.ts +63 -0
- package/src/core/uiScale/uiScale.spec.ts +77 -0
- package/src/core/uiScale/uiScaleStore.ts +100 -0
- package/src/core/wire/__fixtures__/rowsMarshalWorker.ts +24 -0
- package/src/core/wire/index.ts +56 -0
- package/src/core/wire/ingress.spec.ts +276 -0
- package/src/core/wire/ingress.ts +182 -0
- package/src/core/wire/marshalWorker.spec.ts +196 -0
- package/src/core/wire/marshalWorker.ts +361 -0
- package/src/core/wire/marshalling.spec.ts +139 -0
- package/src/core/wire/marshalling.ts +238 -0
- package/src/core/wire/wireHub.spec.ts +137 -0
- package/src/core/wire/wireHub.ts +213 -0
- package/src/ctgr/base64url.ts +44 -0
- package/src/ctgr/chunkAccumulator.ts +403 -0
- package/src/ctgr/chunkStore.spec.ts +101 -0
- package/src/ctgr/chunkStore.ts +168 -0
- package/src/ctgr/codec.spec.ts +208 -0
- package/src/ctgr/codec.ts +202 -0
- package/src/ctgr/compress.ts +21 -0
- package/src/ctgr/crc32.ts +23 -0
- package/src/ctgr/digest.ts +14 -0
- package/src/ctgr/index.ts +39 -0
- package/src/ctgr/types.ts +80 -0
- package/src/ctgr/v0compat.spec.ts +73 -0
- package/src/ctgr/v0compat.ts +156 -0
- package/src/declaredDepsAreImported.spec.ts +87 -0
- package/src/index.ts +25 -0
- package/src/leafSubpathsImportNothing.spec.ts +287 -0
- package/src/lockfileIsTracked.spec.ts +50 -0
- package/src/namedSubpathsResolve.spec.ts +122 -0
- package/src/noPathDeps.spec.ts +24 -0
- package/src/publishShape.spec.ts +89 -0
- package/src/shared/errors.ts +153 -0
- package/src/skippedTestsAreAnswered.spec.ts +221 -0
- package/src/testTmpRoot.spec.ts +102 -0
- package/src/testing/bytes.ts +38 -0
- package/src/testing/domInspect.spec.ts +79 -0
- package/src/testing/domInspect.ts +127 -0
- package/src/testing/fakeFetch.ts +26 -0
- package/src/testing/index.ts +11 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port marshalling + the 512KB worker gate (02 §7.1).
|
|
3
|
+
*
|
|
4
|
+
* External payloads — webhooks, third-party feeds, spreadsheet imports — never
|
|
5
|
+
* touch component code. A marshaller registered on the token is the only entry,
|
|
6
|
+
* and its `transform` output is checked against the token's canonical Zod schema
|
|
7
|
+
* (V3 asserts this statically; here it is enforced at runtime too, because a
|
|
8
|
+
* provider can change its shape without anyone recompiling).
|
|
9
|
+
*
|
|
10
|
+
* **The size boundary is the interesting part.** Inbound payloads over 512KB are
|
|
11
|
+
* never validated or transformed on the main thread: a 4MB webhook body running
|
|
12
|
+
* a synchronous Zod parse blocks the request loop on the server and the render
|
|
13
|
+
* thread in the browser, and the symptom — "the app freezes sometimes" —
|
|
14
|
+
* is nearly impossible to trace back to one large payload. Above the threshold
|
|
15
|
+
* the work is offloaded to a Worker (structured-clone in, validated result out).
|
|
16
|
+
*/
|
|
17
|
+
import type { z } from 'zod';
|
|
18
|
+
import { AppError } from '../../shared/errors';
|
|
19
|
+
|
|
20
|
+
/** Above this, `validate` + `transform` are offloaded to a Worker (02 §7.1). */
|
|
21
|
+
export const MARSHALLER_WORKER_OFFLOAD_BYTES = 512 * 1024;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The ONE marshaller contract (05 §B1, unified 2026-07-26).
|
|
25
|
+
*
|
|
26
|
+
* There were TWO interfaces of this name in this package: this one, and a
|
|
27
|
+
* divergent copy in `core/tokens/registry.ts` that additionally required
|
|
28
|
+
* `tokenId` and `workerOffload` — with `MARSHALLER_WORKER_OFFLOAD_BYTES`
|
|
29
|
+
* declared twice alongside them. The verify pipeline (V3, `verify/types.ts`, the
|
|
30
|
+
* fixtures) read the registry's shape; `marshalInbound` below read this one. So a
|
|
31
|
+
* value that satisfied the runtime could be rejected by the type the pipeline
|
|
32
|
+
* checks, and the two thresholds could drift apart silently. The registry's
|
|
33
|
+
* shape won — it is the one the pipeline enforces — and `registry.ts` now
|
|
34
|
+
* re-exports both symbols from here.
|
|
35
|
+
*/
|
|
36
|
+
export interface CCWireMarshaller<Inbound = unknown, Out = unknown> {
|
|
37
|
+
id: string;
|
|
38
|
+
/** The wire token this marshaller feeds — V3 resolves it against the registry. */
|
|
39
|
+
tokenId: string;
|
|
40
|
+
/** REQUIRED — V3 `marshaller-payload-unbounded` when absent or non-positive. */
|
|
41
|
+
maxPayloadBytes: number;
|
|
42
|
+
/**
|
|
43
|
+
* The module that runs this marshaller's `validate`+`transform` off the main
|
|
44
|
+
* thread — an entry calling `serveMarshalWorker` (`core/wire/marshalWorker.ts`).
|
|
45
|
+
*
|
|
46
|
+
* **This replaced a `workerOffload: boolean` in the Worker commit (05 §B1).**
|
|
47
|
+
* That flag was authored by hand and asserted a capability the codebase did
|
|
48
|
+
* not have: every marshaller in the repo set it `true` while no worker
|
|
49
|
+
* existed anywhere, so the V3 gate it fed was checking that someone had
|
|
50
|
+
* remembered to type `true`. Whether a marshaller can offload is now a
|
|
51
|
+
* question about an ARTIFACT — this entry either exists or it does not — and
|
|
52
|
+
* whether it NEEDS to is {@link requiresWorkerOffload}, derived from
|
|
53
|
+
* `maxPayloadBytes`. Neither is a promise anyone can make by hand.
|
|
54
|
+
*
|
|
55
|
+
* Optional because a marshaller whose `maxPayloadBytes` sits under the
|
|
56
|
+
* main-thread limit can never reach the offload path; V3 requires it of
|
|
57
|
+
* exactly those that can.
|
|
58
|
+
*/
|
|
59
|
+
workerEntry?: string;
|
|
60
|
+
validate(inbound: Inbound): boolean;
|
|
61
|
+
transform(inbound: Inbound): Out;
|
|
62
|
+
/** V3 checks `transform` output against the token's canonical schema. */
|
|
63
|
+
targetZodSchema: z.ZodType<Out>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Can this marshaller ever reach the offload path? A DERIVED fact (05 §B1).
|
|
68
|
+
*
|
|
69
|
+
* A marshaller that refuses anything over 256KB can never produce a payload
|
|
70
|
+
* above the 512KB main-thread limit, so it needs no worker and demanding one
|
|
71
|
+
* would be noise. One whose ceiling is 2MB certainly can, and without a
|
|
72
|
+
* `workerEntry` its large payloads are a guaranteed 503 — which is why V3
|
|
73
|
+
* refuses to verify that combination rather than letting it surface in
|
|
74
|
+
* production as an intermittent failure on the biggest imports.
|
|
75
|
+
*/
|
|
76
|
+
export function requiresWorkerOffload(
|
|
77
|
+
marshaller: Pick<CCWireMarshaller, 'maxPayloadBytes'>,
|
|
78
|
+
thresholdBytes: number = MARSHALLER_WORKER_OFFLOAD_BYTES,
|
|
79
|
+
): boolean {
|
|
80
|
+
return marshaller.maxPayloadBytes > thresholdBytes;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const MARSHALLER_PAYLOAD_TOO_LARGE = 'MARSHALLER_PAYLOAD_TOO_LARGE';
|
|
84
|
+
export const MARSHALLER_VALIDATION_FAILED = 'MARSHALLER_VALIDATION_FAILED';
|
|
85
|
+
export const MARSHALLER_SCHEMA_VIOLATION = 'MARSHALLER_SCHEMA_VIOLATION';
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Measure a payload's wire size.
|
|
89
|
+
*
|
|
90
|
+
* Uses the UTF-8 byte length, not `string.length`: a 400k-character document of
|
|
91
|
+
* CJK text is ~1.2MB on the wire, and sizing it by character count would sail
|
|
92
|
+
* past the offload threshold on the main thread.
|
|
93
|
+
*/
|
|
94
|
+
export function payloadBytes(payload: unknown): number {
|
|
95
|
+
if (typeof payload === 'string') return new TextEncoder().encode(payload).length;
|
|
96
|
+
if (payload instanceof ArrayBuffer) return payload.byteLength;
|
|
97
|
+
if (ArrayBuffer.isView(payload)) return payload.byteLength;
|
|
98
|
+
try {
|
|
99
|
+
return new TextEncoder().encode(JSON.stringify(payload) ?? '').length;
|
|
100
|
+
} catch {
|
|
101
|
+
// A cyclic or non-serializable payload cannot be structured-cloned into a
|
|
102
|
+
// worker either, so treat it as over-threshold and let the caller refuse.
|
|
103
|
+
return Number.POSITIVE_INFINITY;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The worker seam — injected so tests and SSR can run without a real Worker. */
|
|
108
|
+
export interface MarshalWorker {
|
|
109
|
+
run<Inbound, Out>(marshallerId: string, payload: Inbound): Promise<Out>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface MarshalOptions {
|
|
113
|
+
/**
|
|
114
|
+
* Use THIS worker instead of the one the marshaller's `workerEntry` names.
|
|
115
|
+
* Left unset in production: offload is the default path, resolved from the
|
|
116
|
+
* marshaller itself, so an ingress route needs no worker wiring of its own.
|
|
117
|
+
*/
|
|
118
|
+
worker?: MarshalWorker;
|
|
119
|
+
/**
|
|
120
|
+
* Resolves a worker for a marshaller that declares a `workerEntry`. The host
|
|
121
|
+
* supplies this once (`createMarshalWorkerClient`, cached per entry) — it is a
|
|
122
|
+
* parameter rather than a module-level singleton because a worker is a live
|
|
123
|
+
* OS thread, and something has to own disposing of it.
|
|
124
|
+
*/
|
|
125
|
+
resolveWorker?: (marshaller: CCWireMarshaller<never, never>) => MarshalWorker | undefined;
|
|
126
|
+
/** Override the offload threshold (tests drive it down to exercise the path). */
|
|
127
|
+
offloadThresholdBytes?: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Run a payload through its marshaller, offloading past the size gate.
|
|
132
|
+
*
|
|
133
|
+
* Order matters: the marshaller's OWN `maxPayloadBytes` is checked first, so an
|
|
134
|
+
* oversized payload is refused with a typed diagnostic before any work — worker
|
|
135
|
+
* or otherwise — is scheduled for it.
|
|
136
|
+
*/
|
|
137
|
+
export async function marshalInbound<Inbound, Out>(
|
|
138
|
+
marshaller: CCWireMarshaller<Inbound, Out>,
|
|
139
|
+
payload: Inbound,
|
|
140
|
+
options: MarshalOptions = {},
|
|
141
|
+
): Promise<Out> {
|
|
142
|
+
const bytes = payloadBytes(payload);
|
|
143
|
+
|
|
144
|
+
if (bytes > marshaller.maxPayloadBytes)
|
|
145
|
+
throw new AppError(
|
|
146
|
+
`Payload of ${bytes} bytes exceeds marshaller '${marshaller.id}' limit of ${marshaller.maxPayloadBytes}.`,
|
|
147
|
+
{
|
|
148
|
+
code: MARSHALLER_PAYLOAD_TOO_LARGE,
|
|
149
|
+
status: 413,
|
|
150
|
+
context: { marshaller: marshaller.id, bytes, maxPayloadBytes: marshaller.maxPayloadBytes },
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const threshold = options.offloadThresholdBytes ?? MARSHALLER_WORKER_OFFLOAD_BYTES;
|
|
155
|
+
|
|
156
|
+
if (bytes > threshold) {
|
|
157
|
+
// Offload is the DEFAULT path (05 §B1): an explicitly injected worker wins,
|
|
158
|
+
// otherwise the marshaller's own `workerEntry` is resolved through the host's
|
|
159
|
+
// resolver. Neither present means this marshaller has no offload path at all
|
|
160
|
+
// — which V3 refuses to verify, so a verified app never reaches this throw.
|
|
161
|
+
const worker =
|
|
162
|
+
options.worker ??
|
|
163
|
+
(marshaller.workerEntry ? options.resolveWorker?.(marshaller as CCWireMarshaller<never, never>) : undefined);
|
|
164
|
+
if (!worker)
|
|
165
|
+
throw new AppError(
|
|
166
|
+
`Payload of ${bytes} bytes exceeds the ${threshold}-byte main-thread limit and marshaller '${marshaller.id}' has no marshalling worker.`,
|
|
167
|
+
{
|
|
168
|
+
code: MARSHALLER_PAYLOAD_TOO_LARGE,
|
|
169
|
+
status: 503,
|
|
170
|
+
context: {
|
|
171
|
+
marshaller: marshaller.id,
|
|
172
|
+
bytes,
|
|
173
|
+
threshold,
|
|
174
|
+
workerEntry: marshaller.workerEntry ?? null,
|
|
175
|
+
hint: marshaller.workerEntry
|
|
176
|
+
? 'The marshaller declares a workerEntry but no resolveWorker was supplied — pass createMarshalWorkerClient through MarshalOptions.resolveWorker.'
|
|
177
|
+
: 'Give the marshaller a workerEntry (a module calling serveMarshalWorker), or lower maxPayloadBytes below the main-thread limit.',
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
// The worker validates AND transforms; the result still gets schema-checked
|
|
182
|
+
// below, because a worker is a thread boundary, not a trust boundary.
|
|
183
|
+
const result = await worker.run<Inbound, Out>(marshaller.id, payload);
|
|
184
|
+
return assertSchema(marshaller, result);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!marshaller.validate(payload))
|
|
188
|
+
throw new AppError(`Inbound payload rejected by marshaller '${marshaller.id}'.`, {
|
|
189
|
+
code: MARSHALLER_VALIDATION_FAILED,
|
|
190
|
+
status: 400,
|
|
191
|
+
context: { marshaller: marshaller.id },
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return assertSchema(marshaller, marshaller.transform(payload));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The canonical-schema check every path funnels through. Running it on the
|
|
199
|
+
* worker result too is what stops a drifting provider shape from entering the
|
|
200
|
+
* hub as a well-typed lie.
|
|
201
|
+
*/
|
|
202
|
+
function assertSchema<Inbound, Out>(marshaller: CCWireMarshaller<Inbound, Out>, value: Out): Out {
|
|
203
|
+
const parsed = marshaller.targetZodSchema.safeParse(value);
|
|
204
|
+
if (!parsed.success)
|
|
205
|
+
throw new AppError(`Marshaller '${marshaller.id}' produced output that violates its token's schema.`, {
|
|
206
|
+
code: MARSHALLER_SCHEMA_VIOLATION,
|
|
207
|
+
status: 502,
|
|
208
|
+
context: {
|
|
209
|
+
marshaller: marshaller.id,
|
|
210
|
+
issues: parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`),
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
return parsed.data;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The registry of marshallers, keyed by token id (02 §7.1: registered ON the token). */
|
|
217
|
+
export interface MarshallerRegistry {
|
|
218
|
+
register(tokenId: string, marshaller: CCWireMarshaller): void;
|
|
219
|
+
forToken(tokenId: string): CCWireMarshaller | undefined;
|
|
220
|
+
all(): readonly { tokenId: string; marshaller: CCWireMarshaller }[];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function createMarshallerRegistry(): MarshallerRegistry {
|
|
224
|
+
const byToken = new Map<string, CCWireMarshaller>();
|
|
225
|
+
return {
|
|
226
|
+
register(tokenId, marshaller) {
|
|
227
|
+
if (marshaller.maxPayloadBytes === undefined || marshaller.maxPayloadBytes <= 0)
|
|
228
|
+
throw new AppError(`Marshaller '${marshaller.id}' must declare a positive maxPayloadBytes.`, {
|
|
229
|
+
code: 'marshaller-payload-unbounded',
|
|
230
|
+
status: 500,
|
|
231
|
+
context: { marshaller: marshaller.id, tokenId },
|
|
232
|
+
});
|
|
233
|
+
byToken.set(tokenId, marshaller);
|
|
234
|
+
},
|
|
235
|
+
forToken: (tokenId) => byToken.get(tokenId),
|
|
236
|
+
all: () => [...byToken.entries()].map(([tokenId, marshaller]) => ({ tokenId, marshaller })),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { createInstanceRuntime, createWireHub, type WireEnvelope } from './wireHub';
|
|
3
|
+
|
|
4
|
+
const envelope = <T>(rev: number, payload: T, scope?: string): WireEnvelope<T> => ({ rev, at: rev, payload, scope });
|
|
5
|
+
|
|
6
|
+
describe('wire hub — settle by revision (R-5)', () => {
|
|
7
|
+
it('delivers a new rev and drops a repeat of the same rev', () => {
|
|
8
|
+
const hub = createWireHub();
|
|
9
|
+
const seen: number[] = [];
|
|
10
|
+
hub.subscribe<number>('gallery:videos-out', (e) => seen.push(e.payload));
|
|
11
|
+
|
|
12
|
+
expect(hub.emit('gallery:videos-out', envelope(1, 10))).toBe('delivered');
|
|
13
|
+
expect(hub.emit('gallery:videos-out', envelope(1, 10))).toBe('settled');
|
|
14
|
+
expect(hub.emit('gallery:videos-out', envelope(2, 20))).toBe('delivered');
|
|
15
|
+
expect(seen).toEqual([10, 20]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('is inert against a producer that re-allocates an equal payload every tick', () => {
|
|
19
|
+
const hub = createWireHub();
|
|
20
|
+
let deliveries = 0;
|
|
21
|
+
hub.subscribe('gallery:videos-out', () => {
|
|
22
|
+
deliveries += 1;
|
|
23
|
+
});
|
|
24
|
+
for (let i = 0; i < 50; i++) hub.emit('gallery:videos-out', envelope(7, { items: [{ id: 'a' }] }));
|
|
25
|
+
expect(deliveries).toBe(1); // object identity is irrelevant; rev decides
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('retains the last value for late subscribers', () => {
|
|
29
|
+
const hub = createWireHub();
|
|
30
|
+
hub.emit('gallery:videos-out', envelope(3, 'payload'));
|
|
31
|
+
expect(hub.retained<string>('gallery:videos-out')?.payload).toBe('payload');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('trips the storm breaker past the channel budget', () => {
|
|
35
|
+
const hub = createWireHub({ stormBudget: 5, windowMs: 10_000, now: () => 0 });
|
|
36
|
+
const outcomes: string[] = [];
|
|
37
|
+
for (let rev = 1; rev <= 8; rev++) outcomes.push(hub.emit('noisy:port', envelope(rev, rev)));
|
|
38
|
+
expect(outcomes.filter((o) => o === 'breaker-tripped').length).toBe(3);
|
|
39
|
+
expect(hub.breakerTrips()[0]?.channel).toBe('noisy:port');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('removes a listener on teardown', () => {
|
|
43
|
+
const hub = createWireHub();
|
|
44
|
+
const off = hub.subscribe('a:b', () => {});
|
|
45
|
+
expect(hub.listenerCount('a:b')).toBe(1);
|
|
46
|
+
off();
|
|
47
|
+
expect(hub.listenerCount('a:b')).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('instance runtime — two-tier subscriptions (R-4 ∧ J3-3)', () => {
|
|
52
|
+
it('records only {dirty,lastRev} in the ledger, never a payload copy', async () => {
|
|
53
|
+
const hub = createWireHub();
|
|
54
|
+
const runtime = createInstanceRuntime({ id: 'notes', hub, intakeChannels: ['notes:refresh-in'] });
|
|
55
|
+
await runtime.onLoad();
|
|
56
|
+
await runtime.onReady();
|
|
57
|
+
|
|
58
|
+
hub.emit('notes:refresh-in', envelope(4, { huge: 'payload' }));
|
|
59
|
+
const entry = runtime.ledger().get('notes:refresh-in');
|
|
60
|
+
expect(entry).toEqual({ dirty: true, lastRev: 4 });
|
|
61
|
+
expect(JSON.stringify(entry)).not.toContain('huge');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('pulls the retained envelope on mount only when the ledger is ahead', async () => {
|
|
65
|
+
const hub = createWireHub();
|
|
66
|
+
const runtime = createInstanceRuntime({ id: 'notes', hub, intakeChannels: ['notes:refresh-in'] });
|
|
67
|
+
await runtime.onLoad();
|
|
68
|
+
await runtime.onReady();
|
|
69
|
+
|
|
70
|
+
hub.emit('notes:refresh-in', envelope(9, 'fresh'));
|
|
71
|
+
expect(runtime.pullIfDirty<string>('notes:refresh-in', 8)?.payload).toBe('fresh');
|
|
72
|
+
expect(runtime.ledger().get('notes:refresh-in')?.dirty).toBe(false);
|
|
73
|
+
expect(runtime.pullIfDirty('notes:refresh-in', 9)).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('tears intake down at unmount so navigation cannot accumulate listeners', async () => {
|
|
77
|
+
const hub = createWireHub();
|
|
78
|
+
const runtime = createInstanceRuntime({ id: 'notes', hub, intakeChannels: ['notes:refresh-in'] });
|
|
79
|
+
await runtime.onLoad();
|
|
80
|
+
await runtime.onReady();
|
|
81
|
+
expect(hub.listenerCount('notes:refresh-in')).toBe(1);
|
|
82
|
+
runtime.onUnmount();
|
|
83
|
+
expect(hub.listenerCount('notes:refresh-in')).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('instance runtime — lifecycle and abort interception', () => {
|
|
88
|
+
it('enforces onLoad → onReady ordering', async () => {
|
|
89
|
+
const hub = createWireHub();
|
|
90
|
+
const runtime = createInstanceRuntime({ id: 'x', hub, intakeChannels: [] });
|
|
91
|
+
await runtime.onReady();
|
|
92
|
+
expect(runtime.lifecycleErrors()[0]).toContain('onReady called in phase');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('reports a double unmount', async () => {
|
|
96
|
+
const hub = createWireHub();
|
|
97
|
+
const runtime = createInstanceRuntime({ id: 'x', hub, intakeChannels: [] });
|
|
98
|
+
await runtime.onLoad();
|
|
99
|
+
await runtime.onReady();
|
|
100
|
+
runtime.onUnmount();
|
|
101
|
+
runtime.onUnmount();
|
|
102
|
+
expect(runtime.lifecycleErrors()).toContain('onUnmount called twice');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('no-ops a state write that arrives after unmount', async () => {
|
|
106
|
+
const hub = createWireHub();
|
|
107
|
+
const runtime = createInstanceRuntime({ id: 'x', hub, intakeChannels: [] });
|
|
108
|
+
await runtime.onLoad();
|
|
109
|
+
await runtime.onReady();
|
|
110
|
+
|
|
111
|
+
let value = 0;
|
|
112
|
+
const apply = runtime.guarded<number>((next) => {
|
|
113
|
+
value = next;
|
|
114
|
+
});
|
|
115
|
+
expect(apply(1)).toBe(true);
|
|
116
|
+
runtime.onUnmount();
|
|
117
|
+
expect(apply(2)).toBe(false);
|
|
118
|
+
expect(value).toBe(1);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('aborts the view scope on suspend and the instance scope on unmount', async () => {
|
|
122
|
+
const hub = createWireHub();
|
|
123
|
+
const runtime = createInstanceRuntime({ id: 'x', hub, intakeChannels: [] });
|
|
124
|
+
await runtime.onLoad();
|
|
125
|
+
await runtime.onReady();
|
|
126
|
+
|
|
127
|
+
const viewSignal = runtime.viewSignal();
|
|
128
|
+
runtime.onSuspend();
|
|
129
|
+
expect(viewSignal.aborted).toBe(true);
|
|
130
|
+
expect(runtime.signal.aborted).toBe(false); // the instance survives navigation away
|
|
131
|
+
|
|
132
|
+
await runtime.onReady();
|
|
133
|
+
runtime.onUnmount();
|
|
134
|
+
expect(runtime.signal.aborted).toBe(true);
|
|
135
|
+
expect(runtime.phase()).toBe('unmounted');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The port hub and instance lifecycle (03 §6.1–§6.2) — the two primitives V7's
|
|
3
|
+
* wire-smoke and lifecycle probes assert against.
|
|
4
|
+
*
|
|
5
|
+
* **Settle by revision, not identity (R-5).** Every value emission is a
|
|
6
|
+
* `WireEnvelope { rev, at, payload }` and a delivery whose `rev` equals the last
|
|
7
|
+
* delivered one for that channel is dropped. Object identity is irrelevant,
|
|
8
|
+
* which is what makes a server that re-allocates an equal payload on every tick
|
|
9
|
+
* inert instead of a delivery storm.
|
|
10
|
+
*
|
|
11
|
+
* **Two subscription tiers (R-4 ∧ J3-3).** Instance-level intake lives from
|
|
12
|
+
* `onReady` to `onUnmount` and writes only `{dirty, lastRev}` into a ledger —
|
|
13
|
+
* never a payload copy, so an unrendered feature costs O(1) regardless of
|
|
14
|
+
* traffic. Pane-level listeners live only while a pane is mounted and are torn
|
|
15
|
+
* down on unmount, so navigation cannot accumulate dangling listeners. On mount
|
|
16
|
+
* a pane compares the ledger's `lastRev` against what it rendered and pulls the
|
|
17
|
+
* retained envelope if it is behind — no signal is lost, no payload is buffered.
|
|
18
|
+
*
|
|
19
|
+
* **Abort interception.** `InstanceRuntime.signal` fans into everything the
|
|
20
|
+
* instance starts; `onSuspend` aborts the view scope and `onUnmount` the instance
|
|
21
|
+
* scope. State setters routed through `guarded()` no-op after abort, so a late
|
|
22
|
+
* async result is structurally unable to write into an unmounted instance.
|
|
23
|
+
*/
|
|
24
|
+
export interface WireEnvelope<T = unknown> {
|
|
25
|
+
/** Monotonic per (instance, port); bump ONLY on content change. */
|
|
26
|
+
rev: number;
|
|
27
|
+
scope?: string;
|
|
28
|
+
at: number;
|
|
29
|
+
payload: T;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface LedgerEntry {
|
|
33
|
+
dirty: boolean;
|
|
34
|
+
lastRev: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface StormBreakerTrip {
|
|
38
|
+
channel: string;
|
|
39
|
+
count: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface WireHubOptions {
|
|
43
|
+
/** Deliveries per channel per window before the breaker trips (04 §2 V4). */
|
|
44
|
+
stormBudget?: number;
|
|
45
|
+
windowMs?: number;
|
|
46
|
+
now?: () => number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface WireHub {
|
|
50
|
+
emit<T>(channel: string, envelope: WireEnvelope<T>): 'delivered' | 'settled' | 'breaker-tripped';
|
|
51
|
+
/** Pane-level listener: live re-render while mounted; returns its teardown. */
|
|
52
|
+
subscribe<T>(channel: string, listener: (envelope: WireEnvelope<T>) => void): () => void;
|
|
53
|
+
retained<T>(channel: string): WireEnvelope<T> | undefined;
|
|
54
|
+
listenerCount(channel: string): number;
|
|
55
|
+
breakerTrips(): readonly StormBreakerTrip[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createWireHub(options: WireHubOptions = {}): WireHub {
|
|
59
|
+
const budget = options.stormBudget ?? 200;
|
|
60
|
+
const windowMs = options.windowMs ?? 1_000;
|
|
61
|
+
const now = options.now ?? (() => Date.now());
|
|
62
|
+
|
|
63
|
+
const listeners = new Map<string, ((envelope: WireEnvelope<unknown>) => void)[]>();
|
|
64
|
+
const retainedValues = new Map<string, WireEnvelope<unknown>>();
|
|
65
|
+
const lastRevs = new Map<string, number>();
|
|
66
|
+
const windows = new Map<string, { start: number; count: number }>();
|
|
67
|
+
const trips: StormBreakerTrip[] = [];
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
emit(channel, envelope) {
|
|
71
|
+
const window = windows.get(channel);
|
|
72
|
+
const stamp = now();
|
|
73
|
+
if (!window || stamp - window.start > windowMs) windows.set(channel, { start: stamp, count: 1 });
|
|
74
|
+
else {
|
|
75
|
+
window.count += 1;
|
|
76
|
+
if (window.count > budget) {
|
|
77
|
+
trips.push({ channel, count: window.count });
|
|
78
|
+
return 'breaker-tripped';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (lastRevs.get(channel) === envelope.rev) return 'settled'; // identity is irrelevant; rev decides
|
|
83
|
+
lastRevs.set(channel, envelope.rev);
|
|
84
|
+
retainedValues.set(channel, envelope as WireEnvelope<unknown>);
|
|
85
|
+
for (const listener of [...(listeners.get(channel) ?? [])]) listener(envelope as WireEnvelope<unknown>);
|
|
86
|
+
return 'delivered';
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
subscribe(channel, listener) {
|
|
90
|
+
const list = listeners.get(channel) ?? [];
|
|
91
|
+
const typed = listener as (envelope: WireEnvelope<unknown>) => void;
|
|
92
|
+
list.push(typed);
|
|
93
|
+
listeners.set(channel, list);
|
|
94
|
+
return () => {
|
|
95
|
+
const current = listeners.get(channel) ?? [];
|
|
96
|
+
const index = current.indexOf(typed);
|
|
97
|
+
if (index >= 0) current.splice(index, 1);
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
retained: <T>(channel: string) => retainedValues.get(channel) as WireEnvelope<T> | undefined,
|
|
102
|
+
listenerCount: (channel) => (listeners.get(channel) ?? []).length,
|
|
103
|
+
breakerTrips: () => trips,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type LifecyclePhase = 'created' | 'loaded' | 'ready' | 'suspended' | 'unmounted';
|
|
108
|
+
|
|
109
|
+
export interface InstanceRuntime {
|
|
110
|
+
id: string;
|
|
111
|
+
phase(): LifecyclePhase;
|
|
112
|
+
/** Order is strict and verified: onLoad → onReady before the first view render. */
|
|
113
|
+
onLoad(): Promise<void>;
|
|
114
|
+
onReady(): Promise<void>;
|
|
115
|
+
onSuspend(): void;
|
|
116
|
+
onUnmount(): void;
|
|
117
|
+
/** Instance-scoped abort signal — fans into every fetch and background loop. */
|
|
118
|
+
signal: AbortSignal;
|
|
119
|
+
/** View-scoped signal; aborted by onSuspend and re-created on the next render. */
|
|
120
|
+
viewSignal(): AbortSignal;
|
|
121
|
+
/** Wraps a state setter so a late async result cannot write after abort. */
|
|
122
|
+
guarded<T>(apply: (value: T) => void): (value: T) => boolean;
|
|
123
|
+
ledger(): ReadonlyMap<string, LedgerEntry>;
|
|
124
|
+
/** Pane mount: pull the retained envelope iff the ledger is ahead of what was rendered. */
|
|
125
|
+
pullIfDirty<T>(channel: string, renderedRev: number): WireEnvelope<T> | undefined;
|
|
126
|
+
lifecycleErrors(): readonly string[];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface InstanceRuntimeOptions {
|
|
130
|
+
id: string;
|
|
131
|
+
hub: WireHub;
|
|
132
|
+
/** Channels the instance takes intake on at onReady (`subscribesAtMount` ports). */
|
|
133
|
+
intakeChannels: readonly string[];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function createInstanceRuntime(options: InstanceRuntimeOptions): InstanceRuntime {
|
|
137
|
+
const instanceController = new AbortController();
|
|
138
|
+
let viewController = new AbortController();
|
|
139
|
+
let phase: LifecyclePhase = 'created';
|
|
140
|
+
const ledger = new Map<string, LedgerEntry>();
|
|
141
|
+
const teardowns: (() => void)[] = [];
|
|
142
|
+
const errors: string[] = [];
|
|
143
|
+
|
|
144
|
+
const requirePhase = (expected: LifecyclePhase[], transition: string): void => {
|
|
145
|
+
if (!expected.includes(phase)) errors.push(`${transition} called in phase '${phase}' (expected ${expected.join('|')})`);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
id: options.id,
|
|
150
|
+
phase: () => phase,
|
|
151
|
+
|
|
152
|
+
async onLoad() {
|
|
153
|
+
requirePhase(['created'], 'onLoad');
|
|
154
|
+
phase = 'loaded';
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
async onReady() {
|
|
158
|
+
requirePhase(['loaded', 'suspended'], 'onReady');
|
|
159
|
+
if (teardowns.length === 0) {
|
|
160
|
+
for (const channel of options.intakeChannels) {
|
|
161
|
+
ledger.set(channel, { dirty: false, lastRev: -1 });
|
|
162
|
+
// Intake writes a flag and a rev — never a payload copy (R-4).
|
|
163
|
+
teardowns.push(
|
|
164
|
+
options.hub.subscribe(channel, (envelope) => {
|
|
165
|
+
ledger.set(channel, { dirty: true, lastRev: envelope.rev });
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
phase = 'ready';
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
onSuspend() {
|
|
174
|
+
requirePhase(['ready', 'suspended'], 'onSuspend'); // idempotent by contract
|
|
175
|
+
viewController.abort();
|
|
176
|
+
viewController = new AbortController();
|
|
177
|
+
phase = 'suspended';
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
onUnmount() {
|
|
181
|
+
if (phase === 'unmounted') {
|
|
182
|
+
errors.push('onUnmount called twice');
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
viewController.abort();
|
|
186
|
+
instanceController.abort();
|
|
187
|
+
for (const teardown of teardowns.splice(0)) teardown();
|
|
188
|
+
phase = 'unmounted';
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
signal: instanceController.signal,
|
|
192
|
+
viewSignal: () => viewController.signal,
|
|
193
|
+
|
|
194
|
+
guarded<T>(apply: (value: T) => void) {
|
|
195
|
+
return (value: T) => {
|
|
196
|
+
if (instanceController.signal.aborted || viewController.signal.aborted) return false;
|
|
197
|
+
apply(value);
|
|
198
|
+
return true;
|
|
199
|
+
};
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
ledger: () => ledger,
|
|
203
|
+
|
|
204
|
+
pullIfDirty<T>(channel: string, renderedRev: number) {
|
|
205
|
+
const entry = ledger.get(channel);
|
|
206
|
+
if (!entry || entry.lastRev === renderedRev) return undefined;
|
|
207
|
+
ledger.set(channel, { dirty: false, lastRev: entry.lastRev });
|
|
208
|
+
return options.hub.retained<T>(channel);
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
lifecycleErrors: () => errors,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Base64 over raw bytes, isomorphic via `btoa`/`atob` (present on Bun and in the
|
|
3
|
+
* browser). The binary string is built in 32 KB blocks so a large payload never
|
|
4
|
+
* overflows the call stack via `String.fromCharCode(...all)`.
|
|
5
|
+
*
|
|
6
|
+
* Two flavors:
|
|
7
|
+
* - base64URL (`-`/`_`, no padding) for the per-chunk `_d` payload (URL-safe).
|
|
8
|
+
* - standard base64 for the `__files` data carried inside the JSON body.
|
|
9
|
+
*/
|
|
10
|
+
const STEP = 0x8000;
|
|
11
|
+
|
|
12
|
+
function toBinary(bytes: Uint8Array): string {
|
|
13
|
+
let binary = '';
|
|
14
|
+
for (let i = 0; i < bytes.length; i += STEP) {
|
|
15
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + STEP));
|
|
16
|
+
}
|
|
17
|
+
return binary;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function fromBinary(binary: string): Uint8Array {
|
|
21
|
+
const bytes = new Uint8Array(binary.length);
|
|
22
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
23
|
+
return bytes;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** URL-safe base64 of `bytes` (RFC 4648 §5: `+`→`-`, `/`→`_`, no `=` padding). */
|
|
27
|
+
export function toBase64url(bytes: Uint8Array): string {
|
|
28
|
+
return btoa(toBinary(bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Inverse of {@link toBase64url}. */
|
|
32
|
+
export function fromBase64url(value: string): Uint8Array {
|
|
33
|
+
return fromBinary(atob(value.replace(/-/g, '+').replace(/_/g, '/')));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Standard base64 (with `+`/`/` and `=` padding). */
|
|
37
|
+
export function toBase64(bytes: Uint8Array): string {
|
|
38
|
+
return btoa(toBinary(bytes));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Inverse of {@link toBase64}. */
|
|
42
|
+
export function fromBase64(value: string): Uint8Array {
|
|
43
|
+
return fromBinary(atob(value));
|
|
44
|
+
}
|