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

Add @zemble/push-expo and support provider strategies #91

Merged
merged 7 commits into from
May 26, 2024
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
12 changes: 12 additions & 0 deletions .changeset/warm-cows-knock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@zemble/auth": patch
"@zemble/core": patch
"create-zemble-plugin": patch
"@zemble/logger-graphql": patch
"@zemble/mongodb": patch
"@zemble/pino": patch
"@zemble/push-expo": patch
"@zemble/utils": patch
---

Add @zemble/push-expo and support provider strategies
Binary file modified bun.lockb
Binary file not shown.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
"reinstall-with-nuked-lockfile": "rm -rf node_modules/ && rm bun.lockb && find . -name 'node_modules' -type d -prune -exec rm -rf '{}' + && bun install --force",
"git-nuke": "git clean -Xdf",
"clean": "find . -type f \\( -name \"*.mjs\" -o -name \"*.d.mts\" -o -name \"*.d.ts\" \\) -not -path \"./node_modules/*\" -delete",
"upgrade-interactive": "bunx npm-check-updates -ws --root --format group -i -x expo,expo-*,react-native,*react-native,*react-native*,react-native*,*-native*,@react-navigation/*",
"upgrade-interactive-native": "bunx npm-check-updates -ws --root --format group -i --filter expo,expo-*,react-native,*react-native,*react-native*,react-native*,*-native*,@react-navigation/*",
"upgrade-interactive": "bunx npm-check-updates -ws --root --format group -i -x @expo/*,expo,expo-*,react-native,*react-native,*react-native*,react-native*,*-native*,@react-navigation/*",
"upgrade-interactive-native": "bunx npm-check-updates -ws --root --format group -i --filter @expo/*,expo,expo-*,react-native,*react-native,*react-native*,react-native*,*-native*,@react-navigation/*",
"fix-dependencies": "bunx check-dependency-version-consistency --fix"
},
"trustedDependencies": [
Expand Down
2 changes: 1 addition & 1 deletion packages/auth/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ const defaultConfig = {
refreshTokenExpiryInSeconds: 60 * 60 * 24, // 24 hours
checkTokenValidity: checkTokenValidityDefault,
invalidateAllTokens: async (sub) => {
await plugin.providers.kv('tokens-invalidated-at').set(sub, new Date().toString())
await plugin.providers.kv('tokens-invalidated-at').set(sub, new Date().toISOString())
},
invalidateToken: async (sub, token) => {
await plugin.providers.kv('invalid-tokens').set(`${sub}:${token}`, true)
Expand Down
17 changes: 15 additions & 2 deletions packages/core/Plugin.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import debug from 'debug'

import { createProviderProxy } from './createProvidersProxy'
import mergeDeep from './utils/mergeDeep'
import { readPackageJson } from './utils/readPackageJson'
import { defaultProviders } from './zembleContext'
import { defaultMultiProviders } from './zembleContext'

import type { Middleware, PluginOpts } from './types'

Expand All @@ -23,13 +24,16 @@ export class Plugin<
readonly #middleware?: Middleware<TResolvedConfig, Plugin>

// eslint-disable-next-line functional/prefer-readonly-type
providers = defaultProviders as Zemble.Providers
multiProviders = defaultMultiProviders as unknown as Zemble.MultiProviders

// eslint-disable-next-line functional/prefer-readonly-type
#pluginName: string | undefined

readonly debug: debug.Debugger

// eslint-disable-next-line functional/prefer-readonly-type
#providerStrategies: Zemble.ProviderStrategies = {}

/**
*
* @param __dirname This should be the directory of the plugin (usually import.meta.dir), usually containing subdirectories like /routes and /graphql for automatic bootstrapping
Expand Down Expand Up @@ -59,6 +63,15 @@ export class Plugin<
this.dependencies = resolvedDeps
}

setProviderStrategies(providerStrategies: Zemble.ProviderStrategies) {
// eslint-disable-next-line functional/immutable-data
this.#providerStrategies = providerStrategies
}

get providers() {
return createProviderProxy(this.multiProviders, this.#providerStrategies)
}

get isPluginRunLocally() {
return (
process.cwd() === this.pluginPath
Expand Down
18 changes: 14 additions & 4 deletions packages/core/createApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'

import { createProviderProxy } from './createProvidersProxy'
import { Plugin } from './Plugin'
import { readPackageJson } from './utils/readPackageJson'
import context, { defaultProviders } from './zembleContext'
import context, { defaultMultiProviders } from './zembleContext'

import type { RunBeforeServeFn } from './types'

Expand All @@ -14,6 +15,7 @@ const packageJson = readPackageJson()
export type Configure = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly plugins: readonly (Plugin<any, any, any>)[],
readonly providerStrategies?: Zemble.ProviderStrategies
}

const debuggah = debug('@zemble/core')
Expand All @@ -30,7 +32,7 @@ const filterConfig = (config: Zemble.GlobalConfig) => Object.keys(config).reduce
}
}, {})

export const createApp = async ({ plugins: pluginsBeforeResolvingDeps }: Configure) => {
export const createApp = async ({ plugins: pluginsBeforeResolvingDeps, providerStrategies: providerStrategiesIn }: Configure) => {
const hono = new Hono<Zemble.HonoEnv>()

// maybe this should be later - how about middleware that overrides logger?
Expand All @@ -57,7 +59,11 @@ export const createApp = async ({ plugins: pluginsBeforeResolvingDeps }: Configu

const resolved = pluginsBeforeResolvingDeps.flatMap((plugin) => [...plugin.dependencies, plugin])

const providerStrategies = providerStrategiesIn ?? {}

const plugins = resolved.reduce((prev, plugin) => {
plugin.setProviderStrategies(providerStrategies)

const existingPlugin = prev.find(({ pluginName }) => pluginName === plugin.pluginName)
if (existingPlugin) {
if (existingPlugin !== plugin) {
Expand Down Expand Up @@ -94,18 +100,22 @@ export const createApp = async ({ plugins: pluginsBeforeResolvingDeps }: Configu

plugins.forEach((plugin) => {
// eslint-disable-next-line functional/immutable-data, no-param-reassign
plugin.providers = { ...defaultProviders }
plugin.multiProviders = defaultMultiProviders as unknown as Zemble.MultiProviders

debuggah(`Loading ${plugin.pluginName} with config: ${JSON.stringify(filterConfig(plugin.config), null, 2)}`)
})

const appDir = process.cwd()

const multiProviders = defaultMultiProviders as unknown as Zemble.MultiProviders

const preInitApp = {
hono,
appDir,
providers: defaultProviders,
providerStrategies,
providers: createProviderProxy(multiProviders, providerStrategies),
plugins,
multiProviders,
appPlugin: plugins.some((p) => p.isPluginRunLocally) ? undefined : new Plugin(appDir, {
name: packageJson.name,
version: packageJson.version,
Expand Down
66 changes: 66 additions & 0 deletions packages/core/createProvidersProxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
const roundRobinCount = new Map<string, number>()

export const createProviderProxy = (multiProviders: Zemble.MultiProviders, providerStrategiesPerProvider: Zemble.ProviderStrategies) => new Proxy({} as Zemble.Providers, {
get: (_, providerKey) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore asdf as
const multiProvidersForType = multiProviders[providerKey]
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore asdf as
const providers = Object.values(multiProvidersForType)

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const strategy = providerStrategiesPerProvider[providerKey.toString()] ?? 'last'

if (strategy === 'last') {
const lastProvider = providers.at(-1)
if (lastProvider) {
return lastProvider
}
} else if (strategy === 'all') {
const isAllProvidersFunctions = providers.every((p) => typeof p === 'function')

if (!isAllProvidersFunctions) {
throw new Error(`All providers for ${String(providerKey)} must be functions for all strategy to work`)
}

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const callAllProviders = async (...args: readonly unknown[]) => Promise.all(providers.map((p) => p(...args)))

return callAllProviders
} else if (strategy === 'round-robin') {
const count = roundRobinCount.get(providerKey.toString()) ?? 0
const provider = providers[count % providers.length]
roundRobinCount.set(providerKey.toString(), count + 1)

return provider
} else if (strategy === 'failover') {
const isAllProvidersFunctions = providers.every((p) => typeof p === 'function')

if (!isAllProvidersFunctions) {
throw new Error(`All providers for "${String(providerKey)}" must be functions for "failover" strategy to work`)
}

const tryToExecute = async (attempt: number, ...args: readonly unknown[]): Promise<unknown> => {
const hasGoneThroughAllProviders = attempt >= providers.length
if (hasGoneThroughAllProviders) {
throw new Error(`All providers for "${providerKey.toString()}" failed`)
}
const provider = providers.at(-attempt - 1) as (...args: readonly unknown[]) => unknown
try {
return provider(...args)
} catch (e) {
return tryToExecute(attempt + 1, ...args)
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
return async (args: readonly unknown[]) => tryToExecute(0, ...args)
}
throw new Error(`No provider found for ${String(providerKey)}`)
},
set: (_, providerKey) => {
throw new Error(`Attempting to set provider for "${providerKey.toString()}" directly, use setupProvider instead`)
},
})
57 changes: 56 additions & 1 deletion packages/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import type { Plugin } from '.'
import type { WebSocketHandler } from 'bun'
import type { PromiseOrValue } from 'graphql/jsutils/PromiseOrValue'
import type { PubSub } from 'graphql-yoga'
import type {
Hono, Context as HonoContext,
Expand Down Expand Up @@ -51,6 +52,40 @@ export interface IStandardLogger extends pino.BaseLogger {
readonly child: (bindings: Record<string, unknown>) => IStandardLogger
}

export interface PushMessage {
readonly data?: Record<string, unknown>;
readonly title?: string;
readonly subtitle?: string;
readonly body?: string;
readonly sound?: 'default' | null | {
readonly critical?: boolean;
readonly name?: 'default' | null;
readonly volume?: number;
};
readonly ttl?: number;
readonly expiration?: number;
readonly priority?: 'default' | 'normal' | 'high';
readonly badge?: number;
readonly channelId?: string;
readonly categoryId?: string;
readonly mutableContent?: boolean;
}

export type PushTokenWithMessage = {
readonly pushToken: PushTokenWithMetadata
readonly message: PushMessage
}

export type PushTokenWithMessageAndTicket = PushTokenWithMessage & { readonly ticketId: string }

export interface SendPushResponse {
readonly failedSendsToRemoveTokensFor: readonly PushTokenWithMetadata[]
readonly failedSendsOthers: readonly PushTokenWithMessage[]
readonly successfulSends: readonly PushTokenWithMessageAndTicket[]
}

export type SendPushProvider = (pushTokens: readonly PushTokenWithMetadata[], message: PushMessage) => PromiseOrValue<SendPushResponse>

declare global {
namespace Zemble {
interface HonoVariables extends Record<string, unknown> {
Expand Down Expand Up @@ -87,12 +122,27 @@ declare global {

}

// eslint-disable-next-line functional/prefer-readonly-type
type MultiProviders = {
// eslint-disable-next-line functional/prefer-readonly-type
[key in keyof Providers]: {
[middlewareKey in keyof MiddlewareConfig]: Providers[key]
}
}

type ProviderStrategies = Partial<Record<keyof Providers, 'last' | 'all' | 'failover' | 'round-robin'>>

interface App {
readonly hono: Hono<HonoEnv>
readonly appDir: string

// eslint-disable-next-line functional/prefer-readonly-type
providers: Providers
// eslint-disable-next-line functional/prefer-readonly-type
multiProviders: MultiProviders

// eslint-disable-next-line functional/prefer-readonly-type
providerStrategies: ProviderStrategies

readonly runBeforeServe: readonly RunBeforeServeFn[]

Expand Down Expand Up @@ -145,6 +195,10 @@ declare global {
interface TokenRegistry {
// readonly UnknownToken: Record<string, unknown>
}

interface PushTokenRegistry {

}
}
}

Expand All @@ -154,6 +208,7 @@ export interface BaseToken extends JWTPayload
}

export type TokenContents = Zemble.TokenRegistry[keyof Zemble.TokenRegistry] & BaseToken | BaseToken
export type PushTokenWithMetadata = Zemble.PushTokenRegistry[keyof Zemble.PushTokenRegistry]

export type Dependency = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -204,7 +259,7 @@ export type MiddlewareReturn = Promise<void> | void | RunBeforeServeFn | Promise

export type Middleware<TMiddlewareConfig extends Zemble.GlobalConfig, PluginType extends Plugin = Plugin> = (
opts: {
readonly app: Pick<Zemble.App, 'hono' |'appDir' |'providers' | 'websocketHandler' | 'appPlugin' | 'plugins'>,
readonly app: Pick<Zemble.App, 'hono' |'appDir' |'providers' | 'websocketHandler' | 'appPlugin' | 'plugins' | 'multiProviders'>,
readonly context: Zemble.GlobalContext
readonly config: TMiddlewareConfig,
readonly self: PluginType,
Expand Down
37 changes: 28 additions & 9 deletions packages/core/utils/setupProvider.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,51 @@
// strategies for providers:
// - fallback (priority but use a different provider if the first one fails)
// - round robin (use a different provider each time, with a fallback)
// - batch (send to multiple providers at once) (for push we do this as each provider handles a different type of token)
// - singleton (use a specific provider for a specific plugin or app)

import type { Plugin } from '..'

/* eslint-disable no-param-reassign */
/* eslint-disable functional/immutable-data */
export type SetupProviderArgs<
T extends Zemble.Providers[Key],
Key extends keyof Zemble.Providers,
TMiddlewareKey extends keyof Zemble.MiddlewareConfig
> = {
readonly app: Pick<Zemble.App, 'providers' | 'plugins'>,
readonly initializeProvider: (forSpecificPlugin: Zemble.MiddlewareConfig[TMiddlewareKey] | undefined) => Promise<T> | T,
readonly app: Pick<Zemble.App, 'providers' | 'plugins' | 'multiProviders'>,
readonly initializeProvider: (forSpecificPlugin: Zemble.MiddlewareConfig[TMiddlewareKey] | undefined, plugin: Plugin | undefined) => Promise<T> | T,
readonly providerKey: Key,
readonly middlewareKey: TMiddlewareKey
readonly alwaysCreateForEveryPlugin?: boolean
}

export async function setupProvider<T extends Zemble.Providers[Key], Key extends keyof Zemble.Providers, TMiddlewareKey extends keyof Zemble.MiddlewareConfig>({
app, initializeProvider, providerKey, middlewareKey,
app, initializeProvider, providerKey, middlewareKey, alwaysCreateForEveryPlugin,
}: SetupProviderArgs<T, Key, TMiddlewareKey>) {
// eslint-disable-next-line functional/immutable-data, no-param-reassign
app.providers[providerKey] = await initializeProvider(undefined)
const defaultProvider = await initializeProvider(undefined, undefined)

app.multiProviders[providerKey] = app.multiProviders[providerKey] || {}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore hard to fix, and might boil up later
app.multiProviders[providerKey][middlewareKey] = defaultProvider

await Promise.all(app.plugins.map(async (p) => {
const middlewareConfig = p.config.middleware?.[middlewareKey]
const middlewareConfig = middlewareKey && p.config.middleware?.[middlewareKey]
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const isDisabled = !!middlewareConfig?.disable
if (!isDisabled) {
const hasCustomConfig = !!middlewareConfig

// eslint-disable-next-line functional/immutable-data, no-param-reassign
p.providers[providerKey] = hasCustomConfig
? await initializeProvider(middlewareConfig)
const defaultOrCustomProvider = hasCustomConfig || alwaysCreateForEveryPlugin
? await initializeProvider(middlewareConfig, p)
: app.providers[providerKey]

p.multiProviders[providerKey] = p.multiProviders[providerKey] || {}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore hard to fix, and might boil up later
p.multiProviders[providerKey][middlewareKey] = defaultOrCustomProvider
}
}))
}
Expand Down
14 changes: 9 additions & 5 deletions packages/core/zembleContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,14 @@ class ContextInstance implements Zemble.GlobalContext {

const context = new ContextInstance()

export const defaultProviders = {
logger: context.logger,
// eslint-disable-next-line @typescript-eslint/unbound-method
kv: context.kv.bind(context.kv),
} as Zemble.Providers
export const defaultMultiProviders = {
logger: {
'@zemble/core': context.logger,
},
kv: {
// eslint-disable-next-line @typescript-eslint/unbound-method
'@zemble/core': context.kv.bind(context.kv),
},
} as Pick<Zemble.MultiProviders, 'kv' | 'logger'>

export default context
Loading