@zauru-sdk/utils 6.14.0 → 6.16.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/esm/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./zauru/api.loaders.js";
2
+ export * from "./zauru/auth.loaders.js";
2
3
  export * from "./zauru/automatic-number.utils.js";
3
4
  export * from "./zauru/bitacora-edicion-masiva.utils.js";
4
5
  export * from "./zauru/bundles.utils.js";
@@ -0,0 +1,141 @@
1
+ import { redirect } from "@remix-run/node";
2
+ import { config } from "@zauru-sdk/config";
3
+ import { commitSession, destroySession, getRefreshSession, getSession, loginWebApp, } from "@zauru-sdk/services";
4
+ const loginErrorPayload = (description, title) => ({
5
+ error: true,
6
+ type: "error",
7
+ title: title ??
8
+ "Ocurrió un error al cargar la información de inicio de sesión (Carga de webapp)",
9
+ description,
10
+ });
11
+ const isDenied = (result) => result === false;
12
+ const authorizeUrl = (request) => {
13
+ const url = new URL(request.url);
14
+ const hostname = `${url.port ? "http://" : "https://"}${url.hostname}${url.port ? `:${url.port}` : ""}`;
15
+ return `${config.oauthBaseURL}/dialog/authorize?client_id=${config.oauthClientID}&response_type=code&redirect_uri=${hostname}/login`;
16
+ };
17
+ export function createLoginLoader() {
18
+ return async () => Response.json({});
19
+ }
20
+ /**
21
+ * Remix action for `/login`: OAuth authorize, `loginWebApp`, cookie commit.
22
+ * `afterLogin` runs before `commitSession`. `requireAccess` can deny after
23
+ * a successful OAuth exchange or when a session already has `code`.
24
+ */
25
+ export function createLoginAction(extras) {
26
+ return async ({ request }) => {
27
+ const formData = await request.formData();
28
+ const { action, ...values } = Object.fromEntries(formData);
29
+ if (action === "redirect") {
30
+ const cookie = values?.cookie?.toString() ?? "";
31
+ return redirect("/home", {
32
+ headers: {
33
+ "Set-Cookie": cookie,
34
+ },
35
+ });
36
+ }
37
+ const codeValue = new URL(request.url).searchParams.get("code") ?? "";
38
+ const cookie = request.headers.get("Cookie") ?? "";
39
+ const session = await getSession(cookie);
40
+ if (!session.has("code") && !codeValue) {
41
+ return redirect(authorizeUrl(request));
42
+ }
43
+ if (session.has("code")) {
44
+ if (isDenied(extras?.requireAccess?.(session))) {
45
+ return redirect("/");
46
+ }
47
+ return redirect("/home");
48
+ }
49
+ const loginResponse = await loginWebApp(session, codeValue, cookie);
50
+ if (loginResponse.error || !loginResponse.data) {
51
+ return Response.json(loginErrorPayload(loginResponse.userMsg?.toString() ?? ""));
52
+ }
53
+ if (isDenied(extras?.requireAccess?.(session))) {
54
+ return Response.json(loginErrorPayload("No tienes permiso para acceder a esta aplicación. Si crees que esto es un error, contacta al administrador.", "Acceso denegado"));
55
+ }
56
+ if (extras?.afterLogin) {
57
+ await extras.afterLogin({
58
+ request,
59
+ session,
60
+ headers: loginResponse.data.headers,
61
+ loginData: loginResponse.data,
62
+ });
63
+ }
64
+ const expires = new Date();
65
+ expires.setSeconds(expires.getSeconds() + Number(config.expirationDurationInSeconds));
66
+ return Response.json({
67
+ ...loginResponse.data,
68
+ cookie: await commitSession(session, { expires }),
69
+ });
70
+ };
71
+ }
72
+ /**
73
+ * Guard for `/home` and `/reload-catalogs`: no `username` → `/`.
74
+ */
75
+ export function createSessionGuardLoader(extras) {
76
+ return async ({ request }) => {
77
+ const session = await getSession(request.headers.get("Cookie"));
78
+ if (!session.has("username")) {
79
+ return redirect("/");
80
+ }
81
+ if (isDenied(extras?.requireAccess?.(session))) {
82
+ return redirect("/");
83
+ }
84
+ return Response.json({});
85
+ };
86
+ }
87
+ export function createLogoutLoader(extras) {
88
+ return createSessionGuardLoader(extras);
89
+ }
90
+ export function createLogoutAction() {
91
+ return async ({ request }) => {
92
+ const session = await getSession(request.headers.get("Cookie"));
93
+ return redirect(`${config.oauthBaseURL}/logout`, {
94
+ headers: {
95
+ "Set-Cookie": await destroySession(session),
96
+ },
97
+ });
98
+ };
99
+ }
100
+ /**
101
+ * `/` welcome: existing session (and optional access) → `/home`.
102
+ */
103
+ export function createIndexLoader(extras) {
104
+ return async ({ request }) => {
105
+ const session = await getSession(request.headers.get("Cookie"));
106
+ if (session.has("username")) {
107
+ if (isDenied(extras?.requireAccess?.(session))) {
108
+ return Response.json({});
109
+ }
110
+ return redirect("/home");
111
+ }
112
+ return Response.json({});
113
+ };
114
+ }
115
+ /**
116
+ * `/reload-catalogs`: session guard plus optional cookie refresh.
117
+ */
118
+ export function createReloadCatalogsLoader(extras) {
119
+ return async ({ request }) => {
120
+ const session = await getSession(request.headers.get("Cookie"));
121
+ if (!session.has("username")) {
122
+ return redirect("/");
123
+ }
124
+ if (isDenied(extras?.requireAccess?.(session))) {
125
+ return redirect("/");
126
+ }
127
+ try {
128
+ const refreshSession = await getRefreshSession(request);
129
+ return Response.json({}, refreshSession
130
+ ? {
131
+ headers: {
132
+ "Set-Cookie": refreshSession,
133
+ },
134
+ }
135
+ : {});
136
+ }
137
+ catch {
138
+ return Response.json({});
139
+ }
140
+ };
141
+ }
@@ -0,0 +1,426 @@
1
+ import { CloudWatchLogsClient, CreateLogGroupCommand, CreateLogStreamCommand, DescribeLogStreamsCommand, PutLogEventsCommand, ResourceAlreadyExistsException, InvalidSequenceTokenException, DataAlreadyAcceptedException, } from "@aws-sdk/client-cloudwatch-logs";
2
+ /**
3
+ * Server logger for AWS CloudWatch Logs, with `console.*` as a fallback so
4
+ * events still appear in the host (Netlify) logs.
5
+ *
6
+ * Enable by setting `CLOUDWATCH_LOG_GROUP` plus any complete AWS credential
7
+ * prefix (first match wins):
8
+ * - `CLOUDWATCH_AWS_REGION` / `CLOUDWATCH_AWS_ACCESS_KEY_ID` / `CLOUDWATCH_AWS_SECRET_ACCESS_KEY`
9
+ * - `APPOCUS_AWS_*`
10
+ * - `C4PINOS_AWS_*`
11
+ * - `AWS_*`
12
+ *
13
+ * Missing env → no-op for CloudWatch (still logs to console). Never throws.
14
+ *
15
+ * Naming:
16
+ * - Log group = `CLOUDWATCH_LOG_GROUP`
17
+ * - Group tags: `Project=<logGroup>`, `Env=<APP_ENV>`
18
+ * - Streams: `<APP_ENV>/client`, `<APP_ENV>/server`, `<APP_ENV>/background-functions`
19
+ */
20
+ const MAX_EVENT_CHARS = 240_000;
21
+ const MAX_BATCH_EVENTS = 50;
22
+ const AWS_CANDIDATES = [
23
+ {
24
+ region: "CLOUDWATCH_AWS_REGION",
25
+ key: "CLOUDWATCH_AWS_ACCESS_KEY_ID",
26
+ secret: "CLOUDWATCH_AWS_SECRET_ACCESS_KEY",
27
+ label: "CLOUDWATCH_AWS_*",
28
+ },
29
+ {
30
+ region: "APPOCUS_AWS_REGION",
31
+ key: "APPOCUS_AWS_ACCESS_KEY_ID",
32
+ secret: "APPOCUS_AWS_SECRET_ACCESS_KEY",
33
+ label: "APPOCUS_AWS_*",
34
+ },
35
+ {
36
+ region: "C4PINOS_AWS_REGION",
37
+ key: "C4PINOS_AWS_ACCESS_KEY_ID",
38
+ secret: "C4PINOS_AWS_SECRET_ACCESS_KEY",
39
+ label: "C4PINOS_AWS_*",
40
+ },
41
+ {
42
+ region: "AWS_REGION",
43
+ key: "AWS_ACCESS_KEY_ID",
44
+ secret: "AWS_SECRET_ACCESS_KEY",
45
+ label: "AWS_*",
46
+ },
47
+ ];
48
+ let client = null;
49
+ let clientKey = null;
50
+ /** Sequence token per stream. Survives while the lambda instance is warm. */
51
+ const sequenceTokens = {};
52
+ /** Serializes PutLogEvents so sequence tokens are not overwritten. */
53
+ let putChain = Promise.resolve();
54
+ function envTrim(name) {
55
+ const v = process.env[name];
56
+ if (!v || v.trim() === "")
57
+ return undefined;
58
+ return v.trim();
59
+ }
60
+ function resolveAwsCreds() {
61
+ for (const c of AWS_CANDIDATES) {
62
+ const region = envTrim(c.region);
63
+ const accessKeyId = envTrim(c.key);
64
+ const secretAccessKey = envTrim(c.secret);
65
+ if (region && accessKeyId && secretAccessKey) {
66
+ return { region, accessKeyId, secretAccessKey, prefixLabel: c.label };
67
+ }
68
+ }
69
+ return null;
70
+ }
71
+ function getLogGroup() {
72
+ return envTrim("CLOUDWATCH_LOG_GROUP");
73
+ }
74
+ function getEnvName() {
75
+ return envTrim("APP_ENV") ?? "development";
76
+ }
77
+ function getDefaultAppVersion() {
78
+ return envTrim("APP_VERSION") ?? envTrim("npm_package_version") ?? "unknown";
79
+ }
80
+ /**
81
+ * Introspection for smoke tests / diagnostics. Never returns secrets.
82
+ */
83
+ export function resolveCloudWatchConfig() {
84
+ const logGroup = getLogGroup();
85
+ const creds = resolveAwsCreds();
86
+ const appEnv = getEnvName();
87
+ const appVersion = getDefaultAppVersion();
88
+ if (!logGroup && !creds) {
89
+ return {
90
+ enabled: false,
91
+ appEnv,
92
+ appVersion,
93
+ skipReason: "faltan CLOUDWATCH_LOG_GROUP y credenciales AWS (CLOUDWATCH_AWS_*, APPOCUS_AWS_*, C4PINOS_AWS_* o AWS_*)",
94
+ };
95
+ }
96
+ if (!logGroup) {
97
+ return {
98
+ enabled: false,
99
+ region: creds?.region,
100
+ prefixLabel: creds?.prefixLabel,
101
+ appEnv,
102
+ appVersion,
103
+ skipReason: "falta CLOUDWATCH_LOG_GROUP",
104
+ };
105
+ }
106
+ if (!creds) {
107
+ return {
108
+ enabled: false,
109
+ logGroup,
110
+ appEnv,
111
+ appVersion,
112
+ skipReason: "faltan credenciales AWS (CLOUDWATCH_AWS_*, APPOCUS_AWS_*, C4PINOS_AWS_* o AWS_*)",
113
+ };
114
+ }
115
+ return {
116
+ enabled: true,
117
+ logGroup,
118
+ region: creds.region,
119
+ prefixLabel: creds.prefixLabel,
120
+ appEnv,
121
+ appVersion,
122
+ };
123
+ }
124
+ export function isCloudWatchEnabled() {
125
+ return resolveCloudWatchConfig().enabled;
126
+ }
127
+ function getClient() {
128
+ const creds = resolveAwsCreds();
129
+ if (!creds) {
130
+ throw new Error("CloudWatch deshabilitado: faltan credenciales AWS");
131
+ }
132
+ const key = `${creds.prefixLabel}:${creds.region}:${creds.accessKeyId}`;
133
+ if (!client || clientKey !== key) {
134
+ client = new CloudWatchLogsClient({
135
+ region: creds.region,
136
+ credentials: {
137
+ accessKeyId: creds.accessKeyId,
138
+ secretAccessKey: creds.secretAccessKey,
139
+ },
140
+ });
141
+ clientKey = key;
142
+ }
143
+ return client;
144
+ }
145
+ function streamName(origin) {
146
+ const env = getEnvName();
147
+ switch (origin) {
148
+ case "client":
149
+ return `${env}/client`;
150
+ case "server":
151
+ return `${env}/server`;
152
+ case "background":
153
+ return `${env}/background-functions`;
154
+ default: {
155
+ const _never = origin;
156
+ throw new Error(`Origen de log no soportado: ${String(_never)}`);
157
+ }
158
+ }
159
+ }
160
+ async function ensureStreamExists(cw, logGroup, stream) {
161
+ try {
162
+ await cw.send(new CreateLogGroupCommand({
163
+ logGroupName: logGroup,
164
+ tags: {
165
+ Project: logGroup,
166
+ Env: getEnvName(),
167
+ },
168
+ }));
169
+ }
170
+ catch (err) {
171
+ if (!(err instanceof ResourceAlreadyExistsException)) {
172
+ // IAM without logs:TagResource → CreateLogGroup with tags fails.
173
+ // Retry without tags (CreateLogGroup only).
174
+ try {
175
+ await cw.send(new CreateLogGroupCommand({
176
+ logGroupName: logGroup,
177
+ }));
178
+ }
179
+ catch (retryErr) {
180
+ if (!(retryErr instanceof ResourceAlreadyExistsException)) {
181
+ console.error("[observability] No se pudo crear el log group CloudWatch:", retryErr instanceof Error ? retryErr.message : retryErr);
182
+ }
183
+ }
184
+ }
185
+ }
186
+ try {
187
+ await cw.send(new CreateLogStreamCommand({
188
+ logGroupName: logGroup,
189
+ logStreamName: stream,
190
+ }));
191
+ }
192
+ catch (err) {
193
+ if (!(err instanceof ResourceAlreadyExistsException)) {
194
+ console.error("[observability] No se pudo crear el log stream CloudWatch:", err instanceof Error ? err.message : err);
195
+ }
196
+ }
197
+ }
198
+ /** Current sequence token of the stream (for cold starts). */
199
+ async function fetchSequenceToken(cw, logGroup, stream) {
200
+ try {
201
+ const res = await cw.send(new DescribeLogStreamsCommand({
202
+ logGroupName: logGroup,
203
+ logStreamNamePrefix: stream,
204
+ limit: 1,
205
+ }));
206
+ return res.logStreams?.[0]?.uploadSequenceToken;
207
+ }
208
+ catch {
209
+ return undefined;
210
+ }
211
+ }
212
+ const RESERVED_KEYS = new Set([
213
+ "message",
214
+ "stack",
215
+ "url",
216
+ "method",
217
+ "userAgent",
218
+ "userId",
219
+ "appVersion",
220
+ "timestamp",
221
+ "source",
222
+ "level",
223
+ ]);
224
+ function levelPrefix(origin, level) {
225
+ if (origin !== "background") {
226
+ return "[ERROR]";
227
+ }
228
+ switch (level) {
229
+ case "error":
230
+ return "[ERROR]";
231
+ case "warn":
232
+ return "[WARN]";
233
+ case "info":
234
+ return "[INFO]";
235
+ case undefined:
236
+ return "[INFO]";
237
+ default: {
238
+ const _never = level;
239
+ return String(_never);
240
+ }
241
+ }
242
+ }
243
+ function buildLogLine(origin, payload) {
244
+ const ts = new Date(payload.timestamp ?? Date.now()).toISOString();
245
+ const source = payload.source ?? "unknown";
246
+ const parts = [
247
+ levelPrefix(origin, payload.level),
248
+ `[${origin}]`,
249
+ `[${source}]`,
250
+ ts,
251
+ payload.message,
252
+ ];
253
+ if (payload.url)
254
+ parts.push(`url=${payload.url}`);
255
+ if (payload.method)
256
+ parts.push(`method=${payload.method}`);
257
+ if (payload.userId != null)
258
+ parts.push(`user=${payload.userId}`);
259
+ if (payload.appVersion)
260
+ parts.push(`v=${payload.appVersion}`);
261
+ for (const [k, v] of Object.entries(payload)) {
262
+ if (RESERVED_KEYS.has(k))
263
+ continue;
264
+ try {
265
+ parts.push(`${k}=${typeof v === "string" ? v : JSON.stringify(v)}`);
266
+ }
267
+ catch {
268
+ parts.push(`${k}=[unserializable]`);
269
+ }
270
+ }
271
+ if (payload.stack)
272
+ parts.push(`\n${payload.stack}`);
273
+ return parts.join(" ");
274
+ }
275
+ function truncateEvent(message) {
276
+ if (message.length <= MAX_EVENT_CHARS)
277
+ return message;
278
+ return `${message.slice(0, MAX_EVENT_CHARS)}…[truncated]`;
279
+ }
280
+ async function putEvents(cw, logGroup, stream, messages) {
281
+ let sequenceToken = sequenceTokens[stream];
282
+ if (!sequenceToken) {
283
+ sequenceToken = await fetchSequenceToken(cw, logGroup, stream);
284
+ }
285
+ const startedAt = Date.now();
286
+ const input = {
287
+ logGroupName: logGroup,
288
+ logStreamName: stream,
289
+ logEvents: messages.map((m, index) => ({
290
+ message: truncateEvent(m),
291
+ timestamp: startedAt + index,
292
+ })),
293
+ sequenceToken,
294
+ };
295
+ try {
296
+ const res = await cw.send(new PutLogEventsCommand(input));
297
+ sequenceTokens[stream] = res.nextSequenceToken;
298
+ }
299
+ catch (err) {
300
+ if (err instanceof InvalidSequenceTokenException) {
301
+ const expected = err.expectedSequenceToken;
302
+ if (!expected)
303
+ throw err;
304
+ const res = await cw.send(new PutLogEventsCommand({ ...input, sequenceToken: expected }));
305
+ sequenceTokens[stream] = res.nextSequenceToken;
306
+ }
307
+ else if (err instanceof DataAlreadyAcceptedException) {
308
+ sequenceTokens[stream] = err.expectedSequenceToken;
309
+ }
310
+ else {
311
+ throw err;
312
+ }
313
+ }
314
+ }
315
+ function serializePut(fn) {
316
+ const next = putChain.then(fn, fn);
317
+ putChain = next.then(() => undefined, () => undefined);
318
+ return next;
319
+ }
320
+ async function emitToCloudWatch(origin, lines) {
321
+ if (lines.length === 0) {
322
+ return { status: "skipped", reason: "sin eventos" };
323
+ }
324
+ const cfg = resolveCloudWatchConfig();
325
+ if (!cfg.enabled || !cfg.logGroup) {
326
+ return {
327
+ status: "skipped",
328
+ reason: cfg.skipReason ?? "CloudWatch deshabilitado",
329
+ };
330
+ }
331
+ try {
332
+ const cw = getClient();
333
+ const logGroup = cfg.logGroup;
334
+ const stream = streamName(origin);
335
+ await ensureStreamExists(cw, logGroup, stream);
336
+ for (let i = 0; i < lines.length; i += MAX_BATCH_EVENTS) {
337
+ const chunk = lines.slice(i, i + MAX_BATCH_EVENTS);
338
+ await serializePut(() => putEvents(cw, logGroup, stream, chunk));
339
+ }
340
+ return { status: "sent", logGroup, stream };
341
+ }
342
+ catch (err) {
343
+ const error = err instanceof Error ? err.message : String(err);
344
+ console.error("[observability] Falló envío a CloudWatch:", err);
345
+ return { status: "failed", error };
346
+ }
347
+ }
348
+ /**
349
+ * Sends an event to CloudWatch. Never throws.
350
+ * For client/server also prints with `console.error` (Netlify fallback).
351
+ */
352
+ export async function logEvent(origin, payload) {
353
+ const line = buildLogLine(origin, {
354
+ ...payload,
355
+ appVersion: payload.appVersion ?? getDefaultAppVersion(),
356
+ });
357
+ if (origin !== "background") {
358
+ console.error(`[observability] ${line}`);
359
+ }
360
+ else {
361
+ console.log(`[observability] ${line}`);
362
+ }
363
+ return emitToCloudWatch(origin, [line]);
364
+ }
365
+ /**
366
+ * Sends an error event to CloudWatch (and console.error as fallback).
367
+ * Never throws: if CloudWatch fails, the error still lands in host logs.
368
+ */
369
+ export async function logError(origin, payload) {
370
+ return logEvent(origin, { ...payload, level: "error" });
371
+ }
372
+ function formatConsoleArgs(args) {
373
+ return args
374
+ .map((arg) => {
375
+ if (typeof arg === "string")
376
+ return arg;
377
+ if (arg instanceof Error)
378
+ return arg.stack ?? arg.message;
379
+ try {
380
+ return JSON.stringify(arg);
381
+ }
382
+ catch {
383
+ return String(arg);
384
+ }
385
+ })
386
+ .join(" ");
387
+ }
388
+ /**
389
+ * Wraps a Netlify background handler: intercepts console.log/error/warn
390
+ * during the invocation, prefixes `[functionName]:`, and flushes to the
391
+ * `<APP_ENV>/background-functions` stream before returning (so logs are not
392
+ * lost when Netlify freezes the isolate).
393
+ */
394
+ export function withBackgroundLogs(functionName, handler) {
395
+ return async (event, context) => {
396
+ const prefix = `[${functionName}]:`;
397
+ const origLog = console.log;
398
+ const origError = console.error;
399
+ const origWarn = console.warn;
400
+ const buffer = [];
401
+ const enqueue = (args) => {
402
+ buffer.push(`${prefix} ${formatConsoleArgs(args)}`);
403
+ };
404
+ console.log = (...args) => {
405
+ origLog(prefix, ...args);
406
+ enqueue(args);
407
+ };
408
+ console.error = (...args) => {
409
+ origError(prefix, ...args);
410
+ enqueue(args);
411
+ };
412
+ console.warn = (...args) => {
413
+ origWarn(prefix, ...args);
414
+ enqueue(args);
415
+ };
416
+ try {
417
+ return await handler(event, context);
418
+ }
419
+ finally {
420
+ console.log = origLog;
421
+ console.error = origError;
422
+ console.warn = origWarn;
423
+ await emitToCloudWatch("background", buffer);
424
+ }
425
+ };
426
+ }
@@ -0,0 +1,96 @@
1
+ import { getSession } from "@zauru-sdk/services";
2
+ import { logError } from "./cloudwatch-logger.server.js";
3
+ const MAX_MESSAGE_LENGTH = 8000;
4
+ const MAX_STACK_LENGTH = 16000;
5
+ function truncate(value, max) {
6
+ if (!value)
7
+ return undefined;
8
+ if (value.length <= max)
9
+ return value;
10
+ return `${value.slice(0, max)}…[truncated]`;
11
+ }
12
+ function isRouteErrorResponse(error) {
13
+ return (typeof error === "object" &&
14
+ error !== null &&
15
+ "status" in error &&
16
+ "statusText" in error &&
17
+ typeof error.status === "number");
18
+ }
19
+ /**
20
+ * Remix `handleError` for `entry.server.tsx`. Captures loader/action errors
21
+ * and sends them to CloudWatch (`<APP_ENV>/server`). Never throws.
22
+ */
23
+ export async function handleError(error, { request }) {
24
+ if (request.signal.aborted)
25
+ return;
26
+ if (isRouteErrorResponse(error) && error.status < 500)
27
+ return;
28
+ const message = error instanceof Error
29
+ ? error.message
30
+ : isRouteErrorResponse(error)
31
+ ? `${error.status} ${error.statusText}`
32
+ : typeof error === "object" && error !== null && "message" in error
33
+ ? String(error.message)
34
+ : String(error);
35
+ await logError("server", {
36
+ message,
37
+ stack: error instanceof Error ? error.stack : undefined,
38
+ url: request.url,
39
+ method: request.method,
40
+ source: request.method.toUpperCase() === "GET" ? "loader" : "action",
41
+ });
42
+ }
43
+ /**
44
+ * Remix action for `POST /api/client-errors`.
45
+ *
46
+ * Best-effort: session is optional (errors can happen on /login), but if a
47
+ * cookie is present the username is attached. Never requires app access.
48
+ */
49
+ export function createClientErrorsAction() {
50
+ return async ({ request }) => {
51
+ let body;
52
+ try {
53
+ body = (await request.json());
54
+ }
55
+ catch {
56
+ return Response.json({ success: false, error: "Body inválido (se esperaba JSON)." }, { status: 400 });
57
+ }
58
+ if (!body || typeof body !== "object" || !body.message) {
59
+ return Response.json({ success: false, error: "Falta `message` en el payload." }, { status: 400 });
60
+ }
61
+ let userId;
62
+ try {
63
+ const cookie = request.headers.get("Cookie") ?? "";
64
+ const session = await getSession(cookie);
65
+ const username = session.get("username");
66
+ if (typeof username === "string" && username.length > 0) {
67
+ userId = username;
68
+ }
69
+ }
70
+ catch {
71
+ // Ignore: error reporting must not depend on the session.
72
+ }
73
+ const source = typeof body.source === "string" && body.source.length <= 200
74
+ ? body.source
75
+ : "unknown";
76
+ await logError("client", {
77
+ message: truncate(String(body.message), MAX_MESSAGE_LENGTH) ?? "Sin mensaje",
78
+ stack: truncate(typeof body.stack === "string" ? body.stack : undefined, MAX_STACK_LENGTH),
79
+ url: typeof body.url === "string" && body.url.length <= 2000
80
+ ? body.url
81
+ : undefined,
82
+ userAgent: typeof body.userAgent === "string" && body.userAgent.length <= 1000
83
+ ? body.userAgent
84
+ : undefined,
85
+ appVersion: typeof body.appVersion === "string" && body.appVersion.length <= 100
86
+ ? body.appVersion
87
+ : undefined,
88
+ timestamp: typeof body.timestamp === "number" && Number.isFinite(body.timestamp)
89
+ ? body.timestamp
90
+ : Date.now(),
91
+ userId,
92
+ source,
93
+ });
94
+ return new Response(null, { status: 204 });
95
+ };
96
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Server-only CloudWatch observability for Remix webapps.
3
+ *
4
+ * Import from `@zauru-sdk/utils/cloudwatch.server` (not the main barrel) so
5
+ * the AWS SDK stays out of the client bundle.
6
+ */
7
+ export { isCloudWatchEnabled, logError, logEvent, resolveCloudWatchConfig, withBackgroundLogs, } from "./cloudwatch-logger.server.js";
8
+ export { createClientErrorsAction, handleError, } from "./cloudwatch-remix.server.js";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Remix 2 SSR entry + CloudWatch `handleError`.
3
+ *
4
+ * ```ts
5
+ * export { handleRequest as default, handleError } from "@zauru-sdk/utils/remix-entry.server";
6
+ * ```
7
+ */
8
+ export { handleRequest, handleError } from "./remix-handle-request.server.js";
@@ -0,0 +1,93 @@
1
+ import { createElement } from "react";
2
+ import { PassThrough } from "node:stream";
3
+ import { createReadableStreamFromReadable } from "@remix-run/node";
4
+ import { RemixServer } from "@remix-run/react";
5
+ import { renderToPipeableStream } from "react-dom/server";
6
+ import { handleError } from "./cloudwatch-remix.server.js";
7
+ const ABORT_DELAY = 5_000;
8
+ function isBotRequest(userAgent) {
9
+ if (!userAgent)
10
+ return false;
11
+ return /bot|crawler|spider|crawling|facebookexternalhit|slurp|bingpreview/i.test(userAgent);
12
+ }
13
+ /**
14
+ * Remix 2 `handleRequest` (pipeable SSR). Use with `handleError` from
15
+ * `@zauru-sdk/utils/cloudwatch.server` in `app/entry.server.tsx`:
16
+ *
17
+ * ```ts
18
+ * export { handleRequest as default, handleError } from "@zauru-sdk/utils/remix-entry.server";
19
+ * ```
20
+ *
21
+ * Remix 1 apps keep their own `renderToString` entry; they have no `handleError`.
22
+ */
23
+ export function handleRequest(request, responseStatusCode, responseHeaders, remixContext, _loadContext) {
24
+ const prohibitOutOfOrderStreaming = isBotRequest(request.headers.get("user-agent")) || remixContext.isSpaMode;
25
+ return prohibitOutOfOrderStreaming
26
+ ? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext)
27
+ : handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext);
28
+ }
29
+ function handleBotRequest(request, responseStatusCode, responseHeaders, remixContext) {
30
+ return new Promise((resolve, reject) => {
31
+ let shellRendered = false;
32
+ const { pipe, abort } = renderToPipeableStream(createElement(RemixServer, {
33
+ context: remixContext,
34
+ url: request.url,
35
+ abortDelay: ABORT_DELAY,
36
+ }), {
37
+ onAllReady() {
38
+ shellRendered = true;
39
+ const body = new PassThrough();
40
+ const stream = createReadableStreamFromReadable(body);
41
+ responseHeaders.set("Content-Type", "text/html");
42
+ resolve(new Response(stream, {
43
+ headers: responseHeaders,
44
+ status: responseStatusCode,
45
+ }));
46
+ pipe(body);
47
+ },
48
+ onShellError(error) {
49
+ reject(error);
50
+ },
51
+ onError(error) {
52
+ responseStatusCode = 500;
53
+ if (shellRendered) {
54
+ console.error(error);
55
+ }
56
+ },
57
+ });
58
+ setTimeout(abort, ABORT_DELAY);
59
+ });
60
+ }
61
+ function handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext) {
62
+ return new Promise((resolve, reject) => {
63
+ let shellRendered = false;
64
+ const { pipe, abort } = renderToPipeableStream(createElement(RemixServer, {
65
+ context: remixContext,
66
+ url: request.url,
67
+ abortDelay: ABORT_DELAY,
68
+ }), {
69
+ onShellReady() {
70
+ shellRendered = true;
71
+ const body = new PassThrough();
72
+ const stream = createReadableStreamFromReadable(body);
73
+ responseHeaders.set("Content-Type", "text/html");
74
+ resolve(new Response(stream, {
75
+ headers: responseHeaders,
76
+ status: responseStatusCode,
77
+ }));
78
+ pipe(body);
79
+ },
80
+ onShellError(error) {
81
+ reject(error);
82
+ },
83
+ onError(error) {
84
+ responseStatusCode = 500;
85
+ if (shellRendered) {
86
+ console.error(error);
87
+ }
88
+ },
89
+ });
90
+ setTimeout(abort, ABORT_DELAY);
91
+ });
92
+ }
93
+ export { handleError };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./zauru/api.loaders.js";
2
+ export * from "./zauru/auth.loaders.js";
2
3
  export * from "./zauru/automatic-number.utils.js";
3
4
  export * from "./zauru/bitacora-edicion-masiva.utils.js";
4
5
  export * from "./zauru/bundles.utils.js";
@@ -0,0 +1,40 @@
1
+ import type { ActionFunction, LoaderFunction } from "@remix-run/node";
2
+ import { getSession, loginWebApp } from "@zauru-sdk/services";
3
+ export type AuthSession = Awaited<ReturnType<typeof getSession>>;
4
+ export type LoginWebAppData = NonNullable<Awaited<ReturnType<typeof loginWebApp>>["data"]>;
5
+ export type AuthAccessExtras = {
6
+ /**
7
+ * Return `false` to deny. Throw `redirect(...)` to send the user elsewhere.
8
+ * Any other return (including `void`) allows access.
9
+ */
10
+ requireAccess?: (session: AuthSession) => boolean | void;
11
+ };
12
+ export type LoginActionExtras = AuthAccessExtras & {
13
+ afterLogin?: (ctx: {
14
+ request: Request;
15
+ session: AuthSession;
16
+ headers: LoginWebAppData["headers"];
17
+ loginData: LoginWebAppData;
18
+ }) => Promise<void>;
19
+ };
20
+ export declare function createLoginLoader(): LoaderFunction;
21
+ /**
22
+ * Remix action for `/login`: OAuth authorize, `loginWebApp`, cookie commit.
23
+ * `afterLogin` runs before `commitSession`. `requireAccess` can deny after
24
+ * a successful OAuth exchange or when a session already has `code`.
25
+ */
26
+ export declare function createLoginAction(extras?: LoginActionExtras): ActionFunction;
27
+ /**
28
+ * Guard for `/home` and `/reload-catalogs`: no `username` → `/`.
29
+ */
30
+ export declare function createSessionGuardLoader(extras?: AuthAccessExtras): LoaderFunction;
31
+ export declare function createLogoutLoader(extras?: AuthAccessExtras): LoaderFunction;
32
+ export declare function createLogoutAction(): ActionFunction;
33
+ /**
34
+ * `/` welcome: existing session (and optional access) → `/home`.
35
+ */
36
+ export declare function createIndexLoader(extras?: AuthAccessExtras): LoaderFunction;
37
+ /**
38
+ * `/reload-catalogs`: session guard plus optional cookie refresh.
39
+ */
40
+ export declare function createReloadCatalogsLoader(extras?: AuthAccessExtras): LoaderFunction;
@@ -0,0 +1,58 @@
1
+ export type LogOrigin = "client" | "server" | "background";
2
+ export type LogLevel = "error" | "info" | "warn";
3
+ export type CloudWatchConfigInfo = {
4
+ enabled: boolean;
5
+ logGroup?: string;
6
+ region?: string;
7
+ prefixLabel?: string;
8
+ appEnv: string;
9
+ appVersion: string;
10
+ skipReason?: string;
11
+ };
12
+ /**
13
+ * Introspection for smoke tests / diagnostics. Never returns secrets.
14
+ */
15
+ export declare function resolveCloudWatchConfig(): CloudWatchConfigInfo;
16
+ export declare function isCloudWatchEnabled(): boolean;
17
+ export interface LogPayload {
18
+ message: string;
19
+ stack?: string;
20
+ url?: string;
21
+ method?: string;
22
+ userAgent?: string;
23
+ userId?: string | number | null;
24
+ appVersion?: string;
25
+ timestamp?: number;
26
+ /** Sub-origin: errorBoundary, window.onerror, unhandledrejection, loader, action. */
27
+ source?: string;
28
+ level?: LogLevel;
29
+ [key: string]: unknown;
30
+ }
31
+ export type LogErrorResult = {
32
+ status: "sent";
33
+ logGroup: string;
34
+ stream: string;
35
+ } | {
36
+ status: "skipped";
37
+ reason: string;
38
+ } | {
39
+ status: "failed";
40
+ error: string;
41
+ };
42
+ /**
43
+ * Sends an event to CloudWatch. Never throws.
44
+ * For client/server also prints with `console.error` (Netlify fallback).
45
+ */
46
+ export declare function logEvent(origin: LogOrigin, payload: LogPayload): Promise<LogErrorResult>;
47
+ /**
48
+ * Sends an error event to CloudWatch (and console.error as fallback).
49
+ * Never throws: if CloudWatch fails, the error still lands in host logs.
50
+ */
51
+ export declare function logError(origin: "client" | "server", payload: LogPayload): Promise<LogErrorResult>;
52
+ /**
53
+ * Wraps a Netlify background handler: intercepts console.log/error/warn
54
+ * during the invocation, prefixes `[functionName]:`, and flushes to the
55
+ * `<APP_ENV>/background-functions` stream before returning (so logs are not
56
+ * lost when Netlify freezes the isolate).
57
+ */
58
+ export declare function withBackgroundLogs<TEvent, TContext, TResult>(functionName: string, handler: (event: TEvent, context: TContext) => TResult | Promise<TResult>): (event: TEvent, context: TContext) => Promise<TResult>;
@@ -0,0 +1,15 @@
1
+ import type { ActionFunction } from "@remix-run/node";
2
+ /**
3
+ * Remix `handleError` for `entry.server.tsx`. Captures loader/action errors
4
+ * and sends them to CloudWatch (`<APP_ENV>/server`). Never throws.
5
+ */
6
+ export declare function handleError(error: unknown, { request }: {
7
+ request: Request;
8
+ }): Promise<void>;
9
+ /**
10
+ * Remix action for `POST /api/client-errors`.
11
+ *
12
+ * Best-effort: session is optional (errors can happen on /login), but if a
13
+ * cookie is present the username is attached. Never requires app access.
14
+ */
15
+ export declare function createClientErrorsAction(): ActionFunction;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Server-only CloudWatch observability for Remix webapps.
3
+ *
4
+ * Import from `@zauru-sdk/utils/cloudwatch.server` (not the main barrel) so
5
+ * the AWS SDK stays out of the client bundle.
6
+ */
7
+ export { isCloudWatchEnabled, logError, logEvent, resolveCloudWatchConfig, withBackgroundLogs, type CloudWatchConfigInfo, type LogErrorResult, type LogLevel, type LogOrigin, type LogPayload, } from "./cloudwatch-logger.server.js";
8
+ export { createClientErrorsAction, handleError, } from "./cloudwatch-remix.server.js";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Remix 2 SSR entry + CloudWatch `handleError`.
3
+ *
4
+ * ```ts
5
+ * export { handleRequest as default, handleError } from "@zauru-sdk/utils/remix-entry.server";
6
+ * ```
7
+ */
8
+ export { handleRequest, handleError } from "./remix-handle-request.server.js";
@@ -0,0 +1,14 @@
1
+ import type { AppLoadContext, EntryContext } from "@remix-run/node";
2
+ import { handleError } from "./cloudwatch-remix.server.js";
3
+ /**
4
+ * Remix 2 `handleRequest` (pipeable SSR). Use with `handleError` from
5
+ * `@zauru-sdk/utils/cloudwatch.server` in `app/entry.server.tsx`:
6
+ *
7
+ * ```ts
8
+ * export { handleRequest as default, handleError } from "@zauru-sdk/utils/remix-entry.server";
9
+ * ```
10
+ *
11
+ * Remix 1 apps keep their own `renderToString` entry; they have no `handleError`.
12
+ */
13
+ export declare function handleRequest(request: Request, responseStatusCode: number, responseHeaders: Headers, remixContext: EntryContext, _loadContext: AppLoadContext): Promise<unknown>;
14
+ export { handleError };
package/package.json CHANGED
@@ -1,11 +1,38 @@
1
1
  {
2
2
  "name": "@zauru-sdk/utils",
3
- "version": "6.14.0",
3
+ "version": "6.16.0",
4
4
  "description": "Utilidades, parseo de request y demás para desarrollo de webapps de Zauru",
5
5
  "main": "./dist/esm/index.js",
6
6
  "module": "./dist/esm/index.js",
7
7
  "types": "./dist/index.d.ts",
8
8
  "type": "module",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/esm/index.js",
13
+ "default": "./dist/esm/index.js"
14
+ },
15
+ "./cloudwatch.server": {
16
+ "types": "./dist/zauru/cloudwatch.server.d.ts",
17
+ "import": "./dist/esm/zauru/cloudwatch.server.js",
18
+ "default": "./dist/esm/zauru/cloudwatch.server.js"
19
+ },
20
+ "./remix-entry.server": {
21
+ "types": "./dist/zauru/remix-entry.server.d.ts",
22
+ "import": "./dist/esm/zauru/remix-entry.server.js",
23
+ "default": "./dist/esm/zauru/remix-entry.server.js"
24
+ }
25
+ },
26
+ "typesVersions": {
27
+ "*": {
28
+ "cloudwatch.server": [
29
+ "./dist/zauru/cloudwatch.server.d.ts"
30
+ ],
31
+ "remix-entry.server": [
32
+ "./dist/zauru/remix-entry.server.d.ts"
33
+ ]
34
+ }
35
+ },
9
36
  "publishConfig": {
10
37
  "access": "public",
11
38
  "registry": "https://registry.npmjs.org/"
@@ -21,14 +48,28 @@
21
48
  "author": "Oscar Cuéllar",
22
49
  "license": "MIT",
23
50
  "peerDependencies": {
24
- "react": "^18.2.0 || ^19.0.0"
51
+ "@remix-run/react": "^2.8.1",
52
+ "react": "^18.2.0 || ^19.0.0",
53
+ "react-dom": "^18.2.0 || ^19.0.0"
54
+ },
55
+ "peerDependenciesMeta": {
56
+ "@remix-run/react": {
57
+ "optional": true
58
+ },
59
+ "react-dom": {
60
+ "optional": true
61
+ }
25
62
  },
26
63
  "devDependencies": {
64
+ "@remix-run/react": "^2.8.1",
27
65
  "@types/react": "^18.2.20",
66
+ "@types/react-dom": "^18.2.7",
28
67
  "react": "^18.2.0",
68
+ "react-dom": "^18.2.0",
29
69
  "typescript": "^5.1.6"
30
70
  },
31
71
  "dependencies": {
72
+ "@aws-sdk/client-cloudwatch-logs": "^3.1123.0",
32
73
  "@remix-run/node": "^2.8.1",
33
74
  "@zauru-sdk/common": "^6.6.0",
34
75
  "@zauru-sdk/config": "^6.0.0",
@@ -37,5 +78,5 @@
37
78
  "@zauru-sdk/types": "^6.16.0",
38
79
  "moment": "^2.30.1"
39
80
  },
40
- "gitHead": "b0d95fcc90a6789ba2bf4bdcb0f54070c7583e85"
81
+ "gitHead": "41f536143a03ddca17bbf467c027ef0179493479"
41
82
  }