@magicpages/ghost-typesense-webhook 0.0.0

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/.eslintrc.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": ["../../.eslintrc.json"],
3
+ "ignorePatterns": ["dist/**/*"]
4
+ }
package/CHANGELOG.md ADDED
@@ -0,0 +1,46 @@
1
+ # @magicpages/ghost-typesense-webhook
2
+
3
+ ## 2.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 3d371be: Initial release of Ghost Typesense integration packages:
8
+ - Type-safe configuration and utilities
9
+ - Core functionality for Ghost-Typesense synchronization
10
+ - CLI tool for content management
11
+ - Webhook handler for real-time updates
12
+
13
+ ### Patch Changes
14
+
15
+ - Updated dependencies [3d371be]
16
+ - @magicpages/ghost-typesense-config@2.0.0
17
+ - @magicpages/ghost-typesense-core@2.0.0
18
+
19
+ ## 2.0.0
20
+
21
+ ### Major Changes
22
+
23
+ - 35c8344: Initial release of Ghost Typesense integration packages:
24
+
25
+ ### Patch Changes
26
+
27
+ - Updated dependencies [35c8344]
28
+ - @magicpages/ghost-typesense-config@2.0.0
29
+ - @magicpages/ghost-typesense-core@2.0.0
30
+
31
+ ## 1.0.0
32
+
33
+ ### Major Changes
34
+
35
+ - ddd5315: Initial release of the Ghost Typesense integration packages:
36
+
37
+ - CLI tool for managing Ghost content in Typesense
38
+ - Webhook handler for real-time content synchronization
39
+ - Core functionality for Ghost-Typesense integration
40
+ - Configuration types and utilities
41
+
42
+ ### Patch Changes
43
+
44
+ - Updated dependencies [ddd5315]
45
+ - @magicpages/ghost-typesense-core@1.0.0
46
+ - @magicpages/ghost-typesense-config@1.0.0
package/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # Ghost Typesense Webhook Handler
2
+
3
+ A production-ready Netlify Function that keeps your [Typesense](https://typesense.org/) search index synchronized with your [Ghost](https://ghost.org/) blog content in real-time. This webhook handler automatically processes content updates from Ghost and reflects them in your Typesense search index.
4
+
5
+ [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/magicpages/ghost-typesense)
6
+
7
+ ## Features
8
+
9
+ The webhook handler provides seamless integration between Ghost and Typesense. It enables real-time content synchronization and automatic handling of post publishing, updates, unpublishing, and deleting. The handler implements type-safe request processing with runtime validation and includes comprehensive error handling and logging capabilities.
10
+
11
+ ## Deployment
12
+
13
+ ### Option 1: One-Click Deploy (Recommended)
14
+
15
+ 1. Click the "Deploy to Netlify" button above
16
+ 2. Connect your GitHub account
17
+ 3. Configure the required environment variables
18
+ 4. Deploy the function
19
+
20
+ ### Option 2: Manual Deployment
21
+
22
+ 1. Clone the repository:
23
+ ```bash
24
+ git clone https://github.com/magicpages/ghost-typesense.git
25
+ cd ghost-typesense
26
+ ```
27
+
28
+ 2. Install dependencies:
29
+ ```bash
30
+ npm install
31
+ ```
32
+
33
+ 3. Build the project:
34
+ ```bash
35
+ npm run build
36
+ ```
37
+
38
+ 4. Deploy using the Netlify CLI:
39
+ ```bash
40
+ netlify deploy --prod
41
+ ```
42
+
43
+ ## Configuration
44
+
45
+ ### Environment Variables
46
+
47
+ Configure the following environment variables in your Netlify dashboard:
48
+
49
+ | Variable | Description | Example |
50
+ |----------|-------------|---------|
51
+ | `GHOST_URL` | Your Ghost blog URL | `https://blog.example.com` |
52
+ | `GHOST_CONTENT_API_KEY` | Ghost Content API key | `1234abcd...` |
53
+ | `TYPESENSE_HOST` | Typesense server host | `search.example.com` |
54
+ | `TYPESENSE_API_KEY` | Typesense API key (full API access, not search-only) | `xyz789...` |
55
+ | `COLLECTION_NAME` | Typesense collection name | `posts` |
56
+ | `WEBHOOK_SECRET` | A secure random string to validate webhook requests | `your-secret-key` |
57
+
58
+ ### Ghost Integration Setup
59
+
60
+ 1. Access your Ghost Admin panel
61
+ 2. Navigate to Settings → Integrations
62
+ 3. Click "Add custom integration"
63
+ 4. Name your integration (e.g., "Typesense Search")
64
+ 5. Copy the Content API Key
65
+ 6. Generate a secure random string for your webhook secret:
66
+ ```bash
67
+ openssl rand -hex 32
68
+ ```
69
+ 7. Under Webhooks, add the following webhooks:
70
+
71
+ | Name | Event | Target URL |
72
+ |------|--------|------------|
73
+ | Post published | Post published | `https://your-netlify-site.netlify.app/.netlify/functions/handler?secret=your-secret-key` |
74
+ | Post updated | Post updated | `https://your-netlify-site.netlify.app/.netlify/functions/handler?secret=your-secret-key` |
75
+ | Post unpublished | Post unpublished | `https://your-netlify-site.netlify.app/.netlify/functions/handler?secret=your-secret-key` |
76
+ | Post deleted | Post deleted | `https://your-netlify-site.netlify.app/.netlify/functions/handler?secret=your-secret-key` |
77
+
78
+ Make sure to replace `your-secret-key` with the same secure random string you set in your Netlify environment variables.
79
+
80
+ ## Local Development
81
+
82
+ 1. Install dependencies:
83
+ ```bash
84
+ npm install
85
+ ```
86
+
87
+ 2. Create a `.env` file with your configuration:
88
+ ```env
89
+ GHOST_URL=https://your-blog.ghost.io
90
+ GHOST_CONTENT_API_KEY=your_content_api_key
91
+ TYPESENSE_HOST=your_typesense_host
92
+ TYPESENSE_API_KEY=your_typesense_api_key
93
+ COLLECTION_NAME=posts
94
+ WEBHOOK_SECRET=your-development-secret
95
+ ```
96
+
97
+ 3. Start the development server:
98
+ ```bash
99
+ npm run dev
100
+ ```
101
+
102
+ 4. Send webhook events to the local server from a local Ghost instance, including your secret:
103
+ `http://localhost:8888/.netlify/functions/handler?secret=your-development-secret`
104
+
105
+ ## License
106
+
107
+ This project is licensed under the MIT License - see the [LICENSE](../../LICENSE) file for details.
package/netlify.toml ADDED
@@ -0,0 +1,16 @@
1
+ [build]
2
+ command = "npm run build"
3
+ functions = "dist"
4
+ publish = "public"
5
+
6
+ [functions]
7
+ node_bundler = "esbuild"
8
+ included_files = ["dist/**"]
9
+
10
+ [template.environment]
11
+ GHOST_URL = "Your Ghost site URL"
12
+ GHOST_CONTENT_API_KEY = "Your Ghost Content API key"
13
+ TYPESENSE_HOST = "Your Typesense host"
14
+ TYPESENSE_API_KEY = "Your Typesense API key"
15
+ COLLECTION_NAME = "Name of your Typesense collection (default: posts)"
16
+ WEBHOOK_SECRET = "A secure random string to validate webhook requests"
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@magicpages/ghost-typesense-webhook",
3
+ "version": "0.0.0",
4
+ "description": "Webhook handler for real-time Ghost content synchronization with Typesense",
5
+ "author": "MagicPages",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/magicpages/ghost-typesense.git",
10
+ "directory": "apps/webhook-handler"
11
+ },
12
+ "scripts": {
13
+ "build": "tsup src/handler.ts --format cjs --dts",
14
+ "clean": "rimraf dist",
15
+ "dev": "tsup src/handler.ts --format cjs --dts --watch",
16
+ "lint": "eslint src --ext .ts",
17
+ "test": "vitest run",
18
+ "typecheck": "tsc --noEmit"
19
+ },
20
+ "dependencies": {
21
+ "@magicpages/ghost-typesense-config": "*",
22
+ "@magicpages/ghost-typesense-core": "*",
23
+ "@netlify/functions": "^2.5.1",
24
+ "zod": "^3.22.4"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^20.11.17",
28
+ "tsup": "^8.0.1",
29
+ "rimraf": "^5.0.5",
30
+ "vitest": "^1.2.2",
31
+ "typescript": "^5.3.3"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ }
36
+ }
@@ -0,0 +1,175 @@
1
+ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
2
+ import type { HandlerEvent, HandlerResponse } from '@netlify/functions';
3
+ import { handler } from '../handler';
4
+ import { GhostTypesenseManager } from '@magicpages/ghost-typesense-core';
5
+
6
+ // Mock environment variables
7
+ const mockEnv = {
8
+ GHOST_URL: 'https://test.com',
9
+ GHOST_CONTENT_API_KEY: 'test-key',
10
+ TYPESENSE_HOST: 'localhost',
11
+ TYPESENSE_API_KEY: 'test-key',
12
+ COLLECTION_NAME: 'test-collection',
13
+ WEBHOOK_SECRET: 'test-secret'
14
+ };
15
+
16
+ Object.entries(mockEnv).forEach(([key, value]) => {
17
+ vi.stubEnv(key, value);
18
+ });
19
+
20
+ // Mock the core package
21
+ vi.mock('@magicpages/ghost-typesense-core', () => {
22
+ const indexPost = vi.fn().mockResolvedValue(undefined);
23
+ const deletePost = vi.fn().mockResolvedValue(undefined);
24
+ return {
25
+ GhostTypesenseManager: vi.fn().mockImplementation(() => ({
26
+ indexPost,
27
+ deletePost
28
+ }))
29
+ };
30
+ });
31
+
32
+ describe('Webhook Handler', () => {
33
+ beforeEach(() => {
34
+ vi.clearAllMocks();
35
+ });
36
+
37
+ const createEvent = (overrides: Partial<HandlerEvent> = {}): HandlerEvent => ({
38
+ httpMethod: 'POST',
39
+ headers: {},
40
+ multiValueHeaders: {},
41
+ queryStringParameters: {},
42
+ multiValueQueryStringParameters: {},
43
+ path: '/',
44
+ body: null,
45
+ rawUrl: '',
46
+ rawQuery: '',
47
+ isBase64Encoded: false,
48
+ ...overrides
49
+ });
50
+
51
+ const parseResponse = (response: HandlerResponse | void): HandlerResponse => {
52
+ if (!response) {
53
+ throw new Error('Handler returned void');
54
+ }
55
+ return {
56
+ ...response,
57
+ body: response.body || ''
58
+ };
59
+ };
60
+
61
+ const parseResponseBody = <T>(response: HandlerResponse): T => {
62
+ if (!response.body) {
63
+ throw new Error('Response body is empty');
64
+ }
65
+ return JSON.parse(response.body) as T;
66
+ };
67
+
68
+ const mockContext = {
69
+ awsRequestId: '123',
70
+ callbackWaitsForEmptyEventLoop: true,
71
+ functionName: 'test',
72
+ functionVersion: '1',
73
+ invokedFunctionArn: 'test',
74
+ memoryLimitInMB: '128',
75
+ logGroupName: 'test',
76
+ logStreamName: 'test',
77
+ identity: undefined,
78
+ clientContext: undefined,
79
+ getRemainingTimeInMillis: () => 1000,
80
+ done: () => {},
81
+ fail: () => {},
82
+ succeed: () => {}
83
+ };
84
+
85
+ it('should return 401 if no secret provided', async () => {
86
+ const event = createEvent();
87
+ const response = parseResponse(await handler(event, mockContext));
88
+
89
+ expect(response.statusCode, 'Status code').toBe(401);
90
+ expect(parseResponseBody<{ error: string }>(response), 'Response body').toStrictEqual({ error: 'Missing webhook secret' });
91
+ });
92
+
93
+ it('should return 401 if invalid secret provided', async () => {
94
+ const event = createEvent({
95
+ queryStringParameters: { secret: 'wrong-secret' }
96
+ });
97
+
98
+ const response = parseResponse(await handler(event, mockContext));
99
+
100
+ expect(response.statusCode, 'Status code').toBe(401);
101
+ expect(parseResponseBody<{ error: string }>(response), 'Response body').toStrictEqual({ error: 'Invalid webhook secret' });
102
+ });
103
+
104
+ it('should return 405 for non-POST requests', async () => {
105
+ const event = createEvent({
106
+ httpMethod: 'GET',
107
+ queryStringParameters: { secret: 'test-secret' }
108
+ });
109
+
110
+ const response = parseResponse(await handler(event, mockContext));
111
+
112
+ expect(response.statusCode, 'Status code').toBe(405);
113
+ expect(parseResponseBody<{ error: string }>(response), 'Response body').toStrictEqual({ error: 'Method not allowed' });
114
+ });
115
+
116
+ it('should index post when published', async () => {
117
+ const event = createEvent({
118
+ queryStringParameters: { secret: 'test-secret' },
119
+ body: JSON.stringify({
120
+ post: {
121
+ current: {
122
+ id: 'test-post-1',
123
+ title: 'Test Post',
124
+ slug: 'test-post-1',
125
+ html: '<p>Test content</p>',
126
+ status: 'published',
127
+ visibility: 'public',
128
+ updated_at: '2024-02-09T12:00:00.000Z',
129
+ published_at: '2024-02-09T12:00:00.000Z',
130
+ custom_excerpt: 'Test excerpt',
131
+ feature_image: null
132
+ }
133
+ }
134
+ })
135
+ });
136
+
137
+ const response = parseResponse(await handler(event, mockContext));
138
+
139
+ expect(GhostTypesenseManager, 'Manager constructor').toHaveBeenCalledTimes(1);
140
+ const mockManager = (GhostTypesenseManager as unknown as Mock).mock.results[0]?.value;
141
+ expect(mockManager.indexPost, 'Index post').toHaveBeenCalledWith('test-post-1');
142
+ expect(response.statusCode, 'Status code').toBe(200);
143
+ expect(parseResponseBody<{ message: string }>(response), 'Response body').toStrictEqual({ message: 'Post indexed in Typesense' });
144
+ });
145
+
146
+ it('should delete post when unpublished', async () => {
147
+ const event = createEvent({
148
+ queryStringParameters: { secret: 'test-secret' },
149
+ body: JSON.stringify({
150
+ post: {
151
+ current: {
152
+ id: 'test-post-1',
153
+ title: 'Test Post',
154
+ slug: 'test-post-1',
155
+ html: '<p>Test content</p>',
156
+ status: 'draft',
157
+ visibility: 'public',
158
+ updated_at: '2024-02-09T12:00:00.000Z',
159
+ published_at: '2024-02-09T12:00:00.000Z',
160
+ custom_excerpt: 'Test excerpt',
161
+ feature_image: null
162
+ }
163
+ }
164
+ })
165
+ });
166
+
167
+ const response = parseResponse(await handler(event, mockContext));
168
+
169
+ expect(GhostTypesenseManager, 'Manager constructor').toHaveBeenCalledTimes(1);
170
+ const mockManager = (GhostTypesenseManager as unknown as Mock).mock.results[0]?.value;
171
+ expect(mockManager.deletePost, 'Delete post').toHaveBeenCalledWith('test-post-1');
172
+ expect(response.statusCode, 'Status code').toBe(200);
173
+ expect(parseResponseBody<{ message: string }>(response), 'Response body').toStrictEqual({ message: 'Post removed from Typesense' });
174
+ });
175
+ });
package/src/handler.ts ADDED
@@ -0,0 +1,146 @@
1
+ import { Handler } from '@netlify/functions';
2
+ import { z } from 'zod';
3
+ import { createDefaultConfig } from '@magicpages/ghost-typesense-config';
4
+ import { GhostTypesenseManager } from '@magicpages/ghost-typesense-core';
5
+
6
+ // Validate environment variables
7
+ const EnvSchema = z.object({
8
+ GHOST_URL: z.string().url(),
9
+ GHOST_CONTENT_API_KEY: z.string().min(1),
10
+ TYPESENSE_HOST: z.string().min(1),
11
+ TYPESENSE_API_KEY: z.string().min(1),
12
+ COLLECTION_NAME: z.string().min(1).default('posts'),
13
+ WEBHOOK_SECRET: z.string().min(1)
14
+ });
15
+
16
+ // Ghost webhook payload schema
17
+ const WebhookSchema = z.object({
18
+ post: z.object({
19
+ current: z.object({
20
+ id: z.string(),
21
+ title: z.string(),
22
+ slug: z.string(),
23
+ html: z.string(),
24
+ status: z.string(),
25
+ visibility: z.string(),
26
+ updated_at: z.string(),
27
+ published_at: z.string().nullable(),
28
+ custom_excerpt: z.string().nullable().optional(),
29
+ feature_image: z.string().nullable().optional(),
30
+ tags: z.array(z.object({
31
+ name: z.string()
32
+ })).optional(),
33
+ authors: z.array(z.object({
34
+ name: z.string()
35
+ })).optional()
36
+ }).optional(),
37
+ previous: z.object({
38
+ updated_at: z.string(),
39
+ html: z.string().optional(),
40
+ plaintext: z.string().optional()
41
+ }).optional()
42
+ })
43
+ });
44
+
45
+ const handler: Handler = async (event) => {
46
+ try {
47
+ // Log request info
48
+ console.log('\nšŸ”” Incoming webhook request');
49
+ console.log('šŸ“ Method:', event.httpMethod);
50
+
51
+ // Validate environment variables
52
+ const env = EnvSchema.parse(process.env);
53
+ console.log('āœ… Environment loaded successfully');
54
+
55
+ // Validate webhook secret
56
+ const secret = event.queryStringParameters?.secret;
57
+ if (!secret) {
58
+ console.log('āŒ No secret provided in request');
59
+ return {
60
+ statusCode: 401,
61
+ body: JSON.stringify({ error: 'Missing webhook secret' })
62
+ };
63
+ }
64
+
65
+ if (secret !== env.WEBHOOK_SECRET) {
66
+ console.log('🚫 Invalid secret provided');
67
+ return {
68
+ statusCode: 401,
69
+ body: JSON.stringify({ error: 'Invalid webhook secret' })
70
+ };
71
+ }
72
+
73
+ console.log('šŸ” Webhook secret validated');
74
+
75
+ // Create configuration
76
+ const config = createDefaultConfig(
77
+ env.GHOST_URL,
78
+ env.GHOST_CONTENT_API_KEY,
79
+ env.TYPESENSE_HOST,
80
+ env.TYPESENSE_API_KEY,
81
+ env.COLLECTION_NAME
82
+ );
83
+ console.log('āš™ļø Configuration loaded');
84
+
85
+ // Initialize manager
86
+ const manager = new GhostTypesenseManager(config);
87
+ console.log('šŸ”„ Typesense manager initialized');
88
+
89
+ // Only process POST requests
90
+ if (event.httpMethod !== 'POST') {
91
+ console.log('āš ļø Invalid HTTP method:', event.httpMethod);
92
+ return {
93
+ statusCode: 405,
94
+ body: JSON.stringify({ error: 'Method not allowed' })
95
+ };
96
+ }
97
+
98
+ // Parse and validate webhook payload
99
+ if (!event.body) {
100
+ console.log('āŒ No request body provided');
101
+ throw new Error('No request body');
102
+ }
103
+
104
+ const webhook = WebhookSchema.parse(JSON.parse(event.body));
105
+ const { post } = webhook;
106
+ console.log('šŸ“¦ Webhook payload validated');
107
+
108
+ // Handle different webhook events based on post status changes
109
+ if (post.current) {
110
+ const { id, status, visibility, title } = post.current;
111
+ console.log(`šŸ“„ Processing post: "${title}" (${id})`);
112
+
113
+ if (status === 'published' && visibility === 'public') {
114
+ console.log('šŸ“ Indexing published post');
115
+ await manager.indexPost(id);
116
+ console.log('✨ Post indexed successfully');
117
+ return {
118
+ statusCode: 200,
119
+ body: JSON.stringify({ message: 'Post indexed in Typesense' })
120
+ };
121
+ } else {
122
+ console.log('šŸ—‘ļø Removing unpublished/private post');
123
+ await manager.deletePost(id);
124
+ console.log('✨ Post removed successfully');
125
+ return {
126
+ statusCode: 200,
127
+ body: JSON.stringify({ message: 'Post removed from Typesense' })
128
+ };
129
+ }
130
+ }
131
+
132
+ console.log('ā„¹ļø No action required');
133
+ return {
134
+ statusCode: 200,
135
+ body: JSON.stringify({ message: 'No action required' })
136
+ };
137
+ } catch (error) {
138
+ console.error('āŒ Error processing webhook:', error);
139
+ return {
140
+ statusCode: 500,
141
+ body: JSON.stringify({ error: (error as Error).message })
142
+ };
143
+ }
144
+ };
145
+
146
+ export { handler };