@solana-mobile/dapp-store-cli 0.15.0 → 0.16.1
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/bin/dapp-store.js +3 -1
- package/lib/CliSetup.js +304 -505
- package/lib/CliUtils.js +6 -376
- package/lib/__tests__/CliSetupTest.js +484 -74
- package/lib/cli/__tests__/parseErrors.test.js +25 -0
- package/lib/cli/__tests__/signer.test.js +436 -0
- package/lib/cli/constants.js +23 -0
- package/lib/cli/messages.js +21 -0
- package/lib/cli/parseErrors.js +41 -0
- package/lib/{commands/publish/PublishCliSupport.js → cli/selfUpdate.js} +72 -38
- package/lib/{commands/publish/PublishCliRemove.js → cli/signer.js} +35 -56
- package/lib/index.js +96 -5
- package/lib/package.json +5 -24
- package/lib/portal/__tests__/releaseMetadata.test.js +647 -0
- package/lib/portal/__tests__/translators.test.js +76 -0
- package/lib/portal/__tests__/workflowClient.test.js +457 -0
- package/lib/portal/attestationClient.js +143 -0
- package/lib/portal/files.js +64 -0
- package/lib/portal/http.js +364 -0
- package/lib/portal/records.js +64 -0
- package/lib/portal/releaseMetadata.js +748 -0
- package/lib/portal/translators.js +460 -0
- package/lib/portal/types.js +1 -0
- package/lib/portal/workflowClient.js +704 -0
- package/lib/publication/PublicationProgressReporter.js +1051 -0
- package/lib/publication/__tests__/PublicationProgressReporter.test.js +174 -0
- package/lib/{commands/ValidateCommand.js → publication/__tests__/fundingPreflight.test.js} +90 -66
- package/lib/publication/__tests__/publicationSummary.test.js +26 -0
- package/lib/publication/cliValidation.js +482 -0
- package/lib/publication/fundingPreflight.js +246 -0
- package/lib/publication/publicationSummary.js +99 -0
- package/lib/{commands/utils.js → publication/runPublicationWorkflow.js} +16 -46
- package/package.json +5 -24
- package/src/CliSetup.ts +370 -505
- package/src/CliUtils.ts +9 -233
- package/src/__tests__/CliSetupTest.ts +272 -120
- package/src/cli/__tests__/parseErrors.test.ts +34 -0
- package/src/cli/__tests__/signer.test.ts +359 -0
- package/src/cli/constants.ts +3 -0
- package/src/cli/messages.ts +27 -0
- package/src/cli/parseErrors.ts +62 -0
- package/src/cli/selfUpdate.ts +59 -0
- package/src/cli/signer.ts +38 -0
- package/src/index.ts +31 -4
- package/src/portal/__tests__/releaseMetadata.test.ts +508 -0
- package/src/portal/__tests__/translators.test.ts +82 -0
- package/src/portal/__tests__/workflowClient.test.ts +278 -0
- package/src/portal/attestationClient.ts +19 -0
- package/src/portal/files.ts +73 -0
- package/src/portal/http.ts +170 -0
- package/src/portal/records.ts +38 -0
- package/src/portal/releaseMetadata.ts +489 -0
- package/src/portal/translators.ts +750 -0
- package/src/portal/types.ts +27 -0
- package/src/portal/workflowClient.ts +575 -0
- package/src/publication/PublicationProgressReporter.ts +1026 -0
- package/src/publication/__tests__/PublicationProgressReporter.test.ts +210 -0
- package/src/publication/__tests__/fundingPreflight.test.ts +78 -0
- package/src/publication/__tests__/publicationSummary.test.ts +30 -0
- package/src/publication/cliValidation.ts +264 -0
- package/src/publication/fundingPreflight.ts +123 -0
- package/src/publication/publicationSummary.ts +26 -0
- package/src/publication/runPublicationWorkflow.ts +46 -0
- package/lib/commands/create/CreateCliApp.js +0 -223
- package/lib/commands/create/CreateCliRelease.js +0 -290
- package/lib/commands/create/index.js +0 -40
- package/lib/commands/index.js +0 -3
- package/lib/commands/publish/PublishCliSubmit.js +0 -208
- package/lib/commands/publish/PublishCliUpdate.js +0 -211
- package/lib/commands/publish/index.js +0 -22
- package/lib/commands/scaffolding/ScaffoldInit.js +0 -15
- package/lib/commands/scaffolding/index.js +0 -1
- package/lib/config/EnvVariables.js +0 -59
- package/lib/config/PublishDetails.js +0 -915
- package/lib/config/S3StorageManager.js +0 -93
- package/lib/config/index.js +0 -2
- package/lib/generated/config_obj.json +0 -1
- package/lib/generated/config_schema.json +0 -1
- package/lib/prebuild_schema/publishing_source.yaml +0 -64
- package/lib/prebuild_schema/schemagen.js +0 -25
- package/lib/upload/CachedStorageDriver.js +0 -293
- package/lib/upload/TurboStorageDriver.js +0 -718
- package/lib/upload/index.js +0 -2
- package/src/commands/ValidateCommand.ts +0 -82
- package/src/commands/create/CreateCliApp.ts +0 -93
- package/src/commands/create/CreateCliRelease.ts +0 -149
- package/src/commands/create/index.ts +0 -47
- package/src/commands/index.ts +0 -3
- package/src/commands/publish/PublishCliRemove.ts +0 -66
- package/src/commands/publish/PublishCliSubmit.ts +0 -93
- package/src/commands/publish/PublishCliSupport.ts +0 -66
- package/src/commands/publish/PublishCliUpdate.ts +0 -101
- package/src/commands/publish/index.ts +0 -29
- package/src/commands/scaffolding/ScaffoldInit.ts +0 -20
- package/src/commands/scaffolding/index.ts +0 -1
- package/src/commands/utils.ts +0 -33
- package/src/config/EnvVariables.ts +0 -39
- package/src/config/PublishDetails.ts +0 -456
- package/src/config/S3StorageManager.ts +0 -47
- package/src/config/index.ts +0 -2
- package/src/prebuild_schema/publishing_source.yaml +0 -64
- package/src/prebuild_schema/schemagen.js +0 -31
- package/src/upload/CachedStorageDriver.ts +0 -99
- package/src/upload/TurboStorageDriver.ts +0 -277
- package/src/upload/index.ts +0 -2
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, expect, jest, test } from "@jest/globals";
|
|
6
|
+
|
|
7
|
+
import { createPortalWorkflowClient } from "../workflowClient.js";
|
|
8
|
+
|
|
9
|
+
function createProcedureResponse(result: unknown): Response {
|
|
10
|
+
return new Response(JSON.stringify({ result: { data: result } }), {
|
|
11
|
+
status: 200,
|
|
12
|
+
headers: {
|
|
13
|
+
"content-type": "application/json",
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let originalFetch: typeof fetch;
|
|
19
|
+
let fetchMock: jest.MockedFunction<typeof fetch>;
|
|
20
|
+
const tempDirs: string[] = [];
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
originalFetch = global.fetch;
|
|
24
|
+
fetchMock = jest.fn<typeof fetch>();
|
|
25
|
+
global.fetch = fetchMock as typeof fetch;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
global.fetch = originalFetch;
|
|
30
|
+
while (tempDirs.length > 0) {
|
|
31
|
+
fs.rmSync(tempDirs.pop()!, { recursive: true, force: true });
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("createIngestionSession maps apk-url sources to the portal externalUrl API shape", async () => {
|
|
36
|
+
fetchMock.mockResolvedValueOnce(
|
|
37
|
+
createProcedureResponse({
|
|
38
|
+
id: "ingestion-1",
|
|
39
|
+
dappId: "dapp-1",
|
|
40
|
+
idempotencyKey: "idem-1",
|
|
41
|
+
status: "Created",
|
|
42
|
+
sourceKind: "externalUrl",
|
|
43
|
+
sourceUrl: "https://downloads.example.com/releases/app-release.apk",
|
|
44
|
+
releaseFileName: "app-release.apk",
|
|
45
|
+
releaseFileSize: 10,
|
|
46
|
+
releaseId: "release-1",
|
|
47
|
+
publicationSessionId: "session-1",
|
|
48
|
+
bundle: {
|
|
49
|
+
release: {
|
|
50
|
+
id: "release-1",
|
|
51
|
+
releaseFileName: "app-release.apk",
|
|
52
|
+
versionCode: 42,
|
|
53
|
+
versionName: "42",
|
|
54
|
+
androidPackage: "com.example.app",
|
|
55
|
+
localizedName: "Example App",
|
|
56
|
+
newInVersion: "Fixes",
|
|
57
|
+
},
|
|
58
|
+
dapp: {
|
|
59
|
+
id: "dapp-1",
|
|
60
|
+
dappName: "Example App",
|
|
61
|
+
description: "A dApp",
|
|
62
|
+
walletAddress: "wallet-1",
|
|
63
|
+
androidPackage: "com.example.app",
|
|
64
|
+
},
|
|
65
|
+
publisher: {
|
|
66
|
+
id: "publisher-1",
|
|
67
|
+
type: "organization",
|
|
68
|
+
name: "Example Publisher",
|
|
69
|
+
website: "https://example.com",
|
|
70
|
+
email: "team@example.com",
|
|
71
|
+
},
|
|
72
|
+
installFile: {
|
|
73
|
+
uri: "https://downloads.example.com/releases/app-release.apk",
|
|
74
|
+
size: 10,
|
|
75
|
+
},
|
|
76
|
+
signerAuthority: {
|
|
77
|
+
dappWalletAddress: "wallet-1",
|
|
78
|
+
collectionAuthority: "wallet-1",
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
publicationSession: {
|
|
82
|
+
id: "session-1",
|
|
83
|
+
releaseId: "release-1",
|
|
84
|
+
stage: "PreparedForMint",
|
|
85
|
+
created: "2024-01-01T00:00:00.000Z",
|
|
86
|
+
updated: "2024-01-01T00:00:00.000Z",
|
|
87
|
+
},
|
|
88
|
+
})
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const client = createPortalWorkflowClient({
|
|
92
|
+
apiBaseUrl: "https://portal.example.com/api",
|
|
93
|
+
apiKey: "portal-key",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const result = await client.createIngestionSession({
|
|
97
|
+
source: {
|
|
98
|
+
kind: "apk-url",
|
|
99
|
+
url: "https://downloads.example.com/releases/app-release.apk",
|
|
100
|
+
},
|
|
101
|
+
whatsNew: "Fixes",
|
|
102
|
+
idempotencyKey: "idem-1",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const [requestUrl, requestInit] = fetchMock.mock.calls[0]!;
|
|
106
|
+
const requestBody = JSON.parse(String(requestInit?.body));
|
|
107
|
+
|
|
108
|
+
expect(String(requestUrl)).toContain(
|
|
109
|
+
"/trpc/publication.createIngestionSession"
|
|
110
|
+
);
|
|
111
|
+
expect(requestInit?.method).toBe("POST");
|
|
112
|
+
expect(requestBody).toMatchObject({
|
|
113
|
+
source: {
|
|
114
|
+
kind: "externalUrl",
|
|
115
|
+
apkUrl: "https://downloads.example.com/releases/app-release.apk",
|
|
116
|
+
releaseFileName: "app-release.apk",
|
|
117
|
+
},
|
|
118
|
+
whatsNew: "Fixes",
|
|
119
|
+
idempotencyKey: "idem-1",
|
|
120
|
+
});
|
|
121
|
+
expect(result.source.kind).toBe("apk-url");
|
|
122
|
+
expect(result.bundle?.metadata?.installFile.origin).toBe("external");
|
|
123
|
+
expect(result.publicationSession?.checkpoint).toBe("bundle-ready");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("createIngestionSession uploads local APK files before creating the ingestion session", async () => {
|
|
127
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dapp-store-apk-"));
|
|
128
|
+
const apkPath = path.join(tempDir, "release-build");
|
|
129
|
+
tempDirs.push(tempDir);
|
|
130
|
+
fs.writeFileSync(apkPath, Buffer.from("apk-binary"));
|
|
131
|
+
|
|
132
|
+
fetchMock
|
|
133
|
+
.mockResolvedValueOnce(
|
|
134
|
+
createProcedureResponse({
|
|
135
|
+
uploadUrl: "https://uploads.example.com/app.apk",
|
|
136
|
+
key: "upload-key",
|
|
137
|
+
providerId: "provider-1",
|
|
138
|
+
publicUrl: "https://cdn.example.com/app.apk",
|
|
139
|
+
})
|
|
140
|
+
)
|
|
141
|
+
.mockResolvedValueOnce(new Response("", { status: 200 }))
|
|
142
|
+
.mockResolvedValueOnce(
|
|
143
|
+
createProcedureResponse({
|
|
144
|
+
id: "ingestion-2",
|
|
145
|
+
dappId: "dapp-1",
|
|
146
|
+
idempotencyKey: "idem-2",
|
|
147
|
+
status: "Created",
|
|
148
|
+
sourceKind: "portalUpload",
|
|
149
|
+
sourceUrl: "https://cdn.example.com/app.apk",
|
|
150
|
+
releaseFileName: "release-build.apk",
|
|
151
|
+
releaseFileSize: 10,
|
|
152
|
+
releaseId: "release-2",
|
|
153
|
+
publicationSessionId: "session-2",
|
|
154
|
+
bundle: {
|
|
155
|
+
release: {
|
|
156
|
+
id: "release-2",
|
|
157
|
+
releaseFileName: "release-build.apk",
|
|
158
|
+
releaseFileUrl: "https://cdn.example.com/app.apk",
|
|
159
|
+
versionCode: 7,
|
|
160
|
+
versionName: "7",
|
|
161
|
+
androidPackage: "com.example.app",
|
|
162
|
+
localizedName: "Example App",
|
|
163
|
+
newInVersion: "Local upload",
|
|
164
|
+
},
|
|
165
|
+
dapp: {
|
|
166
|
+
id: "dapp-1",
|
|
167
|
+
dappName: "Example App",
|
|
168
|
+
description: "A dApp",
|
|
169
|
+
walletAddress: "wallet-1",
|
|
170
|
+
androidPackage: "com.example.app",
|
|
171
|
+
},
|
|
172
|
+
publisher: {
|
|
173
|
+
id: "publisher-1",
|
|
174
|
+
type: "organization",
|
|
175
|
+
name: "Example Publisher",
|
|
176
|
+
website: "https://example.com",
|
|
177
|
+
email: "team@example.com",
|
|
178
|
+
},
|
|
179
|
+
installFile: {
|
|
180
|
+
uri: "https://cdn.example.com/app.apk",
|
|
181
|
+
size: 10,
|
|
182
|
+
},
|
|
183
|
+
signerAuthority: {
|
|
184
|
+
dappWalletAddress: "wallet-1",
|
|
185
|
+
collectionAuthority: "wallet-1",
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
publicationSession: {
|
|
189
|
+
id: "session-2",
|
|
190
|
+
releaseId: "release-2",
|
|
191
|
+
stage: "PreparedForMint",
|
|
192
|
+
created: "2024-01-01T00:00:00.000Z",
|
|
193
|
+
updated: "2024-01-01T00:00:00.000Z",
|
|
194
|
+
},
|
|
195
|
+
})
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const client = createPortalWorkflowClient({
|
|
199
|
+
apiBaseUrl: "https://portal.example.com/api",
|
|
200
|
+
apiKey: "portal-key",
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const result = await client.createIngestionSession({
|
|
204
|
+
source: {
|
|
205
|
+
kind: "apk-file",
|
|
206
|
+
filePath: apkPath,
|
|
207
|
+
},
|
|
208
|
+
whatsNew: "Local upload",
|
|
209
|
+
idempotencyKey: "idem-2",
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const [uploadTargetUrl, uploadTargetInit] = fetchMock.mock.calls[0]!;
|
|
213
|
+
const uploadTargetBody = JSON.parse(String(uploadTargetInit?.body));
|
|
214
|
+
expect(String(uploadTargetUrl)).toContain(
|
|
215
|
+
"/trpc/publication.createUploadTarget"
|
|
216
|
+
);
|
|
217
|
+
expect(uploadTargetBody).toMatchObject({
|
|
218
|
+
fileExtension: "apk",
|
|
219
|
+
contentType: "application/vnd.android.package-archive",
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const [uploadUrl, uploadInit] = fetchMock.mock.calls[1]!;
|
|
223
|
+
expect(String(uploadUrl)).toBe("https://uploads.example.com/app.apk");
|
|
224
|
+
expect(uploadInit?.method).toBe("PUT");
|
|
225
|
+
expect(Buffer.from(uploadInit?.body as Uint8Array).toString()).toBe(
|
|
226
|
+
"apk-binary"
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const [ingestionUrl, ingestionInit] = fetchMock.mock.calls[2]!;
|
|
230
|
+
const ingestionBody = JSON.parse(String(ingestionInit?.body));
|
|
231
|
+
expect(String(ingestionUrl)).toContain(
|
|
232
|
+
"/trpc/publication.createIngestionSession"
|
|
233
|
+
);
|
|
234
|
+
expect(ingestionBody).toMatchObject({
|
|
235
|
+
source: {
|
|
236
|
+
kind: "portalUpload",
|
|
237
|
+
releaseFileUrl: "https://cdn.example.com/app.apk",
|
|
238
|
+
releaseFileName: "release-build.apk",
|
|
239
|
+
releaseFileSize: 10,
|
|
240
|
+
},
|
|
241
|
+
whatsNew: "Local upload",
|
|
242
|
+
idempotencyKey: "idem-2",
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
expect(result.source.kind).toBe("apk-file");
|
|
246
|
+
expect(result.bundle?.metadata?.installFile.origin).toBe("portal");
|
|
247
|
+
expect(result.releaseId).toBe("release-2");
|
|
248
|
+
expect(result.publicationSessionId).toBe("session-2");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("createIngestionSession explains local file permission errors", async () => {
|
|
252
|
+
const readFileSpy = jest.spyOn(fs, "readFileSync").mockImplementation(() => {
|
|
253
|
+
const error = Object.assign(new Error("operation not permitted"), {
|
|
254
|
+
code: "EPERM",
|
|
255
|
+
});
|
|
256
|
+
throw error;
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const client = createPortalWorkflowClient({
|
|
260
|
+
apiBaseUrl: "https://portal.example.com/api",
|
|
261
|
+
apiKey: "portal-key",
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
await expect(
|
|
265
|
+
client.createIngestionSession({
|
|
266
|
+
source: {
|
|
267
|
+
kind: "apk-file",
|
|
268
|
+
filePath: "/Users/skumail/Downloads/app.apk",
|
|
269
|
+
},
|
|
270
|
+
whatsNew: "Local upload",
|
|
271
|
+
idempotencyKey: "idem-3",
|
|
272
|
+
})
|
|
273
|
+
).rejects.toThrow(
|
|
274
|
+
"Move the APK out of Downloads into your workspace or another accessible folder"
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
readFileSpy.mockRestore();
|
|
278
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { PublicationAttestationClient } from '@solana-mobile/dapp-store-publishing-tools';
|
|
2
|
+
|
|
3
|
+
import { callPortalProcedure } from './http.js';
|
|
4
|
+
import type { PortalClientConfig } from './types.js';
|
|
5
|
+
|
|
6
|
+
export function createPortalAttestationClient(
|
|
7
|
+
config: PortalClientConfig,
|
|
8
|
+
): PublicationAttestationClient {
|
|
9
|
+
return {
|
|
10
|
+
async getBlockData() {
|
|
11
|
+
return await callPortalProcedure<{ slot: number; blockhash: string }>(
|
|
12
|
+
config,
|
|
13
|
+
'attestation.getBlockData',
|
|
14
|
+
{},
|
|
15
|
+
'query',
|
|
16
|
+
);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export function ensureHttpsUrl(url: string): string {
|
|
2
|
+
const trimmed = url.trim();
|
|
3
|
+
|
|
4
|
+
if (!trimmed) {
|
|
5
|
+
return trimmed;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
if (trimmed.startsWith("https://")) {
|
|
9
|
+
return trimmed;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (trimmed.startsWith("http://")) {
|
|
13
|
+
return trimmed.replace(/^http:\/\//, "https://");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return `https://${trimmed}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function inferFileNameFromUrl(url: string): string {
|
|
20
|
+
try {
|
|
21
|
+
const pathname = new URL(url).pathname;
|
|
22
|
+
const basename = pathname.split("/").filter(Boolean).pop();
|
|
23
|
+
return basename || "app-release.apk";
|
|
24
|
+
} catch {
|
|
25
|
+
return "app-release.apk";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function inferFileExtension(fileName: string): string {
|
|
30
|
+
const extension = fileName.split(".").pop()?.trim().toLowerCase() || "apk";
|
|
31
|
+
return extension.replace(/[^a-z0-9]/g, "") || "apk";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function ensureApkFileName(fileName: string): string {
|
|
35
|
+
return /\.apk$/i.test(fileName) ? fileName : `${fileName}.apk`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function inferMimeType(fileName: string): string {
|
|
39
|
+
const extension = inferFileExtension(fileName);
|
|
40
|
+
switch (extension) {
|
|
41
|
+
case "apk":
|
|
42
|
+
return "application/vnd.android.package-archive";
|
|
43
|
+
case "json":
|
|
44
|
+
return "application/json";
|
|
45
|
+
case "png":
|
|
46
|
+
return "image/png";
|
|
47
|
+
case "jpg":
|
|
48
|
+
case "jpeg":
|
|
49
|
+
return "image/jpeg";
|
|
50
|
+
case "webp":
|
|
51
|
+
return "image/webp";
|
|
52
|
+
case "gif":
|
|
53
|
+
return "image/gif";
|
|
54
|
+
case "svg":
|
|
55
|
+
return "image/svg+xml";
|
|
56
|
+
case "mp4":
|
|
57
|
+
return "video/mp4";
|
|
58
|
+
case "webm":
|
|
59
|
+
return "video/webm";
|
|
60
|
+
case "mov":
|
|
61
|
+
return "video/quicktime";
|
|
62
|
+
default:
|
|
63
|
+
return "application/octet-stream";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function toBase64(buffer: Uint8Array): string {
|
|
68
|
+
return Buffer.from(buffer).toString("base64");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function fromBase64(value: string): Uint8Array {
|
|
72
|
+
return new Uint8Array(Buffer.from(value, "base64"));
|
|
73
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type PortalClientConfig,
|
|
3
|
+
type PortalProcedureResult,
|
|
4
|
+
} from './types.js';
|
|
5
|
+
import { isRecord, readDeep } from './records.js';
|
|
6
|
+
|
|
7
|
+
function unwrapPortalResult<T>(
|
|
8
|
+
result: PortalProcedureResult<T> | Record<string, unknown> | T,
|
|
9
|
+
fallbackMessage: string,
|
|
10
|
+
): T {
|
|
11
|
+
if (isRecord(result) && '_tag' in result) {
|
|
12
|
+
const tagged = result as PortalProcedureResult<T>;
|
|
13
|
+
if (tagged._tag === 'Left') {
|
|
14
|
+
throw new Error(tagged.left.message || fallbackMessage);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return tagged.right;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return result as T;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function callPortalProcedure<T>(
|
|
24
|
+
config: PortalClientConfig,
|
|
25
|
+
procedure: string,
|
|
26
|
+
input: unknown,
|
|
27
|
+
method: 'query' | 'mutation' = 'mutation',
|
|
28
|
+
): Promise<T> {
|
|
29
|
+
const baseUrl = config.apiBaseUrl.replace(/\/$/, '');
|
|
30
|
+
const url = new URL(`${baseUrl}/trpc/${procedure}`);
|
|
31
|
+
const headers: Record<string, string> = {
|
|
32
|
+
accept: 'application/json',
|
|
33
|
+
'x-api-key': config.apiKey,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let response: Response;
|
|
37
|
+
if (method === 'query') {
|
|
38
|
+
if (input !== undefined) {
|
|
39
|
+
url.searchParams.set('input', JSON.stringify(input));
|
|
40
|
+
}
|
|
41
|
+
response = await fetch(url, {
|
|
42
|
+
method: 'GET',
|
|
43
|
+
headers,
|
|
44
|
+
});
|
|
45
|
+
} else {
|
|
46
|
+
headers['content-type'] = 'application/json';
|
|
47
|
+
response = await fetch(url, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers,
|
|
50
|
+
body: input === undefined ? undefined : JSON.stringify(input),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const text = await response.text();
|
|
55
|
+
let payload: unknown;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
payload = text.length > 0 ? JSON.parse(text) : null;
|
|
59
|
+
} catch {
|
|
60
|
+
const preview =
|
|
61
|
+
text.replace(/\s+/g, ' ').trim().slice(0, 180) || '[empty]';
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Failed to parse portal response from ${procedure}: ${preview}`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const normalizedPayload =
|
|
68
|
+
isRecord(payload) && '0' in payload
|
|
69
|
+
? (payload as Record<string, unknown>)['0']
|
|
70
|
+
: payload;
|
|
71
|
+
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
if (isRecord(normalizedPayload)) {
|
|
74
|
+
const error = readDeep(normalizedPayload, 'error.message');
|
|
75
|
+
if (typeof error === 'string' && error.length > 0) {
|
|
76
|
+
throw new Error(`${procedure}: ${error}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const nested = readDeep(normalizedPayload, 'result.data');
|
|
80
|
+
if (isRecord(nested) && nested._tag === 'Left') {
|
|
81
|
+
const left = nested as Extract<
|
|
82
|
+
PortalProcedureResult<T>,
|
|
83
|
+
{ _tag: 'Left' }
|
|
84
|
+
>;
|
|
85
|
+
if (left.left.message) {
|
|
86
|
+
throw new Error(`${procedure}: ${left.left.message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
throw new Error(
|
|
92
|
+
`${procedure}: Portal request failed with status ${response.status}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const result =
|
|
97
|
+
readDeep(normalizedPayload, 'result.data') ??
|
|
98
|
+
readDeep(normalizedPayload, 'result');
|
|
99
|
+
return unwrapPortalResult(
|
|
100
|
+
result as PortalProcedureResult<T> | Record<string, unknown> | T,
|
|
101
|
+
`Portal request failed for ${procedure}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isRetryableCreateIngestionSessionError(error: unknown): boolean {
|
|
106
|
+
const message =
|
|
107
|
+
error instanceof Error
|
|
108
|
+
? error.message.toLowerCase()
|
|
109
|
+
: String(error).toLowerCase();
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
message.includes(
|
|
113
|
+
'failed to parse portal response from publication.createingestionsession',
|
|
114
|
+
) ||
|
|
115
|
+
message.includes('gateway timeout') ||
|
|
116
|
+
message.includes('bad gateway') ||
|
|
117
|
+
message.includes('service unavailable') ||
|
|
118
|
+
message.includes('unexpected token <')
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function callCreateIngestionSessionWithRetry(
|
|
123
|
+
config: PortalClientConfig,
|
|
124
|
+
input: Record<string, unknown>,
|
|
125
|
+
): Promise<Record<string, unknown>> {
|
|
126
|
+
let lastError: unknown;
|
|
127
|
+
|
|
128
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
129
|
+
try {
|
|
130
|
+
return await callPortalProcedure<Record<string, unknown>>(
|
|
131
|
+
config,
|
|
132
|
+
'publication.createIngestionSession',
|
|
133
|
+
input,
|
|
134
|
+
'mutation',
|
|
135
|
+
);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
lastError = error;
|
|
138
|
+
if (!isRetryableCreateIngestionSessionError(error) || attempt === 2) {
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
await new Promise((resolve) => setTimeout(resolve, 1500 * (attempt + 1)));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
throw lastError instanceof Error
|
|
147
|
+
? lastError
|
|
148
|
+
: new Error('Failed to create ingestion session');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function uploadBytes(
|
|
152
|
+
uploadUrl: string,
|
|
153
|
+
body: Uint8Array,
|
|
154
|
+
contentType: string,
|
|
155
|
+
): Promise<void> {
|
|
156
|
+
const response = await fetch(uploadUrl, {
|
|
157
|
+
method: 'PUT',
|
|
158
|
+
headers: {
|
|
159
|
+
'content-type': contentType,
|
|
160
|
+
},
|
|
161
|
+
body,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
if (!response.ok) {
|
|
165
|
+
const preview = (await response.text()).replace(/\s+/g, ' ').trim();
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Failed to upload file to the portal: ${preview || response.statusText}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
2
|
+
return typeof value === 'object' && value !== null;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function asRecord(
|
|
6
|
+
value: unknown,
|
|
7
|
+
): Record<string, unknown> | undefined {
|
|
8
|
+
return isRecord(value) ? value : undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function readDeep(value: unknown, propertyPath: string): unknown {
|
|
12
|
+
const parts = propertyPath.split('.');
|
|
13
|
+
let current: unknown = value;
|
|
14
|
+
|
|
15
|
+
for (const part of parts) {
|
|
16
|
+
if (!isRecord(current)) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
current = current[part];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return current;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function firstString(
|
|
27
|
+
value: unknown,
|
|
28
|
+
paths: string[],
|
|
29
|
+
): string | undefined {
|
|
30
|
+
for (const propertyPath of paths) {
|
|
31
|
+
const candidate = readDeep(value, propertyPath);
|
|
32
|
+
if (typeof candidate === 'string' && candidate.trim().length > 0) {
|
|
33
|
+
return candidate;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|