@czap/astro 0.1.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 +21 -0
- package/README.md +19 -0
- package/dist/Satellite.d.ts +42 -0
- package/dist/Satellite.d.ts.map +1 -0
- package/dist/Satellite.js +55 -0
- package/dist/Satellite.js.map +1 -0
- package/dist/client-directives/gpu.d.ts +3 -0
- package/dist/client-directives/gpu.d.ts.map +1 -0
- package/dist/client-directives/gpu.js +5 -0
- package/dist/client-directives/gpu.js.map +1 -0
- package/dist/client-directives/llm.d.ts +3 -0
- package/dist/client-directives/llm.d.ts.map +1 -0
- package/dist/client-directives/llm.js +5 -0
- package/dist/client-directives/llm.js.map +1 -0
- package/dist/client-directives/satellite.d.ts +3 -0
- package/dist/client-directives/satellite.d.ts.map +1 -0
- package/dist/client-directives/satellite.js +5 -0
- package/dist/client-directives/satellite.js.map +1 -0
- package/dist/client-directives/stream.d.ts +3 -0
- package/dist/client-directives/stream.d.ts.map +1 -0
- package/dist/client-directives/stream.js +5 -0
- package/dist/client-directives/stream.js.map +1 -0
- package/dist/client-directives/wasm.d.ts +3 -0
- package/dist/client-directives/wasm.d.ts.map +1 -0
- package/dist/client-directives/wasm.js +6 -0
- package/dist/client-directives/wasm.js.map +1 -0
- package/dist/client-directives/worker.d.ts +3 -0
- package/dist/client-directives/worker.d.ts.map +1 -0
- package/dist/client-directives/worker.js +5 -0
- package/dist/client-directives/worker.js.map +1 -0
- package/dist/detect-upgrade.d.ts +16 -0
- package/dist/detect-upgrade.d.ts.map +1 -0
- package/dist/detect-upgrade.js +105 -0
- package/dist/detect-upgrade.js.map +1 -0
- package/dist/headers.d.ts +45 -0
- package/dist/headers.d.ts.map +1 -0
- package/dist/headers.js +64 -0
- package/dist/headers.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/integration.d.ts +76 -0
- package/dist/integration.d.ts.map +1 -0
- package/dist/integration.js +240 -0
- package/dist/integration.js.map +1 -0
- package/dist/middleware.d.ts +69 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +75 -0
- package/dist/middleware.js.map +1 -0
- package/dist/quantize.d.ts +50 -0
- package/dist/quantize.d.ts.map +1 -0
- package/dist/quantize.js +122 -0
- package/dist/quantize.js.map +1 -0
- package/dist/runtime/boundary.d.ts +123 -0
- package/dist/runtime/boundary.d.ts.map +1 -0
- package/dist/runtime/boundary.js +164 -0
- package/dist/runtime/boundary.js.map +1 -0
- package/dist/runtime/globals.d.ts +32 -0
- package/dist/runtime/globals.d.ts.map +1 -0
- package/dist/runtime/globals.js +45 -0
- package/dist/runtime/globals.js.map +1 -0
- package/dist/runtime/gpu.d.ts +15 -0
- package/dist/runtime/gpu.d.ts.map +1 -0
- package/dist/runtime/gpu.js +266 -0
- package/dist/runtime/gpu.js.map +1 -0
- package/dist/runtime/index.d.ts +7 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/index.js +5 -0
- package/dist/runtime/index.js.map +1 -0
- package/dist/runtime/llm-receipt-tracker.d.ts +21 -0
- package/dist/runtime/llm-receipt-tracker.d.ts.map +1 -0
- package/dist/runtime/llm-receipt-tracker.js +60 -0
- package/dist/runtime/llm-receipt-tracker.js.map +1 -0
- package/dist/runtime/llm-render-pipeline.d.ts +89 -0
- package/dist/runtime/llm-render-pipeline.d.ts.map +1 -0
- package/dist/runtime/llm-render-pipeline.js +241 -0
- package/dist/runtime/llm-render-pipeline.js.map +1 -0
- package/dist/runtime/llm-session.d.ts +126 -0
- package/dist/runtime/llm-session.d.ts.map +1 -0
- package/dist/runtime/llm-session.js +385 -0
- package/dist/runtime/llm-session.js.map +1 -0
- package/dist/runtime/llm.d.ts +16 -0
- package/dist/runtime/llm.d.ts.map +1 -0
- package/dist/runtime/llm.js +273 -0
- package/dist/runtime/llm.js.map +1 -0
- package/dist/runtime/policy.d.ts +100 -0
- package/dist/runtime/policy.d.ts.map +1 -0
- package/dist/runtime/policy.js +147 -0
- package/dist/runtime/policy.js.map +1 -0
- package/dist/runtime/receipt-chain.d.ts +22 -0
- package/dist/runtime/receipt-chain.d.ts.map +1 -0
- package/dist/runtime/receipt-chain.js +80 -0
- package/dist/runtime/receipt-chain.js.map +1 -0
- package/dist/runtime/runtime-session.d.ts +34 -0
- package/dist/runtime/runtime-session.d.ts.map +1 -0
- package/dist/runtime/runtime-session.js +102 -0
- package/dist/runtime/runtime-session.js.map +1 -0
- package/dist/runtime/satellite.d.ts +13 -0
- package/dist/runtime/satellite.d.ts.map +1 -0
- package/dist/runtime/satellite.js +59 -0
- package/dist/runtime/satellite.js.map +1 -0
- package/dist/runtime/slots.d.ts +34 -0
- package/dist/runtime/slots.d.ts.map +1 -0
- package/dist/runtime/slots.js +108 -0
- package/dist/runtime/slots.js.map +1 -0
- package/dist/runtime/stream-session.d.ts +47 -0
- package/dist/runtime/stream-session.d.ts.map +1 -0
- package/dist/runtime/stream-session.js +82 -0
- package/dist/runtime/stream-session.js.map +1 -0
- package/dist/runtime/stream.d.ts +9 -0
- package/dist/runtime/stream.d.ts.map +1 -0
- package/dist/runtime/stream.js +308 -0
- package/dist/runtime/stream.js.map +1 -0
- package/dist/runtime/url-policy.d.ts +28 -0
- package/dist/runtime/url-policy.d.ts.map +1 -0
- package/dist/runtime/url-policy.js +87 -0
- package/dist/runtime/url-policy.js.map +1 -0
- package/dist/runtime/wasm.d.ts +20 -0
- package/dist/runtime/wasm.d.ts.map +1 -0
- package/dist/runtime/wasm.js +70 -0
- package/dist/runtime/wasm.js.map +1 -0
- package/dist/runtime/worker.d.ts +11 -0
- package/dist/runtime/worker.d.ts.map +1 -0
- package/dist/runtime/worker.js +249 -0
- package/dist/runtime/worker.js.map +1 -0
- package/package.json +106 -0
- package/src/Satellite.astro +39 -0
- package/src/Satellite.ts +84 -0
- package/src/client-directives/gpu.ts +5 -0
- package/src/client-directives/llm.ts +5 -0
- package/src/client-directives/satellite.ts +5 -0
- package/src/client-directives/stream.ts +5 -0
- package/src/client-directives/wasm.ts +6 -0
- package/src/client-directives/worker.ts +5 -0
- package/src/detect-upgrade.ts +105 -0
- package/src/headers.ts +84 -0
- package/src/index.ts +30 -0
- package/src/integration.ts +309 -0
- package/src/middleware.ts +133 -0
- package/src/quantize.ts +173 -0
- package/src/runtime/boundary.ts +263 -0
- package/src/runtime/globals.ts +57 -0
- package/src/runtime/gpu.ts +291 -0
- package/src/runtime/index.ts +12 -0
- package/src/runtime/llm-receipt-tracker.ts +88 -0
- package/src/runtime/llm-render-pipeline.ts +366 -0
- package/src/runtime/llm-session.ts +548 -0
- package/src/runtime/llm.ts +344 -0
- package/src/runtime/policy.ts +229 -0
- package/src/runtime/receipt-chain.ts +106 -0
- package/src/runtime/runtime-session.ts +139 -0
- package/src/runtime/satellite.ts +80 -0
- package/src/runtime/slots.ts +136 -0
- package/src/runtime/stream-session.ts +125 -0
- package/src/runtime/stream.ts +407 -0
- package/src/runtime/url-policy.ts +107 -0
- package/src/runtime/wasm.ts +85 -0
- package/src/runtime/worker.ts +307 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { Diagnostics } from '@czap/core';
|
|
2
|
+
import type { Receipt } from '@czap/core';
|
|
3
|
+
import type { LLMChunk } from '@czap/web';
|
|
4
|
+
import { createLLMSession } from './llm-session.js';
|
|
5
|
+
import { readRuntimeHtmlPolicy, readRuntimeEndpointPolicy } from './policy.js';
|
|
6
|
+
import { allowRuntimeEndpointUrl } from './url-policy.js';
|
|
7
|
+
|
|
8
|
+
const SAFE_LLM_TARGET_SELECTOR = /^(?:#[A-Za-z][\w-]*|\.[A-Za-z_][\w-]*|\[data-czap-target="[-:A-Za-z0-9_]+"\])$/;
|
|
9
|
+
|
|
10
|
+
const parseJSONUnknown = (text: string): unknown => JSON.parse(text);
|
|
11
|
+
|
|
12
|
+
function normalizeToolDeltaContent(content: unknown, toolArgs: unknown): string | undefined {
|
|
13
|
+
if (typeof content === 'string') {
|
|
14
|
+
return content;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (typeof toolArgs === 'string') {
|
|
18
|
+
return toolArgs;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (toolArgs && typeof toolArgs === 'object') {
|
|
22
|
+
return JSON.stringify(toolArgs);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function firstMeaningfulCharCode(raw: string): number {
|
|
29
|
+
for (let index = 0; index < raw.length; index++) {
|
|
30
|
+
const code = raw.charCodeAt(index);
|
|
31
|
+
if (code !== 32 && code !== 9 && code !== 10 && code !== 13) {
|
|
32
|
+
return code;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return -1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toStructuredChunk(
|
|
40
|
+
type: LLMChunk['type'],
|
|
41
|
+
partial: unknown,
|
|
42
|
+
content: unknown,
|
|
43
|
+
toolName: unknown,
|
|
44
|
+
toolArgs: unknown,
|
|
45
|
+
): LLMChunk {
|
|
46
|
+
const normalizedToolName = typeof toolName === 'string' ? toolName : undefined;
|
|
47
|
+
const normalizedContent =
|
|
48
|
+
type === 'tool-call-delta'
|
|
49
|
+
? normalizeToolDeltaContent(content, toolArgs)
|
|
50
|
+
: typeof content === 'string'
|
|
51
|
+
? content
|
|
52
|
+
: undefined;
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
type,
|
|
56
|
+
partial: partial === true,
|
|
57
|
+
content: normalizedContent,
|
|
58
|
+
toolName: normalizedToolName,
|
|
59
|
+
toolArgs,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Parse a raw `MessageEvent` payload into an {@link LLMChunk}. Returns
|
|
65
|
+
* `null` when the payload is unrecognised so callers can drop
|
|
66
|
+
* non-chunk events (metrics, heartbeats, ...) silently.
|
|
67
|
+
*/
|
|
68
|
+
export function parseLLMChunk(event: Pick<MessageEvent, 'data'>): LLMChunk | null {
|
|
69
|
+
const decoded = decodeLLMEventData(event.data);
|
|
70
|
+
return decoded.type === 'chunk' ? decoded.chunk : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function mapDeviceTier(): 'none' | 'transitions' | 'animations' | 'physics' | 'compute' {
|
|
74
|
+
switch (document.documentElement.getAttribute('data-czap-tier')) {
|
|
75
|
+
case 'static':
|
|
76
|
+
return 'none';
|
|
77
|
+
case 'styled':
|
|
78
|
+
return 'transitions';
|
|
79
|
+
case 'gpu':
|
|
80
|
+
return 'compute';
|
|
81
|
+
case 'animated':
|
|
82
|
+
return 'physics';
|
|
83
|
+
default:
|
|
84
|
+
return 'animations';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parseLLMError(error: { content?: unknown; message?: unknown }): string {
|
|
89
|
+
if (typeof error.content === 'string') {
|
|
90
|
+
return error.content;
|
|
91
|
+
}
|
|
92
|
+
if (typeof error.message === 'string') {
|
|
93
|
+
return error.message;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return 'unknown error';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
type ParsedEventData =
|
|
100
|
+
| { readonly type: 'receipt'; readonly envelope: Receipt.Envelope }
|
|
101
|
+
| { readonly type: 'error'; readonly message: string }
|
|
102
|
+
| { readonly type: 'chunk'; readonly chunk: LLMChunk }
|
|
103
|
+
| { readonly type: 'ignored' };
|
|
104
|
+
|
|
105
|
+
interface StructuredLLMEvent {
|
|
106
|
+
readonly type?: unknown;
|
|
107
|
+
readonly data?: unknown;
|
|
108
|
+
readonly content?: unknown;
|
|
109
|
+
readonly message?: unknown;
|
|
110
|
+
readonly partial?: unknown;
|
|
111
|
+
readonly toolName?: unknown;
|
|
112
|
+
readonly toolArgs?: unknown;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isStructuredLLMEvent(value: unknown): value is StructuredLLMEvent {
|
|
116
|
+
return typeof value === 'object' && value !== null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function decodeStructuredLLMEventData(data: unknown): ParsedEventData {
|
|
120
|
+
if (!isStructuredLLMEvent(data)) {
|
|
121
|
+
return { type: 'ignored' };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
switch (data.type) {
|
|
125
|
+
case 'receipt':
|
|
126
|
+
return isReceiptEnvelope(data.data) ? { type: 'receipt', envelope: data.data } : { type: 'ignored' };
|
|
127
|
+
case 'error': {
|
|
128
|
+
return { type: 'error', message: parseLLMError(data) };
|
|
129
|
+
}
|
|
130
|
+
case 'text':
|
|
131
|
+
case 'tool-call-start':
|
|
132
|
+
case 'tool-call-delta':
|
|
133
|
+
case 'tool-call-end':
|
|
134
|
+
case 'done':
|
|
135
|
+
return {
|
|
136
|
+
type: 'chunk',
|
|
137
|
+
chunk: toStructuredChunk(data.type, data.partial, data.content, data.toolName, data.toolArgs),
|
|
138
|
+
};
|
|
139
|
+
default:
|
|
140
|
+
return { type: 'ignored' };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function decodeLLMEventData(data: unknown): ParsedEventData {
|
|
145
|
+
if (typeof data === 'string') {
|
|
146
|
+
const firstChar = firstMeaningfulCharCode(data);
|
|
147
|
+
if (firstChar === -1) {
|
|
148
|
+
return { type: 'ignored' };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (firstChar !== 123 && firstChar !== 91) {
|
|
152
|
+
return {
|
|
153
|
+
type: 'chunk',
|
|
154
|
+
chunk: {
|
|
155
|
+
type: 'text',
|
|
156
|
+
partial: false,
|
|
157
|
+
content: data,
|
|
158
|
+
toolName: undefined,
|
|
159
|
+
toolArgs: undefined,
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let parsed: unknown;
|
|
165
|
+
let syntaxError = false;
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
parsed = parseJSONUnknown(data);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (error instanceof SyntaxError) {
|
|
171
|
+
syntaxError = true;
|
|
172
|
+
} else {
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (syntaxError) {
|
|
178
|
+
return { type: 'ignored' };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return decodeStructuredLLMEventData(parsed);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return decodeStructuredLLMEventData(data);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isReceiptEnvelope(value: unknown): value is Receipt.Envelope {
|
|
188
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
189
|
+
if (!('hash' in value) || !('previous' in value)) return false;
|
|
190
|
+
return typeof value.hash === 'string';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function resolveLLMTarget(element: HTMLElement, selector: string | null): HTMLElement {
|
|
194
|
+
if (!selector || !SAFE_LLM_TARGET_SELECTOR.test(selector)) {
|
|
195
|
+
return element;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const found = element.querySelector(selector);
|
|
200
|
+
return found instanceof HTMLElement ? found : element;
|
|
201
|
+
} catch {
|
|
202
|
+
return element;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Entry point used by the `client:llm` directive to start a streaming
|
|
208
|
+
* LLM session on `element`. Reads `data-czap-llm-url` (plus optional
|
|
209
|
+
* target / mode attributes), validates it against the runtime
|
|
210
|
+
* endpoint policy, opens an SSE stream, and drives an
|
|
211
|
+
* {@link LLMSessionShape} to completion.
|
|
212
|
+
*/
|
|
213
|
+
export function initLLMDirective(load: () => Promise<unknown>, element: HTMLElement): void {
|
|
214
|
+
const endpointPolicy = readRuntimeEndpointPolicy();
|
|
215
|
+
const htmlPolicy = readRuntimeHtmlPolicy();
|
|
216
|
+
const llmUrl = allowRuntimeEndpointUrl(
|
|
217
|
+
element.getAttribute('data-czap-llm-url'),
|
|
218
|
+
'llm',
|
|
219
|
+
'czap/astro.llm',
|
|
220
|
+
{
|
|
221
|
+
crossOriginRejected: 'llm-cross-origin-url-rejected',
|
|
222
|
+
malformedUrl: 'llm-malformed-url-rejected',
|
|
223
|
+
originNotAllowed: 'llm-origin-not-allowed',
|
|
224
|
+
endpointKindNotPermitted: 'llm-endpoint-kind-not-permitted',
|
|
225
|
+
},
|
|
226
|
+
endpointPolicy,
|
|
227
|
+
);
|
|
228
|
+
if (!llmUrl) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const mode = element.getAttribute('data-czap-llm-mode') ?? 'append';
|
|
233
|
+
const targetSelector = element.getAttribute('data-czap-llm-target');
|
|
234
|
+
const resolveTarget = (): HTMLElement => resolveLLMTarget(element, targetSelector);
|
|
235
|
+
const resetTarget = (target: HTMLElement): void => {
|
|
236
|
+
target.replaceChildren();
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
let source: EventSource | null = null;
|
|
240
|
+
const session = createLLMSession({
|
|
241
|
+
element,
|
|
242
|
+
target: resolveTarget(),
|
|
243
|
+
mode,
|
|
244
|
+
getDeviceTier: mapDeviceTier,
|
|
245
|
+
htmlPolicy: htmlPolicy.llmDefault,
|
|
246
|
+
allowTrustedHtml: htmlPolicy.allowTrustedHtml,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const cleanupSource = (): void => {
|
|
250
|
+
if (source) {
|
|
251
|
+
source.onopen = null;
|
|
252
|
+
source.onmessage = null;
|
|
253
|
+
source.onerror = null;
|
|
254
|
+
}
|
|
255
|
+
source?.close();
|
|
256
|
+
source = null;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const cleanup = (): void => {
|
|
260
|
+
cleanupSource();
|
|
261
|
+
session.dispose();
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const handleDisconnect = (): void => {
|
|
265
|
+
cleanupSource();
|
|
266
|
+
session.beginReconnect();
|
|
267
|
+
|
|
268
|
+
const strategy = session.replayGap();
|
|
269
|
+
if (strategy.type === 'replay') {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
element.dispatchEvent(
|
|
274
|
+
new CustomEvent('czap:llm-error', {
|
|
275
|
+
detail: { reason: 'connection-error', strategy: strategy.type },
|
|
276
|
+
bubbles: true,
|
|
277
|
+
}),
|
|
278
|
+
);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const connect = (): void => {
|
|
282
|
+
cleanupSource();
|
|
283
|
+
const target = resolveTarget();
|
|
284
|
+
resetTarget(target);
|
|
285
|
+
session.reset(target);
|
|
286
|
+
source = new EventSource(llmUrl);
|
|
287
|
+
|
|
288
|
+
source.onopen = () => {
|
|
289
|
+
session.activate();
|
|
290
|
+
element.dispatchEvent(new CustomEvent('czap:llm-start', { bubbles: true }));
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
source.onmessage = (event: MessageEvent) => {
|
|
294
|
+
const decoded = decodeLLMEventData(event.data);
|
|
295
|
+
switch (decoded.type) {
|
|
296
|
+
case 'receipt':
|
|
297
|
+
session.rememberEnvelope(decoded.envelope);
|
|
298
|
+
return;
|
|
299
|
+
case 'error':
|
|
300
|
+
element.dispatchEvent(
|
|
301
|
+
new CustomEvent('czap:llm-error', {
|
|
302
|
+
detail: { message: decoded.message },
|
|
303
|
+
bubbles: true,
|
|
304
|
+
}),
|
|
305
|
+
);
|
|
306
|
+
cleanupSource();
|
|
307
|
+
return;
|
|
308
|
+
case 'ignored':
|
|
309
|
+
return;
|
|
310
|
+
case 'chunk':
|
|
311
|
+
if (session.ingest(decoded.chunk) === 'done') {
|
|
312
|
+
cleanupSource();
|
|
313
|
+
}
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
source.onerror = () => {
|
|
319
|
+
handleDisconnect();
|
|
320
|
+
};
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
connect();
|
|
325
|
+
} catch (error) {
|
|
326
|
+
Diagnostics.error({
|
|
327
|
+
source: 'czap/astro.llm',
|
|
328
|
+
code: 'llm-runtime-init-failed',
|
|
329
|
+
message: 'The shared LLM runtime could not initialize.',
|
|
330
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
331
|
+
});
|
|
332
|
+
cleanup();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
element.addEventListener('czap:reinit', () => {
|
|
336
|
+
connect();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
element.addEventListener('czap:dispose', () => {
|
|
340
|
+
cleanup();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
load();
|
|
344
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime security-policy data model: how `@czap/astro`'s client
|
|
3
|
+
* directives decide which endpoints they may fetch and how to treat
|
|
4
|
+
* HTML returned from those endpoints.
|
|
5
|
+
*
|
|
6
|
+
* @module
|
|
7
|
+
*/
|
|
8
|
+
import type { HtmlPolicy, RuntimeEndpointKind, RuntimeEndpointPolicy } from '@czap/web';
|
|
9
|
+
import { readRuntimeGlobal, writeRuntimeGlobal } from './globals.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* User-supplied HTML policy. Directives fall back to conservative
|
|
13
|
+
* defaults (`text` for LLM output, `sanitized-html` for stream
|
|
14
|
+
* payloads) when individual fields are omitted.
|
|
15
|
+
*/
|
|
16
|
+
export interface RuntimeHtmlPolicy {
|
|
17
|
+
/** Default HTML trust level for `client:llm` text sinks. */
|
|
18
|
+
readonly llmDefault?: HtmlPolicy;
|
|
19
|
+
/** Default HTML trust level for `client:stream` payloads. */
|
|
20
|
+
readonly streamDefault?: HtmlPolicy;
|
|
21
|
+
/** Opt-in to `trusted-html` system-wide. */
|
|
22
|
+
readonly allowTrustedHtml?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Combined runtime security policy (endpoint + HTML). Passed to
|
|
27
|
+
* {@link configureRuntimePolicy} and persisted on `window` for
|
|
28
|
+
* directive consumption.
|
|
29
|
+
*/
|
|
30
|
+
export interface RuntimeSecurityPolicy {
|
|
31
|
+
/** Endpoint allowlist configuration. */
|
|
32
|
+
readonly endpointPolicy?: RuntimeEndpointPolicy;
|
|
33
|
+
/** HTML policy configuration. */
|
|
34
|
+
readonly htmlPolicy?: RuntimeHtmlPolicy;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Frozen, fully-populated form of {@link RuntimeEndpointPolicy}. Every
|
|
39
|
+
* `RuntimeEndpointKind` has an allowlist (possibly empty) so callers
|
|
40
|
+
* can index safely without presence checks.
|
|
41
|
+
*/
|
|
42
|
+
export interface NormalizedRuntimeEndpointPolicy {
|
|
43
|
+
readonly mode: RuntimeEndpointPolicy['mode'];
|
|
44
|
+
readonly allowOrigins: readonly string[];
|
|
45
|
+
readonly byKind: Readonly<Record<RuntimeEndpointKind, readonly string[]>>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Frozen form of {@link RuntimeSecurityPolicy} with every optional
|
|
50
|
+
* field materialised to its default. Produced by
|
|
51
|
+
* {@link normalizeRuntimeSecurityPolicy}.
|
|
52
|
+
*/
|
|
53
|
+
export interface NormalizedRuntimeSecurityPolicy {
|
|
54
|
+
readonly endpointPolicy: NormalizedRuntimeEndpointPolicy;
|
|
55
|
+
readonly htmlPolicy: {
|
|
56
|
+
readonly llmDefault: HtmlPolicy;
|
|
57
|
+
readonly streamDefault: HtmlPolicy;
|
|
58
|
+
readonly allowTrustedHtml: boolean;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isNormalizedRuntimeSecurityPolicy(value: unknown): value is NormalizedRuntimeSecurityPolicy {
|
|
63
|
+
return (
|
|
64
|
+
typeof value === 'object' &&
|
|
65
|
+
value !== null &&
|
|
66
|
+
'endpointPolicy' in value &&
|
|
67
|
+
'htmlPolicy' in value &&
|
|
68
|
+
typeof value.endpointPolicy === 'object' &&
|
|
69
|
+
value.endpointPolicy !== null &&
|
|
70
|
+
typeof value.htmlPolicy === 'object' &&
|
|
71
|
+
value.htmlPolicy !== null
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const DEFAULT_ENDPOINT_POLICY: NormalizedRuntimeEndpointPolicy = Object.freeze({
|
|
76
|
+
mode: 'same-origin',
|
|
77
|
+
allowOrigins: Object.freeze([]),
|
|
78
|
+
byKind: Object.freeze({
|
|
79
|
+
stream: Object.freeze([]),
|
|
80
|
+
snapshot: Object.freeze([]),
|
|
81
|
+
replay: Object.freeze([]),
|
|
82
|
+
llm: Object.freeze([]),
|
|
83
|
+
'gpu-shader': Object.freeze([]),
|
|
84
|
+
wasm: Object.freeze([]),
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const DEFAULT_HTML_POLICY: {
|
|
89
|
+
readonly llmDefault: HtmlPolicy;
|
|
90
|
+
readonly streamDefault: HtmlPolicy;
|
|
91
|
+
readonly allowTrustedHtml: boolean;
|
|
92
|
+
} = Object.freeze({
|
|
93
|
+
llmDefault: 'text',
|
|
94
|
+
streamDefault: 'sanitized-html',
|
|
95
|
+
allowTrustedHtml: false,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
function freezeOrigins(origins?: readonly string[]): readonly string[] {
|
|
99
|
+
return Object.freeze((origins ?? []).map((origin) => origin.trim()).filter((origin) => origin.length > 0));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function freezeEndpointPolicy(policy?: RuntimeEndpointPolicy): NormalizedRuntimeEndpointPolicy {
|
|
103
|
+
if (!policy) {
|
|
104
|
+
return DEFAULT_ENDPOINT_POLICY;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Fully populate every kind up-front so the record is typed correctly
|
|
108
|
+
// from the initializer, without an empty-literal cast.
|
|
109
|
+
const byKind: Record<RuntimeEndpointKind, readonly string[]> = {
|
|
110
|
+
stream: freezeOrigins(policy.byKind?.stream),
|
|
111
|
+
snapshot: freezeOrigins(policy.byKind?.snapshot),
|
|
112
|
+
replay: freezeOrigins(policy.byKind?.replay),
|
|
113
|
+
llm: freezeOrigins(policy.byKind?.llm),
|
|
114
|
+
'gpu-shader': freezeOrigins(policy.byKind?.['gpu-shader']),
|
|
115
|
+
wasm: freezeOrigins(policy.byKind?.wasm),
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
return Object.freeze({
|
|
119
|
+
mode: policy.mode,
|
|
120
|
+
allowOrigins: freezeOrigins(policy.allowOrigins),
|
|
121
|
+
byKind: Object.freeze(byKind),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Freeze a user-supplied security policy into the fully-populated
|
|
127
|
+
* {@link NormalizedRuntimeSecurityPolicy} form. Applies conservative
|
|
128
|
+
* defaults for any missing fields.
|
|
129
|
+
*/
|
|
130
|
+
export function normalizeRuntimeSecurityPolicy(policy?: RuntimeSecurityPolicy): NormalizedRuntimeSecurityPolicy {
|
|
131
|
+
return Object.freeze({
|
|
132
|
+
endpointPolicy: freezeEndpointPolicy(policy?.endpointPolicy),
|
|
133
|
+
htmlPolicy: Object.freeze({
|
|
134
|
+
llmDefault: policy?.htmlPolicy?.llmDefault ?? DEFAULT_HTML_POLICY.llmDefault,
|
|
135
|
+
streamDefault: policy?.htmlPolicy?.streamDefault ?? DEFAULT_HTML_POLICY.streamDefault,
|
|
136
|
+
allowTrustedHtml: policy?.htmlPolicy?.allowTrustedHtml ?? DEFAULT_HTML_POLICY.allowTrustedHtml,
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Module-private source of truth for the active runtime policy. Lives
|
|
143
|
+
* in a closure no external script can `Object.defineProperty` past;
|
|
144
|
+
* the window global is a discoverable broadcast and a cross-bundle
|
|
145
|
+
* bridge, not the canonical store. Production callers always go
|
|
146
|
+
* through `configureRuntimePolicy` / `readRuntimePolicy`, both of
|
|
147
|
+
* which read the closure first.
|
|
148
|
+
*
|
|
149
|
+
* @internal
|
|
150
|
+
*/
|
|
151
|
+
let _currentPolicy: NormalizedRuntimeSecurityPolicy | null = null;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Tracks whether the cross-bundle window broadcast has been published.
|
|
155
|
+
* The broadcast happens once per realm with `configurable: false` so
|
|
156
|
+
* an attacker with script execution cannot redefine the property on
|
|
157
|
+
* later loaders. Subsequent `configureRuntimePolicy` calls (HMR,
|
|
158
|
+
* tests) update the module-private store but skip a re-broadcast,
|
|
159
|
+
* which would throw against the locked descriptor. The broadcast is
|
|
160
|
+
* informational; consumers in the same module-graph see updates
|
|
161
|
+
* through the closure regardless.
|
|
162
|
+
*
|
|
163
|
+
* @internal
|
|
164
|
+
*/
|
|
165
|
+
let _windowGlobalPublished = false;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Normalise `policy` and install it as the active runtime configuration.
|
|
169
|
+
*
|
|
170
|
+
* The first call in a given realm publishes the value to
|
|
171
|
+
* `window.__CZAP_RUNTIME_POLICY__` with `configurable: false` and
|
|
172
|
+
* `writable: false`, so an attacker cannot redefine the global via
|
|
173
|
+
* `Object.defineProperty` to install a permissive policy. Subsequent
|
|
174
|
+
* calls (HMR, test re-initialisation) update the module-private store
|
|
175
|
+
* only — the window global stays locked at the first published value.
|
|
176
|
+
*
|
|
177
|
+
* Production callers run `configureRuntimePolicy` once during the
|
|
178
|
+
* integration boot script. Test harnesses re-call it freely; reads
|
|
179
|
+
* via `readRuntimePolicy()` return the latest configured value
|
|
180
|
+
* because the module-private store is checked first.
|
|
181
|
+
*/
|
|
182
|
+
export function configureRuntimePolicy(policy?: RuntimeSecurityPolicy): NormalizedRuntimeSecurityPolicy {
|
|
183
|
+
const normalized = normalizeRuntimeSecurityPolicy(policy);
|
|
184
|
+
_currentPolicy = normalized;
|
|
185
|
+
|
|
186
|
+
if (!_windowGlobalPublished) {
|
|
187
|
+
writeRuntimeGlobal('__CZAP_RUNTIME_POLICY__', normalized, { configurable: false });
|
|
188
|
+
_windowGlobalPublished = true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return normalized;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Read the active runtime policy. Prefers the module-private store
|
|
196
|
+
* (the canonical source of truth), falls back to the cross-bundle
|
|
197
|
+
* window broadcast for consumers loaded as a separate bundle, and
|
|
198
|
+
* finally to a default normalised policy when nothing has been
|
|
199
|
+
* configured (e.g. in tests that haven't called
|
|
200
|
+
* `configureRuntimePolicy` yet).
|
|
201
|
+
*/
|
|
202
|
+
export function readRuntimePolicy(): NormalizedRuntimeSecurityPolicy {
|
|
203
|
+
if (_currentPolicy) return _currentPolicy;
|
|
204
|
+
return (
|
|
205
|
+
readRuntimeGlobal('__CZAP_RUNTIME_POLICY__', isNormalizedRuntimeSecurityPolicy) ?? normalizeRuntimeSecurityPolicy()
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Reset the module-private policy store to its uninitialised state.
|
|
211
|
+
* Test-only: production code must not call this. The window-global
|
|
212
|
+
* broadcast is intentionally NOT cleared (the descriptor is
|
|
213
|
+
* non-configurable and cannot be redefined within a single realm).
|
|
214
|
+
*
|
|
215
|
+
* @internal
|
|
216
|
+
*/
|
|
217
|
+
export function _resetRuntimePolicyForTests(): void {
|
|
218
|
+
_currentPolicy = null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Convenience accessor for the endpoint sub-policy. */
|
|
222
|
+
export function readRuntimeEndpointPolicy(): NormalizedRuntimeEndpointPolicy {
|
|
223
|
+
return readRuntimePolicy().endpointPolicy;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Convenience accessor for the HTML sub-policy. */
|
|
227
|
+
export function readRuntimeHtmlPolicy(): NormalizedRuntimeSecurityPolicy['htmlPolicy'] {
|
|
228
|
+
return readRuntimePolicy().htmlPolicy;
|
|
229
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { DAG, Diagnostics } from '@czap/core';
|
|
2
|
+
import type { Receipt, UIFrame } from '@czap/core';
|
|
3
|
+
|
|
4
|
+
type ReceiptEnvelope = Receipt.Envelope;
|
|
5
|
+
type ReceiptTrustMode = 'advisory-unverified';
|
|
6
|
+
|
|
7
|
+
interface ReceiptChainShape {
|
|
8
|
+
rememberFrame(frame: UIFrame): void;
|
|
9
|
+
ingestEnvelope(envelope: ReceiptEnvelope): boolean;
|
|
10
|
+
hasFramesAfter(receiptId: string | null): boolean;
|
|
11
|
+
getFramesAfter(receiptId: string | null): readonly UIFrame[];
|
|
12
|
+
latestReceiptId(): string | null;
|
|
13
|
+
trustMode(): ReceiptTrustMode;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build a new in-memory receipt chain. Owns a DAG of ingested receipt
|
|
18
|
+
* envelopes plus a map of remembered frames keyed by receipt id, so
|
|
19
|
+
* the LLM directive can replay gaps when the SSE stream reconnects.
|
|
20
|
+
*
|
|
21
|
+
* The chain currently treats signatures as advisory; a diagnostic is
|
|
22
|
+
* emitted when signed envelopes arrive without a configured verifier.
|
|
23
|
+
*/
|
|
24
|
+
export function createReceiptChain(): ReceiptChainShape {
|
|
25
|
+
let dag = DAG.empty();
|
|
26
|
+
const framesByReceipt = new Map<string, UIFrame>();
|
|
27
|
+
const orderedReceipts: string[] = [];
|
|
28
|
+
|
|
29
|
+
const orderedFrameIds = (): readonly string[] => {
|
|
30
|
+
if (DAG.size(dag) > 0) {
|
|
31
|
+
const ids = DAG.linearize(dag)
|
|
32
|
+
.map((envelope) => envelope.hash)
|
|
33
|
+
.filter((hash) => framesByReceipt.has(hash));
|
|
34
|
+
if (ids.length > 0) {
|
|
35
|
+
return ids;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return orderedReceipts;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
rememberFrame(frame) {
|
|
44
|
+
framesByReceipt.set(frame.receiptId, frame);
|
|
45
|
+
if (!orderedReceipts.includes(frame.receiptId)) {
|
|
46
|
+
orderedReceipts.push(frame.receiptId);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
ingestEnvelope(envelope) {
|
|
51
|
+
if (envelope.signature) {
|
|
52
|
+
Diagnostics.warnOnce({
|
|
53
|
+
source: 'czap/astro.receipt-chain',
|
|
54
|
+
code: 'receipt-signature-unverified',
|
|
55
|
+
message:
|
|
56
|
+
'Receipt signatures are present but runtime ingestion treats them as advisory metadata until verification is configured.',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
dag = DAG.ingest(dag, envelope);
|
|
61
|
+
return true;
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
hasFramesAfter(receiptId) {
|
|
65
|
+
const ids = orderedFrameIds();
|
|
66
|
+
if (ids.length === 0) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (receiptId === null) {
|
|
71
|
+
return ids.length > 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const index = ids.indexOf(receiptId);
|
|
75
|
+
if (index === -1) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return index < ids.length - 1;
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
getFramesAfter(receiptId) {
|
|
83
|
+
const ids = orderedFrameIds();
|
|
84
|
+
if (receiptId === null) {
|
|
85
|
+
return ids.map((id) => framesByReceipt.get(id)!).filter(Boolean);
|
|
86
|
+
}
|
|
87
|
+
const index = ids.indexOf(receiptId);
|
|
88
|
+
if (index === -1) {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
return ids
|
|
92
|
+
.slice(index + 1)
|
|
93
|
+
.map((id) => framesByReceipt.get(id)!)
|
|
94
|
+
.filter(Boolean);
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
latestReceiptId() {
|
|
98
|
+
const ids = orderedFrameIds();
|
|
99
|
+
return ids.length > 0 ? ids[ids.length - 1]! : null;
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
trustMode() {
|
|
103
|
+
return 'advisory-unverified';
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|