Skip to content

Add typings for WebChannel #2385

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

Merged
merged 7 commits into from
Dec 3, 2019
Merged

Add typings for WebChannel #2385

merged 7 commits into from
Dec 3, 2019

Conversation

schmidt-sebastian
Copy link
Contributor

@schmidt-sebastian schmidt-sebastian commented Nov 27, 2019

@schmidt-sebastian schmidt-sebastian force-pushed the mrschmidt/webchannel branch 2 times, most recently from 1cce950 to 9f7b146 Compare December 1, 2019 22:11
Copy link
Contributor

@mikelehen mikelehen left a comment

Choose a reason for hiding this comment

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

I think these types are diverging from our (unfortunate) reality. 😄

body?: ArrayBufferView | Blob | string | null,
headers?: Headers,
timeoutInterval?: number
): Promise<XhrIo>;
Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My initial attempt to the types from Google3 is the source of this:

https://cs.corp.google.com/piper///depot/google3/third_party/javascript/firebase/src/packages/storage/src/implementation/xhrio.ts?q=%22Promise%3CXhrIo%3E%22&dr=C&l=26

This also explains retroactively why the input types are limited to ArrayBufferView | Blob | string.

send(
url: string,
method?: string,
body?: ArrayBufferView | Blob | string | null,
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't care too much, but this doesn't exactly match the closure types (which include ArrayBuffer, Document, FormData, etc.) or our own usage (where we just pass string I assume).

If somebody ever needs to modify this in the future, it may not be clear how we ended up with the existing type definition and so it may not be clear how to properly update it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

My new goal is to only support the usage in Firestore. As such, I changed it to just support string.

CLOSE = 'close',
ERROR = 'error',
MESSAGE = 'message',
COMPLETE = 'complete'
Copy link
Contributor

Choose a reason for hiding this comment

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

complete doesn't seem to exist on webchannel or be used by our code?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Naive me thought it was possible to re-use the same EventType typings in two different places. I have since gone back on this approach and embraced reality.

}

export enum ErrorCode {
NO_ERROR = 0,
Copy link
Contributor

Choose a reason for hiding this comment

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

Again, this doesn't match reality (where we only export NO_ERROR, TIMEOUT, and HTTP_ERROR).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah - I removed all not exported members. As part of this I also saw that we export getResponseText but don't use it. I removed it from the closure wrapper.


getResponseText(): string;

getResponseJson(): Object | any;
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: is blah | any better / different than just any?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Nope. But my new way of returning WebChannelError might be (which I think is needed since we access error.code and error.message.

CLOSE = 'close',
ERROR = 'error',
MESSAGE = 'message',
COMPLETE = 'complete'
Copy link
Contributor

Choose a reason for hiding this comment

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

Hrm... I don't think this is right. Our webchannel-wrapper module actually only exports EventType.COMPLETE and the value should come from closure-library (goog.net.EventType.COMPLETE) rather than us redefine the value to a constant ('complete') here. See:

goog.net.EventType['COMPLETE'] = goog.net.EventType.COMPLETE;

'EventType': goog.net.EventType,

With your change, this .d.ts file will now let us use e.g. EventType.OPEN in our code but at runtime it will be undefined because the actual implementation (in webchannel-wrapper/index.js) doesn't actually define it.

So I think we need to reduce this to only what we export from index.js... and I don't think we should be assigning values ('open' etc.) to the enum values. In fact, maybe we shouldn't use an enum at all, to make it clearer that the values aren't known (to TypeScript) at compile-time. Poking around, I'm not sure what the right way to do that is, but this seems to work:

export interface EventType { }
export var EventType: {
  COMPLETE: EventType
};

I wonder if there's some better way to go about all of this. webchannel-wrapper is an ugly wart to allow us to use (a very small subset of) closure-library from our TypeScript code. The fact that https://github.com/fivetran/typescript-closure-tools exists suggests that maybe there's a better way and we wouldn't have to manually re-expose the part of closure-library we need? Perhaps a good hackweek project! 😛

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I should have done more research prior to this PR, but I think at least now I have gained a rough understanding of how the exports work. I traced them through to the minified code and replaced all types with strings (or numbers for ErrorCode).

@@ -31,7 +82,9 @@ export interface WebChannelOptions {
messageHeaders?: StringMap;
initMessageHeaders?: StringMap;
Copy link
Contributor

Choose a reason for hiding this comment

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

Are these usages of StringMap safe for some reason? I'm not clear why you had to change messageUrlParams and internalChannelParams but not these.

That said, it would be nice if you didn't have to change any of these and our minification logic was able to handle StringMap. Is that not possible?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

They are only used as empty objects in Firestore. I ended up removing the string map altogether and changed the types to {}. I hope that may act as more of a deterrent.

close(): void;
halfClose(): void;
listen<T>(type: EventType, cb: (param: T) => void): void;
send<T>(msg: T): void;
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: Is there a benefit to making these generic instead of typing them as unknown or similar?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Send can be unknown (it should probably not be - but then we should both the generic type on WebChannel). I made it unknown for now.
The in listen calls back to us so it cannot be unknown.

body?: ArrayBufferView | Blob | string | null,
headers?: Headers,
timeoutInterval?: number
): Promise<XhrIo>;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

My initial attempt to the types from Google3 is the source of this:

https://cs.corp.google.com/piper///depot/google3/third_party/javascript/firebase/src/packages/storage/src/implementation/xhrio.ts?q=%22Promise%3CXhrIo%3E%22&dr=C&l=26

This also explains retroactively why the input types are limited to ArrayBufferView | Blob | string.

CLOSE = 'close',
ERROR = 'error',
MESSAGE = 'message',
COMPLETE = 'complete'
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Naive me thought it was possible to re-use the same EventType typings in two different places. I have since gone back on this approach and embraced reality.

CLOSE = 'close',
ERROR = 'error',
MESSAGE = 'message',
COMPLETE = 'complete'
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I should have done more research prior to this PR, but I think at least now I have gained a rough understanding of how the exports work. I traced them through to the minified code and replaced all types with strings (or numbers for ErrorCode).

}

export enum ErrorCode {
NO_ERROR = 0,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah - I removed all not exported members. As part of this I also saw that we export getResponseText but don't use it. I removed it from the closure wrapper.

send(
url: string,
method?: string,
body?: ArrayBufferView | Blob | string | null,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

My new goal is to only support the usage in Firestore. As such, I changed it to just support string.


getResponseText(): string;

getResponseJson(): Object | any;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Nope. But my new way of returning WebChannelError might be (which I think is needed since we access error.code and error.message.

@@ -31,7 +82,9 @@ export interface WebChannelOptions {
messageHeaders?: StringMap;
initMessageHeaders?: StringMap;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

They are only used as empty objects in Firestore. I ended up removing the string map altogether and changed the types to {}. I hope that may act as more of a deterrent.

close(): void;
halfClose(): void;
listen<T>(type: EventType, cb: (param: T) => void): void;
send<T>(msg: T): void;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Send can be unknown (it should probably not be - but then we should both the generic type on WebChannel). I made it unknown for now.
The in listen calls back to us so it cannot be unknown.

@@ -68,8 +68,6 @@ goog.net.XhrIo.prototype['getLastError'] =
goog.net.XhrIo.prototype['getLastErrorCode'] =
goog.net.XhrIo.prototype.getLastErrorCode;
goog.net.XhrIo.prototype['getStatus'] = goog.net.XhrIo.prototype.getStatus;
goog.net.XhrIo.prototype['getStatusText'] =
goog.net.XhrIo.prototype.getStatusText;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is not used by our code. Removing it is technically a breaking change though for consumers of this package. We might be ok though: https://github.com/search?l=JavaScript&q=%22%40firebase%2Fwebchannel-wrapper%22&type=Code

@@ -371,10 +369,9 @@ export class WebChannelConnection implements Connection {
// compatible with the bug we need to check either condition. The latter
// can be removed once the fix has been rolled out.
// Use any because msgData.error is not typed.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const msgDataAsAny: any = msgData;
const msgDataAsAny: WebChannelError | object = msgData;
Copy link
Contributor

Choose a reason for hiding this comment

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

The name msgDataAsAny did not survive your refactor very well. msgDataOrError ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sounds good. Renamed.

};

export interface Headers {
[name: string]: string | number;
Copy link
Contributor

Choose a reason for hiding this comment

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

Out of curiosity, why doesn't this cause problems like StringMap did?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I opted not to enable renaming for Object literal access. Since all headers are invalid JavaScript identifiers, they are only ever set using the literal access notation (such as header['X-Google-Foo'] = 42).

messageHeaders?: StringMap;
initMessageHeaders?: StringMap;
messageHeaders?: {};
initMessageHeaders?: {};
Copy link
Contributor

Choose a reason for hiding this comment

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

Is your intention to require that we set these to an empty object? Because I don't think that's what this actually does. This will allow us to pass any object (e.g. {'foo': 'bar'} since TypeScript does structural typing (aka duck typing).

Not sure it's possible to specify a type that indicates that only an empty object is valid.

That said, in general this is feeling somewhat fragile which is a bit worrisome since webchannel is already a bit fragile. It wouldn't be too surprising if we tweak some webchannel setting for performance/reliability reasons and then it gets minified wrong because we don't properly update this d.ts file and therefore it's ignored, and we don't notice because it's not something we can readily test.

If that's the world we're heading towards, I'd like to discuss if there are ways to avoid it... and if not, then we will perhaps eventually (once your minification stuff lands) want some big warnings in this file or something.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

To be perfectly honest, that was my intention. I did realize this doesn't work but I thought that it might still convey some of the original intent.

But you can ignore all this because I came up with a way that actually works. I can disallow all properties with:

messageHeaders: { [k : string] : never }

Furthermore, all members that have at least a single property specified do not allow any extra properties as is.

export interface WebChannel {
open(): void;
close(): void;
listen<T>(type: string, cb: (param: T) => void): void;
Copy link
Contributor

Choose a reason for hiding this comment

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

I still don't really understand this usage of generics. Is there even any practical difference between:

function foo(x: any)
// and
function foo<T>(x: T)

It seems like it's a way of telling TypeScript to let you pass anything. The generic way looks more type-safe, but I don't think it is?

It seems like the correct type here is unknown and we should explicitly cast to something else if we need to.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No - there is no difference, but one looks safer! I changed this to unknown and modified the consumer.


getResponseJson(): WebChannelError | object;

listenOnce<T>(type: string, cb: (param: T) => void): void;
Copy link
Contributor

Choose a reason for hiding this comment

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

same feedback about generics here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed. I am "casting" in the callsite now.

@@ -310,7 +308,7 @@ export class WebChannelConnection implements Connection {
// Note that eventually this function could go away if we are confident
// enough the code is exception free.
const unguardedEventListen = <T>(
type: WebChannel.EventType,
type: string,
Copy link
Contributor

Choose a reason for hiding this comment

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

Was it not possible to make WebChannel.EventType work? It seems like we're losing a bit of help from the type system here by switching to just string. Not the end of the world though.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

WebChannel.EventType is now a variable declaration and no longer a string. I had tried both WebChannel.EventType and typepof WebChannel.EventType.

Copy link
Contributor

Choose a reason for hiding this comment

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

Bleh... I came up with several ways to get this to compile (let us use EventType as a type as well as hang property values off it). E.g.:

export interface EventType {}
export var EventType: {
  readonly COMPLETE: EventType
}

or:

export class EventType {
  static readonly COMPLETE: EventType;
}

but they didn't provide type-safety, since TypeScript's structural typing would let you pass anything (e.g. {x: 1}) for a parameter typed as EventType. 😕

So the only thing I found that actually works for type-safety is using export enum EventType { COMPLETE } like you originally had. So if you feel like putting that back, I would be okay with that. Else if you're tired of messing with it, this is okay too.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hm. My "I couldn't get it to work" response above is based on the current version that used the variable declaration w/ nested property syntax. I am inclined to leave that as is for now, as it matches the underlying library closer than the Enum values.

@@ -310,7 +308,7 @@ export class WebChannelConnection implements Connection {
// Note that eventually this function could go away if we are confident
// enough the code is exception free.
const unguardedEventListen = <T>(
type: WebChannel.EventType,
type: string,
Copy link
Contributor

Choose a reason for hiding this comment

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

Bleh... I came up with several ways to get this to compile (let us use EventType as a type as well as hang property values off it). E.g.:

export interface EventType {}
export var EventType: {
  readonly COMPLETE: EventType
}

or:

export class EventType {
  static readonly COMPLETE: EventType;
}

but they didn't provide type-safety, since TypeScript's structural typing would let you pass anything (e.g. {x: 1}) for a parameter typed as EventType. 😕

So the only thing I found that actually works for type-safety is using export enum EventType { COMPLETE } like you originally had. So if you feel like putting that back, I would be okay with that. Else if you're tired of messing with it, this is okay too.

@schmidt-sebastian schmidt-sebastian merged commit 81730ba into master Dec 3, 2019
@hsubox76 hsubox76 added this to the next milestone Dec 10, 2019
@firebase firebase locked and limited conversation to collaborators Jan 3, 2020
@schmidt-sebastian schmidt-sebastian deleted the mrschmidt/webchannel branch February 28, 2020 23:03
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants