@proveanything/smartlinks 2.1.4 → 2.1.5

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,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 2.1.4 | Generated: 2026-10-28T12:35:09.709Z
3
+ Version: 2.1.5 | Generated: 2026-10-30T13:51:55.292Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
@@ -1,6 +1,6 @@
1
1
  # Smartlinks API Summary
2
2
 
3
- Version: 2.1.4 | Generated: 2026-10-28T12:35:09.709Z
3
+ Version: 2.1.5 | Generated: 2026-10-30T13:51:55.292Z
4
4
 
5
5
  This is a concise summary of all available API functions and types.
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proveanything/smartlinks",
3
- "version": "2.1.4",
3
+ "version": "2.1.5",
4
4
  "description": "Official JavaScript/TypeScript SDK for the Smartlinks API",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -18,6 +18,7 @@
18
18
 
19
19
  import { readFileSync, readdirSync, statSync, watch } from 'node:fs'
20
20
  import { join, relative, sep } from 'node:path'
21
+ import { createHash } from 'node:crypto'
21
22
 
22
23
  const args = process.argv.slice(2)
23
24
  const flag = (name, def) => { const i = args.indexOf(name); return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true) : def }
@@ -66,6 +67,69 @@ const BATCH_FILES = 150
66
67
  // through an API request, so no single file is ever too large to publish (e.g. a readable dev build).
67
68
  const DIRECT_BYTES = 4 * 1024 * 1024
68
69
 
70
+ // A large bundle sends only what changed, and straight to storage (POST /publish/plan): the API compares
71
+ // each file's MD5 with what the dev folder already holds and hands out an upload session for the rest, which
72
+ // go up in parallel, never through the API. A site with 1,500 files, mostly images, sent all of them through
73
+ // the API on every publish (about two minutes); a text fix now sends the few files the build changed.
74
+ const PLAN_FILES = 500
75
+ const PARALLEL_UPLOADS = 8
76
+ const md5Of = (buf) => createHash('md5').update(buf).digest('base64')
77
+
78
+ async function pool(items, size, fn) {
79
+ let i = 0
80
+ await Promise.all(Array.from({ length: Math.min(size, items.length) }, async () => {
81
+ while (i < items.length) { const k = i++; await fn(items[k]) }
82
+ }))
83
+ }
84
+
85
+ // PUT one file to its upload session; a failed upload asks for a fresh session and tries again (3 tries).
86
+ async function putFile(path, buf, url, qs) {
87
+ let at = url
88
+ for (let attempt = 1; ; attempt++) {
89
+ const put = await fetch(at, { method: 'PUT', body: buf }).catch((e) => ({ ok: false, status: 0, text: async () => e.message }))
90
+ if (put.ok) return true
91
+ const why = `${put.status}: ${(await put.text().catch(() => '')).slice(0, 200)}`
92
+ if (attempt >= 3) { console.error(`❌ upload of ${path} failed (${why})`); return false }
93
+ const { res, b } = await post(`/publish/uploads${qs}`, { files: [{ path, size: buf.length }] })
94
+ if (!res.ok || !b.ok || !b.uploads || !b.uploads[0]) { console.error(`❌ upload of ${path} failed (${why}); no new upload session`); return false }
95
+ at = b.uploads[0].url
96
+ }
97
+ }
98
+
99
+ // → the paths now in the dev folder (unchanged + uploaded), null when this API has no plans yet (an older
100
+ // API: the caller sends batches, as before), or false when an upload failed.
101
+ async function publishByPlan(rest, qs) {
102
+ // Empty files can't take an upload session: they go in a batch (they cost nothing).
103
+ const planned = rest.filter(([, buf]) => buf.length > 0)
104
+ const have = []
105
+ const uploads = []
106
+ for (let i = 0; i < planned.length; i += PLAN_FILES) {
107
+ const part = planned.slice(i, i + PLAN_FILES)
108
+ const { res, b } = await post(`/publish/plan${qs}`, { files: part.map(([p, buf]) => ({ path: p, size: buf.length, md5: md5Of(buf) })) })
109
+ if (res.status === 404 && i === 0) return null
110
+ if (!res.ok || !b.ok) { reportFailure(res, b); return false }
111
+ have.push(...(b.have || []))
112
+ uploads.push(...(b.uploads || []))
113
+ }
114
+ const bytes = new Map(rest)
115
+ const sendBytes = uploads.reduce((n, u) => n + (bytes.get(u.path) || []).length, 0)
116
+ console.log(`📦 ${APP_ID}: ${planned.length} files — ${have.length} unchanged, ${uploads.length} to send (${(sendBytes / 1048576).toFixed(1)} MB, straight to storage)`)
117
+ let sent = 0
118
+ let failed = false
119
+ await pool(uploads, PARALLEL_UPLOADS, async (u) => {
120
+ if (failed) return
121
+ if (!(await putFile(u.path, bytes.get(u.path), u.url, qs))) { failed = true; return }
122
+ if (++sent % 100 === 0 || sent === uploads.length) console.log(` ↑ ${sent}/${uploads.length}`)
123
+ })
124
+ if (failed) return false
125
+ const empty = rest.filter(([, buf]) => buf.length === 0)
126
+ if (empty.length) {
127
+ const { res, b } = await post(`/publish/files${qs}`, { files: toB64(empty) })
128
+ if (!res.ok || !b.ok) { reportFailure(res, b); return false }
129
+ }
130
+ return rest.map(([p]) => p)
131
+ }
132
+
69
133
  // Big files → storage directly. Returns the paths uploaded, or null when the server has no direct
70
134
  // uploads yet (an older API: the caller falls back to batches).
71
135
  async function uploadDirect(entries, qs) {
@@ -122,37 +186,45 @@ async function publishOnce() {
122
186
  if (totalBytes <= BATCH_BYTES && entries.length <= BATCH_FILES) {
123
187
  finalBody = { files: toB64(entries) } // small bundle: one request, as before
124
188
  } else {
125
- // Large bundle: upload everything except the manifest in batches, then send the manifest last —
126
- // the server only registers the release once every referenced file is confirmed uploaded.
189
+ // Large bundle: everything except the manifest first (only what changed, straight to storage; or in
190
+ // batches on an older API), then the manifest last — the server only registers the release once every
191
+ // referenced file is confirmed uploaded.
127
192
  const rest = entries.filter(([p]) => p !== 'app.manifest.json').sort((a, b) => b[1].length - a[1].length)
128
- const big = rest.filter(([, buf]) => buf.length >= DIRECT_BYTES)
129
- let direct = []
130
- if (big.length) {
131
- const done = await uploadDirect(big, qs)
132
- if (done === false) { if (WATCH) return false; process.exit(1) }
133
- if (done) direct = done
134
- else console.warn(' ⚠︎ this SmartLinks API has no direct uploads yet — sending big files in batches')
135
- }
136
- const viaBatches = rest.filter(([p]) => !direct.includes(p))
137
- const batches = []
138
- let cur = [], curBytes = 0
139
- for (const e of viaBatches) {
140
- if (cur.length && (curBytes + e[1].length > BATCH_BYTES || cur.length >= BATCH_FILES)) { batches.push(cur); cur = []; curBytes = 0 }
141
- if (e[1].length > BATCH_BYTES) console.warn(` ⚠︎ ${e[0]} is ${(e[1].length / 1048576).toFixed(1)} MB — larger than one request allows; the server will likely reject it`)
142
- cur.push(e); curBytes += e[1].length
143
- }
144
- if (cur.length) batches.push(cur)
145
- console.log(`📦 ${APP_ID}: ${(totalBytes / 1048576).toFixed(1)} MB in ${entries.length} files — ${direct.length ? `${direct.length} sent directly, ` : ''}${batches.length} batch(es)`)
146
- for (let i = 0; i < batches.length; i++) {
147
- const { res, b } = await post(`/publish/files${qs}`, { files: toB64(batches[i]) })
148
- if (!res.ok || !b.ok) {
149
- reportFailure(res, b)
150
- if (WATCH) return false
151
- process.exit(1)
193
+ const byPlan = await publishByPlan(rest, qs)
194
+ if (byPlan === false) { if (WATCH) return false; process.exit(1) }
195
+ if (byPlan) {
196
+ finalBody = { files: toB64([['app.manifest.json', files['app.manifest.json']]]), uploaded: byPlan }
197
+ } else {
198
+ console.warn(' ⚠︎ this SmartLinks API has no publish plans yet — sending every file in batches')
199
+ const big = rest.filter(([, buf]) => buf.length >= DIRECT_BYTES)
200
+ let direct = []
201
+ if (big.length) {
202
+ const done = await uploadDirect(big, qs)
203
+ if (done === false) { if (WATCH) return false; process.exit(1) }
204
+ if (done) direct = done
205
+ else console.warn(' ⚠︎ this SmartLinks API has no direct uploads yet — sending big files in batches')
206
+ }
207
+ const viaBatches = rest.filter(([p]) => !direct.includes(p))
208
+ const batches = []
209
+ let cur = [], curBytes = 0
210
+ for (const e of viaBatches) {
211
+ if (cur.length && (curBytes + e[1].length > BATCH_BYTES || cur.length >= BATCH_FILES)) { batches.push(cur); cur = []; curBytes = 0 }
212
+ if (e[1].length > BATCH_BYTES) console.warn(` ⚠︎ ${e[0]} is ${(e[1].length / 1048576).toFixed(1)} MB — larger than one request allows; the server will likely reject it`)
213
+ cur.push(e); curBytes += e[1].length
214
+ }
215
+ if (cur.length) batches.push(cur)
216
+ console.log(`📦 ${APP_ID}: ${(totalBytes / 1048576).toFixed(1)} MB in ${entries.length} files — ${direct.length ? `${direct.length} sent directly, ` : ''}${batches.length} batch(es)`)
217
+ for (let i = 0; i < batches.length; i++) {
218
+ const { res, b } = await post(`/publish/files${qs}`, { files: toB64(batches[i]) })
219
+ if (!res.ok || !b.ok) {
220
+ reportFailure(res, b)
221
+ if (WATCH) return false
222
+ process.exit(1)
223
+ }
224
+ console.log(` ↑ batch ${i + 1}/${batches.length} (${batches[i].length} files)`)
152
225
  }
153
- console.log(` ↑ batch ${i + 1}/${batches.length} (${batches[i].length} files)`)
226
+ finalBody = { files: toB64([['app.manifest.json', files['app.manifest.json']]]), uploaded: rest.map(([p]) => p) }
154
227
  }
155
- finalBody = { files: toB64([['app.manifest.json', files['app.manifest.json']]]), uploaded: rest.map(([p]) => p) }
156
228
  }
157
229
 
158
230
  const { res, b } = await post(`/publish${qs}`, finalBody)