@solana-mobile/dapp-store-cli 0.15.0 → 0.16.0
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/lib/CliUtils.js +1 -1
- package/lib/package.json +3 -3
- package/lib/upload/CachedStorageDriver.js +194 -29
- package/lib/upload/TurboStorageDriver.js +17 -17
- package/lib/upload/__tests__/CachedStorageDriver.test.js +437 -0
- package/lib/upload/__tests__/TurboStorageDriver.test.js +17 -0
- package/lib/upload/__tests__/contentGateway.test.js +17 -0
- package/lib/upload/contentGateway.js +23 -0
- package/package.json +3 -3
- package/src/CliUtils.ts +1 -1
- package/src/upload/CachedStorageDriver.ts +93 -13
- package/src/upload/TurboStorageDriver.ts +22 -16
- package/src/upload/__tests__/CachedStorageDriver.test.ts +246 -0
- package/src/upload/__tests__/TurboStorageDriver.test.ts +15 -0
- package/src/upload/__tests__/contentGateway.test.ts +31 -0
- package/src/upload/contentGateway.ts +37 -0
|
@@ -3,6 +3,7 @@ import type { MetaplexFile } from "@metaplex-foundation/js";
|
|
|
3
3
|
import { TurboFactory, lamportToTokenAmount } from "@ardrive/turbo-sdk";
|
|
4
4
|
import bs58 from "bs58";
|
|
5
5
|
import debugModule from "debug";
|
|
6
|
+
import { buildPublicContentUrl, type StorageNetwork } from "./contentGateway.js";
|
|
6
7
|
|
|
7
8
|
const debug = debugModule("cli:turbo-storage");
|
|
8
9
|
|
|
@@ -22,6 +23,12 @@ interface TurboClient {
|
|
|
22
23
|
}): Promise<{ id: string }>;
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
type TurboServiceConfig = {
|
|
27
|
+
gatewayUrl?: string;
|
|
28
|
+
paymentServiceConfig?: { url: string };
|
|
29
|
+
uploadServiceConfig?: { url: string };
|
|
30
|
+
};
|
|
31
|
+
|
|
25
32
|
const SOL_IN_LAMPORTS = 1_000_000_000;
|
|
26
33
|
const MIN_TOP_UP_LAMPORTS = 1_000_000;
|
|
27
34
|
const MIN_TOP_UP_SOL = MIN_TOP_UP_LAMPORTS / SOL_IN_LAMPORTS;
|
|
@@ -37,9 +44,12 @@ const CONSTANTS = {
|
|
|
37
44
|
BASE_MS: 500,
|
|
38
45
|
MAX_MS: 8000,
|
|
39
46
|
},
|
|
40
|
-
|
|
41
|
-
devnet:
|
|
42
|
-
|
|
47
|
+
SERVICE_URLS: {
|
|
48
|
+
devnet: {
|
|
49
|
+
gatewayUrl: "https://api.devnet.solana.com",
|
|
50
|
+
uploadServiceConfig: { url: "https://upload.ardrive.dev" },
|
|
51
|
+
paymentServiceConfig: { url: "https://payment.ardrive.dev" },
|
|
52
|
+
},
|
|
43
53
|
},
|
|
44
54
|
} as const;
|
|
45
55
|
|
|
@@ -49,7 +59,7 @@ const delay = (ms: number): Promise<void> =>
|
|
|
49
59
|
export class TurboStorageDriver {
|
|
50
60
|
private turbo: TurboClient;
|
|
51
61
|
private bufferPercentage: number;
|
|
52
|
-
private network:
|
|
62
|
+
private network: StorageNetwork;
|
|
53
63
|
|
|
54
64
|
private uploadQueue: Array<{
|
|
55
65
|
file: MetaplexFile;
|
|
@@ -60,7 +70,7 @@ export class TurboStorageDriver {
|
|
|
60
70
|
|
|
61
71
|
constructor(
|
|
62
72
|
keypair: Keypair,
|
|
63
|
-
network:
|
|
73
|
+
network: StorageNetwork = "mainnet",
|
|
64
74
|
bufferPercentage = 20
|
|
65
75
|
) {
|
|
66
76
|
this.network = network;
|
|
@@ -69,18 +79,10 @@ export class TurboStorageDriver {
|
|
|
69
79
|
this.turbo = TurboFactory.authenticated({
|
|
70
80
|
privateKey: bs58.encode(keypair.secretKey),
|
|
71
81
|
token: "solana",
|
|
72
|
-
...
|
|
82
|
+
...getTurboServiceConfig(network),
|
|
73
83
|
}) as TurboClient;
|
|
74
84
|
}
|
|
75
85
|
|
|
76
|
-
private getServiceUrls(isDev: boolean) {
|
|
77
|
-
const base = isDev ? "ardrive.dev" : "ardrive.io";
|
|
78
|
-
return {
|
|
79
|
-
uploadUrl: `https://upload.${base}`,
|
|
80
|
-
paymentUrl: `https://payment.${base}`,
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
86
|
async getUploadPrice(bytes: number): Promise<bigint> {
|
|
85
87
|
if (bytes <= CONSTANTS.FREE_UPLOAD_LIMIT) return BigInt(0);
|
|
86
88
|
|
|
@@ -248,8 +250,7 @@ export class TurboStorageDriver {
|
|
|
248
250
|
dataItemOpts: { tags },
|
|
249
251
|
});
|
|
250
252
|
|
|
251
|
-
const
|
|
252
|
-
const url = `${gateway}/${uploadResult.id}`;
|
|
253
|
+
const url = buildPublicContentUrl(uploadResult.id, this.network);
|
|
253
254
|
debug(`Upload complete: ${url}`);
|
|
254
255
|
item.resolve(url);
|
|
255
256
|
|
|
@@ -275,3 +276,8 @@ export class TurboStorageDriver {
|
|
|
275
276
|
});
|
|
276
277
|
}
|
|
277
278
|
}
|
|
279
|
+
|
|
280
|
+
export const getTurboServiceConfig = (
|
|
281
|
+
network: StorageNetwork
|
|
282
|
+
): TurboServiceConfig =>
|
|
283
|
+
network === "devnet" ? CONSTANTS.SERVICE_URLS.devnet : {};
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { createHash } from "crypto";
|
|
5
|
+
import { jest } from "@jest/globals";
|
|
6
|
+
import type { MetaplexFile, StorageDriver } from "@metaplex-foundation/js";
|
|
7
|
+
import { CachedStorageDriver } from "../CachedStorageDriver";
|
|
8
|
+
|
|
9
|
+
type MockStorageDriver = Pick<StorageDriver, "getUploadPrice" | "upload">;
|
|
10
|
+
|
|
11
|
+
describe("CachedStorageDriver", () => {
|
|
12
|
+
const originalCwd = process.cwd();
|
|
13
|
+
let tempDir: string;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "cached-storage-driver-"));
|
|
17
|
+
process.chdir(tempDir);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
process.chdir(originalCwd);
|
|
22
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("normalizes a cached legacy arweave URL and persists the rewritten manifest", async () => {
|
|
26
|
+
const file = makeMetaplexFile("icon.png", Buffer.from("cached-asset"));
|
|
27
|
+
const hash = hashBuffer(file.buffer);
|
|
28
|
+
const manifestPath = ".asset-manifest.json";
|
|
29
|
+
|
|
30
|
+
fs.writeFileSync(
|
|
31
|
+
path.join(tempDir, manifestPath),
|
|
32
|
+
JSON.stringify(
|
|
33
|
+
{
|
|
34
|
+
assets: {
|
|
35
|
+
[file.fileName]: {
|
|
36
|
+
path: file.fileName,
|
|
37
|
+
sha256: hash,
|
|
38
|
+
uri: "https://arweave.net/legacy-id",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
null,
|
|
43
|
+
2
|
|
44
|
+
)
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
let uploadCalls = 0;
|
|
48
|
+
const storageDriver: MockStorageDriver = {
|
|
49
|
+
getUploadPrice: async (_bytes: number) => {
|
|
50
|
+
throw new Error("getUploadPrice should not be called in this test");
|
|
51
|
+
},
|
|
52
|
+
upload: async (_file: MetaplexFile) => {
|
|
53
|
+
uploadCalls += 1;
|
|
54
|
+
throw new Error("upload should not be called in this test");
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const driver = new CachedStorageDriver(storageDriver as StorageDriver, {
|
|
59
|
+
assetManifestPath: manifestPath,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await expect(driver.upload(file)).resolves.toBe(
|
|
63
|
+
"https://dappstorecontent.com/legacy-id"
|
|
64
|
+
);
|
|
65
|
+
expect(uploadCalls).toBe(0);
|
|
66
|
+
|
|
67
|
+
const rewrittenManifest = JSON.parse(
|
|
68
|
+
fs.readFileSync(path.join(tempDir, manifestPath), "utf-8")
|
|
69
|
+
);
|
|
70
|
+
expect(rewrittenManifest.schema_version).toBe("0.1");
|
|
71
|
+
expect(rewrittenManifest.assets[file.fileName].uri).toBe(
|
|
72
|
+
"https://dappstorecontent.com/legacy-id"
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("supports absolute asset manifest paths", async () => {
|
|
77
|
+
const file = makeMetaplexFile("absolute-icon.png", Buffer.from("cached-asset"));
|
|
78
|
+
const hash = hashBuffer(file.buffer);
|
|
79
|
+
const manifestPath = path.join(tempDir, ".asset-manifest.json");
|
|
80
|
+
|
|
81
|
+
fs.writeFileSync(
|
|
82
|
+
manifestPath,
|
|
83
|
+
JSON.stringify(
|
|
84
|
+
{
|
|
85
|
+
assets: {
|
|
86
|
+
[file.fileName]: {
|
|
87
|
+
path: file.fileName,
|
|
88
|
+
sha256: hash,
|
|
89
|
+
uri: "https://arweave.net/absolute-id",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
null,
|
|
94
|
+
2
|
|
95
|
+
)
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
let uploadCalls = 0;
|
|
99
|
+
const storageDriver: MockStorageDriver = {
|
|
100
|
+
getUploadPrice: async (_bytes: number) => {
|
|
101
|
+
throw new Error("getUploadPrice should not be called in this test");
|
|
102
|
+
},
|
|
103
|
+
upload: async (_file: MetaplexFile) => {
|
|
104
|
+
uploadCalls += 1;
|
|
105
|
+
throw new Error("upload should not be called in this test");
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const driver = new CachedStorageDriver(storageDriver as StorageDriver, {
|
|
110
|
+
assetManifestPath: manifestPath,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await expect(driver.upload(file)).resolves.toBe(
|
|
114
|
+
"https://dappstorecontent.com/absolute-id"
|
|
115
|
+
);
|
|
116
|
+
expect(uploadCalls).toBe(0);
|
|
117
|
+
|
|
118
|
+
const rewrittenManifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
|
|
119
|
+
expect(rewrittenManifest.assets[file.fileName].uri).toBe(
|
|
120
|
+
"https://dappstorecontent.com/absolute-id"
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("normalizes fresh uploads before persisting them in the manifest", async () => {
|
|
125
|
+
const file = makeMetaplexFile("banner.png", Buffer.from("fresh-asset"));
|
|
126
|
+
const manifestPath = ".asset-manifest.json";
|
|
127
|
+
|
|
128
|
+
let uploadCalls = 0;
|
|
129
|
+
const storageDriver: MockStorageDriver = {
|
|
130
|
+
getUploadPrice: async (_bytes: number) => {
|
|
131
|
+
throw new Error("getUploadPrice should not be called in this test");
|
|
132
|
+
},
|
|
133
|
+
upload: async (_file: MetaplexFile) => {
|
|
134
|
+
uploadCalls += 1;
|
|
135
|
+
return "https://arweave.com/fresh-id";
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const driver = new CachedStorageDriver(storageDriver as StorageDriver, {
|
|
140
|
+
assetManifestPath: manifestPath,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await expect(driver.upload(file)).resolves.toBe(
|
|
144
|
+
"https://dappstorecontent.com/fresh-id"
|
|
145
|
+
);
|
|
146
|
+
expect(uploadCalls).toBe(1);
|
|
147
|
+
|
|
148
|
+
const persistedManifest = JSON.parse(
|
|
149
|
+
fs.readFileSync(path.join(tempDir, manifestPath), "utf-8")
|
|
150
|
+
);
|
|
151
|
+
expect(persistedManifest.schema_version).toBe("0.1");
|
|
152
|
+
expect(persistedManifest.assets[file.fileName].uri).toBe(
|
|
153
|
+
"https://dappstorecontent.com/fresh-id"
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("normalizes inline metadata uploads without caching them", async () => {
|
|
158
|
+
const file = makeMetaplexFile("inline.json", Buffer.from("{}"));
|
|
159
|
+
let uploadCalls = 0;
|
|
160
|
+
const storageDriver: MockStorageDriver = {
|
|
161
|
+
getUploadPrice: async (_bytes: number) => {
|
|
162
|
+
throw new Error("getUploadPrice should not be called in this test");
|
|
163
|
+
},
|
|
164
|
+
upload: async (_file: MetaplexFile) => {
|
|
165
|
+
uploadCalls += 1;
|
|
166
|
+
return "https://arweave.net/metadata-id";
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const driver = new CachedStorageDriver(storageDriver as StorageDriver, {
|
|
171
|
+
assetManifestPath: ".asset-manifest.json",
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await expect(driver.upload(file)).resolves.toBe(
|
|
175
|
+
"https://dappstorecontent.com/metadata-id"
|
|
176
|
+
);
|
|
177
|
+
expect(uploadCalls).toBe(1);
|
|
178
|
+
expect(fs.existsSync(path.join(tempDir, ".asset-manifest.json"))).toBe(
|
|
179
|
+
false
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("returns a normalized cached URL even when manifest rewrite persistence fails", async () => {
|
|
184
|
+
const file = makeMetaplexFile("icon.png", Buffer.from("cached-asset"));
|
|
185
|
+
const hash = hashBuffer(file.buffer);
|
|
186
|
+
const manifestPath = ".asset-manifest.json";
|
|
187
|
+
|
|
188
|
+
fs.writeFileSync(
|
|
189
|
+
path.join(tempDir, manifestPath),
|
|
190
|
+
JSON.stringify(
|
|
191
|
+
{
|
|
192
|
+
assets: {
|
|
193
|
+
[file.fileName]: {
|
|
194
|
+
path: file.fileName,
|
|
195
|
+
sha256: hash,
|
|
196
|
+
uri: "https://arweave.net/rewrite-failure-id",
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
null,
|
|
201
|
+
2
|
|
202
|
+
)
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const writeFileSpy = jest
|
|
206
|
+
.spyOn(fs.promises, "writeFile")
|
|
207
|
+
.mockRejectedValueOnce(new Error("disk full"));
|
|
208
|
+
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
209
|
+
|
|
210
|
+
const storageDriver: MockStorageDriver = {
|
|
211
|
+
getUploadPrice: async (_bytes: number) => {
|
|
212
|
+
throw new Error("getUploadPrice should not be called in this test");
|
|
213
|
+
},
|
|
214
|
+
upload: async (_file: MetaplexFile) => {
|
|
215
|
+
throw new Error("upload should not be called in this test");
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const driver = new CachedStorageDriver(storageDriver as StorageDriver, {
|
|
220
|
+
assetManifestPath: manifestPath,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
await expect(driver.upload(file)).resolves.toBe(
|
|
225
|
+
"https://dappstorecontent.com/rewrite-failure-id"
|
|
226
|
+
);
|
|
227
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
228
|
+
expect.stringContaining(
|
|
229
|
+
"Failed to rewrite .asset-manifest.json; continuing with normalized URL"
|
|
230
|
+
)
|
|
231
|
+
);
|
|
232
|
+
} finally {
|
|
233
|
+
writeFileSpy.mockRestore();
|
|
234
|
+
warnSpy.mockRestore();
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const makeMetaplexFile = (fileName: string, buffer: Buffer): MetaplexFile =>
|
|
240
|
+
({
|
|
241
|
+
fileName,
|
|
242
|
+
buffer,
|
|
243
|
+
}) as MetaplexFile;
|
|
244
|
+
|
|
245
|
+
const hashBuffer = (buffer: Buffer): string =>
|
|
246
|
+
createHash("sha256").update(buffer).digest("base64");
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { getTurboServiceConfig } from "../TurboStorageDriver";
|
|
2
|
+
|
|
3
|
+
describe("TurboStorageDriver", () => {
|
|
4
|
+
test("uses the latest Turbo SDK service config shape for devnet", () => {
|
|
5
|
+
expect(getTurboServiceConfig("devnet")).toEqual({
|
|
6
|
+
gatewayUrl: "https://api.devnet.solana.com",
|
|
7
|
+
uploadServiceConfig: { url: "https://upload.ardrive.dev" },
|
|
8
|
+
paymentServiceConfig: { url: "https://payment.ardrive.dev" },
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("relies on the SDK defaults for mainnet service URLs", () => {
|
|
13
|
+
expect(getTurboServiceConfig("mainnet")).toEqual({});
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { buildPublicContentUrl, normalizePublicContentUrl } from "../contentGateway";
|
|
2
|
+
|
|
3
|
+
describe("contentGateway", () => {
|
|
4
|
+
test("builds a mainnet dappstorecontent URL from an upload id", () => {
|
|
5
|
+
expect(buildPublicContentUrl("abc123", "mainnet")).toBe(
|
|
6
|
+
"https://dappstorecontent.com/abc123"
|
|
7
|
+
);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("builds a devnet Turbo raw URL from an upload id", () => {
|
|
11
|
+
expect(buildPublicContentUrl("abc123", "devnet")).toBe(
|
|
12
|
+
"https://turbo.ardrive.dev/raw/abc123"
|
|
13
|
+
);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("normalizes arweave.net and arweave.com URLs to dappstorecontent", () => {
|
|
17
|
+
expect(normalizePublicContentUrl("https://arweave.net/abc123")).toBe(
|
|
18
|
+
"https://dappstorecontent.com/abc123"
|
|
19
|
+
);
|
|
20
|
+
expect(
|
|
21
|
+
normalizePublicContentUrl("https://www.arweave.com/abc123?ext=png")
|
|
22
|
+
).toBe("https://dappstorecontent.com/abc123?ext=png");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("leaves non-arweave URLs unchanged", () => {
|
|
26
|
+
expect(
|
|
27
|
+
normalizePublicContentUrl("https://turbo.ardrive.dev/raw/abc123")
|
|
28
|
+
).toBe("https://turbo.ardrive.dev/raw/abc123");
|
|
29
|
+
expect(normalizePublicContentUrl("not-a-url")).toBe("not-a-url");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type StorageNetwork = "devnet" | "mainnet";
|
|
2
|
+
|
|
3
|
+
const MAINNET_PUBLIC_CONTENT_GATEWAY = "https://dappstorecontent.com";
|
|
4
|
+
const DEVNET_PUBLIC_CONTENT_GATEWAY = "https://turbo.ardrive.dev/raw";
|
|
5
|
+
|
|
6
|
+
const LEGACY_MAINNET_CONTENT_HOSTS = new Set([
|
|
7
|
+
"arweave.net",
|
|
8
|
+
"www.arweave.net",
|
|
9
|
+
"arweave.com",
|
|
10
|
+
"www.arweave.com",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
export const buildPublicContentUrl = (
|
|
14
|
+
id: string,
|
|
15
|
+
network: StorageNetwork
|
|
16
|
+
): string => {
|
|
17
|
+
const gateway =
|
|
18
|
+
network === "devnet"
|
|
19
|
+
? DEVNET_PUBLIC_CONTENT_GATEWAY
|
|
20
|
+
: MAINNET_PUBLIC_CONTENT_GATEWAY;
|
|
21
|
+
|
|
22
|
+
return `${gateway}/${id}`;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const normalizePublicContentUrl = (url: string): string => {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = new URL(url);
|
|
28
|
+
|
|
29
|
+
if (!LEGACY_MAINNET_CONTENT_HOSTS.has(parsed.host.toLowerCase())) {
|
|
30
|
+
return url;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return `${MAINNET_PUBLIC_CONTENT_GATEWAY}${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
34
|
+
} catch {
|
|
35
|
+
return url;
|
|
36
|
+
}
|
|
37
|
+
};
|