@cognite/dune 0.3.7 → 0.4.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/_templates/app/new/root/app.json.ejs.t +3 -0
- package/_templates/app/new/root/package.json.ejs.t +3 -0
- package/_templates/app/new/src/App.tsx.ejs.t +70 -1
- package/_templates/app/new/src/main.tsx.ejs.t +6 -0
- package/bin/cli.js +21 -17
- package/bin/deploy-command.js +3 -14
- package/bin/deploy-interactive-command.js +41 -42
- package/bin/utils/fusion-url.js +28 -0
- package/bin/utils/fusion-url.test.js +50 -0
- package/dist/auth/index.d.ts +14 -1
- package/dist/auth/index.js +3 -1
- package/dist/{chunk-53VTKDSC.js → chunk-XWZCFZUH.js} +50 -0
- package/dist/cli/cli.js +63 -0
- package/dist/deploy/index.d.ts +41 -1
- package/dist/deploy/index.js +154 -22
- package/dist/index.d.ts +1 -1
- package/dist/index.js +3 -1
- package/dist/vite/index.js +10 -1
- package/package.json +23 -3
- package/src/auth/app-sdk-auth-provider.test.tsx +126 -0
- package/src/auth/app-sdk-auth-provider.tsx +69 -0
- package/src/auth/index.ts +1 -0
- package/src/deploy/apphosting-deployer.test.ts +177 -0
- package/src/deploy/apphosting-deployer.ts +166 -0
- package/src/deploy/deploy.ts +25 -20
- package/src/deploy/index.ts +1 -0
- package/src/deploy/types.ts +2 -0
- package/src/vite/fusion-open-plugin.ts +10 -1
package/dist/deploy/index.d.ts
CHANGED
|
@@ -17,10 +17,50 @@ type App = {
|
|
|
17
17
|
name: string;
|
|
18
18
|
description: string;
|
|
19
19
|
versionTag: string;
|
|
20
|
+
/** When set to "appsApi", deploy uses the App Hosting API instead of Files API */
|
|
21
|
+
infra?: "appsApi";
|
|
20
22
|
};
|
|
21
23
|
|
|
22
24
|
declare const deploy: (deployment: Deployment, app: App, folder: string) => Promise<void>;
|
|
23
25
|
|
|
26
|
+
/**
|
|
27
|
+
* App Hosting API Deployment
|
|
28
|
+
*
|
|
29
|
+
* Handles deployment of packaged applications via the new App Hosting API
|
|
30
|
+
* instead of CDF Files API + dataset.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
declare class AppHostingDeployer {
|
|
34
|
+
private client;
|
|
35
|
+
constructor(client: CogniteClient);
|
|
36
|
+
private get appsBasePath();
|
|
37
|
+
/**
|
|
38
|
+
* Ensure the app exists in the App Hosting service. Creates it if missing.
|
|
39
|
+
*/
|
|
40
|
+
ensureApp(externalId: string, name: string, description: string): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Upload a new version of the app as a zip file.
|
|
43
|
+
*
|
|
44
|
+
* `entryPath` must be a relative path (no leading slash). The App
|
|
45
|
+
* Hosting backend rejects absolute paths as a security measure against
|
|
46
|
+
* archive traversal, see `ZipCentralDirectory.normalizeEntryPath` in
|
|
47
|
+
* infrastructure/services/app-hosting.
|
|
48
|
+
*/
|
|
49
|
+
uploadVersion(appExternalId: string, version: string, zipPath: string, entryPath?: string): Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* Publish the version and set it as the ACTIVE alias.
|
|
52
|
+
*/
|
|
53
|
+
publishAndActivate(appExternalId: string, version: string): Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* Full deployment: ensure app exists, upload version, and optionally publish and activate.
|
|
56
|
+
*
|
|
57
|
+
* When `published` is false the version is uploaded in DRAFT state with no alias —
|
|
58
|
+
* it is stored but not served to end users. Pass `published: true` to transition the
|
|
59
|
+
* version to PUBLISHED and set it as the ACTIVE alias so it starts receiving traffic.
|
|
60
|
+
*/
|
|
61
|
+
deploy(appExternalId: string, name: string, description: string, versionTag: string, zipPath: string, published?: boolean): Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
|
|
24
64
|
/**
|
|
25
65
|
* CDF Application Deployment
|
|
26
66
|
*
|
|
@@ -95,4 +135,4 @@ declare const getSdk: (deployment: Deployment, folder: string) => Promise<Cognit
|
|
|
95
135
|
*/
|
|
96
136
|
declare const getToken: (deployment: Deployment) => Promise<string>;
|
|
97
137
|
|
|
98
|
-
export { type App, ApplicationPackager, CdfApplicationDeployer, type Deployment, deploy, getSdk, getToken };
|
|
138
|
+
export { type App, AppHostingDeployer, ApplicationPackager, CdfApplicationDeployer, type Deployment, deploy, getSdk, getToken };
|
package/dist/deploy/index.js
CHANGED
|
@@ -1,8 +1,131 @@
|
|
|
1
1
|
// src/deploy/deploy.ts
|
|
2
|
-
import
|
|
2
|
+
import fs4 from "fs";
|
|
3
3
|
|
|
4
|
-
// src/deploy/
|
|
4
|
+
// src/deploy/apphosting-deployer.ts
|
|
5
5
|
import fs from "fs";
|
|
6
|
+
import path from "path";
|
|
7
|
+
var AppHostingDeployer = class {
|
|
8
|
+
constructor(client) {
|
|
9
|
+
this.client = client;
|
|
10
|
+
}
|
|
11
|
+
get appsBasePath() {
|
|
12
|
+
return `/api/v1/projects/${encodeURIComponent(this.client.project)}/apphosting/apps`;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Ensure the app exists in the App Hosting service. Creates it if missing.
|
|
16
|
+
*/
|
|
17
|
+
async ensureApp(externalId, name, description) {
|
|
18
|
+
console.log("\u{1F50D} Ensuring app exists...");
|
|
19
|
+
try {
|
|
20
|
+
await this.client.post(this.appsBasePath, {
|
|
21
|
+
data: { items: [{ externalId, name, description }] }
|
|
22
|
+
});
|
|
23
|
+
console.log(`\u2705 App '${externalId}' created`);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error instanceof Error && "status" in error && error.status === 409) {
|
|
26
|
+
console.log(`\u2705 App '${externalId}' already exists`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Upload a new version of the app as a zip file.
|
|
34
|
+
*
|
|
35
|
+
* `entryPath` must be a relative path (no leading slash). The App
|
|
36
|
+
* Hosting backend rejects absolute paths as a security measure against
|
|
37
|
+
* archive traversal, see `ZipCentralDirectory.normalizeEntryPath` in
|
|
38
|
+
* infrastructure/services/app-hosting.
|
|
39
|
+
*/
|
|
40
|
+
async uploadVersion(appExternalId, version, zipPath, entryPath = "index.html") {
|
|
41
|
+
console.log(`\u{1F4E4} Uploading version ${version}...`);
|
|
42
|
+
const fileBuffer = fs.readFileSync(zipPath);
|
|
43
|
+
const fileName = path.basename(zipPath);
|
|
44
|
+
const formData = new FormData();
|
|
45
|
+
formData.append("file", new Blob([fileBuffer]), fileName);
|
|
46
|
+
formData.append("version", version);
|
|
47
|
+
formData.append("entryPath", entryPath);
|
|
48
|
+
const encodedAppExternalId = encodeURIComponent(appExternalId);
|
|
49
|
+
const uploadPath = `${this.appsBasePath}/${encodedAppExternalId}/versions`;
|
|
50
|
+
const token = await this.client.authenticate();
|
|
51
|
+
const url = `${this.client.getBaseUrl()}${uploadPath}`;
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
const timeout = setTimeout(() => controller.abort(), 5 * 60 * 1e3);
|
|
54
|
+
let response;
|
|
55
|
+
try {
|
|
56
|
+
response = await fetch(url, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
59
|
+
body: formData,
|
|
60
|
+
signal: controller.signal
|
|
61
|
+
});
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
64
|
+
throw new Error("Upload timed out after 5 minutes");
|
|
65
|
+
}
|
|
66
|
+
throw error;
|
|
67
|
+
} finally {
|
|
68
|
+
clearTimeout(timeout);
|
|
69
|
+
}
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
const body = await response.text();
|
|
72
|
+
let message = body;
|
|
73
|
+
try {
|
|
74
|
+
const json = JSON.parse(body);
|
|
75
|
+
message = json.message ?? json.error ?? body;
|
|
76
|
+
} catch {
|
|
77
|
+
}
|
|
78
|
+
throw new Error(`Upload failed: ${response.status} \u2014 ${message}`);
|
|
79
|
+
}
|
|
80
|
+
console.log(`\u2705 Version ${version} uploaded`);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Publish the version and set it as the ACTIVE alias.
|
|
84
|
+
*/
|
|
85
|
+
async publishAndActivate(appExternalId, version) {
|
|
86
|
+
console.log(`\u{1F680} Publishing and activating version ${version}...`);
|
|
87
|
+
const encodedAppExternalId = encodeURIComponent(appExternalId);
|
|
88
|
+
const url = `${this.appsBasePath}/${encodedAppExternalId}/versions/update`;
|
|
89
|
+
await this.client.post(url, {
|
|
90
|
+
data: {
|
|
91
|
+
items: [
|
|
92
|
+
{
|
|
93
|
+
version,
|
|
94
|
+
update: {
|
|
95
|
+
lifecycleState: { set: "PUBLISHED" },
|
|
96
|
+
alias: { set: "ACTIVE" }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
]
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
console.log(`\u2705 Version ${version} is now PUBLISHED and ACTIVE`);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Full deployment: ensure app exists, upload version, and optionally publish and activate.
|
|
106
|
+
*
|
|
107
|
+
* When `published` is false the version is uploaded in DRAFT state with no alias —
|
|
108
|
+
* it is stored but not served to end users. Pass `published: true` to transition the
|
|
109
|
+
* version to PUBLISHED and set it as the ACTIVE alias so it starts receiving traffic.
|
|
110
|
+
*/
|
|
111
|
+
async deploy(appExternalId, name, description, versionTag, zipPath, published = false) {
|
|
112
|
+
console.log("\n\u{1F680} Deploying application via App Hosting API...\n");
|
|
113
|
+
try {
|
|
114
|
+
await this.ensureApp(appExternalId, name, description);
|
|
115
|
+
await this.uploadVersion(appExternalId, versionTag, zipPath);
|
|
116
|
+
if (published) {
|
|
117
|
+
await this.publishAndActivate(appExternalId, versionTag);
|
|
118
|
+
}
|
|
119
|
+
console.log("\n\u2705 Deployment successful!");
|
|
120
|
+
} catch (error) {
|
|
121
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
122
|
+
throw Object.assign(new Error(`Deployment failed: ${message}`), { cause: error });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// src/deploy/application-deployer.ts
|
|
128
|
+
import fs2 from "fs";
|
|
6
129
|
var CdfApplicationDeployer = class {
|
|
7
130
|
/**
|
|
8
131
|
* @param {CogniteClient} client - Cognite SDK client
|
|
@@ -56,7 +179,7 @@ var CdfApplicationDeployer = class {
|
|
|
56
179
|
console.log(`\u2705 Data set '${this.DATA_SET_EXTERNAL_ID}' validated (ID: ${dataSetId})
|
|
57
180
|
`);
|
|
58
181
|
console.log("\u{1F4C1} Creating file record...");
|
|
59
|
-
const fileContent =
|
|
182
|
+
const fileContent = fs2.readFileSync(zipFilename);
|
|
60
183
|
const metadata = {
|
|
61
184
|
published: String(published),
|
|
62
185
|
name,
|
|
@@ -109,22 +232,22 @@ var CdfApplicationDeployer = class {
|
|
|
109
232
|
};
|
|
110
233
|
|
|
111
234
|
// src/deploy/application-packager.ts
|
|
112
|
-
import
|
|
113
|
-
import
|
|
235
|
+
import fs3 from "fs";
|
|
236
|
+
import path2 from "path";
|
|
114
237
|
import archiver from "archiver";
|
|
115
238
|
var ApplicationPackager = class {
|
|
116
239
|
/**
|
|
117
240
|
* @param {string} distDirectory - Build directory to package (can be relative or absolute)
|
|
118
241
|
*/
|
|
119
242
|
constructor(distDirectory = "dist") {
|
|
120
|
-
this.distPath =
|
|
243
|
+
this.distPath = path2.isAbsolute(distDirectory) ? distDirectory : path2.join(process.cwd(), distDirectory);
|
|
121
244
|
}
|
|
122
245
|
/**
|
|
123
246
|
* Validate that build directory exists
|
|
124
247
|
* @throws {Error} If build directory not found
|
|
125
248
|
*/
|
|
126
249
|
validateBuildDirectory() {
|
|
127
|
-
if (!
|
|
250
|
+
if (!fs3.existsSync(this.distPath)) {
|
|
128
251
|
throw new Error(`Build directory "${this.distPath}" not found. Run build first.`);
|
|
129
252
|
}
|
|
130
253
|
}
|
|
@@ -138,7 +261,7 @@ var ApplicationPackager = class {
|
|
|
138
261
|
this.validateBuildDirectory();
|
|
139
262
|
console.log("\u{1F4E6} Packaging application...");
|
|
140
263
|
return new Promise((resolve, reject) => {
|
|
141
|
-
const output =
|
|
264
|
+
const output = fs3.createWriteStream(outputFilename);
|
|
142
265
|
const archive = archiver("zip", {
|
|
143
266
|
zlib: { level: 9 }
|
|
144
267
|
// Maximum compression
|
|
@@ -316,26 +439,35 @@ var getSdk = async (deployment, folder) => {
|
|
|
316
439
|
};
|
|
317
440
|
|
|
318
441
|
// src/deploy/deploy.ts
|
|
319
|
-
|
|
442
|
+
async function deployViaAppHosting(deployment, app, folder, zipFilename) {
|
|
443
|
+
const { externalId, name, description, versionTag } = app;
|
|
444
|
+
const sdk = await getSdk(deployment, folder);
|
|
445
|
+
const deployer = new AppHostingDeployer(sdk);
|
|
446
|
+
await deployer.deploy(externalId, name, description, versionTag, zipFilename, deployment.published);
|
|
447
|
+
}
|
|
448
|
+
async function deployViaCdf(deployment, app, folder, zipFilename) {
|
|
449
|
+
const { externalId, name, description, versionTag } = app;
|
|
320
450
|
const sdk = await getSdk(deployment, folder);
|
|
321
|
-
const distPath = `${folder}/dist`;
|
|
322
|
-
const packager = new ApplicationPackager(distPath);
|
|
323
|
-
const zipFilename = await packager.createZip("app.zip", true);
|
|
324
451
|
const deployer = new CdfApplicationDeployer(sdk);
|
|
325
|
-
await deployer.deploy(
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
app.versionTag,
|
|
330
|
-
zipFilename,
|
|
331
|
-
deployment.published
|
|
332
|
-
);
|
|
452
|
+
await deployer.deploy(externalId, name, description, versionTag, zipFilename, deployment.published);
|
|
453
|
+
}
|
|
454
|
+
var deploy = async (deployment, app, folder) => {
|
|
455
|
+
const zipFilename = await new ApplicationPackager(`${folder}/dist`).createZip("app.zip", true);
|
|
333
456
|
try {
|
|
334
|
-
|
|
335
|
-
|
|
457
|
+
if (app.infra === "appsApi") {
|
|
458
|
+
await deployViaAppHosting(deployment, app, folder, zipFilename);
|
|
459
|
+
} else {
|
|
460
|
+
await deployViaCdf(deployment, app, folder, zipFilename);
|
|
461
|
+
}
|
|
462
|
+
} finally {
|
|
463
|
+
try {
|
|
464
|
+
fs4.unlinkSync(zipFilename);
|
|
465
|
+
} catch {
|
|
466
|
+
}
|
|
336
467
|
}
|
|
337
468
|
};
|
|
338
469
|
export {
|
|
470
|
+
AppHostingDeployer,
|
|
339
471
|
ApplicationPackager,
|
|
340
472
|
CdfApplicationDeployer,
|
|
341
473
|
deploy,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { CDFConfig, DuneAuthProvider, DuneAuthProviderContext, DuneAuthProviderProps, DuneContextValue, EMPTY_SDK, createCDFSDK, getToken, useDune } from './auth/index.js';
|
|
1
|
+
export { AppSdkAuthProvider, CDFConfig, DuneAuthProvider, DuneAuthProviderContext, DuneAuthProviderProps, DuneContextValue, EMPTY_SDK, createCDFSDK, getToken, useDune } from './auth/index.js';
|
|
2
2
|
import 'react/jsx-runtime';
|
|
3
3
|
import 'react';
|
|
4
4
|
import '@cognite/sdk';
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
|
+
AppSdkAuthProvider,
|
|
2
3
|
DuneAuthProvider,
|
|
3
4
|
DuneAuthProviderContext,
|
|
4
5
|
EMPTY_SDK,
|
|
5
6
|
createCDFSDK,
|
|
6
7
|
getToken,
|
|
7
8
|
useDune
|
|
8
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-XWZCFZUH.js";
|
|
9
10
|
export {
|
|
11
|
+
AppSdkAuthProvider,
|
|
10
12
|
DuneAuthProvider,
|
|
11
13
|
DuneAuthProviderContext,
|
|
12
14
|
EMPTY_SDK,
|
package/dist/vite/index.js
CHANGED
|
@@ -25,7 +25,16 @@ var fusionOpenPlugin = () => {
|
|
|
25
25
|
const { org, project, baseUrl } = firstDeployment || {};
|
|
26
26
|
const parsedBaseUrl = baseUrl?.split("//")[1];
|
|
27
27
|
if (org && project && baseUrl) {
|
|
28
|
-
|
|
28
|
+
let fusionUrl;
|
|
29
|
+
if (appJson.infra === "appsApi") {
|
|
30
|
+
if (!appJson.externalId) {
|
|
31
|
+
console.warn(" \u279C app.json is missing externalId \u2014 cannot determine Fusion URL");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
fusionUrl = `https://${org}.fusion.cognite.com/${project}/dune-app-host/development/${appJson.externalId}/${port}?cluster=${parsedBaseUrl}`;
|
|
35
|
+
} else {
|
|
36
|
+
fusionUrl = `https://${org}.fusion.cognite.com/${project}/streamlit-apps/dune/development/${port}?cluster=${parsedBaseUrl}&workspace=industrial-tools`;
|
|
37
|
+
}
|
|
29
38
|
console.log(` \u279C Fusion: ${fusionUrl}`);
|
|
30
39
|
openUrl(fusionUrl);
|
|
31
40
|
return;
|
package/package.json
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cognite/dune",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Build and deploy React apps to Cognite Data Fusion",
|
|
5
|
-
"keywords": [
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cognite",
|
|
7
|
+
"dune",
|
|
8
|
+
"cdf",
|
|
9
|
+
"fusion",
|
|
10
|
+
"react",
|
|
11
|
+
"scaffold",
|
|
12
|
+
"deploy"
|
|
13
|
+
],
|
|
6
14
|
"license": "Apache-2.0",
|
|
7
15
|
"author": "Cognite",
|
|
8
16
|
"repository": {
|
|
@@ -41,7 +49,17 @@
|
|
|
41
49
|
"bin": {
|
|
42
50
|
"dune": "./bin/cli.js"
|
|
43
51
|
},
|
|
44
|
-
"files": [
|
|
52
|
+
"files": [
|
|
53
|
+
"bin/cli.js",
|
|
54
|
+
"bin/deploy-command.js",
|
|
55
|
+
"bin/deploy-interactive-command.js",
|
|
56
|
+
"bin/skills-command.js",
|
|
57
|
+
"bin/auth",
|
|
58
|
+
"bin/utils",
|
|
59
|
+
"dist",
|
|
60
|
+
"src",
|
|
61
|
+
"_templates"
|
|
62
|
+
],
|
|
45
63
|
"scripts": {
|
|
46
64
|
"build": "tsup",
|
|
47
65
|
"prepare": "tsup",
|
|
@@ -51,7 +69,9 @@
|
|
|
51
69
|
"test:watch": "vitest"
|
|
52
70
|
},
|
|
53
71
|
"dependencies": {
|
|
72
|
+
"@cognite/app-sdk": "^0.3.0",
|
|
54
73
|
"archiver": "^7.0.1",
|
|
74
|
+
"commander": "^14.0.3",
|
|
55
75
|
"enquirer": "^2.4.1",
|
|
56
76
|
"execa": "^5.1.1",
|
|
57
77
|
"hygen": "^6.2.11",
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import { useContext } from 'react';
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { AppSdkAuthProvider } from './app-sdk-auth-provider';
|
|
7
|
+
import { DuneAuthProviderContext, type DuneContextValue } from './dune-auth-provider';
|
|
8
|
+
|
|
9
|
+
const mockConnectToHostApp = vi.hoisted(() => vi.fn());
|
|
10
|
+
const MockCogniteClient = vi.hoisted(() => vi.fn());
|
|
11
|
+
|
|
12
|
+
vi.mock(import('@cognite/app-sdk'), () => ({
|
|
13
|
+
connectToHostApp: mockConnectToHostApp,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock(import('@cognite/sdk'), async (importOriginal) => {
|
|
17
|
+
const actual = await importOriginal();
|
|
18
|
+
return {
|
|
19
|
+
...actual,
|
|
20
|
+
CogniteClient: MockCogniteClient as Partial<typeof actual.CogniteClient> as typeof actual.CogniteClient,
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function ContextCapture({ onValue }: { onValue: (v: DuneContextValue) => void }) {
|
|
25
|
+
const value = useContext(DuneAuthProviderContext);
|
|
26
|
+
onValue(value);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type ApiMock = {
|
|
31
|
+
getProject: () => Promise<string>;
|
|
32
|
+
getBaseUrl: () => Promise<string>;
|
|
33
|
+
getAccessToken: () => Promise<string>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function makeApi(overrides?: Partial<ApiMock>): ApiMock {
|
|
37
|
+
return {
|
|
38
|
+
getProject: vi.fn(async () => 'my-project'),
|
|
39
|
+
getBaseUrl: vi.fn(async () => 'https://cognite.test'),
|
|
40
|
+
getAccessToken: vi.fn(async () => 'token-abc'),
|
|
41
|
+
...overrides,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe(AppSdkAuthProvider.name, () => {
|
|
46
|
+
const mockSdkInstance = { authenticate: vi.fn(async () => undefined) };
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
vi.clearAllMocks();
|
|
50
|
+
vi.mocked(MockCogniteClient).mockReturnValue(mockSdkInstance);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('renders the loadingComponent while the handshake is in progress', () => {
|
|
54
|
+
// Arrange
|
|
55
|
+
vi.mocked(mockConnectToHostApp).mockReturnValue(new Promise(() => {})); // never resolves
|
|
56
|
+
|
|
57
|
+
// Act
|
|
58
|
+
render(
|
|
59
|
+
<AppSdkAuthProvider loadingComponent={<div>Connecting…</div>}>
|
|
60
|
+
<div>content</div>
|
|
61
|
+
</AppSdkAuthProvider>
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// Assert
|
|
65
|
+
expect(screen.getByText('Connecting…')).toBeDefined();
|
|
66
|
+
expect(screen.queryByText('content')).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('exposes the configured sdk and clears loading on successful connection', async () => {
|
|
70
|
+
// Arrange
|
|
71
|
+
const api = makeApi();
|
|
72
|
+
vi.mocked(mockConnectToHostApp).mockResolvedValue({ api });
|
|
73
|
+
const captured: DuneContextValue[] = [];
|
|
74
|
+
|
|
75
|
+
// Act — loadingComponent=null so children render even during loading
|
|
76
|
+
render(
|
|
77
|
+
<AppSdkAuthProvider loadingComponent={null}>
|
|
78
|
+
<ContextCapture onValue={(v) => captured.push({ ...v })} />
|
|
79
|
+
</AppSdkAuthProvider>
|
|
80
|
+
);
|
|
81
|
+
await waitFor(() => expect(captured[captured.length - 1]?.isLoading).toBe(false));
|
|
82
|
+
|
|
83
|
+
// Assert
|
|
84
|
+
expect(captured[captured.length - 1]).toMatchObject({ isLoading: false, error: undefined, sdk: mockSdkInstance });
|
|
85
|
+
expect(MockCogniteClient).toHaveBeenCalledWith(
|
|
86
|
+
expect.objectContaining({
|
|
87
|
+
appId: 'dune-app',
|
|
88
|
+
project: 'my-project',
|
|
89
|
+
baseUrl: 'https://cognite.test',
|
|
90
|
+
oidcTokenProvider: expect.any(Function),
|
|
91
|
+
})
|
|
92
|
+
);
|
|
93
|
+
expect(mockSdkInstance.authenticate).toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('renders the errorComponent when connectToHostApp rejects with an Error', async () => {
|
|
97
|
+
// Arrange
|
|
98
|
+
vi.mocked(mockConnectToHostApp).mockRejectedValue(new Error('Handshake timed out'));
|
|
99
|
+
|
|
100
|
+
// Act
|
|
101
|
+
render(
|
|
102
|
+
<AppSdkAuthProvider errorComponent={(msg) => <div>Error: {msg}</div>}>
|
|
103
|
+
<div>content</div>
|
|
104
|
+
</AppSdkAuthProvider>
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// Assert
|
|
108
|
+
await waitFor(() => expect(screen.getByText('Error: Handshake timed out')).toBeDefined());
|
|
109
|
+
expect(screen.queryByText('content')).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('coerces non-Error rejections to a string in the errorComponent', async () => {
|
|
113
|
+
// Arrange
|
|
114
|
+
vi.mocked(mockConnectToHostApp).mockRejectedValue('network timeout');
|
|
115
|
+
|
|
116
|
+
// Act
|
|
117
|
+
render(
|
|
118
|
+
<AppSdkAuthProvider errorComponent={(msg) => <div>Error: {msg}</div>}>
|
|
119
|
+
<div>content</div>
|
|
120
|
+
</AppSdkAuthProvider>
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// Assert
|
|
124
|
+
await waitFor(() => expect(screen.getByText('Error: network timeout')).toBeDefined());
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { connectToHostApp } from '@cognite/app-sdk';
|
|
2
|
+
import { CogniteClient } from '@cognite/sdk';
|
|
3
|
+
import { useEffect, useState, type ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
import { DuneAuthProviderContext } from './dune-auth-provider';
|
|
6
|
+
import { EMPTY_SDK } from './utils';
|
|
7
|
+
|
|
8
|
+
interface AppSdkAuthProviderProps {
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
loadingComponent?: ReactNode;
|
|
11
|
+
errorComponent?: (error: string) => ReactNode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Auth provider for apps running in the new Fusion app host (dune-app-host).
|
|
16
|
+
* Uses @cognite/app-sdk's Comlink handshake to connect to the host and obtain
|
|
17
|
+
* tokens on-demand. Exposes the same DuneContextValue ({ sdk, isLoading,
|
|
18
|
+
* error }) as DuneAuthProvider so useDune() works unchanged.
|
|
19
|
+
*/
|
|
20
|
+
export const AppSdkAuthProvider = ({
|
|
21
|
+
children,
|
|
22
|
+
loadingComponent = <div>Loading CDF authentication...</div>,
|
|
23
|
+
errorComponent = (error: string) => <div>Authentication error: {error}</div>,
|
|
24
|
+
}: AppSdkAuthProviderProps) => {
|
|
25
|
+
const [sdk, setSdk] = useState<CogniteClient>(EMPTY_SDK);
|
|
26
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
27
|
+
const [error, setError] = useState<string | undefined>();
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
connectToHostApp()
|
|
31
|
+
.then(async ({ api }) => {
|
|
32
|
+
const [project, baseUrl] = await Promise.all([
|
|
33
|
+
api.getProject(),
|
|
34
|
+
api.getBaseUrl(),
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const client = new CogniteClient({
|
|
38
|
+
appId: 'dune-app',
|
|
39
|
+
project,
|
|
40
|
+
baseUrl,
|
|
41
|
+
oidcTokenProvider: async () => api.getAccessToken(),
|
|
42
|
+
});
|
|
43
|
+
await client.authenticate();
|
|
44
|
+
setSdk(client);
|
|
45
|
+
setError(undefined);
|
|
46
|
+
})
|
|
47
|
+
.catch((err: unknown) => {
|
|
48
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
49
|
+
setError(message);
|
|
50
|
+
})
|
|
51
|
+
.finally(() => {
|
|
52
|
+
setIsLoading(false);
|
|
53
|
+
});
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
if (error && errorComponent) {
|
|
57
|
+
return <>{errorComponent(error)}</>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (isLoading && loadingComponent) {
|
|
61
|
+
return <>{loadingComponent}</>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<DuneAuthProviderContext.Provider value={{ sdk, isLoading, error }}>
|
|
66
|
+
{children}
|
|
67
|
+
</DuneAuthProviderContext.Provider>
|
|
68
|
+
);
|
|
69
|
+
};
|