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,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The marshalling Worker (05 §B1, second half) — `validate` + `transform` for a
|
|
3
|
+
* large inbound payload, off the thread that has to answer requests.
|
|
4
|
+
*
|
|
5
|
+
* `marshalling.ts` has always had the SIZE BOUNDARY and the injection seam; what
|
|
6
|
+
* it never had was anything to hand a large payload to, so a >512KB webhook body
|
|
7
|
+
* got a 503 with no documentation anywhere that a limit existed. This module is
|
|
8
|
+
* the missing half: a real, long-lived worker with a request/response protocol,
|
|
9
|
+
* on both runtimes cursedbelt ships to.
|
|
10
|
+
*
|
|
11
|
+
* ## Why the marshaller names its own worker entry
|
|
12
|
+
*
|
|
13
|
+
* A marshaller is mostly FUNCTIONS (`validate`, `transform`, a Zod schema), and
|
|
14
|
+
* functions do not survive structured clone — a worker cannot be handed one, it
|
|
15
|
+
* has to `import` it. The obvious design (one shared worker that resolves any
|
|
16
|
+
* marshaller by id from a registry module) needs the host to declare, register
|
|
17
|
+
* and keep in sync a second module listing every marshaller, and it gives that
|
|
18
|
+
* one worker the authority to run all of them.
|
|
19
|
+
*
|
|
20
|
+
* Instead each marshaller declares `workerEntry`: the module that constructs
|
|
21
|
+
* exactly that marshaller and calls {@link serveMarshalWorker}. The worker can
|
|
22
|
+
* therefore only ever run the one marshaller it was built for, the host wires
|
|
23
|
+
* nothing, and — the part that matters for the gate — "can this marshaller
|
|
24
|
+
* offload?" stops being a boolean somebody remembered to set and becomes a
|
|
25
|
+
* question about an artifact that either exists or does not. V3 checks it
|
|
26
|
+
* statically (`marshaller-worker-offload-missing`), so the 503 path below is
|
|
27
|
+
* unreachable in any app that passed verification.
|
|
28
|
+
*
|
|
29
|
+
* ## The two runtimes
|
|
30
|
+
*
|
|
31
|
+
* Server (Bun/Node) uses `node:worker_threads`; the browser uses a module
|
|
32
|
+
* `Worker`. The import of `node:worker_threads` is deliberately DYNAMIC and
|
|
33
|
+
* inside the server branch — a static import would put a Node builtin in the
|
|
34
|
+
* module graph of every client bundle that touches wire code.
|
|
35
|
+
*/
|
|
36
|
+
import { AppError } from '../../shared/errors';
|
|
37
|
+
import {
|
|
38
|
+
type CCWireMarshaller,
|
|
39
|
+
MARSHALLER_SCHEMA_VIOLATION,
|
|
40
|
+
MARSHALLER_VALIDATION_FAILED,
|
|
41
|
+
type MarshalWorker,
|
|
42
|
+
} from './marshalling';
|
|
43
|
+
|
|
44
|
+
export const MARSHALLER_WORKER_FAILED = 'MARSHALLER_WORKER_FAILED';
|
|
45
|
+
export const MARSHALLER_WORKER_TIMEOUT = 'MARSHALLER_WORKER_TIMEOUT';
|
|
46
|
+
|
|
47
|
+
/** How long one payload may occupy the worker before the host gives up. */
|
|
48
|
+
export const MARSHAL_WORKER_DEFAULT_TIMEOUT_MS = 30_000;
|
|
49
|
+
|
|
50
|
+
/* -------------------------------------------------------------- protocol */
|
|
51
|
+
|
|
52
|
+
export interface MarshalWorkerRequest {
|
|
53
|
+
/** Correlates the reply — several payloads are in flight at once. */
|
|
54
|
+
id: number;
|
|
55
|
+
payload: unknown;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type MarshalWorkerResponse =
|
|
59
|
+
| { id: number; ok: true; value: unknown }
|
|
60
|
+
| { id: number; ok: false; code: string; message: string };
|
|
61
|
+
|
|
62
|
+
/* ----------------------------------------------- the worker side (inside) */
|
|
63
|
+
|
|
64
|
+
/** The two host runtimes look identical from inside a worker if we reduce them
|
|
65
|
+
* to "give me messages / let me post one". */
|
|
66
|
+
interface WorkerChannel {
|
|
67
|
+
onMessage(handler: (data: unknown) => void): void;
|
|
68
|
+
post(data: unknown): void;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function resolveWorkerChannel(): Promise<WorkerChannel> {
|
|
72
|
+
// Browser/Web Worker: `self` has postMessage and no `window`.
|
|
73
|
+
const maybeSelf = globalThis as unknown as {
|
|
74
|
+
postMessage?: (data: unknown) => void;
|
|
75
|
+
addEventListener?: (type: string, listener: (event: { data: unknown }) => void) => void;
|
|
76
|
+
window?: unknown;
|
|
77
|
+
};
|
|
78
|
+
if (
|
|
79
|
+
maybeSelf.window === undefined &&
|
|
80
|
+
typeof maybeSelf.postMessage === 'function' &&
|
|
81
|
+
typeof maybeSelf.addEventListener === 'function'
|
|
82
|
+
) {
|
|
83
|
+
const post = maybeSelf.postMessage.bind(globalThis);
|
|
84
|
+
const listen = maybeSelf.addEventListener.bind(globalThis);
|
|
85
|
+
return {
|
|
86
|
+
onMessage: (handler) => listen('message', (event) => handler(event.data)),
|
|
87
|
+
post,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const { parentPort } = await import('node:worker_threads');
|
|
92
|
+
if (!parentPort)
|
|
93
|
+
throw new AppError('serveMarshalWorker was called outside a worker thread.', {
|
|
94
|
+
code: MARSHALLER_WORKER_FAILED,
|
|
95
|
+
status: 500,
|
|
96
|
+
});
|
|
97
|
+
return {
|
|
98
|
+
onMessage: (handler) => parentPort.on('message', handler),
|
|
99
|
+
post: (data) => parentPort.postMessage(data),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Run `marshaller` inside a worker, answering one payload per message.
|
|
105
|
+
*
|
|
106
|
+
* A worker entry is three lines:
|
|
107
|
+
*
|
|
108
|
+
* ```ts
|
|
109
|
+
* import { serveMarshalWorker } from 'cursedbelt-core/wire';
|
|
110
|
+
* import { sheetRowsMarshaller } from './sheetRows';
|
|
111
|
+
* await serveMarshalWorker(sheetRowsMarshaller);
|
|
112
|
+
* ```
|
|
113
|
+
*
|
|
114
|
+
* **The schema check is deliberately NOT run here.** `marshalInbound` re-checks
|
|
115
|
+
* every worker result against the token's canonical schema on the host side,
|
|
116
|
+
* because a worker is a thread boundary, not a trust boundary — a result that
|
|
117
|
+
* validated itself is exactly the kind of assurance that stops being true the
|
|
118
|
+
* day someone edits the worker entry. Doing it in both places would also mean
|
|
119
|
+
* two implementations of one law, and the worker's copy is the one no test
|
|
120
|
+
* would cover.
|
|
121
|
+
*/
|
|
122
|
+
export async function serveMarshalWorker<Inbound, Out>(
|
|
123
|
+
marshaller: Pick<CCWireMarshaller<Inbound, Out>, 'id' | 'validate' | 'transform'>,
|
|
124
|
+
): Promise<void> {
|
|
125
|
+
const channel = await resolveWorkerChannel();
|
|
126
|
+
|
|
127
|
+
channel.onMessage((data) => {
|
|
128
|
+
const request = data as MarshalWorkerRequest;
|
|
129
|
+
if (typeof request?.id !== 'number') return; // not ours; ignore rather than crash the worker
|
|
130
|
+
try {
|
|
131
|
+
if (!marshaller.validate(request.payload as Inbound)) {
|
|
132
|
+
channel.post({
|
|
133
|
+
id: request.id,
|
|
134
|
+
ok: false,
|
|
135
|
+
code: MARSHALLER_VALIDATION_FAILED,
|
|
136
|
+
message: `Inbound payload rejected by marshaller '${marshaller.id}'.`,
|
|
137
|
+
} satisfies MarshalWorkerResponse);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const value = marshaller.transform(request.payload as Inbound);
|
|
141
|
+
channel.post({ id: request.id, ok: true, value } satisfies MarshalWorkerResponse);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
// A throwing transform must not take the worker down with it: the next
|
|
144
|
+
// payload is a different payload, and a dead worker turns one bad webhook
|
|
145
|
+
// into an outage.
|
|
146
|
+
channel.post({
|
|
147
|
+
id: request.id,
|
|
148
|
+
ok: false,
|
|
149
|
+
code: MARSHALLER_WORKER_FAILED,
|
|
150
|
+
message: error instanceof Error ? error.message : String(error),
|
|
151
|
+
} satisfies MarshalWorkerResponse);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* ------------------------------------------------- the host side (outside) */
|
|
157
|
+
|
|
158
|
+
/** The subset of both runtimes' Worker the client below needs. */
|
|
159
|
+
interface HostWorker {
|
|
160
|
+
post(data: unknown): void;
|
|
161
|
+
onMessage(handler: (data: unknown) => void): void;
|
|
162
|
+
onError(handler: (error: unknown) => void): void;
|
|
163
|
+
terminate(): void;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface MarshalWorkerClientOptions {
|
|
167
|
+
/** Module specifier / URL of the entry that calls {@link serveMarshalWorker}. */
|
|
168
|
+
entry: string | URL;
|
|
169
|
+
/** Per-payload ceiling. Defaults to {@link MARSHAL_WORKER_DEFAULT_TIMEOUT_MS}. */
|
|
170
|
+
timeoutMs?: number;
|
|
171
|
+
/** Spawn override — the seam tests use to run without a real thread. */
|
|
172
|
+
spawn?: (entry: string | URL) => HostWorker;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface MarshalWorkerClient extends MarshalWorker {
|
|
176
|
+
/** Stop the thread. Pending payloads reject; further calls are refused. */
|
|
177
|
+
dispose(): Promise<void>;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function spawnHostWorker(entry: string | URL): HostWorker {
|
|
181
|
+
const WebWorker = (globalThis as { Worker?: new (url: string | URL, options?: { type: string }) => unknown })
|
|
182
|
+
.Worker;
|
|
183
|
+
// A browser (or Bun's web-Worker shim) — `type: 'module'` so the entry may use
|
|
184
|
+
// static imports, which every entry will.
|
|
185
|
+
if (typeof WebWorker === 'function') {
|
|
186
|
+
const worker = new WebWorker(entry, { type: 'module' }) as {
|
|
187
|
+
postMessage(data: unknown): void;
|
|
188
|
+
addEventListener(type: string, listener: (event: { data?: unknown; message?: string }) => void): void;
|
|
189
|
+
terminate(): void;
|
|
190
|
+
};
|
|
191
|
+
return {
|
|
192
|
+
post: (data) => worker.postMessage(data),
|
|
193
|
+
onMessage: (handler) => worker.addEventListener('message', (event) => handler(event.data)),
|
|
194
|
+
onError: (handler) => worker.addEventListener('error', (event) => handler(event.message ?? event)),
|
|
195
|
+
terminate: () => worker.terminate(),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Node/Bun worker_threads. Constructed lazily and synchronously-wrapped: the
|
|
200
|
+
// client below queues sends until the thread exists, so callers never see the
|
|
201
|
+
// construction race.
|
|
202
|
+
let pending: unknown[] = [];
|
|
203
|
+
let realPost: ((data: unknown) => void) | null = null;
|
|
204
|
+
let terminated = false;
|
|
205
|
+
const messageHandlers: ((data: unknown) => void)[] = [];
|
|
206
|
+
const errorHandlers: ((error: unknown) => void)[] = [];
|
|
207
|
+
let terminateReal: (() => void) | null = null;
|
|
208
|
+
|
|
209
|
+
void (async () => {
|
|
210
|
+
try {
|
|
211
|
+
const { Worker } = await import('node:worker_threads');
|
|
212
|
+
if (terminated) return;
|
|
213
|
+
const worker = new Worker(entry as string | URL);
|
|
214
|
+
worker.on('message', (data: unknown) => {
|
|
215
|
+
for (const handler of messageHandlers) handler(data);
|
|
216
|
+
});
|
|
217
|
+
worker.on('error', (error: unknown) => {
|
|
218
|
+
for (const handler of errorHandlers) handler(error);
|
|
219
|
+
});
|
|
220
|
+
// ANY exit is a failure for this client: the worker is long-lived by
|
|
221
|
+
// design, so it should outlive every payload. Gating this on `code !== 0`
|
|
222
|
+
// left a cleanly-exited thread invisible — payloads in flight waited out
|
|
223
|
+
// their full timeout instead of failing at once, and every later `run()`
|
|
224
|
+
// posted into a dead thread and did the same.
|
|
225
|
+
worker.on('exit', (code: number) => {
|
|
226
|
+
const how = code === 0 ? 'exited cleanly while still in use' : `exited with code ${code}`;
|
|
227
|
+
for (const handler of errorHandlers) handler(new Error(`marshal worker ${how}`));
|
|
228
|
+
});
|
|
229
|
+
// `unref` would let the process exit with payloads in flight; the client's
|
|
230
|
+
// own timeout is the liveness backstop instead.
|
|
231
|
+
realPost = (data) => worker.postMessage(data);
|
|
232
|
+
terminateReal = () => void worker.terminate();
|
|
233
|
+
for (const queued of pending) realPost(queued);
|
|
234
|
+
pending = [];
|
|
235
|
+
} catch (error) {
|
|
236
|
+
for (const handler of errorHandlers) handler(error);
|
|
237
|
+
}
|
|
238
|
+
})();
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
post: (data) => (realPost ? realPost(data) : pending.push(data)),
|
|
242
|
+
onMessage: (handler) => messageHandlers.push(handler),
|
|
243
|
+
onError: (handler) => errorHandlers.push(handler),
|
|
244
|
+
terminate: () => {
|
|
245
|
+
terminated = true;
|
|
246
|
+
terminateReal?.();
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* A long-lived worker for ONE marshaller, multiplexing concurrent payloads.
|
|
253
|
+
*
|
|
254
|
+
* Long-lived rather than one-worker-per-payload deliberately: thread startup
|
|
255
|
+
* plus module load is tens of milliseconds, which on a burst of imports would
|
|
256
|
+
* cost more than the parse it is avoiding, and a spawn-per-payload design lets
|
|
257
|
+
* an import storm fork unbounded threads.
|
|
258
|
+
*/
|
|
259
|
+
export function createMarshalWorkerClient(options: MarshalWorkerClientOptions): MarshalWorkerClient {
|
|
260
|
+
const timeoutMs = options.timeoutMs ?? MARSHAL_WORKER_DEFAULT_TIMEOUT_MS;
|
|
261
|
+
const worker = (options.spawn ?? spawnHostWorker)(options.entry);
|
|
262
|
+
|
|
263
|
+
interface Pending {
|
|
264
|
+
resolve(value: unknown): void;
|
|
265
|
+
reject(error: unknown): void;
|
|
266
|
+
timer: ReturnType<typeof setTimeout>;
|
|
267
|
+
}
|
|
268
|
+
const pending = new Map<number, Pending>();
|
|
269
|
+
let nextId = 1;
|
|
270
|
+
let disposed = false;
|
|
271
|
+
|
|
272
|
+
const settle = (id: number, apply: (p: Pending) => void): void => {
|
|
273
|
+
const entry = pending.get(id);
|
|
274
|
+
if (!entry) return; // already timed out — the late reply is discarded
|
|
275
|
+
pending.delete(id);
|
|
276
|
+
clearTimeout(entry.timer);
|
|
277
|
+
apply(entry);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
worker.onMessage((data) => {
|
|
281
|
+
const response = data as MarshalWorkerResponse;
|
|
282
|
+
if (typeof response?.id !== 'number') return;
|
|
283
|
+
settle(response.id, (p) => {
|
|
284
|
+
if (response.ok) {
|
|
285
|
+
p.resolve(response.value);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
p.reject(
|
|
289
|
+
new AppError(response.message, {
|
|
290
|
+
// A validation failure is the CALLER's fault (400) and must keep its
|
|
291
|
+
// own code; anything else the worker reports is ours (500).
|
|
292
|
+
code: response.code,
|
|
293
|
+
status: response.code === MARSHALLER_VALIDATION_FAILED ? 400 : 500,
|
|
294
|
+
context: { worker: String(options.entry) },
|
|
295
|
+
}),
|
|
296
|
+
);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// A worker that dies takes every payload in flight with it — failing them all
|
|
301
|
+
// now beats each one waiting out its own timeout.
|
|
302
|
+
worker.onError((error) => {
|
|
303
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
304
|
+
for (const id of [...pending.keys()])
|
|
305
|
+
settle(id, (p) =>
|
|
306
|
+
p.reject(
|
|
307
|
+
new AppError(`Marshalling worker failed: ${message}`, {
|
|
308
|
+
code: MARSHALLER_WORKER_FAILED,
|
|
309
|
+
status: 500,
|
|
310
|
+
context: { worker: String(options.entry) },
|
|
311
|
+
}),
|
|
312
|
+
),
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
run<Inbound, Out>(_marshallerId: string, payload: Inbound): Promise<Out> {
|
|
318
|
+
if (disposed)
|
|
319
|
+
return Promise.reject(
|
|
320
|
+
new AppError('Marshalling worker has been disposed.', {
|
|
321
|
+
code: MARSHALLER_WORKER_FAILED,
|
|
322
|
+
status: 500,
|
|
323
|
+
context: { worker: String(options.entry) },
|
|
324
|
+
}),
|
|
325
|
+
);
|
|
326
|
+
const id = nextId++;
|
|
327
|
+
return new Promise<Out>((resolve, reject) => {
|
|
328
|
+
const timer = setTimeout(() => {
|
|
329
|
+
pending.delete(id);
|
|
330
|
+
reject(
|
|
331
|
+
new AppError(`Marshalling worker did not answer within ${timeoutMs}ms.`, {
|
|
332
|
+
code: MARSHALLER_WORKER_TIMEOUT,
|
|
333
|
+
status: 504,
|
|
334
|
+
context: { worker: String(options.entry), timeoutMs },
|
|
335
|
+
}),
|
|
336
|
+
);
|
|
337
|
+
}, timeoutMs);
|
|
338
|
+
pending.set(id, { resolve: resolve as (value: unknown) => void, reject, timer });
|
|
339
|
+
worker.post({ id, payload } satisfies MarshalWorkerRequest);
|
|
340
|
+
});
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
async dispose(): Promise<void> {
|
|
344
|
+
disposed = true;
|
|
345
|
+
for (const id of [...pending.keys()])
|
|
346
|
+
settle(id, (p) =>
|
|
347
|
+
p.reject(
|
|
348
|
+
new AppError('Marshalling worker was disposed while a payload was in flight.', {
|
|
349
|
+
code: MARSHALLER_WORKER_FAILED,
|
|
350
|
+
status: 503,
|
|
351
|
+
context: { worker: String(options.entry) },
|
|
352
|
+
}),
|
|
353
|
+
),
|
|
354
|
+
);
|
|
355
|
+
worker.terminate();
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Re-exported so a caller catching worker failures needs one import. */
|
|
361
|
+
export { MARSHALLER_SCHEMA_VIOLATION, MARSHALLER_VALIDATION_FAILED };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port marshalling + the 512KB worker gate (02 §7.1).
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it } from 'bun:test';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import {
|
|
7
|
+
type CCWireMarshaller,
|
|
8
|
+
createMarshallerRegistry,
|
|
9
|
+
MARSHALLER_WORKER_OFFLOAD_BYTES,
|
|
10
|
+
marshalInbound,
|
|
11
|
+
payloadBytes,
|
|
12
|
+
} from './marshalling';
|
|
13
|
+
|
|
14
|
+
const schema = z.object({ rows: z.array(z.object({ id: z.string() })) });
|
|
15
|
+
|
|
16
|
+
const marshaller = (over: Partial<CCWireMarshaller<unknown, z.infer<typeof schema>>> = {}) =>
|
|
17
|
+
({
|
|
18
|
+
id: 'csv-rows',
|
|
19
|
+
maxPayloadBytes: 10 * 1024 * 1024,
|
|
20
|
+
validate: () => true,
|
|
21
|
+
transform: (inbound: unknown) => ({ rows: (inbound as { rows: { id: string }[] }).rows }),
|
|
22
|
+
targetZodSchema: schema,
|
|
23
|
+
...over,
|
|
24
|
+
}) as CCWireMarshaller<unknown, z.infer<typeof schema>>;
|
|
25
|
+
|
|
26
|
+
describe('payloadBytes', () => {
|
|
27
|
+
it('measures UTF-8 bytes, not characters', () => {
|
|
28
|
+
// A CJK document is ~3 bytes/char; sizing by character count sails past the
|
|
29
|
+
// offload threshold on the main thread.
|
|
30
|
+
expect(payloadBytes('あ')).toBe(3);
|
|
31
|
+
expect(payloadBytes('a')).toBe(1);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('treats a non-serializable payload as over-threshold rather than crashing', () => {
|
|
35
|
+
const cyclic: Record<string, unknown> = {};
|
|
36
|
+
cyclic.self = cyclic;
|
|
37
|
+
expect(payloadBytes(cyclic)).toBe(Number.POSITIVE_INFINITY);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('marshalInbound', () => {
|
|
42
|
+
it('validates, transforms, and schema-checks a normal payload on the main thread', async () => {
|
|
43
|
+
const result = await marshalInbound(marshaller(), { rows: [{ id: '1' }] });
|
|
44
|
+
expect(result).toEqual({ rows: [{ id: '1' }] });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('rejects a payload over the marshaller’s own limit before scheduling any work', async () => {
|
|
48
|
+
let transformed = false;
|
|
49
|
+
await expect(
|
|
50
|
+
marshalInbound(
|
|
51
|
+
marshaller({
|
|
52
|
+
maxPayloadBytes: 4,
|
|
53
|
+
transform: () => {
|
|
54
|
+
transformed = true;
|
|
55
|
+
return { rows: [] };
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
{ rows: [{ id: 'long-enough-to-exceed' }] },
|
|
59
|
+
),
|
|
60
|
+
).rejects.toMatchObject({ code: 'MARSHALLER_PAYLOAD_TOO_LARGE' });
|
|
61
|
+
expect(transformed).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('refuses a validate() rejection with a typed error', async () => {
|
|
65
|
+
await expect(
|
|
66
|
+
marshalInbound(marshaller({ validate: () => false }), { rows: [] }),
|
|
67
|
+
).rejects.toMatchObject({ code: 'MARSHALLER_VALIDATION_FAILED' });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('refuses transform output that violates the token’s canonical schema', async () => {
|
|
71
|
+
await expect(
|
|
72
|
+
marshalInbound(marshaller({ transform: () => ({ rows: 'not an array' }) as never }), { rows: [] }),
|
|
73
|
+
).rejects.toMatchObject({ code: 'MARSHALLER_SCHEMA_VIOLATION' });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('offloads past the threshold instead of blocking the calling thread', async () => {
|
|
77
|
+
let ranOnMainThread = false;
|
|
78
|
+
let offloaded = false;
|
|
79
|
+
|
|
80
|
+
const result = await marshalInbound(
|
|
81
|
+
marshaller({
|
|
82
|
+
transform: () => {
|
|
83
|
+
ranOnMainThread = true;
|
|
84
|
+
return { rows: [] };
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
{ rows: [{ id: 'x' }] },
|
|
88
|
+
{
|
|
89
|
+
offloadThresholdBytes: 4,
|
|
90
|
+
worker: {
|
|
91
|
+
async run() {
|
|
92
|
+
offloaded = true;
|
|
93
|
+
return { rows: [{ id: 'from-worker' }] } as never;
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
expect(offloaded).toBe(true);
|
|
100
|
+
expect(ranOnMainThread).toBe(false);
|
|
101
|
+
expect(result).toEqual({ rows: [{ id: 'from-worker' }] });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('still schema-checks the worker’s result — a thread boundary is not a trust boundary', async () => {
|
|
105
|
+
await expect(
|
|
106
|
+
marshalInbound(marshaller(), { rows: [] }, {
|
|
107
|
+
offloadThresholdBytes: 1,
|
|
108
|
+
worker: { async run() { return { rows: 'garbage' } as never; } },
|
|
109
|
+
}),
|
|
110
|
+
).rejects.toMatchObject({ code: 'MARSHALLER_SCHEMA_VIOLATION' });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('refuses an oversized payload when no worker is configured', async () => {
|
|
114
|
+
await expect(
|
|
115
|
+
marshalInbound(marshaller(), { rows: [{ id: 'x' }] }, { offloadThresholdBytes: 1 }),
|
|
116
|
+
).rejects.toMatchObject({ code: 'MARSHALLER_PAYLOAD_TOO_LARGE' });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('pins the 512KB threshold', () => {
|
|
120
|
+
expect(MARSHALLER_WORKER_OFFLOAD_BYTES).toBe(512 * 1024);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('createMarshallerRegistry', () => {
|
|
125
|
+
it('refuses a marshaller with no declared payload ceiling', () => {
|
|
126
|
+
const registry = createMarshallerRegistry();
|
|
127
|
+
expect(() => registry.register('knowledge:records', marshaller({ maxPayloadBytes: 0 }))).toThrow(
|
|
128
|
+
/maxPayloadBytes/,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('registers and resolves by token id', () => {
|
|
133
|
+
const registry = createMarshallerRegistry();
|
|
134
|
+
const m = marshaller();
|
|
135
|
+
registry.register('knowledge:records', m);
|
|
136
|
+
expect(registry.forToken('knowledge:records')).toBe(m);
|
|
137
|
+
expect(registry.forToken('media:videos')).toBeUndefined();
|
|
138
|
+
});
|
|
139
|
+
});
|