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
5 changes: 5 additions & 0 deletions .changeset/tasty-rabbits-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@reflag/node-sdk": patch
---

Replace the built-in GCS fallback provider's default client dependency with `@googleapis/storage`, removing the deprecated `@google-cloud/storage` dependency and its vulnerable transitive request stack.
2 changes: 1 addition & 1 deletion packages/node-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.888.0",
"@google-cloud/storage": "^7.19.0",
"@googleapis/storage": "21.2.0",
"@redis/client": "^5.11.0",
"@reflag/flag-evaluation": "1.0.0"
},
Expand Down
220 changes: 192 additions & 28 deletions packages/node-sdk/src/flagsFallbackProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,44 @@ export type S3FallbackProviderOptions = {
keyPrefix?: string;
};

export type GCSLegacyClient = {
bucket(name: string): {
file(path: string): {
exists(): Promise<[boolean]>;
download(): Promise<[Uint8Array]>;
save(body: string, options: { contentType: string }): Promise<unknown>;
};
};
};

export type GCSGoogleApisClient = {
objects: {
get(
params: {
bucket: string;
object: string;
alt?: string;
},
options?: {
responseType?: "arraybuffer";
},
): Promise<{
data: unknown;
}>;
insert(params: {
bucket: string;
name: string;
uploadType: "media";
media: {
mimeType: string;
body: string;
};
}): Promise<unknown>;
};
};

export type GCSFallbackProviderClient = GCSLegacyClient | GCSGoogleApisClient;

export type GCSFallbackProviderOptions = {
/**
* Bucket where snapshots are stored.
Expand All @@ -47,16 +85,13 @@ export type GCSFallbackProviderOptions = {

/**
* Optional GCS client. A default client is created when omitted.
*
* Accepts either a legacy `bucket().file()` client or a generated
* `@googleapis/storage` client.
*
* TODO(next major): Replace this with a simpler object-store interface.
*/
client?: {
bucket(name: string): {
file(path: string): {
exists(): Promise<[boolean]>;
download(): Promise<[Uint8Array]>;
save(body: string, options: { contentType: string }): Promise<unknown>;
};
};
};
client?: GCSFallbackProviderClient;

/**
* Prefix for generated per-environment keys.
Expand All @@ -66,6 +101,17 @@ export type GCSFallbackProviderOptions = {
keyPrefix?: string;
};

type GCSObjectStore = {
exists(bucket: string, objectPath: string): Promise<boolean>;
download(bucket: string, objectPath: string): Promise<Uint8Array>;
save(
bucket: string,
objectPath: string,
body: string,
options: { contentType: string },
): Promise<unknown>;
};

export type RedisFallbackProviderOptions = {
/**
* Optional Redis client. When omitted, a client is created using `REDIS_URL`.
Expand Down Expand Up @@ -153,6 +199,12 @@ function isFlagApiResponse(value: unknown): value is FlagAPIResponse {
async function readBodyAsString(body: unknown) {
if (typeof body === "string") return body;
if (body instanceof Uint8Array) return Buffer.from(body).toString("utf-8");
if (body instanceof ArrayBuffer) return Buffer.from(body).toString("utf-8");
if (ArrayBuffer.isView(body)) {
return Buffer.from(body.buffer, body.byteOffset, body.byteLength).toString(
"utf-8",
);
}
if (body && typeof body === "object") {
if (
"transformToString" in body &&
Expand All @@ -169,6 +221,15 @@ function parseSnapshot(raw: string) {
return isFlagsFallbackSnapshot(parsed) ? parsed : undefined;
}

function isNotFoundError(error: any) {
return (
error?.code === 404 ||
error?.status === 404 ||
error?.response?.status === 404 ||
error?.$metadata?.httpStatusCode === 404
);
}

function staticFlagApiResponse(
key: string,
isEnabled: boolean,
Expand All @@ -195,9 +256,113 @@ async function createDefaultS3Client() {
return new S3Client({});
}

async function createDefaultGCSClient() {
const { Storage } = await import("@google-cloud/storage");
return new Storage();
function createLegacyGCSObjectStore(client: GCSLegacyClient): GCSObjectStore {
return {
async exists(bucket, objectPath) {
const [exists] = await client.bucket(bucket).file(objectPath).exists();
return exists;
},

async download(bucket, objectPath) {
const [contents] = await client
.bucket(bucket)
.file(objectPath)
.download();
return contents;
},

async save(bucket, objectPath, body, options) {
return client.bucket(bucket).file(objectPath).save(body, options);
},
};
}

function createGoogleApisGCSObjectStore(
client: GCSGoogleApisClient,
): GCSObjectStore {
return {
async exists(bucket, objectPath) {
try {
await client.objects.get({
bucket,
object: objectPath,
});
return true;
} catch (error) {
if (isNotFoundError(error)) {
return false;
}
throw error;
}
},

async download(bucket, objectPath) {
const response = await client.objects.get(
{
bucket,
object: objectPath,
alt: "media",
},
{
responseType: "arraybuffer",
},
);

if (response.data instanceof Uint8Array) {
return response.data;
}
if (response.data instanceof ArrayBuffer) {
return new Uint8Array(response.data);
}

throw new TypeError("Unexpected GCS download response body format");
},

async save(bucket, objectPath, body, options) {
await client.objects.insert({
bucket,
name: objectPath,
uploadType: "media",
media: {
mimeType: options.contentType,
body,
},
});
},
};
}

function isGoogleApisGCSClient(
client: GCSFallbackProviderClient,
): client is GCSGoogleApisClient {
return (
"objects" in client &&
isObject(client.objects) &&
typeof client.objects.get === "function" &&
typeof client.objects.insert === "function"
);
}

function createGCSObjectStore(
client: GCSFallbackProviderClient,
): GCSObjectStore {
if (isGoogleApisGCSClient(client)) {
return createGoogleApisGCSObjectStore(client);
}

return createLegacyGCSObjectStore(client);
}

async function createDefaultGCSObjectStore(): Promise<GCSObjectStore> {
const { auth, storage } = await import("@googleapis/storage");
return createGoogleApisGCSObjectStore(
storage({
version: "v1",
auth: new auth.GoogleAuth({
scopes: ["https://www.googleapis.com/auth/devstorage.read_write"],
}),
}),
);
}

export function createStaticFallbackProvider({
Expand Down Expand Up @@ -281,10 +446,7 @@ export function createS3FallbackProvider({

return parseSnapshot(body);
} catch (error: any) {
if (
error?.name === "NoSuchKey" ||
error?.$metadata?.httpStatusCode === 404
) {
if (error?.name === "NoSuchKey" || isNotFoundError(error)) {
return undefined;
}
throw error;
Expand Down Expand Up @@ -312,36 +474,38 @@ export function createGCSFallbackProvider({
client,
keyPrefix,
}: GCSFallbackProviderOptions): FlagsFallbackProvider {
let defaultClient: GCSFallbackProviderOptions["client"] | undefined;
let defaultClient: GCSObjectStore | undefined;

const getClient = async () => {
defaultClient ??= client ?? (await createDefaultGCSClient());
defaultClient ??= client
? createGCSObjectStore(client)
: await createDefaultGCSObjectStore();
return defaultClient;
};

return {
async load(context) {
const storage = await getClient();
const file = storage
.bucket(bucket)
.file(snapshotObjectKey(context, keyPrefix));
const [exists] = await file.exists();
const objectKey = snapshotObjectKey(context, keyPrefix);
const exists = await storage.exists(bucket, objectKey);
if (!exists) {
return undefined;
}

const [contents] = await file.download();
const contents = await storage.download(bucket, objectKey);
return parseSnapshot(Buffer.from(contents).toString("utf-8"));
},

async save(context, snapshot) {
const storage = await getClient();
await storage
.bucket(bucket)
.file(snapshotObjectKey(context, keyPrefix))
.save(JSON.stringify(snapshot), {
await storage.save(
bucket,
snapshotObjectKey(context, keyPrefix),
JSON.stringify(snapshot),
{
contentType: "application/json",
});
},
);
},
};
}
Expand Down
3 changes: 3 additions & 0 deletions packages/node-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ export { BoundReflagClient, ReflagClient } from "./client";
export { EdgeClient, EdgeClientOptions } from "./edgeClient";
export type {
FileFallbackProviderOptions,
GCSFallbackProviderClient,
GCSFallbackProviderOptions,
GCSGoogleApisClient,
GCSLegacyClient,
RedisFallbackProviderOptions,
S3FallbackProviderOptions,
StaticFallbackProviderOptions,
Expand Down
Loading
Loading