dsh-edge 0.2.0 → 0.3.0-alpha.2

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.
@@ -1,4 +1,4 @@
1
- import { randomBytes } from 'node:crypto'
1
+ import { createHash, randomBytes } from 'node:crypto'
2
2
  import { execFile } from 'node:child_process'
3
3
  import { chmod, mkdtemp, open, rm, writeFile } from 'node:fs/promises'
4
4
  import { createRequire } from 'node:module'
@@ -93,6 +93,7 @@ const SENSITIVE_ENV_KEY = /(KEY|PASSWORD|SECRET|TOKEN)/iu
93
93
  const WORKER_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u
94
94
  const CLAIM_URL = /https:\/\/dash\.cloudflare\.com\/claim-preview\?[^\s\u001b]+/u
95
95
  const MAX_CAPTURE_BYTES = 2 * 1024 * 1024
96
+ const ATTACHMENT_STORAGE_BINDING = 'DSH_EDGE_ATTACHMENT_STORAGE'
96
97
  const WINDOWS_ACL_TIMEOUT_MS = 30_000
97
98
  const WINDOWS_PRIVATE_DIRECTORY_SCRIPT = String.raw`
98
99
  $ErrorActionPreference = 'Stop'
@@ -151,6 +152,13 @@ export class InstallerOutputError extends Error {
151
152
  }
152
153
  }
153
154
 
155
+ class R2SubscriptionUnavailableError extends Error {
156
+ constructor(message) {
157
+ super(message)
158
+ this.name = 'R2SubscriptionUnavailableError'
159
+ }
160
+ }
161
+
154
162
  /** Return account choices permitted by the selected runtime. */
155
163
  export function accountChoices(mode, accounts, command = 'install') {
156
164
  requireRuntimeMode(mode)
@@ -232,6 +240,82 @@ export function generateOwnerSecret() {
232
240
  return randomBytes(32).toString('base64url')
233
241
  }
234
242
 
243
+ /** Derive one stable account-local R2 bucket name from the Worker service name. */
244
+ export function attachmentBucketName(workerName) {
245
+ if (validateWorkerName(workerName) !== undefined) throw new Error('Worker name is invalid.')
246
+ const suffix = '-attachments'
247
+ if (workerName.length + suffix.length <= 63) return `${workerName}${suffix}`
248
+ const digest = createHash('sha256').update(workerName).digest('hex').slice(0, 8)
249
+ const prefix = workerName.slice(0, 42).replace(/-+$/u, '')
250
+ return `${prefix}-${digest}${suffix}`
251
+ }
252
+
253
+ function r2ActivationUrl(accountId) {
254
+ return `https://dash.cloudflare.com/${encodeURIComponent(accountId)}/r2/overview`
255
+ }
256
+
257
+ /** Create or reuse the permanent deployment's private R2 attachment bucket. */
258
+ export async function ensureR2Bucket({
259
+ bucketName,
260
+ runWrangler,
261
+ environment,
262
+ profile,
263
+ signal,
264
+ }) {
265
+ const infoArgs = ['r2', 'bucket', 'info', bucketName, '--json', ...profileArgs(profile)]
266
+ const initial = await runWrangler(infoArgs, { environment, signal })
267
+ throwIfR2SubscriptionUnavailable(initial)
268
+ if (initial.status === 0) {
269
+ requireR2BucketInfo(initial.stdout, bucketName)
270
+ return { bucketName, created: false }
271
+ }
272
+ const created = await runWrangler([
273
+ 'r2', 'bucket', 'create', bucketName, ...profileArgs(profile),
274
+ ], { environment, signal })
275
+ throwIfR2SubscriptionUnavailable(created)
276
+ if (created.status === 0) return { bucketName, created: true }
277
+ // A concurrent installer may have won the create race; prove exact existence.
278
+ const recovered = await runWrangler(infoArgs, { environment, signal })
279
+ throwIfR2SubscriptionUnavailable(recovered)
280
+ if (recovered.status === 0) {
281
+ requireR2BucketInfo(recovered.stdout, bucketName)
282
+ return { bucketName, created: false }
283
+ }
284
+ throw new Error(commandFailure(
285
+ `Could not create or access private R2 bucket "${bucketName}". Check this account's R2 access and permissions, then retry`,
286
+ created,
287
+ ))
288
+ }
289
+
290
+ /** Verify R2 availability without creating a bucket or collecting deployment credentials. */
291
+ async function verifyR2Subscription({ runWrangler, environment, profile, signal }) {
292
+ const result = await runWrangler([
293
+ 'r2', 'bucket', 'list', ...profileArgs(profile),
294
+ ], { environment, signal })
295
+ throwIfR2SubscriptionUnavailable(result)
296
+ requireSuccess(result, 'Could not check Cloudflare R2 availability')
297
+ }
298
+
299
+ function throwIfR2SubscriptionUnavailable(result) {
300
+ if (!/\[code:\s*10042\]/u.test(`${result.stdout}\n${result.stderr}`)) return
301
+ throw new R2SubscriptionUnavailableError(commandFailure(
302
+ 'Cloudflare R2 is not enabled for this account',
303
+ result,
304
+ ))
305
+ }
306
+
307
+ function requireR2BucketInfo(source, expectedName) {
308
+ let info
309
+ try {
310
+ info = JSON.parse(source)
311
+ } catch {
312
+ throw new Error('Wrangler returned malformed R2 bucket information.')
313
+ }
314
+ if (info === null || typeof info !== 'object' || info.name !== expectedName) {
315
+ throw new Error('Wrangler returned unexpected R2 bucket information.')
316
+ }
317
+ }
318
+
235
319
  /** Build the least-privilege environment used by authenticated Wrangler commands. */
236
320
  export function wranglerEnvironment(environment = process.env) {
237
321
  return pickEnvironment(environment, [
@@ -319,6 +403,96 @@ export function parseWorkerExistence(result) {
319
403
  throw new Error(commandFailure('Could not check whether the Worker already exists', result))
320
404
  }
321
405
 
406
+ /** Inspect active Worker versions so upgrades preserve the existing attachment backend. */
407
+ export async function detectExistingAttachmentStorage({
408
+ workerName,
409
+ mode,
410
+ runWrangler,
411
+ environment,
412
+ profile,
413
+ signal,
414
+ }) {
415
+ const args = [
416
+ '--name', workerName, '--json',
417
+ ...runtimeEnvironmentArgs(mode),
418
+ ...profileArgs(profile),
419
+ ]
420
+ const status = await runWrangler(['deployments', 'status', ...args], {
421
+ environment,
422
+ signal,
423
+ })
424
+ requireSuccess(status, 'Could not inspect the existing Worker deployment')
425
+ const versionIds = deploymentVersionIds(status.stdout)
426
+ const backends = new Set()
427
+ for (const versionId of versionIds) {
428
+ const version = await runWrangler([
429
+ 'versions', 'view', versionId, ...args,
430
+ ], { environment, signal })
431
+ requireSuccess(version, `Could not inspect existing Worker version ${versionId}`)
432
+ backends.add(versionAttachmentStorage(version.stdout))
433
+ }
434
+ if (backends.size !== 1) {
435
+ throw new Error('The active Worker versions use different attachment backends. Finish the existing rollout before upgrading.')
436
+ }
437
+ return backends.values().next().value
438
+ }
439
+
440
+ function deploymentVersionIds(source) {
441
+ let deployment
442
+ try {
443
+ deployment = JSON.parse(source)
444
+ } catch {
445
+ throw new Error('Wrangler returned malformed deployment status.')
446
+ }
447
+ if (!isRecord(deployment) || !Array.isArray(deployment.versions)) {
448
+ throw new Error('Wrangler returned unexpected deployment status.')
449
+ }
450
+ const ids = deployment.versions
451
+ .filter(version => isRecord(version) && typeof version.percentage === 'number'
452
+ && version.percentage > 0)
453
+ .map(version => version.version_id)
454
+ if (ids.length === 0 || ids.some(id => typeof id !== 'string' || id.length === 0)) {
455
+ throw new Error('Wrangler deployment status has no active Worker version.')
456
+ }
457
+ return [...new Set(ids)]
458
+ }
459
+
460
+ function versionAttachmentStorage(source) {
461
+ let version
462
+ try {
463
+ version = JSON.parse(source)
464
+ } catch {
465
+ throw new Error('Wrangler returned malformed Worker version details.')
466
+ }
467
+ const bindings = isRecord(version) && isRecord(version.resources)
468
+ ? version.resources.bindings
469
+ : undefined
470
+ if (!Array.isArray(bindings) || bindings.some(binding => !isRecord(binding))) {
471
+ throw new Error('Wrangler returned unexpected Worker version details.')
472
+ }
473
+ const attachment = bindings.filter(binding => binding.name === 'DSH_EDGE_ATTACHMENTS')
474
+ if (attachment.length > 1
475
+ || (attachment.length === 1 && attachment[0].type !== 'r2_bucket')) {
476
+ throw new Error('The existing Worker has an invalid attachment binding.')
477
+ }
478
+ const markers = bindings.filter(binding => binding.name === ATTACHMENT_STORAGE_BINDING)
479
+ if (markers.length > 1 || (markers.length === 1
480
+ && (markers[0].type !== 'plain_text'
481
+ || (markers[0].text !== 'temporary-do' && markers[0].text !== 'private-r2')))) {
482
+ throw new Error('The existing Worker has an invalid attachment storage marker.')
483
+ }
484
+ const marker = markers[0]?.text
485
+ if ((marker === 'temporary-do' && attachment.length !== 0)
486
+ || (marker === 'private-r2' && attachment.length !== 1)) {
487
+ throw new Error('The existing Worker attachment marker does not match its binding.')
488
+ }
489
+ if (marker !== undefined) return marker
490
+ // An R2 binding predating the explicit marker is still authoritative. A
491
+ // release with neither binding nor marker predates image attachments, so it
492
+ // has no image references to strand and can ask the owner to choose once.
493
+ return attachment.length === 1 ? 'private-r2' : undefined
494
+ }
495
+
322
496
  /** Run the complete guided install with UI and Wrangler supplied as replaceable boundaries. */
323
497
  export async function installEdge({
324
498
  command = 'install',
@@ -380,6 +554,7 @@ export async function installEdge({
380
554
  : requireSelectedAccount(accountSelection, accounts)
381
555
 
382
556
  let workerName = DEFAULT_WORKER_NAME
557
+ let updatingExisting = false
383
558
  while (true) {
384
559
  workerName = await ui.workerName(workerName, validateWorkerName)
385
560
  if (temporary) break
@@ -394,24 +569,59 @@ export async function installEdge({
394
569
  }))
395
570
  if (command === 'upgrade') {
396
571
  if (!exists) throw new Error(`${workerName} does not exist in this account and runtime. Run dsh-edge install first.`)
572
+ updatingExisting = true
397
573
  break
398
574
  }
399
575
  if (!exists) break
400
576
  const action = await ui.workerConflict(workerName)
401
- if (action === 'update') break
577
+ if (action === 'update') {
578
+ updatingExisting = true
579
+ break
580
+ }
402
581
  if (action === 'cancel') throw new InstallCancelledError()
403
582
  workerName = `${workerName}-2`
404
583
  }
405
584
 
406
- const secretMode = await ui.selectOwnerSecretMode()
407
- const ownerSecret = secretMode === 'generate'
408
- ? generateOwnerSecret()
409
- : await ui.ownerSecret(validateOwnerSecret)
410
- const secretError = validateOwnerSecret(ownerSecret)
411
- if (secretError !== undefined) throw new Error(secretError)
412
- const deepSeekKey = await ui.deepSeekKey(validateDeepSeekKey)
413
- const deepSeekError = validateDeepSeekKey(deepSeekKey)
414
- if (deepSeekError !== undefined) throw new Error(deepSeekError)
585
+ const commandEnvironment = temporary
586
+ ? unauthenticatedEnvironment(environment)
587
+ : accountEnvironment(profileEnvironment ?? environment, account.id)
588
+ const existingAttachmentStorage = updatingExisting
589
+ ? await detectExistingAttachmentStorage({
590
+ workerName,
591
+ mode,
592
+ runWrangler,
593
+ environment: commandEnvironment,
594
+ profile,
595
+ signal,
596
+ })
597
+ : undefined
598
+ let attachmentStorage = updatingExisting
599
+ ? existingAttachmentStorage ?? await ui.selectInitialAttachmentStorage()
600
+ : temporary ? 'temporary-do' : 'private-r2'
601
+ requireAttachmentStorage(attachmentStorage)
602
+
603
+ const canSwitchToDurableObject = updatingExisting
604
+ && existingAttachmentStorage === undefined
605
+ while (attachmentStorage === 'private-r2') {
606
+ ui.step('Checking Cloudflare R2 availability…')
607
+ try {
608
+ await verifyR2Subscription({
609
+ runWrangler,
610
+ environment: commandEnvironment,
611
+ profile,
612
+ signal,
613
+ })
614
+ break
615
+ } catch (error) {
616
+ if (!(error instanceof R2SubscriptionUnavailableError)) throw error
617
+ const resolution = await promptR2Recovery(
618
+ ui,
619
+ account.id,
620
+ canSwitchToDurableObject,
621
+ )
622
+ if (resolution === 'temporary-do') attachmentStorage = 'temporary-do'
623
+ }
624
+ }
415
625
 
416
626
  const confirmed = await ui.confirm({
417
627
  mode,
@@ -419,17 +629,61 @@ export async function installEdge({
419
629
  accountLabel: temporary ? 'Temporary account' : account.name,
420
630
  workerName,
421
631
  paid: mode === 'isolated',
632
+ temporary,
633
+ attachmentStorage,
422
634
  })
423
635
  if (!confirmed) throw new InstallCancelledError()
424
636
  if (temporary && !await ui.acceptTemporaryTerms()) {
425
637
  throw new InstallCancelledError()
426
638
  }
427
639
 
640
+ const secretMode = await ui.selectOwnerSecretMode()
641
+ const ownerSecret = secretMode === 'generate'
642
+ ? generateOwnerSecret()
643
+ : await ui.ownerSecret(validateOwnerSecret)
644
+ const secretError = validateOwnerSecret(ownerSecret)
645
+ if (secretError !== undefined) throw new Error(secretError)
646
+ const deepSeekKey = await ui.deepSeekKey(validateDeepSeekKey)
647
+ const deepSeekError = validateDeepSeekKey(deepSeekKey)
648
+ if (deepSeekError !== undefined) throw new Error(deepSeekError)
649
+
650
+ let bucketName
651
+ if (attachmentStorage === 'private-r2') {
652
+ bucketName = attachmentBucketName(workerName)
653
+ ui.step(`Preparing private image storage (${bucketName})…`)
654
+ while (true) {
655
+ try {
656
+ await ensureR2Bucket({
657
+ bucketName,
658
+ runWrangler,
659
+ environment: commandEnvironment,
660
+ profile,
661
+ signal,
662
+ })
663
+ break
664
+ } catch (error) {
665
+ if (!(error instanceof R2SubscriptionUnavailableError)) throw error
666
+ const resolution = await promptR2Recovery(
667
+ ui,
668
+ account.id,
669
+ canSwitchToDurableObject,
670
+ )
671
+ if (resolution === 'temporary-do') {
672
+ attachmentStorage = 'temporary-do'
673
+ bucketName = undefined
674
+ break
675
+ }
676
+ }
677
+ }
678
+ }
679
+
428
680
  temporaryDirectory = await createTemporaryDirectory()
429
681
  const secretsFile = join(temporaryDirectory, 'secrets.json')
430
682
  const configFile = join(temporaryDirectory, 'wrangler.json')
431
683
  const outputFile = join(temporaryDirectory, 'wrangler-output.ndjson')
432
- await writePrebuiltModeWranglerConfig(mode, configFile)
684
+ await writePrebuiltModeWranglerConfig(mode, configFile, {
685
+ ...bucketName === undefined ? {} : { r2BucketName: bucketName },
686
+ })
433
687
  await writeFile(secretsFile, JSON.stringify({
434
688
  DEEPSEEK_API_KEY: deepSeekKey,
435
689
  DSH_EDGE_ACCESS_KEY: ownerSecret,
@@ -439,9 +693,6 @@ export async function installEdge({
439
693
  ? 'Uploading the tested Worker release…'
440
694
  : 'Installing the tested Worker release…'
441
695
  ui.deploymentStart?.(deploymentMessage)
442
- const commandEnvironment = temporary
443
- ? unauthenticatedEnvironment(environment)
444
- : accountEnvironment(profileEnvironment ?? environment, account.id)
445
696
  if (temporary) commandEnvironment.XDG_CONFIG_HOME = temporaryDirectory
446
697
  commandEnvironment.WRANGLER_LOG_SANITIZE = 'true'
447
698
  commandEnvironment.WRANGLER_OUTPUT_FILE_PATH = outputFile
@@ -527,6 +778,7 @@ export async function installEdge({
527
778
  let result = {
528
779
  ...deployment,
529
780
  account,
781
+ attachmentStorage,
530
782
  claimUrl,
531
783
  mode,
532
784
  ownerSecret,
@@ -843,6 +1095,10 @@ function isTerminalSafe(value) {
843
1095
  return !CONTROL_CHARACTER.test(value) && !BIDI_CONTROL.test(value)
844
1096
  }
845
1097
 
1098
+ function isRecord(value) {
1099
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
1100
+ }
1101
+
846
1102
  /** Create a chunk-safe terminal filter that retains plain text only. */
847
1103
  export function createTerminalSanitizer() {
848
1104
  let state = 'text'
@@ -983,16 +1239,48 @@ function requireRuntimeMode(mode) {
983
1239
  }
984
1240
  }
985
1241
 
1242
+ function requireAttachmentStorage(storage) {
1243
+ if (storage !== 'temporary-do' && storage !== 'private-r2') {
1244
+ throw new Error(`Unsupported attachment storage: ${String(storage)}`)
1245
+ }
1246
+ }
1247
+
1248
+ function requireR2RecoveryAction(action, canSwitchToDurableObject) {
1249
+ if (action === 'cancel') throw new InstallCancelledError()
1250
+ if (action === 'retry') return action
1251
+ if (action === 'temporary-do' && canSwitchToDurableObject) return action
1252
+ throw new Error(`Unsupported R2 recovery action: ${String(action)}`)
1253
+ }
1254
+
1255
+ async function promptR2Recovery(ui, accountId, canSwitchToDurableObject) {
1256
+ return requireR2RecoveryAction(
1257
+ await ui.r2SubscriptionUnavailable({
1258
+ activationUrl: r2ActivationUrl(accountId),
1259
+ canSwitchToDurableObject,
1260
+ }),
1261
+ canSwitchToDurableObject,
1262
+ )
1263
+ }
1264
+
986
1265
  function requireSuccess(result, prefix) {
987
1266
  if (result.outputFailure !== undefined) throw result.outputFailure
988
1267
  if (result.status !== 0) throw new Error(commandFailure(prefix, result))
989
1268
  }
990
1269
 
991
1270
  function commandFailure(prefix, result) {
992
- const detail = stripAnsi(result.stderr || result.stdout).trim()
1271
+ const detail = conciseDiagnostic(result.stderr || result.stdout)
993
1272
  return detail === '' ? `${prefix}.` : `${prefix}: ${detail}`
994
1273
  }
995
1274
 
1275
+ /** Remove known Node dependency noise from concise errors while verbose output stays unchanged. */
1276
+ function conciseDiagnostic(value) {
1277
+ return stripAnsi(value).split(/\r?\n/u)
1278
+ .filter(line => !/^\(node:\d+\) \[DEP0040\] DeprecationWarning: The `punycode` module is deprecated\./u.test(line.trim()))
1279
+ .filter(line => !/^\(Use `node --trace-deprecation \.\.\.` to show where the warning was created\)$/u.test(line.trim()))
1280
+ .join('\n')
1281
+ .trim()
1282
+ }
1283
+
996
1284
  function describeError(error) {
997
1285
  return error instanceof Error ? error.message : String(error)
998
1286
  }
@@ -41,6 +41,10 @@ const repositoryRoot = fileURLToPath(new URL('../../../', import.meta.url))
41
41
  const packageRoot = fileURLToPath(new URL('../', import.meta.url))
42
42
  const standaloneRoot = join(packageRoot, 'standalone')
43
43
  const edgeManifest = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'))
44
+ const patchAudit = JSON.parse(readFileSync(join(standaloneRoot, 'patches', 'audit.json'), 'utf8'))
45
+ if (!Array.isArray(patchAudit) || patchAudit.length === 0) {
46
+ throw new Error('The retained patch audit must be a non-empty array.')
47
+ }
44
48
 
45
49
  function bundledComponents() {
46
50
  const invocation = resolvePnpmInvocation(process.env.npm_execpath ?? 'pnpm', [
@@ -212,7 +216,7 @@ function renderNotices(components) {
212
216
 
213
217
  ## DeepSeek Harness
214
218
 
215
- \`dsh-edge\` assembles published DeepSeek Harness packages and applies six version-bound adaptations to the pinned \`${edgeManifest.dshEdge.upstreamVersion}\` release. DeepSeek Harness remains under its upstream MIT license:
219
+ \`dsh-edge\` assembles published DeepSeek Harness packages and applies ${String(patchAudit.length)} version-bound adaptations to the pinned \`${edgeManifest.dshEdge.upstreamVersion}\` release. DeepSeek Harness remains under its upstream MIT license:
216
220
 
217
221
  \`\`\`text
218
222
  ${upstreamLicense}
@@ -11,6 +11,8 @@ const WORKER_ARTIFACTS = Object.freeze({
11
11
  direct: 'worker/direct/index.js',
12
12
  isolated: 'worker/isolated/index.js',
13
13
  })
14
+ const ATTACHMENT_BINDING = 'DSH_EDGE_ATTACHMENTS'
15
+ const ATTACHMENT_STORAGE_BINDING = 'DSH_EDGE_ATTACHMENT_STORAGE'
14
16
 
15
17
  /** Render one mode-specific build configuration from an already parsed source object. */
16
18
  export function renderParsedSourceModeWranglerConfig(mode, parsed, options = {}) {
@@ -34,6 +36,7 @@ export function renderParsedSourceModeWranglerConfig(mode, parsed, options = {})
34
36
  config.main = resolve(root, parsed.main)
35
37
  config.assets.directory = options.assetsDirectory ?? resolve(root, parsed.assets.directory)
36
38
  config.minify = true
39
+ applyAttachmentStorage(config, mode, options.r2BucketName)
37
40
  if (mode === 'direct') {
38
41
  config.alias = {
39
42
  ...config.alias,
@@ -63,9 +66,34 @@ export function renderParsedPrebuiltModeWranglerConfig(mode, parsed, options = {
63
66
  config.assets.directory = resolve(root, parsed.assets.directory)
64
67
  config.no_bundle = true
65
68
  config.find_additional_modules = false
69
+ applyAttachmentStorage(config, mode, options.r2BucketName)
66
70
  return `${JSON.stringify(config, undefined, 2)}\n`
67
71
  }
68
72
 
73
+ function applyAttachmentStorage(config, mode, bucketName) {
74
+ if (bucketName !== undefined && (typeof bucketName !== 'string' || bucketName.length === 0)) {
75
+ throw new Error('R2 attachment bucket name must be a non-empty string.')
76
+ }
77
+ const target = mode === 'direct' ? config : config.env?.isolated
78
+ if (!isRecord(target)) {
79
+ throw new Error('wrangler.jsonc must declare the isolated environment.')
80
+ }
81
+ if (target.vars !== undefined && !isRecord(target.vars)) {
82
+ throw new Error('wrangler.jsonc vars must be an object.')
83
+ }
84
+ target.vars = {
85
+ ...target.vars,
86
+ [ATTACHMENT_STORAGE_BINDING]: bucketName === undefined ? 'temporary-do' : 'private-r2',
87
+ }
88
+ if (bucketName === undefined) return
89
+ const binding = [{ binding: ATTACHMENT_BINDING, bucket_name: bucketName }]
90
+ if (mode === 'direct') {
91
+ config.r2_buckets = binding
92
+ return
93
+ }
94
+ target.r2_buckets = binding
95
+ }
96
+
69
97
  /** Return the released entrypoint for a runtime mode. */
70
98
  export function workerArtifactPath(mode, options = {}) {
71
99
  requireRuntimeMode(mode)
@@ -4,6 +4,7 @@ export interface WranglerConfigOptions {
4
4
  aliases?: Record<string, string>
5
5
  appDirectory?: string
6
6
  assetsDirectory?: string
7
+ r2BucketName?: string
7
8
  sourceConfigPath?: string
8
9
  }
9
10