@drawcall/market 0.1.77 → 0.1.80

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.
Files changed (43) hide show
  1. package/dist/asset-implementation.d.ts +4 -0
  2. package/dist/asset-implementation.d.ts.map +1 -1
  3. package/dist/asset-implementation.js.map +1 -1
  4. package/dist/cli-client.js +1 -1
  5. package/dist/cli-client.js.map +1 -1
  6. package/dist/cli.js +3 -1
  7. package/dist/cli.js.map +1 -1
  8. package/dist/client.js +1 -1
  9. package/dist/client.js.map +1 -1
  10. package/dist/commands/install.d.ts +3 -1
  11. package/dist/commands/install.d.ts.map +1 -1
  12. package/dist/commands/install.js +11 -6
  13. package/dist/commands/install.js.map +1 -1
  14. package/dist/contract.d.ts +31 -0
  15. package/dist/contract.d.ts.map +1 -1
  16. package/dist/contract.js +5 -1
  17. package/dist/contract.js.map +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/install.d.ts +4 -2
  22. package/dist/install.d.ts.map +1 -1
  23. package/dist/install.js +78 -8
  24. package/dist/install.js.map +1 -1
  25. package/dist/resolve.d.ts +1 -0
  26. package/dist/resolve.d.ts.map +1 -1
  27. package/dist/resolve.js +7 -4
  28. package/dist/resolve.js.map +1 -1
  29. package/dist/schemas.d.ts +8 -0
  30. package/dist/schemas.d.ts.map +1 -1
  31. package/dist/schemas.js +13 -0
  32. package/dist/schemas.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/asset-implementation.ts +4 -0
  35. package/src/cli-client.ts +1 -1
  36. package/src/cli.ts +4 -2
  37. package/src/client.ts +1 -1
  38. package/src/commands/install.ts +13 -4
  39. package/src/contract.ts +32 -0
  40. package/src/index.ts +2 -0
  41. package/src/install.ts +101 -11
  42. package/src/resolve.ts +15 -3
  43. package/src/schemas.ts +15 -0
package/src/install.ts CHANGED
@@ -11,12 +11,13 @@
11
11
  */
12
12
 
13
13
  import { execFile } from 'child_process'
14
+ import { createHash } from 'crypto'
14
15
  import * as fs from 'fs/promises'
15
16
  import * as path from 'path'
16
17
  import { unzipSync } from 'fflate'
17
18
  import { detectPackageManager, installDependencies } from 'nypm'
18
19
  import type { MarketClient } from './client.js'
19
- import type { AssetInstallMetadata } from './contract.js'
20
+ import type { AssetFilesResult, AssetInstallMetadata } from './contract.js'
20
21
  import { withProjectInstallLock } from './install-lock.js'
21
22
  import { readMarketLock, writeMarketLock } from './market-lock.js'
22
23
  import { parsePackageJson, type PackageJson } from './package-json.js'
@@ -31,11 +32,13 @@ export interface InstallOptions {
31
32
  rootRequests?: InstallRootRequest[]
32
33
  /** Asset-type install policy from the Market API. */
33
34
  installMetadata?: Record<string, AssetInstallMetadata>
34
- /** Market API origin (e.g. https://api.market.drawcall.ai). When set, asset zips are fetched from
35
- * the Worker's streaming `/download` route instead of the buffering oRPC `downloadZip`. */
35
+ /** Market API origin (e.g. https://market.drawcall.ai). When set, the zip fallback is fetched
36
+ * from the Worker's streaming `/download` route instead of the buffering oRPC `downloadZip`. */
36
37
  baseUrl?: string
37
38
  /** Bearer token for owner-only assets downloaded through the streaming route. */
38
39
  authToken?: string
40
+ /** Capability key of a shared private asset (`market install <asset> --key <key>`). */
41
+ key?: string
39
42
  /** Log progress */
40
43
  onProgress?: (message: string) => void
41
44
  /**
@@ -82,6 +85,7 @@ interface DownloadOptions {
82
85
  log: (message: string) => void
83
86
  baseUrl?: string
84
87
  authToken?: string
88
+ key?: string
85
89
  }
86
90
 
87
91
  export async function install(
@@ -98,6 +102,7 @@ export async function install(
98
102
  log,
99
103
  baseUrl: opts.baseUrl,
100
104
  authToken: opts.authToken,
105
+ key: opts.key,
101
106
  })
102
107
  const integration = await withProjectInstallLock(
103
108
  installRoot,
@@ -188,14 +193,14 @@ async function downloadOneAsset(
188
193
  ): Promise<{ asset: InstalledAsset; wrotePackageJson: boolean; warnings: string[] }> {
189
194
  opts.log(`Downloading ${asset.name}@${asset.version}...`)
190
195
 
191
- // Only the network fetch is retried — the download is the sole transient step; unzip and file
192
- // writes are deterministic local work and a failure there is a real error, not worth re-attempting.
193
- const files = unzipSync(new Uint8Array(await fetchAssetZipBytes(client, asset, opts)))
196
+ // Only the network fetches are retried — downloads are the sole transient step; file writes are
197
+ // deterministic local work and a failure there is a real error, not worth re-attempting.
198
+ const files = await fetchAssetEntries(client, asset, opts)
194
199
  const installedFiles: string[] = []
195
200
  const warnings: string[] = []
196
201
  let wrotePackageJson = false
197
202
 
198
- for (const [relativePath, content] of Object.entries(files) as [string, Uint8Array][]) {
203
+ for (const [relativePath, content] of files) {
199
204
  const zipPath = relativePath.replace(/\\/g, '/')
200
205
  if (
201
206
  zipPath.split('/').includes('..') ||
@@ -248,6 +253,82 @@ async function downloadOneAsset(
248
253
  }
249
254
  }
250
255
 
256
+ /** Files fetched in flight per asset. Assets themselves download 8-wide, so this bounds total
257
+ * connections at 8×4 against edge-cached objects — cheap for the CDN, fast for the install. */
258
+ const FILE_DOWNLOAD_CONCURRENCY = 4
259
+
260
+ /**
261
+ * A version's files: fetched per file from the data plane (parallel, md5-verified against R2
262
+ * etags, through the same edge cache browsers warm), or by unzipping the zip view when the server
263
+ * or version predates unpacked storage.
264
+ */
265
+ async function fetchAssetEntries(
266
+ client: MarketClient,
267
+ asset: ResolveResult['assets'][number],
268
+ opts: DownloadOptions,
269
+ ): Promise<Array<[string, Uint8Array]>> {
270
+ const index = await fetchFileIndex(client, asset, opts)
271
+ if (!index) {
272
+ const zip = unzipSync(new Uint8Array(await fetchAssetZipBytes(client, asset, opts)))
273
+ return Object.entries(zip)
274
+ }
275
+
276
+ const label = `${asset.name}@${asset.version}`
277
+ return mapWithConcurrency(index.files, FILE_DOWNLOAD_CONCURRENCY, async (file) => {
278
+ const bytes = await withRetry(() => fetchVerifiedFile(index, file), {
279
+ attempts: DOWNLOAD_ATTEMPTS,
280
+ onRetry: (msg) => opts.log(`Downloading ${label} ${file.path}: ${msg}`),
281
+ })
282
+ return [file.path, bytes] as [string, Uint8Array]
283
+ })
284
+ }
285
+
286
+ /**
287
+ * The derived file index of a version, or null when the zip view must cover it: a NOT_FOUND means
288
+ * the server predates `asset.files` or the version is not yet backfilled — both transitional
289
+ * states the migration's Contract phase retires.
290
+ */
291
+ async function fetchFileIndex(
292
+ client: MarketClient,
293
+ asset: ResolveResult['assets'][number],
294
+ opts: DownloadOptions,
295
+ ): Promise<AssetFilesResult | null> {
296
+ try {
297
+ return await client.asset.files({
298
+ name: asset.name,
299
+ version: asset.version,
300
+ ...(opts.key ? { key: opts.key } : {}),
301
+ })
302
+ } catch (error) {
303
+ if ((error as { status?: unknown } | null)?.status === 404) return null
304
+ throw error
305
+ }
306
+ }
307
+
308
+ async function fetchVerifiedFile(
309
+ index: AssetFilesResult,
310
+ file: AssetFilesResult['files'][number],
311
+ ): Promise<Uint8Array> {
312
+ const url = new URL(encodeSubpath(file.path), index.baseUrl)
313
+ if (index.key) url.searchParams.set('key', index.key)
314
+ const res = await fetch(url)
315
+ if (!res.ok) {
316
+ throw Object.assign(new Error(`file download responded ${res.status}`), { status: res.status })
317
+ }
318
+ const bytes = new Uint8Array(await res.arrayBuffer())
319
+ // A digest mismatch is a corrupt transfer; thrown without a status so withRetry treats it as
320
+ // transient and re-fetches.
321
+ const md5 = createHash('md5').update(bytes).digest('hex')
322
+ if (md5 !== file.etag) {
323
+ throw new Error(`md5 mismatch for ${file.path}: expected ${file.etag}, got ${md5}`)
324
+ }
325
+ return bytes
326
+ }
327
+
328
+ function encodeSubpath(subpath: string): string {
329
+ return subpath.split('/').map(encodeURIComponent).join('/')
330
+ }
331
+
251
332
  /** Fetch an asset's source-zip bytes. The CLI uses the Worker's streaming `/download` route so a
252
333
  * large zip never has to fit in Worker memory. Library callers without a base URL use the typed
253
334
  * client route. Both paths retry transient failures and surface a final failure unchanged. */
@@ -258,7 +339,7 @@ async function fetchAssetZipBytes(
258
339
  ): Promise<ArrayBuffer> {
259
340
  const label = `${asset.name}@${asset.version}`
260
341
  if (opts.baseUrl) {
261
- const url = assetDownloadUrl(opts.baseUrl, asset)
342
+ const url = assetDownloadUrl(opts.baseUrl, asset, opts.key)
262
343
  return withRetry(() => fetchZipFromUrl(url, opts.authToken), {
263
344
  attempts: DOWNLOAD_ATTEMPTS,
264
345
  onRetry: (msg) => opts.log(`Downloading ${label} (streaming): ${msg}`),
@@ -267,17 +348,26 @@ async function fetchAssetZipBytes(
267
348
  return withRetry(
268
349
  () =>
269
350
  client.asset
270
- .downloadZip({ name: asset.name, version: asset.version })
351
+ .downloadZip({
352
+ name: asset.name,
353
+ version: asset.version,
354
+ ...(opts.key ? { key: opts.key } : {}),
355
+ })
271
356
  .then((blob) => blob.arrayBuffer()),
272
357
  { attempts: DOWNLOAD_ATTEMPTS, onRetry: (msg) => opts.log(`Downloading ${label}: ${msg}`) },
273
358
  )
274
359
  }
275
360
 
276
- /** The Worker's streaming-download URL for an asset (`GET /download?name&version`). */
277
- function assetDownloadUrl(baseUrl: string, asset: ResolveResult['assets'][number]): string {
361
+ /** The Worker's streaming-download URL for an asset (`GET /download?name&version[&key]`). */
362
+ function assetDownloadUrl(
363
+ baseUrl: string,
364
+ asset: ResolveResult['assets'][number],
365
+ key?: string,
366
+ ): string {
278
367
  const url = new URL('/download', baseUrl)
279
368
  url.searchParams.set('name', asset.name)
280
369
  url.searchParams.set('version', asset.version)
370
+ if (key) url.searchParams.set('key', key)
281
371
  return url.href
282
372
  }
283
373
 
package/src/resolve.ts CHANGED
@@ -60,7 +60,7 @@ export class ResolutionError extends Error {
60
60
  export async function resolve(
61
61
  client: MarketClient['asset'],
62
62
  requests: AssetRequest[],
63
- opts: { includeUnapproved?: boolean } = {},
63
+ opts: { includeUnapproved?: boolean; key?: string } = {},
64
64
  ): Promise<ResolveResult> {
65
65
  const metaCache = new Map<string, AssetExactResult>()
66
66
  const includeUnapproved = opts.includeUnapproved ?? false
@@ -76,7 +76,14 @@ export async function resolve(
76
76
  }
77
77
  seenPinSets.add(signature)
78
78
 
79
- const result = await resolvePass(client, requests, exactPins, includeUnapproved, metaCache)
79
+ const result = await resolvePass(
80
+ client,
81
+ requests,
82
+ exactPins,
83
+ includeUnapproved,
84
+ metaCache,
85
+ opts.key,
86
+ )
80
87
  const nextPins = exactPinsFor(result.constraints)
81
88
  if (!samePins(exactPins, nextPins)) {
82
89
  exactPins = nextPins
@@ -103,6 +110,7 @@ async function resolvePass(
103
110
  initialPins: ExactPins,
104
111
  includeUnapproved: boolean,
105
112
  metaCache: Map<string, AssetExactResult>,
113
+ key?: string,
106
114
  ): Promise<ResolutionPass> {
107
115
  const constraints = rootConstraints(requests)
108
116
  const resolved = new Map<string, ResolvedAsset>()
@@ -114,7 +122,7 @@ async function resolvePass(
114
122
 
115
123
  const constrainedExact = requiredExactVersion(assetName, constraints.get(assetName) ?? [])
116
124
  const exactVersion = constrainedExact ?? initialPins.get(assetName) ?? null
117
- const meta = await fetchMeta(client, metaCache, assetName, exactVersion, includeUnapproved)
125
+ const meta = await fetchMeta(client, metaCache, assetName, exactVersion, includeUnapproved, key)
118
126
  if (!meta) {
119
127
  if (exactVersion) {
120
128
  throw new ResolutionError(
@@ -252,14 +260,18 @@ async function fetchMeta(
252
260
  name: string,
253
261
  version: string | null,
254
262
  includeUnapproved: boolean,
263
+ key?: string,
255
264
  ): Promise<AssetExactResult | null> {
256
265
  const cacheKey = version ? `${name}@${version}` : name
257
266
  const cached = cache.get(cacheKey)
258
267
  if (cached) return cached
268
+ // The key is sent with every lookup in the tree; it only unlocks the asset it belongs to and is
269
+ // inert for the rest, so no per-asset routing is needed.
259
270
  const meta = await client.exact({
260
271
  name,
261
272
  ...(version ? { version } : {}),
262
273
  includeUnapproved,
274
+ ...(key ? { key } : {}),
263
275
  })
264
276
  if (meta) cache.set(cacheKey, meta)
265
277
  return meta
package/src/schemas.ts CHANGED
@@ -68,11 +68,17 @@ export const listAssetsSchema = z.object({
68
68
  sort: z.enum(['newest', 'alphabetical', 'relevance']).default('newest'),
69
69
  })
70
70
 
71
+ // An asset's capability key — the `?key=` suffix that unlocks a private (or unapproved) asset's
72
+ // files and preview on the data plane. Accepted by exact reads as an alternative credential to
73
+ // identity, so "sharing an asset" is sharing this one string.
74
+ export const accessKeySchema = z.string().min(1).max(128)
75
+
71
76
  export const exactAssetSchema = z.object({
72
77
  name: assetNameSchema,
73
78
  type: assetTypeSchema.optional(),
74
79
  version: semverSchema.optional(),
75
80
  includeUnapproved: z.boolean().default(false),
81
+ key: accessKeySchema.optional(),
76
82
  })
77
83
 
78
84
  export const uploadZipSchema = z.object({
@@ -92,6 +98,15 @@ export const uploadZipSchema = z.object({
92
98
  export const downloadZipSchema = z.object({
93
99
  name: assetNameSchema,
94
100
  version: semverSchema,
101
+ key: accessKeySchema.optional(),
102
+ })
103
+
104
+ // Input of `asset.files`: the derived file index of a version, for per-file installs from the data
105
+ // plane. Same credential rules as downloadZip.
106
+ export const assetFilesSchema = z.object({
107
+ name: assetNameSchema,
108
+ version: semverSchema,
109
+ key: accessKeySchema.optional(),
95
110
  })
96
111
 
97
112
  export const fileManifestSchema = z.object({