jamdesk 1.1.157 → 1.1.158
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 +1 -1
- package/vendored/components/mdx/Card.tsx +4 -0
- package/vendored/components/theme/ThemeToggle.tsx +24 -14
- package/vendored/lib/auth-resolver.ts +36 -0
- package/vendored/lib/build/r2-upload.ts +2 -0
- package/vendored/lib/project-deploy-id.ts +197 -0
- package/vendored/lib/project-deploy-publisher.ts +95 -0
- package/vendored/lib/r2-cleanup.ts +159 -30
- package/vendored/lib/r2-feature-flags.ts +46 -0
- package/vendored/lib/r2-key-builder.ts +28 -0
- package/vendored/lib/r2-manifest.ts +2 -0
- package/vendored/lib/r2-public-fetch.ts +133 -0
- package/vendored/lib/r2-public-storage-guard.ts +113 -0
- package/vendored/lib/r2-upload-lock.ts +277 -0
- package/vendored/lib/revalidation-helpers.ts +7 -0
- package/vendored/lib/revalidation-trigger.ts +112 -0
- package/vendored/shared/status-reporter.ts +1 -0
- package/vendored/themes/base.css +22 -0
- package/vendored/themes/jam/variables.css +5 -5
- package/vendored/themes/pulsar/variables.css +5 -5
- package/vendored/workspace-package-lock.json +48 -48
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jamdesk",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.158",
|
|
4
4
|
"description": "CLI for Jamdesk — build, preview, and deploy documentation sites from MDX. Dev server with hot reload, 50+ components, OpenAPI support, AI search, and Mintlify migration",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jamdesk",
|
|
@@ -134,6 +134,10 @@ export const Card = memo(function Card({ title, icon, img, href, children, horiz
|
|
|
134
134
|
.group:hover .cta-text {
|
|
135
135
|
color: var(--color-primary) !important;
|
|
136
136
|
}
|
|
137
|
+
/* Card body typography. The height-affecting subset below (line-height
|
|
138
|
+
+ margins on > p and first/last-child) is mirrored in themes/base.css
|
|
139
|
+
so it also applies at first paint — Next.js dev injects styled-jsx
|
|
140
|
+
only after first paint. Keep both copies in sync. */
|
|
137
141
|
.card-content > p {
|
|
138
142
|
margin: 0 0 0.75rem 0;
|
|
139
143
|
font-weight: 300;
|
|
@@ -15,30 +15,38 @@ const NEXT_THEME: Record<string, (typeof THEMES)[number]['name']> = {
|
|
|
15
15
|
system: 'light',
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
size?: 'default' | 'large';
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function ThemeToggle({ size = 'default' }: ThemeToggleProps) {
|
|
18
|
+
export function ThemeToggle() {
|
|
23
19
|
const [mounted, setMounted] = useState(false);
|
|
24
20
|
const { theme, setTheme } = useTheme();
|
|
25
21
|
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
22
|
+
const containerClass =
|
|
23
|
+
'flex items-center gap-1 bg-[var(--color-bg-secondary)]/50 rounded-md px-0.5 py-0.5 border border-[var(--color-border)]/50';
|
|
24
|
+
const buttonClass = 'px-1.5 py-0.5 rounded cursor-pointer';
|
|
25
|
+
const iconClass = 'text-[12px]';
|
|
26
|
+
// Fixed-size icon slot shared by the pre-mount placeholder and the mounted
|
|
27
|
+
// buttons. Without it the control changes width when the FontAwesome icon font
|
|
28
|
+
// swaps in (font-display: swap), reflowing the header nav horizontally (CLS).
|
|
29
|
+
// overflow-hidden caps a wide fallback glyph.
|
|
30
|
+
const iconSlot = 'inline-flex items-center justify-center w-4 h-4 overflow-hidden';
|
|
32
31
|
|
|
33
32
|
useEffect(() => {
|
|
34
33
|
setMounted(true);
|
|
35
34
|
}, []);
|
|
36
35
|
|
|
36
|
+
// The placeholder MUST mirror the mounted markup exactly — same [data-theme-toggle]
|
|
37
|
+
// attribute AND <button> tag — because themes style the toggle through both
|
|
38
|
+
// selectors: Pulsar HIDES the header copy via `header [data-theme-toggle]` and
|
|
39
|
+
// shrinks the sidebar copy's cells via `[data-theme-toggle] button { width:16px }`.
|
|
40
|
+
// If the placeholder used a <div>, that button rule wouldn't match it, so the
|
|
41
|
+
// cells would render wide then collapse to 16px when the real buttons mount —
|
|
42
|
+
// shifting the toggle (and its neighbors) sideways on load.
|
|
37
43
|
if (!mounted) {
|
|
38
44
|
return (
|
|
39
|
-
<div className={containerClass}>
|
|
45
|
+
<div className={containerClass} data-theme-toggle aria-hidden="true">
|
|
40
46
|
{THEMES.map((t) => (
|
|
41
|
-
<
|
|
47
|
+
<button key={t.name} type="button" disabled className={buttonClass}>
|
|
48
|
+
<span className={iconSlot} />
|
|
49
|
+
</button>
|
|
42
50
|
))}
|
|
43
51
|
</div>
|
|
44
52
|
);
|
|
@@ -66,7 +74,9 @@ export function ThemeToggle({ size = 'default' }: ThemeToggleProps) {
|
|
|
66
74
|
aria-pressed={active}
|
|
67
75
|
title={title}
|
|
68
76
|
>
|
|
69
|
-
<
|
|
77
|
+
<span className={iconSlot}>
|
|
78
|
+
<i className={`fa-solid ${icon} ${iconClass}`} aria-hidden="true" />
|
|
79
|
+
</span>
|
|
70
80
|
</button>
|
|
71
81
|
);
|
|
72
82
|
})}
|
|
@@ -104,3 +104,39 @@ export async function resolveAuth(
|
|
|
104
104
|
// for backwards compatibility. The split keeps the matcher (a pure
|
|
105
105
|
// function with no Redis dependency) importable from build-time modules
|
|
106
106
|
// like public-paths-resolver without dragging in the Redis client.
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Quick boolean check: is this project password-gated right now?
|
|
110
|
+
* Used by R2 read paths to CHOOSE WHICH FETCH MECHANISM to use:
|
|
111
|
+
* - true → S3 SDK (HMAC-authed, slower)
|
|
112
|
+
* - false → CF public domain (fast, cached)
|
|
113
|
+
*
|
|
114
|
+
* NOT an access-control function. Real password gating is upstream in
|
|
115
|
+
* proxy.ts via resolveAuth + the unlock cookie. By the time r2-content.ts
|
|
116
|
+
* calls this, access has already been authorized; we're only picking the
|
|
117
|
+
* fetch mechanism. Therefore: fail OPEN (return false) on Redis error so
|
|
118
|
+
* brownouts don't nullify the CDN-cache latency win.
|
|
119
|
+
*
|
|
120
|
+
* Reads go through cached-redis (getProjectAuthSecret/getProjectAuthPublic) —
|
|
121
|
+
* the same wrappers resolveAuth uses — so the two share per-instance cache
|
|
122
|
+
* entries for these keys instead of each holding its own copy. cached-redis
|
|
123
|
+
* does NOT cache fetch errors, so the fail-open path below still retries on
|
|
124
|
+
* the next call rather than pinning `false`.
|
|
125
|
+
*/
|
|
126
|
+
export async function isProjectGated(slug: string): Promise<boolean> {
|
|
127
|
+
if (!redis) return false;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const [secret, pub] = await Promise.all([
|
|
131
|
+
getProjectAuthSecret(slug),
|
|
132
|
+
getProjectAuthPublic(slug),
|
|
133
|
+
]);
|
|
134
|
+
return !!secret && typeof secret.hash === 'string' && pub?.enabled === true;
|
|
135
|
+
} catch {
|
|
136
|
+
// Fail OPEN: this is a read-mechanism choice, not access control.
|
|
137
|
+
// Gating already happened upstream in proxy.ts. Failing closed here
|
|
138
|
+
// would route every read through S3 SDK during Redis brownouts,
|
|
139
|
+
// nullifying the CDN-cache latency win precisely when it matters most.
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-project deployId pointer with in-process LRU cache.
|
|
3
|
+
*
|
|
4
|
+
* Reads/writes `projectDeployId:<slug>` in Redis. The deployId is the prefix
|
|
5
|
+
* we use under each project's R2 bucket path (`<slug>/<deployId>/<rest>`) so
|
|
6
|
+
* we can swap to a new build atomically without purging CF cache.
|
|
7
|
+
*
|
|
8
|
+
* Cache rules:
|
|
9
|
+
* - Positive entries (string deployId) cached for 30s.
|
|
10
|
+
* - Negative entries (no deployId in Redis) cached for 5s — short enough
|
|
11
|
+
* that a brand-new project's first build appears promptly, long enough
|
|
12
|
+
* to prevent thundering-herd Redis reads on cold slugs.
|
|
13
|
+
* - Transient Redis errors are NOT cached. Returns null for the current
|
|
14
|
+
* call but lets the next call retry.
|
|
15
|
+
* - LRU evicts oldest at 500 entries; a successful get reorders to MRU.
|
|
16
|
+
*/
|
|
17
|
+
import type { Redis } from '@upstash/redis';
|
|
18
|
+
import { redis } from './redis';
|
|
19
|
+
import { logger } from '../shared/logger';
|
|
20
|
+
|
|
21
|
+
const REDIS_KEY = (slug: string): string => `projectDeployId:${slug}`;
|
|
22
|
+
const PROJECT_DEPLOY_HISTORY_PREFIX = 'projectDeployHistory:';
|
|
23
|
+
const DEPLOY_HISTORY_RETENTION = 10; // GC protects the most recent N deployIds
|
|
24
|
+
const LRU_MAX = 500;
|
|
25
|
+
const LRU_TTL_MS = 30_000;
|
|
26
|
+
const LRU_NULL_TTL_MS = 5_000;
|
|
27
|
+
|
|
28
|
+
interface CacheEntry {
|
|
29
|
+
value: string | null;
|
|
30
|
+
expires: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const lru = new Map<string, CacheEntry>();
|
|
34
|
+
|
|
35
|
+
function lruGet(slug: string): CacheEntry | undefined {
|
|
36
|
+
const entry = lru.get(slug);
|
|
37
|
+
if (!entry) return undefined;
|
|
38
|
+
if (entry.expires < Date.now()) {
|
|
39
|
+
lru.delete(slug);
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
// Recency refresh: reorder to most-recently-used.
|
|
43
|
+
lru.delete(slug);
|
|
44
|
+
lru.set(slug, entry);
|
|
45
|
+
return entry;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function lruSet(slug: string, value: string | null): void {
|
|
49
|
+
if (lru.size >= LRU_MAX) {
|
|
50
|
+
const oldest = lru.keys().next().value;
|
|
51
|
+
if (oldest !== undefined) lru.delete(oldest);
|
|
52
|
+
}
|
|
53
|
+
const ttl = value === null ? LRU_NULL_TTL_MS : LRU_TTL_MS;
|
|
54
|
+
lru.set(slug, { value, expires: Date.now() + ttl });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Clear the in-process LRU. With a slug, evicts just that project's entry —
|
|
59
|
+
* whole-project revalidation calls this so the next read re-resolves the
|
|
60
|
+
* pointer from Redis instead of serving a ≤30s-stale deployId into freshly
|
|
61
|
+
* purged Data Cache entries. Without a slug, clears everything (tests,
|
|
62
|
+
* bare `{all: true}` revalidation).
|
|
63
|
+
*/
|
|
64
|
+
export function clearProjectDeployIdCache(slug?: string): void {
|
|
65
|
+
if (slug === undefined) {
|
|
66
|
+
lru.clear();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
lru.delete(slug);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read the current deployId for a project. Returns null when the slug has
|
|
74
|
+
* no pointer yet (brand-new project, or pre-rollout) or when Redis read
|
|
75
|
+
* throws (we fail open to the legacy non-prefixed path; see r2-content.ts
|
|
76
|
+
* branch for the migration story).
|
|
77
|
+
*/
|
|
78
|
+
export async function getProjectDeployId(slug: string): Promise<string | null> {
|
|
79
|
+
const cached = lruGet(slug);
|
|
80
|
+
if (cached !== undefined) return cached.value;
|
|
81
|
+
|
|
82
|
+
if (!redis) {
|
|
83
|
+
lruSet(slug, null);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const value = await redis.get(REDIS_KEY(slug));
|
|
89
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
90
|
+
lruSet(slug, value);
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
lruSet(slug, null);
|
|
94
|
+
return null;
|
|
95
|
+
} catch (err) {
|
|
96
|
+
// Do NOT cache failures — next call must retry Redis. Caching a
|
|
97
|
+
// transient error would extend a 5s blip into a 30s outage for
|
|
98
|
+
// every slug that happened to miss during the window.
|
|
99
|
+
logger.warn('[project-deploy-id] redis read failed', {
|
|
100
|
+
slug,
|
|
101
|
+
error: (err as Error).message,
|
|
102
|
+
});
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Write the current deployId pointer. Called by upload-to-r2 after the new
|
|
109
|
+
* deployId's content is fully uploaded — atomic swap to the new prefix.
|
|
110
|
+
*
|
|
111
|
+
* Throws on empty/non-string deployId (caller bug — better to fail loudly
|
|
112
|
+
* than silently corrupt the pointer) and when Redis isn't configured
|
|
113
|
+
* (shouldn't happen in prod; throwing surfaces the misconfig fast).
|
|
114
|
+
*/
|
|
115
|
+
export async function setProjectDeployId(
|
|
116
|
+
slug: string,
|
|
117
|
+
deployId: string,
|
|
118
|
+
// Injectable so recordProjectDeploy's history writes and pointer write
|
|
119
|
+
// go through the SAME client its caller provided; defaults to the
|
|
120
|
+
// module singleton for direct callers.
|
|
121
|
+
client: Redis | null = redis,
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
if (typeof deployId !== 'string' || deployId.length === 0) {
|
|
124
|
+
throw new Error('deployId must be a non-empty string');
|
|
125
|
+
}
|
|
126
|
+
if (!client) {
|
|
127
|
+
throw new Error('Redis not configured');
|
|
128
|
+
}
|
|
129
|
+
await client.set(REDIS_KEY(slug), deployId);
|
|
130
|
+
lruSet(slug, deployId);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Publish a new deployId: records into history FIRST, then writes the
|
|
135
|
+
* current pointer.
|
|
136
|
+
*
|
|
137
|
+
* Ordering invariant — load-bearing for `r2-deploy-gc`:
|
|
138
|
+
* `history MUST contain the deployId before the pointer references it`
|
|
139
|
+
*
|
|
140
|
+
* Otherwise GC could observe `pointer = X` while `X ∉ history`, classify
|
|
141
|
+
* X's R2 prefix as orphaned, and delete the live deploy. Concretely:
|
|
142
|
+
* - LPUSH first. If it throws, we re-throw without ever setting the
|
|
143
|
+
* pointer — readers keep serving the previous deploy.
|
|
144
|
+
* - SET pointer + LTRIM history then fan out in parallel. They're
|
|
145
|
+
* independent (different keys, no causal dependency on each other).
|
|
146
|
+
* - SET failure after LPUSH succeeded: the new deploy's prefix is
|
|
147
|
+
* protected (it's in history) but the pointer didn't flip. Caller
|
|
148
|
+
* sees the throw and surfaces a build warning.
|
|
149
|
+
* - LTRIM failure is best-effort — history grows by 1 entry, GC still
|
|
150
|
+
* walks the LRANGE correctly. Logged at WARN, NOT re-thrown.
|
|
151
|
+
*
|
|
152
|
+
* Net cost vs the prior parallel-everything design: one extra Redis
|
|
153
|
+
* round-trip on the happy path. Worth it — the alternative is silent
|
|
154
|
+
* data loss when GC lands in Phase 6.
|
|
155
|
+
*/
|
|
156
|
+
export async function recordProjectDeploy(
|
|
157
|
+
slug: string,
|
|
158
|
+
deployId: string,
|
|
159
|
+
redisClient: Redis,
|
|
160
|
+
): Promise<void> {
|
|
161
|
+
const historyKey = `${PROJECT_DEPLOY_HISTORY_PREFIX}${slug}`;
|
|
162
|
+
|
|
163
|
+
await redisClient.lpush(historyKey, deployId);
|
|
164
|
+
|
|
165
|
+
await Promise.all([
|
|
166
|
+
setProjectDeployId(slug, deployId, redisClient),
|
|
167
|
+
redisClient
|
|
168
|
+
.ltrim(historyKey, 0, DEPLOY_HISTORY_RETENTION - 1)
|
|
169
|
+
.catch((err: unknown) => {
|
|
170
|
+
logger.warn('[project-deploy-id] ltrim failed (non-fatal)', {
|
|
171
|
+
slug,
|
|
172
|
+
error: err instanceof Error ? err.message : String(err),
|
|
173
|
+
});
|
|
174
|
+
}),
|
|
175
|
+
]);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Delete a project's deployId pointer AND history. Called when a project
|
|
180
|
+
* becomes password-gated (legacy-only writes): after its versioned prefixes are
|
|
181
|
+
* purged there is no versioned deploy to reference, and leaving a stale pointer
|
|
182
|
+
* would make r2-deploy-gc eternally "protect" a deployId whose prefix no longer
|
|
183
|
+
* exists. Idempotent — a no-op for a project that never published a pointer.
|
|
184
|
+
*/
|
|
185
|
+
export async function clearProjectDeploy(
|
|
186
|
+
slug: string,
|
|
187
|
+
client: Redis | null = redis,
|
|
188
|
+
): Promise<void> {
|
|
189
|
+
if (!client) {
|
|
190
|
+
throw new Error('Redis not configured');
|
|
191
|
+
}
|
|
192
|
+
await Promise.all([
|
|
193
|
+
client.del(REDIS_KEY(slug)),
|
|
194
|
+
client.del(`${PROJECT_DEPLOY_HISTORY_PREFIX}${slug}`),
|
|
195
|
+
]);
|
|
196
|
+
clearProjectDeployIdCache(slug);
|
|
197
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Redis } from '@upstash/redis';
|
|
2
|
+
import { logger } from '../shared/logger.js';
|
|
3
|
+
import { recordProjectDeploy } from './project-deploy-id.js';
|
|
4
|
+
import { isPublicFetchEnabled } from './r2-feature-flags.js';
|
|
5
|
+
import { DEPLOY_FLIP_TAG } from './revalidation-trigger.js';
|
|
6
|
+
|
|
7
|
+
const PUBLISH_ATTEMPTS = 3;
|
|
8
|
+
const PUBLISH_RETRY_BASE_MS = 250;
|
|
9
|
+
|
|
10
|
+
const sleep = (ms: number): Promise<void> =>
|
|
11
|
+
new Promise((resolve) => setTimeout(resolve, ms));
|
|
12
|
+
|
|
13
|
+
interface PublishArgs {
|
|
14
|
+
slug: string;
|
|
15
|
+
deployId: string;
|
|
16
|
+
redisClient: Redis | null;
|
|
17
|
+
recordProjectDeploy?: typeof recordProjectDeploy;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Publish the deployId pointer with retries. Fatality is flag-aware:
|
|
22
|
+
*
|
|
23
|
+
* - USE_R2_PUBLIC_FETCH off (current prod): no reader resolves the
|
|
24
|
+
* pointer — every read goes to legacy keys — so a Redis outage here
|
|
25
|
+
* must NOT fail customer builds for a pointer nothing consumes yet.
|
|
26
|
+
* Log at error (greppable for the rollout preflight) and continue.
|
|
27
|
+
* - USE_R2_PUBLIC_FETCH on: a pointer that doesn't flip means readers
|
|
28
|
+
* keep serving the previous deploy even though new content uploaded,
|
|
29
|
+
* with no fallback exposing the fresh writes. That's fatal — the
|
|
30
|
+
* build must not report success.
|
|
31
|
+
*
|
|
32
|
+
* Transient Upstash blips were the failure mode that motivated retries:
|
|
33
|
+
* a single 503 during the one-shot publish would have failed an
|
|
34
|
+
* otherwise-complete build.
|
|
35
|
+
*/
|
|
36
|
+
export async function publishProjectDeployIdOrThrow({
|
|
37
|
+
slug,
|
|
38
|
+
deployId,
|
|
39
|
+
redisClient,
|
|
40
|
+
recordProjectDeploy: record = recordProjectDeploy,
|
|
41
|
+
}: PublishArgs): Promise<void> {
|
|
42
|
+
if (!redisClient) {
|
|
43
|
+
const message =
|
|
44
|
+
'[r2-upload] KV not configured; cannot publish projectDeployId pointer';
|
|
45
|
+
if (isPublicFetchEnabled()) {
|
|
46
|
+
throw new Error(message);
|
|
47
|
+
}
|
|
48
|
+
logger.error(
|
|
49
|
+
`${message} (non-fatal: USE_R2_PUBLIC_FETCH off, readers use legacy keys)`,
|
|
50
|
+
{ slug, deployId },
|
|
51
|
+
);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let lastErr: unknown;
|
|
56
|
+
for (let attempt = 1; attempt <= PUBLISH_ATTEMPTS; attempt++) {
|
|
57
|
+
try {
|
|
58
|
+
await record(slug, deployId, redisClient);
|
|
59
|
+
logger.info(
|
|
60
|
+
`${DEPLOY_FLIP_TAG} projectDeployId set, revalidation should follow`,
|
|
61
|
+
{ projectName: slug, deployId, ...(attempt > 1 ? { attempt } : {}) },
|
|
62
|
+
);
|
|
63
|
+
return;
|
|
64
|
+
} catch (err) {
|
|
65
|
+
lastErr = err;
|
|
66
|
+
if (attempt < PUBLISH_ATTEMPTS) {
|
|
67
|
+
const delayMs = PUBLISH_RETRY_BASE_MS * attempt;
|
|
68
|
+
logger.warn('[r2-upload] projectDeployId publish failed, retrying', {
|
|
69
|
+
slug,
|
|
70
|
+
deployId,
|
|
71
|
+
attempt,
|
|
72
|
+
delayMs,
|
|
73
|
+
error: err instanceof Error ? err.message : String(err),
|
|
74
|
+
});
|
|
75
|
+
await sleep(delayMs);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (isPublicFetchEnabled()) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Failed to publish projectDeployId pointer for ${slug}/${deployId}`,
|
|
83
|
+
{ cause: lastErr },
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
logger.error(
|
|
87
|
+
'[r2-upload] projectDeployId publish failed after retries (non-fatal: USE_R2_PUBLIC_FETCH off, readers use legacy keys)',
|
|
88
|
+
{
|
|
89
|
+
slug,
|
|
90
|
+
deployId,
|
|
91
|
+
attempts: PUBLISH_ATTEMPTS,
|
|
92
|
+
error: lastErr instanceof Error ? lastErr.message : String(lastErr),
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
}
|