lakebed 0.0.18 → 0.0.19
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/README.md +46 -30
- package/package.json +5 -5
- package/src/anonymous-server.js +365 -8
- package/src/anonymous.js +241 -0
- package/src/cli.js +288 -47
- package/src/server.d.ts +49 -0
- package/src/server.js +53 -0
- package/src/source-runtime-worker.js +114 -1
- package/src/source-runtime.js +27 -0
- package/src/version.js +1 -1
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
|
+
}
|