Strongly typed subscription parameters

Following on from a solution I came up with for strongly typed Meteor.method parameters and results, as I was starting out on a new project my subscriptions all felt “dirty” because they weren’t typed. I could make any typo or miss any paramater and not know 'till runtime! :astonished: - TypeScript FTW!

So here’s what I’ve come up with for defining publications and calling subscriptions with types. My objective when defining types is that I should never have to define something twice - everything should be inferred from first usage. Otherwise there’s scope for error.

First you need to define an interface for each publication and at the same time create wrapper functions for Meteor.publish() and Meteor.subscribe(). Technically these wrappers only need to be on the server and client respectively, but for convenience I put them in the same file (they are very small):

// imports/TypedPubSub.ts

export default function TypedPubSub<T>(name: string, _paramDef: T) {
	return {
		publish: (fn: (this: Subscription, params: T) => void) => Meteor.publish(name, fn),
		subscribe: (params: T, callback?: any) => Meteor.subscribe(name, params, callback),
	}
}

Then you can define your interfaces for each publication. These need to be available on the client and the server, so don’t combine it with any code you don’t want on the client, hence I put them in their own imports folder, grouped by collection:

// imports/subdefs/tenants.ts
import TypedPubSub from "/imports/TypedPubSub";

export const tenantSubs = {
	tenants: TypedPubSub('tenants', {tradingOnly: false}),
	tenantMonths: TypedPubSub('tenantMonths', {tenantId: '' as string}),
	tenantsMonths: TypedPubSub('tenantsMonths', {tenantIds: [] as string[], minMonth: '' as string, maxMonth: '' as string}),
} as const

Then you can create your publications on the server:

// server/publications/tenants.ts
import { tenantsSubs } from '/imports/subdefs/tenants';

tenantSubs.tenantsMonths.publish(function(params) {
	check(this.userId, String);
	check(params, {
		tenantIds: [String],
		minMonth: String,
		maxMonth: String,
	})
	return tenantMonthsCollection.find({
		deleted: {$ne: true},
		tenantId: {$in: params.tenantIds},
		ownerId: this.userId,
		month: {$gte: params.minMonth, $lte: params.maxMonth},
	});
});

Here the types of tenantIds, minMonth and maxMonth are all automatically known without any further type annotations, although using check to validate the parameters does that for us anyway.

Finally, to subscribe to the publication on the client:

// somewhere/in/a/client/component
import { tenantsSubs } from '/imports/subdefs/tenants';

...
// any error in these parameters will display an error in your IDE... ;-)
const subHandle = tenantSubs.tenantsMonths.subscribe({tenantIds: [...], minMonth: '2021-01', maxMonth: '2021-12'})

The eagle eyed among you will notice that subscriptions defined in this way only take one object parameter, just to keep things simple.

Hopefully this helps someone else!

2 Likes

So, all the above is for raw Meteor, but since I use Vue with vue-meteor-tracker, here’s how I adapt it for Vue.

For this we need to define an interface for the pub/subs, and also store the pub name for future reference, plus we need a helper function to wrap vue-meteor-tracker’s this.$subscribe() function:

// imports/TypedPubSub.ts

interface ITypedPubSub<T> {
	name: string,
	publish: (fn: (this: Subscription, params: T) => void) => void,
	subscribe(params: T, callback?: any): Meteor.SubscriptionHandle,
};

export default function TypedPubSub<T>(name: string, _paramDef: T): ITypedPubSub<T> {
	return {
		name,
		publish: (fn: (this: Subscription, params: T) => void) => Meteor.publish(name, fn),
		subscribe: (params: T, callback?: any) => Meteor.subscribe(name, params, callback),
	}
}

// this is only required for Vue with vue-meteor-tracker
export function TypedSubscribe<T>(this: Vue, subDef: ITypedPubSub<T>, params: T) {
	return (this as any).$subscribe(subDef.name, [params]);
}

Then I add this as a prototype to the Vue instance:

// client/main.ts
import { TypedSubscribe } from '/imports/TypedPubSub';
Vue.prototype.$typedSubscribe = TypedSubscribe;

declare module 'vue/types/vue' {
  interface Vue {
	// $subscribe(name: string, params: any[]): void; // don't use this any more!
	$typedSubscribe: typeof TypedSubscribe;          // use this instead!
	$autorun(fn: () => void): number;
	$subReady: Record<string, boolean>;
  }
}

Then in any Vue component:

// imports/ui/Tenant.vue
...
import { tenantsSubs } from '/imports/subdefs/tenants';

export default {
    props: {
        tenant: object as PropType<Tenant>;
    },
    ...
	watch: {
		'tenant._id': {
			immediate: true,
			handler() {
				this.$typedSubscribe(tenantSubs.tenantMonths, {tenantId: this.tenant._id});
			}
		},
3 Likes

I just wanted to highlight, that this approach will indeed make these strongly typed (the same goes for methods), but the user is still able to open devtools (or connect from a different source) and execute them with any arguments they want. That’s why it’s extremely important to always add validators.

It is possible to infer the type from a schema though. You could use something like typebox or zod.

3 Likes

Hi @radekmie. You are of course correct, and that is omitted from my simplified examples but always important to remember, thank you.

I don’t mention it in the above, but I’ve got so used to using low-level database partitioning (via wildhart:partitioner) that collection.find() can only return documents the user is authorised to see, so in my examples above I don’t actually need any additional validation.

Even with this package, I’d still say you do. Your example is destructuring an argument, so tenantSubs.tenantsMonths.subscribe() will fail with an unhandled error. What is more, without validation, you’re vulnerable to NoSQL injection.

@radekmie Destructuring throwing an unhandled error doesn’t concern me too much because it is still ‘safe’. The only NoSQL injection attack I was able to come up with was by passing {tenantIds: [/.*/]}, which without wildhart:partitioner would expose everyone else’s data. I’d be interested to know if you think this query is exposed to any other attacks, with or without partitioning?

For completeness and to promote best practice I have amended my example publication to include validation and explicit limitation of the query to the user’s own documents, thank you.

The exact examples you provided were not that unsafe, but showed a bad practice of not validating the data. Thank you for changing that!

About that destructuring - yes, it is safe as in it’ll work when executed properly. I’d still recommend everyone do that, as (1) it’s an unhandled error that will pollute your logs, and (2) it’s relatively expensive to gather the stack trace and report the error. After all, better safe than sorry.

An example that is actually susceptible to a NoSQL attack:

 tenantSubs.tenantsMonths.publish(function(params) {
 	return tenantMonthsCollection.find({
 		deleted: {$ne: true},
- 		tenantId: {$in: params.tenantIds},
+ 		tenantId: params.tenantIds,
 		ownerId: this.userId,
 		month: {$gte: params.minMonth, $lte: params.maxMonth},
 	});
 });

Here’s an example using Meteor’s check instead of an external npm package:

import { Match, check } from 'meteor/check';

export default function TypedPubSub<T extends Match.Pattern>(name: string, pattern: T) {
  return {
    publish(fn: (this: Subscription, input: Match.PatternMatch<T>) => void) {
      Meteor.publish(name, function publication(input: unknown) {
        check(input, pattern);
        return fn.call(this, input);
      });
    },
    subscribe(
      input: Match.PatternMatch<T>,
      callbacks?: { onReady?: () => void; onStop?: () => void },
    ) {
      return Meteor.subscribe(name, input, callbacks);
    },
  };
}

const example = TypedPubSub('example', { foo: String, bar: [{ baz: Number }] });
example.publish(input => {
  // input: { foo: string; bar: { baz: number }[] }
  return [];
});

// Autocomplete works like a charm.
example.subscribe({ foo: 'test', bar: [{ baz: 1 }] });

I’ll have to take a deeper look on this and implement this later. Just want to ask @wildhart if you think this could be refined further and in some fashion integrated into core?

1 Like

Hi @storyteller. Thanks for showing an interest. I’m sure it could be refined further, and it would be great if something similar could be integrated into Core! Of even more benefit would be a solution for typed methods as per my post linked above because I’m sure they are used more frequently and typing of the return value is useful.

On the other hand though, I’ve shown that these problems can be very easily solved in userland, so maybe all we need is a page collecting these tips so that people looking at using Typescript with Meteor are aware of them from the start, e.g. Build System | Meteor Guide

Also, related to typescript, and I see that you’re aware of this one, is some mechanism for exposing to the IDE the type definition files of atmosphere packages… [ref atmosphere-packages-with-typescript-definitions/54292]

1 Like