threadwire 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/docs/card-10520-plan.md +45 -0
- package/docs/container-runtime.md +3 -2
- package/docs/delegated-result-protocol.md +161 -0
- package/docs/evidence-artifacts.md +106 -0
- package/package.json +1 -1
- package/scripts/verify-package.js +7 -1
- package/src/cli.js +250 -29
- package/src/context-budget-metrics.js +180 -0
- package/src/delegated-result-admission.js +377 -0
- package/src/evidence-store.js +1472 -0
- package/src/notice-queue.js +22 -7
- package/src/providers/opencode.js +42 -18
- package/src/relay.js +6 -6
- package/src/run-worker.js +158 -40
- package/src/telegram-ingress/command.js +16 -0
- package/src/telegram-ingress/config.js +89 -11
- package/src/telegram-ingress/core.js +162 -90
- package/src/telegram-ingress/http.js +17 -1
- package/src/telegram-webhook.js +16 -0
- package/src/types.js +2 -2
- package/src/worker-control.js +294 -0
- package/src/hermes-protocol.js +0 -126
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import {types as utilTypes} from "node:util"
|
|
4
|
+
import {sanitizeOutput} from "./relay.js"
|
|
5
|
+
|
|
6
|
+
export const CONCLUSION_LIMIT = 16_384
|
|
7
|
+
export const BLOCKER_LIMIT = 1_024
|
|
8
|
+
export const DECISION_REQUEST_LIMIT = 1_024
|
|
9
|
+
export const CONTINUATION_HANDLE_LIMIT = 512
|
|
10
|
+
export const ARTIFACT_HANDLE_LIMIT = 512
|
|
11
|
+
export const REFERENCE_VALUE_LIMIT = 2_048
|
|
12
|
+
export const VALIDATION_SUMMARY_LIMIT = 2_048
|
|
13
|
+
export const MAX_ARTIFACT_HANDLES = 16
|
|
14
|
+
export const MAX_REFERENCES = 16
|
|
15
|
+
export const MAX_ENVELOPE_BYTES = 98_304
|
|
16
|
+
const MAX_VALIDATION_BUDGET = 256
|
|
17
|
+
|
|
18
|
+
const CANDIDATE_FIELDS = new Set([
|
|
19
|
+
"state",
|
|
20
|
+
"exitCode",
|
|
21
|
+
"conclusion",
|
|
22
|
+
"blocker",
|
|
23
|
+
"decisionRequest",
|
|
24
|
+
"continuationHandle",
|
|
25
|
+
"artifactHandles",
|
|
26
|
+
"references",
|
|
27
|
+
"validationSummary"
|
|
28
|
+
])
|
|
29
|
+
const STATES = new Set(["completed", "failed", "blocked", "needs_decision"])
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {"completed" | "failed" | "blocked" | "needs_decision"} TerminalState
|
|
33
|
+
* @typedef {{kind: "commit" | "url", value: string}} AdmissionReference
|
|
34
|
+
* @typedef {{
|
|
35
|
+
* state: TerminalState,
|
|
36
|
+
* exitCode: number,
|
|
37
|
+
* conclusion?: string,
|
|
38
|
+
* blocker?: string,
|
|
39
|
+
* decisionRequest?: string,
|
|
40
|
+
* continuationHandle?: string,
|
|
41
|
+
* artifactHandles?: string[],
|
|
42
|
+
* references?: AdmissionReference[],
|
|
43
|
+
* validationSummary?: string
|
|
44
|
+
* }} DelegatedResultCandidate
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
export class DelegatedResultAdmission {
|
|
48
|
+
/** @param {{output: NodeJS.WritableStream, metrics?: import("./context-budget-metrics.js").ContextBudgetMetrics}} options */
|
|
49
|
+
constructor(options) {
|
|
50
|
+
this.output = options.output
|
|
51
|
+
this.metrics = options.metrics
|
|
52
|
+
this.conclusion = ""
|
|
53
|
+
this.conclusionTruncated = false
|
|
54
|
+
/** @type {string | undefined} */
|
|
55
|
+
this.continuationHandle = undefined
|
|
56
|
+
/** @type {string[]} */
|
|
57
|
+
this.artifactHandles = []
|
|
58
|
+
/** @type {AdmissionReference[]} */
|
|
59
|
+
this.references = []
|
|
60
|
+
this.completed = false
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @param {string} text */
|
|
64
|
+
appendConclusion(text) {
|
|
65
|
+
if (typeof text !== "string") throw new Error("Conclusion text must be a string")
|
|
66
|
+
if (this.conclusionTruncated) return
|
|
67
|
+
text = wellFormedString(text)
|
|
68
|
+
const remaining = CONCLUSION_LIMIT - Array.from(this.conclusion).length
|
|
69
|
+
const characters = Array.from(text)
|
|
70
|
+
if (characters.length <= remaining) this.conclusion += text
|
|
71
|
+
else {
|
|
72
|
+
this.conclusion += characters.slice(0, Math.max(0, remaining - 1)).join("") + "…"
|
|
73
|
+
this.conclusionTruncated = true
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @param {unknown} event */
|
|
78
|
+
acceptConclusionEvent(event) {
|
|
79
|
+
if (!isPlainObject(event) || !hasExactEnumerableFields(event, ["type", "text", "streamId"])) {
|
|
80
|
+
throw new Error("Invalid admitted conclusion event")
|
|
81
|
+
}
|
|
82
|
+
if (event.type !== "text-delta" || typeof event.text !== "string" || typeof event.streamId !== "string") {
|
|
83
|
+
throw new Error("Invalid admitted conclusion event")
|
|
84
|
+
}
|
|
85
|
+
this.appendConclusion(event.text)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @param {string} handle */
|
|
89
|
+
setContinuationHandle(handle) {
|
|
90
|
+
this.continuationHandle = validateContinuationHandle(handle)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** @param {string} handle */
|
|
94
|
+
addArtifactHandle(handle) {
|
|
95
|
+
this.artifactHandles = optionalArtifactHandles([...this.artifactHandles, handle]) ?? []
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @param {string} handle */
|
|
99
|
+
removeArtifactHandle(handle) {
|
|
100
|
+
this.artifactHandles = this.artifactHandles.filter((candidate) => candidate !== handle)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** @param {{state: TerminalState, exitCode: number}} terminal */
|
|
104
|
+
preview(terminal) {
|
|
105
|
+
return this.createEnvelope(terminal)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** @param {AdmissionReference} reference */
|
|
109
|
+
addReference(reference) {
|
|
110
|
+
this.references = /** @type {AdmissionReference[]} */ (optionalReferences([...this.references, reference]) ?? [])
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @param {{state: TerminalState, exitCode: number}} terminal */
|
|
114
|
+
complete(terminal) {
|
|
115
|
+
if (this.completed) throw new Error("Delegated result already completed")
|
|
116
|
+
const envelope = this.createEnvelope(terminal)
|
|
117
|
+
this.completed = true
|
|
118
|
+
this.metrics?.recordAdmission(envelope)
|
|
119
|
+
this.output.write(`${JSON.stringify(envelope)}\n`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** @param {{state: TerminalState, exitCode: number}} terminal */
|
|
123
|
+
createEnvelope(terminal) {
|
|
124
|
+
const safeConclusion = truncateBoundedString(sanitizeOutput(this.conclusion), CONCLUSION_LIMIT)
|
|
125
|
+
return createDelegatedResultEnvelope({
|
|
126
|
+
...terminal,
|
|
127
|
+
...(terminal.state !== "completed" || safeConclusion === undefined ? {} : {conclusion: safeConclusion}),
|
|
128
|
+
...(this.continuationHandle === undefined ? {} : {continuationHandle: this.continuationHandle}),
|
|
129
|
+
...(this.artifactHandles.length === 0 ? {} : {artifactHandles: this.artifactHandles}),
|
|
130
|
+
...(this.references.length === 0 ? {} : {references: this.references})
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** @param {unknown} candidate */
|
|
136
|
+
export function createDelegatedResultEnvelope(candidate) {
|
|
137
|
+
const clonedCandidate = cloneTopLevelCandidate(candidate)
|
|
138
|
+
for (const field of Reflect.ownKeys(clonedCandidate)) {
|
|
139
|
+
if (typeof field !== "string" || !CANDIDATE_FIELDS.has(field) || !Object.prototype.propertyIsEnumerable.call(clonedCandidate, field)) {
|
|
140
|
+
throw new Error(`Unknown delegated result candidate field: ${String(field)}`)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (typeof clonedCandidate.state !== "string" || !STATES.has(clonedCandidate.state)) {
|
|
144
|
+
throw new Error("Invalid delegated result terminal state")
|
|
145
|
+
}
|
|
146
|
+
if (!Number.isSafeInteger(clonedCandidate.exitCode) || /** @type {number} */ (clonedCandidate.exitCode) < 0) {
|
|
147
|
+
throw new Error("Invalid delegated result exitCode")
|
|
148
|
+
}
|
|
149
|
+
const conclusion = optionalBoundedString(clonedCandidate.conclusion, "conclusion", CONCLUSION_LIMIT)
|
|
150
|
+
const blocker = optionalBoundedString(clonedCandidate.blocker, "blocker", BLOCKER_LIMIT)
|
|
151
|
+
const decisionRequest = optionalBoundedString(clonedCandidate.decisionRequest, "decisionRequest", DECISION_REQUEST_LIMIT)
|
|
152
|
+
const continuationHandle = clonedCandidate.continuationHandle === undefined ? undefined : validateContinuationHandle(clonedCandidate.continuationHandle)
|
|
153
|
+
const validationSummary = optionalBoundedString(clonedCandidate.validationSummary, "validationSummary", VALIDATION_SUMMARY_LIMIT)
|
|
154
|
+
const artifactHandles = optionalArtifactHandles(clonedCandidate.artifactHandles)
|
|
155
|
+
const references = optionalReferences(clonedCandidate.references)
|
|
156
|
+
|
|
157
|
+
const envelope = {
|
|
158
|
+
version: 1,
|
|
159
|
+
type: "delegated_result",
|
|
160
|
+
state: /** @type {TerminalState} */ (clonedCandidate.state),
|
|
161
|
+
exitCode: /** @type {number} */ (clonedCandidate.exitCode),
|
|
162
|
+
...(conclusion === undefined ? {} : {conclusion}),
|
|
163
|
+
...(blocker === undefined ? {} : {blocker}),
|
|
164
|
+
...(decisionRequest === undefined ? {} : {decisionRequest}),
|
|
165
|
+
...(continuationHandle === undefined ? {} : {continuationHandle}),
|
|
166
|
+
...(artifactHandles === undefined ? {} : {artifactHandles}),
|
|
167
|
+
...(references === undefined ? {} : {references}),
|
|
168
|
+
...(validationSummary === undefined ? {} : {validationSummary})
|
|
169
|
+
}
|
|
170
|
+
if (Buffer.byteLength(JSON.stringify(envelope), "utf8") > MAX_ENVELOPE_BYTES) {
|
|
171
|
+
throw new Error("Delegated result candidate exceeds the envelope byte limit")
|
|
172
|
+
}
|
|
173
|
+
return envelope
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** @param {unknown} value @param {string} field @param {number} limit */
|
|
177
|
+
function optionalBoundedString(value, field, limit) {
|
|
178
|
+
return value === undefined ? undefined : boundedString(value, field, limit)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** @param {unknown} value @param {string} field @param {number} limit */
|
|
182
|
+
function boundedString(value, field, limit) {
|
|
183
|
+
if (typeof value !== "string" || value.length === 0 || wellFormedString(value) !== value || Array.from(value).length > limit) {
|
|
184
|
+
throw new Error(`Invalid delegated result ${field}`)
|
|
185
|
+
}
|
|
186
|
+
return value
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** @param {string | undefined} value @param {number} limit */
|
|
190
|
+
function truncateBoundedString(value, limit) {
|
|
191
|
+
if (value === undefined) return undefined
|
|
192
|
+
const characters = Array.from(value)
|
|
193
|
+
if (characters.length <= limit) return value
|
|
194
|
+
if (limit <= 1) return "…"
|
|
195
|
+
return `${characters.slice(0, limit - 1).join("")}…`
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** @param {unknown} value */
|
|
199
|
+
export function validateContinuationHandle(value) {
|
|
200
|
+
const handle = boundedString(value, "continuationHandle", CONTINUATION_HANDLE_LIMIT)
|
|
201
|
+
if (
|
|
202
|
+
!/^[A-Za-z0-9][A-Za-z0-9._-]*(?::[A-Za-z0-9][A-Za-z0-9._-]*)*$/u.test(handle)
|
|
203
|
+
|| /^(?:data|file|ftp|ftps|http|https|javascript|mailto|sftp|ssh|urn|ws|wss):/iu.test(handle)
|
|
204
|
+
) {
|
|
205
|
+
throw new Error("Invalid delegated result continuationHandle")
|
|
206
|
+
}
|
|
207
|
+
return handle
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** @param {unknown} value */
|
|
211
|
+
function optionalArtifactHandles(value) {
|
|
212
|
+
if (value === undefined) return undefined
|
|
213
|
+
return validateClosedArray(value, "artifactHandles", MAX_ARTIFACT_HANDLES).map((item) => artifactHandle(item))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** @param {unknown} value */
|
|
217
|
+
function artifactHandle(value) {
|
|
218
|
+
const handle = boundedString(value, "artifactHandles", ARTIFACT_HANDLE_LIMIT)
|
|
219
|
+
if (!/^\/?[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/u.test(handle)) {
|
|
220
|
+
throw new Error("Invalid delegated result artifactHandles")
|
|
221
|
+
}
|
|
222
|
+
const components = handle.split("/").filter((component) => component.length > 0)
|
|
223
|
+
if (components.some((component) => component === "." || component === "..")) {
|
|
224
|
+
throw new Error("Invalid delegated result artifactHandles")
|
|
225
|
+
}
|
|
226
|
+
return handle
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** @param {unknown} value */
|
|
230
|
+
function optionalReferences(value) {
|
|
231
|
+
if (value === undefined) return undefined
|
|
232
|
+
return validateClosedArray(value, "references", MAX_REFERENCES).map((reference) => {
|
|
233
|
+
if (!isPlainObject(reference) || !hasExactEnumerableFields(reference, ["kind", "value"])) {
|
|
234
|
+
throw new Error("Invalid delegated result reference")
|
|
235
|
+
}
|
|
236
|
+
if (reference.kind !== "commit" && reference.kind !== "url") throw new Error("Invalid delegated result reference kind")
|
|
237
|
+
const referenceValue = boundedString(reference.value, "references", REFERENCE_VALUE_LIMIT)
|
|
238
|
+
if (reference.kind === "commit" && !/^[0-9a-f]{7,64}$/iu.test(referenceValue)) {
|
|
239
|
+
throw new Error("Invalid delegated result commit reference")
|
|
240
|
+
}
|
|
241
|
+
if (reference.kind === "url") validateUrlReference(referenceValue)
|
|
242
|
+
return {kind: reference.kind, value: referenceValue}
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* @param {unknown} value
|
|
248
|
+
* @param {"artifactHandles" | "references"} field
|
|
249
|
+
* @param {number} limit
|
|
250
|
+
* @returns {unknown[]}
|
|
251
|
+
*/
|
|
252
|
+
function validateClosedArray(value, field, limit) {
|
|
253
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > limit) {
|
|
254
|
+
throw new Error(`Invalid delegated result ${field}`)
|
|
255
|
+
}
|
|
256
|
+
const keys = Reflect.ownKeys(value)
|
|
257
|
+
if (keys.length !== value.length + 1) throw new Error(`Invalid delegated result ${field}`)
|
|
258
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
259
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index))
|
|
260
|
+
if (!descriptor || !("value" in descriptor) || descriptor.enumerable !== true) {
|
|
261
|
+
throw new Error(`Invalid delegated result ${field}`)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length")
|
|
265
|
+
if (
|
|
266
|
+
!lengthDescriptor
|
|
267
|
+
|| !("value" in lengthDescriptor)
|
|
268
|
+
|| lengthDescriptor.value !== value.length
|
|
269
|
+
|| lengthDescriptor.enumerable !== false
|
|
270
|
+
) throw new Error(`Invalid delegated result ${field}`)
|
|
271
|
+
if (keys.some((key) => (
|
|
272
|
+
key !== "length"
|
|
273
|
+
&& (typeof key !== "string" || !/^(?:0|[1-9]\d*)$/u.test(key) || Number(key) >= value.length)
|
|
274
|
+
))) throw new Error(`Invalid delegated result ${field}`)
|
|
275
|
+
return value
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** @param {string} value */
|
|
279
|
+
function validateUrlReference(value) {
|
|
280
|
+
if (Array.from(value).some((character) => {
|
|
281
|
+
const codePoint = character.codePointAt(0) ?? 0
|
|
282
|
+
return codePoint <= 31 || codePoint === 127 || /\s/u.test(character)
|
|
283
|
+
})) throw new Error("Invalid delegated result URL reference")
|
|
284
|
+
let parsed
|
|
285
|
+
try {
|
|
286
|
+
parsed = new URL(value)
|
|
287
|
+
} catch {
|
|
288
|
+
throw new Error("Invalid delegated result URL reference")
|
|
289
|
+
}
|
|
290
|
+
if (!["http:", "https:"].includes(parsed.protocol) || parsed.username.length > 0 || parsed.password.length > 0) {
|
|
291
|
+
throw new Error("Invalid delegated result URL reference")
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** @param {unknown} candidate @returns {DelegatedResultCandidate} */
|
|
296
|
+
function cloneTopLevelCandidate(candidate) {
|
|
297
|
+
if (candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) {
|
|
298
|
+
throw new Error("Delegated result candidate must be a plain object")
|
|
299
|
+
}
|
|
300
|
+
if (utilTypes.isProxy(candidate)) throw new Error("Delegated result candidate must not be a proxy")
|
|
301
|
+
if (!isPlainObject(candidate)) throw new Error("Delegated result candidate must be a plain object")
|
|
302
|
+
return /** @type {DelegatedResultCandidate} */ (cloneWithinBudget(candidate, {remaining: MAX_VALIDATION_BUDGET, seen: new WeakSet()}))
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* @param {unknown} value
|
|
307
|
+
* @param {{remaining: number, seen: WeakSet<object>}} state
|
|
308
|
+
* @returns {unknown}
|
|
309
|
+
*/
|
|
310
|
+
function cloneWithinBudget(value, state) {
|
|
311
|
+
state.remaining -= 1
|
|
312
|
+
if (state.remaining < 0) throw new Error("Delegated result candidate exceeds the validation budget")
|
|
313
|
+
if (value === null || typeof value !== "object") return value
|
|
314
|
+
if (utilTypes.isProxy(value)) throw new Error("Delegated result candidate must not contain proxy values")
|
|
315
|
+
if (!Array.isArray(value) && !isPlainObject(value)) return value
|
|
316
|
+
if (state.seen.has(value)) throw new Error("Delegated result candidate must not contain cycles")
|
|
317
|
+
state.seen.add(value)
|
|
318
|
+
try {
|
|
319
|
+
return cloneConcreteContainer(value, state)
|
|
320
|
+
} finally {
|
|
321
|
+
state.seen.delete(value)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* @param {Record<PropertyKey, unknown> | unknown[]} value
|
|
327
|
+
* @param {{remaining: number, seen: WeakSet<object>}} state
|
|
328
|
+
*/
|
|
329
|
+
function cloneConcreteContainer(value, state) {
|
|
330
|
+
const clone = Array.isArray(value) ? [] : Object.create(Object.getPrototypeOf(value))
|
|
331
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
332
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key)
|
|
333
|
+
if (!descriptor) continue
|
|
334
|
+
if ("get" in descriptor || "set" in descriptor) {
|
|
335
|
+
throw new Error("Delegated result candidate must not contain accessor descriptors")
|
|
336
|
+
}
|
|
337
|
+
Object.defineProperty(clone, key, {
|
|
338
|
+
...descriptor,
|
|
339
|
+
value: Array.isArray(value) && key === "length" ? descriptor.value : cloneWithinBudget(descriptor.value, state)
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
return clone
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
346
|
+
function isPlainObject(value) {
|
|
347
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false
|
|
348
|
+
const prototype = Object.getPrototypeOf(value)
|
|
349
|
+
return prototype === Object.prototype || prototype === null
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** @param {Record<string, unknown>} value @param {string[]} expected */
|
|
353
|
+
function hasExactEnumerableFields(value, expected) {
|
|
354
|
+
const fields = Reflect.ownKeys(value)
|
|
355
|
+
return fields.length === expected.length && fields.every((field) => (
|
|
356
|
+
typeof field === "string"
|
|
357
|
+
&& expected.includes(field)
|
|
358
|
+
&& Object.prototype.propertyIsEnumerable.call(value, field)
|
|
359
|
+
))
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** @param {string} value */
|
|
363
|
+
function wellFormedString(value) {
|
|
364
|
+
let result = ""
|
|
365
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
366
|
+
const codeUnit = value.charCodeAt(index)
|
|
367
|
+
if (codeUnit >= 0xD800 && codeUnit <= 0xDBFF) {
|
|
368
|
+
const next = value.charCodeAt(index + 1)
|
|
369
|
+
if (next >= 0xDC00 && next <= 0xDFFF) {
|
|
370
|
+
result += value.slice(index, index + 2)
|
|
371
|
+
index += 1
|
|
372
|
+
} else result += "\uFFFD"
|
|
373
|
+
} else if (codeUnit >= 0xDC00 && codeUnit <= 0xDFFF) result += "\uFFFD"
|
|
374
|
+
else result += value.slice(index, index + 1)
|
|
375
|
+
}
|
|
376
|
+
return result
|
|
377
|
+
}
|