@beespoke/micro-server-push-firebase 0.0.0-stage → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,80 @@
1
- # Temporary Holding Version
1
+ # @beespoke/micro-server-push-firebase
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Firebase Cloud Messaging notifications through the Firebase Admin SDK.
4
+
5
+ Supports Node 24+ and Bun 1.3.14+.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @beespoke/micro-server @beespoke/micro-server-push-firebase
11
+ ```
12
+
13
+ Use `bun add` in place of `npm install` for Bun projects.
14
+
15
+ ## Configure the plugin
16
+
17
+ ```ts
18
+ import { defineConfig } from '@beespoke/micro-server';
19
+
20
+ export default defineConfig({
21
+ plugins: ['@beespoke/micro-server-push-firebase'],
22
+ pluginOptions: {
23
+ '@beespoke/micro-server-push-firebase': {
24
+ serviceAccount: './service-account.json',
25
+ },
26
+ },
27
+ });
28
+ ```
29
+
30
+ Supply your Firebase service-account JSON at the configured path. Keep credentials outside version control. `serviceAccount` also accepts an inline object; supported fields include `projectId`, `clientEmail`, and `privateKey`, or the JSON file's snake-case names. Alternatively supply `credential` or a credential through `appOptions`. Optional `appName` selects the Firebase app name; `configFile` supports application-owned legacy configuration.
31
+
32
+ ## Send a notification
33
+
34
+ After bootstrap:
35
+
36
+ ```ts
37
+ import { PushNotifier } from '@beespoke/micro-server-push-firebase';
38
+
39
+ await PushNotifier.sendNotification(
40
+ process.env.DEVICE_TOKEN!,
41
+ { title: 'Order ready', body: 'Your order is ready for collection.' },
42
+ { orderId: '123' },
43
+ );
44
+ ```
45
+
46
+ `sendNotifications(tokens, message, metadata, options)` handles multiple recipients. `NotificationType` contains `title`, `body`, and optional `imageUrl`. Pass string metadata values for Firebase data messages. The package also exports `NotificationOptions` and `PushNotifierOptions`.
47
+
48
+ The plugin initializes its Firebase app and registers cleanup. Standalone users own initialization and `PushNotifier.close()`.
49
+
50
+ ## Start the application
51
+
52
+ Save the configuration as `micro-server.config.ts` next to your application's `package.json`, then create `index.ts`:
53
+
54
+ ```ts
55
+ import { ApplicationServer } from '@beespoke/micro-server';
56
+
57
+ await ApplicationServer.bootstrap();
58
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
59
+ process.once(signal, () => {
60
+ void ApplicationServer.close().catch(error => {
61
+ console.error(error);
62
+ process.exitCode = 1;
63
+ });
64
+ });
65
+ }
66
+ ```
67
+
68
+ Run `bun index.ts` or `node --env-file-if-exists=.env index.ts`. Node requires ESM (`"type": "module"` in package.json). When using Bun, load configuration secrets from the application environment or its `.env` file.
69
+
70
+ ## TypeScript and lifecycle
71
+
72
+ Add this type-only import to an application-owned `plugin-types.d.ts` for configuration autocomplete:
73
+
74
+ ```ts
75
+ import type {} from '@beespoke/micro-server-push-firebase/plugin';
76
+ ```
77
+
78
+ Core validates plugin options before setup and owns cleanup after successful startup or startup failure. Await `ApplicationServer.bootstrap()` before using the configured client, and call `ApplicationServer.close()` during application shutdown. Importing the package does not open a connection. Relative plugin paths resolve from the selected configuration file's directory.
79
+
80
+ The root export can also be used without HTTP core. In that case, explicitly initialize and close the client. The `/plugin` entry is the integration adapter for `@beespoke/micro-server`.
@@ -0,0 +1,3 @@
1
+ export { default as PushNotifier } from './utils/PushNotifier.js';
2
+ export type { PushNotifierOptions } from './types/PushNotifierOptions.js';
3
+ export type { NotificationType, NotificationOptions } from './types/NotificationTypes.js';
package/dist/index.js ADDED
@@ -0,0 +1,124 @@
1
+ // src/utils/PushNotifier.ts
2
+ import admin from "firebase-admin";
3
+ import { readFile } from "node:fs/promises";
4
+ import { resolve, extname } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+
7
+ // src/support.ts
8
+ var consoleLogger = {
9
+ info: (...args) => console.info(...args),
10
+ warn: (...args) => console.warn(...args),
11
+ error: (...args) => console.error(...args),
12
+ debug: (...args) => console.debug(...args)
13
+ };
14
+
15
+ // src/utils/PushNotifier.ts
16
+ class PushNotifier {
17
+ name = "push-firebase";
18
+ static FirebaseAdmin = admin;
19
+ static instance = 0;
20
+ static app;
21
+ static initializing;
22
+ static closing;
23
+ static logger = consoleLogger;
24
+ rootDir;
25
+ options;
26
+ configDir;
27
+ constructor(rootOrOptions = {}, options = {}) {
28
+ this.rootDir = typeof rootOrOptions === "string" ? resolve(rootOrOptions) : undefined;
29
+ this.options = typeof rootOrOptions === "string" ? options : rootOrOptions;
30
+ }
31
+ async setup(context) {
32
+ this.configDir = context.configDir;
33
+ PushNotifier.logger = context.logger;
34
+ await this.init(this.options, (cleanup) => context.onClose(cleanup));
35
+ }
36
+ async init(options = this.options, registerCleanup) {
37
+ if (PushNotifier.app || PushNotifier.initializing || PushNotifier.closing) {
38
+ throw new Error("Firebase push client is already initialized or changing state");
39
+ }
40
+ const root = this.rootDir ?? this.configDir ?? process.cwd();
41
+ const initialize = async () => {
42
+ const appOptions = { ...options.appOptions };
43
+ if (options.serviceAccount !== undefined) {
44
+ const account = typeof options.serviceAccount === "string" ? await this.readAccount(resolve(root, options.serviceAccount)) : options.serviceAccount;
45
+ appOptions.credential = PushNotifier.FirebaseAdmin.credential.cert(account);
46
+ } else if (options.credential) {
47
+ appOptions.credential = options.credential;
48
+ } else if (!options.appOptions) {
49
+ const account = await this.readAccount(resolve(root, options.configFile ?? "notif.config.ts"));
50
+ appOptions.credential = PushNotifier.FirebaseAdmin.credential.cert(account);
51
+ }
52
+ const app = PushNotifier.FirebaseAdmin.initializeApp(appOptions, options.appName);
53
+ PushNotifier.app = app;
54
+ PushNotifier.instance = 1;
55
+ try {
56
+ registerCleanup?.(() => PushNotifier.close());
57
+ } catch (error) {
58
+ try {
59
+ await app.delete();
60
+ } finally {
61
+ PushNotifier.app = undefined;
62
+ PushNotifier.instance = 0;
63
+ }
64
+ throw error;
65
+ }
66
+ };
67
+ PushNotifier.initializing = initialize();
68
+ try {
69
+ await PushNotifier.initializing;
70
+ } finally {
71
+ PushNotifier.initializing = undefined;
72
+ }
73
+ }
74
+ async readAccount(path) {
75
+ if (extname(path) === ".json")
76
+ return JSON.parse(await readFile(path, "utf8"));
77
+ const module = await import(pathToFileURL(path).href);
78
+ if (!module.default || typeof module.default !== "object") {
79
+ throw new Error(`Firebase configuration must export a default service account: ${path}`);
80
+ }
81
+ return module.default;
82
+ }
83
+ static close() {
84
+ if (PushNotifier.closing)
85
+ return PushNotifier.closing;
86
+ PushNotifier.closing = (async () => {
87
+ try {
88
+ await PushNotifier.initializing;
89
+ await PushNotifier.app?.delete();
90
+ } finally {
91
+ PushNotifier.app = undefined;
92
+ PushNotifier.instance = 0;
93
+ PushNotifier.logger = consoleLogger;
94
+ }
95
+ })().finally(() => {
96
+ PushNotifier.closing = undefined;
97
+ });
98
+ return PushNotifier.closing;
99
+ }
100
+ static async sendNotifications(tokens, message, metadata = {}, options) {
101
+ try {
102
+ if (!PushNotifier.app)
103
+ throw new Error("Firebase push client is not initialized. Call `init()` first.");
104
+ const params = tokens.map((token) => ({ token, notification: message, data: metadata }));
105
+ return await PushNotifier.FirebaseAdmin.messaging(PushNotifier.app).sendEach(params);
106
+ } catch (error) {
107
+ PushNotifier.logger.error(error);
108
+ return;
109
+ }
110
+ }
111
+ static async sendNotification(token, message, metadata = {}, options) {
112
+ try {
113
+ if (!PushNotifier.app)
114
+ throw new Error("Firebase push client is not initialized. Call `init()` first.");
115
+ return await PushNotifier.FirebaseAdmin.messaging(PushNotifier.app).send({ token, notification: message, data: metadata });
116
+ } catch (error) {
117
+ PushNotifier.logger.error(error);
118
+ return;
119
+ }
120
+ }
121
+ }
122
+ export {
123
+ PushNotifier
124
+ };
@@ -0,0 +1,10 @@
1
+ import type { PluginDefinition } from '@beespoke/micro-server';
2
+ import { type PushNotifierOptions } from '../index.js';
3
+ export type FirebasePluginOptions = PushNotifierOptions;
4
+ declare module '@beespoke/micro-server' {
5
+ interface PluginOptionsRegistry {
6
+ '@beespoke/micro-server-push-firebase': FirebasePluginOptions;
7
+ }
8
+ }
9
+ declare const definition: PluginDefinition<FirebasePluginOptions>;
10
+ export default definition;
@@ -0,0 +1,67 @@
1
+ // src/plugin/index.ts
2
+ import { PushNotifier } from "../index.js";
3
+ function object(value) {
4
+ return !!value && typeof value === "object" && !Array.isArray(value);
5
+ }
6
+ function credential(value, field) {
7
+ if (!object(value) || typeof value.getAccessToken !== "function")
8
+ throw new Error(`Firebase ${field} must implement getAccessToken`);
9
+ }
10
+ var definition = {
11
+ name: "@beespoke/micro-server-push-firebase",
12
+ validate(options) {
13
+ if (!object(options))
14
+ throw new Error("Firebase options must be an object");
15
+ const keys = ["serviceAccount", "credential", "appOptions", "appName", "configFile"];
16
+ if (Object.keys(options).some((key) => !keys.includes(key)))
17
+ throw new Error("Firebase options contain an unknown field");
18
+ for (const key of ["appName", "configFile"]) {
19
+ if (options[key] !== undefined && (typeof options[key] !== "string" || !options[key].trim()))
20
+ throw new Error(`Firebase ${key} must be a nonempty string`);
21
+ }
22
+ if (options.serviceAccount !== undefined) {
23
+ const account = options.serviceAccount;
24
+ if (typeof account === "string") {
25
+ if (!account.trim())
26
+ throw new Error("Firebase serviceAccount must be a nonempty path or service account object");
27
+ } else {
28
+ if (!object(account))
29
+ throw new Error("Firebase serviceAccount must be a path or service account object");
30
+ for (const [camel, snake] of [["projectId", "project_id"], ["clientEmail", "client_email"], ["privateKey", "private_key"]]) {
31
+ const value = account[camel] ?? account[snake];
32
+ if (typeof value !== "string" || !value.trim())
33
+ throw new Error(`Firebase serviceAccount.${camel} must be a nonempty string`);
34
+ }
35
+ }
36
+ }
37
+ if (options.credential !== undefined)
38
+ credential(options.credential, "credential");
39
+ if (options.appOptions !== undefined) {
40
+ const app = options.appOptions;
41
+ if (!object(app))
42
+ throw new Error("Firebase appOptions must be an object");
43
+ const appKeys = ["credential", "databaseAuthVariableOverride", "databaseURL", "serviceAccountId", "storageBucket", "projectId", "httpAgent"];
44
+ if (Object.keys(app).some((key) => !appKeys.includes(key)))
45
+ throw new Error("Firebase appOptions contain an unknown field");
46
+ if (app.credential !== undefined)
47
+ credential(app.credential, "appOptions.credential");
48
+ for (const key of ["databaseURL", "serviceAccountId", "storageBucket", "projectId"]) {
49
+ if (app[key] !== undefined && (typeof app[key] !== "string" || !app[key].trim()))
50
+ throw new Error(`Firebase appOptions.${key} must be a nonempty string`);
51
+ }
52
+ if (app.databaseAuthVariableOverride !== undefined && app.databaseAuthVariableOverride !== null && !object(app.databaseAuthVariableOverride))
53
+ throw new Error("Firebase appOptions.databaseAuthVariableOverride must be an object or null");
54
+ if (app.httpAgent !== undefined && (!object(app.httpAgent) || typeof app.httpAgent.addRequest !== "function"))
55
+ throw new Error("Firebase appOptions.httpAgent must be an HTTP agent");
56
+ }
57
+ if (options.serviceAccount === undefined && options.credential === undefined && options.appOptions === undefined && options.configFile === undefined)
58
+ throw new Error("Firebase requires serviceAccount, credential, appOptions or configFile");
59
+ },
60
+ create(options) {
61
+ return new PushNotifier(options);
62
+ }
63
+ };
64
+ var plugin_default = definition;
65
+ export {
66
+ plugin_default as default
67
+ };
@@ -0,0 +1,18 @@
1
+ export interface Logger {
2
+ info(...args: unknown[]): void;
3
+ warn(...args: unknown[]): void;
4
+ error(...args: unknown[]): void;
5
+ debug(...args: unknown[]): void;
6
+ }
7
+ export type LifecycleHook = () => void | Promise<void>;
8
+ export interface PluginContext {
9
+ readonly logger: Logger;
10
+ readonly configDir: string;
11
+ onStart(hook: LifecycleHook): void;
12
+ onClose(hook: LifecycleHook): void;
13
+ }
14
+ export interface ServerPlugin {
15
+ readonly name: string;
16
+ setup(context: PluginContext): void | Promise<void>;
17
+ }
18
+ export declare const consoleLogger: Logger;
@@ -0,0 +1,8 @@
1
+ export type NotificationType = {
2
+ title: string;
3
+ body: string;
4
+ imageUrl?: string;
5
+ };
6
+ export type NotificationOptions = {
7
+ isBroadcast?: Boolean;
8
+ };
@@ -0,0 +1,10 @@
1
+ import type { AppOptions, Credential, ServiceAccount } from 'firebase-admin/app';
2
+ export interface PushNotifierOptions {
3
+ /** Credentials supplied here take precedence over notif.config.ts. Paths resolve against the plugin root/config directory. */
4
+ serviceAccount?: ServiceAccount | string;
5
+ credential?: Credential;
6
+ appOptions?: AppOptions;
7
+ appName?: string;
8
+ /** Legacy configuration fallback; defaults to notif.config.ts. */
9
+ configFile?: string;
10
+ }
@@ -0,0 +1,28 @@
1
+ import admin from 'firebase-admin';
2
+ import type { BatchResponse } from 'firebase-admin/messaging';
3
+ import type { PluginContext, ServerPlugin } from '../support.js';
4
+ import type { NotificationType, NotificationOptions } from '../types/NotificationTypes.js';
5
+ import type { PushNotifierOptions } from '../types/PushNotifierOptions.js';
6
+ type Cleanup = () => void | Promise<void>;
7
+ export default class PushNotifier implements ServerPlugin {
8
+ readonly name = "push-firebase";
9
+ static FirebaseAdmin: typeof admin;
10
+ static instance: number;
11
+ private static app;
12
+ private static initializing;
13
+ private static closing;
14
+ private static logger;
15
+ private readonly rootDir;
16
+ private readonly options;
17
+ private configDir;
18
+ constructor(options?: PushNotifierOptions);
19
+ constructor(path: string, options?: PushNotifierOptions);
20
+ setup(context: PluginContext): Promise<void>;
21
+ /** Explicit standalone initialization; constructing this class only records options. */
22
+ init(options?: PushNotifierOptions, registerCleanup?: (cleanup: Cleanup) => void): Promise<void>;
23
+ private readAccount;
24
+ static close(): Promise<void>;
25
+ static sendNotifications(tokens: string[], message: NotificationType, metadata?: any, options?: NotificationOptions): Promise<BatchResponse | undefined>;
26
+ static sendNotification(token: string, message: NotificationType, metadata?: any, options?: NotificationOptions): Promise<string | undefined>;
27
+ }
28
+ export {};
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "@beespoke/micro-server-push-firebase",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.0.5",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ },
12
+ "./plugin": {
13
+ "types": "./dist/plugin/index.d.ts",
14
+ "import": "./dist/plugin/index.js",
15
+ "default": "./dist/plugin/index.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "scripts": {
22
+ "build": "bun ../../scripts/build-package.ts",
23
+ "test": "bun test tests"
24
+ },
25
+ "dependencies": {
26
+ "firebase-admin": "^12.4.0",
27
+ "@types/node": "^24.0.0"
28
+ },
29
+ "peerDependencies": {
30
+ "@beespoke/micro-server": "^2.0.5"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "@beespoke/micro-server": {
34
+ "optional": true
35
+ }
36
+ },
37
+ "license": "MIT"
38
+ }