@ericminassian/platform 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/dist/app.d.ts +10 -0
  2. package/dist/app.js +23 -0
  3. package/dist/app.js.map +1 -0
  4. package/dist/auth/cookies.d.ts +10 -0
  5. package/dist/auth/cookies.js +37 -0
  6. package/dist/auth/cookies.js.map +1 -0
  7. package/dist/auth/index.d.ts +22 -0
  8. package/dist/auth/index.js +217 -0
  9. package/dist/auth/index.js.map +1 -0
  10. package/dist/auth/pkce.d.ts +16 -0
  11. package/dist/auth/pkce.js +32 -0
  12. package/dist/auth/pkce.js.map +1 -0
  13. package/dist/auth/tokens.d.ts +17 -0
  14. package/dist/auth/tokens.js +142 -0
  15. package/dist/auth/tokens.js.map +1 -0
  16. package/dist/bindings.d.ts +13 -0
  17. package/dist/bindings.js +32 -0
  18. package/dist/bindings.js.map +1 -0
  19. package/dist/clients.d.ts +38 -0
  20. package/dist/clients.js +65 -0
  21. package/dist/clients.js.map +1 -0
  22. package/dist/context.d.ts +31 -0
  23. package/dist/context.js +33 -0
  24. package/dist/context.js.map +1 -0
  25. package/dist/db.d.ts +97 -0
  26. package/dist/db.js +297 -0
  27. package/dist/db.js.map +1 -0
  28. package/dist/events.d.ts +9 -0
  29. package/dist/events.js +28 -0
  30. package/dist/events.js.map +1 -0
  31. package/dist/http.d.ts +14 -0
  32. package/dist/http.js +82 -0
  33. package/dist/http.js.map +1 -0
  34. package/dist/index.d.ts +30 -0
  35. package/dist/index.js +11 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/queue.d.ts +7 -0
  38. package/dist/queue.js +50 -0
  39. package/dist/queue.js.map +1 -0
  40. package/dist/secrets.d.ts +8 -0
  41. package/dist/secrets.js +42 -0
  42. package/dist/secrets.js.map +1 -0
  43. package/dist/server.d.ts +12 -0
  44. package/dist/server.js +232 -0
  45. package/dist/server.js.map +1 -0
  46. package/dist/storage.d.ts +30 -0
  47. package/dist/storage.js +108 -0
  48. package/dist/storage.js.map +1 -0
  49. package/dist/telemetry.d.ts +42 -0
  50. package/dist/telemetry.js +218 -0
  51. package/dist/telemetry.js.map +1 -0
  52. package/dist/testing.d.ts +31 -0
  53. package/dist/testing.js +54 -0
  54. package/dist/testing.js.map +1 -0
  55. package/dist/worker.d.ts +73 -0
  56. package/dist/worker.js +179 -0
  57. package/dist/worker.js.map +1 -0
  58. package/package.json +58 -3
  59. package/src/app.ts +32 -0
  60. package/src/auth/cookies.ts +39 -0
  61. package/src/auth/index.ts +246 -0
  62. package/src/auth/pkce.ts +43 -0
  63. package/src/auth/tokens.ts +174 -0
  64. package/src/bindings.ts +35 -0
  65. package/src/clients.ts +98 -0
  66. package/src/context.ts +56 -0
  67. package/src/db.ts +380 -0
  68. package/src/events.ts +42 -0
  69. package/src/http.ts +82 -0
  70. package/src/index.ts +40 -0
  71. package/src/queue.ts +67 -0
  72. package/src/secrets.ts +57 -0
  73. package/src/server.ts +274 -0
  74. package/src/storage.ts +143 -0
  75. package/src/telemetry.ts +260 -0
  76. package/src/testing.ts +73 -0
  77. package/src/worker.ts +291 -0
  78. package/README.md +0 -3
package/src/testing.ts ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Test helpers for apps and the SDK itself.
3
+ *
4
+ * import { useTestBindings, captureTelemetry } from '@ericminassian/platform/testing';
5
+ *
6
+ * For tests against real (emulated) resources use `plat test`, which starts
7
+ * the AWS Emulator, provisions the app and sets PLATFORM_BINDINGS for vitest.
8
+ */
9
+ import type { Bindings, TelemetryEvent } from '@ericminassian/platform-core';
10
+ import { setBindings } from './bindings.ts';
11
+ import { resetClients, type SendClient, setClient } from './clients.ts';
12
+ import { clearSecretsCache } from './secrets.ts';
13
+ import { setTelemetrySink, uninstallInstrumentation } from './telemetry.ts';
14
+
15
+ export type { SendClient } from './clients.ts';
16
+ export { createPlatformFetch } from './server.ts';
17
+ export { createWorkerHandler } from './worker.ts';
18
+
19
+ /** Complete bindings with sensible test defaults. */
20
+ export function testBindings(overrides: Partial<Bindings> = {}): Bindings {
21
+ return {
22
+ v: 1,
23
+ app: 'test-app',
24
+ stage: 'test',
25
+ host: 'test-app.localhost',
26
+ url: 'http://test-app.localhost:3000',
27
+ revision: 'dtest',
28
+ region: 'us-east-1',
29
+ queues: {},
30
+ secrets: {
31
+ path: '/platform/apps/test-app/test/secrets/',
32
+ sharedPath: '/platform/apps/test-app/_shared/secrets/',
33
+ names: [],
34
+ },
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ /** Install bindings for the current test process (returns them). */
40
+ export function useTestBindings(overrides: Partial<Bindings> = {}): Bindings {
41
+ const b = testBindings(overrides);
42
+ setBindings(b);
43
+ return b;
44
+ }
45
+
46
+ /** Replace an AWS client with a fake `{ send(command) }`. */
47
+ export function mockClient(
48
+ name: 'ddb' | 'sqs' | 'events' | 's3' | 'ssm' | 'lambda' | 'cognito',
49
+ send: (command: { constructor: { name: string }; input: Record<string, unknown> }) => unknown,
50
+ ): SendClient {
51
+ const fake: SendClient = {
52
+ send: async (command) =>
53
+ send(command as { constructor: { name: string }; input: Record<string, unknown> }),
54
+ };
55
+ setClient(name, fake);
56
+ return fake;
57
+ }
58
+
59
+ /** Collect telemetry events instead of writing them to stdout. Call `stop()` to restore. */
60
+ export function captureTelemetry(): { events: TelemetryEvent[]; stop(): void } {
61
+ const events: TelemetryEvent[] = [];
62
+ setTelemetrySink((e) => events.push(e));
63
+ return { events, stop: () => setTelemetrySink(null) };
64
+ }
65
+
66
+ /** Reset all SDK module state (bindings, clients, caches, telemetry sink). */
67
+ export function resetPlatform(): void {
68
+ setBindings(undefined);
69
+ resetClients();
70
+ clearSecretsCache();
71
+ setTelemetrySink(null);
72
+ uninstallInstrumentation();
73
+ }
package/src/worker.ts ADDED
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Worker (Lambda Event Function) entry. Apps write:
3
+ *
4
+ * export default defineWorker({
5
+ * queues: { emails: async (msg) => { ... } },
6
+ * events: { 'order.created': async (evt) => { ... } },
7
+ * crons: { nightly: async () => { ... } },
8
+ * });
9
+ *
10
+ * `plat build` generates `export const handler = createWorkerHandler(def)`.
11
+ * One function per stage handles every queue, event subscription, cron and
12
+ * direct invocation; the handler dispatches on the event shape. Each job is
13
+ * auto-instrumented (`job`/`err` telemetry, trace propagation from the
14
+ * producer). SQS batches report partial failures.
15
+ *
16
+ * CONTRACT: used by the CLI build and the control plane. Keep stable.
17
+ */
18
+
19
+ import { gunzipSync } from 'node:zlib';
20
+ import { getBindings, tryBindings } from './bindings.ts';
21
+ import { client } from './clients.ts';
22
+ import { currentTrace, newContext, runWithContext } from './context.ts';
23
+ import type { EventEnvelope } from './events.ts';
24
+ import { emit, emitError, installConsoleCapture, installFetchInstrumentation, log } from './telemetry.ts';
25
+
26
+ export interface JobContext {
27
+ /** Trace id propagated from whoever enqueued/published/invoked this job. */
28
+ trace: string;
29
+ attempt: number;
30
+ /** Remaining Lambda time in ms. */
31
+ remainingMs(): number;
32
+ }
33
+
34
+ export interface QueueMessage<T = unknown> {
35
+ id: string;
36
+ body: T;
37
+ attempt: number;
38
+ sentAt: Date;
39
+ }
40
+
41
+ export interface PlatformEvent<T = unknown> {
42
+ id: string;
43
+ type: string;
44
+ detail: T;
45
+ time: Date;
46
+ }
47
+
48
+ /** Decoded CloudWatch Logs subscription payload (system use: telemetry processor). */
49
+ export interface LogsBatch {
50
+ logGroup: string;
51
+ logStream: string;
52
+ logEvents: Array<{ id: string; timestamp: number; message: string }>;
53
+ }
54
+
55
+ // biome-ignore lint/suspicious/noExplicitAny: handlers choose their own payload types
56
+ type AnyPayload = any;
57
+
58
+ export interface WorkerDefinition {
59
+ queues?: Record<string, (message: QueueMessage<AnyPayload>, ctx: JobContext) => unknown | Promise<unknown>>;
60
+ events?: Record<string, (event: PlatformEvent<AnyPayload>, ctx: JobContext) => unknown | Promise<unknown>>;
61
+ crons?: Record<string, (ctx: JobContext) => unknown | Promise<unknown>>;
62
+ /** Direct invocations by name, e.g. the control plane's `deploy`. Return value is the invoke result. */
63
+ invoke?: Record<string, (payload: AnyPayload, ctx: JobContext) => unknown | Promise<unknown>>;
64
+ /** CloudWatch Logs subscription deliveries (system use). */
65
+ logs?: (batch: LogsBatch, ctx: JobContext) => unknown | Promise<unknown>;
66
+ }
67
+
68
+ /** Payload shapes the platform sends to the worker besides SQS/EventBridge records. */
69
+ export type DirectPayload =
70
+ | { __platform: 'cron'; name: string; trace?: string }
71
+ | { __platform: 'invoke'; name: string; payload: unknown; trace?: string };
72
+
73
+ interface LambdaContextLike {
74
+ getRemainingTimeInMillis?: () => number;
75
+ }
76
+
77
+ interface SqsRecord {
78
+ messageId: string;
79
+ body: string;
80
+ eventSource: string;
81
+ eventSourceARN: string;
82
+ attributes?: { ApproximateReceiveCount?: string; SentTimestamp?: string };
83
+ messageAttributes?: Record<string, { stringValue?: string; dataType?: string }>;
84
+ }
85
+
86
+ interface EventBridgeEvent {
87
+ id: string;
88
+ 'detail-type': string;
89
+ source: string;
90
+ time: string;
91
+ detail: unknown;
92
+ }
93
+
94
+ export function defineWorker<T extends WorkerDefinition>(def: T): T {
95
+ return def;
96
+ }
97
+
98
+ type Kind = 'queue' | 'event' | 'cron' | 'invoke';
99
+
100
+ async function runJob<R>(
101
+ kind: Kind,
102
+ name: string,
103
+ trace: string | undefined,
104
+ attempt: number,
105
+ lambdaCtx: LambdaContextLike | undefined,
106
+ fn: (ctx: JobContext) => R | Promise<R>,
107
+ ): Promise<{ ok: true; value: R } | { ok: false; error: unknown }> {
108
+ const ctx = newContext(trace);
109
+ ctx.job = `${kind}:${name}`;
110
+ return runWithContext(ctx, async () => {
111
+ const t0 = performance.now();
112
+ const jobCtx: JobContext = {
113
+ trace: ctx.trace,
114
+ attempt,
115
+ remainingMs: () => lambdaCtx?.getRemainingTimeInMillis?.() ?? Number.POSITIVE_INFINITY,
116
+ };
117
+ try {
118
+ const value = await fn(jobCtx);
119
+ emit('job', { kind, name, status: 'ok', dur: ms(t0), attempt, span: ctx.span });
120
+ return { ok: true as const, value };
121
+ } catch (error) {
122
+ emitError(error, 'job', { job: `${kind}:${name}` });
123
+ emit('job', { kind, name, status: 'error', dur: ms(t0), attempt, span: ctx.span });
124
+ return { ok: false as const, error };
125
+ }
126
+ });
127
+ }
128
+
129
+ function ms(t0: number): number {
130
+ return Math.round((performance.now() - t0) * 10) / 10;
131
+ }
132
+
133
+ function parseBody(raw: string): unknown {
134
+ try {
135
+ return JSON.parse(raw);
136
+ } catch {
137
+ return raw;
138
+ }
139
+ }
140
+
141
+ function isSqsEvent(e: unknown): e is { Records: SqsRecord[] } {
142
+ const records = (e as { Records?: unknown })?.Records;
143
+ return Array.isArray(records) && records.length > 0 && (records[0] as SqsRecord)?.eventSource === 'aws:sqs';
144
+ }
145
+
146
+ function isEventBridge(e: unknown): e is EventBridgeEvent {
147
+ return (
148
+ typeof e === 'object' && e !== null && typeof (e as Record<string, unknown>)['detail-type'] === 'string'
149
+ );
150
+ }
151
+
152
+ function isDirect(e: unknown): e is DirectPayload {
153
+ const p = (e as { __platform?: unknown })?.__platform;
154
+ return p === 'cron' || p === 'invoke';
155
+ }
156
+
157
+ function isLogs(e: unknown): e is { awslogs: { data: string } } {
158
+ return typeof (e as { awslogs?: { data?: unknown } })?.awslogs?.data === 'string';
159
+ }
160
+
161
+ /** Lambda handler for a worker definition. */
162
+ export function createWorkerHandler(
163
+ def: WorkerDefinition,
164
+ ): (event: unknown, context: unknown) => Promise<unknown> {
165
+ installConsoleCapture();
166
+ installFetchInstrumentation();
167
+ emit('env', { event: 'start', runtime: 'worker', initMs: Math.round(performance.now()) });
168
+
169
+ return async (event: unknown, context: unknown) => {
170
+ const lambdaCtx = context as LambdaContextLike | undefined;
171
+
172
+ if (isSqsEvent(event)) return handleSqs(def, event.Records, lambdaCtx);
173
+
174
+ if (isLogs(event)) {
175
+ const decoded = JSON.parse(
176
+ gunzipSync(Buffer.from(event.awslogs.data, 'base64')).toString('utf8'),
177
+ ) as LogsBatch & {
178
+ messageType?: string;
179
+ };
180
+ if (decoded.messageType === 'CONTROL_MESSAGE') return;
181
+ if (!def.logs) {
182
+ log.warn('worker received a CloudWatch Logs batch but defines no `logs` handler');
183
+ return;
184
+ }
185
+ const ctx = newContext();
186
+ return runWithContext(ctx, () =>
187
+ def.logs!(
188
+ { logGroup: decoded.logGroup, logStream: decoded.logStream, logEvents: decoded.logEvents ?? [] },
189
+ {
190
+ trace: ctx.trace,
191
+ attempt: 1,
192
+ remainingMs: () => lambdaCtx?.getRemainingTimeInMillis?.() ?? Number.POSITIVE_INFINITY,
193
+ },
194
+ ),
195
+ );
196
+ }
197
+
198
+ if (isEventBridge(event)) {
199
+ const type = event['detail-type'];
200
+ const handler = def.events?.[type];
201
+ if (!handler) {
202
+ log.warn(`no handler for event "${type}"`, { source: event.source });
203
+ return;
204
+ }
205
+ const env = event.detail as Partial<EventEnvelope> | undefined;
206
+ const isEnvelope = env !== null && typeof env === 'object' && 'data' in env;
207
+ const r = await runJob('event', type, isEnvelope ? env.trace : undefined, 1, lambdaCtx, (ctx) =>
208
+ handler(
209
+ { id: event.id, type, detail: isEnvelope ? env.data : event.detail, time: new Date(event.time) },
210
+ ctx,
211
+ ),
212
+ );
213
+ // Rethrow so Lambda's async retries and the on-failure destination apply.
214
+ if (!r.ok) throw r.error;
215
+ return;
216
+ }
217
+
218
+ if (isDirect(event)) {
219
+ if (event.__platform === 'cron') {
220
+ const handler = def.crons?.[event.name];
221
+ if (!handler) throw new Error(`no cron handler "${event.name}"`);
222
+ const r = await runJob('cron', event.name, event.trace, 1, lambdaCtx, (ctx) => handler(ctx));
223
+ if (!r.ok) throw r.error;
224
+ return;
225
+ }
226
+ const handler = def.invoke?.[event.name];
227
+ if (!handler) throw new Error(`no invoke handler "${event.name}"`);
228
+ const r = await runJob('invoke', event.name, event.trace, 1, lambdaCtx, (ctx) =>
229
+ handler(event.payload, ctx),
230
+ );
231
+ if (!r.ok) throw r.error;
232
+ return r.value;
233
+ }
234
+
235
+ throw new Error('Unrecognized worker event');
236
+ };
237
+ }
238
+
239
+ async function handleSqs(
240
+ def: WorkerDefinition,
241
+ records: SqsRecord[],
242
+ lambdaCtx: LambdaContextLike | undefined,
243
+ ): Promise<{ batchItemFailures: Array<{ itemIdentifier: string }> }> {
244
+ const queues = tryBindings()?.queues ?? {};
245
+ const byArn = new Map(Object.entries(queues).map(([name, q]) => [q.arn, name]));
246
+ const failures: Array<{ itemIdentifier: string }> = [];
247
+ for (const record of records) {
248
+ const name = byArn.get(record.eventSourceARN);
249
+ const handler = name ? def.queues?.[name] : undefined;
250
+ if (!name || !handler) {
251
+ emitError(new Error(`no queue handler for ${record.eventSourceARN}`), 'job');
252
+ failures.push({ itemIdentifier: record.messageId });
253
+ continue;
254
+ }
255
+ const attempt = Number(record.attributes?.ApproximateReceiveCount ?? '1');
256
+ const trace = record.messageAttributes?.trace?.stringValue;
257
+ const sentAt = new Date(Number(record.attributes?.SentTimestamp ?? Date.now()));
258
+ const r = await runJob('queue', name, trace, attempt, lambdaCtx, (ctx) =>
259
+ handler({ id: record.messageId, body: parseBody(record.body), attempt, sentAt }, ctx),
260
+ );
261
+ if (!r.ok) failures.push({ itemIdentifier: record.messageId });
262
+ }
263
+ return { batchItemFailures: failures };
264
+ }
265
+
266
+ /** Invoke another named job on this stage's worker asynchronously. */
267
+ export async function invokeJob(name: string, payload: unknown): Promise<void> {
268
+ const worker = getBindings().worker;
269
+ if (!worker)
270
+ throw new Error('This app has no worker. Add a "worker" (or queues/events/crons) to platform.json.');
271
+ const body: DirectPayload = { __platform: 'invoke', name, payload, trace: currentTrace() };
272
+ // `plat dev` / `plat test` run the worker as a local Node process (the
273
+ // emulator's Lambda sandbox has no networking modules) and set this URL.
274
+ const local = process.env.PLATFORM_LOCAL_WORKER_URL;
275
+ if (local) {
276
+ // Fire-and-forget like an async Lambda invoke; failures surface in the worker's logs.
277
+ void fetch(local, { method: 'POST', body: JSON.stringify(body) })
278
+ .then((r) => r.body?.cancel())
279
+ .catch((err: unknown) => console.warn(`invokeJob(${name}) could not reach the local worker`, err));
280
+ return;
281
+ }
282
+ const { InvokeCommand } = await import('@aws-sdk/client-lambda');
283
+ const out = await (await client('lambda')).send(
284
+ new InvokeCommand({
285
+ FunctionName: worker,
286
+ InvocationType: 'Event',
287
+ Payload: new TextEncoder().encode(JSON.stringify(body)),
288
+ }),
289
+ );
290
+ if (out.FunctionError) throw new Error(`invokeJob(${name}) failed: ${out.FunctionError}`);
291
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.