@meith/marketplace 0.17.0 → 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 +2 -2
- package/src/build-info.ts +1 -1
- package/src/cache.ts +14 -7
- package/src/fetch.ts +49 -6
- package/src/index.ts +6 -1
- package/src/refresh.ts +9 -4
- package/src/schema.ts +7 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/marketplace",
|
|
3
|
-
"version": "0.17.
|
|
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.
|
|
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.
|
|
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
|
-
* `
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* notification is
|
|
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
|
-
|
|
39
|
-
|
|
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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
52
|
-
if (
|
|
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(
|
|
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 {
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|