Monorepo for Wisp.place. A static site hosting service built on top of the AT Protocol.

domain verfication worker, upload blobs as octet-stream

+36 -4
hosting-service/src/index.ts
···
import { serve } from 'bun';
import app from './server';
import { FirehoseWorker } from './lib/firehose';
+
import { DNSVerificationWorker } from './lib/dns-verification-worker';
import { mkdirSync, existsSync } from 'fs';
const PORT = process.env.PORT || 3001;
···
firehose.start();
+
// Start DNS verification worker (runs every hour)
+
const dnsVerifier = new DNSVerificationWorker(
+
60 * 60 * 1000, // 1 hour
+
(msg, data) => {
+
console.log('[DNS Verifier]', msg, data || '');
+
}
+
);
+
+
dnsVerifier.start();
+
// Add health check endpoint
app.get('/health', (c) => {
const firehoseHealth = firehose.getHealth();
+
const dnsVerifierHealth = dnsVerifier.getHealth();
return c.json({
status: 'ok',
firehose: firehoseHealth,
+
dnsVerifier: dnsVerifierHealth,
});
});
+
// Add manual DNS verification trigger (for testing/admin)
+
app.post('/admin/verify-dns', async (c) => {
+
try {
+
await dnsVerifier.trigger();
+
return c.json({
+
success: true,
+
message: 'DNS verification triggered',
+
});
+
} catch (error) {
+
return c.json({
+
success: false,
+
error: error instanceof Error ? error.message : String(error),
+
}, 500);
+
}
+
});
+
// Start HTTP server
const server = serve({
port: PORT,
···
console.log(`
Wisp Hosting Service
-
Server: http://localhost:${PORT}
-
Health: http://localhost:${PORT}/health
-
Cache: ${CACHE_DIR}
-
Firehose: Connected to Jetstream
+
Server: http://localhost:${PORT}
+
Health: http://localhost:${PORT}/health
+
Cache: ${CACHE_DIR}
+
Firehose: Connected to Jetstream
+
DNS Verifier: Checking every hour
`);
// Graceful shutdown
process.on('SIGINT', () => {
console.log('\n🛑 Shutting down...');
firehose.stop();
+
dnsVerifier.stop();
server.stop();
process.exit(0);
});
···
process.on('SIGTERM', () => {
console.log('\n🛑 Shutting down...');
firehose.stop();
+
dnsVerifier.stop();
server.stop();
process.exit(0);
});
+170
hosting-service/src/lib/dns-verification-worker.ts
···
+
import { verifyCustomDomain } from '../../../src/lib/dns-verify';
+
import { db } from '../../../src/lib/db';
+
+
interface VerificationStats {
+
totalChecked: number;
+
verified: number;
+
failed: number;
+
errors: number;
+
}
+
+
export class DNSVerificationWorker {
+
private interval: Timer | null = null;
+
private isRunning = false;
+
private lastRunTime: number | null = null;
+
private stats: VerificationStats = {
+
totalChecked: 0,
+
verified: 0,
+
failed: 0,
+
errors: 0,
+
};
+
+
constructor(
+
private checkIntervalMs: number = 60 * 60 * 1000, // 1 hour default
+
private onLog?: (message: string, data?: any) => void
+
) {}
+
+
private log(message: string, data?: any) {
+
if (this.onLog) {
+
this.onLog(message, data);
+
}
+
}
+
+
async start() {
+
if (this.isRunning) {
+
this.log('DNS verification worker already running');
+
return;
+
}
+
+
this.isRunning = true;
+
this.log('Starting DNS verification worker', {
+
intervalMinutes: this.checkIntervalMs / 60000,
+
});
+
+
// Run immediately on start
+
await this.verifyAllDomains();
+
+
// Then run on interval
+
this.interval = setInterval(() => {
+
this.verifyAllDomains();
+
}, this.checkIntervalMs);
+
}
+
+
stop() {
+
if (this.interval) {
+
clearInterval(this.interval);
+
this.interval = null;
+
}
+
this.isRunning = false;
+
this.log('DNS verification worker stopped');
+
}
+
+
private async verifyAllDomains() {
+
this.log('Starting DNS verification check');
+
const startTime = Date.now();
+
+
const runStats: VerificationStats = {
+
totalChecked: 0,
+
verified: 0,
+
failed: 0,
+
errors: 0,
+
};
+
+
try {
+
// Get all verified custom domains
+
const domains = await db`
+
SELECT id, domain, did FROM custom_domains WHERE verified = true
+
`;
+
+
if (!domains || domains.length === 0) {
+
this.log('No verified custom domains to check');
+
this.lastRunTime = Date.now();
+
return;
+
}
+
+
this.log(`Checking ${domains.length} verified custom domains`);
+
+
// Verify each domain
+
for (const row of domains) {
+
runStats.totalChecked++;
+
const { id, domain, did } = row;
+
+
try {
+
// Extract hash from id (SHA256 of did:domain)
+
const expectedHash = id.substring(0, 16);
+
+
// Verify DNS records
+
const result = await verifyCustomDomain(domain, did, expectedHash);
+
+
if (result.verified) {
+
// Update last_verified_at timestamp
+
await db`
+
UPDATE custom_domains
+
SET last_verified_at = EXTRACT(EPOCH FROM NOW())
+
WHERE id = ${id}
+
`;
+
runStats.verified++;
+
this.log(`Domain verified: ${domain}`, { did });
+
} else {
+
// Mark domain as unverified
+
await db`
+
UPDATE custom_domains
+
SET verified = false,
+
last_verified_at = EXTRACT(EPOCH FROM NOW())
+
WHERE id = ${id}
+
`;
+
runStats.failed++;
+
this.log(`Domain verification failed: ${domain}`, {
+
did,
+
error: result.error,
+
found: result.found,
+
});
+
}
+
} catch (error) {
+
runStats.errors++;
+
this.log(`Error verifying domain: ${domain}`, {
+
did,
+
error: error instanceof Error ? error.message : String(error),
+
});
+
}
+
}
+
+
// Update cumulative stats
+
this.stats.totalChecked += runStats.totalChecked;
+
this.stats.verified += runStats.verified;
+
this.stats.failed += runStats.failed;
+
this.stats.errors += runStats.errors;
+
+
const duration = Date.now() - startTime;
+
this.lastRunTime = Date.now();
+
+
this.log('DNS verification check completed', {
+
duration: `${duration}ms`,
+
...runStats,
+
});
+
} catch (error) {
+
this.log('Fatal error in DNS verification worker', {
+
error: error instanceof Error ? error.message : String(error),
+
});
+
}
+
}
+
+
getHealth() {
+
return {
+
isRunning: this.isRunning,
+
lastRunTime: this.lastRunTime,
+
intervalMs: this.checkIntervalMs,
+
stats: this.stats,
+
healthy: this.isRunning && (
+
this.lastRunTime === null ||
+
Date.now() - this.lastRunTime < this.checkIntervalMs * 2
+
),
+
};
+
}
+
+
// Manual trigger for testing
+
async trigger() {
+
this.log('Manual DNS verification triggered');
+
await this.verifyAllDomains();
+
}
+
}
+20 -20
hosting-service/src/lib/html-rewriter.test.ts
···
test('rewriteHtmlPaths - rewrites absolute paths in src attributes', () => {
const html = '<img src="/logo.png">';
-
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
-
expect(result).toBe('<img src="/s/did:plc:123/mysite/logo.png">');
+
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
+
expect(result).toBe('<img src="/did:plc:123/mysite/logo.png">');
});
test('rewriteHtmlPaths - rewrites absolute paths in href attributes', () => {
const html = '<link rel="stylesheet" href="/style.css">';
-
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
-
expect(result).toBe('<link rel="stylesheet" href="/s/did:plc:123/mysite/style.css">');
+
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
+
expect(result).toBe('<link rel="stylesheet" href="/did:plc:123/mysite/style.css">');
});
test('rewriteHtmlPaths - preserves external URLs', () => {
const html = '<img src="https://example.com/logo.png">';
-
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
+
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
expect(result).toBe('<img src="https://example.com/logo.png">');
});
test('rewriteHtmlPaths - preserves protocol-relative URLs', () => {
const html = '<script src="//cdn.example.com/script.js"></script>';
-
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
+
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
expect(result).toBe('<script src="//cdn.example.com/script.js"></script>');
});
test('rewriteHtmlPaths - preserves data URIs', () => {
const html = '<img src="data:image/png;base64,abc123">';
-
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
+
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
expect(result).toBe('<img src="data:image/png;base64,abc123">');
});
test('rewriteHtmlPaths - preserves anchors', () => {
const html = '<a href="/#section">Jump</a>';
-
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
+
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
expect(result).toBe('<a href="/#section">Jump</a>');
});
test('rewriteHtmlPaths - preserves relative paths', () => {
const html = '<img src="./logo.png">';
-
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
+
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
expect(result).toBe('<img src="./logo.png">');
});
test('rewriteHtmlPaths - handles single quotes', () => {
const html = "<img src='/logo.png'>";
-
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
-
expect(result).toBe("<img src='/s/did:plc:123/mysite/logo.png'>");
+
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
+
expect(result).toBe("<img src='/did:plc:123/mysite/logo.png'>");
});
test('rewriteHtmlPaths - handles srcset', () => {
const html = '<img srcset="/logo.png 1x, /logo@2x.png 2x">';
-
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
-
expect(result).toBe('<img srcset="/s/did:plc:123/mysite/logo.png 1x, /s/did:plc:123/mysite/logo@2x.png 2x">');
+
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
+
expect(result).toBe('<img srcset="/did:plc:123/mysite/logo.png 1x, /did:plc:123/mysite/logo@2x.png 2x">');
});
test('rewriteHtmlPaths - handles form actions', () => {
const html = '<form action="/submit"></form>';
-
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
-
expect(result).toBe('<form action="/s/did:plc:123/mysite/submit"></form>');
+
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
+
expect(result).toBe('<form action="/did:plc:123/mysite/submit"></form>');
});
test('rewriteHtmlPaths - handles complex HTML', () => {
···
</html>
`.trim();
-
const result = rewriteHtmlPaths(html, '/s/did:plc:123/mysite/');
+
const result = rewriteHtmlPaths(html, '/did:plc:123/mysite/');
-
expect(result).toContain('href="/s/did:plc:123/mysite/style.css"');
-
expect(result).toContain('src="/s/did:plc:123/mysite/app.js"');
-
expect(result).toContain('src="/s/did:plc:123/mysite/images/logo.png"');
-
expect(result).toContain('href="/s/did:plc:123/mysite/about"');
+
expect(result).toContain('href="/did:plc:123/mysite/style.css"');
+
expect(result).toContain('src="/did:plc:123/mysite/app.js"');
+
expect(result).toContain('src="/did:plc:123/mysite/images/logo.png"');
+
expect(result).toContain('href="/did:plc:123/mysite/about"');
expect(result).toContain('href="https://example.com"'); // External preserved
expect(result).toContain('href="#section"'); // Anchor preserved
});
+49 -9
hosting-service/src/lib/utils.ts
···
import { existsSync, mkdirSync } from 'fs';
import { writeFile } from 'fs/promises';
import { safeFetchJson, safeFetchBlob } from './safe-fetch';
+
import { CID } from 'multiformats/cid';
const CACHE_DIR = './cache/sites';
+
+
// Type guards for different blob reference formats
+
interface IpldLink {
+
$link: string;
+
}
+
+
interface TypedBlobRef {
+
ref: CID | IpldLink;
+
}
+
+
interface UntypedBlobRef {
+
cid: string;
+
}
+
+
function isIpldLink(obj: unknown): obj is IpldLink {
+
return typeof obj === 'object' && obj !== null && '$link' in obj && typeof (obj as IpldLink).$link === 'string';
+
}
+
+
function isTypedBlobRef(obj: unknown): obj is TypedBlobRef {
+
return typeof obj === 'object' && obj !== null && 'ref' in obj;
+
}
+
+
function isUntypedBlobRef(obj: unknown): obj is UntypedBlobRef {
+
return typeof obj === 'object' && obj !== null && 'cid' in obj && typeof (obj as UntypedBlobRef).cid === 'string';
+
}
export async function resolveDid(identifier: string): Promise<string | null> {
try {
···
}
}
-
export function extractBlobCid(blobRef: any): string | null {
-
if (typeof blobRef === 'object' && blobRef !== null) {
-
if ('ref' in blobRef && blobRef.ref?.$link) {
-
return blobRef.ref.$link;
-
}
-
if ('cid' in blobRef && typeof blobRef.cid === 'string') {
-
return blobRef.cid;
+
export function extractBlobCid(blobRef: unknown): string | null {
+
// Check if it's a direct IPLD link
+
if (isIpldLink(blobRef)) {
+
return blobRef.$link;
+
}
+
+
// Check if it's a typed blob ref with a ref property
+
if (isTypedBlobRef(blobRef)) {
+
const ref = blobRef.ref;
+
+
// Check if ref is a CID object
+
if (CID.isCID(ref)) {
+
return ref.toString();
}
-
if ('$link' in blobRef && typeof blobRef.$link === 'string') {
-
return blobRef.$link;
+
+
// Check if ref is an IPLD link object
+
if (isIpldLink(ref)) {
+
return ref.$link;
}
}
+
+
// Check if it's an untyped blob ref with a cid string
+
if (isUntypedBlobRef(blobRef)) {
+
return blobRef.cid;
+
}
+
return null;
}
+55 -35
hosting-service/src/server.ts
···
if (existsSync(cachedFile)) {
const file = Bun.file(cachedFile);
-
return new Response(file);
+
return new Response(file, {
+
headers: {
+
'Content-Type': file.type || 'application/octet-stream',
+
},
+
});
}
// Try index.html for directory-like paths
···
const indexFile = getCachedFilePath(did, rkey, `${requestPath}/index.html`);
if (existsSync(indexFile)) {
const file = Bun.file(indexFile);
-
return new Response(file);
+
return new Response(file, {
+
headers: {
+
'Content-Type': 'text/html; charset=utf-8',
+
},
+
});
}
}
return new Response('Not Found', { status: 404 });
}
-
// Helper to serve files from cache with HTML path rewriting for /s/ routes
+
// Helper to serve files from cache with HTML path rewriting for sites.wisp.place routes
async function serveFromCacheWithRewrite(
did: string,
rkey: string,
···
});
}
-
// Non-HTML files served as-is
-
return new Response(file);
+
// Non-HTML files served with proper MIME type
+
return new Response(file, {
+
headers: {
+
'Content-Type': file.type || 'application/octet-stream',
+
},
+
});
}
// Try index.html for directory-like paths
···
}
}
-
// Route 4: Direct file serving (no DB) - /s.wisp.place/:identifier/:site/*
-
app.get('/s/:identifier/:site/*', async (c) => {
-
const identifier = c.req.param('identifier');
-
const site = c.req.param('site');
-
const rawPath = c.req.path.replace(`/s/${identifier}/${site}/`, '');
-
const filePath = sanitizePath(rawPath);
-
-
console.log('[Direct] Serving', { identifier, site, filePath });
-
-
// Validate site name (rkey)
-
if (!isValidRkey(site)) {
-
return c.text('Invalid site name', 400);
-
}
-
-
// Resolve identifier to DID
-
const did = await resolveDid(identifier);
-
if (!did) {
-
return c.text('Invalid identifier', 400);
-
}
-
-
// Ensure site is cached
-
const cached = await ensureSiteCached(did, site);
-
if (!cached) {
-
return c.text('Site not found', 404);
-
}
-
-
// Serve with HTML path rewriting to handle absolute paths
-
const basePath = `/s/${identifier}/${site}/`;
-
return serveFromCacheWithRewrite(did, site, filePath, basePath);
-
});
+
// Route 4: Direct file serving (no DB) - sites.wisp.place/:identifier/:site/*
+
// This route is now handled in the catch-all route below
// Route 3: DNS routing for custom domains - /hash.dns.wisp.place/*
app.get('/*', async (c) => {
···
const path = sanitizePath(rawPath);
console.log('[Request]', { hostname, path });
+
+
// Check if this is sites.wisp.place subdomain
+
if (hostname === `sites.${BASE_HOST}` || hostname === `sites.${BASE_HOST}:${process.env.PORT || 3000}`) {
+
// Extract identifier and site from path: /did:plc:123abc/sitename/file.html
+
const pathParts = rawPath.split('/');
+
if (pathParts.length < 2) {
+
return c.text('Invalid path format. Expected: /identifier/sitename/path', 400);
+
}
+
+
const identifier = pathParts[0];
+
const site = pathParts[1];
+
const filePath = sanitizePath(pathParts.slice(2).join('/'));
+
+
console.log('[Sites] Serving', { identifier, site, filePath });
+
+
// Validate site name (rkey)
+
if (!isValidRkey(site)) {
+
return c.text('Invalid site name', 400);
+
}
+
+
// Resolve identifier to DID
+
const did = await resolveDid(identifier);
+
if (!did) {
+
return c.text('Invalid identifier', 400);
+
}
+
+
// Ensure site is cached
+
const cached = await ensureSiteCached(did, site);
+
if (!cached) {
+
return c.text('Site not found', 404);
+
}
+
+
// Serve with HTML path rewriting to handle absolute paths
+
const basePath = `/${identifier}/${site}/`;
+
return serveFromCacheWithRewrite(did, site, filePath, basePath);
+
}
// Check if this is a DNS hash subdomain
const dnsMatch = hostname.match(/^([a-f0-9]{16})\.dns\.(.+)$/);
+2 -1
package.json
···
"react-dom": "^19.2.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "4",
-
"tw-animate-css": "^1.4.0"
+
"tw-animate-css": "^1.4.0",
+
"typescript": "^5.9.3"
},
"devDependencies": {
"@types/react": "^19.2.2",
-34
src/lib/wisp-utils.ts
···
filePaths: string[],
currentPath: string = ''
): Directory {
-
const mimeTypeMismatches: string[] = [];
-
const updatedEntries = directory.entries.map(entry => {
if ('type' in entry.node && entry.node.type === 'file') {
// Build the full path for this file
···
if (fileIndex !== -1 && uploadResults[fileIndex]) {
const blobRef = uploadResults[fileIndex].blobRef;
-
const uploadedPath = filePaths[fileIndex];
-
-
// Check if MIME types make sense for this file extension
-
const expectedMime = getExpectedMimeType(entry.name);
-
if (expectedMime && blobRef.mimeType !== expectedMime && !blobRef.mimeType.startsWith(expectedMime)) {
-
mimeTypeMismatches.push(`${fullPath}: expected ${expectedMime}, got ${blobRef.mimeType} (from upload: ${uploadedPath})`);
-
}
return {
...entry,
···
return entry;
}) as Entry[];
-
if (mimeTypeMismatches.length > 0) {
-
console.error('\n⚠️ MIME TYPE MISMATCHES DETECTED IN MANIFEST:');
-
mimeTypeMismatches.forEach(m => console.error(` ${m}`));
-
console.error('');
-
}
-
const result = {
$type: 'place.wisp.fs#directory' as const,
type: 'directory' as const,
···
return result;
}
-
-
function getExpectedMimeType(filename: string): string | null {
-
const ext = filename.toLowerCase().split('.').pop();
-
const mimeMap: Record<string, string> = {
-
'html': 'text/html',
-
'htm': 'text/html',
-
'css': 'text/css',
-
'js': 'text/javascript',
-
'mjs': 'text/javascript',
-
'json': 'application/json',
-
'jpg': 'image/jpeg',
-
'jpeg': 'image/jpeg',
-
'png': 'image/png',
-
'gif': 'image/gif',
-
'webp': 'image/webp',
-
'svg': 'image/svg+xml',
-
};
-
return ext ? (mimeMap[ext] || null) : null;
-
}
+4 -65
src/routes/wisp.ts
···
uploadedFiles.push({
name: file.name,
content: Buffer.from(arrayBuffer),
-
mimeType: file.type || 'application/octet-stream',
+
mimeType: 'application/octet-stream',
size: file.size
});
}
···
// Process files into directory structure
const { directory, fileCount } = processUploadedFiles(uploadedFiles);
-
// Upload files as blobs in parallel
-
const mimeTypeMismatches: Array<{file: string, sent: string, returned: string}> = [];
-
+
// Upload files as blobs in parallel (always as octet-stream)
const uploadPromises = uploadedFiles.map(async (file, i) => {
try {
const uploadResult = await agent.com.atproto.repo.uploadBlob(
file.content,
{
-
encoding: file.mimeType
+
encoding: 'application/octet-stream'
}
);
const sentMimeType = file.mimeType;
const returnedBlobRef = uploadResult.data.blob;
-
// Track MIME type mismatches for summary
-
if (sentMimeType !== returnedBlobRef.mimeType) {
-
mimeTypeMismatches.push({
-
file: file.name,
-
sent: sentMimeType,
-
returned: returnedBlobRef.mimeType
-
});
-
}
-
// Use the blob ref exactly as returned from PDS
return {
result: {
-
hash: returnedBlobRef.ref.$link || returnedBlobRef.ref.toString(),
+
hash: returnedBlobRef.ref.toString(),
blobRef: returnedBlobRef
},
filePath: file.name,
···
// Wait for all uploads to complete
const uploadedBlobs = await Promise.all(uploadPromises);
-
// Show MIME type mismatch summary
-
if (mimeTypeMismatches.length > 0) {
-
console.warn(`\n⚠️ PDS changed MIME types for ${mimeTypeMismatches.length} files:`);
-
mimeTypeMismatches.slice(0, 20).forEach(m => {
-
console.warn(` ${m.file}: ${m.sent} → ${m.returned}`);
-
});
-
if (mimeTypeMismatches.length > 20) {
-
console.warn(` ... and ${mimeTypeMismatches.length - 20} more`);
-
}
-
console.warn('');
-
}
-
-
// CRITICAL: Find files uploaded as application/octet-stream
-
const octetStreamFiles = uploadedBlobs.filter(b => b.returnedMimeType === 'application/octet-stream');
-
if (octetStreamFiles.length > 0) {
-
console.error(`\n🚨 FILES UPLOADED AS application/octet-stream (${octetStreamFiles.length}):`);
-
octetStreamFiles.forEach(f => {
-
console.error(` ${f.filePath}: sent=${f.sentMimeType}, returned=${f.returnedMimeType}`);
-
});
-
console.error('');
-
}
-
// Extract results and file paths in correct order
const uploadResults: FileUploadResult[] = uploadedBlobs.map(blob => blob.result);
const filePaths: string[] = uploadedBlobs.map(blob => blob.filePath);
···
} catch (putRecordError: any) {
console.error('\n❌ Failed to create record on PDS');
console.error('Error:', putRecordError.message);
-
-
// Try to identify which file has the MIME type mismatch
-
if (putRecordError.message?.includes('Mimetype') || putRecordError.message?.includes('mimeType')) {
-
console.error('\n🔍 Analyzing manifest for MIME type issues...');
-
-
// Recursively check all blobs in manifest
-
const checkBlobs = (node: any, path: string = '') => {
-
if (node.type === 'file' && node.blob) {
-
const mimeType = node.blob.mimeType;
-
console.error(` File: ${path} - MIME: ${mimeType}`);
-
} else if (node.type === 'directory' && node.entries) {
-
for (const entry of node.entries) {
-
const entryPath = path ? `${path}/${entry.name}` : entry.name;
-
checkBlobs(entry.node, entryPath);
-
}
-
}
-
};
-
-
checkBlobs(manifest.root, '');
-
-
console.error('\n📊 Blob upload summary:');
-
uploadedBlobs.slice(0, 20).forEach((b, i) => {
-
console.error(` [${i}] ${b.filePath}: sent=${b.sentMimeType}, returned=${b.returnedMimeType}`);
-
});
-
if (uploadedBlobs.length > 20) {
-
console.error(` ... and ${uploadedBlobs.length - 20} more`);
-
}
-
}
throw putRecordError;
}