@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/db.ts ADDED
@@ -0,0 +1,380 @@
1
+ import {
2
+ DeleteCommand,
3
+ type DynamoDBDocumentClient,
4
+ GetCommand,
5
+ PutCommand,
6
+ QueryCommand,
7
+ type QueryCommandInput,
8
+ UpdateCommand,
9
+ } from '@aws-sdk/lib-dynamodb';
10
+ import { getBindings } from './bindings.ts';
11
+ import { ddbClient } from './clients.ts';
12
+ import { span } from './telemetry.ts';
13
+
14
+ /**
15
+ * Document collections on the stage's single DynamoDB table (single-table
16
+ * design). Adding a collection needs no provisioning.
17
+ *
18
+ * Item layout:
19
+ * pk = "<collection>#<partition values joined by #>"
20
+ * sk = "<sort values joined by #>" (or "#" without a sort key)
21
+ * gsi{n}pk = "<collection>#<index>#<values>", gsi{n}sk = sort values or "#"
22
+ * _c = collection, _ttl = epoch seconds (DynamoDB TTL)
23
+ * Internal attributes are stripped from everything returned.
24
+ */
25
+
26
+ export type KeyValue = string | number;
27
+
28
+ type Fields<T> = (keyof T & string) | readonly (keyof T & string)[];
29
+
30
+ export interface CollectionOptions<T> {
31
+ /** Attribute(s) forming the partition key. */
32
+ partition: Fields<T>;
33
+ /** Attribute(s) forming the sort key (optional). */
34
+ sort?: Fields<T>;
35
+ /** Up to three secondary indexes, each pinned to a fixed slot so reordering is safe. */
36
+ indexes?: Record<string, { slot: 1 | 2 | 3; partition: Fields<T>; sort?: Fields<T> }>;
37
+ /** Attribute holding an epoch-seconds expiry (DynamoDB TTL). */
38
+ ttl?: keyof T & string;
39
+ }
40
+
41
+ export interface QueryOptions {
42
+ index?: string;
43
+ beginsWith?: string;
44
+ between?: [KeyValue, KeyValue];
45
+ gt?: KeyValue;
46
+ gte?: KeyValue;
47
+ lt?: KeyValue;
48
+ lte?: KeyValue;
49
+ limit?: number;
50
+ cursor?: string;
51
+ reverse?: boolean;
52
+ }
53
+
54
+ export interface Page<T> {
55
+ items: T[];
56
+ cursor?: string;
57
+ }
58
+
59
+ export interface Collection<T extends Record<string, unknown>> {
60
+ get(key: Partial<T>): Promise<T | undefined>;
61
+ put(item: T, opts?: { ifNotExists?: boolean }): Promise<T>;
62
+ update(key: Partial<T>, patch: Partial<T>): Promise<T>;
63
+ delete(key: Partial<T>): Promise<void>;
64
+ /** All items sharing the given partition-key values. */
65
+ query(partition: Partial<T>, opts?: QueryOptions): Promise<Page<T>>;
66
+ }
67
+
68
+ export interface Db {
69
+ collection<T extends Record<string, unknown>>(name: string, opts: CollectionOptions<T>): Collection<T>;
70
+ /** Escape hatch (AWS-specific): the DynamoDB document client and table name. */
71
+ raw(): { client: unknown; table: string };
72
+ }
73
+
74
+ export const INTERNAL_ATTRIBUTES = [
75
+ 'pk',
76
+ 'sk',
77
+ 'gsi1pk',
78
+ 'gsi1sk',
79
+ 'gsi2pk',
80
+ 'gsi2sk',
81
+ 'gsi3pk',
82
+ 'gsi3sk',
83
+ '_c',
84
+ '_ttl',
85
+ ];
86
+ const COLLECTION_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
87
+
88
+ function list<T>(f: Fields<T> | undefined): string[] {
89
+ if (!f) return [];
90
+ return typeof f === 'string' ? [f] : [...f];
91
+ }
92
+
93
+ function table(): string {
94
+ const t = getBindings().table;
95
+ if (!t) throw new Error('This app has no database. Add `"db": true` to platform.json.');
96
+ return t;
97
+ }
98
+
99
+ function ddb(): DynamoDBDocumentClient {
100
+ return ddbClient();
101
+ }
102
+
103
+ /** Values for `fields` from `item`, joined by `#`. Throws when any is missing. */
104
+ export function keyPart(item: Record<string, unknown>, fields: string[], what: string): string {
105
+ return fields
106
+ .map((f) => {
107
+ const v = item[f];
108
+ if (v === undefined || v === null || v === '') throw new Error(`${what}: missing key attribute "${f}"`);
109
+ if (typeof v !== 'string' && typeof v !== 'number')
110
+ throw new Error(`${what}: key attribute "${f}" must be a string or number`);
111
+ return String(v);
112
+ })
113
+ .join('#');
114
+ }
115
+
116
+ function hasAll(item: Record<string, unknown>, fields: string[]): boolean {
117
+ return fields.every((f) => item[f] !== undefined && item[f] !== null && item[f] !== '');
118
+ }
119
+
120
+ export function stripInternal<T>(item: Record<string, unknown> | undefined): T | undefined {
121
+ if (!item) return undefined;
122
+ const out: Record<string, unknown> = {};
123
+ for (const [k, v] of Object.entries(item)) if (!INTERNAL_ATTRIBUTES.includes(k)) out[k] = v;
124
+ return out as T;
125
+ }
126
+
127
+ export function encodeCursor(key: Record<string, unknown> | undefined): string | undefined {
128
+ return key ? Buffer.from(JSON.stringify(key)).toString('base64url') : undefined;
129
+ }
130
+
131
+ export function decodeCursor(cursor: string | undefined): Record<string, unknown> | undefined {
132
+ if (!cursor) return undefined;
133
+ try {
134
+ return JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
135
+ } catch {
136
+ throw new Error('Invalid cursor');
137
+ }
138
+ }
139
+
140
+ /** Pure key/attribute mapping, exported for tests and the console's data browser. */
141
+ export function createKeyMapper<T extends Record<string, unknown>>(name: string, opts: CollectionOptions<T>) {
142
+ if (!COLLECTION_NAME.test(name)) throw new Error(`Invalid collection name "${name}"`);
143
+ const partition = list(opts.partition);
144
+ const sort = list(opts.sort);
145
+ if (partition.length === 0) throw new Error(`Collection "${name}" needs a partition key`);
146
+ const indexes = Object.entries(opts.indexes ?? {}).map(([indexName, ix]) => ({
147
+ name: indexName,
148
+ slot: ix.slot,
149
+ partition: list(ix.partition),
150
+ sort: list(ix.sort),
151
+ }));
152
+ const slots = new Set<number>();
153
+ for (const ix of indexes) {
154
+ if (slots.has(ix.slot)) throw new Error(`Collection "${name}": two indexes use slot ${ix.slot}`);
155
+ slots.add(ix.slot);
156
+ }
157
+ const what = `collection "${name}"`;
158
+
159
+ const primaryKey = (item: Record<string, unknown>) => ({
160
+ pk: `${name}#${keyPart(item, partition, what)}`,
161
+ sk: sort.length ? keyPart(item, sort, what) : '#',
162
+ });
163
+
164
+ /** gsi/ttl attributes for `item`; `null` values mean "remove". */
165
+ const derived = (item: Record<string, unknown>): Record<string, string | number | null> => {
166
+ const out: Record<string, string | number | null> = {};
167
+ for (const ix of indexes) {
168
+ if (hasAll(item, ix.partition) && (ix.sort.length === 0 || hasAll(item, ix.sort))) {
169
+ out[`gsi${ix.slot}pk`] = `${name}#${ix.name}#${keyPart(item, ix.partition, what)}`;
170
+ out[`gsi${ix.slot}sk`] = ix.sort.length ? keyPart(item, ix.sort, what) : '#';
171
+ } else {
172
+ out[`gsi${ix.slot}pk`] = null;
173
+ out[`gsi${ix.slot}sk`] = null;
174
+ }
175
+ }
176
+ if (opts.ttl) {
177
+ const v = item[opts.ttl];
178
+ out._ttl = typeof v === 'number' ? Math.floor(v) : null;
179
+ }
180
+ return out;
181
+ };
182
+
183
+ const toItem = (item: T): Record<string, unknown> => {
184
+ const extra: Record<string, unknown> = {};
185
+ for (const [k, v] of Object.entries(derived(item))) if (v !== null) extra[k] = v;
186
+ return { ...item, ...primaryKey(item), _c: name, ...extra };
187
+ };
188
+
189
+ const queryInput = (values: Partial<T>, q: QueryOptions, tableName: string): QueryCommandInput => {
190
+ let pkAttr = 'pk';
191
+ let skAttr = 'sk';
192
+ let pkValue: string;
193
+ let indexName: string | undefined;
194
+ if (q.index) {
195
+ const ix = indexes.find((i) => i.name === q.index);
196
+ if (!ix) throw new Error(`${what} has no index "${q.index}"`);
197
+ pkAttr = `gsi${ix.slot}pk`;
198
+ skAttr = `gsi${ix.slot}sk`;
199
+ indexName = `gsi${ix.slot}`;
200
+ pkValue = `${name}#${ix.name}#${keyPart(values, ix.partition, what)}`;
201
+ } else {
202
+ pkValue = `${name}#${keyPart(values, partition, what)}`;
203
+ }
204
+ const names: Record<string, string> = { '#pk': pkAttr };
205
+ const vals: Record<string, unknown> = { ':pk': pkValue };
206
+ let cond = '#pk = :pk';
207
+ const sortCond = (expr: string) => {
208
+ names['#sk'] = skAttr;
209
+ cond += ` AND ${expr}`;
210
+ };
211
+ if (q.beginsWith !== undefined) {
212
+ vals[':b'] = String(q.beginsWith);
213
+ sortCond('begins_with(#sk, :b)');
214
+ } else if (q.between) {
215
+ vals[':lo'] = String(q.between[0]);
216
+ vals[':hi'] = String(q.between[1]);
217
+ sortCond('#sk BETWEEN :lo AND :hi');
218
+ } else {
219
+ const ops: Array<[keyof QueryOptions, string]> = [
220
+ ['gt', '>'],
221
+ ['gte', '>='],
222
+ ['lt', '<'],
223
+ ['lte', '<='],
224
+ ];
225
+ const set = ops.filter(([k]) => q[k] !== undefined);
226
+ if (set.length > 1) throw new Error('Use `between` for a range; only one of gt/gte/lt/lte is allowed');
227
+ const op = set[0];
228
+ if (op) {
229
+ vals[':v'] = String(q[op[0]]);
230
+ sortCond(`#sk ${op[1]} :v`);
231
+ }
232
+ }
233
+ const start = decodeCursor(q.cursor);
234
+ return {
235
+ TableName: tableName,
236
+ ...(indexName ? { IndexName: indexName } : {}),
237
+ KeyConditionExpression: cond,
238
+ ExpressionAttributeNames: names,
239
+ ExpressionAttributeValues: vals,
240
+ ScanIndexForward: !q.reverse,
241
+ ...(q.limit ? { Limit: q.limit } : {}),
242
+ ...(start ? { ExclusiveStartKey: start } : {}),
243
+ };
244
+ };
245
+
246
+ const keyFields = new Set([...partition, ...sort]);
247
+ const derivedSources = new Set([
248
+ ...indexes.flatMap((i) => [...i.partition, ...i.sort]),
249
+ ...(opts.ttl ? [opts.ttl] : []),
250
+ ]);
251
+
252
+ return {
253
+ name,
254
+ partition,
255
+ sort,
256
+ indexes,
257
+ primaryKey,
258
+ derived,
259
+ toItem,
260
+ queryInput,
261
+ keyFields,
262
+ derivedSources,
263
+ };
264
+ }
265
+
266
+ function collection<T extends Record<string, unknown>>(
267
+ name: string,
268
+ opts: CollectionOptions<T>,
269
+ ): Collection<T> {
270
+ const m = createKeyMapper(name, opts);
271
+ return {
272
+ get: (key) =>
273
+ span('db.get', name, async () => {
274
+ const out = await ddb().send(new GetCommand({ TableName: table(), Key: m.primaryKey(key) }));
275
+ return stripInternal<T>(out.Item);
276
+ }),
277
+
278
+ put: (item, putOpts) =>
279
+ span('db.put', name, async () => {
280
+ await ddb().send(
281
+ new PutCommand({
282
+ TableName: table(),
283
+ Item: m.toItem(item),
284
+ ...(putOpts?.ifNotExists ? { ConditionExpression: 'attribute_not_exists(pk)' } : {}),
285
+ }),
286
+ );
287
+ return item;
288
+ }),
289
+
290
+ update: (key, patch) =>
291
+ span('db.update', name, async () => {
292
+ for (const k of Object.keys(patch)) {
293
+ if (m.keyFields.has(k) && patch[k] !== key[k])
294
+ throw new Error(`collection "${name}": cannot change key attribute "${k}"`);
295
+ }
296
+ const tableName = table();
297
+ const Key = m.primaryKey(key);
298
+ const { expr, names, values } = setExpression(patch as Record<string, unknown>, m.keyFields);
299
+ const out = await ddb().send(
300
+ new UpdateCommand({
301
+ TableName: tableName,
302
+ Key,
303
+ UpdateExpression: expr,
304
+ ExpressionAttributeNames: { ...names, '#pk': 'pk' },
305
+ ...(Object.keys(values).length ? { ExpressionAttributeValues: values } : {}),
306
+ ConditionExpression: 'attribute_exists(#pk)',
307
+ ReturnValues: 'ALL_NEW',
308
+ }),
309
+ );
310
+ let item = out.Attributes ?? {};
311
+ if (Object.keys(patch).some((k) => m.derivedSources.has(k))) {
312
+ const d = setExpression(m.derived(item), new Set());
313
+ const again = await ddb().send(
314
+ new UpdateCommand({
315
+ TableName: tableName,
316
+ Key,
317
+ UpdateExpression: d.expr,
318
+ ExpressionAttributeNames: { ...d.names, '#pk': 'pk' },
319
+ ...(Object.keys(d.values).length ? { ExpressionAttributeValues: d.values } : {}),
320
+ ConditionExpression: 'attribute_exists(#pk)',
321
+ ReturnValues: 'ALL_NEW',
322
+ }),
323
+ );
324
+ item = again.Attributes ?? item;
325
+ }
326
+ return stripInternal<T>(item)!;
327
+ }),
328
+
329
+ delete: (key) =>
330
+ span('db.delete', name, async () => {
331
+ await ddb().send(new DeleteCommand({ TableName: table(), Key: m.primaryKey(key) }));
332
+ }),
333
+
334
+ query: (values, q = {}) =>
335
+ span('db.query', name, async () => {
336
+ const out = await ddb().send(new QueryCommand(m.queryInput(values, q, table())));
337
+ const page: Page<T> = { items: (out.Items ?? []).map((i) => stripInternal<T>(i)!) };
338
+ const cursor = encodeCursor(out.LastEvaluatedKey);
339
+ if (cursor) page.cursor = cursor;
340
+ return page;
341
+ }),
342
+ };
343
+ }
344
+
345
+ /** `SET #a = :a, ... REMOVE #b` from a patch; `undefined`/`null` values are removed. */
346
+ export function setExpression(
347
+ patch: Record<string, unknown>,
348
+ skip: Set<string>,
349
+ ): { expr: string; names: Record<string, string>; values: Record<string, unknown> } {
350
+ const sets: string[] = [];
351
+ const removes: string[] = [];
352
+ const names: Record<string, string> = {};
353
+ const values: Record<string, unknown> = {};
354
+ let i = 0;
355
+ for (const [k, v] of Object.entries(patch)) {
356
+ if (skip.has(k)) continue;
357
+ const n = `#f${i}`;
358
+ names[n] = k;
359
+ if (v === undefined || v === null) removes.push(n);
360
+ else {
361
+ values[`:v${i}`] = v;
362
+ sets.push(`${n} = :v${i}`);
363
+ }
364
+ i++;
365
+ }
366
+ const parts: string[] = [];
367
+ if (sets.length) parts.push(`SET ${sets.join(', ')}`);
368
+ if (removes.length) parts.push(`REMOVE ${removes.join(', ')}`);
369
+ if (!parts.length) {
370
+ // No-op update still verifies existence.
371
+ names['#c'] = '_c';
372
+ parts.push('SET #c = #c');
373
+ }
374
+ return { expr: parts.join(' '), names, values };
375
+ }
376
+
377
+ export const db: Db = {
378
+ collection,
379
+ raw: () => ({ client: ddb(), table: table() }),
380
+ };
package/src/events.ts ADDED
@@ -0,0 +1,42 @@
1
+ import { getBindings } from './bindings.ts';
2
+ import { client } from './clients.ts';
3
+ import { currentTrace } from './context.ts';
4
+ import { span } from './telemetry.ts';
5
+
6
+ export interface Events {
7
+ publish<T = unknown>(type: string, detail: T): Promise<void>;
8
+ }
9
+
10
+ /** Envelope inside EventBridge `detail`, so the trace survives the hop. */
11
+ export interface EventEnvelope<T = unknown> {
12
+ data: T;
13
+ trace: string;
14
+ }
15
+
16
+ export const events: Events = {
17
+ publish: (type, detail) =>
18
+ span('events.publish', type, async () => {
19
+ const b = getBindings();
20
+ if (!b.bus) throw new Error('This app has no event bus. Add an "events" entry to platform.json.');
21
+ const envelope: EventEnvelope = { data: detail, trace: currentTrace() };
22
+ const { PutEventsCommand } = await import('@aws-sdk/client-eventbridge');
23
+ const out = await (await client('events')).send(
24
+ new PutEventsCommand({
25
+ Entries: [
26
+ {
27
+ EventBusName: b.bus.name,
28
+ Source: `platform.${b.app}`,
29
+ DetailType: type,
30
+ Detail: JSON.stringify(envelope),
31
+ },
32
+ ],
33
+ }),
34
+ );
35
+ if (out.FailedEntryCount) {
36
+ const e = out.Entries?.find((x) => x.ErrorCode);
37
+ throw new Error(
38
+ `events.publish("${type}") failed: ${e?.ErrorCode ?? 'unknown'} ${e?.ErrorMessage ?? ''}`.trim(),
39
+ );
40
+ }
41
+ }),
42
+ };
package/src/http.ts ADDED
@@ -0,0 +1,82 @@
1
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
2
+ import { Readable } from 'node:stream';
3
+
4
+ /**
5
+ * Minimal Node `http` ⇄ web `fetch` adapter (no framework dependency).
6
+ * Streams request and response bodies, preserves multiple Set-Cookie headers.
7
+ */
8
+ export type FetchFn = (request: Request) => Promise<Response>;
9
+
10
+ export function toRequest(req: IncomingMessage, signal?: AbortSignal): Request {
11
+ const host = req.headers.host ?? 'localhost';
12
+ const url = new URL(req.url ?? '/', `http://${host}`);
13
+ const headers = new Headers();
14
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
15
+ const k = req.rawHeaders[i]!;
16
+ const v = req.rawHeaders[i + 1]!;
17
+ if (k.startsWith(':')) continue;
18
+ headers.append(k, v);
19
+ }
20
+ const method = req.method ?? 'GET';
21
+ const hasBody = method !== 'GET' && method !== 'HEAD';
22
+ return new Request(url, {
23
+ method,
24
+ headers,
25
+ ...(hasBody ? { body: Readable.toWeb(req) as ReadableStream, duplex: 'half' } : {}),
26
+ ...(signal ? { signal } : {}),
27
+ } as RequestInit);
28
+ }
29
+
30
+ export async function writeResponse(res: ServerResponse, response: Response): Promise<void> {
31
+ const headers: Record<string, string | string[]> = {};
32
+ response.headers.forEach((value, key) => {
33
+ if (key === 'set-cookie') return;
34
+ headers[key] = value;
35
+ });
36
+ const cookies = response.headers.getSetCookie();
37
+ if (cookies.length) headers['set-cookie'] = cookies;
38
+ res.writeHead(response.status, response.statusText || undefined, headers);
39
+ if (!response.body) {
40
+ res.end();
41
+ return;
42
+ }
43
+ const reader = response.body.getReader();
44
+ try {
45
+ for (;;) {
46
+ const { done, value } = await reader.read();
47
+ if (done) break;
48
+ if (!res.write(value)) await new Promise<void>((r) => res.once('drain', r));
49
+ }
50
+ } finally {
51
+ res.end();
52
+ }
53
+ }
54
+
55
+ export function createNodeServer(fetchFn: FetchFn): Server {
56
+ return createServer((req, res) => {
57
+ const controller = new AbortController();
58
+ res.once('close', () => {
59
+ if (!res.writableFinished) controller.abort();
60
+ });
61
+ fetchFn(toRequest(req, controller.signal))
62
+ .then((response) => writeResponse(res, response))
63
+ .catch((e) => {
64
+ if (!res.headersSent) {
65
+ res.writeHead(500, { 'content-type': 'application/json' });
66
+ res.end(
67
+ JSON.stringify({ error: { code: 'internal', message: String((e as Error)?.message ?? e) } }),
68
+ );
69
+ } else res.destroy(e as Error);
70
+ });
71
+ });
72
+ }
73
+
74
+ /** Parse `host:port` (IPv6 in brackets allowed). */
75
+ export function parseEndpoint(endpoint: string): { host: string; port: number } {
76
+ const i = endpoint.lastIndexOf(':');
77
+ if (i <= 0) throw new Error(`Invalid listen endpoint "${endpoint}" (expected host:port)`);
78
+ const host = endpoint.slice(0, i).replace(/^\[|\]$/g, '');
79
+ const port = Number(endpoint.slice(i + 1));
80
+ if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`Invalid port in "${endpoint}"`);
81
+ return { host, port };
82
+ }
package/src/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @ericminassian/platform — the only thing app code imports. Cloud-neutral
3
+ * interfaces backed by AWS (DynamoDB, SQS, EventBridge, S3, SSM, Cognito)
4
+ * through bindings the engine injects. Under `plat dev` the same code talks to
5
+ * the AWS Emulator, so there is exactly one implementation.
6
+ *
7
+ * CONTRACT: these exports are documented in templates/app/AGENTS.md and used
8
+ * by every app. Keep signatures stable.
9
+ */
10
+ export type { PlatformUser } from '@ericminassian/platform-core';
11
+ export { type AppInfo, app } from './app.ts';
12
+ export { type Auth, auth } from './auth/index.ts';
13
+ export { setRoute } from './context.ts';
14
+ export {
15
+ type Collection,
16
+ type CollectionOptions,
17
+ type Db,
18
+ db,
19
+ type KeyValue,
20
+ type Page,
21
+ type QueryOptions,
22
+ } from './db.ts';
23
+ export { type Events, events } from './events.ts';
24
+ export { type Queue, queue } from './queue.ts';
25
+ export { type Secrets, secrets } from './secrets.ts';
26
+ export { type Store, type StoredObject, storage } from './storage.ts';
27
+ export { log, metrics } from './telemetry.ts';
28
+ export { invokeJob } from './worker.ts';
29
+
30
+ export interface Metrics {
31
+ count(name: string, n?: number): void;
32
+ value(name: string, value: number, unit?: string): void;
33
+ }
34
+
35
+ export interface Logger {
36
+ debug(msg: string, fields?: Record<string, unknown>): void;
37
+ info(msg: string, fields?: Record<string, unknown>): void;
38
+ warn(msg: string, fields?: Record<string, unknown>): void;
39
+ error(msg: string, fields?: Record<string, unknown> | unknown): void;
40
+ }
package/src/queue.ts ADDED
@@ -0,0 +1,67 @@
1
+ import { getBindings } from './bindings.ts';
2
+ import { client } from './clients.ts';
3
+ import { currentTrace } from './context.ts';
4
+ import { span } from './telemetry.ts';
5
+
6
+ export interface Queue<T> {
7
+ send(body: T, opts?: { delaySeconds?: number }): Promise<void>;
8
+ sendBatch(bodies: T[]): Promise<void>;
9
+ }
10
+
11
+ function queueUrl(name: string): string {
12
+ const queues = getBindings().queues;
13
+ const q = queues[name];
14
+ if (!q) {
15
+ const declared = Object.keys(queues);
16
+ throw new Error(
17
+ `Unknown queue "${name}". Declared queues: ${declared.length ? declared.join(', ') : '(none)'} — add it under "queues" in platform.json.`,
18
+ );
19
+ }
20
+ return q.url;
21
+ }
22
+
23
+ function traceAttribute(trace: string) {
24
+ return { trace: { DataType: 'String', StringValue: trace } };
25
+ }
26
+
27
+ export function queue<T = unknown>(name: string): Queue<T> {
28
+ return {
29
+ send: (body, opts) =>
30
+ span('queue.send', name, async () => {
31
+ const { SendMessageCommand } = await import('@aws-sdk/client-sqs');
32
+ await (await client('sqs')).send(
33
+ new SendMessageCommand({
34
+ QueueUrl: queueUrl(name),
35
+ MessageBody: JSON.stringify(body),
36
+ MessageAttributes: traceAttribute(currentTrace()),
37
+ ...(opts?.delaySeconds !== undefined ? { DelaySeconds: opts.delaySeconds } : {}),
38
+ }),
39
+ );
40
+ }),
41
+ sendBatch: (bodies) =>
42
+ span('queue.send', name, async () => {
43
+ const url = queueUrl(name);
44
+ const trace = currentTrace();
45
+ const { SendMessageBatchCommand } = await import('@aws-sdk/client-sqs');
46
+ const sqs = await client('sqs');
47
+ for (let i = 0; i < bodies.length; i += 10) {
48
+ const chunk = bodies.slice(i, i + 10);
49
+ const out = await sqs.send(
50
+ new SendMessageBatchCommand({
51
+ QueueUrl: url,
52
+ Entries: chunk.map((b, j) => ({
53
+ Id: String(i + j),
54
+ MessageBody: JSON.stringify(b),
55
+ MessageAttributes: traceAttribute(trace),
56
+ })),
57
+ }),
58
+ );
59
+ if (out.Failed?.length) {
60
+ throw new Error(
61
+ `queue "${name}": ${out.Failed.length} message(s) failed: ${out.Failed[0]?.Message ?? out.Failed[0]?.Code}`,
62
+ );
63
+ }
64
+ }
65
+ }),
66
+ };
67
+ }
package/src/secrets.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { getBindings } from './bindings.ts';
2
+ import { client } from './clients.ts';
3
+ import { span } from './telemetry.ts';
4
+
5
+ export interface Secrets {
6
+ /** Throws if missing. Stage value wins over the app-wide shared value. Cached ~5 min. */
7
+ get(name: string): Promise<string>;
8
+ getOptional(name: string): Promise<string | undefined>;
9
+ }
10
+
11
+ const TTL_MS = 5 * 60 * 1000;
12
+ const cache = new Map<string, { value: string | undefined; at: number }>();
13
+
14
+ async function read(path: string): Promise<string | undefined> {
15
+ try {
16
+ const { GetParameterCommand } = await import('@aws-sdk/client-ssm');
17
+ const out = await (await client('ssm')).send(
18
+ new GetParameterCommand({ Name: path, WithDecryption: true }),
19
+ );
20
+ return out.Parameter?.Value;
21
+ } catch (e) {
22
+ if ((e as { name?: string })?.name === 'ParameterNotFound') return undefined;
23
+ throw e;
24
+ }
25
+ }
26
+
27
+ async function lookup(name: string): Promise<string | undefined> {
28
+ const hit = cache.get(name);
29
+ if (hit && Date.now() - hit.at < TTL_MS) return hit.value;
30
+ const { secrets } = getBindings();
31
+ const value = await span(
32
+ 'secrets.get',
33
+ name,
34
+ async () => (await read(`${secrets.path}${name}`)) ?? read(`${secrets.sharedPath}${name}`),
35
+ );
36
+ cache.set(name, { value, at: Date.now() });
37
+ return value;
38
+ }
39
+
40
+ export const secrets: Secrets = {
41
+ async get(name) {
42
+ const v = await lookup(name);
43
+ if (v === undefined) {
44
+ const b = getBindings();
45
+ throw new Error(
46
+ `Secret ${name} is not set. Run: plat secrets set ${b.app} ${name} --stage ${b.stage} (or without --stage for all stages)`,
47
+ );
48
+ }
49
+ return v;
50
+ },
51
+ getOptional: (name) => lookup(name),
52
+ };
53
+
54
+ /** Drop cached values (tests). */
55
+ export function clearSecretsCache(): void {
56
+ cache.clear();
57
+ }