@codaco/analytics 5.1.0 → 6.0.0-alpha.2
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/.turbo/turbo-build.log +2 -7
- package/dist/index.d.ts +27 -22
- package/dist/index.js +27 -17
- package/dist/index.js.map +1 -1
- package/package.json +19 -8
- package/src/index.ts +75 -50
- package/src/utils.ts +24 -11
- package/tsconfig.json +8 -10
- package/.eslintrc.js +0 -4
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
|
|
2
2
|
|
|
3
|
-
> @codaco/analytics@5.
|
|
3
|
+
> @codaco/analytics@5.1.0 build /Users/jmh629/Projects/network-canvas/packages/analytics
|
|
4
4
|
> tsup src/index.ts --format esm --dts --clean --sourcemap
|
|
5
5
|
|
|
6
6
|
[34mCLI[39m Building entry: src/index.ts
|
|
7
7
|
[34mCLI[39m Using tsconfig: tsconfig.json
|
|
8
|
-
[34mCLI[39m tsup
|
|
8
|
+
[34mCLI[39m tsup v8.0.2
|
|
9
9
|
[34mCLI[39m Target: es2022
|
|
10
10
|
[34mCLI[39m Cleaning output folder
|
|
11
11
|
[34mESM[39m Build start
|
|
12
|
-
[32mESM[39m [1mdist/index.js [22m[32m5.77 KB[39m
|
|
13
|
-
[32mESM[39m [1mdist/index.js.map [22m[32m11.87 KB[39m
|
|
14
|
-
[32mESM[39m ⚡️ Build success in 18ms
|
|
15
12
|
[34mDTS[39m Build start
|
|
16
|
-
[32mDTS[39m ⚡️ Build success in 907ms
|
|
17
|
-
[32mDTS[39m [1mdist/index.d.ts [22m[32m5.64 KB[39m
|
package/dist/index.d.ts
CHANGED
|
@@ -7,68 +7,72 @@ declare const eventTypes: readonly ["AppSetup", "ProtocolInstalled", "InterviewS
|
|
|
7
7
|
* events or errors. We discriminate on the `type` property to determine which
|
|
8
8
|
* schema to use, and then merge the shared properties.
|
|
9
9
|
*/
|
|
10
|
-
declare const RawEventSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
|
|
10
|
+
declare const RawEventSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<z.objectUtil.extendShape<{
|
|
11
11
|
metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
12
|
+
}, {
|
|
12
13
|
type: z.ZodEnum<["AppSetup", "ProtocolInstalled", "InterviewStarted", "InterviewCompleted", "DataExported"]>;
|
|
13
|
-
}
|
|
14
|
+
}>, "strip", z.ZodTypeAny, {
|
|
14
15
|
type: "AppSetup" | "ProtocolInstalled" | "InterviewStarted" | "InterviewCompleted" | "DataExported";
|
|
15
16
|
metadata?: Record<string, unknown> | undefined;
|
|
16
17
|
}, {
|
|
17
18
|
type: "AppSetup" | "ProtocolInstalled" | "InterviewStarted" | "InterviewCompleted" | "DataExported";
|
|
18
19
|
metadata?: Record<string, unknown> | undefined;
|
|
19
|
-
}>, z.ZodObject<{
|
|
20
|
+
}>, z.ZodObject<z.objectUtil.extendShape<{
|
|
20
21
|
metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
22
|
+
}, {
|
|
21
23
|
type: z.ZodLiteral<"Error">;
|
|
22
24
|
message: z.ZodString;
|
|
23
25
|
name: z.ZodString;
|
|
24
26
|
stack: z.ZodOptional<z.ZodString>;
|
|
25
27
|
cause: z.ZodOptional<z.ZodString>;
|
|
26
|
-
}
|
|
28
|
+
}>, "strip", z.ZodTypeAny, {
|
|
27
29
|
type: "Error";
|
|
28
30
|
message: string;
|
|
29
31
|
name: string;
|
|
30
|
-
metadata?: Record<string, unknown> | undefined;
|
|
31
32
|
stack?: string | undefined;
|
|
32
33
|
cause?: string | undefined;
|
|
34
|
+
metadata?: Record<string, unknown> | undefined;
|
|
33
35
|
}, {
|
|
34
36
|
type: "Error";
|
|
35
37
|
message: string;
|
|
36
38
|
name: string;
|
|
37
|
-
metadata?: Record<string, unknown> | undefined;
|
|
38
39
|
stack?: string | undefined;
|
|
39
40
|
cause?: string | undefined;
|
|
41
|
+
metadata?: Record<string, unknown> | undefined;
|
|
40
42
|
}>]>;
|
|
41
43
|
type RawEvent = z.infer<typeof RawEventSchema>;
|
|
42
|
-
declare const TrackableEventSchema: z.ZodIntersection<z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
|
|
44
|
+
declare const TrackableEventSchema: z.ZodIntersection<z.ZodDiscriminatedUnion<"type", [z.ZodObject<z.objectUtil.extendShape<{
|
|
43
45
|
metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
46
|
+
}, {
|
|
44
47
|
type: z.ZodEnum<["AppSetup", "ProtocolInstalled", "InterviewStarted", "InterviewCompleted", "DataExported"]>;
|
|
45
|
-
}
|
|
48
|
+
}>, "strip", z.ZodTypeAny, {
|
|
46
49
|
type: "AppSetup" | "ProtocolInstalled" | "InterviewStarted" | "InterviewCompleted" | "DataExported";
|
|
47
50
|
metadata?: Record<string, unknown> | undefined;
|
|
48
51
|
}, {
|
|
49
52
|
type: "AppSetup" | "ProtocolInstalled" | "InterviewStarted" | "InterviewCompleted" | "DataExported";
|
|
50
53
|
metadata?: Record<string, unknown> | undefined;
|
|
51
|
-
}>, z.ZodObject<{
|
|
54
|
+
}>, z.ZodObject<z.objectUtil.extendShape<{
|
|
52
55
|
metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
56
|
+
}, {
|
|
53
57
|
type: z.ZodLiteral<"Error">;
|
|
54
58
|
message: z.ZodString;
|
|
55
59
|
name: z.ZodString;
|
|
56
60
|
stack: z.ZodOptional<z.ZodString>;
|
|
57
61
|
cause: z.ZodOptional<z.ZodString>;
|
|
58
|
-
}
|
|
62
|
+
}>, "strip", z.ZodTypeAny, {
|
|
59
63
|
type: "Error";
|
|
60
64
|
message: string;
|
|
61
65
|
name: string;
|
|
62
|
-
metadata?: Record<string, unknown> | undefined;
|
|
63
66
|
stack?: string | undefined;
|
|
64
67
|
cause?: string | undefined;
|
|
68
|
+
metadata?: Record<string, unknown> | undefined;
|
|
65
69
|
}, {
|
|
66
70
|
type: "Error";
|
|
67
71
|
message: string;
|
|
68
72
|
name: string;
|
|
69
|
-
metadata?: Record<string, unknown> | undefined;
|
|
70
73
|
stack?: string | undefined;
|
|
71
74
|
cause?: string | undefined;
|
|
75
|
+
metadata?: Record<string, unknown> | undefined;
|
|
72
76
|
}>]>, z.ZodObject<{
|
|
73
77
|
timestamp: z.ZodString;
|
|
74
78
|
}, "strip", z.ZodTypeAny, {
|
|
@@ -82,36 +86,38 @@ type TrackableEvent = z.infer<typeof TrackableEventSchema>;
|
|
|
82
86
|
* validate the event before it is inserted into the database. It is the
|
|
83
87
|
* intersection of the trackable event and the dispatchable properties.
|
|
84
88
|
*/
|
|
85
|
-
declare const AnalyticsEventSchema: z.ZodIntersection<z.ZodIntersection<z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
|
|
89
|
+
declare const AnalyticsEventSchema: z.ZodIntersection<z.ZodIntersection<z.ZodDiscriminatedUnion<"type", [z.ZodObject<z.objectUtil.extendShape<{
|
|
86
90
|
metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
91
|
+
}, {
|
|
87
92
|
type: z.ZodEnum<["AppSetup", "ProtocolInstalled", "InterviewStarted", "InterviewCompleted", "DataExported"]>;
|
|
88
|
-
}
|
|
93
|
+
}>, "strip", z.ZodTypeAny, {
|
|
89
94
|
type: "AppSetup" | "ProtocolInstalled" | "InterviewStarted" | "InterviewCompleted" | "DataExported";
|
|
90
95
|
metadata?: Record<string, unknown> | undefined;
|
|
91
96
|
}, {
|
|
92
97
|
type: "AppSetup" | "ProtocolInstalled" | "InterviewStarted" | "InterviewCompleted" | "DataExported";
|
|
93
98
|
metadata?: Record<string, unknown> | undefined;
|
|
94
|
-
}>, z.ZodObject<{
|
|
99
|
+
}>, z.ZodObject<z.objectUtil.extendShape<{
|
|
95
100
|
metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
101
|
+
}, {
|
|
96
102
|
type: z.ZodLiteral<"Error">;
|
|
97
103
|
message: z.ZodString;
|
|
98
104
|
name: z.ZodString;
|
|
99
105
|
stack: z.ZodOptional<z.ZodString>;
|
|
100
106
|
cause: z.ZodOptional<z.ZodString>;
|
|
101
|
-
}
|
|
107
|
+
}>, "strip", z.ZodTypeAny, {
|
|
102
108
|
type: "Error";
|
|
103
109
|
message: string;
|
|
104
110
|
name: string;
|
|
105
|
-
metadata?: Record<string, unknown> | undefined;
|
|
106
111
|
stack?: string | undefined;
|
|
107
112
|
cause?: string | undefined;
|
|
113
|
+
metadata?: Record<string, unknown> | undefined;
|
|
108
114
|
}, {
|
|
109
115
|
type: "Error";
|
|
110
116
|
message: string;
|
|
111
117
|
name: string;
|
|
112
|
-
metadata?: Record<string, unknown> | undefined;
|
|
113
118
|
stack?: string | undefined;
|
|
114
119
|
cause?: string | undefined;
|
|
120
|
+
metadata?: Record<string, unknown> | undefined;
|
|
115
121
|
}>]>, z.ZodObject<{
|
|
116
122
|
timestamp: z.ZodString;
|
|
117
123
|
}, "strip", z.ZodTypeAny, {
|
|
@@ -130,12 +136,11 @@ declare const AnalyticsEventSchema: z.ZodIntersection<z.ZodIntersection<z.ZodDis
|
|
|
130
136
|
}>>;
|
|
131
137
|
type analyticsEvent = z.infer<typeof AnalyticsEventSchema>;
|
|
132
138
|
declare const createRouteHandler: ({ platformUrl, installationId, }: {
|
|
133
|
-
platformUrl?: string
|
|
139
|
+
platformUrl?: string;
|
|
134
140
|
installationId: string;
|
|
135
141
|
}) => (request: NextRequest) => Promise<Response>;
|
|
136
|
-
declare const makeEventTracker: ({
|
|
137
|
-
|
|
138
|
-
endpoint?: string | undefined;
|
|
142
|
+
declare const makeEventTracker: ({ endpoint }: {
|
|
143
|
+
endpoint?: string;
|
|
139
144
|
}) => (event: RawEvent) => Promise<{
|
|
140
145
|
error: string | null;
|
|
141
146
|
success: boolean;
|
package/dist/index.js
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { NextResponse } from "next/server";
|
|
3
|
+
|
|
1
4
|
// src/utils.ts
|
|
2
5
|
function ensureError(value) {
|
|
3
6
|
if (!value)
|
|
4
7
|
return new Error("No value was thrown");
|
|
5
8
|
if (value instanceof Error)
|
|
6
9
|
return value;
|
|
7
|
-
if (
|
|
10
|
+
if (Object.prototype.isPrototypeOf.call(value, Error))
|
|
8
11
|
return value;
|
|
9
12
|
let stringified = "[Unable to stringify the thrown value]";
|
|
10
13
|
try {
|
|
11
14
|
stringified = JSON.stringify(value);
|
|
12
|
-
} catch {
|
|
15
|
+
} catch (e) {
|
|
16
|
+
console.error(e);
|
|
13
17
|
}
|
|
14
18
|
const error = new Error(
|
|
15
19
|
`This value was thrown as is, not through an Error: ${stringified}`
|
|
@@ -74,15 +78,26 @@ var createRouteHandler = ({
|
|
|
74
78
|
return async (request) => {
|
|
75
79
|
try {
|
|
76
80
|
const incomingEvent = await request.json();
|
|
81
|
+
if (process.env.DISABLE_ANALYTICS) {
|
|
82
|
+
console.info("\u{1F6D1} Analytics disabled. Payload not sent.");
|
|
83
|
+
try {
|
|
84
|
+
console.info(
|
|
85
|
+
"Payload:",
|
|
86
|
+
"\n",
|
|
87
|
+
JSON.stringify(incomingEvent, null, 2)
|
|
88
|
+
);
|
|
89
|
+
} catch (e) {
|
|
90
|
+
console.error("Error stringifying payload:", e);
|
|
91
|
+
}
|
|
92
|
+
return NextResponse.json(
|
|
93
|
+
{ message: "Analytics disabled" },
|
|
94
|
+
{ status: 200 }
|
|
95
|
+
);
|
|
96
|
+
}
|
|
77
97
|
const trackableEvent = TrackableEventSchema.safeParse(incomingEvent);
|
|
78
98
|
if (!trackableEvent.success) {
|
|
79
99
|
console.error("Invalid event:", trackableEvent.error);
|
|
80
|
-
return
|
|
81
|
-
status: 400,
|
|
82
|
-
headers: {
|
|
83
|
-
"Content-Type": "application/json"
|
|
84
|
-
}
|
|
85
|
-
});
|
|
100
|
+
return NextResponse.json({ error: "Invalid event" }, { status: 400 });
|
|
86
101
|
}
|
|
87
102
|
let countryISOCode = "Unknown";
|
|
88
103
|
try {
|
|
@@ -92,7 +107,9 @@ var createRouteHandler = ({
|
|
|
92
107
|
if (!ip) {
|
|
93
108
|
throw new Error("Could not fetch IP address");
|
|
94
109
|
}
|
|
95
|
-
const geoData = await fetch(`http://ip-api.com/json/${ip}`).then(
|
|
110
|
+
const geoData = await fetch(`http://ip-api.com/json/${ip}`).then(
|
|
111
|
+
(res) => res.json()
|
|
112
|
+
);
|
|
96
113
|
if (geoData.status === "success") {
|
|
97
114
|
countryISOCode = geoData.countryCode;
|
|
98
115
|
} else {
|
|
@@ -145,14 +162,7 @@ var createRouteHandler = ({
|
|
|
145
162
|
}
|
|
146
163
|
};
|
|
147
164
|
};
|
|
148
|
-
var makeEventTracker = ({
|
|
149
|
-
enabled = false,
|
|
150
|
-
endpoint = "/api/analytics"
|
|
151
|
-
}) => async (event) => {
|
|
152
|
-
if (!enabled) {
|
|
153
|
-
console.log("Analytics disabled - event not sent.");
|
|
154
|
-
return { error: null, success: true };
|
|
155
|
-
}
|
|
165
|
+
var makeEventTracker = ({ endpoint = "/api/analytics" }) => async (event) => {
|
|
156
166
|
const endpointWithHost = getBaseUrl() + endpoint;
|
|
157
167
|
const eventWithTimeStamp = {
|
|
158
168
|
...event,
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/utils.ts","../src/index.ts"],"sourcesContent":["// Helper function that ensures that a value is an Error\nexport function ensureError(value: unknown): Error {\n if (!value) return new Error(\"No value was thrown\");\n\n if (value instanceof Error) return value;\n\n // Test if value inherits from Error\n if (value.isPrototypeOf(Error)) return value as Error & typeof value;\n\n let stringified = \"[Unable to stringify the thrown value]\";\n try {\n stringified = JSON.stringify(value);\n } catch {}\n\n const error = new Error(\n `This value was thrown as is, not through an Error: ${stringified}`\n );\n return error;\n}\n\nexport function getBaseUrl() {\n if (typeof window !== \"undefined\")\n // browser should use relative path\n return \"\";\n\n if (process.env.VERCEL_URL)\n // reference for vercel.com\n return `https://${process.env.VERCEL_URL}`;\n\n if (process.env.NEXT_PUBLIC_URL)\n // Manually set deployment URL from env\n return process.env.NEXT_PUBLIC_URL;\n\n // assume localhost\n return `http://127.0.0.1:3000`;\n}\n","import { type NextRequest } from \"next/server\";\nimport { ensureError, getBaseUrl } from \"./utils\";\nimport z from \"zod\";\n\n// Todo: it would be great to work out a way to support arbitrary types here.\nexport const eventTypes = [\n \"AppSetup\",\n \"ProtocolInstalled\",\n \"InterviewStarted\",\n \"InterviewCompleted\",\n \"DataExported\",\n] as const;\n\nconst EventSchema = z.object({\n type: z.enum(eventTypes),\n});\n\nconst ErrorSchema = z.object({\n type: z.literal(\"Error\"),\n message: z.string(),\n name: z.string(),\n stack: z.string().optional(),\n cause: z.string().optional(),\n});\n\nconst SharedEventAndErrorSchema = z.object({\n metadata: z.record(z.unknown()).optional(),\n});\n\n/**\n * Raw events are the events that are sent trackEvent. They are either general\n * events or errors. We discriminate on the `type` property to determine which\n * schema to use, and then merge the shared properties.\n */\nexport const RawEventSchema = z.discriminatedUnion(\"type\", [\n SharedEventAndErrorSchema.merge(EventSchema),\n SharedEventAndErrorSchema.merge(ErrorSchema),\n]);\nexport type RawEvent = z.infer<typeof RawEventSchema>;\n\n/**\n * Trackable events are the events that are sent to the route handler. The\n * `trackEvent` function adds the timestamp to ensure it is not inaccurate\n * due to network latency or processing time.\n */\nconst TrackablePropertiesSchema = z.object({\n timestamp: z.string(),\n});\n\nexport const TrackableEventSchema = z.intersection(\n RawEventSchema,\n TrackablePropertiesSchema\n);\nexport type TrackableEvent = z.infer<typeof TrackableEventSchema>;\n\n/**\n * Dispatchable events are the events that are sent to the platform. The route\n * handler injects the installationId and countryISOCode properties.\n */\nconst DispatchablePropertiesSchema = z.object({\n installationId: z.string(),\n countryISOCode: z.string(),\n});\n\n/**\n * The final schema for an analytics event. This is the schema that is used to\n * validate the event before it is inserted into the database. It is the\n * intersection of the trackable event and the dispatchable properties.\n */\nexport const AnalyticsEventSchema = z.intersection(\n TrackableEventSchema,\n DispatchablePropertiesSchema\n);\nexport type analyticsEvent = z.infer<typeof AnalyticsEventSchema>;\n\nexport const createRouteHandler = ({\n platformUrl = \"https://analytics.networkcanvas.com\",\n installationId,\n}: {\n platformUrl?: string;\n installationId: string;\n}) => {\n return async (request: NextRequest) => {\n try {\n const incomingEvent = (await request.json()) as unknown;\n\n // Validate the event\n const trackableEvent = TrackableEventSchema.safeParse(incomingEvent);\n\n if (!trackableEvent.success) {\n console.error(\"Invalid event:\", trackableEvent.error);\n return new Response(JSON.stringify({ error: \"Invalid event\" }), {\n status: 400,\n headers: {\n \"Content-Type\": \"application/json\",\n },\n });\n }\n\n // We don't want failures in third party services to prevent us from\n // tracking analytics events, so we'll catch any errors and log them\n // and continue with an 'Unknown' country code.\n let countryISOCode = \"Unknown\";\n try {\n const ip = await fetch(\"https://api64.ipify.org\").then((res) =>\n res.text()\n );\n\n if (!ip) {\n throw new Error(\"Could not fetch IP address\");\n }\n\n const geoData = await fetch(`http://ip-api.com/json/${ip}`).then((res) => res.json());\n\n if(geoData.status === \"success\") {\n countryISOCode = geoData.countryCode;\n } else {\n throw new Error(geoData.message);\n }\n } catch (e) {\n console.error(\"Geolocation failed:\", e);\n }\n\n const analyticsEvent: analyticsEvent = {\n ...trackableEvent.data,\n installationId,\n countryISOCode,\n };\n\n // Forward to backend\n const response = await fetch(`${platformUrl}/api/event`, {\n keepalive: true,\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(analyticsEvent),\n });\n\n if (!response.ok) {\n let error = `Analytics platform returned an unexpected error: ${response.statusText}`;\n\n if (response.status === 400) {\n error = `Analytics platform rejected the event as invalid. Please check the event schema`;\n }\n\n if (response.status === 404) {\n error = `Analytics platform could not be reached. Please specify a valid platform URL, or check that the platform is online.`;\n }\n\n if (response.status === 500) {\n error = `Analytics platform returned an internal server error. Please check the platform logs.`;\n }\n\n console.info(`⚠️ Analytics platform rejected event: ${error}`);\n return Response.json(\n {\n error,\n },\n { status: 500 }\n );\n }\n console.info(\"🚀 Analytics event sent to platform!\");\n return Response.json({ message: \"Event forwarded successfully\" });\n } catch (e) {\n const error = ensureError(e);\n console.info(\"🚫 Internal error with sending analytics event.\");\n\n return Response.json(\n { error: `Error in analytics route handler: ${error.message}` },\n { status: 500 }\n );\n }\n };\n};\n\nexport const makeEventTracker =\n ({\n enabled = false,\n endpoint = \"/api/analytics\",\n }: {\n enabled?: boolean;\n endpoint?: string;\n }) =>\n async (\n event: RawEvent\n ): Promise<{\n error: string | null;\n success: boolean;\n }> => {\n if (!enabled) {\n console.log(\"Analytics disabled - event not sent.\");\n return { error: null, success: true };\n }\n\n const endpointWithHost = getBaseUrl() + endpoint;\n\n const eventWithTimeStamp: TrackableEvent = {\n ...event,\n timestamp: new Date().toJSON(),\n };\n\n try {\n const response = await fetch(endpointWithHost, {\n method: \"POST\",\n keepalive: true,\n body: JSON.stringify(eventWithTimeStamp),\n headers: {\n \"Content-Type\": \"application/json\",\n },\n });\n\n if (!response.ok) {\n if (response.status === 404) {\n return {\n error: `Analytics endpoint not found, did you forget to add the route?`,\n success: false,\n };\n }\n\n // createRouteHandler will return a 400 if the event failed schema validation.\n if (response.status === 400) {\n return {\n error: `Invalid event sent to analytics endpoint: ${response.statusText}`,\n success: false,\n };\n }\n\n // createRouteHandler will return a 500 for all error states\n return {\n error: `Internal server error when sending analytics event: ${response.statusText}. Check the route handler implementation.`,\n success: false,\n };\n }\n\n return { error: null, success: true };\n } catch (e) {\n const error = ensureError(e);\n return {\n error: `Internal error when sending analytics event: ${error.message}`,\n success: false,\n };\n }\n };\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/utils.ts"],"sourcesContent":["import { NextResponse, type NextRequest } from 'next/server';\nimport { ensureError, getBaseUrl } from './utils';\nimport z from 'zod';\n\n// Todo: it would be great to work out a way to support arbitrary types here.\nexport const eventTypes = [\n 'AppSetup',\n 'ProtocolInstalled',\n 'InterviewStarted',\n 'InterviewCompleted',\n 'DataExported',\n] as const;\n\nconst EventSchema = z.object({\n type: z.enum(eventTypes),\n});\n\nconst ErrorSchema = z.object({\n type: z.literal('Error'),\n message: z.string(),\n name: z.string(),\n stack: z.string().optional(),\n cause: z.string().optional(),\n});\n\nconst SharedEventAndErrorSchema = z.object({\n metadata: z.record(z.unknown()).optional(),\n});\n\n/**\n * Raw events are the events that are sent trackEvent. They are either general\n * events or errors. We discriminate on the `type` property to determine which\n * schema to use, and then merge the shared properties.\n */\nexport const RawEventSchema = z.discriminatedUnion('type', [\n SharedEventAndErrorSchema.merge(EventSchema),\n SharedEventAndErrorSchema.merge(ErrorSchema),\n]);\nexport type RawEvent = z.infer<typeof RawEventSchema>;\n\n/**\n * Trackable events are the events that are sent to the route handler. The\n * `trackEvent` function adds the timestamp to ensure it is not inaccurate\n * due to network latency or processing time.\n */\nconst TrackablePropertiesSchema = z.object({\n timestamp: z.string(),\n});\n\nexport const TrackableEventSchema = z.intersection(\n RawEventSchema,\n TrackablePropertiesSchema,\n);\nexport type TrackableEvent = z.infer<typeof TrackableEventSchema>;\n\n/**\n * Dispatchable events are the events that are sent to the platform. The route\n * handler injects the installationId and countryISOCode properties.\n */\nconst DispatchablePropertiesSchema = z.object({\n installationId: z.string(),\n countryISOCode: z.string(),\n});\n\n/**\n * The final schema for an analytics event. This is the schema that is used to\n * validate the event before it is inserted into the database. It is the\n * intersection of the trackable event and the dispatchable properties.\n */\nexport const AnalyticsEventSchema = z.intersection(\n TrackableEventSchema,\n DispatchablePropertiesSchema,\n);\nexport type analyticsEvent = z.infer<typeof AnalyticsEventSchema>;\n\ntype GeoData = {\n status: 'success' | 'fail';\n countryCode: string;\n message: string;\n};\n\nexport const createRouteHandler = ({\n platformUrl = 'https://analytics.networkcanvas.com',\n installationId,\n}: {\n platformUrl?: string;\n installationId: string;\n}) => {\n return async (request: NextRequest) => {\n try {\n const incomingEvent = (await request.json()) as unknown;\n\n // Check if analytics is disabled\n // eslint-disable-next-line no-process-env, turbo/no-undeclared-env-vars\n if (process.env.DISABLE_ANALYTICS) {\n // eslint-disable-next-line no-console\n console.info('🛑 Analytics disabled. Payload not sent.');\n try {\n // eslint-disable-next-line no-console\n console.info(\n 'Payload:',\n '\\n',\n JSON.stringify(incomingEvent, null, 2),\n );\n } catch (e) {\n // eslint-disable-next-line no-console\n console.error('Error stringifying payload:', e);\n }\n\n return NextResponse.json(\n { message: 'Analytics disabled' },\n { status: 200 },\n );\n }\n\n // Validate the event\n const trackableEvent = TrackableEventSchema.safeParse(incomingEvent);\n\n if (!trackableEvent.success) {\n // eslint-disable-next-line no-console\n console.error('Invalid event:', trackableEvent.error);\n return NextResponse.json({ error: 'Invalid event' }, { status: 400 });\n }\n\n // We don't want failures in third party services to prevent us from\n // tracking analytics events, so we'll catch any errors and log them\n // and continue with an 'Unknown' country code.\n let countryISOCode = 'Unknown';\n try {\n const ip = await fetch('https://api64.ipify.org').then((res) =>\n res.text(),\n );\n\n if (!ip) {\n throw new Error('Could not fetch IP address');\n }\n\n const geoData = (await fetch(`http://ip-api.com/json/${ip}`).then(\n (res) => res.json(),\n )) as GeoData;\n\n if (geoData.status === 'success') {\n countryISOCode = geoData.countryCode;\n } else {\n throw new Error(geoData.message);\n }\n } catch (e) {\n // eslint-disable-next-line no-console\n console.error('Geolocation failed:', e);\n }\n\n const analyticsEvent: analyticsEvent = {\n ...trackableEvent.data,\n installationId,\n countryISOCode,\n };\n\n // Forward to backend\n const response = await fetch(`${platformUrl}/api/event`, {\n keepalive: true,\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(analyticsEvent),\n });\n\n if (!response.ok) {\n let error = `Analytics platform returned an unexpected error: ${response.statusText}`;\n\n if (response.status === 400) {\n error = `Analytics platform rejected the event as invalid. Please check the event schema`;\n }\n\n if (response.status === 404) {\n error = `Analytics platform could not be reached. Please specify a valid platform URL, or check that the platform is online.`;\n }\n\n if (response.status === 500) {\n error = `Analytics platform returned an internal server error. Please check the platform logs.`;\n }\n\n // eslint-disable-next-line no-console\n console.info(`⚠️ Analytics platform rejected event: ${error}`);\n return Response.json(\n {\n error,\n },\n { status: 500 },\n );\n }\n // eslint-disable-next-line no-console\n console.info('🚀 Analytics event sent to platform!');\n return Response.json({ message: 'Event forwarded successfully' });\n } catch (e) {\n const error = ensureError(e);\n // eslint-disable-next-line no-console\n console.info('🚫 Internal error with sending analytics event.');\n\n return Response.json(\n { error: `Error in analytics route handler: ${error.message}` },\n { status: 500 },\n );\n }\n };\n};\n\nexport const makeEventTracker =\n ({ endpoint = '/api/analytics' }: { endpoint?: string }) =>\n async (\n event: RawEvent,\n ): Promise<{\n error: string | null;\n success: boolean;\n }> => {\n const endpointWithHost = getBaseUrl() + endpoint;\n\n const eventWithTimeStamp: TrackableEvent = {\n ...event,\n timestamp: new Date().toJSON(),\n };\n\n try {\n const response = await fetch(endpointWithHost, {\n method: 'POST',\n keepalive: true,\n body: JSON.stringify(eventWithTimeStamp),\n headers: {\n 'Content-Type': 'application/json',\n },\n });\n\n if (!response.ok) {\n if (response.status === 404) {\n return {\n error: `Analytics endpoint not found, did you forget to add the route?`,\n success: false,\n };\n }\n\n // createRouteHandler will return a 400 if the event failed schema validation.\n if (response.status === 400) {\n return {\n error: `Invalid event sent to analytics endpoint: ${response.statusText}`,\n success: false,\n };\n }\n\n // createRouteHandler will return a 500 for all error states\n return {\n error: `Internal server error when sending analytics event: ${response.statusText}. Check the route handler implementation.`,\n success: false,\n };\n }\n\n return { error: null, success: true };\n } catch (e) {\n const error = ensureError(e);\n return {\n error: `Internal error when sending analytics event: ${error.message}`,\n success: false,\n };\n }\n };\n","/* eslint-disable no-process-env */\n// Helper function that ensures that a value is an Error\nexport function ensureError(value: unknown): Error {\n if (!value) return new Error('No value was thrown');\n\n if (value instanceof Error) return value;\n\n // Test if value inherits from Error\n if (Object.prototype.isPrototypeOf.call(value, Error))\n return value as Error & typeof value;\n\n let stringified = '[Unable to stringify the thrown value]';\n try {\n stringified = JSON.stringify(value);\n } catch (e) {\n // eslint-disable-next-line no-console\n console.error(e);\n }\n\n const error = new Error(\n `This value was thrown as is, not through an Error: ${stringified}`,\n );\n return error;\n}\n\nexport function getBaseUrl() {\n if (typeof window !== 'undefined')\n // browser should use relative path\n return '';\n\n if (process.env.VERCEL_URL)\n // reference for vercel.com\n return `https://${process.env.VERCEL_URL}`;\n\n if (process.env.NEXT_PUBLIC_URL)\n // Manually set deployment URL from env\n return process.env.NEXT_PUBLIC_URL;\n\n // assume localhost\n return `http://127.0.0.1:3000`;\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codaco/analytics",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "6.0.0-alpha.2",
|
|
4
|
+
"packageManager": "pnpm@8.15.5+sha256.4b4efa12490e5055d59b9b9fc9438b7d581a6b7af3b5675eb5c5f447cee1a589",
|
|
4
5
|
"type": "module",
|
|
5
6
|
"main": "./dist/index.js",
|
|
6
7
|
"types": "./dist/index.d.ts",
|
|
7
|
-
"author": "Complex Data Collective <
|
|
8
|
+
"author": "Complex Data Collective <hello@complexdatacollective.org>",
|
|
8
9
|
"description": "Utilities for tracking analytics and error reporting in Fresco",
|
|
9
10
|
"scripts": {
|
|
10
11
|
"build": "tsup src/index.ts --format esm --dts --clean --sourcemap",
|
|
@@ -12,15 +13,25 @@
|
|
|
12
13
|
"dev": "npm run build -- --watch"
|
|
13
14
|
},
|
|
14
15
|
"peerDependencies": {
|
|
15
|
-
"next": "13 || 14"
|
|
16
|
+
"next": "13 || 14 || 15"
|
|
16
17
|
},
|
|
17
18
|
"devDependencies": {
|
|
18
|
-
"eslint-config
|
|
19
|
-
"tsconfig": "workspace:*",
|
|
20
|
-
"
|
|
21
|
-
"
|
|
19
|
+
"@codaco/eslint-config": "workspace:*",
|
|
20
|
+
"@codaco/tsconfig": "workspace:*",
|
|
21
|
+
"eslint": "^8.57.0",
|
|
22
|
+
"prettier": "^3.2.5",
|
|
23
|
+
"tsup": "^8.0.2",
|
|
24
|
+
"typescript": "^5.4.5"
|
|
22
25
|
},
|
|
23
26
|
"dependencies": {
|
|
24
|
-
"zod": "^3.
|
|
27
|
+
"zod": "^3.23.8"
|
|
28
|
+
},
|
|
29
|
+
"eslintConfig": {
|
|
30
|
+
"root": true,
|
|
31
|
+
"extends": [
|
|
32
|
+
"@codaco/eslint-config/base",
|
|
33
|
+
"@codaco/eslint-config/nextjs",
|
|
34
|
+
"@codaco/eslint-config/react"
|
|
35
|
+
]
|
|
25
36
|
}
|
|
26
37
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { type NextRequest } from
|
|
2
|
-
import { ensureError, getBaseUrl } from
|
|
3
|
-
import z from
|
|
1
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
|
+
import { ensureError, getBaseUrl, strictBooleanSchema } from './utils';
|
|
3
|
+
import z from 'zod';
|
|
4
4
|
|
|
5
5
|
// Todo: it would be great to work out a way to support arbitrary types here.
|
|
6
6
|
export const eventTypes = [
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
'AppSetup',
|
|
8
|
+
'ProtocolInstalled',
|
|
9
|
+
'InterviewStarted',
|
|
10
|
+
'InterviewCompleted',
|
|
11
|
+
'DataExported',
|
|
12
12
|
] as const;
|
|
13
13
|
|
|
14
14
|
const EventSchema = z.object({
|
|
@@ -16,7 +16,7 @@ const EventSchema = z.object({
|
|
|
16
16
|
});
|
|
17
17
|
|
|
18
18
|
const ErrorSchema = z.object({
|
|
19
|
-
type: z.literal(
|
|
19
|
+
type: z.literal('Error'),
|
|
20
20
|
message: z.string(),
|
|
21
21
|
name: z.string(),
|
|
22
22
|
stack: z.string().optional(),
|
|
@@ -32,7 +32,7 @@ const SharedEventAndErrorSchema = z.object({
|
|
|
32
32
|
* events or errors. We discriminate on the `type` property to determine which
|
|
33
33
|
* schema to use, and then merge the shared properties.
|
|
34
34
|
*/
|
|
35
|
-
export const RawEventSchema = z.discriminatedUnion(
|
|
35
|
+
export const RawEventSchema = z.discriminatedUnion('type', [
|
|
36
36
|
SharedEventAndErrorSchema.merge(EventSchema),
|
|
37
37
|
SharedEventAndErrorSchema.merge(ErrorSchema),
|
|
38
38
|
]);
|
|
@@ -49,7 +49,7 @@ const TrackablePropertiesSchema = z.object({
|
|
|
49
49
|
|
|
50
50
|
export const TrackableEventSchema = z.intersection(
|
|
51
51
|
RawEventSchema,
|
|
52
|
-
TrackablePropertiesSchema
|
|
52
|
+
TrackablePropertiesSchema,
|
|
53
53
|
);
|
|
54
54
|
export type TrackableEvent = z.infer<typeof TrackableEventSchema>;
|
|
55
55
|
|
|
@@ -69,12 +69,18 @@ const DispatchablePropertiesSchema = z.object({
|
|
|
69
69
|
*/
|
|
70
70
|
export const AnalyticsEventSchema = z.intersection(
|
|
71
71
|
TrackableEventSchema,
|
|
72
|
-
DispatchablePropertiesSchema
|
|
72
|
+
DispatchablePropertiesSchema,
|
|
73
73
|
);
|
|
74
74
|
export type analyticsEvent = z.infer<typeof AnalyticsEventSchema>;
|
|
75
75
|
|
|
76
|
+
type GeoData = {
|
|
77
|
+
status: 'success' | 'fail';
|
|
78
|
+
countryCode: string;
|
|
79
|
+
message: string;
|
|
80
|
+
};
|
|
81
|
+
|
|
76
82
|
export const createRouteHandler = ({
|
|
77
|
-
platformUrl =
|
|
83
|
+
platformUrl = 'https://analytics.networkcanvas.com',
|
|
78
84
|
installationId,
|
|
79
85
|
}: {
|
|
80
86
|
platformUrl?: string;
|
|
@@ -84,41 +90,67 @@ export const createRouteHandler = ({
|
|
|
84
90
|
try {
|
|
85
91
|
const incomingEvent = (await request.json()) as unknown;
|
|
86
92
|
|
|
93
|
+
const disableAnalytics = strictBooleanSchema.parse(
|
|
94
|
+
// eslint-disable-next-line no-process-env
|
|
95
|
+
process.env.DISABLE_ANALYTICS,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// Check if analytics is disabled
|
|
99
|
+
if (disableAnalytics) {
|
|
100
|
+
// eslint-disable-next-line no-console
|
|
101
|
+
console.info('🛑 Analytics disabled. Payload not sent.');
|
|
102
|
+
try {
|
|
103
|
+
// eslint-disable-next-line no-console
|
|
104
|
+
console.info(
|
|
105
|
+
'Payload:',
|
|
106
|
+
'\n',
|
|
107
|
+
JSON.stringify(incomingEvent, null, 2),
|
|
108
|
+
);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
// eslint-disable-next-line no-console
|
|
111
|
+
console.error('Error stringifying payload:', e);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return NextResponse.json(
|
|
115
|
+
{ message: 'Analytics disabled' },
|
|
116
|
+
{ status: 200 },
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
87
120
|
// Validate the event
|
|
88
121
|
const trackableEvent = TrackableEventSchema.safeParse(incomingEvent);
|
|
89
122
|
|
|
90
123
|
if (!trackableEvent.success) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
headers: {
|
|
95
|
-
"Content-Type": "application/json",
|
|
96
|
-
},
|
|
97
|
-
});
|
|
124
|
+
// eslint-disable-next-line no-console
|
|
125
|
+
console.error('Invalid event:', trackableEvent.error);
|
|
126
|
+
return NextResponse.json({ error: 'Invalid event' }, { status: 400 });
|
|
98
127
|
}
|
|
99
128
|
|
|
100
129
|
// We don't want failures in third party services to prevent us from
|
|
101
130
|
// tracking analytics events, so we'll catch any errors and log them
|
|
102
131
|
// and continue with an 'Unknown' country code.
|
|
103
|
-
let countryISOCode =
|
|
132
|
+
let countryISOCode = 'Unknown';
|
|
104
133
|
try {
|
|
105
|
-
const ip = await fetch(
|
|
106
|
-
res.text()
|
|
134
|
+
const ip = await fetch('https://api64.ipify.org').then((res) =>
|
|
135
|
+
res.text(),
|
|
107
136
|
);
|
|
108
137
|
|
|
109
138
|
if (!ip) {
|
|
110
|
-
throw new Error(
|
|
139
|
+
throw new Error('Could not fetch IP address');
|
|
111
140
|
}
|
|
112
141
|
|
|
113
|
-
const geoData = await fetch(`http://ip-api.com/json/${ip}`).then(
|
|
142
|
+
const geoData = (await fetch(`http://ip-api.com/json/${ip}`).then(
|
|
143
|
+
(res) => res.json(),
|
|
144
|
+
)) as GeoData;
|
|
114
145
|
|
|
115
|
-
if(geoData.status ===
|
|
146
|
+
if (geoData.status === 'success') {
|
|
116
147
|
countryISOCode = geoData.countryCode;
|
|
117
148
|
} else {
|
|
118
149
|
throw new Error(geoData.message);
|
|
119
150
|
}
|
|
120
151
|
} catch (e) {
|
|
121
|
-
|
|
152
|
+
// eslint-disable-next-line no-console
|
|
153
|
+
console.error('Geolocation failed:', e);
|
|
122
154
|
}
|
|
123
155
|
|
|
124
156
|
const analyticsEvent: analyticsEvent = {
|
|
@@ -130,9 +162,9 @@ export const createRouteHandler = ({
|
|
|
130
162
|
// Forward to backend
|
|
131
163
|
const response = await fetch(`${platformUrl}/api/event`, {
|
|
132
164
|
keepalive: true,
|
|
133
|
-
method:
|
|
165
|
+
method: 'POST',
|
|
134
166
|
headers: {
|
|
135
|
-
|
|
167
|
+
'Content-Type': 'application/json',
|
|
136
168
|
},
|
|
137
169
|
body: JSON.stringify(analyticsEvent),
|
|
138
170
|
});
|
|
@@ -152,48 +184,41 @@ export const createRouteHandler = ({
|
|
|
152
184
|
error = `Analytics platform returned an internal server error. Please check the platform logs.`;
|
|
153
185
|
}
|
|
154
186
|
|
|
187
|
+
// eslint-disable-next-line no-console
|
|
155
188
|
console.info(`⚠️ Analytics platform rejected event: ${error}`);
|
|
156
189
|
return Response.json(
|
|
157
190
|
{
|
|
158
191
|
error,
|
|
159
192
|
},
|
|
160
|
-
{ status: 500 }
|
|
193
|
+
{ status: 500 },
|
|
161
194
|
);
|
|
162
195
|
}
|
|
163
|
-
|
|
164
|
-
|
|
196
|
+
// eslint-disable-next-line no-console
|
|
197
|
+
console.info('🚀 Analytics event sent to platform!');
|
|
198
|
+
return Response.json({ message: 'Event forwarded successfully' });
|
|
165
199
|
} catch (e) {
|
|
166
200
|
const error = ensureError(e);
|
|
167
|
-
|
|
201
|
+
// eslint-disable-next-line no-console
|
|
202
|
+
console.info('🚫 Internal error with sending analytics event.');
|
|
168
203
|
|
|
169
204
|
return Response.json(
|
|
170
205
|
{ error: `Error in analytics route handler: ${error.message}` },
|
|
171
|
-
{ status: 500 }
|
|
206
|
+
{ status: 500 },
|
|
172
207
|
);
|
|
173
208
|
}
|
|
174
209
|
};
|
|
175
210
|
};
|
|
176
211
|
|
|
177
212
|
export const makeEventTracker =
|
|
178
|
-
({
|
|
179
|
-
enabled = false,
|
|
180
|
-
endpoint = "/api/analytics",
|
|
181
|
-
}: {
|
|
182
|
-
enabled?: boolean;
|
|
183
|
-
endpoint?: string;
|
|
184
|
-
}) =>
|
|
213
|
+
(options?: { endpoint?: string }) =>
|
|
185
214
|
async (
|
|
186
|
-
event: RawEvent
|
|
215
|
+
event: RawEvent,
|
|
187
216
|
): Promise<{
|
|
188
217
|
error: string | null;
|
|
189
218
|
success: boolean;
|
|
190
219
|
}> => {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
return { error: null, success: true };
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const endpointWithHost = getBaseUrl() + endpoint;
|
|
220
|
+
const endpointWithHost =
|
|
221
|
+
getBaseUrl() + options?.endpoint ?? '/api/analytics';
|
|
197
222
|
|
|
198
223
|
const eventWithTimeStamp: TrackableEvent = {
|
|
199
224
|
...event,
|
|
@@ -202,11 +227,11 @@ export const makeEventTracker =
|
|
|
202
227
|
|
|
203
228
|
try {
|
|
204
229
|
const response = await fetch(endpointWithHost, {
|
|
205
|
-
method:
|
|
230
|
+
method: 'POST',
|
|
206
231
|
keepalive: true,
|
|
207
232
|
body: JSON.stringify(eventWithTimeStamp),
|
|
208
233
|
headers: {
|
|
209
|
-
|
|
234
|
+
'Content-Type': 'application/json',
|
|
210
235
|
},
|
|
211
236
|
});
|
|
212
237
|
|
package/src/utils.ts
CHANGED
|
@@ -1,36 +1,49 @@
|
|
|
1
|
+
/* eslint-disable no-process-env */
|
|
2
|
+
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
1
5
|
// Helper function that ensures that a value is an Error
|
|
2
6
|
export function ensureError(value: unknown): Error {
|
|
3
|
-
if (!value) return new Error(
|
|
7
|
+
if (!value) return new Error('No value was thrown');
|
|
4
8
|
|
|
5
9
|
if (value instanceof Error) return value;
|
|
6
10
|
|
|
7
11
|
// Test if value inherits from Error
|
|
8
|
-
if (
|
|
12
|
+
if (Object.prototype.isPrototypeOf.call(value, Error))
|
|
13
|
+
return value as Error & typeof value;
|
|
9
14
|
|
|
10
|
-
let stringified =
|
|
15
|
+
let stringified = '[Unable to stringify the thrown value]';
|
|
11
16
|
try {
|
|
12
17
|
stringified = JSON.stringify(value);
|
|
13
|
-
} catch {
|
|
18
|
+
} catch (e) {
|
|
19
|
+
// eslint-disable-next-line no-console
|
|
20
|
+
console.error(e);
|
|
21
|
+
}
|
|
14
22
|
|
|
15
23
|
const error = new Error(
|
|
16
|
-
`This value was thrown as is, not through an Error: ${stringified}
|
|
24
|
+
`This value was thrown as is, not through an Error: ${stringified}`,
|
|
17
25
|
);
|
|
18
26
|
return error;
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
export function getBaseUrl() {
|
|
22
|
-
if (typeof window !== "undefined")
|
|
23
|
-
// browser should use relative path
|
|
24
|
-
return "";
|
|
25
|
-
|
|
26
30
|
if (process.env.VERCEL_URL)
|
|
27
31
|
// reference for vercel.com
|
|
28
32
|
return `https://${process.env.VERCEL_URL}`;
|
|
29
33
|
|
|
30
|
-
if (process.env.
|
|
34
|
+
if (process.env.PUBLIC_URL)
|
|
31
35
|
// Manually set deployment URL from env
|
|
32
|
-
return process.env.
|
|
36
|
+
return process.env.PUBLIC_URL;
|
|
33
37
|
|
|
34
38
|
// assume localhost
|
|
35
39
|
return `http://127.0.0.1:3000`;
|
|
36
40
|
}
|
|
41
|
+
|
|
42
|
+
// this is a workaround for this issue:https://github.com/colinhacks/zod/issues/1630
|
|
43
|
+
// z.coerce.boolean() doesn't work as expected
|
|
44
|
+
export const strictBooleanSchema = z
|
|
45
|
+
.enum(['true', 'false', 'True', 'False', 'TRUE', 'FALSE'])
|
|
46
|
+
.default('false')
|
|
47
|
+
.transform(
|
|
48
|
+
(value) => value === 'true' || value === 'True' || value === 'TRUE',
|
|
49
|
+
);
|
package/tsconfig.json
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"extends": "tsconfig/
|
|
3
|
-
"
|
|
4
|
-
"."
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
]
|
|
11
|
-
}
|
|
2
|
+
"extends": "@codaco/tsconfig/base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"baseUrl": ".",
|
|
5
|
+
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
|
6
|
+
},
|
|
7
|
+
"include": ["."],
|
|
8
|
+
"exclude": ["dist", "build", "node_modules"]
|
|
9
|
+
}
|
package/.eslintrc.js
DELETED