@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.
- package/dist/app.d.ts +10 -0
- package/dist/app.js +23 -0
- package/dist/app.js.map +1 -0
- package/dist/auth/cookies.d.ts +10 -0
- package/dist/auth/cookies.js +37 -0
- package/dist/auth/cookies.js.map +1 -0
- package/dist/auth/index.d.ts +22 -0
- package/dist/auth/index.js +217 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/pkce.d.ts +16 -0
- package/dist/auth/pkce.js +32 -0
- package/dist/auth/pkce.js.map +1 -0
- package/dist/auth/tokens.d.ts +17 -0
- package/dist/auth/tokens.js +142 -0
- package/dist/auth/tokens.js.map +1 -0
- package/dist/bindings.d.ts +13 -0
- package/dist/bindings.js +32 -0
- package/dist/bindings.js.map +1 -0
- package/dist/clients.d.ts +38 -0
- package/dist/clients.js +65 -0
- package/dist/clients.js.map +1 -0
- package/dist/context.d.ts +31 -0
- package/dist/context.js +33 -0
- package/dist/context.js.map +1 -0
- package/dist/db.d.ts +97 -0
- package/dist/db.js +297 -0
- package/dist/db.js.map +1 -0
- package/dist/events.d.ts +9 -0
- package/dist/events.js +28 -0
- package/dist/events.js.map +1 -0
- package/dist/http.d.ts +14 -0
- package/dist/http.js +82 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/queue.d.ts +7 -0
- package/dist/queue.js +50 -0
- package/dist/queue.js.map +1 -0
- package/dist/secrets.d.ts +8 -0
- package/dist/secrets.js +42 -0
- package/dist/secrets.js.map +1 -0
- package/dist/server.d.ts +12 -0
- package/dist/server.js +232 -0
- package/dist/server.js.map +1 -0
- package/dist/storage.d.ts +30 -0
- package/dist/storage.js +108 -0
- package/dist/storage.js.map +1 -0
- package/dist/telemetry.d.ts +42 -0
- package/dist/telemetry.js +218 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/testing.d.ts +31 -0
- package/dist/testing.js +54 -0
- package/dist/testing.js.map +1 -0
- package/dist/worker.d.ts +73 -0
- package/dist/worker.js +179 -0
- package/dist/worker.js.map +1 -0
- package/package.json +58 -3
- package/src/app.ts +32 -0
- package/src/auth/cookies.ts +39 -0
- package/src/auth/index.ts +246 -0
- package/src/auth/pkce.ts +43 -0
- package/src/auth/tokens.ts +174 -0
- package/src/bindings.ts +35 -0
- package/src/clients.ts +98 -0
- package/src/context.ts +56 -0
- package/src/db.ts +380 -0
- package/src/events.ts +42 -0
- package/src/http.ts +82 -0
- package/src/index.ts +40 -0
- package/src/queue.ts +67 -0
- package/src/secrets.ts +57 -0
- package/src/server.ts +274 -0
- package/src/storage.ts +143 -0
- package/src/telemetry.ts +260 -0
- package/src/testing.ts +73 -0
- package/src/worker.ts +291 -0
- package/README.md +0 -3
package/src/server.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Function entry wrapper. `plat build` generates:
|
|
3
|
+
*
|
|
4
|
+
* import handler from '<app>/src/server.ts';
|
|
5
|
+
* import { startServer } from '@ericminassian/platform/server';
|
|
6
|
+
* startServer(handler);
|
|
7
|
+
*
|
|
8
|
+
* and bundles it to `index.mjs`. startServer listens on
|
|
9
|
+
* `AWS_LAMBDA_HTTP_ENDPOINT` (Web Function contract; `plat dev` sets it too),
|
|
10
|
+
* adds `/__platform/*` routes (health, auth), auto-instruments every request
|
|
11
|
+
* (telemetry `req`/`err`/`span` lines, traces) and maps `x-platform-host` to
|
|
12
|
+
* the public URL the app sees.
|
|
13
|
+
*
|
|
14
|
+
* CONTRACT: used by the CLI build. Keep stable.
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
fingerprint,
|
|
18
|
+
HEALTH_PATH,
|
|
19
|
+
HOST_HEADER,
|
|
20
|
+
PLATFORM_PATH_PREFIX,
|
|
21
|
+
TRACE_HEADER,
|
|
22
|
+
} from '@ericminassian/platform-core';
|
|
23
|
+
import { authGate, handleAuthRoute } from './auth/index.ts';
|
|
24
|
+
import { getBindings, tryBindings } from './bindings.ts';
|
|
25
|
+
import { currentContext, newContext, parseTraceparent, runWithContext } from './context.ts';
|
|
26
|
+
import { createNodeServer, parseEndpoint } from './http.ts';
|
|
27
|
+
import { emit, emitError, installConsoleCapture, installFetchInstrumentation, toError } from './telemetry.ts';
|
|
28
|
+
|
|
29
|
+
/** Anything with a web-standard fetch: a Hono app, `{ fetch }`, or a bare function. */
|
|
30
|
+
export type FetchHandlerLike =
|
|
31
|
+
| { fetch(request: Request, ...rest: unknown[]): Response | Promise<Response> }
|
|
32
|
+
| ((request: Request) => Response | Promise<Response>);
|
|
33
|
+
|
|
34
|
+
interface RouteLike {
|
|
35
|
+
path: string;
|
|
36
|
+
method: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let cold = true;
|
|
40
|
+
|
|
41
|
+
/** The wrapped fetch (platform routes + instrumentation), without listening. Useful in tests. */
|
|
42
|
+
export function createPlatformFetch(handler: FetchHandlerLike): (request: Request) => Promise<Response> {
|
|
43
|
+
const appFetch =
|
|
44
|
+
typeof handler === 'function'
|
|
45
|
+
? handler
|
|
46
|
+
: (req: Request) => (handler as { fetch: (r: Request) => Response | Promise<Response> }).fetch(req);
|
|
47
|
+
const matchRoute = routeMatcher(handler);
|
|
48
|
+
instrumentHonoErrors(handler);
|
|
49
|
+
|
|
50
|
+
return async (incoming: Request): Promise<Response> => {
|
|
51
|
+
const t0 = performance.now();
|
|
52
|
+
const trace = incoming.headers.get(TRACE_HEADER) ?? parseTraceparent(incoming.headers.get('traceparent'));
|
|
53
|
+
const ctx = newContext(trace);
|
|
54
|
+
const wasCold = cold;
|
|
55
|
+
cold = false;
|
|
56
|
+
|
|
57
|
+
return runWithContext(ctx, async () => {
|
|
58
|
+
const request = publicRequest(incoming);
|
|
59
|
+
const url = new URL(request.url);
|
|
60
|
+
let response: Response;
|
|
61
|
+
let errorEmitted = false;
|
|
62
|
+
try {
|
|
63
|
+
if (url.pathname.startsWith(PLATFORM_PATH_PREFIX)) {
|
|
64
|
+
response = await platformRoute(request);
|
|
65
|
+
} else {
|
|
66
|
+
response = (await authGate(request)) ?? (await appFetch(request));
|
|
67
|
+
}
|
|
68
|
+
} catch (e) {
|
|
69
|
+
if (e instanceof Response) response = e;
|
|
70
|
+
else {
|
|
71
|
+
emitError(e, 'http', { route: ctx.route ?? matchRoute(request.method, url.pathname) });
|
|
72
|
+
errorEmitted = true;
|
|
73
|
+
response = Response.json(
|
|
74
|
+
{ error: { code: 'internal', message: 'Internal Server Error', trace: ctx.trace } },
|
|
75
|
+
{ status: 500 },
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
response = finalize(response, ctx.trace, ctx.cookies);
|
|
81
|
+
const route = ctx.route ?? matchRoute(request.method, url.pathname);
|
|
82
|
+
if (response.status >= 500 && !errorEmitted) {
|
|
83
|
+
if (ctx.error !== undefined) emitError(ctx.error, 'http', { route });
|
|
84
|
+
else {
|
|
85
|
+
const e = new Error(`HTTP ${response.status} ${request.method} ${route}`);
|
|
86
|
+
e.name = 'HttpError';
|
|
87
|
+
emitError(e, 'http', { route });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
emit('req', {
|
|
91
|
+
method: request.method,
|
|
92
|
+
route,
|
|
93
|
+
path: url.pathname,
|
|
94
|
+
status: response.status,
|
|
95
|
+
dur: Math.round((performance.now() - t0) * 10) / 10,
|
|
96
|
+
...(lengthOf(response) !== undefined ? { bytes: lengthOf(response)! } : {}),
|
|
97
|
+
...(incoming.headers.get('cloudfront-viewer-country')
|
|
98
|
+
? { country: incoming.headers.get('cloudfront-viewer-country')! }
|
|
99
|
+
: {}),
|
|
100
|
+
...(ctx.user ? { user: fingerprint([ctx.user.id]) } : {}),
|
|
101
|
+
...(wasCold ? { cold: true } : {}),
|
|
102
|
+
span: ctx.span,
|
|
103
|
+
});
|
|
104
|
+
return response;
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Rewrite the request onto the app's public origin (scheme/port from bindings, host from the router). */
|
|
110
|
+
function publicRequest(incoming: Request): Request {
|
|
111
|
+
const b = tryBindings();
|
|
112
|
+
if (!b) return incoming;
|
|
113
|
+
const inUrl = new URL(incoming.url);
|
|
114
|
+
const origin = new URL(b.url);
|
|
115
|
+
const forwarded = incoming.headers.get(HOST_HEADER);
|
|
116
|
+
if (forwarded && /^[a-z0-9.-]+$/i.test(forwarded.replace(/:\d+$/, '')))
|
|
117
|
+
origin.hostname = forwarded.replace(/:\d+$/, '');
|
|
118
|
+
const target = `${origin.origin}${inUrl.pathname}${inUrl.search}`;
|
|
119
|
+
if (target === incoming.url) return incoming;
|
|
120
|
+
const hasBody = incoming.method !== 'GET' && incoming.method !== 'HEAD';
|
|
121
|
+
return new Request(target, {
|
|
122
|
+
method: incoming.method,
|
|
123
|
+
headers: incoming.headers,
|
|
124
|
+
signal: incoming.signal,
|
|
125
|
+
redirect: incoming.redirect,
|
|
126
|
+
...(hasBody ? { body: incoming.body, duplex: 'half' } : {}),
|
|
127
|
+
} as RequestInit);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function platformRoute(request: Request): Promise<Response> {
|
|
131
|
+
const url = new URL(request.url);
|
|
132
|
+
if (url.pathname === HEALTH_PATH) {
|
|
133
|
+
const b = getBindings();
|
|
134
|
+
return Response.json(
|
|
135
|
+
{ ok: true, app: b.app, stage: b.stage, revision: b.revision },
|
|
136
|
+
{ headers: { 'cache-control': 'no-store' } },
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const authResponse = await handleAuthRoute(request);
|
|
140
|
+
if (authResponse) return authResponse;
|
|
141
|
+
return Response.json({ error: { code: 'not_found', message: 'Not found' } }, { status: 404 });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function finalize(response: Response, trace: string, cookies: string[]): Response {
|
|
145
|
+
const headers = new Headers(response.headers);
|
|
146
|
+
headers.set(TRACE_HEADER, trace);
|
|
147
|
+
if (!headers.has('cache-control')) headers.set('cache-control', 'private, no-store');
|
|
148
|
+
for (const c of cookies) headers.append('set-cookie', c);
|
|
149
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function lengthOf(r: Response): number | undefined {
|
|
153
|
+
const v = r.headers.get('content-length');
|
|
154
|
+
return v ? Number(v) : undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------- framework integration
|
|
158
|
+
|
|
159
|
+
type ErrorHandler = (err: unknown, c: unknown) => Response | Promise<Response>;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Hono catches handler errors itself (default: plain-text 500). Hook its
|
|
163
|
+
* onError so errors reach telemetry with their real stack, and so apps using
|
|
164
|
+
* the default handler get the platform's JSON 500 with a trace id. A custom
|
|
165
|
+
* onError the app installed keeps control of the response.
|
|
166
|
+
*/
|
|
167
|
+
function instrumentHonoErrors(handler: FetchHandlerLike): void {
|
|
168
|
+
const h = handler as {
|
|
169
|
+
onError?: (fn: ErrorHandler) => unknown;
|
|
170
|
+
errorHandler?: ErrorHandler;
|
|
171
|
+
__platform?: boolean;
|
|
172
|
+
};
|
|
173
|
+
if (typeof h.onError !== 'function' || h.__platform) return;
|
|
174
|
+
const prev = h.errorHandler;
|
|
175
|
+
const prevIsDefault =
|
|
176
|
+
typeof prev !== 'function' ||
|
|
177
|
+
prev.name === 'errorHandler' ||
|
|
178
|
+
String(prev).includes('Internal Server Error');
|
|
179
|
+
h.onError((err, c) => {
|
|
180
|
+
if (err && typeof (err as { getResponse?: unknown }).getResponse === 'function') {
|
|
181
|
+
return prev ? prev(err, c) : (err as { getResponse(): Response }).getResponse();
|
|
182
|
+
}
|
|
183
|
+
const ctx = currentContext();
|
|
184
|
+
if (ctx) ctx.error = err;
|
|
185
|
+
if (prev && !prevIsDefault) return prev(err, c);
|
|
186
|
+
throw err;
|
|
187
|
+
});
|
|
188
|
+
h.__platform = true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------- route patterns
|
|
192
|
+
|
|
193
|
+
const ID_SEGMENT =
|
|
194
|
+
/^(\d+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9a-f]{12,}|[0-9a-hjkmnp-tv-z]{26}|(?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{16,})$/i;
|
|
195
|
+
|
|
196
|
+
/** `/api/todos/42` → `/api/todos/:id` (used when the framework's route pattern is unknown). */
|
|
197
|
+
export function normalizePath(path: string): string {
|
|
198
|
+
return path
|
|
199
|
+
.split('/')
|
|
200
|
+
.map((seg) => (seg && ID_SEGMENT.test(seg) ? ':id' : seg))
|
|
201
|
+
.join('/');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Hono apps expose `routes`; match against them to recover the route pattern. */
|
|
205
|
+
function routeMatcher(handler: FetchHandlerLike): (method: string, path: string) => string {
|
|
206
|
+
const routes = (handler as { routes?: RouteLike[] }).routes;
|
|
207
|
+
if (!Array.isArray(routes)) return (_m, path) => normalizePath(path);
|
|
208
|
+
let compiled: Array<{ method: string; path: string; re: RegExp }> | undefined;
|
|
209
|
+
return (method, path) => {
|
|
210
|
+
compiled ??= routes
|
|
211
|
+
.filter((r) => r.method !== 'ALL' && typeof r.path === 'string' && !/^\/?\*?$/.test(r.path))
|
|
212
|
+
.map((r) => ({ method: r.method, path: r.path, re: patternToRegex(r.path) }));
|
|
213
|
+
const hit = compiled.find(
|
|
214
|
+
(r) => (r.method === method || (method === 'HEAD' && r.method === 'GET')) && r.re.test(path),
|
|
215
|
+
);
|
|
216
|
+
return hit ? hit.path : normalizePath(path);
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function patternToRegex(pattern: string): RegExp {
|
|
221
|
+
const body = pattern
|
|
222
|
+
.split('/')
|
|
223
|
+
.map((seg) => {
|
|
224
|
+
if (seg === '*') return '.*';
|
|
225
|
+
if (seg.startsWith(':')) {
|
|
226
|
+
const custom = /\{(.+)\}/.exec(seg);
|
|
227
|
+
const optional = seg.endsWith('?');
|
|
228
|
+
const part = custom ? `(?:${custom[1]})` : '[^/]+';
|
|
229
|
+
return optional ? `?(?:${part})?` : part;
|
|
230
|
+
}
|
|
231
|
+
return seg.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\\\*/g, '.*');
|
|
232
|
+
})
|
|
233
|
+
.join('/');
|
|
234
|
+
return new RegExp(`^${body}/?$`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------- server
|
|
238
|
+
|
|
239
|
+
/** Start the HTTP server. Resolves once listening. */
|
|
240
|
+
export async function startServer(handler: FetchHandlerLike): Promise<{ close(): Promise<void> }> {
|
|
241
|
+
installConsoleCapture();
|
|
242
|
+
installFetchInstrumentation();
|
|
243
|
+
const endpoint =
|
|
244
|
+
process.env.AWS_LAMBDA_HTTP_ENDPOINT ??
|
|
245
|
+
(process.env.PORT ? `127.0.0.1:${process.env.PORT}` : '127.0.0.1:8080');
|
|
246
|
+
const { host, port } = parseEndpoint(endpoint);
|
|
247
|
+
const server = createNodeServer(createPlatformFetch(handler));
|
|
248
|
+
server.keepAliveTimeout = 65_000;
|
|
249
|
+
|
|
250
|
+
await new Promise<void>((resolve, reject) => {
|
|
251
|
+
server.once('error', reject);
|
|
252
|
+
server.listen(port, host, () => {
|
|
253
|
+
server.off('error', reject);
|
|
254
|
+
resolve();
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
emit('env', { event: 'start', runtime: 'web', initMs: Math.round(performance.now()) });
|
|
258
|
+
|
|
259
|
+
const close = () =>
|
|
260
|
+
new Promise<void>((resolve) => {
|
|
261
|
+
server.close(() => resolve());
|
|
262
|
+
server.closeIdleConnections?.();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
process.once('SIGTERM', () => {
|
|
266
|
+
emit('env', { event: 'stop', runtime: 'web' });
|
|
267
|
+
// Web Functions allow ~1 s for cleanup after SIGTERM.
|
|
268
|
+
const force = setTimeout(() => process.exit(0), 900);
|
|
269
|
+
force.unref();
|
|
270
|
+
close().then(() => process.exit(0));
|
|
271
|
+
});
|
|
272
|
+
process.on('unhandledRejection', (reason) => emitError(toError(reason), 'unhandled'));
|
|
273
|
+
return { close };
|
|
274
|
+
}
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { getBindings } from './bindings.ts';
|
|
2
|
+
import { client } from './clients.ts';
|
|
3
|
+
import { span } from './telemetry.ts';
|
|
4
|
+
|
|
5
|
+
export interface StoredObject {
|
|
6
|
+
key: string;
|
|
7
|
+
size: number;
|
|
8
|
+
lastModified?: Date;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface Store {
|
|
12
|
+
put(key: string, body: Uint8Array | string, opts?: { contentType?: string }): Promise<void>;
|
|
13
|
+
get(key: string): Promise<Uint8Array | undefined>;
|
|
14
|
+
getText(key: string): Promise<string | undefined>;
|
|
15
|
+
delete(key: string): Promise<void>;
|
|
16
|
+
list(
|
|
17
|
+
prefix?: string,
|
|
18
|
+
opts?: { cursor?: string; limit?: number },
|
|
19
|
+
): Promise<{ objects: StoredObject[]; cursor?: string }>;
|
|
20
|
+
/** Browser-direct upload URL (HTTP PUT). */
|
|
21
|
+
uploadUrl(key: string, opts?: { contentType?: string; expiresInSeconds?: number }): Promise<string>;
|
|
22
|
+
downloadUrl(key: string, opts?: { expiresInSeconds?: number; filename?: string }): Promise<string>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function location(name: string): { bucket: string; prefix: string } {
|
|
26
|
+
const s = getBindings().storage;
|
|
27
|
+
if (!s?.stores.includes(name)) {
|
|
28
|
+
const declared = s?.stores ?? [];
|
|
29
|
+
throw new Error(
|
|
30
|
+
`Unknown store "${name}". Declared storage: ${declared.length ? declared.join(', ') : '(none)'} — add it under "storage" in platform.json.`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return { bucket: s.bucket, prefix: `${s.prefix}${name}/` };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The S3 client and command module, loaded on first use. */
|
|
37
|
+
async function s3() {
|
|
38
|
+
const [c, m] = await Promise.all([client('s3'), import('@aws-sdk/client-s3')]);
|
|
39
|
+
return { c, m };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function sign(command: unknown, expiresIn: number): Promise<string> {
|
|
43
|
+
const [{ getSignedUrl }, c] = await Promise.all([import('@aws-sdk/s3-request-presigner'), client('s3')]);
|
|
44
|
+
// biome-ignore lint/suspicious/noExplicitAny: presigner and client generics differ across smithy versions
|
|
45
|
+
return getSignedUrl(c as any, command as any, { expiresIn });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function objectKey(prefix: string, key: string): string {
|
|
49
|
+
const k = key.replace(/^\/+/, '');
|
|
50
|
+
if (!k || k.split('/').some((p) => p === '..')) throw new Error(`Invalid storage key "${key}"`);
|
|
51
|
+
return prefix + k;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isNotFound(e: unknown): boolean {
|
|
55
|
+
const name = (e as { name?: string })?.name;
|
|
56
|
+
const status = (e as { $metadata?: { httpStatusCode?: number } })?.$metadata?.httpStatusCode;
|
|
57
|
+
return name === 'NoSuchKey' || name === 'NotFound' || status === 404;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function storage(name: string): Store {
|
|
61
|
+
const get = (key: string) =>
|
|
62
|
+
span('storage.get', name, async () => {
|
|
63
|
+
const { bucket, prefix } = location(name);
|
|
64
|
+
const { c, m } = await s3();
|
|
65
|
+
try {
|
|
66
|
+
const out = await c.send(new m.GetObjectCommand({ Bucket: bucket, Key: objectKey(prefix, key) }));
|
|
67
|
+
return out.Body ? await out.Body.transformToByteArray() : new Uint8Array();
|
|
68
|
+
} catch (e) {
|
|
69
|
+
if (isNotFound(e)) return undefined;
|
|
70
|
+
throw e;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
put: (key, body, opts) =>
|
|
76
|
+
span('storage.put', name, async () => {
|
|
77
|
+
const { bucket, prefix } = location(name);
|
|
78
|
+
const { c, m } = await s3();
|
|
79
|
+
await c.send(
|
|
80
|
+
new m.PutObjectCommand({
|
|
81
|
+
Bucket: bucket,
|
|
82
|
+
Key: objectKey(prefix, key),
|
|
83
|
+
Body: typeof body === 'string' ? Buffer.from(body) : body,
|
|
84
|
+
...(opts?.contentType ? { ContentType: opts.contentType } : {}),
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
}),
|
|
88
|
+
get,
|
|
89
|
+
getText: async (key) => {
|
|
90
|
+
const bytes = await get(key);
|
|
91
|
+
return bytes ? new TextDecoder().decode(bytes) : undefined;
|
|
92
|
+
},
|
|
93
|
+
delete: (key) =>
|
|
94
|
+
span('storage.delete', name, async () => {
|
|
95
|
+
const { bucket, prefix } = location(name);
|
|
96
|
+
const { c, m } = await s3();
|
|
97
|
+
await c.send(new m.DeleteObjectCommand({ Bucket: bucket, Key: objectKey(prefix, key) }));
|
|
98
|
+
}),
|
|
99
|
+
list: (sub, opts) =>
|
|
100
|
+
span('storage.list', name, async () => {
|
|
101
|
+
const { bucket, prefix } = location(name);
|
|
102
|
+
const { c, m } = await s3();
|
|
103
|
+
const out = await c.send(
|
|
104
|
+
new m.ListObjectsV2Command({
|
|
105
|
+
Bucket: bucket,
|
|
106
|
+
Prefix: prefix + (sub ?? '').replace(/^\/+/, ''),
|
|
107
|
+
...(opts?.cursor ? { ContinuationToken: opts.cursor } : {}),
|
|
108
|
+
...(opts?.limit ? { MaxKeys: opts.limit } : {}),
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
111
|
+
const objects: StoredObject[] = (out.Contents ?? []).map((o) => ({
|
|
112
|
+
key: (o.Key ?? '').slice(prefix.length),
|
|
113
|
+
size: o.Size ?? 0,
|
|
114
|
+
...(o.LastModified ? { lastModified: o.LastModified } : {}),
|
|
115
|
+
}));
|
|
116
|
+
return out.NextContinuationToken ? { objects, cursor: out.NextContinuationToken } : { objects };
|
|
117
|
+
}),
|
|
118
|
+
uploadUrl: (key, opts) =>
|
|
119
|
+
span('storage.sign', name, async () => {
|
|
120
|
+
const { bucket, prefix } = location(name);
|
|
121
|
+
const { m } = await s3();
|
|
122
|
+
const cmd = new m.PutObjectCommand({
|
|
123
|
+
Bucket: bucket,
|
|
124
|
+
Key: objectKey(prefix, key),
|
|
125
|
+
...(opts?.contentType ? { ContentType: opts.contentType } : {}),
|
|
126
|
+
});
|
|
127
|
+
return sign(cmd, opts?.expiresInSeconds ?? 900);
|
|
128
|
+
}),
|
|
129
|
+
downloadUrl: (key, opts) =>
|
|
130
|
+
span('storage.sign', name, async () => {
|
|
131
|
+
const { bucket, prefix } = location(name);
|
|
132
|
+
const { m } = await s3();
|
|
133
|
+
const cmd = new m.GetObjectCommand({
|
|
134
|
+
Bucket: bucket,
|
|
135
|
+
Key: objectKey(prefix, key),
|
|
136
|
+
...(opts?.filename
|
|
137
|
+
? { ResponseContentDisposition: `attachment; filename="${opts.filename.replace(/"/g, '')}"` }
|
|
138
|
+
: {}),
|
|
139
|
+
});
|
|
140
|
+
return sign(cmd, opts?.expiresInSeconds ?? 900);
|
|
141
|
+
}),
|
|
142
|
+
};
|
|
143
|
+
}
|
package/src/telemetry.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { format } from 'node:util';
|
|
2
|
+
import {
|
|
3
|
+
type ErrorEvent,
|
|
4
|
+
errorFingerprint,
|
|
5
|
+
newSpanId,
|
|
6
|
+
type TelemetryEvent,
|
|
7
|
+
type TelemetryKind,
|
|
8
|
+
} from '@ericminassian/platform-core';
|
|
9
|
+
import { tryBindings } from './bindings.ts';
|
|
10
|
+
import { currentContext } from './context.ts';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Telemetry writer. Every line is one core `TelemetryEvent` as JSON on stdout
|
|
14
|
+
* (Lambda ships stdout to CloudWatch Logs; the account-level subscription
|
|
15
|
+
* filter forwards `_p: 1` lines to the telemetry processor). Under `plat dev`
|
|
16
|
+
* (`PLATFORM_LOG_PRETTY=1`) lines are printed compactly for humans instead.
|
|
17
|
+
* Nothing here may ever throw into app code.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
type Sink = (event: TelemetryEvent) => void;
|
|
21
|
+
|
|
22
|
+
// Captured before console is patched so telemetry never recurses through it.
|
|
23
|
+
const rawWrite = process.stdout.write.bind(process.stdout);
|
|
24
|
+
let sink: Sink | null = null;
|
|
25
|
+
|
|
26
|
+
/** Replace the output (tests). `null` restores stdout. */
|
|
27
|
+
export function setTelemetrySink(s: Sink | null): void {
|
|
28
|
+
sink = s;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type Fields<K extends TelemetryKind> = Omit<
|
|
32
|
+
Extract<TelemetryEvent, { t: K }>,
|
|
33
|
+
'_p' | 'ts' | 'app' | 'stage' | 'rev' | 't'
|
|
34
|
+
> & {
|
|
35
|
+
ts?: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function emit<K extends TelemetryKind>(t: K, fields: Fields<K>): void {
|
|
39
|
+
try {
|
|
40
|
+
const b = tryBindings();
|
|
41
|
+
const ctx = currentContext();
|
|
42
|
+
const event = {
|
|
43
|
+
_p: 1,
|
|
44
|
+
t,
|
|
45
|
+
ts: Date.now(),
|
|
46
|
+
app: b?.app ?? process.env.PLATFORM_APP ?? 'unknown',
|
|
47
|
+
stage: b?.stage ?? process.env.PLATFORM_STAGE ?? 'unknown',
|
|
48
|
+
rev: b?.revision ?? 'unknown',
|
|
49
|
+
...(ctx ? { trace: ctx.trace } : {}),
|
|
50
|
+
...fields,
|
|
51
|
+
} as unknown as TelemetryEvent;
|
|
52
|
+
if (sink) sink(event);
|
|
53
|
+
else rawWrite(`${process.env.PLATFORM_LOG_PRETTY === '1' ? pretty(event) : JSON.stringify(event)}\n`);
|
|
54
|
+
} catch {
|
|
55
|
+
// never throw from telemetry
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Time `fn` as a span (`db.get`, `queue.send`, ...) under the current trace. */
|
|
60
|
+
export async function span<T>(op: string, name: string | undefined, fn: () => Promise<T>): Promise<T> {
|
|
61
|
+
const t0 = performance.now();
|
|
62
|
+
const ctx = currentContext();
|
|
63
|
+
const id = newSpanId();
|
|
64
|
+
let status: 'ok' | 'error' = 'ok';
|
|
65
|
+
try {
|
|
66
|
+
return await fn();
|
|
67
|
+
} catch (e) {
|
|
68
|
+
status = 'error';
|
|
69
|
+
throw e;
|
|
70
|
+
} finally {
|
|
71
|
+
// Spans only matter as part of a trace; skip them outside a request/job.
|
|
72
|
+
if (ctx)
|
|
73
|
+
emit('span', {
|
|
74
|
+
op,
|
|
75
|
+
...(name ? { name } : {}),
|
|
76
|
+
dur: round(performance.now() - t0),
|
|
77
|
+
status,
|
|
78
|
+
span: id,
|
|
79
|
+
parent: ctx.span,
|
|
80
|
+
start: round(t0 - ctx.startedAt),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Emit an `err` event for a thrown value. */
|
|
86
|
+
export function emitError(
|
|
87
|
+
err: unknown,
|
|
88
|
+
source: ErrorEvent['source'],
|
|
89
|
+
extra: { route?: string; job?: string } = {},
|
|
90
|
+
): void {
|
|
91
|
+
const e = toError(err);
|
|
92
|
+
emit('err', {
|
|
93
|
+
source,
|
|
94
|
+
name: e.name,
|
|
95
|
+
message: e.message,
|
|
96
|
+
...(e.stack ? { stack: e.stack } : {}),
|
|
97
|
+
...extra,
|
|
98
|
+
fingerprint: errorFingerprint(e.name, e.message, e.stack),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function toError(err: unknown): Error {
|
|
103
|
+
if (err instanceof Error) return err;
|
|
104
|
+
return new Error(typeof err === 'string' ? err : safeStringify(err));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------- metrics & log
|
|
108
|
+
|
|
109
|
+
export const metrics = {
|
|
110
|
+
count(name: string, n = 1): void {
|
|
111
|
+
emit('metric', { name, value: n, unit: 'count' });
|
|
112
|
+
},
|
|
113
|
+
value(name: string, value: number, unit?: string): void {
|
|
114
|
+
emit('metric', { name, value, ...(unit ? { unit } : {}) });
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
type Level = 'debug' | 'info' | 'warn' | 'error';
|
|
119
|
+
|
|
120
|
+
function logAt(level: Level, msg: string, fields?: unknown): void {
|
|
121
|
+
if (
|
|
122
|
+
level === 'error' &&
|
|
123
|
+
(fields instanceof Error || (fields && typeof fields === 'object' && 'stack' in fields))
|
|
124
|
+
) {
|
|
125
|
+
const e = toError(fields);
|
|
126
|
+
emit('log', { level, msg, error: { name: e.name, message: e.message, stack: e.stack } });
|
|
127
|
+
emitError(e, 'log', jobOrRoute());
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const extra =
|
|
131
|
+
fields && typeof fields === 'object'
|
|
132
|
+
? (fields as Record<string, unknown>)
|
|
133
|
+
: fields === undefined
|
|
134
|
+
? {}
|
|
135
|
+
: { value: fields };
|
|
136
|
+
emit('log', { ...extra, level, msg });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export const log = {
|
|
140
|
+
debug: (msg: string, fields?: Record<string, unknown>) => logAt('debug', msg, fields),
|
|
141
|
+
info: (msg: string, fields?: Record<string, unknown>) => logAt('info', msg, fields),
|
|
142
|
+
warn: (msg: string, fields?: Record<string, unknown>) => logAt('warn', msg, fields),
|
|
143
|
+
error: (msg: string, fields?: Record<string, unknown> | unknown) => logAt('error', msg, fields),
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
function jobOrRoute(): { route?: string; job?: string } {
|
|
147
|
+
const ctx = currentContext();
|
|
148
|
+
if (!ctx) return {};
|
|
149
|
+
return { ...(ctx.route ? { route: ctx.route } : {}), ...(ctx.job ? { job: ctx.job } : {}) };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------- console capture
|
|
153
|
+
|
|
154
|
+
let consolePatched = false;
|
|
155
|
+
const originalConsole: Partial<Record<keyof Console, unknown>> = {};
|
|
156
|
+
|
|
157
|
+
/** Route console.* through telemetry so app logs carry the trace id. */
|
|
158
|
+
export function installConsoleCapture(): void {
|
|
159
|
+
if (consolePatched) return;
|
|
160
|
+
consolePatched = true;
|
|
161
|
+
const map: Array<[keyof Console, Level]> = [
|
|
162
|
+
['log', 'info'],
|
|
163
|
+
['info', 'info'],
|
|
164
|
+
['debug', 'debug'],
|
|
165
|
+
['warn', 'warn'],
|
|
166
|
+
['error', 'error'],
|
|
167
|
+
];
|
|
168
|
+
for (const [method, level] of map) {
|
|
169
|
+
originalConsole[method] = console[method];
|
|
170
|
+
(console as unknown as Record<string, (...a: unknown[]) => void>)[method] = (...args: unknown[]) => {
|
|
171
|
+
emit('log', { level, msg: format(...args) });
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------- fetch spans
|
|
177
|
+
|
|
178
|
+
let fetchPatched = false;
|
|
179
|
+
let originalFetch: typeof fetch | undefined;
|
|
180
|
+
|
|
181
|
+
/** Record outgoing `fetch` calls as spans (method + host only; no URLs with secrets). */
|
|
182
|
+
export function installFetchInstrumentation(): void {
|
|
183
|
+
if (fetchPatched || typeof globalThis.fetch !== 'function') return;
|
|
184
|
+
fetchPatched = true;
|
|
185
|
+
originalFetch = globalThis.fetch;
|
|
186
|
+
const original = globalThis.fetch.bind(globalThis);
|
|
187
|
+
globalThis.fetch = (async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
|
|
188
|
+
if (!currentContext()) return original(input, init);
|
|
189
|
+
let name = 'fetch';
|
|
190
|
+
try {
|
|
191
|
+
const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url);
|
|
192
|
+
const method = init?.method ?? (input instanceof Request ? input.method : 'GET');
|
|
193
|
+
name = `${method.toUpperCase()} ${url.host}`;
|
|
194
|
+
} catch {}
|
|
195
|
+
return span('fetch', name, () => original(input, init));
|
|
196
|
+
}) as typeof fetch;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Undo console/fetch patching (tests). */
|
|
200
|
+
export function uninstallInstrumentation(): void {
|
|
201
|
+
if (consolePatched) {
|
|
202
|
+
for (const [k, v] of Object.entries(originalConsole))
|
|
203
|
+
(console as unknown as Record<string, unknown>)[k] = v;
|
|
204
|
+
consolePatched = false;
|
|
205
|
+
}
|
|
206
|
+
if (fetchPatched && originalFetch) {
|
|
207
|
+
globalThis.fetch = originalFetch;
|
|
208
|
+
fetchPatched = false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------- pretty printing
|
|
213
|
+
|
|
214
|
+
const COLORS = {
|
|
215
|
+
dim: '\x1b[2m',
|
|
216
|
+
red: '\x1b[31m',
|
|
217
|
+
yellow: '\x1b[33m',
|
|
218
|
+
green: '\x1b[32m',
|
|
219
|
+
cyan: '\x1b[36m',
|
|
220
|
+
reset: '\x1b[0m',
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
function pretty(e: TelemetryEvent): string {
|
|
224
|
+
const time = new Date(e.ts).toISOString().slice(11, 23);
|
|
225
|
+
const c = (color: keyof typeof COLORS, s: string) => `${COLORS[color]}${s}${COLORS.reset}`;
|
|
226
|
+
switch (e.t) {
|
|
227
|
+
case 'req': {
|
|
228
|
+
const color = e.status >= 500 ? 'red' : e.status >= 400 ? 'yellow' : 'green';
|
|
229
|
+
return `${c('dim', time)} ${c(color, String(e.status))} ${e.method} ${e.path} ${c('dim', `${e.dur}ms${e.cold ? ' cold' : ''}`)}`;
|
|
230
|
+
}
|
|
231
|
+
case 'err':
|
|
232
|
+
return `${c('dim', time)} ${c('red', `✖ ${e.name}: ${e.message}`)}${e.stack ? `\n${c('dim', e.stack.split('\n').slice(1, 6).join('\n'))}` : ''}`;
|
|
233
|
+
case 'job':
|
|
234
|
+
return `${c('dim', time)} ${c(e.status === 'ok' ? 'cyan' : 'red', `${e.kind}:${e.name}`)} ${e.status} ${c('dim', `${e.dur}ms`)}`;
|
|
235
|
+
case 'span':
|
|
236
|
+
return `${c('dim', `${time} ↳ ${e.op}${e.name ? ` ${e.name}` : ''} ${e.dur}ms${e.status === 'error' ? ' error' : ''}`)}`;
|
|
237
|
+
case 'log': {
|
|
238
|
+
const { _p, t, ts, app, stage, rev, trace, span: _s, parent, level, msg, ...rest } = e;
|
|
239
|
+
const extra = Object.keys(rest).length ? ` ${c('dim', safeStringify(rest))}` : '';
|
|
240
|
+
const color = level === 'error' ? 'red' : level === 'warn' ? 'yellow' : 'reset';
|
|
241
|
+
return `${c('dim', time)} ${c(color, msg)}${extra}`;
|
|
242
|
+
}
|
|
243
|
+
case 'metric':
|
|
244
|
+
return `${c('dim', `${time} metric ${e.name}=${e.value}${e.unit ? ` ${e.unit}` : ''}`)}`;
|
|
245
|
+
case 'env':
|
|
246
|
+
return `${c('dim', `${time} ${e.runtime} ${e.event}${e.initMs !== undefined ? ` (${e.initMs}ms)` : ''}`)}`;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function round(ms: number): number {
|
|
251
|
+
return Math.round(ms * 10) / 10;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function safeStringify(v: unknown): string {
|
|
255
|
+
try {
|
|
256
|
+
return JSON.stringify(v);
|
|
257
|
+
} catch {
|
|
258
|
+
return String(v);
|
|
259
|
+
}
|
|
260
|
+
}
|