@meith/marketplace 0.17.1 → 0.17.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/marketplace",
3
- "version": "0.17.1",
3
+ "version": "0.17.2",
4
4
  "license": "LGPL-3.0-or-later",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,6 +19,6 @@
19
19
  "access": "public"
20
20
  },
21
21
  "dependencies": {
22
- "@meith/theme-kit": "0.17.1"
22
+ "@meith/theme-kit": "0.17.2"
23
23
  }
24
24
  }
package/src/build-info.ts CHANGED
@@ -7,7 +7,7 @@ import { parseApiVersion, THEME_API_VERSION } from '@meith/theme-kit'
7
7
  * packages/create-meith/src/bin.ts — see docs/release.md — and kept honest
8
8
  * by the same `scripts/release-check.mjs`.
9
9
  */
10
- export const MEITH_VERSION = '0.17.1'
10
+ export const MEITH_VERSION = '0.17.2'
11
11
 
12
12
  /**
13
13
  * The theme-kit major this build implements, read from the single place
package/src/cache.ts CHANGED
@@ -21,11 +21,14 @@ export const EMPTY_CACHE: CachedMarketplace = {
21
21
  /**
22
22
  * The cached feed and the daily task's own bookkeeping — one row, whichever
23
23
  * infrastructure package backs it (see packages/db's `marketplace-repo.ts`).
24
- * `hasNotified`/`markNotified` are what make an update notify administrators
25
- * exactly once per (plugin, version), ever: independent of whether the
26
- * notification itself has since been read, which a bare `dedupeKey` on the
27
- * notification service is not (its coalescing only holds while a
28
- * notification is unread see packages/notifications).
24
+ * `claimNotified` is what makes an update notify administrators exactly once
25
+ * per (plugin, version), ever, even when the daily task and an admin's
26
+ * "Refresh" click race on the same newly-seen version independent of
27
+ * whether the notification itself has since been read, which a bare
28
+ * `dedupeKey` on the notification service is not (its coalescing only holds
29
+ * while a notification is unread — see packages/notifications). See
30
+ * docs/marketplace.md for why the claim is a single atomic step rather than
31
+ * a check followed by a write.
29
32
  */
30
33
  export interface MarketplaceCacheRepository {
31
34
  read(): Promise<CachedMarketplace>
@@ -35,6 +38,10 @@ export interface MarketplaceCacheRepository {
35
38
  readonly fetchedAt: Date
36
39
  }): Promise<void>
37
40
  saveError(input: { readonly message: string; readonly at: Date }): Promise<void>
38
- hasNotified(key: string, version: string): Promise<boolean>
39
- markNotified(key: string, version: string): Promise<void>
41
+ /**
42
+ * Atomically records (key, version) as notified and reports whether this
43
+ * call is the one that newly claimed it — `false` means some other caller
44
+ * (a concurrent refresh) already has, and this one must not notify.
45
+ */
46
+ claimNotified(key: string, version: string): Promise<boolean>
40
47
  }
package/src/fetch.ts CHANGED
@@ -22,11 +22,53 @@ export interface FetchFeedOptions {
22
22
  readonly fetchImpl?: typeof fetch
23
23
  }
24
24
 
25
+ /** See `docs/marketplace.md#outbound-fetches-do-not-follow-redirects`. */
26
+ export async function readCappedBody(
27
+ response: Response,
28
+ maxBytes: number,
29
+ ): Promise<Uint8Array | null> {
30
+ const declaredLength = response.headers.get('content-length')
31
+ if (declaredLength !== null) {
32
+ const declared = Number(declaredLength)
33
+ if (Number.isFinite(declared) && declared > maxBytes) return null
34
+ }
35
+
36
+ if (response.body === null) {
37
+ const buffer = await response.arrayBuffer()
38
+ return buffer.byteLength > maxBytes ? null : new Uint8Array(buffer)
39
+ }
40
+
41
+ const reader = response.body.getReader()
42
+ const chunks: Uint8Array[] = []
43
+ let total = 0
44
+
45
+ while (true) {
46
+ const { done, value } = await reader.read()
47
+ if (done) break
48
+ total += value.byteLength
49
+ if (total > maxBytes) {
50
+ await reader.cancel()
51
+ return null
52
+ }
53
+ chunks.push(value)
54
+ }
55
+
56
+ const combined = new Uint8Array(total)
57
+ let offset = 0
58
+ for (const chunk of chunks) {
59
+ combined.set(chunk, offset)
60
+ offset += chunk.byteLength
61
+ }
62
+ return combined
63
+ }
64
+
25
65
  /**
26
66
  * Fetches and JSON-parses a marketplace feed. Never throws: an unreachable
27
- * host, a non-200 response, an oversized body or invalid JSON are all
28
- * reported as `{ ok: false, error }` — the board with no outbound network is
29
- * meant to fail quietly here, not crash the task that called this.
67
+ * host, a non-200 response (a redirect included see
68
+ * `docs/marketplace.md#outbound-fetches-do-not-follow-redirects`), an
69
+ * oversized body or invalid JSON are all reported as `{ ok: false, error }`
70
+ * — the board with no outbound network is meant to fail quietly here, not
71
+ * crash the task that called this.
30
72
  */
31
73
  export async function fetchMarketplaceFeed(options: FetchFeedOptions): Promise<FetchFeedResult> {
32
74
  const fetchImpl = options.fetchImpl ?? fetch
@@ -39,6 +81,7 @@ export async function fetchMarketplaceFeed(options: FetchFeedOptions): Promise<F
39
81
  response = await fetchImpl(options.url, {
40
82
  signal: controller.signal,
41
83
  headers: { accept: 'application/json' },
84
+ redirect: 'manual',
42
85
  })
43
86
  } catch (error) {
44
87
  return { ok: false, body: null, error: `could not reach ${options.url}: ${String(error)}` }
@@ -48,8 +91,8 @@ export async function fetchMarketplaceFeed(options: FetchFeedOptions): Promise<F
48
91
  return { ok: false, body: null, error: `${options.url} answered ${response.status}` }
49
92
  }
50
93
 
51
- const text = await response.text()
52
- if (text.length > MAX_BODY_BYTES) {
94
+ const bytes = await readCappedBody(response, MAX_BODY_BYTES)
95
+ if (bytes === null) {
53
96
  return {
54
97
  ok: false,
55
98
  body: null,
@@ -58,7 +101,7 @@ export async function fetchMarketplaceFeed(options: FetchFeedOptions): Promise<F
58
101
  }
59
102
 
60
103
  try {
61
- return { ok: true, body: JSON.parse(text), error: null }
104
+ return { ok: true, body: JSON.parse(new TextDecoder().decode(bytes)), error: null }
62
105
  } catch {
63
106
  return { ok: false, body: null, error: `${options.url} did not answer valid JSON` }
64
107
  }
package/src/index.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  export { MEITH_VERSION, PLUGIN_API_MAJOR, THEME_API_MAJOR } from './build-info'
2
2
  export { type CachedMarketplace, EMPTY_CACHE, type MarketplaceCacheRepository } from './cache'
3
- export { type FetchFeedOptions, type FetchFeedResult, fetchMarketplaceFeed } from './fetch'
3
+ export {
4
+ type FetchFeedOptions,
5
+ type FetchFeedResult,
6
+ fetchMarketplaceFeed,
7
+ readCappedBody,
8
+ } from './fetch'
4
9
  export {
5
10
  compareSemver,
6
11
  parseMeithRange,
package/src/refresh.ts CHANGED
@@ -10,7 +10,11 @@ export interface RefreshCatalogInput {
10
10
  readonly build: BuildInfo
11
11
  /** How this build resolves a listing's key against what it compiled in. */
12
12
  readonly resolveInstalled: (listing: MarketplaceListing) => InstalledEntry | null
13
- /** Called once per newly-detected (plugin, version) update, before it is recorded as notified. */
13
+ /**
14
+ * Called once per newly-detected (plugin, version) update, after it has
15
+ * already been claimed as notified — see docs/marketplace.md for why the
16
+ * claim happens first, and what that means if this throws.
17
+ */
14
18
  readonly notifyUpdate: (listing: MarketplaceListing) => Promise<void>
15
19
  readonly now?: () => Date
16
20
  readonly fetchImpl?: typeof fetch
@@ -29,7 +33,9 @@ export interface RefreshCatalogResult {
29
33
  * notification for any plugin whose new version this board has not already
30
34
  * notified about. This is the one function both the daily task and the
31
35
  * admin "Refresh" button call — see docs/marketplace.md — so there is
32
- * exactly one place that decides what counts as a successful refresh.
36
+ * exactly one place that decides what counts as a successful refresh, and
37
+ * the two can run concurrently: `claimNotified` is what keeps a race between
38
+ * them from raising the same (plugin, version) update twice.
33
39
  */
34
40
  export async function refreshCatalog(input: RefreshCatalogInput): Promise<RefreshCatalogResult> {
35
41
  const now = input.now ?? (() => new Date())
@@ -62,10 +68,9 @@ export async function refreshCatalog(input: RefreshCatalogInput): Promise<Refres
62
68
  const installed = input.resolveInstalled(listing)
63
69
  const result = computeListingStatus({ ...listing, installed }, input.build)
64
70
  if (result.status !== 'update-available') continue
65
- if (await input.repository.hasNotified(listing.key, listing.version)) continue
71
+ if (!(await input.repository.claimNotified(listing.key, listing.version))) continue
66
72
 
67
73
  await input.notifyUpdate(listing)
68
- await input.repository.markNotified(listing.key, listing.version)
69
74
  notified += 1
70
75
  }
71
76
 
package/src/schema.ts CHANGED
@@ -52,11 +52,15 @@ export const REQUIRED_LISTING_FIELDS = [
52
52
  'licence',
53
53
  ] as const
54
54
 
55
- const KEY_PATTERN = /^[a-z][a-z0-9-]{1,39}$/
55
+ /**
56
+ * Mirrors KEY_PATTERN in scripts/marketplace-gen.mjs and
57
+ * packages/plugin-kit/src/plugin.ts. Exported so
58
+ * scripts/marketplace-gen.test.ts can pin all three against each other.
59
+ */
60
+ export const KEY_PATTERN = /^[a-z][a-z0-9-]{1,39}$/
56
61
  const VERSION_PATTERN = /^\d+\.\d+\.\d+$/
57
62
  const PACKAGE_PATTERN = /^(@[a-z0-9-][a-z0-9-._]*\/)?[a-z0-9-][a-z0-9-._]*$/
58
- // The served feed's screenshots are absolute site paths, not bare filenames —
59
- // see the module comment above.
63
+ /** The served feed's screenshots are absolute site paths, not bare filenames — see this file's own header. */
60
64
  const SCREENSHOT_PATH_PATTERN = /^\/marketplace\/screenshots\/[a-z0-9][a-z0-9-]*\.png$/
61
65
  const KINDS = new Set<string>(['plugin', 'theme'])
62
66