lakebed 0.0.18 → 0.0.20

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/src/anonymous.js CHANGED
@@ -30,6 +30,7 @@ export const DEFAULT_ANONYMOUS_LIMITS = {
30
30
 
31
31
  const expressionOps = new Set(["arg", "auth", "call", "row"]);
32
32
  const authFields = new Set(["displayName", "email", "emailVerified", "isAuthenticated", "isGuest", "picture", "provider", "userId"]);
33
+ const endpointMethodPattern = /^[A-Z0-9!#$%&'*+.^_`|~-]+$/;
33
34
  const sourceModuleCache = new Map();
34
35
 
35
36
  export class AnonymousCompilerError extends Error {
@@ -487,6 +488,69 @@ function serializeSchema(schema) {
487
488
  return { diagnostics, schema: cleanSchema };
488
489
  }
489
490
 
491
+ function isReservedEndpointPath(path) {
492
+ return (
493
+ path === "/" ||
494
+ path === "/index.html" ||
495
+ path === "/client.js" ||
496
+ path === "/auth/callback" ||
497
+ path.startsWith("/auth/") ||
498
+ path === "/__lakebed" ||
499
+ path.startsWith("/__lakebed/") ||
500
+ path === "/__span" ||
501
+ path.startsWith("/__span/")
502
+ );
503
+ }
504
+
505
+ function validateEndpointRoute({ method, path }, diagnosticPath, diagnostics) {
506
+ if (typeof method !== "string" || !endpointMethodPattern.test(method)) {
507
+ diagnostics.push(diagnostic(diagnosticPath, "Endpoint method must be a valid uppercase HTTP method."));
508
+ }
509
+
510
+ if (typeof path !== "string" || !path.startsWith("/") || path.startsWith("//") || path.includes("\\") || path.includes("?") || path.includes("#")) {
511
+ diagnostics.push(diagnostic(diagnosticPath, "Endpoint path must be an absolute app path like /webhooks/stripe."));
512
+ return;
513
+ }
514
+
515
+ if (isReservedEndpointPath(path)) {
516
+ diagnostics.push(diagnostic(diagnosticPath, `Endpoint path ${path} is reserved by Lakebed.`));
517
+ }
518
+ }
519
+
520
+ function serializeEndpoints(endpoints) {
521
+ const diagnostics = [];
522
+ const cleanEndpoints = {};
523
+ const seenRoutes = new Map();
524
+
525
+ for (const [name, endpoint] of Object.entries(endpoints ?? {})) {
526
+ const diagnosticPath = `server.index.endpoints.${name}`;
527
+ if (!endpoint || endpoint.kind !== "endpoint" || typeof endpoint.handler !== "function") {
528
+ diagnostics.push(diagnostic("server/index.ts", `Endpoint ${name} must be defined with endpoint({ method, path }, handler).`));
529
+ continue;
530
+ }
531
+
532
+ const method = String(endpoint.method ?? "").toUpperCase();
533
+ const path = String(endpoint.path ?? "");
534
+ validateEndpointRoute({ method, path }, diagnosticPath, diagnostics);
535
+
536
+ const routeKey = `${method} ${path}`;
537
+ const existing = seenRoutes.get(routeKey);
538
+ if (existing) {
539
+ diagnostics.push(diagnostic("server/index.ts", `Endpoint ${name} duplicates ${existing} at ${routeKey}.`));
540
+ continue;
541
+ }
542
+ seenRoutes.set(routeKey, name);
543
+
544
+ cleanEndpoints[name] = {
545
+ method,
546
+ op: "source",
547
+ path
548
+ };
549
+ }
550
+
551
+ return { diagnostics, endpoints: cleanEndpoints };
552
+ }
553
+
490
554
  function compileQueryHandler({ handler, name, schema }) {
491
555
  const { ctx, recorder } = createTraceContext({ mode: "query", schema });
492
556
  try {
@@ -615,7 +679,12 @@ export async function createAnonymousArtifact({ app, clientOut, serverOut, sourc
615
679
  const sourceFiles = await readSourceFiles(sourceStore);
616
680
  const diagnostics = forbiddenSourceDiagnostics(sourceFiles, { allowAsync: Boolean(serverOut) });
617
681
  const { diagnostics: schemaDiagnostics, schema } = serializeSchema(app.schema);
682
+ const { diagnostics: endpointDiagnostics, endpoints } = serializeEndpoints(app.endpoints);
618
683
  diagnostics.push(...schemaDiagnostics);
684
+ diagnostics.push(...endpointDiagnostics);
685
+ if (!serverOut && Object.keys(endpoints).length > 0) {
686
+ diagnostics.push(diagnostic("server/index.ts", "Endpoints require the source runtime. Build with a bundled server module."));
687
+ }
619
688
 
620
689
  if (diagnostics.length > 0) {
621
690
  throw new AnonymousCompilerError(diagnostics);
@@ -631,6 +700,7 @@ export async function createAnonymousArtifact({ app, clientOut, serverOut, sourc
631
700
  const sourceSnapshotHash = sha256(stableStringify(sourceManifest));
632
701
  const server = serverBundle
633
702
  ? {
703
+ endpoints,
634
704
  helpers: {},
635
705
  imports: ["lakebed/server"],
636
706
  mutations: Object.fromEntries(Object.keys(app.mutations ?? {}).map((name) => [name, { op: "source" }])),
@@ -674,6 +744,7 @@ export async function createAnonymousArtifact({ app, clientOut, serverOut, sourc
674
744
  maxValueBytes: DEFAULT_ANONYMOUS_LIMITS.maxValueBytes
675
745
  },
676
746
  server: server ?? {
747
+ endpoints: {},
677
748
  helpers: {},
678
749
  imports: ["lakebed/server"],
679
750
  mutations: compiled.mutations,
@@ -707,7 +778,9 @@ export async function createClaimedArtifact({ app, clientOut, serverOut, sourceS
707
778
  entry.message !== "Async server handlers are not part of the anonymous IR yet. Use synchronous Lakebed database operations."
708
779
  );
709
780
  const { diagnostics: schemaDiagnostics, schema } = serializeSchema(app.schema);
781
+ const { diagnostics: endpointDiagnostics, endpoints } = serializeEndpoints(app.endpoints);
710
782
  diagnostics.push(...schemaDiagnostics);
783
+ diagnostics.push(...endpointDiagnostics);
711
784
  if (diagnostics.length > 0) {
712
785
  throw new AnonymousCompilerError(diagnostics);
713
786
  }
@@ -739,6 +812,7 @@ export async function createClaimedArtifact({ app, clientOut, serverOut, sourceS
739
812
  maxValueBytes: DEFAULT_ANONYMOUS_LIMITS.maxValueBytes
740
813
  },
741
814
  server: {
815
+ endpoints,
742
816
  helpers: {},
743
817
  imports: ["lakebed/server"],
744
818
  mutations: Object.fromEntries(Object.keys(app.mutations ?? {}).map((name) => [name, { op: "source" }])),
@@ -922,6 +996,30 @@ export function validateAnonymousArtifact(artifact, { allowClaimedSource = false
922
996
  }
923
997
  }
924
998
 
999
+ const seenEndpointRoutes = new Map();
1000
+ for (const [name, endpoint] of Object.entries(artifact.server?.endpoints ?? {})) {
1001
+ const path = `artifact.server.endpoints.${name}`;
1002
+ if (!isPlainObject(endpoint) || endpoint.op !== "source") {
1003
+ diagnostics.push(diagnostic(path, "Endpoint must be source-backed."));
1004
+ continue;
1005
+ }
1006
+
1007
+ validateEndpointRoute(endpoint, path, diagnostics);
1008
+ const routeKey = `${endpoint.method} ${endpoint.path}`;
1009
+ const existing = seenEndpointRoutes.get(routeKey);
1010
+ if (existing) {
1011
+ diagnostics.push(diagnostic(path, `Endpoint route duplicates ${existing}.`));
1012
+ }
1013
+ seenEndpointRoutes.set(routeKey, name);
1014
+
1015
+ if (artifact.server?.source === undefined) {
1016
+ diagnostics.push(diagnostic(path, "Source endpoint requires artifact.server.source."));
1017
+ }
1018
+ if (!sourceDeployTargets.has(artifact.deployTarget)) {
1019
+ diagnostics.push(diagnostic(path, "Source endpoint requires deployTarget anonymous-source or claimed-source."));
1020
+ }
1021
+ }
1022
+
925
1023
  if (artifact.server?.source !== undefined) {
926
1024
  if (!sourceDeployTargets.has(artifact.deployTarget)) {
927
1025
  diagnostics.push(diagnostic("artifact.server.source", "Server source bundles require deployTarget anonymous-source or claimed-source."));
@@ -1449,3 +1547,146 @@ export async function executeAnonymousMutation({ args = [], artifact, auth, depl
1449
1547
  return null;
1450
1548
  }, mutationTransactionOptions(limits));
1451
1549
  }
1550
+
1551
+ function normalizeEndpointHeaders(headers = {}) {
1552
+ if (!headers) {
1553
+ return {};
1554
+ }
1555
+
1556
+ if (typeof headers.entries === "function") {
1557
+ return Object.fromEntries(Array.from(headers.entries()).map(([key, value]) => [String(key), String(value)]));
1558
+ }
1559
+
1560
+ if (Array.isArray(headers)) {
1561
+ return Object.fromEntries(headers.map(([key, value]) => [String(key), String(value)]));
1562
+ }
1563
+
1564
+ if (isPlainObject(headers)) {
1565
+ return Object.fromEntries(Object.entries(headers).map(([key, value]) => [String(key), String(value)]));
1566
+ }
1567
+
1568
+ return {};
1569
+ }
1570
+
1571
+ function endpointBodyToBase64(body) {
1572
+ if (body === undefined || body === null) {
1573
+ return "";
1574
+ }
1575
+ if (typeof body === "string") {
1576
+ return Buffer.from(body, "utf8").toString("base64");
1577
+ }
1578
+ if (body instanceof ArrayBuffer) {
1579
+ return Buffer.from(body).toString("base64");
1580
+ }
1581
+ if (ArrayBuffer.isView(body)) {
1582
+ return Buffer.from(body.buffer, body.byteOffset, body.byteLength).toString("base64");
1583
+ }
1584
+ return Buffer.from(JSON.stringify(body ?? null), "utf8").toString("base64");
1585
+ }
1586
+
1587
+ function endpointStatus(status, fallback = 200) {
1588
+ const parsed = Number(status);
1589
+ return Number.isInteger(parsed) && parsed >= 100 && parsed <= 599 ? parsed : fallback;
1590
+ }
1591
+
1592
+ async function normalizeEndpointResponse(result) {
1593
+ if (result === undefined || result === null) {
1594
+ return { bodyBase64: "", headers: {}, status: 204 };
1595
+ }
1596
+
1597
+ if (typeof Response !== "undefined" && result instanceof Response) {
1598
+ const body = Buffer.from(await result.arrayBuffer());
1599
+ return {
1600
+ bodyBase64: body.toString("base64"),
1601
+ headers: normalizeEndpointHeaders(result.headers),
1602
+ status: endpointStatus(result.status)
1603
+ };
1604
+ }
1605
+
1606
+ if (isPlainObject(result) && result.kind === "response") {
1607
+ return {
1608
+ bodyBase64: endpointBodyToBase64(result.body),
1609
+ headers: normalizeEndpointHeaders(result.headers),
1610
+ status: endpointStatus(result.status)
1611
+ };
1612
+ }
1613
+
1614
+ if (typeof result === "string") {
1615
+ return {
1616
+ bodyBase64: endpointBodyToBase64(result),
1617
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
1618
+ status: 200
1619
+ };
1620
+ }
1621
+
1622
+ return {
1623
+ bodyBase64: endpointBodyToBase64(result),
1624
+ headers: { "Content-Type": "application/json; charset=utf-8" },
1625
+ status: 200
1626
+ };
1627
+ }
1628
+
1629
+ function createEndpointRequest(request) {
1630
+ const url = new URL(request.url ?? request.path ?? "/", "http://lakebed.local");
1631
+ const body = Buffer.from(request.bodyBase64 ?? "", "base64");
1632
+ const headers = new Map(
1633
+ Object.entries(normalizeEndpointHeaders(request.headers)).map(([key, value]) => [key.toLowerCase(), String(value)])
1634
+ );
1635
+
1636
+ return {
1637
+ headers: {
1638
+ entries() {
1639
+ return headers.entries();
1640
+ },
1641
+ get(name) {
1642
+ return headers.get(String(name).toLowerCase()) ?? null;
1643
+ },
1644
+ has(name) {
1645
+ return headers.has(String(name).toLowerCase());
1646
+ }
1647
+ },
1648
+ method: String(request.method ?? "GET").toUpperCase(),
1649
+ path: request.path ?? url.pathname,
1650
+ query: new URLSearchParams(url.searchParams),
1651
+ url: url.href,
1652
+ async bytes() {
1653
+ return new Uint8Array(body);
1654
+ },
1655
+ async json() {
1656
+ return JSON.parse(body.toString("utf8"));
1657
+ },
1658
+ async text() {
1659
+ return body.toString("utf8");
1660
+ }
1661
+ };
1662
+ }
1663
+
1664
+ export async function executeAnonymousEndpoint({ artifact, auth, deployId, limits = DEFAULT_ANONYMOUS_LIMITS, name, request, sourceRuntime, state }) {
1665
+ const endpoint = artifact.server?.endpoints?.[name];
1666
+ if (!endpoint) {
1667
+ throw new Error(`Unknown endpoint: ${name}`);
1668
+ }
1669
+
1670
+ if (!artifact.server?.source) {
1671
+ throw new Error(`Endpoint ${name} requires the source runtime.`);
1672
+ }
1673
+
1674
+ if (sourceRuntime) {
1675
+ return sourceRuntime.executeEndpoint({ artifact, auth, deployId, limits, name, request, state });
1676
+ }
1677
+
1678
+ const sourceApp = await loadSourceApp(artifact);
1679
+ const definition = sourceApp.endpoints?.[name];
1680
+ const handler = definition?.handler ?? definition;
1681
+ if (typeof handler !== "function") {
1682
+ throw new Error(`Unknown endpoint: ${name}`);
1683
+ }
1684
+
1685
+ return state.transaction(deployId, async (tx) => {
1686
+ const source = await createSourceContext({ artifact, auth, deployId, state: tx });
1687
+ const result = await handler(source.ctx, createEndpointRequest(request));
1688
+ const response = await normalizeEndpointResponse(result);
1689
+ await source.flush(tx);
1690
+ return response;
1691
+ }, mutationTransactionOptions(limits));
1692
+ }