@geekmidas/constructs 10.0.0-alpha.122 → 10.0.0-alpha.124

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.
Files changed (55) hide show
  1. package/dist/ObjectSubscriberBuilder-B4j3zgCZ.d.cts +129 -0
  2. package/dist/ObjectSubscriberBuilder-B4j3zgCZ.d.cts.map +1 -0
  3. package/dist/ObjectSubscriberBuilder-C0WUU-ov.d.mts +129 -0
  4. package/dist/ObjectSubscriberBuilder-C0WUU-ov.d.mts.map +1 -0
  5. package/dist/{Subscriber-CEt7kHkc.d.cts → Subscriber-Bfw5cqzz.d.cts} +13 -1
  6. package/dist/{Subscriber-CEt7kHkc.d.cts.map → Subscriber-Bfw5cqzz.d.cts.map} +1 -1
  7. package/dist/{Subscriber-dsxc5LKa.d.mts → Subscriber-Czs77h-H.d.mts} +13 -1
  8. package/dist/{Subscriber-dsxc5LKa.d.mts.map → Subscriber-Czs77h-H.d.mts.map} +1 -1
  9. package/dist/{SubscriberBuilder-M2EAgQhC.cjs → SubscriberBuilder-CYluzubx.cjs} +109 -1
  10. package/dist/SubscriberBuilder-CYluzubx.cjs.map +1 -0
  11. package/dist/{SubscriberBuilder-DRXfxWn_.mjs → SubscriberBuilder-DUMXzjbR.mjs} +98 -2
  12. package/dist/SubscriberBuilder-DUMXzjbR.mjs.map +1 -0
  13. package/dist/adaptors/aws.cjs +2 -2
  14. package/dist/adaptors/aws.cjs.map +1 -1
  15. package/dist/adaptors/aws.d.cts +1 -1
  16. package/dist/adaptors/aws.d.mts +1 -1
  17. package/dist/adaptors/aws.mjs +2 -2
  18. package/dist/adaptors/aws.mjs.map +1 -1
  19. package/dist/adaptors/testing.cjs +109 -2
  20. package/dist/adaptors/testing.cjs.map +1 -1
  21. package/dist/adaptors/testing.d.cts +22 -1
  22. package/dist/adaptors/testing.d.cts.map +1 -1
  23. package/dist/adaptors/testing.d.mts +22 -1
  24. package/dist/adaptors/testing.d.mts.map +1 -1
  25. package/dist/adaptors/testing.mjs +109 -2
  26. package/dist/adaptors/testing.mjs.map +1 -1
  27. package/dist/{runSubscriber-D8nLXIv1.cjs → runSubscriber-B1Yg4dID.cjs} +5 -3
  28. package/dist/runSubscriber-B1Yg4dID.cjs.map +1 -0
  29. package/dist/{runSubscriber-BHvJ8POr.mjs → runSubscriber-JCgkeYbE.mjs} +5 -3
  30. package/dist/runSubscriber-JCgkeYbE.mjs.map +1 -0
  31. package/dist/subscribers/index.cjs +31 -13
  32. package/dist/subscribers/index.cjs.map +1 -1
  33. package/dist/subscribers/index.d.cts +27 -4
  34. package/dist/subscribers/index.d.cts.map +1 -1
  35. package/dist/subscribers/index.d.mts +27 -4
  36. package/dist/subscribers/index.d.mts.map +1 -1
  37. package/dist/subscribers/index.mjs +30 -14
  38. package/dist/subscribers/index.mjs.map +1 -1
  39. package/dist/worker.cjs +22 -1
  40. package/dist/worker.cjs.map +1 -1
  41. package/dist/worker.d.cts +20 -1
  42. package/dist/worker.d.cts.map +1 -1
  43. package/dist/worker.d.mts +20 -1
  44. package/dist/worker.d.mts.map +1 -1
  45. package/dist/worker.mjs +22 -1
  46. package/dist/worker.mjs.map +1 -1
  47. package/package.json +31 -31
  48. package/dist/SubscriberBuilder-BRa_nrmH.d.cts +0 -67
  49. package/dist/SubscriberBuilder-BRa_nrmH.d.cts.map +0 -1
  50. package/dist/SubscriberBuilder-D9bkrYzH.d.mts +0 -67
  51. package/dist/SubscriberBuilder-D9bkrYzH.d.mts.map +0 -1
  52. package/dist/SubscriberBuilder-DRXfxWn_.mjs.map +0 -1
  53. package/dist/SubscriberBuilder-M2EAgQhC.cjs.map +0 -1
  54. package/dist/runSubscriber-BHvJ8POr.mjs.map +0 -1
  55. package/dist/runSubscriber-D8nLXIv1.cjs.map +0 -1
@@ -10,15 +10,17 @@ var SubscriberOutputInvalid = class extends Error {
10
10
  }
11
11
  };
12
12
  /** The events in an SQS or SNS batch (a Lambda event's `Records`) that `subscriber` subscribed to. */
13
- function subscribedEvents(subscriber, rawEvent, logger) {
13
+ async function subscribedEvents(subscriber, rawEvent, logger) {
14
14
  const events = [];
15
15
  const records = rawEvent.Records ?? [];
16
16
  const first = records[0];
17
17
  const parse = first?.eventSource === "aws:sqs" ? (record) => parseSQSRecord(record) : first?.EventSource === "aws:sns" ? (record) => parseSNSRecord(record) : void 0;
18
18
  if (!parse) return events;
19
+ const toObjectEvents = subscriber.bucketName ? (await import("@geekmidas/storage/events")).toObjectEvents : void 0;
19
20
  for (const record of records) try {
20
21
  const event = parse(record);
21
- if (isSubscribed(subscriber, event)) events.push(event);
22
+ const parsed = toObjectEvents && !(typeof event === "object" && event?.type) ? toObjectEvents(event) : [event];
23
+ for (const one of parsed) if (isSubscribed(subscriber, one)) events.push(one);
22
24
  } catch (error) {
23
25
  logger.error({
24
26
  err: error,
@@ -106,4 +108,4 @@ function safeJsonParse(value) {
106
108
  //#endregion
107
109
  export { subscribedEvents as n, subscriberContext as r, runSubscriber as t };
108
110
 
109
- //# sourceMappingURL=runSubscriber-BHvJ8POr.mjs.map
111
+ //# sourceMappingURL=runSubscriber-JCgkeYbE.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runSubscriber-JCgkeYbE.mjs","names":[],"sources":["../src/subscribers/runSubscriber.ts"],"sourcesContent":["/**\n * What a subscriber does with a batch of records, however they arrived: parse\n * them into events, keep the ones it subscribed to, and run its handler with\n * its services and database.\n *\n * Shared by the Lambda adaptor, which wraps it in middy, and the SNS push\n * adaptor, which a server runs and which must not need middy to load.\n */\n\nimport type { EnvironmentParser } from '@geekmidas/envkit';\nimport type { Logger } from '@geekmidas/logger';\nimport { ServiceDiscovery } from '@geekmidas/services';\nimport type { SNSEvent, SNSEventRecord, SQSEvent, SQSRecord } from 'aws-lambda';\nimport type { Subscriber } from './Subscriber';\n\ntype AnySubscriber = Subscriber<any, any, any, any, any, any, any>;\n\n/** The subscriber's output did not match its `.output()` schema. */\nexport class SubscriberOutputInvalid extends Error {\n\tconstructor(readonly issues: ReadonlyArray<unknown>) {\n\t\tsuper(\n\t\t\t\"Subscriber output validation failed: the handler's result does not match its .output() schema.\",\n\t\t);\n\t\tthis.name = 'SubscriberOutputInvalid';\n\t}\n}\n\n/** The events in an SQS or SNS batch (a Lambda event's `Records`) that `subscriber` subscribed to. */\nexport async function subscribedEvents(\n\tsubscriber: AnySubscriber,\n\trawEvent: { Records?: readonly unknown[] },\n\tlogger: Logger,\n): Promise<any[]> {\n\tconst events: any[] = [];\n\tconst records = rawEvent.Records ?? [];\n\tconst first = records[0] as any;\n\tconst parse =\n\t\tfirst?.eventSource === 'aws:sqs'\n\t\t\t? (record: any) => parseSQSRecord(record)\n\t\t\t: first?.EventSource === 'aws:sns'\n\t\t\t\t? (record: any) => parseSNSRecord(record)\n\t\t\t\t: undefined;\n\tif (!parse) return events;\n\n\t// A bucket's subscriber is sent what the storage sends — a notification\n\t// of records — and reads it as object events. Loaded only then: an app\n\t// with no bucket never installs the storage package.\n\tconst toObjectEvents = subscriber.bucketName\n\t\t? (await import('@geekmidas/storage/events')).toObjectEvents\n\t\t: undefined;\n\n\tfor (const record of records) {\n\t\ttry {\n\t\t\tconst event = parse(record);\n\t\t\tconst parsed =\n\t\t\t\ttoObjectEvents && !(typeof event === 'object' && event?.type)\n\t\t\t\t\t? toObjectEvents(event)\n\t\t\t\t\t: [event];\n\t\t\tfor (const one of parsed) {\n\t\t\t\tif (isSubscribed(subscriber, one)) events.push(one);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tlogger.error({ err: error, record }, 'Failed to parse record');\n\t\t}\n\t}\n\treturn events;\n}\n\n/** The subscriber's services and database, registered as functions do. */\nexport async function subscriberContext(\n\tsubscriber: AnySubscriber,\n\tenvParser: EnvironmentParser<{}>,\n): Promise<{ services: Record<string, unknown>; db: unknown }> {\n\tconst discovery = ServiceDiscovery.getInstance(envParser);\n\tconst services =\n\t\tsubscriber.services.length > 0\n\t\t\t? await discovery.register(subscriber.services)\n\t\t\t: {};\n\tconst service = subscriber.databaseService;\n\tconst db = service\n\t\t? (await discovery.register([service]))[service.serviceName]\n\t\t: undefined;\n\treturn { services, db };\n}\n\n/** Runs the handler on `events`, and checks its output against its schema. */\nexport async function runSubscriber(\n\tsubscriber: AnySubscriber,\n\tcontext: {\n\t\tevents: any[];\n\t\tservices: Record<string, unknown>;\n\t\tdb: unknown;\n\t\tlogger: Logger;\n\t},\n): Promise<unknown> {\n\tif (context.events.length === 0) {\n\t\tcontext.logger.info('No subscribed events to process');\n\t\treturn { batchItemFailures: [] };\n\t}\n\n\tconst { events, services, db, logger } = context;\n\tconst result = await subscriber.handler({\n\t\tevents,\n\t\tservices,\n\t\tlogger,\n\t\tdb,\n\t} as any);\n\n\tif (subscriber.outputSchema && result) {\n\t\tconst validation =\n\t\t\tawait subscriber.outputSchema['~standard'].validate(result);\n\t\tif (validation.issues) {\n\t\t\tcontext.logger.error(\n\t\t\t\t{ issues: validation.issues },\n\t\t\t\t'Subscriber output validation failed',\n\t\t\t);\n\t\t\tthrow new SubscriberOutputInvalid(validation.issues);\n\t\t}\n\t\treturn validation.value;\n\t}\n\n\treturn result;\n}\n\nfunction isSubscribed(subscriber: AnySubscriber, event: any): boolean {\n\t// No event type (raw string/non-object) — always include\n\tif (typeof event !== 'object' || !event?.type) return true;\n\t// No filter configured — accept all\n\tif (!subscriber.subscribedEvents) return true;\n\treturn subscriber.subscribedEvents.includes(event.type);\n}\n\nfunction parseSNSRecord(record: SNSEventRecord): any {\n\tconst message = safeJsonParse(record.Sns.Message);\n\tconst messageType = record.Sns.MessageAttributes?.type?.Value;\n\n\t// Not JSON — wrap raw string with type from MessageAttributes if available\n\tif (message === null) {\n\t\treturn messageType\n\t\t\t? { type: messageType, payload: record.Sns.Message }\n\t\t\t: record.Sns.Message;\n\t}\n\n\tif (message.type) {\n\t\treturn message; // Full event format: { type, payload }\n\t}\n\n\t// Payload-only format: type is in MessageAttributes\n\treturn messageType ? { type: messageType, payload: message } : message;\n}\n\nfunction parseSQSRecord(record: SQSRecord): any {\n\tconst body = safeJsonParse(record.body);\n\n\t// Not JSON — return raw body as-is\n\tif (body === null) {\n\t\treturn record.body;\n\t}\n\n\t// Check if this is an SNS message wrapped in SQS\n\tif (body.Type === 'Notification' && body.Message) {\n\t\tconst snsMessage = safeJsonParse(body.Message);\n\t\tconst messageType = body.MessageAttributes?.type?.Value;\n\n\t\t// SNS Message not JSON — wrap with type from MessageAttributes if available\n\t\tif (snsMessage === null) {\n\t\t\treturn messageType\n\t\t\t\t? { type: messageType, payload: body.Message }\n\t\t\t\t: body.Message;\n\t\t}\n\n\t\tif (snsMessage.type) {\n\t\t\treturn snsMessage; // Full event format: { type, payload }\n\t\t}\n\n\t\t// Payload-only format: type is in MessageAttributes\n\t\treturn messageType\n\t\t\t? { type: messageType, payload: snsMessage }\n\t\t\t: snsMessage;\n\t}\n\n\t// Direct SQS message\n\treturn body;\n}\n\nfunction safeJsonParse(value: string): any | null {\n\ttry {\n\t\treturn JSON.parse(value);\n\t} catch {\n\t\treturn null;\n\t}\n}\n"],"mappings":"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"}
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_SubscriberBuilder = require("../SubscriberBuilder-M2EAgQhC.cjs");
3
- const require_runSubscriber = require("../runSubscriber-D8nLXIv1.cjs");
2
+ const require_SubscriberBuilder = require("../SubscriberBuilder-CYluzubx.cjs");
3
+ const require_runSubscriber = require("../runSubscriber-B1Yg4dID.cjs");
4
4
  let _geekmidas_events = require("@geekmidas/events");
5
5
  let _geekmidas_services = require("@geekmidas/services");
6
6
  let _geekmidas_errors = require("@geekmidas/errors");
@@ -24,7 +24,7 @@ var SnsPushSubscriberAdaptor = class {
24
24
  this.subscriber = subscriber;
25
25
  this.options = options;
26
26
  }
27
- async handle(body) {
27
+ async handle(body, request = {}) {
28
28
  const message = body;
29
29
  if (typeof message?.Type !== "string" || !message.TopicArn) return refuse(400, "not an SNS message");
30
30
  if (message.TopicArn !== this.options.topicArn) return refuse(403, `not subscribed to ${message.TopicArn}`);
@@ -39,6 +39,14 @@ var SnsPushSubscriberAdaptor = class {
39
39
  }
40
40
  switch (message.Type) {
41
41
  case "SubscriptionConfirmation":
42
+ if (this.options.confirm) {
43
+ await this.options.confirm(message);
44
+ this.subscriber.logger.info({ topicArn: message.TopicArn }, "Confirmed SNS subscription");
45
+ return {
46
+ status: 200,
47
+ body: { status: "confirmed" }
48
+ };
49
+ }
42
50
  await (0, _geekmidas_events_sns.confirmSnsSubscription)(this.options.emulatorEndpoint && message.SubscribeURL ? {
43
51
  ...message,
44
52
  SubscribeURL: throughEndpoint(message.SubscribeURL, this.options.emulatorEndpoint)
@@ -48,15 +56,23 @@ var SnsPushSubscriberAdaptor = class {
48
56
  status: 200,
49
57
  body: { status: "confirmed" }
50
58
  };
51
- case "Notification": try {
52
- await this.notify(message);
53
- return {
54
- status: 200,
55
- body: { status: "ok" }
56
- };
57
- } catch (error) {
58
- return refuse(500, error.name);
59
- }
59
+ case "Notification":
60
+ if (this.options.subscriptionArn && request.subscriptionArn !== this.options.subscriptionArn) {
61
+ this.subscriber.logger.warn({
62
+ topicArn: message.TopicArn,
63
+ subscriptionArn: request.subscriptionArn
64
+ }, "Refused an SNS notification from a subscription the deploy did not make");
65
+ return refuse(403, "not the recorded subscription");
66
+ }
67
+ try {
68
+ await this.notify(message);
69
+ return {
70
+ status: 200,
71
+ body: { status: "ok" }
72
+ };
73
+ } catch (error) {
74
+ return refuse(500, error.name);
75
+ }
60
76
  default: return {
61
77
  status: 200,
62
78
  body: { status: "ignored" }
@@ -81,7 +97,7 @@ var SnsPushSubscriberAdaptor = class {
81
97
  async run(message, logger) {
82
98
  try {
83
99
  const { services, db } = await require_runSubscriber.subscriberContext(this.subscriber, this.envParser);
84
- const events = require_runSubscriber.subscribedEvents(this.subscriber, (0, _geekmidas_events_sns.toSnsEvent)(message), logger);
100
+ const events = await require_runSubscriber.subscribedEvents(this.subscriber, (0, _geekmidas_events_sns.toSnsEvent)(message), logger);
85
101
  await require_runSubscriber.runSubscriber(this.subscriber, {
86
102
  events,
87
103
  services,
@@ -112,6 +128,8 @@ function refuse(status, error) {
112
128
  };
113
129
  }
114
130
  //#endregion
131
+ exports.ObjectSubscriberBuilder = require_SubscriberBuilder.ObjectSubscriberBuilder;
132
+ exports.ObjectSubscriberHasNoEvents = require_SubscriberBuilder.ObjectSubscriberHasNoEvents;
115
133
  exports.SnsPushSubscriberAdaptor = SnsPushSubscriberAdaptor;
116
134
  exports.Subscriber = require_SubscriberBuilder.Subscriber;
117
135
  exports.SubscriberBuilder = require_SubscriberBuilder.SubscriberBuilder;
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":["verifySnsMessage","confirmSnsSubscription","runWithRequestContext","consumeTraced","carrierFromAttributes","subscriberContext","subscribedEvents","toSnsEvent","runSubscriber","wrapError"],"sources":["../../src/subscribers/SnsPushSubscriberAdaptor.ts"],"sourcesContent":["import type { EnvironmentParser } from '@geekmidas/envkit';\nimport { wrapError } from '@geekmidas/errors';\nimport { carrierFromAttributes, consumeTraced } from '@geekmidas/events';\nimport {\n\tconfirmSnsSubscription,\n\ttype SnsHttpMessage,\n\ttoSnsEvent,\n\tverifySnsMessage,\n} from '@geekmidas/events/sns';\nimport { runWithRequestContext } from '@geekmidas/services';\nimport {\n\trunSubscriber,\n\tsubscribedEvents,\n\tsubscriberContext,\n} from './runSubscriber';\nimport type { Subscriber } from './Subscriber';\n\nexport interface SnsPushOptions {\n\t/** The topic this subscriber is subscribed to. Anything else is refused. */\n\ttopicArn: string;\n\t/**\n\t * Verify each message's signature. Off only against an emulator, which\n\t * signs nothing — decided by the connection string's endpoint, never by\n\t * anything the request says about itself.\n\t */\n\tverify: boolean;\n\t/**\n\t * The emulator's address, as this server was configured with it. An\n\t * emulator names itself in `SubscribeURL` by the port it listens on inside\n\t * its container, which is not the port published to the host — so against\n\t * one, the confirmation is sent here instead. From configuration, never\n\t * from the request.\n\t */\n\temulatorEndpoint?: string;\n}\n\n/** What to answer SNS with. A non-2xx tells SNS to retry. */\nexport interface SnsPushResponse {\n\tstatus: 200 | 400 | 403 | 500;\n\tbody: { status: string; error?: string };\n}\n\n/**\n * A topic subscriber run by SNS pushing to an HTTP endpoint, rather than by a\n * poller.\n *\n * The notification is read as the Lambda event SNS would have invoked a\n * function with, and run through the same parsing, services and handler as the\n * Lambda adaptor — without its middy wrapper, which is Lambda's alone: a\n * server serving this route has no reason to install `@middy/core`.\n */\nexport class SnsPushSubscriberAdaptor {\n\tconstructor(\n\t\tprivate readonly envParser: EnvironmentParser<{}>,\n\t\treadonly subscriber: Subscriber<any, any, any, any, any, any, any>,\n\t\tprivate readonly options: SnsPushOptions,\n\t) {}\n\n\tasync handle(body: unknown): Promise<SnsPushResponse> {\n\t\tconst message = body as SnsHttpMessage;\n\t\tif (typeof message?.Type !== 'string' || !message.TopicArn) {\n\t\t\treturn refuse(400, 'not an SNS message');\n\t\t}\n\t\t// Checked before the signature: a genuine message for another topic is\n\t\t// still not this subscriber's to confirm or to handle.\n\t\tif (message.TopicArn !== this.options.topicArn) {\n\t\t\treturn refuse(403, `not subscribed to ${message.TopicArn}`);\n\t\t}\n\n\t\tif (this.options.verify) {\n\t\t\ttry {\n\t\t\t\tawait verifySnsMessage(message);\n\t\t\t} catch (error) {\n\t\t\t\tthis.subscriber.logger.warn(\n\t\t\t\t\t{ err: error, topicArn: message.TopicArn },\n\t\t\t\t\t'Refused an SNS message that did not verify',\n\t\t\t\t);\n\t\t\t\treturn refuse(403, (error as Error).name);\n\t\t\t}\n\t\t}\n\n\t\tswitch (message.Type) {\n\t\t\tcase 'SubscriptionConfirmation':\n\t\t\t\tawait confirmSnsSubscription(\n\t\t\t\t\tthis.options.emulatorEndpoint && message.SubscribeURL\n\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\t...message,\n\t\t\t\t\t\t\t\tSubscribeURL: throughEndpoint(\n\t\t\t\t\t\t\t\t\tmessage.SubscribeURL,\n\t\t\t\t\t\t\t\t\tthis.options.emulatorEndpoint,\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t: message,\n\t\t\t\t);\n\t\t\t\tthis.subscriber.logger.info(\n\t\t\t\t\t{ topicArn: message.TopicArn },\n\t\t\t\t\t'Confirmed SNS subscription',\n\t\t\t\t);\n\t\t\t\treturn { status: 200, body: { status: 'confirmed' } };\n\t\t\tcase 'Notification':\n\t\t\t\ttry {\n\t\t\t\t\tawait this.notify(message);\n\t\t\t\t\treturn { status: 200, body: { status: 'ok' } };\n\t\t\t\t} catch (error) {\n\t\t\t\t\t// Already logged. A 500 makes SNS retry, which is the point of\n\t\t\t\t\t// answering at all.\n\t\t\t\t\treturn refuse(500, (error as Error).name);\n\t\t\t\t}\n\t\t\tdefault:\n\t\t\t\treturn { status: 200, body: { status: 'ignored' } };\n\t\t}\n\t}\n\n\tprivate async notify(message: SnsHttpMessage): Promise<void> {\n\t\tconst requestId = message.MessageId;\n\t\tconst logger = this.subscriber.logger.child({ requestId });\n\n\t\tawait runWithRequestContext(\n\t\t\t{\n\t\t\t\tlogger,\n\t\t\t\trequestId,\n\t\t\t\tstartTime: Date.now(),\n\t\t\t\toperation: `subscriber ${this.subscriber.topicName ?? 'sns'}`,\n\t\t\t},\n\t\t\t// A CONSUMER span, child of the publisher's: SNS hands the trace\n\t\t\t// context on in the message attributes it was published with.\n\t\t\t() =>\n\t\t\t\tconsumeTraced(\n\t\t\t\t\t{\n\t\t\t\t\t\tsystem: 'aws_sns',\n\t\t\t\t\t\tdestination:\n\t\t\t\t\t\t\tthis.subscriber.topicName ??\n\t\t\t\t\t\t\tmessage.TopicArn.split(':').pop() ??\n\t\t\t\t\t\t\tmessage.TopicArn,\n\t\t\t\t\t\ttype: message.MessageAttributes?.type?.Value,\n\t\t\t\t\t\tmessageId: message.MessageId,\n\t\t\t\t\t},\n\t\t\t\t\tcarrierFromAttributes(message.MessageAttributes),\n\t\t\t\t\t() => this.run(message, logger),\n\t\t\t\t),\n\t\t);\n\t}\n\n\tprivate async run(\n\t\tmessage: SnsHttpMessage,\n\t\tlogger: Subscriber<any, any, any, any, any, any, any>['logger'],\n\t): Promise<void> {\n\t\ttry {\n\t\t\tconst { services, db } = await subscriberContext(\n\t\t\t\tthis.subscriber,\n\t\t\t\tthis.envParser,\n\t\t\t);\n\t\t\tconst events = subscribedEvents(\n\t\t\t\tthis.subscriber,\n\t\t\t\ttoSnsEvent(message),\n\t\t\t\tlogger,\n\t\t\t);\n\t\t\tawait runSubscriber(this.subscriber, {\n\t\t\t\tevents,\n\t\t\t\tservices,\n\t\t\t\tlogger,\n\t\t\t\tdb,\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tlogger.error(error as object, 'Error processing subscriber');\n\t\t\tthrow wrapError(error);\n\t\t}\n\t}\n}\n\n/** `url`, sent to `endpoint`'s origin instead of its own. */\nfunction throughEndpoint(url: string, endpoint: string): string {\n\tconst target = new URL(url);\n\tconst origin = new URL(endpoint);\n\ttarget.protocol = origin.protocol;\n\ttarget.host = origin.host;\n\treturn target.toString();\n}\n\nfunction refuse(\n\tstatus: SnsPushResponse['status'],\n\terror: string,\n): SnsPushResponse {\n\treturn { status, body: { status: 'refused', error } };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.cjs","names":["verifySnsMessage","confirmSnsSubscription","runWithRequestContext","consumeTraced","carrierFromAttributes","subscriberContext","subscribedEvents","toSnsEvent","runSubscriber","wrapError"],"sources":["../../src/subscribers/SnsPushSubscriberAdaptor.ts"],"sourcesContent":["import type { EnvironmentParser } from '@geekmidas/envkit';\nimport { wrapError } from '@geekmidas/errors';\nimport { carrierFromAttributes, consumeTraced } from '@geekmidas/events';\nimport {\n\tconfirmSnsSubscription,\n\ttype SnsHttpMessage,\n\ttoSnsEvent,\n\tverifySnsMessage,\n} from '@geekmidas/events/sns';\nimport { runWithRequestContext } from '@geekmidas/services';\nimport {\n\trunSubscriber,\n\tsubscribedEvents,\n\tsubscriberContext,\n} from './runSubscriber';\nimport type { Subscriber } from './Subscriber';\n\nexport interface SnsPushOptions {\n\t/** The topic this subscriber is subscribed to. Anything else is refused. */\n\ttopicArn: string;\n\t/**\n\t * Verify each message's signature. Off only against an emulator, which\n\t * signs nothing — decided by the connection string's endpoint, never by\n\t * anything the request says about itself.\n\t */\n\tverify: boolean;\n\t/**\n\t * The emulator's address, as this server was configured with it. An\n\t * emulator names itself in `SubscribeURL` by the port it listens on inside\n\t * its container, which is not the port published to the host — so against\n\t * one, the confirmation is sent here instead. From configuration, never\n\t * from the request.\n\t */\n\temulatorEndpoint?: string;\n\t/**\n\t * The subscription the deploy made for this route, as it recorded it\n\t * (`GKM_SNS_SUBSCRIPTIONS`). A notification naming another one in its\n\t * `x-amz-sns-subscription-arn` header is refused — a subscription someone\n\t * added by hand, pointing at this route.\n\t *\n\t * Defence in depth, not authentication: SNS does not sign its headers.\n\t * What authenticates a message is its signature and its `TopicArn`.\n\t */\n\tsubscriptionArn?: string;\n\t/**\n\t * How a confirmation is confirmed. By visiting its `SubscribeURL` when\n\t * unset — what an emulator takes. Against SNS itself, the server confirms\n\t * through the API with `AuthenticateOnUnsubscribe`, so the `UnsubscribeURL`\n\t * every notification carries cannot cut the route off.\n\t */\n\tconfirm?: (message: SnsHttpMessage) => Promise<unknown>;\n}\n\n/** What SNS said about a request, outside its signed body. */\nexport interface SnsPushRequest {\n\t/** The `x-amz-sns-subscription-arn` header. */\n\tsubscriptionArn?: string;\n}\n\n/** What to answer SNS with. A non-2xx tells SNS to retry. */\nexport interface SnsPushResponse {\n\tstatus: 200 | 400 | 403 | 500;\n\tbody: { status: string; error?: string };\n}\n\n/**\n * A topic subscriber run by SNS pushing to an HTTP endpoint, rather than by a\n * poller.\n *\n * The notification is read as the Lambda event SNS would have invoked a\n * function with, and run through the same parsing, services and handler as the\n * Lambda adaptor — without its middy wrapper, which is Lambda's alone: a\n * server serving this route has no reason to install `@middy/core`.\n */\nexport class SnsPushSubscriberAdaptor {\n\tconstructor(\n\t\tprivate readonly envParser: EnvironmentParser<{}>,\n\t\treadonly subscriber: Subscriber<any, any, any, any, any, any, any>,\n\t\tprivate readonly options: SnsPushOptions,\n\t) {}\n\n\tasync handle(\n\t\tbody: unknown,\n\t\trequest: SnsPushRequest = {},\n\t): Promise<SnsPushResponse> {\n\t\tconst message = body as SnsHttpMessage;\n\t\tif (typeof message?.Type !== 'string' || !message.TopicArn) {\n\t\t\treturn refuse(400, 'not an SNS message');\n\t\t}\n\t\t// Checked before the signature: a genuine message for another topic is\n\t\t// still not this subscriber's to confirm or to handle.\n\t\tif (message.TopicArn !== this.options.topicArn) {\n\t\t\treturn refuse(403, `not subscribed to ${message.TopicArn}`);\n\t\t}\n\n\t\tif (this.options.verify) {\n\t\t\ttry {\n\t\t\t\tawait verifySnsMessage(message);\n\t\t\t} catch (error) {\n\t\t\t\tthis.subscriber.logger.warn(\n\t\t\t\t\t{ err: error, topicArn: message.TopicArn },\n\t\t\t\t\t'Refused an SNS message that did not verify',\n\t\t\t\t);\n\t\t\t\treturn refuse(403, (error as Error).name);\n\t\t\t}\n\t\t}\n\n\t\tswitch (message.Type) {\n\t\t\tcase 'SubscriptionConfirmation':\n\t\t\t\tif (this.options.confirm) {\n\t\t\t\t\tawait this.options.confirm(message);\n\t\t\t\t\tthis.subscriber.logger.info(\n\t\t\t\t\t\t{ topicArn: message.TopicArn },\n\t\t\t\t\t\t'Confirmed SNS subscription',\n\t\t\t\t\t);\n\t\t\t\t\treturn { status: 200, body: { status: 'confirmed' } };\n\t\t\t\t}\n\t\t\t\tawait confirmSnsSubscription(\n\t\t\t\t\tthis.options.emulatorEndpoint && message.SubscribeURL\n\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\t...message,\n\t\t\t\t\t\t\t\tSubscribeURL: throughEndpoint(\n\t\t\t\t\t\t\t\t\tmessage.SubscribeURL,\n\t\t\t\t\t\t\t\t\tthis.options.emulatorEndpoint,\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t: message,\n\t\t\t\t);\n\t\t\t\tthis.subscriber.logger.info(\n\t\t\t\t\t{ topicArn: message.TopicArn },\n\t\t\t\t\t'Confirmed SNS subscription',\n\t\t\t\t);\n\t\t\t\treturn { status: 200, body: { status: 'confirmed' } };\n\t\t\tcase 'Notification':\n\t\t\t\tif (\n\t\t\t\t\tthis.options.subscriptionArn &&\n\t\t\t\t\trequest.subscriptionArn !== this.options.subscriptionArn\n\t\t\t\t) {\n\t\t\t\t\tthis.subscriber.logger.warn(\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttopicArn: message.TopicArn,\n\t\t\t\t\t\t\tsubscriptionArn: request.subscriptionArn,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t'Refused an SNS notification from a subscription the deploy did not make',\n\t\t\t\t\t);\n\t\t\t\t\treturn refuse(403, 'not the recorded subscription');\n\t\t\t\t}\n\t\t\t\ttry {\n\t\t\t\t\tawait this.notify(message);\n\t\t\t\t\treturn { status: 200, body: { status: 'ok' } };\n\t\t\t\t} catch (error) {\n\t\t\t\t\t// Already logged. A 500 makes SNS retry, which is the point of\n\t\t\t\t\t// answering at all.\n\t\t\t\t\treturn refuse(500, (error as Error).name);\n\t\t\t\t}\n\t\t\tdefault:\n\t\t\t\treturn { status: 200, body: { status: 'ignored' } };\n\t\t}\n\t}\n\n\tprivate async notify(message: SnsHttpMessage): Promise<void> {\n\t\tconst requestId = message.MessageId;\n\t\tconst logger = this.subscriber.logger.child({ requestId });\n\n\t\tawait runWithRequestContext(\n\t\t\t{\n\t\t\t\tlogger,\n\t\t\t\trequestId,\n\t\t\t\tstartTime: Date.now(),\n\t\t\t\toperation: `subscriber ${this.subscriber.topicName ?? 'sns'}`,\n\t\t\t},\n\t\t\t// A CONSUMER span, child of the publisher's: SNS hands the trace\n\t\t\t// context on in the message attributes it was published with.\n\t\t\t() =>\n\t\t\t\tconsumeTraced(\n\t\t\t\t\t{\n\t\t\t\t\t\tsystem: 'aws_sns',\n\t\t\t\t\t\tdestination:\n\t\t\t\t\t\t\tthis.subscriber.topicName ??\n\t\t\t\t\t\t\tmessage.TopicArn.split(':').pop() ??\n\t\t\t\t\t\t\tmessage.TopicArn,\n\t\t\t\t\t\ttype: message.MessageAttributes?.type?.Value,\n\t\t\t\t\t\tmessageId: message.MessageId,\n\t\t\t\t\t},\n\t\t\t\t\tcarrierFromAttributes(message.MessageAttributes),\n\t\t\t\t\t() => this.run(message, logger),\n\t\t\t\t),\n\t\t);\n\t}\n\n\tprivate async run(\n\t\tmessage: SnsHttpMessage,\n\t\tlogger: Subscriber<any, any, any, any, any, any, any>['logger'],\n\t): Promise<void> {\n\t\ttry {\n\t\t\tconst { services, db } = await subscriberContext(\n\t\t\t\tthis.subscriber,\n\t\t\t\tthis.envParser,\n\t\t\t);\n\t\t\tconst events = await subscribedEvents(\n\t\t\t\tthis.subscriber,\n\t\t\t\ttoSnsEvent(message),\n\t\t\t\tlogger,\n\t\t\t);\n\t\t\tawait runSubscriber(this.subscriber, {\n\t\t\t\tevents,\n\t\t\t\tservices,\n\t\t\t\tlogger,\n\t\t\t\tdb,\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tlogger.error(error as object, 'Error processing subscriber');\n\t\t\tthrow wrapError(error);\n\t\t}\n\t}\n}\n\n/** `url`, sent to `endpoint`'s origin instead of its own. */\nfunction throughEndpoint(url: string, endpoint: string): string {\n\tconst target = new URL(url);\n\tconst origin = new URL(endpoint);\n\ttarget.protocol = origin.protocol;\n\ttarget.host = origin.host;\n\treturn target.toString();\n}\n\nfunction refuse(\n\tstatus: SnsPushResponse['status'],\n\terror: string,\n): SnsPushResponse {\n\treturn { status, body: { status: 'refused', error } };\n}\n"],"mappings":"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"}
@@ -1,6 +1,7 @@
1
- import { t as Subscriber } from "../Subscriber-CEt7kHkc.cjs";
2
- import { t as SubscriberBuilder } from "../SubscriberBuilder-BRa_nrmH.cjs";
1
+ import { t as Subscriber } from "../Subscriber-Bfw5cqzz.cjs";
2
+ import { a as ObjectSubscriberHasNoEvents, i as ObjectSubscriberHandler, n as ObjectSubscriberBuilder, o as SubscriberBuilder, r as ObjectSubscriberContext, t as ObjectSubscriber } from "../ObjectSubscriberBuilder-B4j3zgCZ.cjs";
3
3
  import { EnvironmentParser } from "@geekmidas/envkit";
4
+ import { SnsHttpMessage } from "@geekmidas/events/sns";
4
5
  //#region src/subscribers/SnsPushSubscriberAdaptor.d.ts
5
6
  interface SnsPushOptions {
6
7
  /** The topic this subscriber is subscribed to. Anything else is refused. */
@@ -19,6 +20,28 @@ interface SnsPushOptions {
19
20
  * from the request.
20
21
  */
21
22
  emulatorEndpoint?: string;
23
+ /**
24
+ * The subscription the deploy made for this route, as it recorded it
25
+ * (`GKM_SNS_SUBSCRIPTIONS`). A notification naming another one in its
26
+ * `x-amz-sns-subscription-arn` header is refused — a subscription someone
27
+ * added by hand, pointing at this route.
28
+ *
29
+ * Defence in depth, not authentication: SNS does not sign its headers.
30
+ * What authenticates a message is its signature and its `TopicArn`.
31
+ */
32
+ subscriptionArn?: string;
33
+ /**
34
+ * How a confirmation is confirmed. By visiting its `SubscribeURL` when
35
+ * unset — what an emulator takes. Against SNS itself, the server confirms
36
+ * through the API with `AuthenticateOnUnsubscribe`, so the `UnsubscribeURL`
37
+ * every notification carries cannot cut the route off.
38
+ */
39
+ confirm?: (message: SnsHttpMessage) => Promise<unknown>;
40
+ }
41
+ /** What SNS said about a request, outside its signed body. */
42
+ interface SnsPushRequest {
43
+ /** The `x-amz-sns-subscription-arn` header. */
44
+ subscriptionArn?: string;
22
45
  }
23
46
  /** What to answer SNS with. A non-2xx tells SNS to retry. */
24
47
  interface SnsPushResponse {
@@ -42,10 +65,10 @@ export declare class SnsPushSubscriberAdaptor {
42
65
  readonly subscriber: Subscriber<any, any, any, any, any, any, any>;
43
66
  private readonly options;
44
67
  constructor(envParser: EnvironmentParser<{}>, subscriber: Subscriber<any, any, any, any, any, any, any>, options: SnsPushOptions);
45
- handle(body: unknown): Promise<SnsPushResponse>;
68
+ handle(body: unknown, request?: SnsPushRequest): Promise<SnsPushResponse>;
46
69
  private notify;
47
70
  private run;
48
71
  }
49
72
  //#endregion
50
- export { type SnsPushOptions, type SnsPushResponse, Subscriber, SubscriberBuilder };
73
+ export { type ObjectSubscriber, ObjectSubscriberBuilder, type ObjectSubscriberContext, type ObjectSubscriberHandler, ObjectSubscriberHasNoEvents, type SnsPushOptions, type SnsPushRequest, type SnsPushResponse, Subscriber, SubscriberBuilder };
51
74
  //# sourceMappingURL=index.d.cts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.cts","names":[],"sources":["../../src/subscribers/SnsPushSubscriberAdaptor.ts"],"mappings":";;;;UAiBiB;;EAEhB;;;;;;EAMA;;;;;;;;EAQA;;;UAIgB;EAChB;EACA;IAAQ;IAAgB;;;;;;;;;;;;qBAYZ;mBAEM;WACR,YAAY;mBACJ;EAHlB,YACkB,WAAW,uBACnB,YAAY,+CACJ,SAAS;EAGrB,OAAO,gBAAgB,QAAQ;UAuDvB;UA8BA"}
1
+ {"version":3,"file":"index.d.cts","names":[],"sources":["../../src/subscribers/SnsPushSubscriberAdaptor.ts"],"mappings":";;;;;UAiBiB;;EAEhB;;;;;;EAMA;;;;;;;;EAQA;;;;;;;;;;EAUA;;;;;;;EAOA,WAAW,SAAS,mBAAmB;;;UAIvB;;EAEhB;;;UAIgB;EAChB;EACA;IAAQ;IAAgB;;;;;;;;;;;;qBAYZ;mBAEM;WACR,YAAY;mBACJ;EAHlB,YACkB,WAAW,uBACnB,YAAY,+CACJ,SAAS;EAGrB,OACL,eACA,UAAS,iBACP,QAAQ;UA4EG;UA8BA"}
@@ -1,6 +1,7 @@
1
- import { t as Subscriber } from "../Subscriber-dsxc5LKa.mjs";
2
- import { t as SubscriberBuilder } from "../SubscriberBuilder-D9bkrYzH.mjs";
1
+ import { t as Subscriber } from "../Subscriber-Czs77h-H.mjs";
2
+ import { a as ObjectSubscriberHasNoEvents, i as ObjectSubscriberHandler, n as ObjectSubscriberBuilder, o as SubscriberBuilder, r as ObjectSubscriberContext, t as ObjectSubscriber } from "../ObjectSubscriberBuilder-C0WUU-ov.mjs";
3
3
  import { EnvironmentParser } from "@geekmidas/envkit";
4
+ import { SnsHttpMessage } from "@geekmidas/events/sns";
4
5
  //#region src/subscribers/SnsPushSubscriberAdaptor.d.ts
5
6
  interface SnsPushOptions {
6
7
  /** The topic this subscriber is subscribed to. Anything else is refused. */
@@ -19,6 +20,28 @@ interface SnsPushOptions {
19
20
  * from the request.
20
21
  */
21
22
  emulatorEndpoint?: string;
23
+ /**
24
+ * The subscription the deploy made for this route, as it recorded it
25
+ * (`GKM_SNS_SUBSCRIPTIONS`). A notification naming another one in its
26
+ * `x-amz-sns-subscription-arn` header is refused — a subscription someone
27
+ * added by hand, pointing at this route.
28
+ *
29
+ * Defence in depth, not authentication: SNS does not sign its headers.
30
+ * What authenticates a message is its signature and its `TopicArn`.
31
+ */
32
+ subscriptionArn?: string;
33
+ /**
34
+ * How a confirmation is confirmed. By visiting its `SubscribeURL` when
35
+ * unset — what an emulator takes. Against SNS itself, the server confirms
36
+ * through the API with `AuthenticateOnUnsubscribe`, so the `UnsubscribeURL`
37
+ * every notification carries cannot cut the route off.
38
+ */
39
+ confirm?: (message: SnsHttpMessage) => Promise<unknown>;
40
+ }
41
+ /** What SNS said about a request, outside its signed body. */
42
+ interface SnsPushRequest {
43
+ /** The `x-amz-sns-subscription-arn` header. */
44
+ subscriptionArn?: string;
22
45
  }
23
46
  /** What to answer SNS with. A non-2xx tells SNS to retry. */
24
47
  interface SnsPushResponse {
@@ -42,10 +65,10 @@ export declare class SnsPushSubscriberAdaptor {
42
65
  readonly subscriber: Subscriber<any, any, any, any, any, any, any>;
43
66
  private readonly options;
44
67
  constructor(envParser: EnvironmentParser<{}>, subscriber: Subscriber<any, any, any, any, any, any, any>, options: SnsPushOptions);
45
- handle(body: unknown): Promise<SnsPushResponse>;
68
+ handle(body: unknown, request?: SnsPushRequest): Promise<SnsPushResponse>;
46
69
  private notify;
47
70
  private run;
48
71
  }
49
72
  //#endregion
50
- export { type SnsPushOptions, type SnsPushResponse, Subscriber, SubscriberBuilder };
73
+ export { type ObjectSubscriber, ObjectSubscriberBuilder, type ObjectSubscriberContext, type ObjectSubscriberHandler, ObjectSubscriberHasNoEvents, type SnsPushOptions, type SnsPushRequest, type SnsPushResponse, Subscriber, SubscriberBuilder };
51
74
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/subscribers/SnsPushSubscriberAdaptor.ts"],"mappings":";;;;UAiBiB;;EAEhB;;;;;;EAMA;;;;;;;;EAQA;;;UAIgB;EAChB;EACA;IAAQ;IAAgB;;;;;;;;;;;;qBAYZ;mBAEM;WACR,YAAY;mBACJ;EAHlB,YACkB,WAAW,uBACnB,YAAY,+CACJ,SAAS;EAGrB,OAAO,gBAAgB,QAAQ;UAuDvB;UA8BA"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/subscribers/SnsPushSubscriberAdaptor.ts"],"mappings":";;;;;UAiBiB;;EAEhB;;;;;;EAMA;;;;;;;;EAQA;;;;;;;;;;EAUA;;;;;;;EAOA,WAAW,SAAS,mBAAmB;;;UAIvB;;EAEhB;;;UAIgB;EAChB;EACA;IAAQ;IAAgB;;;;;;;;;;;;qBAYZ;mBAEM;WACR,YAAY;mBACJ;EAHlB,YACkB,WAAW,uBACnB,YAAY,+CACJ,SAAS;EAGrB,OACL,eACA,UAAS,iBACP,QAAQ;UA4EG;UA8BA"}
@@ -1,5 +1,5 @@
1
- import { n as Subscriber, t as SubscriberBuilder } from "../SubscriberBuilder-DRXfxWn_.mjs";
2
- import { n as subscribedEvents, r as subscriberContext, t as runSubscriber } from "../runSubscriber-BHvJ8POr.mjs";
1
+ import { i as ObjectSubscriberHasNoEvents, n as Subscriber, r as ObjectSubscriberBuilder, t as SubscriberBuilder } from "../SubscriberBuilder-DUMXzjbR.mjs";
2
+ import { n as subscribedEvents, r as subscriberContext, t as runSubscriber } from "../runSubscriber-JCgkeYbE.mjs";
3
3
  import { carrierFromAttributes, consumeTraced } from "@geekmidas/events";
4
4
  import { runWithRequestContext } from "@geekmidas/services";
5
5
  import { wrapError } from "@geekmidas/errors";
@@ -23,7 +23,7 @@ var SnsPushSubscriberAdaptor = class {
23
23
  this.subscriber = subscriber;
24
24
  this.options = options;
25
25
  }
26
- async handle(body) {
26
+ async handle(body, request = {}) {
27
27
  const message = body;
28
28
  if (typeof message?.Type !== "string" || !message.TopicArn) return refuse(400, "not an SNS message");
29
29
  if (message.TopicArn !== this.options.topicArn) return refuse(403, `not subscribed to ${message.TopicArn}`);
@@ -38,6 +38,14 @@ var SnsPushSubscriberAdaptor = class {
38
38
  }
39
39
  switch (message.Type) {
40
40
  case "SubscriptionConfirmation":
41
+ if (this.options.confirm) {
42
+ await this.options.confirm(message);
43
+ this.subscriber.logger.info({ topicArn: message.TopicArn }, "Confirmed SNS subscription");
44
+ return {
45
+ status: 200,
46
+ body: { status: "confirmed" }
47
+ };
48
+ }
41
49
  await confirmSnsSubscription(this.options.emulatorEndpoint && message.SubscribeURL ? {
42
50
  ...message,
43
51
  SubscribeURL: throughEndpoint(message.SubscribeURL, this.options.emulatorEndpoint)
@@ -47,15 +55,23 @@ var SnsPushSubscriberAdaptor = class {
47
55
  status: 200,
48
56
  body: { status: "confirmed" }
49
57
  };
50
- case "Notification": try {
51
- await this.notify(message);
52
- return {
53
- status: 200,
54
- body: { status: "ok" }
55
- };
56
- } catch (error) {
57
- return refuse(500, error.name);
58
- }
58
+ case "Notification":
59
+ if (this.options.subscriptionArn && request.subscriptionArn !== this.options.subscriptionArn) {
60
+ this.subscriber.logger.warn({
61
+ topicArn: message.TopicArn,
62
+ subscriptionArn: request.subscriptionArn
63
+ }, "Refused an SNS notification from a subscription the deploy did not make");
64
+ return refuse(403, "not the recorded subscription");
65
+ }
66
+ try {
67
+ await this.notify(message);
68
+ return {
69
+ status: 200,
70
+ body: { status: "ok" }
71
+ };
72
+ } catch (error) {
73
+ return refuse(500, error.name);
74
+ }
59
75
  default: return {
60
76
  status: 200,
61
77
  body: { status: "ignored" }
@@ -80,7 +96,7 @@ var SnsPushSubscriberAdaptor = class {
80
96
  async run(message, logger) {
81
97
  try {
82
98
  const { services, db } = await subscriberContext(this.subscriber, this.envParser);
83
- const events = subscribedEvents(this.subscriber, toSnsEvent(message), logger);
99
+ const events = await subscribedEvents(this.subscriber, toSnsEvent(message), logger);
84
100
  await runSubscriber(this.subscriber, {
85
101
  events,
86
102
  services,
@@ -111,6 +127,6 @@ function refuse(status, error) {
111
127
  };
112
128
  }
113
129
  //#endregion
114
- export { SnsPushSubscriberAdaptor, Subscriber, SubscriberBuilder };
130
+ export { ObjectSubscriberBuilder, ObjectSubscriberHasNoEvents, SnsPushSubscriberAdaptor, Subscriber, SubscriberBuilder };
115
131
 
116
132
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../../src/subscribers/SnsPushSubscriberAdaptor.ts"],"sourcesContent":["import type { EnvironmentParser } from '@geekmidas/envkit';\nimport { wrapError } from '@geekmidas/errors';\nimport { carrierFromAttributes, consumeTraced } from '@geekmidas/events';\nimport {\n\tconfirmSnsSubscription,\n\ttype SnsHttpMessage,\n\ttoSnsEvent,\n\tverifySnsMessage,\n} from '@geekmidas/events/sns';\nimport { runWithRequestContext } from '@geekmidas/services';\nimport {\n\trunSubscriber,\n\tsubscribedEvents,\n\tsubscriberContext,\n} from './runSubscriber';\nimport type { Subscriber } from './Subscriber';\n\nexport interface SnsPushOptions {\n\t/** The topic this subscriber is subscribed to. Anything else is refused. */\n\ttopicArn: string;\n\t/**\n\t * Verify each message's signature. Off only against an emulator, which\n\t * signs nothing — decided by the connection string's endpoint, never by\n\t * anything the request says about itself.\n\t */\n\tverify: boolean;\n\t/**\n\t * The emulator's address, as this server was configured with it. An\n\t * emulator names itself in `SubscribeURL` by the port it listens on inside\n\t * its container, which is not the port published to the host — so against\n\t * one, the confirmation is sent here instead. From configuration, never\n\t * from the request.\n\t */\n\temulatorEndpoint?: string;\n}\n\n/** What to answer SNS with. A non-2xx tells SNS to retry. */\nexport interface SnsPushResponse {\n\tstatus: 200 | 400 | 403 | 500;\n\tbody: { status: string; error?: string };\n}\n\n/**\n * A topic subscriber run by SNS pushing to an HTTP endpoint, rather than by a\n * poller.\n *\n * The notification is read as the Lambda event SNS would have invoked a\n * function with, and run through the same parsing, services and handler as the\n * Lambda adaptor — without its middy wrapper, which is Lambda's alone: a\n * server serving this route has no reason to install `@middy/core`.\n */\nexport class SnsPushSubscriberAdaptor {\n\tconstructor(\n\t\tprivate readonly envParser: EnvironmentParser<{}>,\n\t\treadonly subscriber: Subscriber<any, any, any, any, any, any, any>,\n\t\tprivate readonly options: SnsPushOptions,\n\t) {}\n\n\tasync handle(body: unknown): Promise<SnsPushResponse> {\n\t\tconst message = body as SnsHttpMessage;\n\t\tif (typeof message?.Type !== 'string' || !message.TopicArn) {\n\t\t\treturn refuse(400, 'not an SNS message');\n\t\t}\n\t\t// Checked before the signature: a genuine message for another topic is\n\t\t// still not this subscriber's to confirm or to handle.\n\t\tif (message.TopicArn !== this.options.topicArn) {\n\t\t\treturn refuse(403, `not subscribed to ${message.TopicArn}`);\n\t\t}\n\n\t\tif (this.options.verify) {\n\t\t\ttry {\n\t\t\t\tawait verifySnsMessage(message);\n\t\t\t} catch (error) {\n\t\t\t\tthis.subscriber.logger.warn(\n\t\t\t\t\t{ err: error, topicArn: message.TopicArn },\n\t\t\t\t\t'Refused an SNS message that did not verify',\n\t\t\t\t);\n\t\t\t\treturn refuse(403, (error as Error).name);\n\t\t\t}\n\t\t}\n\n\t\tswitch (message.Type) {\n\t\t\tcase 'SubscriptionConfirmation':\n\t\t\t\tawait confirmSnsSubscription(\n\t\t\t\t\tthis.options.emulatorEndpoint && message.SubscribeURL\n\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\t...message,\n\t\t\t\t\t\t\t\tSubscribeURL: throughEndpoint(\n\t\t\t\t\t\t\t\t\tmessage.SubscribeURL,\n\t\t\t\t\t\t\t\t\tthis.options.emulatorEndpoint,\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t: message,\n\t\t\t\t);\n\t\t\t\tthis.subscriber.logger.info(\n\t\t\t\t\t{ topicArn: message.TopicArn },\n\t\t\t\t\t'Confirmed SNS subscription',\n\t\t\t\t);\n\t\t\t\treturn { status: 200, body: { status: 'confirmed' } };\n\t\t\tcase 'Notification':\n\t\t\t\ttry {\n\t\t\t\t\tawait this.notify(message);\n\t\t\t\t\treturn { status: 200, body: { status: 'ok' } };\n\t\t\t\t} catch (error) {\n\t\t\t\t\t// Already logged. A 500 makes SNS retry, which is the point of\n\t\t\t\t\t// answering at all.\n\t\t\t\t\treturn refuse(500, (error as Error).name);\n\t\t\t\t}\n\t\t\tdefault:\n\t\t\t\treturn { status: 200, body: { status: 'ignored' } };\n\t\t}\n\t}\n\n\tprivate async notify(message: SnsHttpMessage): Promise<void> {\n\t\tconst requestId = message.MessageId;\n\t\tconst logger = this.subscriber.logger.child({ requestId });\n\n\t\tawait runWithRequestContext(\n\t\t\t{\n\t\t\t\tlogger,\n\t\t\t\trequestId,\n\t\t\t\tstartTime: Date.now(),\n\t\t\t\toperation: `subscriber ${this.subscriber.topicName ?? 'sns'}`,\n\t\t\t},\n\t\t\t// A CONSUMER span, child of the publisher's: SNS hands the trace\n\t\t\t// context on in the message attributes it was published with.\n\t\t\t() =>\n\t\t\t\tconsumeTraced(\n\t\t\t\t\t{\n\t\t\t\t\t\tsystem: 'aws_sns',\n\t\t\t\t\t\tdestination:\n\t\t\t\t\t\t\tthis.subscriber.topicName ??\n\t\t\t\t\t\t\tmessage.TopicArn.split(':').pop() ??\n\t\t\t\t\t\t\tmessage.TopicArn,\n\t\t\t\t\t\ttype: message.MessageAttributes?.type?.Value,\n\t\t\t\t\t\tmessageId: message.MessageId,\n\t\t\t\t\t},\n\t\t\t\t\tcarrierFromAttributes(message.MessageAttributes),\n\t\t\t\t\t() => this.run(message, logger),\n\t\t\t\t),\n\t\t);\n\t}\n\n\tprivate async run(\n\t\tmessage: SnsHttpMessage,\n\t\tlogger: Subscriber<any, any, any, any, any, any, any>['logger'],\n\t): Promise<void> {\n\t\ttry {\n\t\t\tconst { services, db } = await subscriberContext(\n\t\t\t\tthis.subscriber,\n\t\t\t\tthis.envParser,\n\t\t\t);\n\t\t\tconst events = subscribedEvents(\n\t\t\t\tthis.subscriber,\n\t\t\t\ttoSnsEvent(message),\n\t\t\t\tlogger,\n\t\t\t);\n\t\t\tawait runSubscriber(this.subscriber, {\n\t\t\t\tevents,\n\t\t\t\tservices,\n\t\t\t\tlogger,\n\t\t\t\tdb,\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tlogger.error(error as object, 'Error processing subscriber');\n\t\t\tthrow wrapError(error);\n\t\t}\n\t}\n}\n\n/** `url`, sent to `endpoint`'s origin instead of its own. */\nfunction throughEndpoint(url: string, endpoint: string): string {\n\tconst target = new URL(url);\n\tconst origin = new URL(endpoint);\n\ttarget.protocol = origin.protocol;\n\ttarget.host = origin.host;\n\treturn target.toString();\n}\n\nfunction refuse(\n\tstatus: SnsPushResponse['status'],\n\terror: string,\n): SnsPushResponse {\n\treturn { status, body: { status: 'refused', error } };\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAmDA,IAAa,2BAAb,MAAsC;CAEnB;CACR;CACQ;CAHlB,YACC,WACA,YACA,SACC;EAHgB,KAAA,YAAA;EACR,KAAA,aAAA;EACQ,KAAA,UAAA;CACf;CAEH,MAAM,OAAO,MAAyC;EACrD,MAAM,UAAU;EAChB,IAAI,OAAO,SAAS,SAAS,YAAY,CAAC,QAAQ,UACjD,OAAO,OAAO,KAAK,oBAAoB;EAIxC,IAAI,QAAQ,aAAa,KAAK,QAAQ,UACrC,OAAO,OAAO,KAAK,qBAAqB,QAAQ,UAAU;EAG3D,IAAI,KAAK,QAAQ,QAChB,IAAI;GACH,MAAM,iBAAiB,OAAO;EAC/B,SAAS,OAAO;GACf,KAAK,WAAW,OAAO,KACtB;IAAE,KAAK;IAAO,UAAU,QAAQ;GAAS,GACzC,4CACD;GACA,OAAO,OAAO,KAAM,MAAgB,IAAI;EACzC;EAGD,QAAQ,QAAQ,MAAhB;GACC,KAAK;IACJ,MAAM,uBACL,KAAK,QAAQ,oBAAoB,QAAQ,eACtC;KACA,GAAG;KACH,cAAc,gBACb,QAAQ,cACR,KAAK,QAAQ,gBACd;IACD,IACC,OACJ;IACA,KAAK,WAAW,OAAO,KACtB,EAAE,UAAU,QAAQ,SAAS,GAC7B,4BACD;IACA,OAAO;KAAE,QAAQ;KAAK,MAAM,EAAE,QAAQ,YAAY;IAAE;GACrD,KAAK,gBACJ,IAAI;IACH,MAAM,KAAK,OAAO,OAAO;IACzB,OAAO;KAAE,QAAQ;KAAK,MAAM,EAAE,QAAQ,KAAK;IAAE;GAC9C,SAAS,OAAO;IAGf,OAAO,OAAO,KAAM,MAAgB,IAAI;GACzC;GACD,SACC,OAAO;IAAE,QAAQ;IAAK,MAAM,EAAE,QAAQ,UAAU;GAAE;EACpD;CACD;CAEA,MAAc,OAAO,SAAwC;EAC5D,MAAM,YAAY,QAAQ;EAC1B,MAAM,SAAS,KAAK,WAAW,OAAO,MAAM,EAAE,UAAU,CAAC;EAEzD,MAAM,sBACL;GACC;GACA;GACA,WAAW,KAAK,IAAI;GACpB,WAAW,cAAc,KAAK,WAAW,aAAa;EACvD,SAIC,cACC;GACC,QAAQ;GACR,aACC,KAAK,WAAW,aAChB,QAAQ,SAAS,MAAM,GAAG,CAAC,CAAC,IAAI,KAChC,QAAQ;GACT,MAAM,QAAQ,mBAAmB,MAAM;GACvC,WAAW,QAAQ;EACpB,GACA,sBAAsB,QAAQ,iBAAiB,SACzC,KAAK,IAAI,SAAS,MAAM,CAC/B,CACF;CACD;CAEA,MAAc,IACb,SACA,QACgB;EAChB,IAAI;GACH,MAAM,EAAE,UAAU,OAAO,MAAM,kBAC9B,KAAK,YACL,KAAK,SACN;GACA,MAAM,SAAS,iBACd,KAAK,YACL,WAAW,OAAO,GAClB,MACD;GACA,MAAM,cAAc,KAAK,YAAY;IACpC;IACA;IACA;IACA;GACD,CAAC;EACF,SAAS,OAAO;GACf,OAAO,MAAM,OAAiB,6BAA6B;GAC3D,MAAM,UAAU,KAAK;EACtB;CACD;AACD;;AAGA,SAAS,gBAAgB,KAAa,UAA0B;CAC/D,MAAM,SAAS,IAAI,IAAI,GAAG;CAC1B,MAAM,SAAS,IAAI,IAAI,QAAQ;CAC/B,OAAO,WAAW,OAAO;CACzB,OAAO,OAAO,OAAO;CACrB,OAAO,OAAO,SAAS;AACxB;AAEA,SAAS,OACR,QACA,OACkB;CAClB,OAAO;EAAE;EAAQ,MAAM;GAAE,QAAQ;GAAW;EAAM;CAAE;AACrD"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/subscribers/SnsPushSubscriberAdaptor.ts"],"sourcesContent":["import type { EnvironmentParser } from '@geekmidas/envkit';\nimport { wrapError } from '@geekmidas/errors';\nimport { carrierFromAttributes, consumeTraced } from '@geekmidas/events';\nimport {\n\tconfirmSnsSubscription,\n\ttype SnsHttpMessage,\n\ttoSnsEvent,\n\tverifySnsMessage,\n} from '@geekmidas/events/sns';\nimport { runWithRequestContext } from '@geekmidas/services';\nimport {\n\trunSubscriber,\n\tsubscribedEvents,\n\tsubscriberContext,\n} from './runSubscriber';\nimport type { Subscriber } from './Subscriber';\n\nexport interface SnsPushOptions {\n\t/** The topic this subscriber is subscribed to. Anything else is refused. */\n\ttopicArn: string;\n\t/**\n\t * Verify each message's signature. Off only against an emulator, which\n\t * signs nothing — decided by the connection string's endpoint, never by\n\t * anything the request says about itself.\n\t */\n\tverify: boolean;\n\t/**\n\t * The emulator's address, as this server was configured with it. An\n\t * emulator names itself in `SubscribeURL` by the port it listens on inside\n\t * its container, which is not the port published to the host — so against\n\t * one, the confirmation is sent here instead. From configuration, never\n\t * from the request.\n\t */\n\temulatorEndpoint?: string;\n\t/**\n\t * The subscription the deploy made for this route, as it recorded it\n\t * (`GKM_SNS_SUBSCRIPTIONS`). A notification naming another one in its\n\t * `x-amz-sns-subscription-arn` header is refused — a subscription someone\n\t * added by hand, pointing at this route.\n\t *\n\t * Defence in depth, not authentication: SNS does not sign its headers.\n\t * What authenticates a message is its signature and its `TopicArn`.\n\t */\n\tsubscriptionArn?: string;\n\t/**\n\t * How a confirmation is confirmed. By visiting its `SubscribeURL` when\n\t * unset — what an emulator takes. Against SNS itself, the server confirms\n\t * through the API with `AuthenticateOnUnsubscribe`, so the `UnsubscribeURL`\n\t * every notification carries cannot cut the route off.\n\t */\n\tconfirm?: (message: SnsHttpMessage) => Promise<unknown>;\n}\n\n/** What SNS said about a request, outside its signed body. */\nexport interface SnsPushRequest {\n\t/** The `x-amz-sns-subscription-arn` header. */\n\tsubscriptionArn?: string;\n}\n\n/** What to answer SNS with. A non-2xx tells SNS to retry. */\nexport interface SnsPushResponse {\n\tstatus: 200 | 400 | 403 | 500;\n\tbody: { status: string; error?: string };\n}\n\n/**\n * A topic subscriber run by SNS pushing to an HTTP endpoint, rather than by a\n * poller.\n *\n * The notification is read as the Lambda event SNS would have invoked a\n * function with, and run through the same parsing, services and handler as the\n * Lambda adaptor — without its middy wrapper, which is Lambda's alone: a\n * server serving this route has no reason to install `@middy/core`.\n */\nexport class SnsPushSubscriberAdaptor {\n\tconstructor(\n\t\tprivate readonly envParser: EnvironmentParser<{}>,\n\t\treadonly subscriber: Subscriber<any, any, any, any, any, any, any>,\n\t\tprivate readonly options: SnsPushOptions,\n\t) {}\n\n\tasync handle(\n\t\tbody: unknown,\n\t\trequest: SnsPushRequest = {},\n\t): Promise<SnsPushResponse> {\n\t\tconst message = body as SnsHttpMessage;\n\t\tif (typeof message?.Type !== 'string' || !message.TopicArn) {\n\t\t\treturn refuse(400, 'not an SNS message');\n\t\t}\n\t\t// Checked before the signature: a genuine message for another topic is\n\t\t// still not this subscriber's to confirm or to handle.\n\t\tif (message.TopicArn !== this.options.topicArn) {\n\t\t\treturn refuse(403, `not subscribed to ${message.TopicArn}`);\n\t\t}\n\n\t\tif (this.options.verify) {\n\t\t\ttry {\n\t\t\t\tawait verifySnsMessage(message);\n\t\t\t} catch (error) {\n\t\t\t\tthis.subscriber.logger.warn(\n\t\t\t\t\t{ err: error, topicArn: message.TopicArn },\n\t\t\t\t\t'Refused an SNS message that did not verify',\n\t\t\t\t);\n\t\t\t\treturn refuse(403, (error as Error).name);\n\t\t\t}\n\t\t}\n\n\t\tswitch (message.Type) {\n\t\t\tcase 'SubscriptionConfirmation':\n\t\t\t\tif (this.options.confirm) {\n\t\t\t\t\tawait this.options.confirm(message);\n\t\t\t\t\tthis.subscriber.logger.info(\n\t\t\t\t\t\t{ topicArn: message.TopicArn },\n\t\t\t\t\t\t'Confirmed SNS subscription',\n\t\t\t\t\t);\n\t\t\t\t\treturn { status: 200, body: { status: 'confirmed' } };\n\t\t\t\t}\n\t\t\t\tawait confirmSnsSubscription(\n\t\t\t\t\tthis.options.emulatorEndpoint && message.SubscribeURL\n\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\t...message,\n\t\t\t\t\t\t\t\tSubscribeURL: throughEndpoint(\n\t\t\t\t\t\t\t\t\tmessage.SubscribeURL,\n\t\t\t\t\t\t\t\t\tthis.options.emulatorEndpoint,\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t: message,\n\t\t\t\t);\n\t\t\t\tthis.subscriber.logger.info(\n\t\t\t\t\t{ topicArn: message.TopicArn },\n\t\t\t\t\t'Confirmed SNS subscription',\n\t\t\t\t);\n\t\t\t\treturn { status: 200, body: { status: 'confirmed' } };\n\t\t\tcase 'Notification':\n\t\t\t\tif (\n\t\t\t\t\tthis.options.subscriptionArn &&\n\t\t\t\t\trequest.subscriptionArn !== this.options.subscriptionArn\n\t\t\t\t) {\n\t\t\t\t\tthis.subscriber.logger.warn(\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\ttopicArn: message.TopicArn,\n\t\t\t\t\t\t\tsubscriptionArn: request.subscriptionArn,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t'Refused an SNS notification from a subscription the deploy did not make',\n\t\t\t\t\t);\n\t\t\t\t\treturn refuse(403, 'not the recorded subscription');\n\t\t\t\t}\n\t\t\t\ttry {\n\t\t\t\t\tawait this.notify(message);\n\t\t\t\t\treturn { status: 200, body: { status: 'ok' } };\n\t\t\t\t} catch (error) {\n\t\t\t\t\t// Already logged. A 500 makes SNS retry, which is the point of\n\t\t\t\t\t// answering at all.\n\t\t\t\t\treturn refuse(500, (error as Error).name);\n\t\t\t\t}\n\t\t\tdefault:\n\t\t\t\treturn { status: 200, body: { status: 'ignored' } };\n\t\t}\n\t}\n\n\tprivate async notify(message: SnsHttpMessage): Promise<void> {\n\t\tconst requestId = message.MessageId;\n\t\tconst logger = this.subscriber.logger.child({ requestId });\n\n\t\tawait runWithRequestContext(\n\t\t\t{\n\t\t\t\tlogger,\n\t\t\t\trequestId,\n\t\t\t\tstartTime: Date.now(),\n\t\t\t\toperation: `subscriber ${this.subscriber.topicName ?? 'sns'}`,\n\t\t\t},\n\t\t\t// A CONSUMER span, child of the publisher's: SNS hands the trace\n\t\t\t// context on in the message attributes it was published with.\n\t\t\t() =>\n\t\t\t\tconsumeTraced(\n\t\t\t\t\t{\n\t\t\t\t\t\tsystem: 'aws_sns',\n\t\t\t\t\t\tdestination:\n\t\t\t\t\t\t\tthis.subscriber.topicName ??\n\t\t\t\t\t\t\tmessage.TopicArn.split(':').pop() ??\n\t\t\t\t\t\t\tmessage.TopicArn,\n\t\t\t\t\t\ttype: message.MessageAttributes?.type?.Value,\n\t\t\t\t\t\tmessageId: message.MessageId,\n\t\t\t\t\t},\n\t\t\t\t\tcarrierFromAttributes(message.MessageAttributes),\n\t\t\t\t\t() => this.run(message, logger),\n\t\t\t\t),\n\t\t);\n\t}\n\n\tprivate async run(\n\t\tmessage: SnsHttpMessage,\n\t\tlogger: Subscriber<any, any, any, any, any, any, any>['logger'],\n\t): Promise<void> {\n\t\ttry {\n\t\t\tconst { services, db } = await subscriberContext(\n\t\t\t\tthis.subscriber,\n\t\t\t\tthis.envParser,\n\t\t\t);\n\t\t\tconst events = await subscribedEvents(\n\t\t\t\tthis.subscriber,\n\t\t\t\ttoSnsEvent(message),\n\t\t\t\tlogger,\n\t\t\t);\n\t\t\tawait runSubscriber(this.subscriber, {\n\t\t\t\tevents,\n\t\t\t\tservices,\n\t\t\t\tlogger,\n\t\t\t\tdb,\n\t\t\t});\n\t\t} catch (error) {\n\t\t\tlogger.error(error as object, 'Error processing subscriber');\n\t\t\tthrow wrapError(error);\n\t\t}\n\t}\n}\n\n/** `url`, sent to `endpoint`'s origin instead of its own. */\nfunction throughEndpoint(url: string, endpoint: string): string {\n\tconst target = new URL(url);\n\tconst origin = new URL(endpoint);\n\ttarget.protocol = origin.protocol;\n\ttarget.host = origin.host;\n\treturn target.toString();\n}\n\nfunction refuse(\n\tstatus: SnsPushResponse['status'],\n\terror: string,\n): SnsPushResponse {\n\treturn { status, body: { status: 'refused', error } };\n}\n"],"mappings":"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"}
package/dist/worker.cjs CHANGED
@@ -4,7 +4,7 @@ const require_functions = require("./functions-D7O5SGVc.cjs");
4
4
  const require_surfaceEnv = require("./surfaceEnv-CNimK76p.cjs");
5
5
  const require_CronBuilder = require("./CronBuilder-BXTG10u5.cjs");
6
6
  const require_QueueBuilder = require("./QueueBuilder-DdU2klPu.cjs");
7
- const require_SubscriberBuilder = require("./SubscriberBuilder-M2EAgQhC.cjs");
7
+ const require_SubscriberBuilder = require("./SubscriberBuilder-CYluzubx.cjs");
8
8
  let _geekmidas_manifest = require("@geekmidas/manifest");
9
9
  let _geekmidas_logger_console = require("@geekmidas/logger/console");
10
10
  //#region src/worker.ts
@@ -92,6 +92,27 @@ var Worker = class Worker {
92
92
  return this.own(new require_QueueBuilder.QueueBuilder().logger(this.logger)).queue(name);
93
93
  }
94
94
  /**
95
+ * A runnable that reacts to a bucket's objects changing.
96
+ *
97
+ * A bucket is a topic whose publisher is the storage: the binding is the
98
+ * same as {@link Worker.topic}'s, and so is everything after it — the
99
+ * services, the database, how it is delivered. The handler is handed the
100
+ * objects; reading one still means depending on the bucket,
101
+ * `.dependsOn([uploads])`.
102
+ *
103
+ * @example
104
+ * ```ts
105
+ * export const onAvatar = worker
106
+ * .objects(uploads)
107
+ * .on(['object.created'], { prefix: 'avatars/', suffix: '.png' })
108
+ * .handle(async ({ objects }) => { … });
109
+ * ```
110
+ */
111
+ objects(bucket) {
112
+ const inner = this.own(new require_SubscriberBuilder.SubscriberBuilder().logger(this.logger));
113
+ return new require_SubscriberBuilder.ObjectSubscriberBuilder(inner, bucket.id);
114
+ }
115
+ /**
95
116
  * A runnable that consumes a topic.
96
117
  *
97
118
  * Binding is not publishing: a consumer is handed the topic's event types
@@ -1 +1 @@
1
- {"version":3,"file":"worker.cjs","names":["canonicalId","DEFAULT_LOGGER","envParserFor","edgeTo","CronBuilder","QueueBuilder","SubscriberBuilder","FunctionBuilder"],"sources":["../src/worker.ts"],"sourcesContent":["/**\n * A process with no port.\n *\n * `RestApi` is the process that answers HTTP. This is the one that does not —\n * the thing that runs a schedule, drains a queue, consumes a topic. Until it\n * existed, those had nowhere to say they belonged: a cron declared a schedule\n * and nothing else, and which container ran it was decided by the directory the\n * file sat in. A glob owned it.\n *\n * The cost of that shows up in the scaffolds. `gkm init --template worker` —\n * a project that is background jobs and nothing else — declared\n * `new RestApi('Api', { defaultAuthorizer: 'none', logger })`: an HTTP surface\n * with no HTTP on it, written because it was the only construct that made an\n * app exist and the only way to get a logger into the generated runtime.\n *\n * It takes no authorizer, and that is the tell that these are two constructs\n * rather than one with a flag: a `RestApi` needs one because an HTTP surface\n * can ship open by omission, and nothing reaches a worker from outside.\n *\n * ## Crons on a server target need a database\n *\n * A cron becomes an EventBridge rule on AWS and nothing here is consulted. A\n * server has nothing firing its crons but itself, and a process that schedules\n * in memory fires every job once per replica while reporting success — so the\n * schedule is kept in Postgres, and the worker says which one with\n * `.database(db)`.\n *\n * A worker with crons and no `.database(…)` schedules nothing on a server\n * target and says so at startup. That is a limitation of workers today rather\n * than a design: the store could be a cache, or something the target\n * provisions, and Postgres is what exists.\n *\n * ## The database is everything's default\n *\n * The same `.database(db)` is the database every cron, queue, subscriber and\n * function built from the worker receives as `db`. The work a worker groups is\n * usually work against one database, and naming it per handler was a line\n * repeated in every file that could drift in any one of them. A runnable that\n * works against another names its own with `.database(other)`, which replaces\n * the worker's — client and manifest edge both — rather than adding to it.\n *\n * It is not an app: it declares no path, and its code lives in an app's\n * directory. But it is a process of its own. On a server target each worker\n * with work to do is built — from the app that holds that work — into an image\n * that runs its crons, consumers and subscribers and answers only a health\n * check, so a slow queue never shares a process with the requests an API\n * serves. `gkm dev` still runs everything in one process. Declare as many as\n * the shape of the work wants: each is one container.\n *\n * @example\n * ```ts\n * // constructs/worker.ts\n * import { Worker } from '@geekmidas/constructs/worker';\n * import { logger } from './logger.js';\n *\n * export const worker = new Worker('Worker', { logger }).database(database);\n *\n * // crons/cleanup.ts\n * import { worker } from '@acme/constructs/worker.js';\n *\n * export const cleanup = worker\n * .cron('rate(1 day)')\n * .handle(async ({ db, logger }) => { … });\n *\n * // queues/reports.ts — against another database than the worker's\n * export const reports = worker\n * .queue('Reports')\n * .database(analytics)\n * .message(z.object({ id: z.uuid() }))\n * .handle(async ({ messages, db }) => { … });\n * ```\n */\n\nimport type { EnvironmentParser } from '@geekmidas/envkit';\nimport type { EventPublisher } from '@geekmidas/events';\nimport type { Logger } from '@geekmidas/logger';\nimport { DEFAULT_LOGGER } from '@geekmidas/logger/console';\nimport {\n\ttype ConstructName,\n\tcanonicalId,\n\ttype Declaration,\n\ttype Dependency,\n} from '@geekmidas/manifest';\nimport type { ComposableStandardSchema } from '@geekmidas/schema';\nimport type { Service } from '@geekmidas/services';\nimport type { StandardSchemaV1 } from '@standard-schema/spec';\nimport {\n\ttype Consumable,\n\ttype Declarable,\n\tedgeTo,\n} from './construct-interface';\nimport type { ScheduleExpression } from './crons/Cron';\nimport { CronBuilder } from './crons/CronBuilder';\nimport { envParserFor } from './endpoints/surfaceEnv';\nimport { FunctionBuilder } from './functions/FunctionBuilder';\nimport { QueueBuilder } from './queue/QueueBuilder';\nimport { SubscriberBuilder } from './subscribers/SubscriberBuilder';\nimport type { Telemetry } from './telemetry';\nimport type { Topic, TopicEvents } from './topic/Topic';\n\nexport interface WorkerConfig {\n\t/**\n\t * The logger everything built from this worker runs with.\n\t *\n\t * The actual logger, not a path to one — the same reason `RestApi` takes\n\t * one. Every cron and subscriber in a scaffolded project used to open with\n\t * `import { logger } from '…/constructs/logger.js'`, which is the line this\n\t * removes.\n\t */\n\tlogger?: Logger;\n\t/**\n\t * The environment parser everything built from this worker runs with.\n\t *\n\t * Defaults to `process.env` merged with the credentials `gkm dev` injected,\n\t * which is what an application's own `config/env.ts` always was.\n\t */\n\tenvParser?: EnvironmentParser<{}>;\n\t/**\n\t * What the worker's process emits — the `Telemetry` construct, passed like\n\t * the logger. An edge to the node: the deploy hands the process the\n\t * `OTEL_*` keys its stage resolves.\n\t */\n\ttelemetry?: Telemetry;\n}\n\nexport class Worker<\n\tTName extends string = string,\n\tTDatabase = undefined,\n\tTDatabaseServiceName extends string = string,\n> implements Declarable<TName>\n{\n\treadonly id: TName;\n\n\t/** Always defined — the worker's own, or the console logger. */\n\treadonly logger: Logger;\n\n\t/** Always defined, and normally the default. */\n\treadonly envParser: EnvironmentParser<{}>;\n\n\t/**\n\t * Where this worker's schedules live, when it has crons and a server runs\n\t * them — and the database everything built from it receives as `db`.\n\t * `undefined` until `.database(…)` says.\n\t */\n\treadonly scheduleStore?: Consumable<string, unknown>;\n\n\tconstructor(\n\t\tid: ConstructName<TName>,\n\t\tprivate readonly config: WorkerConfig = {},\n\t\t/** Internal: how `.calls()` carries edges into the copy it returns. */\n\t\tprivate readonly dependencies: readonly Dependency[] = [],\n\t\tscheduleStore?: Consumable<string, unknown>,\n\t) {\n\t\tconst canonical = canonicalId(id as string);\n\n\t\tthis.id = canonical as TName;\n\t\tthis.logger = config.logger ?? DEFAULT_LOGGER;\n\t\tthis.envParser = envParserFor(\n\t\t\tconfig.envParser\n\t\t\t\t? { id: canonical, envParser: config.envParser }\n\t\t\t\t: undefined,\n\t\t);\n\t\tthis.scheduleStore = scheduleStore;\n\t}\n\n\t/**\n\t * The database this worker keeps its schedules in, and the one everything\n\t * built from it — crons, queues, subscribers, functions — receives as `db`.\n\t * One of them that works against another database names it with its own\n\t * `.database(other)`, which replaces this one for that runnable alone.\n\t *\n\t * Only a server target uses it. On AWS a cron is an EventBridge rule\n\t * invoking a Lambda and nothing here is consulted — but a server has nothing\n\t * firing its crons except itself, and a process that schedules in memory\n\t * fires every job once per replica while reporting that all is well.\n\t *\n\t * So the store is declared rather than discovered. It could have been\n\t * inferred — from a database the app happens to declare, or from the events\n\t * backend when that backend is pg-boss — and both would work until an app\n\t * had two databases, or moved its events to SNS, at which point the\n\t * schedules would quietly move or stop. Naming it is one line and cannot\n\t * drift.\n\t *\n\t * @example\n\t * ```ts\n\t * export const jobs = new Worker('Jobs', { logger }).database(database);\n\t *\n\t * export const sweep = jobs\n\t * .cron('rate(1 hour)')\n\t * .handle(async ({ db }) => db.deleteFrom('sessions').execute());\n\t * ```\n\t */\n\tdatabase<T, TDbName extends string>(\n\t\tsource: Consumable<TDbName, T>,\n\t): Worker<TName, T, TDbName> {\n\t\treturn new Worker<TName, T, TDbName>(\n\t\t\tthis.id as ConstructName<TName>,\n\t\t\tthis.config,\n\t\t\t[...this.dependencies, edgeTo(source)],\n\t\t\tsource as unknown as Consumable<string, unknown>,\n\t\t);\n\t}\n\n\t/**\n\t * A scheduled runnable.\n\t *\n\t * @example `worker.cron('rate(1 day)').handle(async ({ logger }) => { … })`\n\t */\n\tcron(schedule: ScheduleExpression) {\n\t\treturn this.own(\n\t\t\tnew CronBuilder<\n\t\t\t\tComposableStandardSchema,\n\t\t\t\tService[],\n\t\t\t\tLogger,\n\t\t\t\tundefined,\n\t\t\t\tTDatabase,\n\t\t\t\tTDatabaseServiceName\n\t\t\t>().logger(this.logger),\n\t\t).schedule(schedule);\n\t}\n\n\t/**\n\t * A queue and its one consumer, run by this worker.\n\t *\n\t * One construct, because a queue has exactly one consumer: nothing else can\n\t * attach a second, or forget to attach the first. Producers send to it by\n\t * depending on it — `.dependsOn([emails])` makes `services.emails` the\n\t * publisher.\n\t *\n\t * @example\n\t * ```ts\n\t * export const emails = worker\n\t * .queue('Emails')\n\t * .message(z.object({ userId: z.uuid(), template: z.string() }))\n\t * .handle(async ({ messages, services }) => { … });\n\t * ```\n\t */\n\tqueue<TQueueName extends string>(name: TQueueName) {\n\t\treturn this.own(\n\t\t\tnew QueueBuilder<\n\t\t\t\tstring,\n\t\t\t\tundefined,\n\t\t\t\t[],\n\t\t\t\tLogger,\n\t\t\t\tTDatabase,\n\t\t\t\tTDatabaseServiceName\n\t\t\t>().logger(this.logger),\n\t\t).queue(name);\n\t}\n\n\t/**\n\t * A runnable that consumes a topic.\n\t *\n\t * Binding is not publishing: a consumer is handed the topic's event types\n\t * and no connection string it could publish with. A handler that emits\n\t * follow-up events depends on the topic it emits to —\n\t * `.dependsOn([orders])` — which is the only reason it would need one.\n\t *\n\t * @example `worker.topic(users).subscribe(['user.created']).handle(…)`\n\t */\n\ttopic<TTopicName extends string, TEvents extends TopicEvents>(\n\t\ttopic: Topic<TTopicName, TEvents>,\n\t) {\n\t\treturn this.own(\n\t\t\tnew SubscriberBuilder<\n\t\t\t\t[],\n\t\t\t\tLogger,\n\t\t\t\tundefined,\n\t\t\t\tundefined,\n\t\t\t\t[],\n\t\t\t\tTDatabase,\n\t\t\t\tTDatabaseServiceName\n\t\t\t>().logger(this.logger),\n\t\t).topic(topic);\n\t}\n\n\t/**\n\t * Everything below builds a function — the runnable with no schedule and no\n\t * topic, invoked by something else.\n\t *\n\t * These sit on the worker rather than behind a `functions` namespace because\n\t * the namespace was a hop that said nothing: `worker.functions.input(…)`\n\t * names a collection to reach one member of it. What kind of runnable is\n\t * being built is decided by which of these is called first — a schedule\n\t * makes a cron, a topic makes a subscriber, and anything else makes a\n\t * function.\n\t */\n\tinput<T extends ComposableStandardSchema>(schema: T) {\n\t\treturn this.functions.input(schema);\n\t}\n\n\toutput<T extends StandardSchemaV1>(schema: T) {\n\t\treturn this.functions.output(schema);\n\t}\n\n\ttimeout(ms: number) {\n\t\treturn this.functions.timeout(ms);\n\t}\n\n\tmemorySize(mb: number) {\n\t\treturn this.functions.memorySize(mb);\n\t}\n\n\tdependsOn<const T extends readonly Consumable[]>(constructs: T) {\n\t\treturn this.functions.dependsOn(constructs);\n\t}\n\n\tservices<T extends Service[]>(services: T) {\n\t\treturn this.functions.services(services);\n\t}\n\n\t/**\n\t * A function with no input schema — the shortest runnable there is.\n\t *\n\t * @example `worker.handle(async ({ logger }) => { … })`\n\t */\n\thandle(\n\t\t...args: Parameters<\n\t\t\tReturnType<\n\t\t\t\tWorker<TName, TDatabase, TDatabaseServiceName>['schemalessFunction']\n\t\t\t>['handle']\n\t\t>\n\t) {\n\t\treturn this.schemalessFunction().handle(...args);\n\t}\n\n\t/** The builder `handle` delegates to, named so its type can be referred to. */\n\tprivate schemalessFunction() {\n\t\treturn this.functions;\n\t}\n\n\t/** The function builder the delegating methods above are built on. */\n\tprivate get functions() {\n\t\treturn this.own(\n\t\t\tnew FunctionBuilder<\n\t\t\t\tany,\n\t\t\t\tundefined,\n\t\t\t\t[],\n\t\t\t\tLogger,\n\t\t\t\tundefined,\n\t\t\t\tstring,\n\t\t\t\tTDatabase,\n\t\t\t\tTDatabaseServiceName\n\t\t\t>().logger(this.logger),\n\t\t);\n\t}\n\n\t/**\n\t * Resources and surfaces this worker calls.\n\t *\n\t * Not `.dependsOn()`: this records the edge that derives the worker's\n\t * environment and its access, the same distinction `RestApi.calls()` draws.\n\t */\n\tcalls(\n\t\tconstructs: readonly Declarable[],\n\t): Worker<TName, TDatabase, TDatabaseServiceName> {\n\t\treturn new Worker<TName, TDatabase, TDatabaseServiceName>(\n\t\t\tthis.id as ConstructName<TName>,\n\t\t\tthis.config,\n\t\t\t[...this.dependencies, ...constructs.map(edgeTo)],\n\t\t\tthis.scheduleStore,\n\t\t);\n\t}\n\n\t/**\n\t * Stamps this worker's id onto a builder, so everything built from it says\n\t * which process runs it — and hands it the worker's database.\n\t *\n\t * The database goes through the builder's own `.database()` rather than\n\t * being written into its fields, so the default is recorded exactly as an\n\t * explicit one would be: the client the handler gets, and the edge the\n\t * manifest reads. That is also what lets a later `.database(other)` replace\n\t * it outright. The builder arrives already typed with this worker's\n\t * database; this is the runtime half of that.\n\t */\n\tprivate own<\n\t\tT extends {\n\t\t\t_owner?: string;\n\t\t\t_scheduleStore?: unknown;\n\t\t\tdatabase(source: Consumable<string, unknown>): unknown;\n\t\t},\n\t>(builder: T): T {\n\t\tconst owned = this.scheduleStore\n\t\t\t? (builder.database(this.scheduleStore) as T)\n\t\t\t: builder;\n\n\t\towned._owner = this.id;\n\t\towned._scheduleStore = this.scheduleStore;\n\n\t\treturn owned;\n\t}\n\n\tdeclare(): Declaration[] {\n\t\treturn [\n\t\t\t{\n\t\t\t\tkind: 'worker',\n\t\t\t\tid: this.id,\n\t\t\t\t...(this.dependencies.length\n\t\t\t\t\t? { dependencies: this.dependencies }\n\t\t\t\t\t: {}),\n\t\t\t\t...(this.config.telemetry\n\t\t\t\t\t? { telemetry: this.config.telemetry.id }\n\t\t\t\t\t: {}),\n\t\t\t\t// Nothing calls a worker, so it publishes no address.\n\t\t\t\tprovides: [],\n\t\t\t},\n\t\t];\n\t}\n}\n"],"mappings":";;;;;;;;;;AA6HA,IAAa,SAAb,MAAa,OAKb;CAkBmB;CAEA;CAnBlB;;CAGA;;CAGA;;;;;;CAOA;CAEA,YACC,IACA,SAAwC,CAAC,GAEzC,eAAuD,CAAC,GACxD,eACC;EAJgB,KAAA,SAAA;EAEA,KAAA,eAAA;EAGjB,MAAM,aAAA,GAAYA,oBAAAA,YAAAA,CAAY,EAAY;EAE1C,KAAK,KAAK;EACV,KAAK,SAAS,OAAO,UAAUC,0BAAAA;EAC/B,KAAK,YAAYC,mBAAAA,aAChB,OAAO,YACJ;GAAE,IAAI;GAAW,WAAW,OAAO;EAAU,IAC7C,KAAA,CACJ;EACA,KAAK,gBAAgB;CACtB;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6BA,SACC,QAC4B;EAC5B,OAAO,IAAI,OACV,KAAK,IACL,KAAK,QACL,CAAC,GAAG,KAAK,cAAcC,4BAAAA,OAAO,MAAM,CAAC,GACrC,MACD;CACD;;;;;;CAOA,KAAK,UAA8B;EAClC,OAAO,KAAK,IACX,IAAIC,oBAAAA,YAOF,CAAC,CAAC,OAAO,KAAK,MAAM,CACvB,CAAC,CAAC,SAAS,QAAQ;CACpB;;;;;;;;;;;;;;;;;CAkBA,MAAiC,MAAkB;EAClD,OAAO,KAAK,IACX,IAAIC,qBAAAA,aAOF,CAAC,CAAC,OAAO,KAAK,MAAM,CACvB,CAAC,CAAC,MAAM,IAAI;CACb;;;;;;;;;;;CAYA,MACC,OACC;EACD,OAAO,KAAK,IACX,IAAIC,0BAAAA,kBAQF,CAAC,CAAC,OAAO,KAAK,MAAM,CACvB,CAAC,CAAC,MAAM,KAAK;CACd;;;;;;;;;;;;CAaA,MAA0C,QAAW;EACpD,OAAO,KAAK,UAAU,MAAM,MAAM;CACnC;CAEA,OAAmC,QAAW;EAC7C,OAAO,KAAK,UAAU,OAAO,MAAM;CACpC;CAEA,QAAQ,IAAY;EACnB,OAAO,KAAK,UAAU,QAAQ,EAAE;CACjC;CAEA,WAAW,IAAY;EACtB,OAAO,KAAK,UAAU,WAAW,EAAE;CACpC;CAEA,UAAiD,YAAe;EAC/D,OAAO,KAAK,UAAU,UAAU,UAAU;CAC3C;CAEA,SAA8B,UAAa;EAC1C,OAAO,KAAK,UAAU,SAAS,QAAQ;CACxC;;;;;;CAOA,OACC,GAAG,MAKF;EACD,OAAO,KAAK,mBAAmB,CAAC,CAAC,OAAO,GAAG,IAAI;CAChD;;CAGA,qBAA6B;EAC5B,OAAO,KAAK;CACb;;CAGA,IAAY,YAAY;EACvB,OAAO,KAAK,IACX,IAAIC,kBAAAA,gBASF,CAAC,CAAC,OAAO,KAAK,MAAM,CACvB;CACD;;;;;;;CAQA,MACC,YACiD;EACjD,OAAO,IAAI,OACV,KAAK,IACL,KAAK,QACL,CAAC,GAAG,KAAK,cAAc,GAAG,WAAW,IAAIJ,4BAAAA,MAAM,CAAC,GAChD,KAAK,aACN;CACD;;;;;;;;;;;;CAaA,IAME,SAAe;EAChB,MAAM,QAAQ,KAAK,gBACf,QAAQ,SAAS,KAAK,aAAa,IACpC;EAEH,MAAM,SAAS,KAAK;EACpB,MAAM,iBAAiB,KAAK;EAE5B,OAAO;CACR;CAEA,UAAyB;EACxB,OAAO,CACN;GACC,MAAM;GACN,IAAI,KAAK;GACT,GAAI,KAAK,aAAa,SACnB,EAAE,cAAc,KAAK,aAAa,IAClC,CAAC;GACJ,GAAI,KAAK,OAAO,YACb,EAAE,WAAW,KAAK,OAAO,UAAU,GAAG,IACtC,CAAC;GAEJ,UAAU,CAAC;EACZ,CACD;CACD;AACD"}
1
+ {"version":3,"file":"worker.cjs","names":["canonicalId","DEFAULT_LOGGER","envParserFor","edgeTo","CronBuilder","QueueBuilder","SubscriberBuilder","ObjectSubscriberBuilder","FunctionBuilder"],"sources":["../src/worker.ts"],"sourcesContent":["/**\n * A process with no port.\n *\n * `RestApi` is the process that answers HTTP. This is the one that does not —\n * the thing that runs a schedule, drains a queue, consumes a topic. Until it\n * existed, those had nowhere to say they belonged: a cron declared a schedule\n * and nothing else, and which container ran it was decided by the directory the\n * file sat in. A glob owned it.\n *\n * The cost of that shows up in the scaffolds. `gkm init --template worker` —\n * a project that is background jobs and nothing else — declared\n * `new RestApi('Api', { defaultAuthorizer: 'none', logger })`: an HTTP surface\n * with no HTTP on it, written because it was the only construct that made an\n * app exist and the only way to get a logger into the generated runtime.\n *\n * It takes no authorizer, and that is the tell that these are two constructs\n * rather than one with a flag: a `RestApi` needs one because an HTTP surface\n * can ship open by omission, and nothing reaches a worker from outside.\n *\n * ## Crons on a server target need a database\n *\n * A cron becomes an EventBridge rule on AWS and nothing here is consulted. A\n * server has nothing firing its crons but itself, and a process that schedules\n * in memory fires every job once per replica while reporting success — so the\n * schedule is kept in Postgres, and the worker says which one with\n * `.database(db)`.\n *\n * A worker with crons and no `.database(…)` schedules nothing on a server\n * target and says so at startup. That is a limitation of workers today rather\n * than a design: the store could be a cache, or something the target\n * provisions, and Postgres is what exists.\n *\n * ## The database is everything's default\n *\n * The same `.database(db)` is the database every cron, queue, subscriber and\n * function built from the worker receives as `db`. The work a worker groups is\n * usually work against one database, and naming it per handler was a line\n * repeated in every file that could drift in any one of them. A runnable that\n * works against another names its own with `.database(other)`, which replaces\n * the worker's — client and manifest edge both — rather than adding to it.\n *\n * It is not an app: it declares no path, and its code lives in an app's\n * directory. But it is a process of its own. On a server target each worker\n * with work to do is built — from the app that holds that work — into an image\n * that runs its crons, consumers and subscribers and answers only a health\n * check, so a slow queue never shares a process with the requests an API\n * serves. `gkm dev` still runs everything in one process. Declare as many as\n * the shape of the work wants: each is one container.\n *\n * @example\n * ```ts\n * // constructs/worker.ts\n * import { Worker } from '@geekmidas/constructs/worker';\n * import { logger } from './logger.js';\n *\n * export const worker = new Worker('Worker', { logger }).database(database);\n *\n * // crons/cleanup.ts\n * import { worker } from '@acme/constructs/worker.js';\n *\n * export const cleanup = worker\n * .cron('rate(1 day)')\n * .handle(async ({ db, logger }) => { … });\n *\n * // queues/reports.ts — against another database than the worker's\n * export const reports = worker\n * .queue('Reports')\n * .database(analytics)\n * .message(z.object({ id: z.uuid() }))\n * .handle(async ({ messages, db }) => { … });\n * ```\n */\n\nimport type { EnvironmentParser } from '@geekmidas/envkit';\nimport type { EventPublisher } from '@geekmidas/events';\nimport type { Logger } from '@geekmidas/logger';\nimport { DEFAULT_LOGGER } from '@geekmidas/logger/console';\nimport {\n\ttype ConstructName,\n\tcanonicalId,\n\ttype Declaration,\n\ttype Dependency,\n} from '@geekmidas/manifest';\nimport type { ComposableStandardSchema } from '@geekmidas/schema';\nimport type { Service } from '@geekmidas/services';\nimport type { StandardSchemaV1 } from '@standard-schema/spec';\nimport {\n\ttype Consumable,\n\ttype Declarable,\n\tedgeTo,\n} from './construct-interface';\nimport type { ScheduleExpression } from './crons/Cron';\nimport { CronBuilder } from './crons/CronBuilder';\nimport { envParserFor } from './endpoints/surfaceEnv';\nimport { FunctionBuilder } from './functions/FunctionBuilder';\nimport type { ObjectStorage } from './object-storage';\nimport { QueueBuilder } from './queue/QueueBuilder';\nimport { ObjectSubscriberBuilder } from './subscribers/ObjectSubscriberBuilder';\nimport { SubscriberBuilder } from './subscribers/SubscriberBuilder';\nimport type { Telemetry } from './telemetry';\nimport type { Topic, TopicEvents } from './topic/Topic';\n\nexport interface WorkerConfig {\n\t/**\n\t * The logger everything built from this worker runs with.\n\t *\n\t * The actual logger, not a path to one — the same reason `RestApi` takes\n\t * one. Every cron and subscriber in a scaffolded project used to open with\n\t * `import { logger } from '…/constructs/logger.js'`, which is the line this\n\t * removes.\n\t */\n\tlogger?: Logger;\n\t/**\n\t * The environment parser everything built from this worker runs with.\n\t *\n\t * Defaults to `process.env` merged with the credentials `gkm dev` injected,\n\t * which is what an application's own `config/env.ts` always was.\n\t */\n\tenvParser?: EnvironmentParser<{}>;\n\t/**\n\t * What the worker's process emits — the `Telemetry` construct, passed like\n\t * the logger. An edge to the node: the deploy hands the process the\n\t * `OTEL_*` keys its stage resolves.\n\t */\n\ttelemetry?: Telemetry;\n}\n\nexport class Worker<\n\tTName extends string = string,\n\tTDatabase = undefined,\n\tTDatabaseServiceName extends string = string,\n> implements Declarable<TName>\n{\n\treadonly id: TName;\n\n\t/** Always defined — the worker's own, or the console logger. */\n\treadonly logger: Logger;\n\n\t/** Always defined, and normally the default. */\n\treadonly envParser: EnvironmentParser<{}>;\n\n\t/**\n\t * Where this worker's schedules live, when it has crons and a server runs\n\t * them — and the database everything built from it receives as `db`.\n\t * `undefined` until `.database(…)` says.\n\t */\n\treadonly scheduleStore?: Consumable<string, unknown>;\n\n\tconstructor(\n\t\tid: ConstructName<TName>,\n\t\tprivate readonly config: WorkerConfig = {},\n\t\t/** Internal: how `.calls()` carries edges into the copy it returns. */\n\t\tprivate readonly dependencies: readonly Dependency[] = [],\n\t\tscheduleStore?: Consumable<string, unknown>,\n\t) {\n\t\tconst canonical = canonicalId(id as string);\n\n\t\tthis.id = canonical as TName;\n\t\tthis.logger = config.logger ?? DEFAULT_LOGGER;\n\t\tthis.envParser = envParserFor(\n\t\t\tconfig.envParser\n\t\t\t\t? { id: canonical, envParser: config.envParser }\n\t\t\t\t: undefined,\n\t\t);\n\t\tthis.scheduleStore = scheduleStore;\n\t}\n\n\t/**\n\t * The database this worker keeps its schedules in, and the one everything\n\t * built from it — crons, queues, subscribers, functions — receives as `db`.\n\t * One of them that works against another database names it with its own\n\t * `.database(other)`, which replaces this one for that runnable alone.\n\t *\n\t * Only a server target uses it. On AWS a cron is an EventBridge rule\n\t * invoking a Lambda and nothing here is consulted — but a server has nothing\n\t * firing its crons except itself, and a process that schedules in memory\n\t * fires every job once per replica while reporting that all is well.\n\t *\n\t * So the store is declared rather than discovered. It could have been\n\t * inferred — from a database the app happens to declare, or from the events\n\t * backend when that backend is pg-boss — and both would work until an app\n\t * had two databases, or moved its events to SNS, at which point the\n\t * schedules would quietly move or stop. Naming it is one line and cannot\n\t * drift.\n\t *\n\t * @example\n\t * ```ts\n\t * export const jobs = new Worker('Jobs', { logger }).database(database);\n\t *\n\t * export const sweep = jobs\n\t * .cron('rate(1 hour)')\n\t * .handle(async ({ db }) => db.deleteFrom('sessions').execute());\n\t * ```\n\t */\n\tdatabase<T, TDbName extends string>(\n\t\tsource: Consumable<TDbName, T>,\n\t): Worker<TName, T, TDbName> {\n\t\treturn new Worker<TName, T, TDbName>(\n\t\t\tthis.id as ConstructName<TName>,\n\t\t\tthis.config,\n\t\t\t[...this.dependencies, edgeTo(source)],\n\t\t\tsource as unknown as Consumable<string, unknown>,\n\t\t);\n\t}\n\n\t/**\n\t * A scheduled runnable.\n\t *\n\t * @example `worker.cron('rate(1 day)').handle(async ({ logger }) => { … })`\n\t */\n\tcron(schedule: ScheduleExpression) {\n\t\treturn this.own(\n\t\t\tnew CronBuilder<\n\t\t\t\tComposableStandardSchema,\n\t\t\t\tService[],\n\t\t\t\tLogger,\n\t\t\t\tundefined,\n\t\t\t\tTDatabase,\n\t\t\t\tTDatabaseServiceName\n\t\t\t>().logger(this.logger),\n\t\t).schedule(schedule);\n\t}\n\n\t/**\n\t * A queue and its one consumer, run by this worker.\n\t *\n\t * One construct, because a queue has exactly one consumer: nothing else can\n\t * attach a second, or forget to attach the first. Producers send to it by\n\t * depending on it — `.dependsOn([emails])` makes `services.emails` the\n\t * publisher.\n\t *\n\t * @example\n\t * ```ts\n\t * export const emails = worker\n\t * .queue('Emails')\n\t * .message(z.object({ userId: z.uuid(), template: z.string() }))\n\t * .handle(async ({ messages, services }) => { … });\n\t * ```\n\t */\n\tqueue<TQueueName extends string>(name: TQueueName) {\n\t\treturn this.own(\n\t\t\tnew QueueBuilder<\n\t\t\t\tstring,\n\t\t\t\tundefined,\n\t\t\t\t[],\n\t\t\t\tLogger,\n\t\t\t\tTDatabase,\n\t\t\t\tTDatabaseServiceName\n\t\t\t>().logger(this.logger),\n\t\t).queue(name);\n\t}\n\n\t/**\n\t * A runnable that reacts to a bucket's objects changing.\n\t *\n\t * A bucket is a topic whose publisher is the storage: the binding is the\n\t * same as {@link Worker.topic}'s, and so is everything after it — the\n\t * services, the database, how it is delivered. The handler is handed the\n\t * objects; reading one still means depending on the bucket,\n\t * `.dependsOn([uploads])`.\n\t *\n\t * @example\n\t * ```ts\n\t * export const onAvatar = worker\n\t * .objects(uploads)\n\t * .on(['object.created'], { prefix: 'avatars/', suffix: '.png' })\n\t * .handle(async ({ objects }) => { … });\n\t * ```\n\t */\n\tobjects<TBucketName extends string>(bucket: ObjectStorage<TBucketName>) {\n\t\tconst inner = this.own(\n\t\t\tnew SubscriberBuilder<\n\t\t\t\t[],\n\t\t\t\tLogger,\n\t\t\t\tundefined,\n\t\t\t\tundefined,\n\t\t\t\t[],\n\t\t\t\tTDatabase,\n\t\t\t\tTDatabaseServiceName\n\t\t\t>().logger(this.logger),\n\t\t);\n\t\treturn new ObjectSubscriberBuilder<\n\t\t\t[],\n\t\t\tLogger,\n\t\t\tundefined,\n\t\t\t[],\n\t\t\tTDatabase,\n\t\t\tTDatabaseServiceName\n\t\t>(inner, bucket.id);\n\t}\n\n\t/**\n\t * A runnable that consumes a topic.\n\t *\n\t * Binding is not publishing: a consumer is handed the topic's event types\n\t * and no connection string it could publish with. A handler that emits\n\t * follow-up events depends on the topic it emits to —\n\t * `.dependsOn([orders])` — which is the only reason it would need one.\n\t *\n\t * @example `worker.topic(users).subscribe(['user.created']).handle(…)`\n\t */\n\ttopic<TTopicName extends string, TEvents extends TopicEvents>(\n\t\ttopic: Topic<TTopicName, TEvents>,\n\t) {\n\t\treturn this.own(\n\t\t\tnew SubscriberBuilder<\n\t\t\t\t[],\n\t\t\t\tLogger,\n\t\t\t\tundefined,\n\t\t\t\tundefined,\n\t\t\t\t[],\n\t\t\t\tTDatabase,\n\t\t\t\tTDatabaseServiceName\n\t\t\t>().logger(this.logger),\n\t\t).topic(topic);\n\t}\n\n\t/**\n\t * Everything below builds a function — the runnable with no schedule and no\n\t * topic, invoked by something else.\n\t *\n\t * These sit on the worker rather than behind a `functions` namespace because\n\t * the namespace was a hop that said nothing: `worker.functions.input(…)`\n\t * names a collection to reach one member of it. What kind of runnable is\n\t * being built is decided by which of these is called first — a schedule\n\t * makes a cron, a topic makes a subscriber, and anything else makes a\n\t * function.\n\t */\n\tinput<T extends ComposableStandardSchema>(schema: T) {\n\t\treturn this.functions.input(schema);\n\t}\n\n\toutput<T extends StandardSchemaV1>(schema: T) {\n\t\treturn this.functions.output(schema);\n\t}\n\n\ttimeout(ms: number) {\n\t\treturn this.functions.timeout(ms);\n\t}\n\n\tmemorySize(mb: number) {\n\t\treturn this.functions.memorySize(mb);\n\t}\n\n\tdependsOn<const T extends readonly Consumable[]>(constructs: T) {\n\t\treturn this.functions.dependsOn(constructs);\n\t}\n\n\tservices<T extends Service[]>(services: T) {\n\t\treturn this.functions.services(services);\n\t}\n\n\t/**\n\t * A function with no input schema — the shortest runnable there is.\n\t *\n\t * @example `worker.handle(async ({ logger }) => { … })`\n\t */\n\thandle(\n\t\t...args: Parameters<\n\t\t\tReturnType<\n\t\t\t\tWorker<TName, TDatabase, TDatabaseServiceName>['schemalessFunction']\n\t\t\t>['handle']\n\t\t>\n\t) {\n\t\treturn this.schemalessFunction().handle(...args);\n\t}\n\n\t/** The builder `handle` delegates to, named so its type can be referred to. */\n\tprivate schemalessFunction() {\n\t\treturn this.functions;\n\t}\n\n\t/** The function builder the delegating methods above are built on. */\n\tprivate get functions() {\n\t\treturn this.own(\n\t\t\tnew FunctionBuilder<\n\t\t\t\tany,\n\t\t\t\tundefined,\n\t\t\t\t[],\n\t\t\t\tLogger,\n\t\t\t\tundefined,\n\t\t\t\tstring,\n\t\t\t\tTDatabase,\n\t\t\t\tTDatabaseServiceName\n\t\t\t>().logger(this.logger),\n\t\t);\n\t}\n\n\t/**\n\t * Resources and surfaces this worker calls.\n\t *\n\t * Not `.dependsOn()`: this records the edge that derives the worker's\n\t * environment and its access, the same distinction `RestApi.calls()` draws.\n\t */\n\tcalls(\n\t\tconstructs: readonly Declarable[],\n\t): Worker<TName, TDatabase, TDatabaseServiceName> {\n\t\treturn new Worker<TName, TDatabase, TDatabaseServiceName>(\n\t\t\tthis.id as ConstructName<TName>,\n\t\t\tthis.config,\n\t\t\t[...this.dependencies, ...constructs.map(edgeTo)],\n\t\t\tthis.scheduleStore,\n\t\t);\n\t}\n\n\t/**\n\t * Stamps this worker's id onto a builder, so everything built from it says\n\t * which process runs it — and hands it the worker's database.\n\t *\n\t * The database goes through the builder's own `.database()` rather than\n\t * being written into its fields, so the default is recorded exactly as an\n\t * explicit one would be: the client the handler gets, and the edge the\n\t * manifest reads. That is also what lets a later `.database(other)` replace\n\t * it outright. The builder arrives already typed with this worker's\n\t * database; this is the runtime half of that.\n\t */\n\tprivate own<\n\t\tT extends {\n\t\t\t_owner?: string;\n\t\t\t_scheduleStore?: unknown;\n\t\t\tdatabase(source: Consumable<string, unknown>): unknown;\n\t\t},\n\t>(builder: T): T {\n\t\tconst owned = this.scheduleStore\n\t\t\t? (builder.database(this.scheduleStore) as T)\n\t\t\t: builder;\n\n\t\towned._owner = this.id;\n\t\towned._scheduleStore = this.scheduleStore;\n\n\t\treturn owned;\n\t}\n\n\tdeclare(): Declaration[] {\n\t\treturn [\n\t\t\t{\n\t\t\t\tkind: 'worker',\n\t\t\t\tid: this.id,\n\t\t\t\t...(this.dependencies.length\n\t\t\t\t\t? { dependencies: this.dependencies }\n\t\t\t\t\t: {}),\n\t\t\t\t...(this.config.telemetry\n\t\t\t\t\t? { telemetry: this.config.telemetry.id }\n\t\t\t\t\t: {}),\n\t\t\t\t// Nothing calls a worker, so it publishes no address.\n\t\t\t\tprovides: [],\n\t\t\t},\n\t\t];\n\t}\n}\n"],"mappings":"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"}
package/dist/worker.d.cts CHANGED
@@ -6,8 +6,9 @@ import { t as FunctionBuilder } from "./index-Cd2w-DRL.cjs";
6
6
  import { i as ScheduleExpression } from "./Cron-bdGRSROY.cjs";
7
7
  import { n as Telemetry } from "./telemetry-B48RLjyk.cjs";
8
8
  import { t as CronBuilder } from "./CronBuilder-B_24LZpn.cjs";
9
+ import { ObjectStorage } from "./object-storage.cjs";
9
10
  import { t as QueueBuilder } from "./QueueBuilder-MfpkJ3R9.cjs";
10
- import { t as SubscriberBuilder } from "./SubscriberBuilder-BRa_nrmH.cjs";
11
+ import { n as ObjectSubscriberBuilder, o as SubscriberBuilder } from "./ObjectSubscriberBuilder-B4j3zgCZ.cjs";
11
12
  import "./topic/index.cjs";
12
13
  import { EnvironmentParser } from "@geekmidas/envkit";
13
14
  import { Logger } from "@geekmidas/logger";
@@ -108,6 +109,24 @@ export declare class Worker<TName extends string = string, TDatabase = undefined
108
109
  * ```
109
110
  */
110
111
  queue<TQueueName extends string>(name: TQueueName): QueueBuilder<TQueueName, undefined, [], Logger, TDatabase, TDatabaseServiceName>;
112
+ /**
113
+ * A runnable that reacts to a bucket's objects changing.
114
+ *
115
+ * A bucket is a topic whose publisher is the storage: the binding is the
116
+ * same as {@link Worker.topic}'s, and so is everything after it — the
117
+ * services, the database, how it is delivered. The handler is handed the
118
+ * objects; reading one still means depending on the bucket,
119
+ * `.dependsOn([uploads])`.
120
+ *
121
+ * @example
122
+ * ```ts
123
+ * export const onAvatar = worker
124
+ * .objects(uploads)
125
+ * .on(['object.created'], { prefix: 'avatars/', suffix: '.png' })
126
+ * .handle(async ({ objects }) => { … });
127
+ * ```
128
+ */
129
+ objects<TBucketName extends string>(bucket: ObjectStorage<TBucketName>): ObjectSubscriberBuilder<[], Logger, undefined, [], TDatabase, TDatabaseServiceName>;
111
130
  /**
112
131
  * A runnable that consumes a topic.
113
132
  *
@@ -1 +1 @@
1
- {"version":3,"file":"worker.d.cts","names":["_$__topic0.TopicMessage","_$__construct_interface0.ServicesOf","_$__functions0.FunctionHandler","_$__functions0.Function"],"sources":["../src/worker.ts"],"mappings":";;;;;;;;;;;;;;;;;iBAoGiB;;;;;;;;;EAShB,SAAS;;;;;;;EAOT,YAAY;;;;;;EAMZ,YAAY;;qBAGA,OACZ,+BACA,uBACA,yDACY,WAAW;mBAmBL;;mBAEA;WAnBT,IAAI;;WAGJ,QAAQ;;WAGR,WAAW;;;;;;WAOX,gBAAgB;EAEzB,YACC,IAAI,cAAc,QACD,SAAQ,cAER;;EAAA,wBAAuB,cACxC,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAyCjB,SAAS,GAAG,wBACX,QAAQ,WAAW,SAAS,KAC1B,OAAO,OAAO,GAAG;;;;;;EAcpB,KAAK,UAAU,qBAAkB,YAAA,0BAAA,WAAA,mBAAA,WAAA;;;;;;;;;;;;;;;;;EA6BjC,MAAM,2BAA2B,MAAM,aAAU,aAAA,2BAAA,QAAA,WAAA;;;;;;;;;;;EAuBjD,MAAM,2BAA2B,gBAAgB,aAChD,OAAO,MAAM,YAAY,WAAQ,sBAAA,mBAAA,eAAAA,aAAA,iBAAA,WAAA;;;;;;;;;;;;EA0BlC,MAAM,UAAU,0BAA0B,QAAQ,IAAC,gBAAA,kBAAA,2BAAA,WAAA,sBAAA;EAInD,OAAO,UAAU,kBAAkB,QAAQ,IAAC,qBAAA,OAAA,2BAAA,WAAA,sBAAA;EAI5C,QAAQ,aAAU,oCAAA,2BAAA,WAAA,sBAAA;EAIlB,WAAW,aAAU,oCAAA,2BAAA,WAAA,sBAAA;EAIrB,gBAAgB,mBAAmB,cAAc,YAAY,IAAC,oCAAAC,WAAA,KAAA,2BAAA,WAAA,sBAAA;EAI9D,SAAS,UAAU,WAAW,UAAU,IAAC,oCAAA,IAAA,2BAAA,WAAA,sBAAA;;;;;;EASzC,UACI,MAAM,WACR,WACC,OAAO,OAAO,WAAW,0DAE1BE,kBAAA,sCAAA,WAAA,sBAAA,kCAAAD,yBAAA,mBAAA,sBAAA;;UAMM;;cAKI;;;;;;;EAqBZ,MACC,qBAAqB,eACnB,OAAO,OAAO,WAAW;;;;;;;;;;;;UAoBpB;EAiBR,WAAW"}
1
+ {"version":3,"file":"worker.d.cts","names":["_$__topic0.TopicMessage","_$__construct_interface0.ServicesOf","_$__functions0.FunctionHandler","_$__functions0.Function"],"sources":["../src/worker.ts"],"mappings":";;;;;;;;;;;;;;;;;;iBAsGiB;;;;;;;;;EAShB,SAAS;;;;;;;EAOT,YAAY;;;;;;EAMZ,YAAY;;qBAGA,OACZ,+BACA,uBACA,yDACY,WAAW;mBAmBL;;mBAEA;WAnBT,IAAI;;WAGJ,QAAQ;;WAGR,WAAW;;;;;;WAOX,gBAAgB;EAEzB,YACC,IAAI,cAAc,QACD,SAAQ,cAER;;EAAA,wBAAuB,cACxC,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAyCjB,SAAS,GAAG,wBACX,QAAQ,WAAW,SAAS,KAC1B,OAAO,OAAO,GAAG;;;;;;EAcpB,KAAK,UAAU,qBAAkB,YAAA,0BAAA,WAAA,mBAAA,WAAA;;;;;;;;;;;;;;;;;EA6BjC,MAAM,2BAA2B,MAAM,aAAU,aAAA,2BAAA,QAAA,WAAA;;;;;;;;;;;;;;;;;;EA8BjD,QAAQ,4BAA4B,QAAQ,cAAc,eAAY,4BAAA,uBAAA,WAAA;;;;;;;;;;;EAgCtE,MAAM,2BAA2B,gBAAgB,aAChD,OAAO,MAAM,YAAY,WAAQ,sBAAA,mBAAA,eAAAA,aAAA,iBAAA,WAAA;;;;;;;;;;;;EA0BlC,MAAM,UAAU,0BAA0B,QAAQ,IAAC,gBAAA,kBAAA,2BAAA,WAAA,sBAAA;EAInD,OAAO,UAAU,kBAAkB,QAAQ,IAAC,qBAAA,OAAA,2BAAA,WAAA,sBAAA;EAI5C,QAAQ,aAAU,oCAAA,2BAAA,WAAA,sBAAA;EAIlB,WAAW,aAAU,oCAAA,2BAAA,WAAA,sBAAA;EAIrB,gBAAgB,mBAAmB,cAAc,YAAY,IAAC,oCAAAC,WAAA,KAAA,2BAAA,WAAA,sBAAA;EAI9D,SAAS,UAAU,WAAW,UAAU,IAAC,oCAAA,IAAA,2BAAA,WAAA,sBAAA;;;;;;EASzC,UACI,MAAM,WACR,WACC,OAAO,OAAO,WAAW,0DAE1BE,kBAAA,sCAAA,WAAA,sBAAA,kCAAAD,yBAAA,mBAAA,sBAAA;;UAMM;;cAKI;;;;;;;EAqBZ,MACC,qBAAqB,eACnB,OAAO,OAAO,WAAW;;;;;;;;;;;;UAoBpB;EAiBR,WAAW"}