@pands/gateway 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +65 -0
- package/dist/index.js +465 -0
- package/package.json +47 -3
- package/README.md +0 -3
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import * as _pands_logger from '@pands/logger';
|
|
2
|
+
import * as hono_types from 'hono/types';
|
|
3
|
+
import { PandAuthInstance } from '@pands/auth';
|
|
4
|
+
import { PandConfig } from '@pands/config';
|
|
5
|
+
export { serveService } from '@pands/service';
|
|
6
|
+
import { Hono } from 'hono';
|
|
7
|
+
|
|
8
|
+
interface ServiceOpenApiInput {
|
|
9
|
+
service: string;
|
|
10
|
+
spec: Record<string, any>;
|
|
11
|
+
}
|
|
12
|
+
declare function mergeServiceOpenApiSpecs(serviceSpecs: ServiceOpenApiInput[], info?: {
|
|
13
|
+
title?: string;
|
|
14
|
+
version?: string;
|
|
15
|
+
}): Record<string, any>;
|
|
16
|
+
|
|
17
|
+
interface PublicClientConfig {
|
|
18
|
+
project: string;
|
|
19
|
+
displayName: string;
|
|
20
|
+
env: string;
|
|
21
|
+
apiBaseUrl: string;
|
|
22
|
+
authBaseUrl: string;
|
|
23
|
+
posthog?: {
|
|
24
|
+
publicKey: string;
|
|
25
|
+
host: string;
|
|
26
|
+
};
|
|
27
|
+
links?: {
|
|
28
|
+
grafanaUrl?: string;
|
|
29
|
+
posthogUrl?: string;
|
|
30
|
+
apiDocsUrl: string;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
interface ResolvedActor {
|
|
34
|
+
userId: string;
|
|
35
|
+
email: string;
|
|
36
|
+
role: "user" | "admin";
|
|
37
|
+
}
|
|
38
|
+
interface CreateGatewayAppOptions {
|
|
39
|
+
config: PandConfig;
|
|
40
|
+
serviceUrls?: Record<string, string>;
|
|
41
|
+
serviceApps?: Record<string, {
|
|
42
|
+
fetch: (req: Request) => Response | Promise<Response>;
|
|
43
|
+
}>;
|
|
44
|
+
auth?: PandAuthInstance;
|
|
45
|
+
databaseUrl?: string;
|
|
46
|
+
resolveSession?: (req: Request) => Promise<ResolvedActor | null>;
|
|
47
|
+
authHandler?: (req: Request) => Response | Promise<Response>;
|
|
48
|
+
dbHealth?: () => Promise<string>;
|
|
49
|
+
internalSecret?: string;
|
|
50
|
+
posthogFetch?: typeof fetch;
|
|
51
|
+
staticRootDir?: string;
|
|
52
|
+
}
|
|
53
|
+
declare function resolveGatewayServiceUrls(config: PandConfig, explicitUrls?: Record<string, string>): Record<string, string>;
|
|
54
|
+
declare function createGatewayApp(options: CreateGatewayAppOptions): Hono<{
|
|
55
|
+
Variables: {
|
|
56
|
+
requestId: string;
|
|
57
|
+
};
|
|
58
|
+
}, hono_types.BlankSchema, "/"> & {
|
|
59
|
+
serviceName: string;
|
|
60
|
+
logger: _pands_logger.Logger;
|
|
61
|
+
registerAdminEntity: () => void;
|
|
62
|
+
getAdminEntities: () => never[];
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export { type CreateGatewayAppOptions, type PublicClientConfig, type ResolvedActor, type ServiceOpenApiInput, createGatewayApp, mergeServiceOpenApiSpecs, resolveGatewayServiceUrls };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import crypto from "crypto";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { authTables, createPandAuth, resolveRequestActor } from "@pands/auth";
|
|
6
|
+
import { resolveProjectPorts } from "@pands/config";
|
|
7
|
+
import { createDb } from "@pands/db";
|
|
8
|
+
import { createLogger } from "@pands/logger";
|
|
9
|
+
import { PandError, serveService, signInternalToken } from "@pands/service";
|
|
10
|
+
import { apiReference } from "@scalar/hono-api-reference";
|
|
11
|
+
import { Hono } from "hono";
|
|
12
|
+
|
|
13
|
+
// src/openapi-merge.ts
|
|
14
|
+
function toPascalCase(input) {
|
|
15
|
+
return input.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
16
|
+
}
|
|
17
|
+
function rewriteSchemaRefs(obj, renameMap) {
|
|
18
|
+
if (Array.isArray(obj)) {
|
|
19
|
+
return obj.map((item) => rewriteSchemaRefs(item, renameMap));
|
|
20
|
+
}
|
|
21
|
+
if (obj && typeof obj === "object") {
|
|
22
|
+
const out = {};
|
|
23
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
24
|
+
if (k === "$ref" && typeof v === "string" && v.startsWith("#/components/schemas/")) {
|
|
25
|
+
const schemaName = v.slice("#/components/schemas/".length);
|
|
26
|
+
const mapped = renameMap[schemaName] ?? schemaName;
|
|
27
|
+
out[k] = `#/components/schemas/${mapped}`;
|
|
28
|
+
} else {
|
|
29
|
+
out[k] = rewriteSchemaRefs(v, renameMap);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
return obj;
|
|
35
|
+
}
|
|
36
|
+
function mergeServiceOpenApiSpecs(serviceSpecs, info = {}) {
|
|
37
|
+
const mergedPaths = {};
|
|
38
|
+
const mergedSchemas = {};
|
|
39
|
+
const internalPaths = /* @__PURE__ */ new Set(["/healthz", "/readyz", "/openapi.json", "/_pand/meta"]);
|
|
40
|
+
for (const { service, spec } of serviceSpecs) {
|
|
41
|
+
const pascalService = toPascalCase(service);
|
|
42
|
+
const renameMap = {};
|
|
43
|
+
const schemas = spec?.components?.schemas ?? {};
|
|
44
|
+
for (const [schemaName, schemaDef] of Object.entries(schemas)) {
|
|
45
|
+
if (mergedSchemas[schemaName]) {
|
|
46
|
+
const prefixed = `${pascalService}_${schemaName}`;
|
|
47
|
+
renameMap[schemaName] = prefixed;
|
|
48
|
+
mergedSchemas[prefixed] = schemaDef;
|
|
49
|
+
} else {
|
|
50
|
+
mergedSchemas[schemaName] = schemaDef;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const rewrittenSpec = Object.keys(renameMap).length > 0 ? rewriteSchemaRefs(spec, renameMap) : spec;
|
|
54
|
+
const rewrittenSchemas = rewrittenSpec?.components?.schemas ?? {};
|
|
55
|
+
for (const [schemaName, schemaDef] of Object.entries(rewrittenSchemas)) {
|
|
56
|
+
const targetName = renameMap[schemaName] ?? schemaName;
|
|
57
|
+
mergedSchemas[targetName] = schemaDef;
|
|
58
|
+
}
|
|
59
|
+
const paths = rewrittenSpec?.paths ?? {};
|
|
60
|
+
for (const [rawPath, pathItem] of Object.entries(paths)) {
|
|
61
|
+
if (internalPaths.has(rawPath)) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const normalized = rawPath === "/" ? "" : rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
|
|
65
|
+
const prefixedPath = `/api/v1/${service}${normalized}`;
|
|
66
|
+
mergedPaths[prefixedPath] = pathItem;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
openapi: "3.1.0",
|
|
71
|
+
info: {
|
|
72
|
+
title: info.title ?? "Pand API",
|
|
73
|
+
version: info.version ?? "0.1.0"
|
|
74
|
+
},
|
|
75
|
+
paths: mergedPaths,
|
|
76
|
+
components: {
|
|
77
|
+
schemas: mergedSchemas
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// src/index.ts
|
|
83
|
+
function defaultResolveSession(req) {
|
|
84
|
+
const testUserId = req.headers.get("x-test-user-id");
|
|
85
|
+
if (testUserId) {
|
|
86
|
+
const roleHeader = req.headers.get("x-test-user-role");
|
|
87
|
+
return Promise.resolve({
|
|
88
|
+
userId: testUserId,
|
|
89
|
+
email: req.headers.get("x-test-user-email") ?? `${testUserId}@mahdins.dev`,
|
|
90
|
+
role: roleHeader === "admin" ? "admin" : "user"
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const authHeader = req.headers.get("authorization");
|
|
94
|
+
if (authHeader?.startsWith("Bearer test-user:")) {
|
|
95
|
+
const parts = authHeader.slice("Bearer test-user:".length).split(":");
|
|
96
|
+
const userId = parts[0] || "user-1";
|
|
97
|
+
const role = parts[1] === "admin" ? "admin" : "user";
|
|
98
|
+
return Promise.resolve({
|
|
99
|
+
userId,
|
|
100
|
+
email: `${userId}@mahdins.dev`,
|
|
101
|
+
role
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return Promise.resolve(null);
|
|
105
|
+
}
|
|
106
|
+
function resolveGatewayServiceUrls(config, explicitUrls) {
|
|
107
|
+
if (explicitUrls) {
|
|
108
|
+
return explicitUrls;
|
|
109
|
+
}
|
|
110
|
+
if (process.env.PAND_SERVICE_URLS) {
|
|
111
|
+
return JSON.parse(process.env.PAND_SERVICE_URLS);
|
|
112
|
+
}
|
|
113
|
+
const ports = resolveProjectPorts(config);
|
|
114
|
+
const urls = {};
|
|
115
|
+
for (const [svc, port] of Object.entries(ports.services)) {
|
|
116
|
+
urls[svc] = `http://localhost:${port}`;
|
|
117
|
+
}
|
|
118
|
+
return urls;
|
|
119
|
+
}
|
|
120
|
+
function createGatewayApp(options) {
|
|
121
|
+
const { config } = options;
|
|
122
|
+
const serviceUrls = resolveGatewayServiceUrls(config, options.serviceUrls);
|
|
123
|
+
let lazyAuth = options.auth ?? null;
|
|
124
|
+
const getAuth = () => {
|
|
125
|
+
if (lazyAuth) return lazyAuth;
|
|
126
|
+
const dbUrl = options.databaseUrl ?? process.env.DATABASE_URL;
|
|
127
|
+
if (!dbUrl) return null;
|
|
128
|
+
const dbInstance = createDb({ databaseUrl: dbUrl, schema: authTables });
|
|
129
|
+
lazyAuth = createPandAuth({ db: dbInstance.db, config });
|
|
130
|
+
return lazyAuth;
|
|
131
|
+
};
|
|
132
|
+
const resolveSession = options.resolveSession ?? (async (req) => {
|
|
133
|
+
const auth = getAuth();
|
|
134
|
+
if (auth) {
|
|
135
|
+
return await resolveRequestActor(auth, req);
|
|
136
|
+
}
|
|
137
|
+
return await defaultResolveSession(req);
|
|
138
|
+
});
|
|
139
|
+
const authHandler = options.authHandler ?? (async (req) => {
|
|
140
|
+
const auth = getAuth();
|
|
141
|
+
if (!auth) {
|
|
142
|
+
throw PandError.notFound("Auth handler is not configured on this gateway");
|
|
143
|
+
}
|
|
144
|
+
return await auth.handler(req);
|
|
145
|
+
});
|
|
146
|
+
const logger = createLogger({
|
|
147
|
+
service: "gateway",
|
|
148
|
+
project: config.name,
|
|
149
|
+
level: process.env.NODE_ENV === "test" ? "silent" : void 0
|
|
150
|
+
});
|
|
151
|
+
const app = new Hono();
|
|
152
|
+
app.use("*", async (c, next) => {
|
|
153
|
+
const reqId = c.req.header("x-request-id") ?? crypto.randomUUID();
|
|
154
|
+
c.set("requestId", reqId);
|
|
155
|
+
c.header("x-request-id", reqId);
|
|
156
|
+
await next();
|
|
157
|
+
});
|
|
158
|
+
app.onError((err, c) => {
|
|
159
|
+
const requestId = c.get("requestId") ?? crypto.randomUUID();
|
|
160
|
+
if (err instanceof PandError) {
|
|
161
|
+
return c.json(
|
|
162
|
+
{
|
|
163
|
+
error: {
|
|
164
|
+
code: err.code,
|
|
165
|
+
message: err.message,
|
|
166
|
+
requestId,
|
|
167
|
+
...err.details ? { details: err.details } : {}
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
err.status
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
logger.error({ err, requestId }, "gateway_unhandled_error");
|
|
174
|
+
return c.json(
|
|
175
|
+
{
|
|
176
|
+
error: {
|
|
177
|
+
code: "INTERNAL_ERROR",
|
|
178
|
+
message: err instanceof Error ? err.message : "Internal gateway error",
|
|
179
|
+
requestId
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
500
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
const callService = async (serviceName, pathAndQuery, init) => {
|
|
186
|
+
const inProcess = options.serviceApps?.[serviceName];
|
|
187
|
+
if (inProcess) {
|
|
188
|
+
const req = new Request(`http://${serviceName}.internal${pathAndQuery}`, init);
|
|
189
|
+
return await inProcess.fetch(req);
|
|
190
|
+
}
|
|
191
|
+
const baseUrl = serviceUrls[serviceName];
|
|
192
|
+
if (!baseUrl) {
|
|
193
|
+
throw PandError.notFound(`Unknown service "${serviceName}"`);
|
|
194
|
+
}
|
|
195
|
+
return await fetch(`${baseUrl.replace(/\/$/, "")}${pathAndQuery}`, init);
|
|
196
|
+
};
|
|
197
|
+
app.get("/healthz", (c) => {
|
|
198
|
+
return c.json({
|
|
199
|
+
status: "ok",
|
|
200
|
+
service: "gateway",
|
|
201
|
+
version: config.pandVersion
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
app.get("/readyz", async (c) => {
|
|
205
|
+
const checks = {};
|
|
206
|
+
let ready = true;
|
|
207
|
+
if (options.dbHealth) {
|
|
208
|
+
const dbStatus = await options.dbHealth();
|
|
209
|
+
checks.db = dbStatus;
|
|
210
|
+
if (dbStatus !== "ok") {
|
|
211
|
+
ready = false;
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
checks.db = "ok";
|
|
215
|
+
}
|
|
216
|
+
return c.json(
|
|
217
|
+
{
|
|
218
|
+
status: ready ? "ready" : "degraded",
|
|
219
|
+
service: "gateway",
|
|
220
|
+
checks
|
|
221
|
+
},
|
|
222
|
+
ready ? 200 : 503
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
app.get("/api/config", (c) => {
|
|
226
|
+
const env = process.env.PAND_ENV ?? "dev";
|
|
227
|
+
const ports = resolveProjectPorts(config);
|
|
228
|
+
const posthogKey = process.env.POSTHOG_PUBLIC_KEY;
|
|
229
|
+
const grafanaUrl = env === "prod" ? `https://grafana.${config.domain}` : `http://localhost:${ports.grafanaPort}`;
|
|
230
|
+
const clientConfig = {
|
|
231
|
+
project: config.name,
|
|
232
|
+
displayName: config.displayName,
|
|
233
|
+
env,
|
|
234
|
+
apiBaseUrl: "/api",
|
|
235
|
+
authBaseUrl: "/api/auth",
|
|
236
|
+
...posthogKey ? {
|
|
237
|
+
posthog: {
|
|
238
|
+
publicKey: posthogKey,
|
|
239
|
+
host: "/ingest"
|
|
240
|
+
}
|
|
241
|
+
} : {},
|
|
242
|
+
links: {
|
|
243
|
+
grafanaUrl,
|
|
244
|
+
posthogUrl: "https://eu.posthog.com",
|
|
245
|
+
apiDocsUrl: "/api/docs"
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
return c.json(clientConfig);
|
|
249
|
+
});
|
|
250
|
+
app.all("/api/auth/*", async (c) => {
|
|
251
|
+
return await authHandler(c.req.raw);
|
|
252
|
+
});
|
|
253
|
+
app.get("/api/openapi.json", async (c) => {
|
|
254
|
+
const specs = await Promise.all(
|
|
255
|
+
Object.keys(config.services).map(async (service) => {
|
|
256
|
+
try {
|
|
257
|
+
const res = await callService(service, "/openapi.json");
|
|
258
|
+
if (!res.ok) {
|
|
259
|
+
return { service, spec: { paths: {}, components: { schemas: {} } } };
|
|
260
|
+
}
|
|
261
|
+
const spec = await res.json();
|
|
262
|
+
return { service, spec };
|
|
263
|
+
} catch {
|
|
264
|
+
return { service, spec: { paths: {}, components: { schemas: {} } } };
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
);
|
|
268
|
+
const merged = mergeServiceOpenApiSpecs(specs, {
|
|
269
|
+
title: `${config.displayName} API`,
|
|
270
|
+
version: config.pandVersion
|
|
271
|
+
});
|
|
272
|
+
return c.json(merged);
|
|
273
|
+
});
|
|
274
|
+
app.get(
|
|
275
|
+
"/api/docs",
|
|
276
|
+
apiReference({
|
|
277
|
+
spec: {
|
|
278
|
+
url: "/api/openapi.json"
|
|
279
|
+
}
|
|
280
|
+
})
|
|
281
|
+
);
|
|
282
|
+
app.get("/api/admin/meta", async (c) => {
|
|
283
|
+
const actor = await resolveSession(c.req.raw);
|
|
284
|
+
if (!actor) {
|
|
285
|
+
throw PandError.unauthorized("Authentication required");
|
|
286
|
+
}
|
|
287
|
+
if (actor.role !== "admin") {
|
|
288
|
+
throw PandError.forbidden("Admin role required");
|
|
289
|
+
}
|
|
290
|
+
const reqId = c.get("requestId");
|
|
291
|
+
const internalToken = await signInternalToken(
|
|
292
|
+
{
|
|
293
|
+
sub: actor.userId,
|
|
294
|
+
authenticated: true,
|
|
295
|
+
email: actor.email,
|
|
296
|
+
role: actor.role,
|
|
297
|
+
caller: "gateway",
|
|
298
|
+
reqId
|
|
299
|
+
},
|
|
300
|
+
options.internalSecret
|
|
301
|
+
);
|
|
302
|
+
const allMeta = [];
|
|
303
|
+
for (const service of Object.keys(config.services)) {
|
|
304
|
+
try {
|
|
305
|
+
const res = await callService(service, "/_pand/meta", {
|
|
306
|
+
headers: {
|
|
307
|
+
"x-pand-internal-token": internalToken,
|
|
308
|
+
"x-request-id": reqId
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
if (res.ok) {
|
|
312
|
+
const body = await res.json();
|
|
313
|
+
const items = Array.isArray(body) ? body : body.entities ?? [];
|
|
314
|
+
allMeta.push(...items);
|
|
315
|
+
}
|
|
316
|
+
} catch (err) {
|
|
317
|
+
logger.warn({ err, service }, "failed_fetching_service_admin_meta");
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return c.json(allMeta);
|
|
321
|
+
});
|
|
322
|
+
const proxyServiceHandler = async (c) => {
|
|
323
|
+
const serviceName = c.req.param("service");
|
|
324
|
+
if (!config.services[serviceName]) {
|
|
325
|
+
throw PandError.notFound(`Service "${serviceName}" not found`);
|
|
326
|
+
}
|
|
327
|
+
const reqId = c.get("requestId");
|
|
328
|
+
const sessionActor = await resolveSession(c.req.raw);
|
|
329
|
+
const internalToken = await signInternalToken(
|
|
330
|
+
sessionActor ? {
|
|
331
|
+
sub: sessionActor.userId,
|
|
332
|
+
authenticated: true,
|
|
333
|
+
email: sessionActor.email,
|
|
334
|
+
role: sessionActor.role,
|
|
335
|
+
caller: "gateway",
|
|
336
|
+
reqId
|
|
337
|
+
} : {
|
|
338
|
+
sub: "anonymous",
|
|
339
|
+
authenticated: false,
|
|
340
|
+
caller: "gateway",
|
|
341
|
+
reqId
|
|
342
|
+
},
|
|
343
|
+
options.internalSecret
|
|
344
|
+
);
|
|
345
|
+
const url = new URL(c.req.url);
|
|
346
|
+
const prefix = `/api/v1/${serviceName}`;
|
|
347
|
+
const strippedPathname = url.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length) || "/" : url.pathname;
|
|
348
|
+
const targetPathAndQuery = `${strippedPathname}${url.search}`;
|
|
349
|
+
const headers = new Headers();
|
|
350
|
+
const contentType = c.req.header("content-type");
|
|
351
|
+
if (contentType) {
|
|
352
|
+
headers.set("content-type", contentType);
|
|
353
|
+
}
|
|
354
|
+
const accept = c.req.header("accept");
|
|
355
|
+
if (accept) {
|
|
356
|
+
headers.set("accept", accept);
|
|
357
|
+
}
|
|
358
|
+
const traceparent = c.req.header("traceparent");
|
|
359
|
+
if (traceparent) {
|
|
360
|
+
headers.set("traceparent", traceparent);
|
|
361
|
+
}
|
|
362
|
+
headers.set("x-request-id", reqId);
|
|
363
|
+
headers.set("x-pand-internal-token", internalToken);
|
|
364
|
+
const method = c.req.method;
|
|
365
|
+
const hasBody = method !== "GET" && method !== "HEAD";
|
|
366
|
+
const bodyBuffer = hasBody ? await c.req.arrayBuffer() : void 0;
|
|
367
|
+
let upstreamRes;
|
|
368
|
+
try {
|
|
369
|
+
upstreamRes = await callService(serviceName, targetPathAndQuery, {
|
|
370
|
+
method,
|
|
371
|
+
headers,
|
|
372
|
+
body: bodyBuffer && bodyBuffer.byteLength > 0 ? bodyBuffer : void 0
|
|
373
|
+
});
|
|
374
|
+
} catch (err) {
|
|
375
|
+
throw PandError.upstream(`Failed to reach service "${serviceName}"`, {
|
|
376
|
+
cause: err instanceof Error ? err.message : String(err)
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
const resHeaders = new Headers(upstreamRes.headers);
|
|
380
|
+
resHeaders.set("x-request-id", reqId);
|
|
381
|
+
return new Response(upstreamRes.body, {
|
|
382
|
+
status: upstreamRes.status,
|
|
383
|
+
headers: resHeaders
|
|
384
|
+
});
|
|
385
|
+
};
|
|
386
|
+
app.all("/api/v1/:service", proxyServiceHandler);
|
|
387
|
+
app.all("/api/v1/:service/*", proxyServiceHandler);
|
|
388
|
+
app.all("/ingest/*", async (c) => {
|
|
389
|
+
const url = new URL(c.req.url);
|
|
390
|
+
const subpath = url.pathname.replace(/^\/ingest/, "");
|
|
391
|
+
const isStatic = subpath.startsWith("/static/");
|
|
392
|
+
const targetHost = isStatic ? "https://eu-assets.i.posthog.com" : (process.env.POSTHOG_HOST ?? "https://eu.i.posthog.com").replace(/\/$/, "");
|
|
393
|
+
const targetUrl = `${targetHost}${subpath}${url.search}`;
|
|
394
|
+
const fetchFn = options.posthogFetch ?? fetch;
|
|
395
|
+
const method = c.req.method;
|
|
396
|
+
const hasBody = method !== "GET" && method !== "HEAD";
|
|
397
|
+
const body = hasBody ? await c.req.arrayBuffer() : void 0;
|
|
398
|
+
const headers = new Headers();
|
|
399
|
+
const contentType = c.req.header("content-type");
|
|
400
|
+
if (contentType) {
|
|
401
|
+
headers.set("content-type", contentType);
|
|
402
|
+
}
|
|
403
|
+
const upstream = await fetchFn(targetUrl, {
|
|
404
|
+
method,
|
|
405
|
+
headers,
|
|
406
|
+
body: body && body.byteLength > 0 ? body : void 0
|
|
407
|
+
});
|
|
408
|
+
return new Response(upstream.body, {
|
|
409
|
+
status: upstream.status,
|
|
410
|
+
headers: {
|
|
411
|
+
"content-type": upstream.headers.get("content-type") ?? "application/json"
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
app.notFound((c) => {
|
|
416
|
+
if (process.env.PAND_SERVE_STATIC === "true" || options.staticRootDir) {
|
|
417
|
+
const host = c.req.header("host") ?? "";
|
|
418
|
+
const isAdminHost = host.startsWith(`${config.name}-admin.`);
|
|
419
|
+
const baseStatic = options.staticRootDir ?? path.resolve(process.cwd(), "public", isAdminHost ? "admin" : "web");
|
|
420
|
+
const reqPath = c.req.path === "/" ? "/index.html" : c.req.path;
|
|
421
|
+
const candidate = path.join(baseStatic, reqPath);
|
|
422
|
+
const fallback = path.join(baseStatic, "index.html");
|
|
423
|
+
const fileToServe = fs.existsSync(candidate) && fs.statSync(candidate).isFile() ? candidate : fallback;
|
|
424
|
+
if (fs.existsSync(fileToServe)) {
|
|
425
|
+
const ext = path.extname(fileToServe);
|
|
426
|
+
const mimeMap = {
|
|
427
|
+
".html": "text/html; charset=utf-8",
|
|
428
|
+
".js": "application/javascript; charset=utf-8",
|
|
429
|
+
".css": "text/css; charset=utf-8",
|
|
430
|
+
".json": "application/json; charset=utf-8",
|
|
431
|
+
".svg": "image/svg+xml",
|
|
432
|
+
".png": "image/png"
|
|
433
|
+
};
|
|
434
|
+
return new Response(fs.readFileSync(fileToServe), {
|
|
435
|
+
status: 200,
|
|
436
|
+
headers: { "content-type": mimeMap[ext] ?? "application/octet-stream" }
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const requestId = c.get("requestId") ?? crypto.randomUUID();
|
|
441
|
+
return c.json(
|
|
442
|
+
{
|
|
443
|
+
error: {
|
|
444
|
+
code: "NOT_FOUND",
|
|
445
|
+
message: `Route ${c.req.method} ${c.req.path} not found`,
|
|
446
|
+
requestId
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
404
|
|
450
|
+
);
|
|
451
|
+
});
|
|
452
|
+
return Object.assign(app, {
|
|
453
|
+
serviceName: "gateway",
|
|
454
|
+
logger,
|
|
455
|
+
registerAdminEntity: () => {
|
|
456
|
+
},
|
|
457
|
+
getAdminEntities: () => []
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
export {
|
|
461
|
+
createGatewayApp,
|
|
462
|
+
mergeServiceOpenApiSpecs,
|
|
463
|
+
resolveGatewayServiceUrls,
|
|
464
|
+
serveService
|
|
465
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pands/gateway",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@hono/node-server": "^1.13.8",
|
|
16
|
+
"@scalar/hono-api-reference": "^0.5.170",
|
|
17
|
+
"hono": "^4.7.2",
|
|
18
|
+
"@pands/auth": "0.1.0",
|
|
19
|
+
"@pands/config": "0.1.0",
|
|
20
|
+
"@pands/logger": "0.1.0",
|
|
21
|
+
"@pands/db": "0.1.0",
|
|
22
|
+
"@pands/service": "0.1.0",
|
|
23
|
+
"@pands/telemetry": "0.1.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@pands/testing": "0.1.0"
|
|
27
|
+
},
|
|
28
|
+
"description": "Reverse proxy, routing, and Better Auth authentication gateway for Pand",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"author": "Mehdi Nosrati",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/MehdiNosrati/pand.git"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/MehdiNosrati/pand#readme",
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/MehdiNosrati/pand/issues"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"dist"
|
|
44
|
+
],
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"test": "vitest run"
|
|
49
|
+
}
|
|
6
50
|
}
|
package/README.md
DELETED