Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NodeJS SDK #1442

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft

NodeJS SDK #1442

wants to merge 1 commit into from

Conversation

jdorn
Copy link
Member

@jdorn jdorn commented Jul 10, 2023

Features and Changes

Dedicated NodeJS SDK, which is a thin wrapper on top of the Javascript SDK. It includes the following:

  • Automatic polyfills for fetch, SubtleCrypto, EventSource, and localStorage so no configuration needed out-of-the-box.
  • Middleware helper for Express and other frameworks
  • Helper functions to hydrate client-side apps with features/experiments fetched from the server
  • Default option values that enforce best practices for server-side applications

@jdorn jdorn marked this pull request as draft July 10, 2023 13:47
@github-actions
Copy link

Your preview environment pr-1442-bttf has been deployed.

Preview environment endpoints are available at:

Comment on lines +47 to +70
export function simpleFileCache(
path: string = "/tmp/gbcache"
): LocalStorageCompat {
const fs = require("fs/promises");

const read = async () => {
const contents = await fs.readFile(path);
if (!contents) return null;
const json = JSON.parse(contents);
return json || null;
};

return {
getItem: async (key: string) => {
const json = await read();
return json?.[key];
},
setItem: async (key: string, value: string) => {
const json = (await read()) || {};
json[key] = value;
await fs.writeFile(path, JSON.stringify(json));
},
};
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible that in some environments the application will not be allowed to write to disk? Or that the contents returned is not JSON parsable? In these cases it would throw errors, do we want to wrap these operations in a try/catch? Handling errors for file reading and writing and JSON deserialization is generally the practice in other languages (e.g. Java, Kotlin, Go), and it's probably a good idea in the Node SDK to avoid crashing the user's app.

Related, I see we are wrapping all the middleware operations in a try/catch but if it fails, it will call the next() function with an error—wouldn't this skip the request handler and go straight to any error middleware, resulting in the request to fail? Do we want to fail requests when networking or cacheing-related disk I/O operations fail?

If you pass anything to the next() function (except the string 'route'), Express regards the current request as being an error and will skip any remaining non-error handling routing and middleware functions. (ref)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch with the filesystem operations. I'm still thinking through the general error handling part. Not happy with how it is now. I think some people will want to customize the behavior when loading features doesn't work (e.g. use our CDN first, but fall back to a static file in case the CDN is down).

Comment on lines +40 to +42
getAttributes?: (
req: Request
) => Record<string, unknown> | Promise<Record<string, unknown>>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we requiring a Request to be passed here? I'm not sure which common Node.js framework this would work with, but in Express, when passing data through the request lifecycle, the recommended pattern is to use the response locals, not the request object (though I know in our backend code we are not following the recommended pattern and have instead augmented an Express request).

Shouldn't they be able to pass anything in here? Or nothing? They just need a function that returns the attributes, we don't need to enforce the arguments it takes.

We can do something like this instead:

interface GetAttributes {
    (...args: unknown[]): Promise<Record<string, unknown>>
}

This will make it more flexible for the developer's implementation. Of course it means they have to do type assertions but it will not constrain them to a specific API.

Example without arguments

const getAttrsNoArgs: GetAttributes = async () => {
    return {
        foo: 'bar'
    }
}

Example using Express' res.locals pattern

// naive express response type
type ExpressResponse = {
    locals: Record<string, unknown>
}

const getAttrsWithRequestAndResponse: GetAttributes = async (req, res) => {
    const method = (req as Request).method

    const locals = (res as ExpressResponse).locals
    const foo = locals.foo

    return {
        method,
        foo,
    }
}

More complex implementation

This one has a UserRepository:

type User = {
    id: string
    email: string
    registeredAt: string
}

class UserRepository {
    async getUser(): Promise<User> {
        return {
            id: 'user-abc123',
            email: 'user@example.com',
            registeredAt: '1689026908',
        }
    }
}

const getAttrsWithUserRepo: GetAttributes = async (userRepo) => {
    return await (userRepo as UserRepository).getUser()
}

Naming it GetAttributesFunction might be more inline stylistically for JS/TS than GetAttributes.

You can see a TypeScript playground here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I follow. The middleware is defined once and called for every request. So the getAttributes function needs context about which request it's getting attributes for (e.g. to look up cookie values). Since we're calling the getAttributes function from within the middleware, we need to know exactly which arguments to pass in, so we can't let the user define their own.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My understanding would be that the user would define the getAttributes function themselves, and the only requirement is that it return attributes as Record<string, unknown>. If previous middleware has populated the response locals with relevant data, for example, they wouldn't be able to get them and would need to adapt their app to work with this middleware. This may require them to make another round trip to the DB which wouldn't be ideal. I'm thinking in the case that perhaps they have auth middleware that populates the response locals (as is generally done with the patterns recommended by Express), then if they need to get any data from the response locals, they cannot do it in TypeScript because they are constrained to working with a Request object only.

For example, this wouldn't pass type checks in TypeScript:

    const middleware = growthbookMiddleware({
      context: {},
      getAttributes: (res) => {
        return {
          registeredAt: res.locals.registrationDate
        };
      },
    });

It seems like we're unnecessarily constraining them to defining things on the request object by enforcing the request be the only argument. It doesn't seem we actually need it to be a request since the user defines the function themselves.

Copy link
Contributor

@tinahollygb tinahollygb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left some feedback inline. In general, I have concerns with the API we expose for the getAttributes function as well as how if we fail to do something like a network request or write to disk for caching, it kills the request. See inline comments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants