@walkeros/server-source-express 4.1.0-next-1778668930820 → 4.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,352 @@
1
+ # @walkeros/server-source-express
2
+
3
+ ## 4.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 13aaeaa: `Source.Context` no longer exposes `setIngest` or `setRespond`.
8
+ Server sources handling concurrent inbound requests must call
9
+ `context.withScope(rawScope, respond, body)` to bind per-request ingest and
10
+ respond. Browser and other single-scope sources keep working without changes.
11
+
12
+ ### Patch Changes
13
+
14
+ - Updated dependencies [e155ff8]
15
+ - Updated dependencies [e800974]
16
+ - Updated dependencies [e155ff8]
17
+ - Updated dependencies [1a8f2d7]
18
+ - Updated dependencies [1a8f2d7]
19
+ - Updated dependencies [b276173]
20
+ - Updated dependencies [dd9f5ad]
21
+ - Updated dependencies [c60ef35]
22
+ - Updated dependencies [adeebea]
23
+ - Updated dependencies [13aaeaa]
24
+ - Updated dependencies [e800974]
25
+ - Updated dependencies [adeebea]
26
+ - Updated dependencies [6cdc362]
27
+ - Updated dependencies [e800974]
28
+ - Updated dependencies [e800974]
29
+ - Updated dependencies [058f7ed]
30
+ - Updated dependencies [28a8ac2]
31
+ - Updated dependencies [fd6076e]
32
+ - @walkeros/core@4.1.0
33
+ - @walkeros/collector@4.1.0
34
+
35
+ ## 4.0.2
36
+
37
+ ### Patch Changes
38
+
39
+ - Updated dependencies [a6a0ea7]
40
+ - @walkeros/core@4.0.2
41
+ - @walkeros/collector@4.0.2
42
+
43
+ ## 4.0.1
44
+
45
+ ### Patch Changes
46
+
47
+ - 70e4d89: Fix events being silently dropped when posted via
48
+ `navigator.sendBeacon`. The browser forces
49
+ `Content-Type: text/plain;charset=UTF-8` for beacon requests even when the
50
+ payload is JSON, which previously caused the express middleware to skip body
51
+ parsing and the GCP Cloud Functions handler to treat the body as an opaque
52
+ string, both falling through to an empty-event push. Express now accepts
53
+ `text/plain` bodies through `express.json()`, and the Cloud Functions handler
54
+ attempts `JSON.parse` on string bodies before classifying the request.
55
+ - Updated dependencies [cb265eb]
56
+ - Updated dependencies [381dfe7]
57
+ - Updated dependencies [1524275]
58
+ - Updated dependencies [03d7055]
59
+ - @walkeros/collector@4.0.1
60
+ - @walkeros/core@4.0.1
61
+
62
+ ## 4.0.0
63
+
64
+ ### Major Changes
65
+
66
+ - 93ea9c4: Event model v4: breaking changes to the `Event`, `Source`, and
67
+ `Entity` shapes.
68
+ - `event.id` is now a W3C span_id (16 lowercase hex chars), generated by the
69
+ collector. Reference: W3C Trace Context (W3C Recommendation, January 2020).
70
+ - `event.version`, `event.group`, `event.count` are removed.
71
+ - `source.type` is now the source kind (e.g. `browser`, `gtag`, `mcp`, `cli`).
72
+ New `source.platform` holds the runtime (`web` | `server` | `app` | ...).
73
+ - `source.id` and `source.previous_id` are removed.
74
+ - Browser source now sets `source.url` and `source.referrer`.
75
+ - MCP source sets `source.tool` per emission. CLI source sets
76
+ `source.command`.
77
+ - `Entity.nested` and `Entity.context` are now optional. Root `event.nested`
78
+ and `event.context` remain required.
79
+ - Each source self-registers via TypeScript module augmentation of `SourceMap`
80
+ in `@walkeros/core`.
81
+ - App-side coordination (`/workspaces/developer/app`) is a follow-up plan, not
82
+ part of this release. Telemetry from v4 CLI/MCP will not validate against
83
+ the existing app schema until that follow-up ships.
84
+ - `Mapping.Rule.skip` is renamed to `Mapping.Rule.silent`. Customer flow.json
85
+ configs using `skip: true` in mapping rules must rename to `silent: true`.
86
+ Hard cut: no legacy alias, the field is gone.
87
+
88
+ ### Patch Changes
89
+
90
+ - Updated dependencies [93ea9c4]
91
+ - Updated dependencies [465775c]
92
+ - Updated dependencies [942a7fe]
93
+ - Updated dependencies [cfc7469]
94
+ - Updated dependencies [8e06b1f]
95
+ - Updated dependencies [3d50dd6]
96
+ - Updated dependencies [1ef33d9]
97
+ - @walkeros/core@4.0.0
98
+ - @walkeros/collector@4.0.0
99
+
100
+ ## 3.4.2
101
+
102
+ ### Patch Changes
103
+
104
+ - @walkeros/collector@3.4.2
105
+ - @walkeros/core@3.4.2
106
+
107
+ ## 3.4.1
108
+
109
+ ### Patch Changes
110
+
111
+ - Updated dependencies [12adf24]
112
+ - Updated dependencies [75aa26b]
113
+ - @walkeros/core@3.4.1
114
+ - @walkeros/collector@3.4.1
115
+
116
+ ## 3.4.0
117
+
118
+ ### Minor Changes
119
+
120
+ - 724f97e: Migrate every step example in every walkerOS package to the
121
+ standardized `[callable, ...args][]` shape introduced in `@walkeros/core`.
122
+ Every step example's `out` is now an array of effect tuples whose first
123
+ element is the callable's public SDK name (`'gtag'`, `'analytics.track'`,
124
+ `'fbq'`, `'dataLayer.push'`, `'sendServer'`, `'fetch'`, `'trackClient.track'`,
125
+ `'amplitude.track'`, `'fs.writeFile'`, `'producer.send'`, `'client.xadd'`,
126
+ `'client.send'`, `'dataset.table.insert'`, etc.). Source examples use `'elb'`
127
+ as the callable; transformer examples use the reserved `'return'` keyword;
128
+ store examples use store-operation callables (`'get'`, `'set'`). Tests capture
129
+ real calls on each component's spy and assert against `example.out` directly —
130
+ the hardcoded `PACKAGE_CALLS` registry in the app is no longer consulted
131
+ (emptied; plan #3 removes it structurally).
132
+
133
+ ### Patch Changes
134
+
135
+ - Updated dependencies [74940cc]
136
+ - Updated dependencies [525f5d9]
137
+ - @walkeros/core@3.4.0
138
+ - @walkeros/collector@3.4.0
139
+
140
+ ## 3.3.1
141
+
142
+ ### Patch Changes
143
+
144
+ - Updated dependencies [b10144a]
145
+ - Updated dependencies [206185a]
146
+ - Updated dependencies [50e5d09]
147
+ - Updated dependencies [32ff626]
148
+ - @walkeros/collector@3.3.1
149
+ - @walkeros/core@3.3.1
150
+
151
+ ## 3.3.0
152
+
153
+ ### Patch Changes
154
+
155
+ - Updated dependencies [2849acb]
156
+ - Updated dependencies [08c365a]
157
+ - Updated dependencies [08c365a]
158
+ - Updated dependencies [08c365a]
159
+ - Updated dependencies [08c365a]
160
+ - @walkeros/core@3.3.0
161
+ - @walkeros/collector@3.3.0
162
+
163
+ ## 3.2.0
164
+
165
+ ### Minor Changes
166
+
167
+ - f47d251: Accept non-JSON POST bodies in all server sources
168
+
169
+ Server sources no longer reject non-JSON bodies with HTTP 400. Instead, they
170
+ push an empty event `{}` to the collector, enabling `source.before`
171
+ transformers to process raw input via ingest. Raw body is available through
172
+ ingest mapping (e.g., `"rawBody": "body"`).
173
+
174
+ ### Patch Changes
175
+
176
+ - Updated dependencies [eb865e1]
177
+ - Updated dependencies [c0a53f9]
178
+ - Updated dependencies [8cdc0bb]
179
+ - Updated dependencies [f007c9f]
180
+ - Updated dependencies [bf2dc5b]
181
+ - Updated dependencies [da0b640]
182
+ - Updated dependencies [a5d25bc]
183
+ - Updated dependencies [9a99298]
184
+ - Updated dependencies [884527d]
185
+ - @walkeros/core@3.2.0
186
+ - @walkeros/collector@3.2.0
187
+
188
+ ## 3.1.1
189
+
190
+ ### Patch Changes
191
+
192
+ - @walkeros/core@3.1.1
193
+ - @walkeros/collector@3.1.1
194
+
195
+ ## 3.1.0
196
+
197
+ ### Minor Changes
198
+
199
+ - ff58828: Add env.express and env.cors to Env interface for dependency
200
+ injection. Tests and simulations can now replace the HTTP layer without
201
+ touching module imports.
202
+ - a9149e4: Rewrite createTrigger to use real HTTP requests via fetch() instead
203
+ of mocked req/res. Follows unified Trigger.CreateFn interface. Step examples
204
+ updated with trigger metadata field.
205
+
206
+ ### Patch Changes
207
+
208
+ - Updated dependencies [a9149e4]
209
+ - Updated dependencies [dfc6738]
210
+ - Updated dependencies [966342b]
211
+ - Updated dependencies [bee8ba7]
212
+ - Updated dependencies [966342b]
213
+ - Updated dependencies [df990d4]
214
+ - @walkeros/collector@3.1.0
215
+ - @walkeros/core@3.1.0
216
+
217
+ ## 3.0.2
218
+
219
+ ### Patch Changes
220
+
221
+ - @walkeros/core@3.0.2
222
+
223
+ ## 3.0.1
224
+
225
+ ### Patch Changes
226
+
227
+ - @walkeros/core@3.0.1
228
+
229
+ ## 3.0.0
230
+
231
+ ### Patch Changes
232
+
233
+ - 499e27a: Add sideEffects declarations to all packages for bundler tree-shaking
234
+ support.
235
+ - Updated dependencies [2b259b6]
236
+ - Updated dependencies [2614014]
237
+ - Updated dependencies [6ae0ee3]
238
+ - Updated dependencies [37299a9]
239
+ - Updated dependencies [499e27a]
240
+ - Updated dependencies [0e5eede]
241
+ - Updated dependencies [d11f574]
242
+ - Updated dependencies [d11f574]
243
+ - Updated dependencies [1fe337a]
244
+ - Updated dependencies [5cb84c1]
245
+ - Updated dependencies [23f218a]
246
+ - Updated dependencies [499e27a]
247
+ - Updated dependencies [c83d909]
248
+ - Updated dependencies [b6c8fa8]
249
+ - @walkeros/core@3.0.0
250
+
251
+ ## 2.1.1
252
+
253
+ ### Patch Changes
254
+
255
+ - Updated dependencies [fab477d]
256
+ - @walkeros/core@2.1.1
257
+
258
+ ## 2.1.0
259
+
260
+ ### Minor Changes
261
+
262
+ - 3eb6416: Add unified `env.respond` capability. Any step (transformer,
263
+ destination) can now customize HTTP responses via
264
+ `env.respond({ body, status?, headers? })`. Sources configure the response
265
+ handler — Express source uses createRespond for idempotent first-call-wins
266
+ semantics. CLI serve mode removed (superseded by response-capable flows).
267
+ - 66aaf2d: Runner-owned health server: The runner now provides /health and
268
+ /ready endpoints independently of flow sources. Express source's `status`
269
+ setting and fetch source's `healthPath` setting have been removed — health
270
+ endpoints are no longer source responsibilities.
271
+ - 97df0b2: Step examples: upgrade all packages to blueprint pattern with inline
272
+ mapping, no intermediate variables, no `all` export
273
+
274
+ ### Patch Changes
275
+
276
+ - Updated dependencies [7fc4cee]
277
+ - Updated dependencies [7fc4cee]
278
+ - Updated dependencies [cb2da05]
279
+ - Updated dependencies [2bbe8c8]
280
+ - Updated dependencies [3eb6416]
281
+ - Updated dependencies [02a7958]
282
+ - Updated dependencies [97df0b2]
283
+ - Updated dependencies [97df0b2]
284
+ - Updated dependencies [026c412]
285
+ - Updated dependencies [7d38d9d]
286
+ - @walkeros/core@2.1.0
287
+
288
+ ## 2.0.1
289
+
290
+ ## 1.1.0
291
+
292
+ ### Minor Changes
293
+
294
+ - bb0ab04: Add multi-path support with per-route method control. The `path`
295
+ setting is deprecated in favor of `paths` array.
296
+
297
+ ### Patch Changes
298
+
299
+ - Updated dependencies [7b2d750]
300
+ - @walkeros/core@1.4.0
301
+
302
+ ## 1.0.5
303
+
304
+ ### Patch Changes
305
+
306
+ - Updated dependencies [a4cc1ea]
307
+ - @walkeros/core@1.3.0
308
+
309
+ ## 1.0.4
310
+
311
+ ### Patch Changes
312
+
313
+ - Updated dependencies [7ad6cfb]
314
+ - @walkeros/core@1.2.2
315
+
316
+ ## 1.0.3
317
+
318
+ ### Patch Changes
319
+
320
+ - Updated dependencies [6256c12]
321
+ - @walkeros/core@1.2.1
322
+
323
+ ## 1.0.2
324
+
325
+ ### Patch Changes
326
+
327
+ - 6778ab2: Add default exports for simpler CLI flow.json configuration
328
+ - Updated dependencies [f39d9fb]
329
+ - Updated dependencies [888bbdf]
330
+ - @walkeros/core@1.2.0
331
+
332
+ ## 1.0.1
333
+
334
+ ### Patch Changes
335
+
336
+ - Updated dependencies [b65b773]
337
+ - Updated dependencies [20eca6e]
338
+ - @walkeros/core@1.1.0
339
+
340
+ ## 1.0.0
341
+
342
+ ### Major Changes
343
+
344
+ - 67c9e1d: Hello World! walkerOS v1.0.0
345
+
346
+ Open-source event data collection. Collect event data for digital analytics in
347
+ a unified and privacy-centric way.
348
+
349
+ ### Patch Changes
350
+
351
+ - Updated dependencies [67c9e1d]
352
+ - @walkeros/core@1.0.0
package/README.md CHANGED
@@ -1,7 +1,19 @@
1
+ <p align="left">
2
+ <a href="https://www.walkeros.io">
3
+ <img alt="walkerOS" title="walkerOS" src="https://www.walkeros.io/img/walkerOS_logo.svg" width="256px"/>
4
+ </a>
5
+ </p>
6
+
1
7
  # @walkeros/server-source-express
2
8
 
3
- Express server source for walkerOS - turn-key HTTP event collection server with
4
- Express.js.
9
+ Turn-key HTTP event collection server with Express.js. Runs standalone or embeds
10
+ inside an existing Express app, handles JSON POST events, pixel tracking via
11
+ GET, and configurable CORS.
12
+
13
+ [Documentation](https://www.walkeros.io/docs/sources/server/express) &bull;
14
+ [NPM Package](https://www.npmjs.com/package/@walkeros/server-source-express)
15
+ &bull;
16
+ [Source Code](https://github.com/elbwalker/walkerOS/tree/main/packages/server/sources/express)
5
17
 
6
18
  ## Installation
7
19
 
@@ -9,191 +21,18 @@ Express.js.
9
21
  npm install @walkeros/server-source-express
10
22
  ```
11
23
 
12
- ## Quick Start
13
-
14
- ### Standalone Server (Docker-style)
15
-
16
- ```typescript
17
- import { startFlow } from '@walkeros/collector';
18
- import { sourceExpress } from '@walkeros/server-source-express';
19
-
20
- const { collector } = await startFlow({
21
- sources: {
22
- express: {
23
- code: sourceExpress,
24
- config: {
25
- settings: {
26
- port: 8080, // Start server on port 8080
27
- },
28
- },
29
- },
30
- },
31
- destinations: {
32
- // Your destinations here
33
- },
34
- });
35
-
36
- // Server is now running!
37
- // POST http://localhost:8080/collect - JSON event ingestion
38
- // GET http://localhost:8080/collect - Pixel tracking
39
- // GET http://localhost:8080/health - Health check
40
- ```
41
-
42
- ### App-Only Mode (Custom Integration)
43
-
44
- ```typescript
45
- const { collector } = await startFlow({
46
- sources: {
47
- express: {
48
- code: sourceExpress,
49
- config: {
50
- settings: {
51
- // No port = app only, no server started
52
- paths: ['/events'],
53
- cors: false, // Handle CORS with your own middleware
54
- },
55
- },
56
- },
57
- },
58
- });
59
-
60
- // Access the Express app
61
- const expressSource = collector.sources.express;
62
- const app = expressSource.app;
63
-
64
- // Add custom middleware
65
- app.use(yourAuthMiddleware);
66
-
67
- // Add custom routes
68
- app.get('/custom', customHandler);
69
-
70
- // Start server manually
71
- app.listen(3000);
72
- ```
73
-
74
- ## Configuration
75
-
76
- ### Settings
77
-
78
- ```typescript
79
- interface Settings {
80
- /**
81
- * HTTP server port to listen on
82
- * If not provided, server will not start (app-only mode)
83
- * @optional
84
- */
85
- port?: number;
86
-
87
- /**
88
- * Route paths to register
89
- * String shorthand registers GET+POST. RouteConfig allows per-route method control.
90
- * @default ['/collect']
91
- */
92
- paths?: Array<string | RouteConfig>;
93
-
94
- /**
95
- * @deprecated Use `paths` instead. Will be removed in next major.
96
- * Converted to `paths: [path]` internally.
97
- */
98
- path?: string;
99
-
100
- /**
101
- * CORS configuration
102
- * - false: Disabled
103
- * - true: Allow all origins (default)
104
- * - object: Custom CORS options
105
- * @default true
106
- */
107
- cors?: boolean | CorsOptions;
108
-
109
- /**
110
- * Enable health check endpoints
111
- * - GET /health (liveness check)
112
- * - GET /ready (readiness check)
113
- * @default true
114
- */
115
- status?: boolean;
116
- }
117
-
118
- interface RouteConfig {
119
- /** Express route path (supports wildcards like /api/*) */
120
- path: string;
121
- /** HTTP methods to register. OPTIONS always included for CORS. */
122
- methods?: ('GET' | 'POST')[];
123
- }
124
- ```
125
-
126
- ### CORS Options
127
-
128
- ```typescript
129
- interface CorsOptions {
130
- /** Allowed origins (string, array, or '*') */
131
- origin?: string | string[] | '*';
132
-
133
- /** Allowed HTTP methods */
134
- methods?: string[];
135
-
136
- /** Allowed request headers */
137
- headers?: string[];
138
-
139
- /** Allow credentials (cookies, authorization) */
140
- credentials?: boolean;
141
-
142
- /** Preflight cache duration in seconds */
143
- maxAge?: number;
144
- }
145
- ```
146
-
147
- ## HTTP Methods
148
-
149
- ### POST - Standard Event Ingestion
150
-
151
- Send events as JSON in the request body.
152
-
153
- **Request:**
154
-
155
- ```bash
156
- curl -X POST http://localhost:8080/collect \
157
- -H "Content-Type: application/json" \
158
- -d '{
159
- "event": "page view",
160
- "data": {
161
- "title": "Home Page",
162
- "path": "/"
163
- },
164
- "user": {
165
- "id": "user123"
166
- }
167
- }'
168
- ```
169
-
170
- **Response:**
24
+ ## Quick start
171
25
 
172
26
  ```json
173
27
  {
174
- "success": true,
175
- "timestamp": 1647261462000
176
- }
177
- ```
178
-
179
- ### POST - Non-JSON Bodies (Raw Input)
180
-
181
- For non-JSON bodies (Base64, XML, form data), the source pushes an empty event
182
- `{}` to the collector. Use `source.before` transformers to decode raw input
183
- available via ingest:
184
-
185
- ```json
186
- {
187
- "sources": {
188
- "http": {
189
- "package": "@walkeros/server-source-express",
190
- "before": "base64Decoder",
191
- "config": {
192
- "ingest": {
193
- "map": {
194
- "rawBody": "body",
195
- "contentType": "headers.content-type"
196
- }
28
+ "version": 4,
29
+ "flows": {
30
+ "default": {
31
+ "config": { "platform": "server" },
32
+ "sources": {
33
+ "express": {
34
+ "package": "@walkeros/server-source-express",
35
+ "config": {}
197
36
  }
198
37
  }
199
38
  }
@@ -201,425 +40,18 @@ available via ingest:
201
40
  }
202
41
  ```
203
42
 
204
- The `base64Decoder` transformer reads `context.ingest.rawBody` and returns the
205
- decoded walkerOS event.
206
-
207
- ### GET - Pixel Tracking
43
+ ## Documentation
208
44
 
209
- Send events as query parameters. Returns a 1x1 transparent GIF.
45
+ Full configuration, mapping, and examples live in the docs:
46
+ **https://www.walkeros.io/docs/sources/server/express**
210
47
 
211
- **Request:**
212
-
213
- ```html
214
- <!-- In your HTML -->
215
- <img
216
- src="http://localhost:8080/collect?event=page%20view&data[title]=Home&user[id]=user123"
217
- width="1"
218
- height="1"
219
- alt=""
220
- />
221
- ```
48
+ ## Contribute
222
49
 
223
- **Response:**
224
-
225
- ```
226
- Content-Type: image/gif
227
- Cache-Control: no-cache, no-store, must-revalidate
228
-
229
- [1x1 transparent GIF binary]
230
- ```
231
-
232
- ### OPTIONS - CORS Preflight
233
-
234
- Automatically handled for cross-origin requests.
235
-
236
- **Request:**
237
-
238
- ```bash
239
- curl -X OPTIONS http://localhost:8080/collect \
240
- -H "Origin: https://example.com"
241
- ```
242
-
243
- **Response:**
244
-
245
- ```
246
- Access-Control-Allow-Origin: *
247
- Access-Control-Allow-Methods: GET, POST, OPTIONS
248
- Access-Control-Allow-Headers: Content-Type
249
-
250
- 204 No Content
251
- ```
252
-
253
- ## Health Checks
254
-
255
- ### GET /health - Liveness Check
256
-
257
- Returns server status (always returns 200 if server is running).
258
-
259
- **Response:**
260
-
261
- ```json
262
- {
263
- "status": "ok",
264
- "timestamp": 1647261462000,
265
- "source": "express"
266
- }
267
- ```
268
-
269
- ### GET /ready - Readiness Check
270
-
271
- Returns readiness status (same as health for Express source).
272
-
273
- **Response:**
274
-
275
- ```json
276
- {
277
- "status": "ready",
278
- "timestamp": 1647261462000,
279
- "source": "express"
280
- }
281
- ```
282
-
283
- ## Advanced Examples
284
-
285
- ### Custom CORS Configuration
286
-
287
- ```typescript
288
- await startFlow({
289
- sources: {
290
- express: {
291
- code: sourceExpress,
292
- config: {
293
- settings: {
294
- port: 8080,
295
- cors: {
296
- origin: ['https://app.example.com', 'https://admin.example.com'],
297
- credentials: true,
298
- methods: ['GET', 'POST', 'OPTIONS'],
299
- headers: ['Content-Type', 'Authorization'],
300
- maxAge: 86400, // 24 hours
301
- },
302
- },
303
- },
304
- },
305
- },
306
- });
307
- ```
308
-
309
- ### Disable Health Checks
310
-
311
- ```typescript
312
- await startFlow({
313
- sources: {
314
- express: {
315
- code: sourceExpress,
316
- config: {
317
- settings: {
318
- port: 8080,
319
- status: false, // Disable /health and /ready endpoints
320
- },
321
- },
322
- },
323
- },
324
- });
325
- ```
326
-
327
- ### Custom Endpoint Paths
328
-
329
- ```typescript
330
- await startFlow({
331
- sources: {
332
- express: {
333
- code: sourceExpress,
334
- config: {
335
- settings: {
336
- port: 8080,
337
- paths: ['/api/v1/events'], // Custom path (GET + POST)
338
- },
339
- },
340
- },
341
- },
342
- });
343
- ```
344
-
345
- ### Multi-Path with Method Control
346
-
347
- ```typescript
348
- await startFlow({
349
- sources: {
350
- express: {
351
- code: sourceExpress,
352
- config: {
353
- settings: {
354
- port: 8080,
355
- paths: [
356
- '/collect', // GET + POST (default)
357
- { path: '/pixel', methods: ['GET'] }, // GET only (pixel tracking)
358
- { path: '/ingest', methods: ['POST'] }, // POST only (JSON ingestion)
359
- { path: '/webhooks/*', methods: ['POST'] }, // POST wildcard
360
- ],
361
- },
362
- },
363
- },
364
- },
365
- });
366
- ```
367
-
368
- ### Ingest Metadata
369
-
370
- Extract request metadata (IP, headers) and forward it to processors and
371
- destinations:
372
-
373
- ```typescript
374
- await startFlow({
375
- sources: {
376
- express: {
377
- code: sourceExpress,
378
- config: {
379
- settings: { port: 8080 },
380
- ingest: {
381
- ip: 'ip',
382
- ua: 'headers.user-agent',
383
- origin: 'headers.origin',
384
- referer: 'headers.referer',
385
- },
386
- },
387
- },
388
- },
389
- });
390
- ```
391
-
392
- **Available ingest paths:**
393
-
394
- | Path | Description |
395
- | ----------- | ------------------------------------------------ |
396
- | `ip` | Client IP address |
397
- | `headers.*` | HTTP headers (user-agent, origin, referer, etc.) |
398
- | `protocol` | Request protocol (http/https) |
399
- | `method` | HTTP method (GET, POST, etc.) |
400
- | `hostname` | Request hostname |
401
- | `url` | Full request URL |
402
-
403
- **Advanced mapping:**
404
-
405
- ```typescript
406
- ingest: {
407
- // Custom function for geo lookup
408
- country: { fn: (req) => geoip.lookup(req.ip)?.country },
409
-
410
- // Conditional extraction
411
- devMode: {
412
- key: 'headers.x-debug',
413
- condition: (req) => req.hostname === 'localhost',
414
- },
415
-
416
- // Nested structure
417
- request: {
418
- map: {
419
- ua: 'headers.user-agent',
420
- origin: 'headers.origin',
421
- },
422
- },
423
- }
424
- ```
425
-
426
- ### Extend Express App
427
-
428
- ```typescript
429
- const { collector } = await startFlow({
430
- sources: {
431
- express: {
432
- code: sourceExpress,
433
- config: {
434
- settings: { port: 8080 },
435
- },
436
- },
437
- },
438
- });
439
-
440
- // Access Express app for advanced customization
441
- const expressSource = collector.sources.express;
442
- const app = expressSource.app;
443
-
444
- // Add authentication middleware
445
- app.use('/collect', authMiddleware);
446
-
447
- // Add rate limiting
448
- import rateLimit from 'express-rate-limit';
449
- app.use(
450
- '/collect',
451
- rateLimit({
452
- windowMs: 15 * 60 * 1000, // 15 minutes
453
- max: 100, // Limit each IP to 100 requests per windowMs
454
- }),
455
- );
456
-
457
- // Add custom logging
458
- app.use((req, res, next) => {
459
- console.log(`${req.method} ${req.path}`);
460
- next();
461
- });
462
- ```
463
-
464
- ## Event Format
465
-
466
- ### Single Event
467
-
468
- ```json
469
- {
470
- "event": "page view",
471
- "data": {
472
- "title": "Home Page",
473
- "path": "/"
474
- },
475
- "context": {
476
- "language": ["en", 0],
477
- "currency": ["USD", 0]
478
- },
479
- "user": {
480
- "id": "user123",
481
- "device": "device456"
482
- },
483
- "globals": {
484
- "appVersion": "1.0.0"
485
- },
486
- "consent": {
487
- "functional": true,
488
- "marketing": true
489
- }
490
- }
491
- ```
492
-
493
- ### Query Parameters (GET)
494
-
495
- For pixel tracking, use nested bracket notation:
496
-
497
- ```
498
- ?event=page%20view
499
- &data[title]=Home%20Page
500
- &data[path]=/
501
- &user[id]=user123
502
- &consent[marketing]=true
503
- ```
504
-
505
- This is automatically parsed by `requestToData` from `@walkeros/core`.
506
-
507
- ## Architecture
508
-
509
- ### Infrastructure Ownership
510
-
511
- The Express source **owns its HTTP infrastructure**:
512
-
513
- - ✅ Creates Express application
514
- - ✅ Configures middleware (JSON parsing, CORS)
515
- - ✅ Registers routes (POST, GET, OPTIONS)
516
- - ✅ Starts HTTP server (if port configured)
517
- - ✅ Handles graceful shutdown (SIGTERM, SIGINT)
518
-
519
- This design enables:
520
-
521
- 1. **Turn-key deployment** - Just specify a port and deploy
522
- 2. **Docker-friendly** - Perfect for containerized environments
523
- 3. **Flexibility** - App-only mode for custom integrations
524
-
525
- ### Request Flow
526
-
527
- ```
528
- ┌─────────────────────────────────────────┐
529
- │ HTTP Client (Browser, Server, etc.) │
530
- └─────────────────────────────────────────┘
531
-
532
- ┌─────────────────────────────────────────┐
533
- │ EXPRESS SOURCE │
534
- │ - Receives HTTP request │
535
- │ - Parses body/query params │
536
- │ - Validates request structure │
537
- │ - Calls env.push() → Collector │
538
- │ - Returns HTTP response │
539
- └─────────────────────────────────────────┘
540
-
541
- ┌─────────────────────────────────────────┐
542
- │ COLLECTOR │
543
- │ - Event validation & processing │
544
- │ - Consent management │
545
- │ - Mapping rules │
546
- │ - Routes to destinations │
547
- └─────────────────────────────────────────┘
548
- ```
549
-
550
- ## Deployment
551
-
552
- Use the [walkerOS Docker image](https://hub.docker.com/r/walkeros/flow) for
553
- deployment:
554
-
555
- ```bash
556
- # Bundle your flow with a Dockerfile
557
- walkeros bundle flow.json --dockerfile
558
-
559
- # Build and run
560
- cd dist
561
- docker build -t my-flow .
562
- docker run -p 8080:8080 my-flow
563
- ```
564
-
565
- See the [Docker documentation](https://www.walkeros.io/docs/apps/docker) for
566
- Cloud Run, Kubernetes, and other deployment options.
567
-
568
- ## Testing
569
-
570
- The package includes comprehensive tests using mocked Express Request/Response
571
- objects.
572
-
573
- **Run tests:**
574
-
575
- ```bash
576
- npm test
577
- ```
578
-
579
- **Example test:**
580
-
581
- ```typescript
582
- import { sourceExpress } from '@walkeros/server-source-express';
583
-
584
- test('should process POST event', async () => {
585
- const mockPush = jest.fn().mockResolvedValue({ event: { id: 'test' } });
586
-
587
- const source = await sourceExpress(
588
- {},
589
- {
590
- push: mockPush,
591
- command: jest.fn(),
592
- elb: jest.fn(),
593
- },
594
- );
595
-
596
- const req = {
597
- method: 'POST',
598
- body: { event: 'page view', data: { title: 'Home' } },
599
- headers: {},
600
- get: () => undefined,
601
- };
602
- const res = {
603
- status: jest.fn().returnThis(),
604
- json: jest.fn(),
605
- send: jest.fn(),
606
- set: jest.fn(),
607
- };
608
-
609
- await source.push(req, res);
610
-
611
- expect(res.status).toHaveBeenCalledWith(200);
612
- expect(mockPush).toHaveBeenCalled();
613
- });
614
- ```
50
+ Feel free to contribute by submitting an
51
+ [issue](https://github.com/elbwalker/walkerOS/issues), starting a
52
+ [discussion](https://github.com/elbwalker/walkerOS/discussions), or getting in
53
+ [contact](https://calendly.com/elb-alexander/30min).
615
54
 
616
55
  ## License
617
56
 
618
57
  MIT
619
-
620
- ## Links
621
-
622
- - [walkerOS Documentation](https://github.com/elbwalker/walkerOS)
623
- - [Express.js](https://expressjs.com/)
624
- - [GitHub Repository](https://github.com/elbwalker/walkerOS)
625
- - [Report Issues](https://github.com/elbwalker/walkerOS/issues)
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- "use strict";var mod,__create=Object.create,__defProp=Object.defineProperty,__getOwnPropDesc=Object.getOwnPropertyDescriptor,__getOwnPropNames=Object.getOwnPropertyNames,__getProtoOf=Object.getPrototypeOf,__hasOwnProp=Object.prototype.hasOwnProperty,__copyProps=(to,from,except,desc)=>{if(from&&"object"==typeof from||"function"==typeof from)for(let key of __getOwnPropNames(from))__hasOwnProp.call(to,key)||key===except||__defProp(to,key,{get:()=>from[key],enumerable:!(desc=__getOwnPropDesc(from,key))||desc.enumerable});return to},__toESM=(mod,isNodeMode,target)=>(target=null!=mod?__create(__getProtoOf(mod)):{},__copyProps(!isNodeMode&&mod&&mod.__esModule?target:__defProp(target,"default",{value:mod,enumerable:!0}),mod)),index_exports={};((target,all)=>{for(var name in all)__defProp(target,name,{get:all[name],enumerable:!0})})(index_exports,{TRANSPARENT_GIF:()=>TRANSPARENT_GIF,default:()=>index_default,setCorsHeaders:()=>setCorsHeaders,sourceExpress:()=>sourceExpress}),module.exports=(mod=index_exports,__copyProps(__defProp({},"__esModule",{value:!0}),mod));var import_express=__toESM(require("express")),import_cors=__toESM(require("cors")),import_core=require("@walkeros/core");function setCorsHeaders(res,corsConfig=!0){if(!1!==corsConfig)if(!0===corsConfig)res.set("Access-Control-Allow-Origin","*"),res.set("Access-Control-Allow-Methods","GET, POST, OPTIONS"),res.set("Access-Control-Allow-Headers","Content-Type");else{if(corsConfig.origin){const origin=Array.isArray(corsConfig.origin)?corsConfig.origin.join(", "):corsConfig.origin;res.set("Access-Control-Allow-Origin",origin)}corsConfig.methods&&res.set("Access-Control-Allow-Methods",corsConfig.methods.join(", ")),corsConfig.headers&&res.set("Access-Control-Allow-Headers",corsConfig.headers.join(", ")),corsConfig.credentials&&res.set("Access-Control-Allow-Credentials","true"),corsConfig.maxAge&&res.set("Access-Control-Max-Age",String(corsConfig.maxAge))}}var TRANSPARENT_GIF=Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7","base64"),sourceExpress=async context=>{const{config:config={},env:env}=context,expressLib=env.express??import_express.default,corsLib=env.cors??import_cors.default,userSettings=config.settings||{},settings={...userSettings,cors:userSettings.cors??!0,paths:userSettings.paths??(userSettings.path?[userSettings.path]:["/collect"])},app=expressLib();if(app.use(expressLib.json({limit:"1mb",type:["application/json","text/plain"]})),!1!==settings.cors){const corsOptions=!0===settings.cors?{}:settings.cors;app.use(corsLib(corsOptions))}const push=async(req,res)=>{try{if("OPTIONS"===req.method)return setCorsHeaders(res,settings.cors),void res.status(204).send();await context.setIngest(req);const respond=(0,import_core.createRespond)(options=>{const status=options.status??200;if(options.headers)for(const[key,value]of Object.entries(options.headers))res.set(key,value);res.status(status),"string"==typeof options.body||Buffer.isBuffer(options.body)?res.send(options.body):res.json(options.body)});if(context.setRespond(respond),"GET"===req.method){const parsedData=(0,import_core.requestToData)(req.url);return parsedData&&"object"==typeof parsedData&&await env.push(parsedData),void respond({body:TRANSPARENT_GIF,headers:{"Content-Type":"image/gif","Cache-Control":"no-cache, no-store, must-revalidate"}})}if("POST"===req.method){const eventData=req.body&&"object"==typeof req.body?req.body:{};return await env.push(eventData),void respond({body:{success:!0,timestamp:Date.now()}})}res.status(405).json({success:!1,error:"Method not allowed. Use POST, GET, or OPTIONS."})}catch(error){res.status(500).json({success:!1,error:error instanceof Error?error.message:"Internal server error"})}},resolvedPaths=settings.paths.map(entry=>"string"==typeof entry?{path:entry,methods:["GET","POST"]}:{path:entry.path,methods:entry.methods||["GET","POST"]});for(const route of resolvedPaths)route.methods.includes("POST")&&app.post(route.path,push),route.methods.includes("GET")&&app.get(route.path,push),app.options(route.path,push);let server;void 0!==settings.port&&(server=app.listen(settings.port,()=>{const routeLines=resolvedPaths.map(r=>` ${[...r.methods,"OPTIONS"].join(", ")} ${r.path}`).join("\n");env.logger.info(`Express source listening on port ${settings.port}\n`+routeLines)}));return{type:"express",config:{...config,settings:settings},push:push,httpHandler:app,app:app,server:server,destroy:_context=>new Promise((resolve,reject)=>{if(!server)return resolve();server.close(err=>err?reject(err):resolve())})}},index_default=sourceExpress;//# sourceMappingURL=index.js.map
1
+ "use strict";var mod,__create=Object.create,__defProp=Object.defineProperty,__getOwnPropDesc=Object.getOwnPropertyDescriptor,__getOwnPropNames=Object.getOwnPropertyNames,__getProtoOf=Object.getPrototypeOf,__hasOwnProp=Object.prototype.hasOwnProperty,__copyProps=(to,from,except,desc)=>{if(from&&"object"==typeof from||"function"==typeof from)for(let key of __getOwnPropNames(from))__hasOwnProp.call(to,key)||key===except||__defProp(to,key,{get:()=>from[key],enumerable:!(desc=__getOwnPropDesc(from,key))||desc.enumerable});return to},__toESM=(mod,isNodeMode,target)=>(target=null!=mod?__create(__getProtoOf(mod)):{},__copyProps(!isNodeMode&&mod&&mod.__esModule?target:__defProp(target,"default",{value:mod,enumerable:!0}),mod)),index_exports={};((target,all)=>{for(var name in all)__defProp(target,name,{get:all[name],enumerable:!0})})(index_exports,{TRANSPARENT_GIF:()=>TRANSPARENT_GIF,default:()=>index_default,setCorsHeaders:()=>setCorsHeaders,sourceExpress:()=>sourceExpress}),module.exports=(mod=index_exports,__copyProps(__defProp({},"__esModule",{value:!0}),mod));var import_express=__toESM(require("express")),import_cors=__toESM(require("cors")),import_core=require("@walkeros/core");function setCorsHeaders(res,corsConfig=!0){if(!1!==corsConfig)if(!0===corsConfig)res.set("Access-Control-Allow-Origin","*"),res.set("Access-Control-Allow-Methods","GET, POST, OPTIONS"),res.set("Access-Control-Allow-Headers","Content-Type");else{if(corsConfig.origin){const origin=Array.isArray(corsConfig.origin)?corsConfig.origin.join(", "):corsConfig.origin;res.set("Access-Control-Allow-Origin",origin)}corsConfig.methods&&res.set("Access-Control-Allow-Methods",corsConfig.methods.join(", ")),corsConfig.headers&&res.set("Access-Control-Allow-Headers",corsConfig.headers.join(", ")),corsConfig.credentials&&res.set("Access-Control-Allow-Credentials","true"),corsConfig.maxAge&&res.set("Access-Control-Max-Age",String(corsConfig.maxAge))}}var TRANSPARENT_GIF=Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7","base64"),sourceExpress=async context=>{const{config:config={},env:env}=context,expressLib=env.express??import_express.default,corsLib=env.cors??import_cors.default,userSettings=config.settings||{},settings={...userSettings,cors:userSettings.cors??!0,paths:userSettings.paths??(userSettings.path?[userSettings.path]:["/collect"])},app=expressLib();if(app.use(expressLib.json({limit:"1mb",type:["application/json","text/plain"]})),!1!==settings.cors){const corsOptions=!0===settings.cors?{}:settings.cors;app.use(corsLib(corsOptions))}const push=async(req,res)=>{try{if("OPTIONS"===req.method)return setCorsHeaders(res,settings.cors),void res.status(204).send();const respond=(0,import_core.createRespond)(options=>{const status=options.status??200;if(options.headers)for(const[key,value]of Object.entries(options.headers))res.set(key,value);res.status(status),"string"==typeof options.body||Buffer.isBuffer(options.body)?res.send(options.body):res.json(options.body)});await context.withScope(req,respond,async env2=>{if("GET"===req.method){const parsedData=(0,import_core.requestToData)(req.url);return parsedData&&"object"==typeof parsedData&&await env2.push(parsedData),void respond({body:TRANSPARENT_GIF,headers:{"Content-Type":"image/gif","Cache-Control":"no-cache, no-store, must-revalidate"}})}if("POST"===req.method){const eventData=req.body&&"object"==typeof req.body?req.body:{};return await env2.push(eventData),void respond({body:{success:!0,timestamp:Date.now()}})}res.status(405).json({success:!1,error:"Method not allowed. Use POST, GET, or OPTIONS."})})}catch(error){res.status(500).json({success:!1,error:error instanceof Error?error.message:"Internal server error"})}},resolvedPaths=settings.paths.map(entry=>"string"==typeof entry?{path:entry,methods:["GET","POST"]}:{path:entry.path,methods:entry.methods||["GET","POST"]});for(const route of resolvedPaths)route.methods.includes("POST")&&app.post(route.path,push),route.methods.includes("GET")&&app.get(route.path,push),app.options(route.path,push);let server;void 0!==settings.port&&(server=app.listen(settings.port,()=>{const routeLines=resolvedPaths.map(r=>` ${[...r.methods,"OPTIONS"].join(", ")} ${r.path}`).join("\n");env.logger.info(`Express source listening on port ${settings.port}\n`+routeLines)}));return{type:"express",config:{...config,settings:settings},push:push,httpHandler:app,app:app,server:server,destroy:_context=>new Promise((resolve,reject)=>{if(!server)return resolve();server.close(err=>err?reject(err):resolve())})}},index_default=sourceExpress;//# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/utils.ts"],"sourcesContent":["import express, { type Request, type Response } from 'express';\nimport cors from 'cors';\nimport { requestToData, createRespond } from '@walkeros/core';\nimport type { Source } from '@walkeros/core';\nimport type { ExpressSource, Types, EventRequest } from './types';\nimport { setCorsHeaders, TRANSPARENT_GIF } from './utils';\n\n/**\n * Express source initialization\n *\n * This source OWNS its HTTP server infrastructure:\n * - Creates Express application\n * - Sets up middleware (JSON parsing, CORS)\n * - Registers event collection endpoints (POST, GET, OPTIONS)\n * - Starts HTTP server (if port configured)\n * - Provides destroy() for graceful shutdown (called by runner)\n *\n * @param context Source context with config, env, logger, id\n * @returns Express source instance with app and push handler\n */\nexport const sourceExpress = async (\n context: Source.Context<Types>,\n): Promise<ExpressSource> => {\n const { config = {}, env } = context;\n const expressLib = env.express ?? express;\n const corsLib = env.cors ?? cors;\n\n // Apply defaults (no runtime validation — flow.json is developer-controlled).\n const userSettings = config.settings || {};\n const settings = {\n ...userSettings,\n cors: userSettings.cors ?? true,\n paths:\n userSettings.paths ??\n (userSettings.path ? [userSettings.path] : ['/collect']),\n };\n\n const app = expressLib();\n\n // Body parsing — JSON content-type plus text/plain so navigator.sendBeacon\n // payloads (which the browser forces to text/plain;charset=UTF-8) are also\n // parsed as JSON. 1mb default limit.\n app.use(\n expressLib.json({\n limit: '1mb',\n type: ['application/json', 'text/plain'],\n }),\n );\n\n // CORS middleware (enabled by default)\n if (settings.cors !== false) {\n const corsOptions = settings.cors === true ? {} : settings.cors;\n app.use(corsLib(corsOptions));\n }\n\n /**\n * Request handler - transforms HTTP requests into walker events\n * Supports POST (JSON body), GET (query params), and OPTIONS (CORS preflight)\n */\n const push = async (req: Request, res: Response): Promise<void> => {\n try {\n // Handle OPTIONS for CORS preflight\n if (req.method === 'OPTIONS') {\n setCorsHeaders(res, settings.cors);\n res.status(204).send();\n return;\n }\n\n // Extract ingest metadata from request (if config.ingest is defined)\n await context.setIngest(req);\n\n // Create per-request respond — first call wins (idempotent)\n const respond = createRespond((options) => {\n const status = options.status ?? 200;\n if (options.headers) {\n for (const [key, value] of Object.entries(options.headers)) {\n res.set(key, value);\n }\n }\n res.status(status);\n if (typeof options.body === 'string' || Buffer.isBuffer(options.body)) {\n res.send(options.body);\n } else {\n res.json(options.body);\n }\n });\n context.setRespond(respond);\n\n // Handle GET requests (pixel tracking)\n if (req.method === 'GET') {\n // Parse query parameters to event data using requestToData\n const parsedData = requestToData(req.url);\n\n // Send to collector\n if (parsedData && typeof parsedData === 'object') {\n await env.push(parsedData);\n }\n\n // Default: 1x1 GIF (skipped if a step already called respond)\n respond({\n body: TRANSPARENT_GIF,\n headers: {\n 'Content-Type': 'image/gif',\n 'Cache-Control': 'no-cache, no-store, must-revalidate',\n },\n });\n return;\n }\n\n // Handle POST requests (standard event ingestion)\n if (req.method === 'POST') {\n const eventData =\n req.body && typeof req.body === 'object' ? req.body : {};\n\n await env.push(eventData);\n\n respond({ body: { success: true, timestamp: Date.now() } });\n return;\n }\n\n // Unsupported method\n res.status(405).json({\n success: false,\n error: 'Method not allowed. Use POST, GET, or OPTIONS.',\n });\n } catch (error) {\n res.status(500).json({\n success: false,\n error: error instanceof Error ? error.message : 'Internal server error',\n });\n }\n };\n\n // Register handlers per route config\n const resolvedPaths = settings.paths.map((entry) =>\n typeof entry === 'string'\n ? { path: entry, methods: ['GET', 'POST'] as const }\n : {\n path: entry.path,\n methods: entry.methods || (['GET', 'POST'] as const),\n },\n );\n\n for (const route of resolvedPaths) {\n if (route.methods.includes('POST')) app.post(route.path, push);\n if (route.methods.includes('GET')) app.get(route.path, push);\n app.options(route.path, push); // Always register OPTIONS for CORS\n }\n\n // Source owns the HTTP server (if port configured)\n let server: ReturnType<typeof app.listen> | undefined;\n\n if (settings.port !== undefined) {\n server = app.listen(settings.port, () => {\n const routeLines = resolvedPaths\n .map((r) => {\n const methods = [...r.methods, 'OPTIONS'].join(', ');\n return ` ${methods} ${r.path}`;\n })\n .join('\\n');\n env.logger.info(\n `Express source listening on port ${settings.port}\\n` + routeLines,\n );\n });\n }\n\n const instance: ExpressSource = {\n type: 'express',\n config: {\n ...config,\n settings,\n },\n push,\n httpHandler: app,\n app,\n server,\n destroy: (_context) =>\n new Promise<void>((resolve, reject) => {\n if (!server) return resolve();\n server.close((err) => (err ? reject(err) : resolve()));\n }),\n };\n\n return instance;\n};\n\n// Export types (avoid re-exporting duplicates from schemas)\nexport type {\n ExpressSource,\n Config,\n PartialConfig,\n Types,\n EventRequest,\n EventResponse,\n RequestBody,\n ResponseBody,\n Push,\n Env,\n Mapping,\n InitSettings,\n Settings,\n RouteConfig,\n RouteMethod,\n} from './types';\n\n// Export utils\nexport { setCorsHeaders, TRANSPARENT_GIF } from './utils';\n\nexport default sourceExpress;\n","import type { Response } from 'express';\nimport type { CorsOptions } from './schemas';\n\n/**\n * Set CORS headers on response\n *\n * @param res Express response object\n * @param corsConfig CORS configuration (false = disabled, true = allow all, object = custom)\n */\nexport function setCorsHeaders(\n res: Response,\n corsConfig: boolean | CorsOptions = true,\n): void {\n if (corsConfig === false) return;\n\n if (corsConfig === true) {\n // Simple CORS - allow all\n res.set('Access-Control-Allow-Origin', '*');\n res.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');\n res.set('Access-Control-Allow-Headers', 'Content-Type');\n } else {\n // Custom CORS configuration\n if (corsConfig.origin) {\n const origin = Array.isArray(corsConfig.origin)\n ? corsConfig.origin.join(', ')\n : corsConfig.origin;\n res.set('Access-Control-Allow-Origin', origin);\n }\n\n if (corsConfig.methods) {\n res.set('Access-Control-Allow-Methods', corsConfig.methods.join(', '));\n }\n\n if (corsConfig.headers) {\n res.set('Access-Control-Allow-Headers', corsConfig.headers.join(', '));\n }\n\n if (corsConfig.credentials) {\n res.set('Access-Control-Allow-Credentials', 'true');\n }\n\n if (corsConfig.maxAge) {\n res.set('Access-Control-Max-Age', String(corsConfig.maxAge));\n }\n }\n}\n\n/**\n * 1x1 transparent GIF for pixel tracking\n * Base64-encoded GIF (43 bytes)\n */\nexport const TRANSPARENT_GIF = Buffer.from(\n 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',\n 'base64',\n);\n"],"mappings":"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","names":["express","cors"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/utils.ts"],"sourcesContent":["import express, { type Request, type Response } from 'express';\nimport cors from 'cors';\nimport { requestToData, createRespond } from '@walkeros/core';\nimport type { Source } from '@walkeros/core';\nimport type { ExpressSource, Types, EventRequest } from './types';\nimport { setCorsHeaders, TRANSPARENT_GIF } from './utils';\n\n/**\n * Express source initialization\n *\n * This source OWNS its HTTP server infrastructure:\n * - Creates Express application\n * - Sets up middleware (JSON parsing, CORS)\n * - Registers event collection endpoints (POST, GET, OPTIONS)\n * - Starts HTTP server (if port configured)\n * - Provides destroy() for graceful shutdown (called by runner)\n *\n * @param context Source context with config, env, logger, id\n * @returns Express source instance with app and push handler\n */\nexport const sourceExpress = async (\n context: Source.Context<Types>,\n): Promise<ExpressSource> => {\n const { config = {}, env } = context;\n const expressLib = env.express ?? express;\n const corsLib = env.cors ?? cors;\n\n // Apply defaults (no runtime validation — flow.json is developer-controlled).\n const userSettings = config.settings || {};\n const settings = {\n ...userSettings,\n cors: userSettings.cors ?? true,\n paths:\n userSettings.paths ??\n (userSettings.path ? [userSettings.path] : ['/collect']),\n };\n\n const app = expressLib();\n\n // Body parsing — JSON content-type plus text/plain so navigator.sendBeacon\n // payloads (which the browser forces to text/plain;charset=UTF-8) are also\n // parsed as JSON. 1mb default limit.\n app.use(\n expressLib.json({\n limit: '1mb',\n type: ['application/json', 'text/plain'],\n }),\n );\n\n // CORS middleware (enabled by default)\n if (settings.cors !== false) {\n const corsOptions = settings.cors === true ? {} : settings.cors;\n app.use(corsLib(corsOptions));\n }\n\n /**\n * Request handler - transforms HTTP requests into walker events\n * Supports POST (JSON body), GET (query params), and OPTIONS (CORS preflight)\n *\n * Each inbound request gets its own `withScope` invocation. The per-scope\n * env carries this request's `ingest` and `respond` end to end, so\n * concurrent requests never crosstalk through source-factory state.\n */\n const push = async (req: Request, res: Response): Promise<void> => {\n try {\n // Handle OPTIONS for CORS preflight (no scope needed: no event, no ingest)\n if (req.method === 'OPTIONS') {\n setCorsHeaders(res, settings.cors);\n res.status(204).send();\n return;\n }\n\n // Create per-request respond — first call wins (idempotent)\n const respond = createRespond((options) => {\n const status = options.status ?? 200;\n if (options.headers) {\n for (const [key, value] of Object.entries(options.headers)) {\n res.set(key, value);\n }\n }\n res.status(status);\n if (typeof options.body === 'string' || Buffer.isBuffer(options.body)) {\n res.send(options.body);\n } else {\n res.json(options.body);\n }\n });\n\n await context.withScope(req, respond, async (env) => {\n // Handle GET requests (pixel tracking)\n if (req.method === 'GET') {\n // Parse query parameters to event data using requestToData\n const parsedData = requestToData(req.url);\n\n // Send to collector\n if (parsedData && typeof parsedData === 'object') {\n await env.push(parsedData);\n }\n\n // Default: 1x1 GIF (skipped if a step already called respond)\n respond({\n body: TRANSPARENT_GIF,\n headers: {\n 'Content-Type': 'image/gif',\n 'Cache-Control': 'no-cache, no-store, must-revalidate',\n },\n });\n return;\n }\n\n // Handle POST requests (standard event ingestion)\n if (req.method === 'POST') {\n const eventData =\n req.body && typeof req.body === 'object' ? req.body : {};\n\n await env.push(eventData);\n\n respond({ body: { success: true, timestamp: Date.now() } });\n return;\n }\n\n // Unsupported method\n res.status(405).json({\n success: false,\n error: 'Method not allowed. Use POST, GET, or OPTIONS.',\n });\n });\n } catch (error) {\n res.status(500).json({\n success: false,\n error: error instanceof Error ? error.message : 'Internal server error',\n });\n }\n };\n\n // Register handlers per route config\n const resolvedPaths = settings.paths.map((entry) =>\n typeof entry === 'string'\n ? { path: entry, methods: ['GET', 'POST'] as const }\n : {\n path: entry.path,\n methods: entry.methods || (['GET', 'POST'] as const),\n },\n );\n\n for (const route of resolvedPaths) {\n if (route.methods.includes('POST')) app.post(route.path, push);\n if (route.methods.includes('GET')) app.get(route.path, push);\n app.options(route.path, push); // Always register OPTIONS for CORS\n }\n\n // Source owns the HTTP server (if port configured)\n let server: ReturnType<typeof app.listen> | undefined;\n\n if (settings.port !== undefined) {\n server = app.listen(settings.port, () => {\n const routeLines = resolvedPaths\n .map((r) => {\n const methods = [...r.methods, 'OPTIONS'].join(', ');\n return ` ${methods} ${r.path}`;\n })\n .join('\\n');\n env.logger.info(\n `Express source listening on port ${settings.port}\\n` + routeLines,\n );\n });\n }\n\n const instance: ExpressSource = {\n type: 'express',\n config: {\n ...config,\n settings,\n },\n push,\n httpHandler: app,\n app,\n server,\n destroy: (_context) =>\n new Promise<void>((resolve, reject) => {\n if (!server) return resolve();\n server.close((err) => (err ? reject(err) : resolve()));\n }),\n };\n\n return instance;\n};\n\n// Export types (avoid re-exporting duplicates from schemas)\nexport type {\n ExpressSource,\n Config,\n PartialConfig,\n Types,\n EventRequest,\n EventResponse,\n RequestBody,\n ResponseBody,\n Push,\n Env,\n Mapping,\n InitSettings,\n Settings,\n RouteConfig,\n RouteMethod,\n} from './types';\n\n// Export utils\nexport { setCorsHeaders, TRANSPARENT_GIF } from './utils';\n\nexport default sourceExpress;\n","import type { Response } from 'express';\nimport type { CorsOptions } from './schemas';\n\n/**\n * Set CORS headers on response\n *\n * @param res Express response object\n * @param corsConfig CORS configuration (false = disabled, true = allow all, object = custom)\n */\nexport function setCorsHeaders(\n res: Response,\n corsConfig: boolean | CorsOptions = true,\n): void {\n if (corsConfig === false) return;\n\n if (corsConfig === true) {\n // Simple CORS - allow all\n res.set('Access-Control-Allow-Origin', '*');\n res.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');\n res.set('Access-Control-Allow-Headers', 'Content-Type');\n } else {\n // Custom CORS configuration\n if (corsConfig.origin) {\n const origin = Array.isArray(corsConfig.origin)\n ? corsConfig.origin.join(', ')\n : corsConfig.origin;\n res.set('Access-Control-Allow-Origin', origin);\n }\n\n if (corsConfig.methods) {\n res.set('Access-Control-Allow-Methods', corsConfig.methods.join(', '));\n }\n\n if (corsConfig.headers) {\n res.set('Access-Control-Allow-Headers', corsConfig.headers.join(', '));\n }\n\n if (corsConfig.credentials) {\n res.set('Access-Control-Allow-Credentials', 'true');\n }\n\n if (corsConfig.maxAge) {\n res.set('Access-Control-Max-Age', String(corsConfig.maxAge));\n }\n }\n}\n\n/**\n * 1x1 transparent GIF for pixel tracking\n * Base64-encoded GIF (43 bytes)\n */\nexport const TRANSPARENT_GIF = Buffer.from(\n 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',\n 'base64',\n);\n"],"mappings":"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","names":["express","cors","env"]}
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- import express from"express";import cors from"cors";import{requestToData,createRespond}from"@walkeros/core";function setCorsHeaders(res,corsConfig=!0){if(!1!==corsConfig)if(!0===corsConfig)res.set("Access-Control-Allow-Origin","*"),res.set("Access-Control-Allow-Methods","GET, POST, OPTIONS"),res.set("Access-Control-Allow-Headers","Content-Type");else{if(corsConfig.origin){const origin=Array.isArray(corsConfig.origin)?corsConfig.origin.join(", "):corsConfig.origin;res.set("Access-Control-Allow-Origin",origin)}corsConfig.methods&&res.set("Access-Control-Allow-Methods",corsConfig.methods.join(", ")),corsConfig.headers&&res.set("Access-Control-Allow-Headers",corsConfig.headers.join(", ")),corsConfig.credentials&&res.set("Access-Control-Allow-Credentials","true"),corsConfig.maxAge&&res.set("Access-Control-Max-Age",String(corsConfig.maxAge))}}var TRANSPARENT_GIF=Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7","base64"),sourceExpress=async context=>{const{config:config={},env:env}=context,expressLib=env.express??express,corsLib=env.cors??cors,userSettings=config.settings||{},settings={...userSettings,cors:userSettings.cors??!0,paths:userSettings.paths??(userSettings.path?[userSettings.path]:["/collect"])},app=expressLib();if(app.use(expressLib.json({limit:"1mb",type:["application/json","text/plain"]})),!1!==settings.cors){const corsOptions=!0===settings.cors?{}:settings.cors;app.use(corsLib(corsOptions))}const push=async(req,res)=>{try{if("OPTIONS"===req.method)return setCorsHeaders(res,settings.cors),void res.status(204).send();await context.setIngest(req);const respond=createRespond(options=>{const status=options.status??200;if(options.headers)for(const[key,value]of Object.entries(options.headers))res.set(key,value);res.status(status),"string"==typeof options.body||Buffer.isBuffer(options.body)?res.send(options.body):res.json(options.body)});if(context.setRespond(respond),"GET"===req.method){const parsedData=requestToData(req.url);return parsedData&&"object"==typeof parsedData&&await env.push(parsedData),void respond({body:TRANSPARENT_GIF,headers:{"Content-Type":"image/gif","Cache-Control":"no-cache, no-store, must-revalidate"}})}if("POST"===req.method){const eventData=req.body&&"object"==typeof req.body?req.body:{};return await env.push(eventData),void respond({body:{success:!0,timestamp:Date.now()}})}res.status(405).json({success:!1,error:"Method not allowed. Use POST, GET, or OPTIONS."})}catch(error){res.status(500).json({success:!1,error:error instanceof Error?error.message:"Internal server error"})}},resolvedPaths=settings.paths.map(entry=>"string"==typeof entry?{path:entry,methods:["GET","POST"]}:{path:entry.path,methods:entry.methods||["GET","POST"]});for(const route of resolvedPaths)route.methods.includes("POST")&&app.post(route.path,push),route.methods.includes("GET")&&app.get(route.path,push),app.options(route.path,push);let server;void 0!==settings.port&&(server=app.listen(settings.port,()=>{const routeLines=resolvedPaths.map(r=>` ${[...r.methods,"OPTIONS"].join(", ")} ${r.path}`).join("\n");env.logger.info(`Express source listening on port ${settings.port}\n`+routeLines)}));return{type:"express",config:{...config,settings:settings},push:push,httpHandler:app,app:app,server:server,destroy:_context=>new Promise((resolve,reject)=>{if(!server)return resolve();server.close(err=>err?reject(err):resolve())})}},index_default=sourceExpress;export{TRANSPARENT_GIF,index_default as default,setCorsHeaders,sourceExpress};//# sourceMappingURL=index.mjs.map
1
+ import express from"express";import cors from"cors";import{requestToData,createRespond}from"@walkeros/core";function setCorsHeaders(res,corsConfig=!0){if(!1!==corsConfig)if(!0===corsConfig)res.set("Access-Control-Allow-Origin","*"),res.set("Access-Control-Allow-Methods","GET, POST, OPTIONS"),res.set("Access-Control-Allow-Headers","Content-Type");else{if(corsConfig.origin){const origin=Array.isArray(corsConfig.origin)?corsConfig.origin.join(", "):corsConfig.origin;res.set("Access-Control-Allow-Origin",origin)}corsConfig.methods&&res.set("Access-Control-Allow-Methods",corsConfig.methods.join(", ")),corsConfig.headers&&res.set("Access-Control-Allow-Headers",corsConfig.headers.join(", ")),corsConfig.credentials&&res.set("Access-Control-Allow-Credentials","true"),corsConfig.maxAge&&res.set("Access-Control-Max-Age",String(corsConfig.maxAge))}}var TRANSPARENT_GIF=Buffer.from("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7","base64"),sourceExpress=async context=>{const{config:config={},env:env}=context,expressLib=env.express??express,corsLib=env.cors??cors,userSettings=config.settings||{},settings={...userSettings,cors:userSettings.cors??!0,paths:userSettings.paths??(userSettings.path?[userSettings.path]:["/collect"])},app=expressLib();if(app.use(expressLib.json({limit:"1mb",type:["application/json","text/plain"]})),!1!==settings.cors){const corsOptions=!0===settings.cors?{}:settings.cors;app.use(corsLib(corsOptions))}const push=async(req,res)=>{try{if("OPTIONS"===req.method)return setCorsHeaders(res,settings.cors),void res.status(204).send();const respond=createRespond(options=>{const status=options.status??200;if(options.headers)for(const[key,value]of Object.entries(options.headers))res.set(key,value);res.status(status),"string"==typeof options.body||Buffer.isBuffer(options.body)?res.send(options.body):res.json(options.body)});await context.withScope(req,respond,async env2=>{if("GET"===req.method){const parsedData=requestToData(req.url);return parsedData&&"object"==typeof parsedData&&await env2.push(parsedData),void respond({body:TRANSPARENT_GIF,headers:{"Content-Type":"image/gif","Cache-Control":"no-cache, no-store, must-revalidate"}})}if("POST"===req.method){const eventData=req.body&&"object"==typeof req.body?req.body:{};return await env2.push(eventData),void respond({body:{success:!0,timestamp:Date.now()}})}res.status(405).json({success:!1,error:"Method not allowed. Use POST, GET, or OPTIONS."})})}catch(error){res.status(500).json({success:!1,error:error instanceof Error?error.message:"Internal server error"})}},resolvedPaths=settings.paths.map(entry=>"string"==typeof entry?{path:entry,methods:["GET","POST"]}:{path:entry.path,methods:entry.methods||["GET","POST"]});for(const route of resolvedPaths)route.methods.includes("POST")&&app.post(route.path,push),route.methods.includes("GET")&&app.get(route.path,push),app.options(route.path,push);let server;void 0!==settings.port&&(server=app.listen(settings.port,()=>{const routeLines=resolvedPaths.map(r=>` ${[...r.methods,"OPTIONS"].join(", ")} ${r.path}`).join("\n");env.logger.info(`Express source listening on port ${settings.port}\n`+routeLines)}));return{type:"express",config:{...config,settings:settings},push:push,httpHandler:app,app:app,server:server,destroy:_context=>new Promise((resolve,reject)=>{if(!server)return resolve();server.close(err=>err?reject(err):resolve())})}},index_default=sourceExpress;export{TRANSPARENT_GIF,index_default as default,setCorsHeaders,sourceExpress};//# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/utils.ts"],"sourcesContent":["import express, { type Request, type Response } from 'express';\nimport cors from 'cors';\nimport { requestToData, createRespond } from '@walkeros/core';\nimport type { Source } from '@walkeros/core';\nimport type { ExpressSource, Types, EventRequest } from './types';\nimport { setCorsHeaders, TRANSPARENT_GIF } from './utils';\n\n/**\n * Express source initialization\n *\n * This source OWNS its HTTP server infrastructure:\n * - Creates Express application\n * - Sets up middleware (JSON parsing, CORS)\n * - Registers event collection endpoints (POST, GET, OPTIONS)\n * - Starts HTTP server (if port configured)\n * - Provides destroy() for graceful shutdown (called by runner)\n *\n * @param context Source context with config, env, logger, id\n * @returns Express source instance with app and push handler\n */\nexport const sourceExpress = async (\n context: Source.Context<Types>,\n): Promise<ExpressSource> => {\n const { config = {}, env } = context;\n const expressLib = env.express ?? express;\n const corsLib = env.cors ?? cors;\n\n // Apply defaults (no runtime validation — flow.json is developer-controlled).\n const userSettings = config.settings || {};\n const settings = {\n ...userSettings,\n cors: userSettings.cors ?? true,\n paths:\n userSettings.paths ??\n (userSettings.path ? [userSettings.path] : ['/collect']),\n };\n\n const app = expressLib();\n\n // Body parsing — JSON content-type plus text/plain so navigator.sendBeacon\n // payloads (which the browser forces to text/plain;charset=UTF-8) are also\n // parsed as JSON. 1mb default limit.\n app.use(\n expressLib.json({\n limit: '1mb',\n type: ['application/json', 'text/plain'],\n }),\n );\n\n // CORS middleware (enabled by default)\n if (settings.cors !== false) {\n const corsOptions = settings.cors === true ? {} : settings.cors;\n app.use(corsLib(corsOptions));\n }\n\n /**\n * Request handler - transforms HTTP requests into walker events\n * Supports POST (JSON body), GET (query params), and OPTIONS (CORS preflight)\n */\n const push = async (req: Request, res: Response): Promise<void> => {\n try {\n // Handle OPTIONS for CORS preflight\n if (req.method === 'OPTIONS') {\n setCorsHeaders(res, settings.cors);\n res.status(204).send();\n return;\n }\n\n // Extract ingest metadata from request (if config.ingest is defined)\n await context.setIngest(req);\n\n // Create per-request respond — first call wins (idempotent)\n const respond = createRespond((options) => {\n const status = options.status ?? 200;\n if (options.headers) {\n for (const [key, value] of Object.entries(options.headers)) {\n res.set(key, value);\n }\n }\n res.status(status);\n if (typeof options.body === 'string' || Buffer.isBuffer(options.body)) {\n res.send(options.body);\n } else {\n res.json(options.body);\n }\n });\n context.setRespond(respond);\n\n // Handle GET requests (pixel tracking)\n if (req.method === 'GET') {\n // Parse query parameters to event data using requestToData\n const parsedData = requestToData(req.url);\n\n // Send to collector\n if (parsedData && typeof parsedData === 'object') {\n await env.push(parsedData);\n }\n\n // Default: 1x1 GIF (skipped if a step already called respond)\n respond({\n body: TRANSPARENT_GIF,\n headers: {\n 'Content-Type': 'image/gif',\n 'Cache-Control': 'no-cache, no-store, must-revalidate',\n },\n });\n return;\n }\n\n // Handle POST requests (standard event ingestion)\n if (req.method === 'POST') {\n const eventData =\n req.body && typeof req.body === 'object' ? req.body : {};\n\n await env.push(eventData);\n\n respond({ body: { success: true, timestamp: Date.now() } });\n return;\n }\n\n // Unsupported method\n res.status(405).json({\n success: false,\n error: 'Method not allowed. Use POST, GET, or OPTIONS.',\n });\n } catch (error) {\n res.status(500).json({\n success: false,\n error: error instanceof Error ? error.message : 'Internal server error',\n });\n }\n };\n\n // Register handlers per route config\n const resolvedPaths = settings.paths.map((entry) =>\n typeof entry === 'string'\n ? { path: entry, methods: ['GET', 'POST'] as const }\n : {\n path: entry.path,\n methods: entry.methods || (['GET', 'POST'] as const),\n },\n );\n\n for (const route of resolvedPaths) {\n if (route.methods.includes('POST')) app.post(route.path, push);\n if (route.methods.includes('GET')) app.get(route.path, push);\n app.options(route.path, push); // Always register OPTIONS for CORS\n }\n\n // Source owns the HTTP server (if port configured)\n let server: ReturnType<typeof app.listen> | undefined;\n\n if (settings.port !== undefined) {\n server = app.listen(settings.port, () => {\n const routeLines = resolvedPaths\n .map((r) => {\n const methods = [...r.methods, 'OPTIONS'].join(', ');\n return ` ${methods} ${r.path}`;\n })\n .join('\\n');\n env.logger.info(\n `Express source listening on port ${settings.port}\\n` + routeLines,\n );\n });\n }\n\n const instance: ExpressSource = {\n type: 'express',\n config: {\n ...config,\n settings,\n },\n push,\n httpHandler: app,\n app,\n server,\n destroy: (_context) =>\n new Promise<void>((resolve, reject) => {\n if (!server) return resolve();\n server.close((err) => (err ? reject(err) : resolve()));\n }),\n };\n\n return instance;\n};\n\n// Export types (avoid re-exporting duplicates from schemas)\nexport type {\n ExpressSource,\n Config,\n PartialConfig,\n Types,\n EventRequest,\n EventResponse,\n RequestBody,\n ResponseBody,\n Push,\n Env,\n Mapping,\n InitSettings,\n Settings,\n RouteConfig,\n RouteMethod,\n} from './types';\n\n// Export utils\nexport { setCorsHeaders, TRANSPARENT_GIF } from './utils';\n\nexport default sourceExpress;\n","import type { Response } from 'express';\nimport type { CorsOptions } from './schemas';\n\n/**\n * Set CORS headers on response\n *\n * @param res Express response object\n * @param corsConfig CORS configuration (false = disabled, true = allow all, object = custom)\n */\nexport function setCorsHeaders(\n res: Response,\n corsConfig: boolean | CorsOptions = true,\n): void {\n if (corsConfig === false) return;\n\n if (corsConfig === true) {\n // Simple CORS - allow all\n res.set('Access-Control-Allow-Origin', '*');\n res.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');\n res.set('Access-Control-Allow-Headers', 'Content-Type');\n } else {\n // Custom CORS configuration\n if (corsConfig.origin) {\n const origin = Array.isArray(corsConfig.origin)\n ? corsConfig.origin.join(', ')\n : corsConfig.origin;\n res.set('Access-Control-Allow-Origin', origin);\n }\n\n if (corsConfig.methods) {\n res.set('Access-Control-Allow-Methods', corsConfig.methods.join(', '));\n }\n\n if (corsConfig.headers) {\n res.set('Access-Control-Allow-Headers', corsConfig.headers.join(', '));\n }\n\n if (corsConfig.credentials) {\n res.set('Access-Control-Allow-Credentials', 'true');\n }\n\n if (corsConfig.maxAge) {\n res.set('Access-Control-Max-Age', String(corsConfig.maxAge));\n }\n }\n}\n\n/**\n * 1x1 transparent GIF for pixel tracking\n * Base64-encoded GIF (43 bytes)\n */\nexport const TRANSPARENT_GIF = Buffer.from(\n 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',\n 'base64',\n);\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts","../src/utils.ts"],"sourcesContent":["import express, { type Request, type Response } from 'express';\nimport cors from 'cors';\nimport { requestToData, createRespond } from '@walkeros/core';\nimport type { Source } from '@walkeros/core';\nimport type { ExpressSource, Types, EventRequest } from './types';\nimport { setCorsHeaders, TRANSPARENT_GIF } from './utils';\n\n/**\n * Express source initialization\n *\n * This source OWNS its HTTP server infrastructure:\n * - Creates Express application\n * - Sets up middleware (JSON parsing, CORS)\n * - Registers event collection endpoints (POST, GET, OPTIONS)\n * - Starts HTTP server (if port configured)\n * - Provides destroy() for graceful shutdown (called by runner)\n *\n * @param context Source context with config, env, logger, id\n * @returns Express source instance with app and push handler\n */\nexport const sourceExpress = async (\n context: Source.Context<Types>,\n): Promise<ExpressSource> => {\n const { config = {}, env } = context;\n const expressLib = env.express ?? express;\n const corsLib = env.cors ?? cors;\n\n // Apply defaults (no runtime validation — flow.json is developer-controlled).\n const userSettings = config.settings || {};\n const settings = {\n ...userSettings,\n cors: userSettings.cors ?? true,\n paths:\n userSettings.paths ??\n (userSettings.path ? [userSettings.path] : ['/collect']),\n };\n\n const app = expressLib();\n\n // Body parsing — JSON content-type plus text/plain so navigator.sendBeacon\n // payloads (which the browser forces to text/plain;charset=UTF-8) are also\n // parsed as JSON. 1mb default limit.\n app.use(\n expressLib.json({\n limit: '1mb',\n type: ['application/json', 'text/plain'],\n }),\n );\n\n // CORS middleware (enabled by default)\n if (settings.cors !== false) {\n const corsOptions = settings.cors === true ? {} : settings.cors;\n app.use(corsLib(corsOptions));\n }\n\n /**\n * Request handler - transforms HTTP requests into walker events\n * Supports POST (JSON body), GET (query params), and OPTIONS (CORS preflight)\n *\n * Each inbound request gets its own `withScope` invocation. The per-scope\n * env carries this request's `ingest` and `respond` end to end, so\n * concurrent requests never crosstalk through source-factory state.\n */\n const push = async (req: Request, res: Response): Promise<void> => {\n try {\n // Handle OPTIONS for CORS preflight (no scope needed: no event, no ingest)\n if (req.method === 'OPTIONS') {\n setCorsHeaders(res, settings.cors);\n res.status(204).send();\n return;\n }\n\n // Create per-request respond — first call wins (idempotent)\n const respond = createRespond((options) => {\n const status = options.status ?? 200;\n if (options.headers) {\n for (const [key, value] of Object.entries(options.headers)) {\n res.set(key, value);\n }\n }\n res.status(status);\n if (typeof options.body === 'string' || Buffer.isBuffer(options.body)) {\n res.send(options.body);\n } else {\n res.json(options.body);\n }\n });\n\n await context.withScope(req, respond, async (env) => {\n // Handle GET requests (pixel tracking)\n if (req.method === 'GET') {\n // Parse query parameters to event data using requestToData\n const parsedData = requestToData(req.url);\n\n // Send to collector\n if (parsedData && typeof parsedData === 'object') {\n await env.push(parsedData);\n }\n\n // Default: 1x1 GIF (skipped if a step already called respond)\n respond({\n body: TRANSPARENT_GIF,\n headers: {\n 'Content-Type': 'image/gif',\n 'Cache-Control': 'no-cache, no-store, must-revalidate',\n },\n });\n return;\n }\n\n // Handle POST requests (standard event ingestion)\n if (req.method === 'POST') {\n const eventData =\n req.body && typeof req.body === 'object' ? req.body : {};\n\n await env.push(eventData);\n\n respond({ body: { success: true, timestamp: Date.now() } });\n return;\n }\n\n // Unsupported method\n res.status(405).json({\n success: false,\n error: 'Method not allowed. Use POST, GET, or OPTIONS.',\n });\n });\n } catch (error) {\n res.status(500).json({\n success: false,\n error: error instanceof Error ? error.message : 'Internal server error',\n });\n }\n };\n\n // Register handlers per route config\n const resolvedPaths = settings.paths.map((entry) =>\n typeof entry === 'string'\n ? { path: entry, methods: ['GET', 'POST'] as const }\n : {\n path: entry.path,\n methods: entry.methods || (['GET', 'POST'] as const),\n },\n );\n\n for (const route of resolvedPaths) {\n if (route.methods.includes('POST')) app.post(route.path, push);\n if (route.methods.includes('GET')) app.get(route.path, push);\n app.options(route.path, push); // Always register OPTIONS for CORS\n }\n\n // Source owns the HTTP server (if port configured)\n let server: ReturnType<typeof app.listen> | undefined;\n\n if (settings.port !== undefined) {\n server = app.listen(settings.port, () => {\n const routeLines = resolvedPaths\n .map((r) => {\n const methods = [...r.methods, 'OPTIONS'].join(', ');\n return ` ${methods} ${r.path}`;\n })\n .join('\\n');\n env.logger.info(\n `Express source listening on port ${settings.port}\\n` + routeLines,\n );\n });\n }\n\n const instance: ExpressSource = {\n type: 'express',\n config: {\n ...config,\n settings,\n },\n push,\n httpHandler: app,\n app,\n server,\n destroy: (_context) =>\n new Promise<void>((resolve, reject) => {\n if (!server) return resolve();\n server.close((err) => (err ? reject(err) : resolve()));\n }),\n };\n\n return instance;\n};\n\n// Export types (avoid re-exporting duplicates from schemas)\nexport type {\n ExpressSource,\n Config,\n PartialConfig,\n Types,\n EventRequest,\n EventResponse,\n RequestBody,\n ResponseBody,\n Push,\n Env,\n Mapping,\n InitSettings,\n Settings,\n RouteConfig,\n RouteMethod,\n} from './types';\n\n// Export utils\nexport { setCorsHeaders, TRANSPARENT_GIF } from './utils';\n\nexport default sourceExpress;\n","import type { Response } from 'express';\nimport type { CorsOptions } from './schemas';\n\n/**\n * Set CORS headers on response\n *\n * @param res Express response object\n * @param corsConfig CORS configuration (false = disabled, true = allow all, object = custom)\n */\nexport function setCorsHeaders(\n res: Response,\n corsConfig: boolean | CorsOptions = true,\n): void {\n if (corsConfig === false) return;\n\n if (corsConfig === true) {\n // Simple CORS - allow all\n res.set('Access-Control-Allow-Origin', '*');\n res.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');\n res.set('Access-Control-Allow-Headers', 'Content-Type');\n } else {\n // Custom CORS configuration\n if (corsConfig.origin) {\n const origin = Array.isArray(corsConfig.origin)\n ? corsConfig.origin.join(', ')\n : corsConfig.origin;\n res.set('Access-Control-Allow-Origin', origin);\n }\n\n if (corsConfig.methods) {\n res.set('Access-Control-Allow-Methods', corsConfig.methods.join(', '));\n }\n\n if (corsConfig.headers) {\n res.set('Access-Control-Allow-Headers', corsConfig.headers.join(', '));\n }\n\n if (corsConfig.credentials) {\n res.set('Access-Control-Allow-Credentials', 'true');\n }\n\n if (corsConfig.maxAge) {\n res.set('Access-Control-Max-Age', String(corsConfig.maxAge));\n }\n }\n}\n\n/**\n * 1x1 transparent GIF for pixel tracking\n * Base64-encoded GIF (43 bytes)\n */\nexport const TRANSPARENT_GIF = Buffer.from(\n 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7',\n 'base64',\n);\n"],"mappings":"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","names":["env"]}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$meta": {
3
3
  "package": "@walkeros/server-source-express",
4
- "version": "4.1.0-next-1778668930820",
4
+ "version": "4.1.0",
5
5
  "type": "source",
6
6
  "platform": [
7
7
  "server"
@@ -128,9 +128,7 @@
128
128
  "description": "CORS configuration: false = disabled, true = allow all origins (default), object = custom configuration"
129
129
  }
130
130
  },
131
- "required": [
132
- "cors"
133
- ],
131
+ "required": [],
134
132
  "additionalProperties": false
135
133
  }
136
134
  },
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@walkeros/server-source-express",
3
3
  "description": "Express server source for walkerOS",
4
- "version": "4.1.0-next-1778668930820",
4
+ "version": "4.1.0",
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.mjs",
8
8
  "types": "./dist/index.d.ts",
9
9
  "files": [
10
- "dist/**"
10
+ "dist/**",
11
+ "CHANGELOG.md"
11
12
  ],
12
13
  "scripts": {
13
14
  "build": "tsup --silent",
@@ -19,8 +20,8 @@
19
20
  "update": "npx npm-check-updates -u && npm update"
20
21
  },
21
22
  "dependencies": {
22
- "@walkeros/collector": "4.1.0-next-1778668930820",
23
- "@walkeros/core": "4.1.0-next-1778668930820",
23
+ "@walkeros/collector": "4.1.0",
24
+ "@walkeros/core": "4.1.0",
24
25
  "express": "^5.2.1",
25
26
  "cors": "^2.8.5"
26
27
  },