@aamini/lib 0.0.1
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/env.d.mts +7 -0
- package/dist/env.d.mts.map +1 -0
- package/dist/env.mjs +27 -0
- package/dist/env.mjs.map +1 -0
- package/dist/posthog-proxy.d.mts +10 -0
- package/dist/posthog-proxy.d.mts.map +1 -0
- package/dist/posthog-proxy.mjs +58 -0
- package/dist/posthog-proxy.mjs.map +1 -0
- package/package.json +47 -0
- package/src/env.ts +31 -0
- package/src/posthog-proxy.ts +95 -0
package/dist/env.d.mts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"env.d.mts","names":[],"sources":["../src/env.ts"],"mappings":";;;iBAGgB,SAAA,WAAoB,UAAA,CAAA,CAAY,MAAA,EAAQ,CAAA,GAAI,CAAA,CAAE,KAAA,CAAM,CAAA"}
|
package/dist/env.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { config } from "dotenv";
|
|
2
|
+
//#region src/env.ts
|
|
3
|
+
function createEnv(schema) {
|
|
4
|
+
const rawEnvironmentName = process.env.RAILWAY_ENVIRONMENT_NAME ?? process.env.NODE_ENV ?? "development";
|
|
5
|
+
const environmentName = /(?:^|-)pr-\d+$/.test(rawEnvironmentName) ? "staging" : rawEnvironmentName;
|
|
6
|
+
const fileEnvironment = {};
|
|
7
|
+
config({
|
|
8
|
+
path: [
|
|
9
|
+
`.env.${environmentName}`,
|
|
10
|
+
".env",
|
|
11
|
+
`.env.${environmentName}.local`,
|
|
12
|
+
".env.local"
|
|
13
|
+
],
|
|
14
|
+
quiet: true,
|
|
15
|
+
override: true,
|
|
16
|
+
processEnv: fileEnvironment
|
|
17
|
+
});
|
|
18
|
+
for (const [key, value] of Object.entries(fileEnvironment)) process.env[key] ??= value;
|
|
19
|
+
return schema.parse({
|
|
20
|
+
...process.env,
|
|
21
|
+
...fileEnvironment
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
25
|
+
export { createEnv };
|
|
26
|
+
|
|
27
|
+
//# sourceMappingURL=env.mjs.map
|
package/dist/env.mjs.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"env.mjs","names":[],"sources":["../src/env.ts"],"sourcesContent":["import { config } from 'dotenv'\nimport type { ZodTypeAny, z } from 'zod'\n\nexport function createEnv<T extends ZodTypeAny>(schema: T): z.infer<T> {\n\tconst rawEnvironmentName =\n\t\tprocess.env.RAILWAY_ENVIRONMENT_NAME ??\n\t\tprocess.env.NODE_ENV ??\n\t\t'development'\n\tconst environmentName = /(?:^|-)pr-\\d+$/.test(rawEnvironmentName)\n\t\t? 'staging'\n\t\t: rawEnvironmentName\n\tconst fileEnvironment: Record<string, string> = {}\n\n\tconfig({\n\t\tpath: [\n\t\t\t`.env.${environmentName}`,\n\t\t\t'.env',\n\t\t\t`.env.${environmentName}.local`,\n\t\t\t'.env.local',\n\t\t],\n\t\tquiet: true,\n\t\toverride: true,\n\t\tprocessEnv: fileEnvironment,\n\t})\n\n\tfor (const [key, value] of Object.entries(fileEnvironment)) {\n\t\tprocess.env[key] ??= value\n\t}\n\n\treturn schema.parse({ ...process.env, ...fileEnvironment })\n}\n"],"mappings":";;AAGA,SAAgB,UAAgC,QAAuB;CACtE,MAAM,qBACL,QAAQ,IAAI,4BACZ,QAAQ,IAAI,YACZ;CACD,MAAM,kBAAkB,iBAAiB,KAAK,mBAAmB,GAC9D,YACA;CACH,MAAM,kBAA0C,EAAE;CAElD,OAAO;EACN,MAAM;GACL,QAAQ;GACR;GACA,QAAQ,gBAAgB;GACxB;GACA;EACD,OAAO;EACP,UAAU;EACV,YAAY;EACZ,CAAC;CAEF,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,gBAAgB,EACzD,QAAQ,IAAI,SAAS;CAGtB,OAAO,OAAO,MAAM;EAAE,GAAG,QAAQ;EAAK,GAAG;EAAiB,CAAC"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
//#region src/posthog-proxy.d.ts
|
|
2
|
+
type PostHogProxyOptions = {
|
|
3
|
+
publicPathPrefix: string;
|
|
4
|
+
upstreamOrigin: string;
|
|
5
|
+
upstreamPathPrefix?: string;
|
|
6
|
+
};
|
|
7
|
+
declare function createPostHogProxyRequestHandler(options: PostHogProxyOptions): (request: Request) => Promise<Response>;
|
|
8
|
+
//#endregion
|
|
9
|
+
export { createPostHogProxyRequestHandler };
|
|
10
|
+
//# sourceMappingURL=posthog-proxy.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"posthog-proxy.d.mts","names":[],"sources":["../src/posthog-proxy.ts"],"mappings":";KAAK,mBAAA;EACJ,gBAAA;EACA,cAAA;EACA,kBAAA;AAAA;AAAA,iBAce,gCAAA,CAAiC,OAAA,EAAS,mBAAA,IACf,OAAA,EAAS,OAAA,KAAO,OAAA,CAAA,QAAA"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
//#region src/posthog-proxy.ts
|
|
2
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
3
|
+
"connection",
|
|
4
|
+
"keep-alive",
|
|
5
|
+
"proxy-authenticate",
|
|
6
|
+
"proxy-authorization",
|
|
7
|
+
"te",
|
|
8
|
+
"trailer",
|
|
9
|
+
"transfer-encoding",
|
|
10
|
+
"upgrade"
|
|
11
|
+
]);
|
|
12
|
+
function createPostHogProxyRequestHandler(options) {
|
|
13
|
+
return async function proxyPostHogRequest(request) {
|
|
14
|
+
const requestUrl = new URL(request.url);
|
|
15
|
+
const upstreamPath = requestUrl.pathname.startsWith(options.publicPathPrefix) ? requestUrl.pathname.slice(options.publicPathPrefix.length) : "";
|
|
16
|
+
const upstreamUrl = new URL(buildUpstreamPath(options, upstreamPath) + requestUrl.search, trimTrailingSlash(options.upstreamOrigin) + "/");
|
|
17
|
+
const headers = new Headers(request.headers);
|
|
18
|
+
const connectionHeader = headers.get("connection");
|
|
19
|
+
for (const header of HOP_BY_HOP_HEADERS) headers.delete(header);
|
|
20
|
+
if (connectionHeader) for (const token of connectionHeader.split(",")) {
|
|
21
|
+
const header = token.trim().toLowerCase();
|
|
22
|
+
if (header) headers.delete(header);
|
|
23
|
+
}
|
|
24
|
+
headers.delete("host");
|
|
25
|
+
const requestInit = {
|
|
26
|
+
method: request.method,
|
|
27
|
+
headers,
|
|
28
|
+
redirect: "follow"
|
|
29
|
+
};
|
|
30
|
+
if (request.method !== "GET" && request.method !== "HEAD") requestInit.body = await request.arrayBuffer();
|
|
31
|
+
const upstreamResponse = await fetch(upstreamUrl, requestInit);
|
|
32
|
+
return new Response(upstreamResponse.body, {
|
|
33
|
+
status: upstreamResponse.status,
|
|
34
|
+
statusText: upstreamResponse.statusText,
|
|
35
|
+
headers: upstreamResponse.headers
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function buildUpstreamPath(options, upstreamPath) {
|
|
40
|
+
const prefix = trimSlashes(options.upstreamPathPrefix ?? "");
|
|
41
|
+
const path = trimLeadingSlash(upstreamPath);
|
|
42
|
+
if (!prefix) return path;
|
|
43
|
+
if (!path) return prefix;
|
|
44
|
+
return `${prefix}/${path}`;
|
|
45
|
+
}
|
|
46
|
+
function trimLeadingSlash(value) {
|
|
47
|
+
return value.replace(/^\/+/, "");
|
|
48
|
+
}
|
|
49
|
+
function trimSlashes(value) {
|
|
50
|
+
return value.replace(/^\/+|\/+$/g, "");
|
|
51
|
+
}
|
|
52
|
+
function trimTrailingSlash(value) {
|
|
53
|
+
return value.replace(/\/+$/, "");
|
|
54
|
+
}
|
|
55
|
+
//#endregion
|
|
56
|
+
export { createPostHogProxyRequestHandler };
|
|
57
|
+
|
|
58
|
+
//# sourceMappingURL=posthog-proxy.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"posthog-proxy.mjs","names":[],"sources":["../src/posthog-proxy.ts"],"sourcesContent":["type PostHogProxyOptions = {\n\tpublicPathPrefix: string\n\tupstreamOrigin: string\n\tupstreamPathPrefix?: string\n}\n\nconst HOP_BY_HOP_HEADERS = new Set([\n\t'connection',\n\t'keep-alive',\n\t'proxy-authenticate',\n\t'proxy-authorization',\n\t'te',\n\t'trailer',\n\t'transfer-encoding',\n\t'upgrade',\n])\n\nexport function createPostHogProxyRequestHandler(options: PostHogProxyOptions) {\n\treturn async function proxyPostHogRequest(request: Request) {\n\t\tconst requestUrl = new URL(request.url)\n\t\tconst upstreamPath = requestUrl.pathname.startsWith(\n\t\t\toptions.publicPathPrefix,\n\t\t)\n\t\t\t? requestUrl.pathname.slice(options.publicPathPrefix.length)\n\t\t\t: ''\n\t\tconst upstreamUrl = new URL(\n\t\t\tbuildUpstreamPath(options, upstreamPath) + requestUrl.search,\n\t\t\ttrimTrailingSlash(options.upstreamOrigin) + '/',\n\t\t)\n\t\tconst headers = new Headers(request.headers)\n\t\tconst connectionHeader = headers.get('connection')\n\n\t\t// Strip hop-by-hop headers before proxying: RFC 9110 §7.6.\n\t\t// https://www.rfc-editor.org/rfc/rfc9110.html#name-connection-specific-header\n\t\tfor (const header of HOP_BY_HOP_HEADERS) {\n\t\t\theaders.delete(header)\n\t\t}\n\n\t\tif (connectionHeader) {\n\t\t\tfor (const token of connectionHeader.split(',')) {\n\t\t\t\tconst header = token.trim().toLowerCase()\n\t\t\t\tif (header) {\n\t\t\t\t\theaders.delete(header)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\theaders.delete('host')\n\n\t\tconst requestInit: RequestInit = {\n\t\t\tmethod: request.method,\n\t\t\theaders,\n\t\t\tredirect: 'follow',\n\t\t}\n\n\t\tif (request.method !== 'GET' && request.method !== 'HEAD') {\n\t\t\trequestInit.body = await request.arrayBuffer()\n\t\t}\n\n\t\tconst upstreamResponse = await fetch(upstreamUrl, requestInit)\n\n\t\treturn new Response(upstreamResponse.body, {\n\t\t\tstatus: upstreamResponse.status,\n\t\t\tstatusText: upstreamResponse.statusText,\n\t\t\theaders: upstreamResponse.headers,\n\t\t})\n\t}\n}\n\nfunction buildUpstreamPath(options: PostHogProxyOptions, upstreamPath: string) {\n\tconst prefix = trimSlashes(options.upstreamPathPrefix ?? '')\n\tconst path = trimLeadingSlash(upstreamPath)\n\n\tif (!prefix) {\n\t\treturn path\n\t}\n\n\tif (!path) {\n\t\treturn prefix\n\t}\n\n\treturn `${prefix}/${path}`\n}\n\nfunction trimLeadingSlash(value: string) {\n\treturn value.replace(/^\\/+/, '')\n}\n\nfunction trimSlashes(value: string) {\n\treturn value.replace(/^\\/+|\\/+$/g, '')\n}\n\nfunction trimTrailingSlash(value: string) {\n\treturn value.replace(/\\/+$/, '')\n}\n"],"mappings":";AAMA,MAAM,qBAAqB,IAAI,IAAI;CAClC;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA,CAAC;AAEF,SAAgB,iCAAiC,SAA8B;CAC9E,OAAO,eAAe,oBAAoB,SAAkB;EAC3D,MAAM,aAAa,IAAI,IAAI,QAAQ,IAAI;EACvC,MAAM,eAAe,WAAW,SAAS,WACxC,QAAQ,iBACR,GACE,WAAW,SAAS,MAAM,QAAQ,iBAAiB,OAAO,GAC1D;EACH,MAAM,cAAc,IAAI,IACvB,kBAAkB,SAAS,aAAa,GAAG,WAAW,QACtD,kBAAkB,QAAQ,eAAe,GAAG,IAC5C;EACD,MAAM,UAAU,IAAI,QAAQ,QAAQ,QAAQ;EAC5C,MAAM,mBAAmB,QAAQ,IAAI,aAAa;EAIlD,KAAK,MAAM,UAAU,oBACpB,QAAQ,OAAO,OAAO;EAGvB,IAAI,kBACH,KAAK,MAAM,SAAS,iBAAiB,MAAM,IAAI,EAAE;GAChD,MAAM,SAAS,MAAM,MAAM,CAAC,aAAa;GACzC,IAAI,QACH,QAAQ,OAAO,OAAO;;EAKzB,QAAQ,OAAO,OAAO;EAEtB,MAAM,cAA2B;GAChC,QAAQ,QAAQ;GAChB;GACA,UAAU;GACV;EAED,IAAI,QAAQ,WAAW,SAAS,QAAQ,WAAW,QAClD,YAAY,OAAO,MAAM,QAAQ,aAAa;EAG/C,MAAM,mBAAmB,MAAM,MAAM,aAAa,YAAY;EAE9D,OAAO,IAAI,SAAS,iBAAiB,MAAM;GAC1C,QAAQ,iBAAiB;GACzB,YAAY,iBAAiB;GAC7B,SAAS,iBAAiB;GAC1B,CAAC;;;AAIJ,SAAS,kBAAkB,SAA8B,cAAsB;CAC9E,MAAM,SAAS,YAAY,QAAQ,sBAAsB,GAAG;CAC5D,MAAM,OAAO,iBAAiB,aAAa;CAE3C,IAAI,CAAC,QACJ,OAAO;CAGR,IAAI,CAAC,MACJ,OAAO;CAGR,OAAO,GAAG,OAAO,GAAG;;AAGrB,SAAS,iBAAiB,OAAe;CACxC,OAAO,MAAM,QAAQ,QAAQ,GAAG;;AAGjC,SAAS,YAAY,OAAe;CACnC,OAAO,MAAM,QAAQ,cAAc,GAAG;;AAGvC,SAAS,kBAAkB,OAAe;CACzC,OAAO,MAAM,QAAQ,QAAQ,GAAG"}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aamini/lib",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/aamini-stack/projects.git",
|
|
9
|
+
"directory": "packages/lib"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/aamini-stack/projects/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/aamini-stack/projects/tree/main/packages/lib#readme",
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src"
|
|
21
|
+
],
|
|
22
|
+
"exports": {
|
|
23
|
+
"./env": {
|
|
24
|
+
"types": "./dist/env.d.mts",
|
|
25
|
+
"import": "./dist/env.mjs",
|
|
26
|
+
"default": "./dist/env.mjs"
|
|
27
|
+
},
|
|
28
|
+
"./posthog-proxy": {
|
|
29
|
+
"types": "./dist/posthog-proxy.d.mts",
|
|
30
|
+
"import": "./dist/posthog-proxy.mjs",
|
|
31
|
+
"default": "./dist/posthog-proxy.mjs"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"pack": "vp pack",
|
|
36
|
+
"prepublishOnly": "vp pack",
|
|
37
|
+
"check": "vp check"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"dotenv": "^17.4.2",
|
|
41
|
+
"zod": "^4.3.6"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@aamini/config": "workspace:*",
|
|
45
|
+
"typescript": "6.0.2"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { config } from 'dotenv'
|
|
2
|
+
import type { ZodTypeAny, z } from 'zod'
|
|
3
|
+
|
|
4
|
+
export function createEnv<T extends ZodTypeAny>(schema: T): z.infer<T> {
|
|
5
|
+
const rawEnvironmentName =
|
|
6
|
+
process.env.RAILWAY_ENVIRONMENT_NAME ??
|
|
7
|
+
process.env.NODE_ENV ??
|
|
8
|
+
'development'
|
|
9
|
+
const environmentName = /(?:^|-)pr-\d+$/.test(rawEnvironmentName)
|
|
10
|
+
? 'staging'
|
|
11
|
+
: rawEnvironmentName
|
|
12
|
+
const fileEnvironment: Record<string, string> = {}
|
|
13
|
+
|
|
14
|
+
config({
|
|
15
|
+
path: [
|
|
16
|
+
`.env.${environmentName}`,
|
|
17
|
+
'.env',
|
|
18
|
+
`.env.${environmentName}.local`,
|
|
19
|
+
'.env.local',
|
|
20
|
+
],
|
|
21
|
+
quiet: true,
|
|
22
|
+
override: true,
|
|
23
|
+
processEnv: fileEnvironment,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
for (const [key, value] of Object.entries(fileEnvironment)) {
|
|
27
|
+
process.env[key] ??= value
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return schema.parse({ ...process.env, ...fileEnvironment })
|
|
31
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
type PostHogProxyOptions = {
|
|
2
|
+
publicPathPrefix: string
|
|
3
|
+
upstreamOrigin: string
|
|
4
|
+
upstreamPathPrefix?: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
8
|
+
'connection',
|
|
9
|
+
'keep-alive',
|
|
10
|
+
'proxy-authenticate',
|
|
11
|
+
'proxy-authorization',
|
|
12
|
+
'te',
|
|
13
|
+
'trailer',
|
|
14
|
+
'transfer-encoding',
|
|
15
|
+
'upgrade',
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
export function createPostHogProxyRequestHandler(options: PostHogProxyOptions) {
|
|
19
|
+
return async function proxyPostHogRequest(request: Request) {
|
|
20
|
+
const requestUrl = new URL(request.url)
|
|
21
|
+
const upstreamPath = requestUrl.pathname.startsWith(
|
|
22
|
+
options.publicPathPrefix,
|
|
23
|
+
)
|
|
24
|
+
? requestUrl.pathname.slice(options.publicPathPrefix.length)
|
|
25
|
+
: ''
|
|
26
|
+
const upstreamUrl = new URL(
|
|
27
|
+
buildUpstreamPath(options, upstreamPath) + requestUrl.search,
|
|
28
|
+
trimTrailingSlash(options.upstreamOrigin) + '/',
|
|
29
|
+
)
|
|
30
|
+
const headers = new Headers(request.headers)
|
|
31
|
+
const connectionHeader = headers.get('connection')
|
|
32
|
+
|
|
33
|
+
// Strip hop-by-hop headers before proxying: RFC 9110 §7.6.
|
|
34
|
+
// https://www.rfc-editor.org/rfc/rfc9110.html#name-connection-specific-header
|
|
35
|
+
for (const header of HOP_BY_HOP_HEADERS) {
|
|
36
|
+
headers.delete(header)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (connectionHeader) {
|
|
40
|
+
for (const token of connectionHeader.split(',')) {
|
|
41
|
+
const header = token.trim().toLowerCase()
|
|
42
|
+
if (header) {
|
|
43
|
+
headers.delete(header)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
headers.delete('host')
|
|
49
|
+
|
|
50
|
+
const requestInit: RequestInit = {
|
|
51
|
+
method: request.method,
|
|
52
|
+
headers,
|
|
53
|
+
redirect: 'follow',
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
57
|
+
requestInit.body = await request.arrayBuffer()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const upstreamResponse = await fetch(upstreamUrl, requestInit)
|
|
61
|
+
|
|
62
|
+
return new Response(upstreamResponse.body, {
|
|
63
|
+
status: upstreamResponse.status,
|
|
64
|
+
statusText: upstreamResponse.statusText,
|
|
65
|
+
headers: upstreamResponse.headers,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function buildUpstreamPath(options: PostHogProxyOptions, upstreamPath: string) {
|
|
71
|
+
const prefix = trimSlashes(options.upstreamPathPrefix ?? '')
|
|
72
|
+
const path = trimLeadingSlash(upstreamPath)
|
|
73
|
+
|
|
74
|
+
if (!prefix) {
|
|
75
|
+
return path
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!path) {
|
|
79
|
+
return prefix
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return `${prefix}/${path}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function trimLeadingSlash(value: string) {
|
|
86
|
+
return value.replace(/^\/+/, '')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function trimSlashes(value: string) {
|
|
90
|
+
return value.replace(/^\/+|\/+$/g, '')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function trimTrailingSlash(value: string) {
|
|
94
|
+
return value.replace(/\/+$/, '')
|
|
95
|
+
}
|