Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/jsapi-nodejs/src/errorUtils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
import { STATUS_CODES } from 'node:http';

/** General Http Error class. */
export class HttpError extends Error {
constructor(
public statusCode: number,
message?: string
) {
super(message ?? STATUS_CODES[statusCode]);
this.name = 'HttpError';
}
}

/**
* Return true if given error has a code:string prop. Optionally check if the
* code matches a given value.
Expand Down
139 changes: 97 additions & 42 deletions packages/jsapi-nodejs/src/loaderUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,36 @@ import type { dh as DhType } from '@deephaven/jsapi-types';
import { downloadFromURL, urlToDirectoryName } from './serverUtils.js';
import { polyfillWs } from './polyfillWs.js';
import { ensureDirectoriesExist, getDownloadPaths } from './fsUtils.js';
import { HttpError } from './errorUtils.js';

type NonEmptyArray<T> = [T, ...T[]];

const DH_CORE_MODULE = 'jsapi/dh-core.js' as const;
const DH_INTERNAL_MODULE = 'jsapi/dh-internal.js' as const;

/** Transform downloaded content */
export type PostDownloadTransform = (
serverPath: string,
content: string
) => string;

export type PostDownloadErrorTransform = (
serverPath: string,
error: unknown
) => string;

export type LoadModuleOptions = {
serverUrl: URL;
serverPaths: NonEmptyArray<string>;
download: boolean | PostDownloadTransform;
storageDir: string;
targetModuleType: 'esm' | 'cjs';
};
} & (
| { download: false }
| {
download: true | PostDownloadTransform;
errorTransform?: PostDownloadErrorTransform;
}
);

/**
* Load a list of modules from a server.
Expand All @@ -32,38 +46,56 @@ export type LoadModuleOptions = {
* the modules will be downloaded and stored. If set to a `PostDownloadTransform`
* function, the downloaded content will be passed to the function and the result
* saved to disk.
* @param errorTransform Optional function to transform errors that occur during
* the download process. If not provided, errors will be thrown.
* @param storageDir The directory to store the downloaded modules.
* @param targetModuleType The type of module to load. Can be either 'esm' or 'cjs'.
* @returns The default export of the first module in `serverPaths`.
*/
export async function loadModules<TMainModule>({
serverUrl,
serverPaths,
download,
storageDir,
targetModuleType,
}: LoadModuleOptions): Promise<TMainModule> {
export async function loadModules<TMainModule>(
options: LoadModuleOptions
): Promise<TMainModule> {
polyfillWs();

const { serverUrl, serverPaths, storageDir, targetModuleType } = options;

const serverStorageDir = path.join(storageDir, urlToDirectoryName(serverUrl));

if (download !== false) {
if (options.download !== false) {
ensureDirectoriesExist([serverStorageDir]);

// Handle rejected Promise from download
const handleRejected = (reason: unknown, i: number): string => {
if (typeof options.errorTransform === 'function') {
return options.errorTransform(serverPaths[i], reason);
}

throw reason;
};

// Handle resolved Promise from download
const handleResolved = (value: string, i: number): string => {
if (typeof options.download === 'function') {
return options.download(serverPaths[i], value);
}

return value;
};

// Download from server
const serverUrls = serverPaths.map(
serverPath => new URL(serverPath, serverUrl)
);
let contents = await Promise.all(

const settledResults = await Promise.allSettled(
serverUrls.map(url => downloadFromURL(url))
);

// Post-download transform
if (typeof download === 'function') {
contents = contents.map((content, i) =>
download(serverPaths[i], content)
);
}
const contents: string[] = settledResults.map((result, i) =>
result.status === 'rejected'
? handleRejected(result.reason, i)
: handleResolved(result.value, i)
);

// Write to disk
const downloadPaths = getDownloadPaths(serverStorageDir, serverPaths);
Expand Down Expand Up @@ -111,37 +143,60 @@ export async function loadDhModules({
globalThis.window = globalThis;
}

// If target module type is `cjs`, we need to transform the downloaded content
// by replaing some ESM specific syntax with CJS syntax.
const cjsDownloadTransform: PostDownloadTransform = (
serverPath: string,
content: string
): string => {
if (serverPath === DH_CORE_MODULE) {
return content
.replace(
`import {dhinternal} from './dh-internal.js';`,
`const {dhinternal} = require("./dh-internal.js");`
)
.replace(`export default dh;`, `module.exports = dh;`);
}

if (serverPath === DH_INTERNAL_MODULE) {
return content.replace(
`export{__webpack_exports__dhinternal as dhinternal};`,
`module.exports={dhinternal:__webpack_exports__dhinternal};`
);
}

return content;
};

// `dh-internal.js` module is being removed from future versions of DH core,
// but there's not a great way for this library to know whether it's present
// or not. Treat 404s as empty content to make things compatible with both
// configurations.
const errorTransform: PostDownloadErrorTransform = (
serverPath: string,
error: unknown
): string => {
if (
serverPath === DH_INTERNAL_MODULE &&
error instanceof HttpError &&
error.statusCode === 404
) {
return '';
}

throw error;
};

const coreModule = await loadModules<
typeof DhType & { default?: typeof DhType }
>({
serverUrl,
serverPaths: ['jsapi/dh-core.js', 'jsapi/dh-internal.js'],
serverPaths: [DH_CORE_MODULE, DH_INTERNAL_MODULE],
storageDir,
targetModuleType,
download:
targetModuleType === 'esm'
? // ESM does not need any transformation since the server modules are already ESM.
true
: // CJS needs a post-download transform to convert the ESM modules to CJS.
(serverPath, content) => {
if (serverPath === 'jsapi/dh-core.js') {
return content
.replace(
`import {dhinternal} from './dh-internal.js';`,
`const {dhinternal} = require("./dh-internal.js");`
)
.replace(`export default dh;`, `module.exports = dh;`);
}

if (serverPath === 'jsapi/dh-internal.js') {
return content.replace(
`export{__webpack_exports__dhinternal as dhinternal};`,
`module.exports={dhinternal:__webpack_exports__dhinternal};`
);
}

return content;
},
// Download the module and transform it if the target module type is `cjs`.
download: targetModuleType === 'esm' ? true : cjsDownloadTransform,
errorTransform,
});

// ESM uses `default` export. CJS does not.
Expand Down
4 changes: 2 additions & 2 deletions packages/jsapi-nodejs/src/serverUtils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as http from 'node:http';
import * as https from 'node:https';
import { hasErrorCode, isAggregateError } from './errorUtils.js';
import { hasErrorCode, HttpError, isAggregateError } from './errorUtils.js';

export const SERVER_STATUS_CHECK_TIMEOUT = 3000;

Expand Down Expand Up @@ -46,7 +46,7 @@ export async function downloadFromURL(

res.on('end', async () => {
if (res.statusCode === 404) {
reject(new Error(`File not found: "${url}"`));
reject(new HttpError(404, `File not found: "${url}"`));
return;
}

Expand Down
Loading