@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 +1 -0
- package/dist/esm/zauru/auth.loaders.js +141 -0
- package/dist/esm/zauru/cloudwatch-logger.server.js +426 -0
- package/dist/esm/zauru/cloudwatch-remix.server.js +96 -0
- package/dist/esm/zauru/cloudwatch.server.js +8 -0
- package/dist/esm/zauru/remix-entry.server.js +8 -0
- package/dist/esm/zauru/remix-handle-request.server.js +93 -0
- package/dist/index.d.ts +1 -0
- package/dist/zauru/auth.loaders.d.ts +40 -0
- package/dist/zauru/cloudwatch-logger.server.d.ts +58 -0
- package/dist/zauru/cloudwatch-remix.server.d.ts +15 -0
- package/dist/zauru/cloudwatch.server.d.ts +8 -0
- package/dist/zauru/remix-entry.server.d.ts +8 -0
- package/dist/zauru/remix-handle-request.server.d.ts +14 -0
- package/package.json +44 -3
package/dist/esm/index.js
CHANGED
|
@@ -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,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
|
@@ -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,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.
|
|
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": "^
|
|
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": "
|
|
81
|
+
"gitHead": "41f536143a03ddca17bbf467c027ef0179493479"
|
|
41
82
|
}
|