oss-client
Version:
Aliyun OSS(Object Storage Service) Node.js Client
448 lines (417 loc) • 12.6 kB
text/typescript
import { debuglog } from 'node:util';
import assert from 'node:assert';
import { createHash } from 'node:crypto';
import { extname } from 'node:path';
import { sendToWormhole } from 'stream-wormhole';
import { parseStringPromise } from 'xml2js';
import { encodeURIComponent as safeEncodeURIComponent } from 'utility';
import mime from 'mime';
import {
type RequestOptions,
type HttpClientResponse,
type IncomingHttpHeaders,
HttpClient,
} from 'urllib';
import ms from 'ms';
import {
authorization,
buildCanonicalString,
computeSignature,
} from './util/index.js';
import type {
OSSRequestParams,
OSSResult,
RequestParameters,
} from './type/Request.js';
import { OSSClientError } from './error/index.js';
const debug = debuglog('oss-client:client');
export interface OSSBaseClientInitOptions {
/** access key you create */
accessKeyId: string;
/** access secret you create */
accessKeySecret: string;
/**
* oss region domain. It takes priority over region.
* e.g.:
* - oss-cn-shanghai.aliyuncs.com
* - oss-cn-shanghai-internal.aliyuncs.com
*/
endpoint: string;
/** the bucket data region location, please see Data Regions, default is oss-cn-hangzhou. */
region?: string | undefined;
/** access OSS with aliyun internal network or not, default is false. If your servers are running on aliyun too, you can set true to save lot of money. */
internal?: boolean | undefined;
/** instance level timeout for all operations, default is 60s */
timeout?: number | string;
isRequestPay?: boolean;
}
export type OSSBaseClientOptions = Required<OSSBaseClientInitOptions> & {
timeout: number;
};
export abstract class OSSBaseClient {
readonly #httpClient = new HttpClient();
readonly #userAgent: string;
protected readonly options: OSSBaseClientOptions;
constructor(options: OSSBaseClientInitOptions) {
this.options = this.#initOptions(options);
this.#userAgent = this.#getUserAgent();
}
/** public methods */
/**
* get OSS signature
*/
signature(stringToSign: string) {
debug('authorization stringToSign: %s', stringToSign);
return computeSignature(this.options.accessKeySecret, stringToSign);
}
/** protected methods */
/**
* get author header
*
* "Authorization: OSS " + Access Key Id + ":" + Signature
*
* Signature = base64(hmac-sha1(Access Key Secret + "\n"
* + VERB + "\n"
* + CONTENT-MD5 + "\n"
* + CONTENT-TYPE + "\n"
* + DATE + "\n"
* + CanonicalizedOSSHeaders
* + CanonicalizedResource))
*/
protected authorization(
method: string,
resource: string,
headers: IncomingHttpHeaders,
subResource?: RequestParameters
) {
const stringToSign = buildCanonicalString(method.toUpperCase(), resource, {
headers,
parameters: subResource,
});
debug('stringToSign: %o', stringToSign);
const auth = authorization(
this.options.accessKeyId,
this.options.accessKeySecret,
stringToSign
);
debug('authorization: %o', auth);
return auth;
}
/**
* encodeURIComponent name except '/'
*/
protected escape(name: string) {
return safeEncodeURIComponent(name).replaceAll('%2F', '/');
}
protected abstract getRequestEndpoint(): string;
protected getRequestURL(
params: Pick<OSSRequestParams, 'object' | 'query' | 'subResource'>
) {
let resourcePath = '/';
if (params.object) {
// Preserve '/' in result url
resourcePath += this.escape(params.object).replaceAll('+', '%2B');
}
const urlObject = new URL(this.getRequestEndpoint());
urlObject.pathname = resourcePath;
if (params.query) {
const query = params.query as Record<string, string | number>;
for (const key in query) {
const value = query[key];
urlObject.searchParams.set(key, `${value}`);
}
}
if (params.subResource) {
let subresAsQuery: Record<string, string | number> = {};
if (typeof params.subResource === 'string') {
subresAsQuery[params.subResource] = '';
} else if (Array.isArray(params.subResource)) {
for (const k of params.subResource) {
subresAsQuery[k] = '';
}
} else {
subresAsQuery = params.subResource;
}
for (const key in subresAsQuery) {
urlObject.searchParams.set(key, `${subresAsQuery[key]}`);
}
}
return urlObject.toString();
}
getResource(params: { bucket?: string; object?: string }) {
let resource = '/';
if (params.bucket) resource += `${params.bucket}/`;
if (params.object) resource += params.object;
return resource;
}
createHttpClientRequestParams(params: OSSRequestParams) {
const headers: IncomingHttpHeaders = {
...params.headers,
// https://help.aliyun.com/zh/oss/developer-reference/include-signatures-in-the-authorization-header
// 此次操作的时间,Date必须为GMT格式,且不能为空。该值取自请求头的Date字段或者x-oss-date字段。当这两个字段同时存在时,以x-oss-date为准。
// e.g.: Sun, 22 Nov 2015 08:16:38 GMT
'x-oss-date': new Date().toUTCString(),
'user-agent': this.#userAgent,
};
if (this.options.isRequestPay) {
headers['x-oss-request-payer'] = 'requester';
}
if (!headers['content-type']) {
let contentType: string | null = null;
if (params.mime) {
if (params.mime.includes('/')) {
contentType = params.mime;
} else {
contentType = mime.getType(params.mime);
}
} else if (params.object) {
contentType = mime.getType(extname(params.object));
}
if (contentType) {
headers['content-type'] = contentType;
}
}
if (params.content) {
if (!params.disabledMD5 && !headers['content-md5']) {
headers['content-md5'] = createHash('md5')
.update(Buffer.from(params.content))
.digest('base64');
}
if (!headers['content-length']) {
headers['content-length'] = `${params.content.length}`;
}
}
const authResource = this.getResource(params);
headers.authorization = this.authorization(
params.method,
authResource,
headers,
params.subResource
);
const url = this.getRequestURL(params);
debug(
'request %s %s, with headers %j, !!stream: %s',
params.method,
url,
headers,
!!params.stream
);
const timeout = params.timeout ?? this.options.timeout;
const options: RequestOptions = {
method: params.method,
content: params.content,
stream: params.stream,
headers,
timeout,
writeStream: params.writeStream,
timing: true,
};
if (params.streaming) {
options.dataType = 'stream';
}
return { url, options };
}
/**
* request oss server
*/
// eslint-disable-next-line no-explicit-any
protected async request<T = any>(
params: OSSRequestParams
): Promise<OSSResult<T>> {
const { url, options } = this.createHttpClientRequestParams(params);
const result = await this.#httpClient.request<Buffer>(url, options);
debug(
'response %s %s, got %s, headers: %j',
params.method,
url,
result.status,
result.headers
);
if (!params.successStatuses?.includes(result.status)) {
const err = await this.#createClientException(result);
if (params.streaming && result.res) {
// consume the response stream
await sendToWormhole(result.res);
}
throw err;
}
let data = result.data as T;
if (params.xmlResponse) {
data = await this.#xml2json<T>(result.data);
}
return {
data,
res: result.res,
} satisfies OSSResult<T>;
}
/** private methods */
#initOptions(options: OSSBaseClientInitOptions) {
assert.ok(
options.accessKeyId && options.accessKeySecret,
'require accessKeyId and accessKeySecret'
);
assert.ok(options.endpoint, 'require endpoint');
let timeout = 60_000;
if (options.timeout) {
if (typeof options.timeout === 'string') {
timeout = ms(options.timeout);
} else {
timeout = options.timeout;
}
}
const initOptions = {
accessKeyId: options.accessKeyId.trim(),
accessKeySecret: options.accessKeySecret.trim(),
endpoint: options.endpoint,
region: options.region ?? 'oss-cn-hangzhou',
internal: options.internal ?? false,
isRequestPay: options.isRequestPay ?? false,
timeout,
} satisfies OSSBaseClientOptions;
return initOptions;
}
/**
* Get User-Agent for Node.js
* @example
* oss-client/2.0.0 Node.js/5.3.0 (darwin; arm64)
*/
#getUserAgent() {
// TODO: should read version from package.json
const sdk = 'oss-client/2.0.0';
const platform = `Node.js/${process.version.slice(1)} (${process.platform}; ${process.arch})`;
return `${sdk} ${platform}`;
}
// eslint-disable-next-line no-explicit-any
async #xml2json<T = any>(xml: string | Buffer) {
if (Buffer.isBuffer(xml)) {
xml = xml.toString();
}
debug('xml2json %o', xml);
return (await parseStringPromise(xml, {
explicitRoot: false,
explicitArray: false,
})) as T;
}
async #createClientException(result: HttpClientResponse<Buffer>) {
let err: OSSClientError;
let requestId = (result.headers['x-oss-request-id'] as string) ?? '';
let hostId = '';
const status = result.status;
if (!result.data || result.data.length === 0) {
// HEAD not exists resource
if (status === 404) {
err = new OSSClientError(
status,
'NoSuchKey',
'Object not exists',
requestId,
hostId
);
} else if (status === 412) {
err = new OSSClientError(
status,
'PreconditionFailed',
'Pre condition failed',
requestId,
hostId
);
} else {
err = new OSSClientError(
status,
'Unknown',
`Unknown error, status=${status}, raw error=${result}`,
requestId,
hostId
);
}
} else {
const xml = result.data.toString();
debug('request response error xml: %o', xml);
let info;
try {
info = await this.#xml2json(xml);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
err = new OSSClientError(
status,
'PreconditionFailed',
`${message} (raw xml=${JSON.stringify(xml)})`,
requestId,
hostId
);
return err;
}
let message =
info?.Message ??
`Unknown request error, status=${result.status}, raw xml=${JSON.stringify(xml)}`;
if (info?.Condition) {
message += ` (condition=${info.Condition})`;
}
if (info?.RequestId) {
requestId = info.RequestId;
}
if (info?.HostId) {
hostId = info.HostId;
}
err = new OSSClientError(
status,
info?.Code ?? 'Unknown',
message,
requestId,
hostId
);
// https://help.aliyun.com/zh/oss/support/http-status-code-409#section-rmc-hvd-j38
if (
info?.Code === 'PositionNotEqualToLength' &&
result.headers['x-oss-next-append-position']
) {
err.nextAppendPosition = result.headers[
'x-oss-next-append-position'
] as string;
}
}
debug('generate error %o', err);
return err;
}
}
// /**
// * Object operations
// */
// merge(proto, require('./common/object'));
// merge(proto, require('./object'));
// merge(proto, require('./common/image'));
// /**
// * Bucket operations
// */
// merge(proto, require('./common/bucket'));
// merge(proto, require('./bucket'));
// // multipart upload
// merge(proto, require('./managed-upload'));
// /**
// * RTMP operations
// */
// merge(proto, require('./rtmp'));
// /**
// * common multipart-copy
// */
// merge(proto, require('./common/multipart-copy'));
// /**
// * Common module parallel
// */
// merge(proto, require('./common/parallel'));
// /**
// * Multipart operations
// */
// merge(proto, require('./common/multipart'));
// /**
// * ImageClient class
// */
// Client.ImageClient = require('./image')(Client);
// /**
// * Cluster Client class
// */
// Client.ClusterClient = require('./cluster')(Client);
// /**
// * STS Client class
// */
// Client.STS = require('./sts');