@pylonsync/sdk 0.4.6 → 0.4.8
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 +63 -0
- package/package.json +1 -1
- package/src/function-discovery.test.ts +207 -0
- package/src/index.ts +222 -4
package/dist/index.d.ts
CHANGED
|
@@ -403,16 +403,35 @@ export interface InputFieldDefinition {
|
|
|
403
403
|
export interface QueryDefinition {
|
|
404
404
|
name: string;
|
|
405
405
|
input?: InputFieldDefinition[];
|
|
406
|
+
/**
|
|
407
|
+
* The function's declarative auth gate ("public" | "user" | …), as the
|
|
408
|
+
* router enforces it. Populated by `discoverFunctions()`; consumers
|
|
409
|
+
* (the OpenAPI generator) use it to mark which endpoints need
|
|
410
|
+
* credentials. Undefined means "not declared" — the runtime's default
|
|
411
|
+
* is "user".
|
|
412
|
+
*/
|
|
413
|
+
auth?: string;
|
|
406
414
|
}
|
|
407
415
|
export declare function query(name: string, options?: {
|
|
408
416
|
input?: InputFieldDefinition[];
|
|
417
|
+
auth?: string;
|
|
409
418
|
}): QueryDefinition;
|
|
410
419
|
export interface ActionDefinition {
|
|
411
420
|
name: string;
|
|
412
421
|
input?: InputFieldDefinition[];
|
|
422
|
+
/** See `QueryDefinition.auth`. */
|
|
423
|
+
auth?: string;
|
|
424
|
+
/**
|
|
425
|
+
* Which write kind this was before the manifest's read/write split
|
|
426
|
+
* collapsed mutations and actions into one bucket. Preserved so a
|
|
427
|
+
* generated spec can tell them apart.
|
|
428
|
+
*/
|
|
429
|
+
fnType?: "mutation" | "action";
|
|
413
430
|
}
|
|
414
431
|
export declare function action(name: string, options?: {
|
|
415
432
|
input?: InputFieldDefinition[];
|
|
433
|
+
auth?: string;
|
|
434
|
+
fnType?: "mutation" | "action";
|
|
416
435
|
}): ActionDefinition;
|
|
417
436
|
export interface PolicyDefinition {
|
|
418
437
|
/** Optional — `buildManifest` auto-generates a name from the entity
|
|
@@ -568,10 +587,16 @@ export interface ManifestInputField {
|
|
|
568
587
|
export interface ManifestQuery {
|
|
569
588
|
name: string;
|
|
570
589
|
input?: ManifestInputField[];
|
|
590
|
+
/** Declarative auth gate as the router enforces it. Absent → "user". */
|
|
591
|
+
auth?: string;
|
|
571
592
|
}
|
|
572
593
|
export interface ManifestAction {
|
|
573
594
|
name: string;
|
|
574
595
|
input?: ManifestInputField[];
|
|
596
|
+
/** Declarative auth gate as the router enforces it. Absent → "user". */
|
|
597
|
+
auth?: string;
|
|
598
|
+
/** "mutation" or "action" — the manifest bucket holds both. */
|
|
599
|
+
fn_type?: string;
|
|
575
600
|
}
|
|
576
601
|
export interface ManifestPolicy {
|
|
577
602
|
name: string;
|
|
@@ -739,6 +764,44 @@ export declare function routesToManifest(routes: RouteDefinition[]): ManifestRou
|
|
|
739
764
|
export declare function discoverAppRoutes(opts?: {
|
|
740
765
|
appDir?: string;
|
|
741
766
|
}): Promise<RouteDefinition[]>;
|
|
767
|
+
export interface DiscoveredFunctions {
|
|
768
|
+
queries: QueryDefinition[];
|
|
769
|
+
actions: ActionDefinition[];
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Walk `functions/` and return every externally callable function, for
|
|
773
|
+
* `buildManifest({ queries, actions })`.
|
|
774
|
+
*
|
|
775
|
+
* The counterpart to `discoverAppRoutes()`. Without it, `buildManifest`
|
|
776
|
+
* takes `queries`/`actions` that nothing produces, so apps ship
|
|
777
|
+
* manifests describing zero callable endpoints while `/api/fn/<name>`
|
|
778
|
+
* serves dozens — `/api/manifest`, the generated OpenAPI spec, and
|
|
779
|
+
* `pylon codegen` all under-report in the same silent way.
|
|
780
|
+
*
|
|
781
|
+
* Discovery deliberately mirrors the runtime loader
|
|
782
|
+
* (`packages/functions/src/runtime.ts`) exactly: top-level `.ts`/`.js`
|
|
783
|
+
* files in `functions/`, no recursion, name = basename without the
|
|
784
|
+
* extension, and a default export whose `type` is a string and whose
|
|
785
|
+
* `handler` is a function. Anything the runtime would not register does
|
|
786
|
+
* not appear here, and vice versa — a manifest that disagrees with the
|
|
787
|
+
* router describes endpoints that don't exist.
|
|
788
|
+
*
|
|
789
|
+
* `internal: true` functions are excluded: the router refuses external
|
|
790
|
+
* calls to them with `FN_NOT_FOUND`, so listing them would document
|
|
791
|
+
* endpoints no client can reach.
|
|
792
|
+
*
|
|
793
|
+
* Queries go to `queries`; mutations and actions both go to `actions`,
|
|
794
|
+
* because the manifest's split is read vs write and has no third bucket.
|
|
795
|
+
* `fnType` on the entry preserves which one it was.
|
|
796
|
+
*
|
|
797
|
+
* Each file is dynamically imported, so function modules must be free of
|
|
798
|
+
* side effects at import time. That is already true of any module the
|
|
799
|
+
* runtime loads — this runs the same imports the server does — but it is
|
|
800
|
+
* a contract worth knowing about.
|
|
801
|
+
*/
|
|
802
|
+
export declare function discoverFunctions(opts?: {
|
|
803
|
+
fnDir?: string;
|
|
804
|
+
}): Promise<DiscoveredFunctions>;
|
|
742
805
|
export declare function queriesToManifest(queries: QueryDefinition[]): ManifestQuery[];
|
|
743
806
|
export declare function actionsToManifest(actions: ActionDefinition[]): ManifestAction[];
|
|
744
807
|
export declare function policiesToManifest(policies: PolicyDefinition[]): ManifestPolicy[];
|
package/package.json
CHANGED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Function discovery (discoverFunctions) — the functions/ walk that fills
|
|
2
|
+
// buildManifest's queries/actions. The correctness bar is agreement with the
|
|
3
|
+
// runtime loader in packages/functions/src/runtime.ts: a manifest that lists
|
|
4
|
+
// something the router won't serve documents an endpoint that doesn't exist,
|
|
5
|
+
// and one that omits a live function under-reports the whole API.
|
|
6
|
+
|
|
7
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { discoverFunctions } from "./index";
|
|
12
|
+
|
|
13
|
+
describe("discoverFunctions", () => {
|
|
14
|
+
const tmpdirs: string[] = [];
|
|
15
|
+
const prevCwd = process.cwd();
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
process.chdir(prevCwd);
|
|
18
|
+
for (const d of tmpdirs.splice(0)) {
|
|
19
|
+
try {
|
|
20
|
+
fs.rmSync(d, { recursive: true, force: true });
|
|
21
|
+
} catch {
|
|
22
|
+
/* best effort */
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** Materialize a functions/ dir (filename → module source) and chdir in. */
|
|
28
|
+
function fns(files: Record<string, string>): void {
|
|
29
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pylon-fns-"));
|
|
30
|
+
tmpdirs.push(root);
|
|
31
|
+
for (const [rel, src] of Object.entries(files)) {
|
|
32
|
+
const abs = path.join(root, "functions", rel);
|
|
33
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
34
|
+
fs.writeFileSync(abs, src);
|
|
35
|
+
}
|
|
36
|
+
process.chdir(root);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A function module shaped like `@pylonsync/functions` builds one. Written
|
|
41
|
+
* literally rather than imported so the SDK's test suite doesn't take a
|
|
42
|
+
* dependency on the functions package.
|
|
43
|
+
*/
|
|
44
|
+
const fn = (
|
|
45
|
+
type: string,
|
|
46
|
+
args: string = "{}",
|
|
47
|
+
extra: string = "",
|
|
48
|
+
): string =>
|
|
49
|
+
`export default { type: ${JSON.stringify(type)}, handler: () => null, args: ${args}${extra} };`;
|
|
50
|
+
|
|
51
|
+
test("splits queries from writes", async () => {
|
|
52
|
+
fns({
|
|
53
|
+
"listEvents.ts": fn("query"),
|
|
54
|
+
"saveEvent.ts": fn("mutation"),
|
|
55
|
+
"sendInvite.ts": fn("action"),
|
|
56
|
+
});
|
|
57
|
+
const { queries, actions } = await discoverFunctions();
|
|
58
|
+
expect(queries.map((q) => q.name)).toEqual(["listEvents"]);
|
|
59
|
+
// Mutations and actions share the write bucket; fnType keeps them apart.
|
|
60
|
+
expect(actions.map((a) => a.name).sort()).toEqual([
|
|
61
|
+
"saveEvent",
|
|
62
|
+
"sendInvite",
|
|
63
|
+
]);
|
|
64
|
+
expect(actions.find((a) => a.name === "saveEvent")!.fnType).toBe("mutation");
|
|
65
|
+
expect(actions.find((a) => a.name === "sendInvite")!.fnType).toBe("action");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("excludes internal functions", async () => {
|
|
69
|
+
// The router answers FN_NOT_FOUND for these, so listing one would
|
|
70
|
+
// document an endpoint no external client can reach.
|
|
71
|
+
fns({
|
|
72
|
+
"publicOne.ts": fn("query"),
|
|
73
|
+
"secretOne.ts": fn("query", "{}", ", internal: true"),
|
|
74
|
+
});
|
|
75
|
+
const { queries } = await discoverFunctions();
|
|
76
|
+
expect(queries.map((q) => q.name)).toEqual(["publicOne"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("maps validators onto manifest field types", async () => {
|
|
80
|
+
fns({
|
|
81
|
+
"saveTrack.ts": fn(
|
|
82
|
+
"mutation",
|
|
83
|
+
`{
|
|
84
|
+
title: { type: "string" },
|
|
85
|
+
seats: { type: "int" },
|
|
86
|
+
price: { type: "float" },
|
|
87
|
+
live: { type: "bool" },
|
|
88
|
+
startsAt: { type: "datetime" },
|
|
89
|
+
eventId: { type: "id", table: "Event" }
|
|
90
|
+
}`,
|
|
91
|
+
),
|
|
92
|
+
});
|
|
93
|
+
const { actions } = await discoverFunctions();
|
|
94
|
+
const byName = Object.fromEntries(
|
|
95
|
+
actions[0]!.input!.map((f) => [f.name, f.type]),
|
|
96
|
+
);
|
|
97
|
+
expect(byName).toEqual({
|
|
98
|
+
title: "string",
|
|
99
|
+
seats: "int",
|
|
100
|
+
price: "float",
|
|
101
|
+
live: "bool",
|
|
102
|
+
startsAt: "datetime",
|
|
103
|
+
eventId: "id(Event)",
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("reads optional off the validator and keeps the id table", async () => {
|
|
108
|
+
// v.optional(v.id("Event")) spreads the inner validator and adds
|
|
109
|
+
// optional:true, so `table` survives the wrapper.
|
|
110
|
+
fns({
|
|
111
|
+
"attach.ts": fn(
|
|
112
|
+
"mutation",
|
|
113
|
+
`{
|
|
114
|
+
eventId: { type: "id", table: "Event", optional: true },
|
|
115
|
+
note: { type: "string" }
|
|
116
|
+
}`,
|
|
117
|
+
),
|
|
118
|
+
});
|
|
119
|
+
const { actions } = await discoverFunctions();
|
|
120
|
+
const input = actions[0]!.input!;
|
|
121
|
+
const eventId = input.find((f) => f.name === "eventId")!;
|
|
122
|
+
expect(eventId.optional).toBe(true);
|
|
123
|
+
expect(eventId.type).toBe("id(Event)");
|
|
124
|
+
expect(input.find((f) => f.name === "note")!.optional).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("unmapped validators collapse to json", async () => {
|
|
128
|
+
// FieldType has no array or union variant. Documented lossiness —
|
|
129
|
+
// widening FieldType is what would preserve Validator.items.
|
|
130
|
+
fns({
|
|
131
|
+
"bulk.ts": fn(
|
|
132
|
+
"action",
|
|
133
|
+
`{
|
|
134
|
+
tags: { type: "array", items: { type: "string" } },
|
|
135
|
+
payload: { type: "any" },
|
|
136
|
+
blob: { type: "json" }
|
|
137
|
+
}`,
|
|
138
|
+
),
|
|
139
|
+
});
|
|
140
|
+
const { actions } = await discoverFunctions();
|
|
141
|
+
for (const field of actions[0]!.input!) {
|
|
142
|
+
expect(field.type).toBe("json");
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("carries the declared auth gate", async () => {
|
|
147
|
+
fns({
|
|
148
|
+
"publicFeed.ts": fn("query", "{}", `, auth: "public"`),
|
|
149
|
+
"myProfile.ts": fn("query"),
|
|
150
|
+
});
|
|
151
|
+
const { queries } = await discoverFunctions();
|
|
152
|
+
expect(queries.find((q) => q.name === "publicFeed")!.auth).toBe("public");
|
|
153
|
+
// Undeclared stays undefined rather than being invented — the
|
|
154
|
+
// runtime's default is "user" and consumers apply it.
|
|
155
|
+
expect(queries.find((q) => q.name === "myProfile")!.auth).toBeUndefined();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("skips modules whose default export is not a function definition", async () => {
|
|
159
|
+
fns({
|
|
160
|
+
"real.ts": fn("query"),
|
|
161
|
+
"config.ts": "export default { apiKey: 'x' };",
|
|
162
|
+
"noDefault.ts": "export const helper = () => null;",
|
|
163
|
+
// `type` present but no handler — the runtime's shape check
|
|
164
|
+
// rejects this too.
|
|
165
|
+
"halfBaked.ts": "export default { type: 'query' };",
|
|
166
|
+
});
|
|
167
|
+
const { queries, actions } = await discoverFunctions();
|
|
168
|
+
expect(queries.map((q) => q.name)).toEqual(["real"]);
|
|
169
|
+
expect(actions).toEqual([]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("a module that throws on import is skipped, not fatal", async () => {
|
|
173
|
+
fns({
|
|
174
|
+
"good.ts": fn("query"),
|
|
175
|
+
"explodes.ts": "throw new Error('boom');",
|
|
176
|
+
});
|
|
177
|
+
const { queries } = await discoverFunctions();
|
|
178
|
+
expect(queries.map((q) => q.name)).toEqual(["good"]);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("no functions directory yields empty, not an error", async () => {
|
|
182
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "pylon-fns-"));
|
|
183
|
+
tmpdirs.push(root);
|
|
184
|
+
process.chdir(root);
|
|
185
|
+
expect(await discoverFunctions()).toEqual({ queries: [], actions: [] });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("output is sorted so two machines agree", async () => {
|
|
189
|
+
fns({
|
|
190
|
+
"zebra.ts": fn("query"),
|
|
191
|
+
"alpha.ts": fn("query"),
|
|
192
|
+
"middle.ts": fn("query"),
|
|
193
|
+
});
|
|
194
|
+
const { queries } = await discoverFunctions();
|
|
195
|
+
expect(queries.map((q) => q.name)).toEqual(["alpha", "middle", "zebra"]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("ignores non-module files the runtime would also ignore", async () => {
|
|
199
|
+
fns({
|
|
200
|
+
"real.ts": fn("query"),
|
|
201
|
+
"notes.md": "# not a function",
|
|
202
|
+
"data.json": "{}",
|
|
203
|
+
});
|
|
204
|
+
const { queries } = await discoverFunctions();
|
|
205
|
+
expect(queries.map((q) => q.name)).toEqual(["real"]);
|
|
206
|
+
});
|
|
207
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -532,13 +532,21 @@ export interface InputFieldDefinition {
|
|
|
532
532
|
export interface QueryDefinition {
|
|
533
533
|
name: string;
|
|
534
534
|
input?: InputFieldDefinition[];
|
|
535
|
+
/**
|
|
536
|
+
* The function's declarative auth gate ("public" | "user" | …), as the
|
|
537
|
+
* router enforces it. Populated by `discoverFunctions()`; consumers
|
|
538
|
+
* (the OpenAPI generator) use it to mark which endpoints need
|
|
539
|
+
* credentials. Undefined means "not declared" — the runtime's default
|
|
540
|
+
* is "user".
|
|
541
|
+
*/
|
|
542
|
+
auth?: string;
|
|
535
543
|
}
|
|
536
544
|
|
|
537
545
|
export function query(
|
|
538
546
|
name: string,
|
|
539
|
-
options?: { input?: InputFieldDefinition[] }
|
|
547
|
+
options?: { input?: InputFieldDefinition[]; auth?: string }
|
|
540
548
|
): QueryDefinition {
|
|
541
|
-
return { name, input: options?.input };
|
|
549
|
+
return { name, input: options?.input, auth: options?.auth };
|
|
542
550
|
}
|
|
543
551
|
|
|
544
552
|
// ---------------------------------------------------------------------------
|
|
@@ -548,13 +556,30 @@ export function query(
|
|
|
548
556
|
export interface ActionDefinition {
|
|
549
557
|
name: string;
|
|
550
558
|
input?: InputFieldDefinition[];
|
|
559
|
+
/** See `QueryDefinition.auth`. */
|
|
560
|
+
auth?: string;
|
|
561
|
+
/**
|
|
562
|
+
* Which write kind this was before the manifest's read/write split
|
|
563
|
+
* collapsed mutations and actions into one bucket. Preserved so a
|
|
564
|
+
* generated spec can tell them apart.
|
|
565
|
+
*/
|
|
566
|
+
fnType?: "mutation" | "action";
|
|
551
567
|
}
|
|
552
568
|
|
|
553
569
|
export function action(
|
|
554
570
|
name: string,
|
|
555
|
-
options?: {
|
|
571
|
+
options?: {
|
|
572
|
+
input?: InputFieldDefinition[];
|
|
573
|
+
auth?: string;
|
|
574
|
+
fnType?: "mutation" | "action";
|
|
575
|
+
}
|
|
556
576
|
): ActionDefinition {
|
|
557
|
-
return {
|
|
577
|
+
return {
|
|
578
|
+
name,
|
|
579
|
+
input: options?.input,
|
|
580
|
+
auth: options?.auth,
|
|
581
|
+
fnType: options?.fnType,
|
|
582
|
+
};
|
|
558
583
|
}
|
|
559
584
|
|
|
560
585
|
// ---------------------------------------------------------------------------
|
|
@@ -745,11 +770,17 @@ export interface ManifestInputField {
|
|
|
745
770
|
export interface ManifestQuery {
|
|
746
771
|
name: string;
|
|
747
772
|
input?: ManifestInputField[];
|
|
773
|
+
/** Declarative auth gate as the router enforces it. Absent → "user". */
|
|
774
|
+
auth?: string;
|
|
748
775
|
}
|
|
749
776
|
|
|
750
777
|
export interface ManifestAction {
|
|
751
778
|
name: string;
|
|
752
779
|
input?: ManifestInputField[];
|
|
780
|
+
/** Declarative auth gate as the router enforces it. Absent → "user". */
|
|
781
|
+
auth?: string;
|
|
782
|
+
/** "mutation" or "action" — the manifest bucket holds both. */
|
|
783
|
+
fn_type?: string;
|
|
753
784
|
}
|
|
754
785
|
|
|
755
786
|
export interface ManifestPolicy {
|
|
@@ -1322,6 +1353,190 @@ export async function discoverAppRoutes(opts?: {
|
|
|
1322
1353
|
return [...navigable, ...boundaryRoutes];
|
|
1323
1354
|
}
|
|
1324
1355
|
|
|
1356
|
+
// ---------------------------------------------------------------------------
|
|
1357
|
+
// Function discovery
|
|
1358
|
+
// ---------------------------------------------------------------------------
|
|
1359
|
+
|
|
1360
|
+
/**
|
|
1361
|
+
* A function definition as `@pylonsync/functions` builds it. Structural —
|
|
1362
|
+
* the SDK does not depend on that package (it would drag server-only
|
|
1363
|
+
* modules into client bundles), so this mirrors the shape the runtime
|
|
1364
|
+
* loader checks for.
|
|
1365
|
+
*/
|
|
1366
|
+
interface RuntimeFnDefinition {
|
|
1367
|
+
type: "query" | "mutation" | "action";
|
|
1368
|
+
handler: unknown;
|
|
1369
|
+
args?: Record<string, RuntimeValidator>;
|
|
1370
|
+
internal?: boolean;
|
|
1371
|
+
auth?: string;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/** A `v.*` validator: `{type, optional?, table?, items?}`. */
|
|
1375
|
+
interface RuntimeValidator {
|
|
1376
|
+
type: string;
|
|
1377
|
+
optional?: boolean;
|
|
1378
|
+
table?: string;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* Map a validator onto the manifest's field type.
|
|
1383
|
+
*
|
|
1384
|
+
* `FieldType` has no array or union variant, so `v.array(...)`,
|
|
1385
|
+
* `v.union(...)`, and `v.any()` all land on `json`. The element type in
|
|
1386
|
+
* `Validator.items` is information this boundary throws away — widening
|
|
1387
|
+
* `FieldType` is the fix, and it would materially improve a generated
|
|
1388
|
+
* OpenAPI spec.
|
|
1389
|
+
*/
|
|
1390
|
+
function validatorToFieldType(validator: RuntimeValidator): FieldType {
|
|
1391
|
+
switch (validator.type) {
|
|
1392
|
+
case "string":
|
|
1393
|
+
return "string";
|
|
1394
|
+
case "int":
|
|
1395
|
+
return "int";
|
|
1396
|
+
case "float":
|
|
1397
|
+
case "number":
|
|
1398
|
+
return "float";
|
|
1399
|
+
case "bool":
|
|
1400
|
+
case "boolean":
|
|
1401
|
+
return "bool";
|
|
1402
|
+
case "datetime":
|
|
1403
|
+
return "datetime";
|
|
1404
|
+
case "id":
|
|
1405
|
+
// `v.optional(v.id("Event"))` spreads the inner validator, so
|
|
1406
|
+
// `table` survives the optional wrapper.
|
|
1407
|
+
return validator.table ? (`id(${validator.table})` as FieldType) : "string";
|
|
1408
|
+
default:
|
|
1409
|
+
return "json";
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
function argsToInput(
|
|
1414
|
+
args: Record<string, RuntimeValidator> | undefined
|
|
1415
|
+
): InputFieldDefinition[] {
|
|
1416
|
+
if (!args) return [];
|
|
1417
|
+
return Object.entries(args).map(([name, validator]) => ({
|
|
1418
|
+
name,
|
|
1419
|
+
type: validatorToFieldType(validator),
|
|
1420
|
+
// `v.optional(inner)` sets `optional: true` on the spread validator
|
|
1421
|
+
// itself, so this reads correctly without unwrapping.
|
|
1422
|
+
optional: validator.optional === true,
|
|
1423
|
+
}));
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
export interface DiscoveredFunctions {
|
|
1427
|
+
queries: QueryDefinition[];
|
|
1428
|
+
actions: ActionDefinition[];
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
/**
|
|
1432
|
+
* Walk `functions/` and return every externally callable function, for
|
|
1433
|
+
* `buildManifest({ queries, actions })`.
|
|
1434
|
+
*
|
|
1435
|
+
* The counterpart to `discoverAppRoutes()`. Without it, `buildManifest`
|
|
1436
|
+
* takes `queries`/`actions` that nothing produces, so apps ship
|
|
1437
|
+
* manifests describing zero callable endpoints while `/api/fn/<name>`
|
|
1438
|
+
* serves dozens — `/api/manifest`, the generated OpenAPI spec, and
|
|
1439
|
+
* `pylon codegen` all under-report in the same silent way.
|
|
1440
|
+
*
|
|
1441
|
+
* Discovery deliberately mirrors the runtime loader
|
|
1442
|
+
* (`packages/functions/src/runtime.ts`) exactly: top-level `.ts`/`.js`
|
|
1443
|
+
* files in `functions/`, no recursion, name = basename without the
|
|
1444
|
+
* extension, and a default export whose `type` is a string and whose
|
|
1445
|
+
* `handler` is a function. Anything the runtime would not register does
|
|
1446
|
+
* not appear here, and vice versa — a manifest that disagrees with the
|
|
1447
|
+
* router describes endpoints that don't exist.
|
|
1448
|
+
*
|
|
1449
|
+
* `internal: true` functions are excluded: the router refuses external
|
|
1450
|
+
* calls to them with `FN_NOT_FOUND`, so listing them would document
|
|
1451
|
+
* endpoints no client can reach.
|
|
1452
|
+
*
|
|
1453
|
+
* Queries go to `queries`; mutations and actions both go to `actions`,
|
|
1454
|
+
* because the manifest's split is read vs write and has no third bucket.
|
|
1455
|
+
* `fnType` on the entry preserves which one it was.
|
|
1456
|
+
*
|
|
1457
|
+
* Each file is dynamically imported, so function modules must be free of
|
|
1458
|
+
* side effects at import time. That is already true of any module the
|
|
1459
|
+
* runtime loads — this runs the same imports the server does — but it is
|
|
1460
|
+
* a contract worth knowing about.
|
|
1461
|
+
*/
|
|
1462
|
+
export async function discoverFunctions(opts?: {
|
|
1463
|
+
fnDir?: string;
|
|
1464
|
+
}): Promise<DiscoveredFunctions> {
|
|
1465
|
+
// Same lazy node-builtin resolution as discoverAppRoutes: the SDK
|
|
1466
|
+
// carries no @types/node and must stay importable from client code.
|
|
1467
|
+
let fs: any;
|
|
1468
|
+
let path: any;
|
|
1469
|
+
let url: any;
|
|
1470
|
+
try {
|
|
1471
|
+
const nodeReq =
|
|
1472
|
+
(globalThis as any).require ??
|
|
1473
|
+
(await import("node:module")).createRequire(import.meta.url);
|
|
1474
|
+
fs = nodeReq("node:fs");
|
|
1475
|
+
path = nodeReq("node:path");
|
|
1476
|
+
url = nodeReq("node:url");
|
|
1477
|
+
} catch {
|
|
1478
|
+
return { queries: [], actions: [] };
|
|
1479
|
+
}
|
|
1480
|
+
if (!fs || !path || !url) return { queries: [], actions: [] };
|
|
1481
|
+
|
|
1482
|
+
const cwd = (globalThis as any).process?.cwd?.() ?? ".";
|
|
1483
|
+
const fnDir =
|
|
1484
|
+
opts?.fnDir && path.isAbsolute(opts.fnDir)
|
|
1485
|
+
? opts.fnDir
|
|
1486
|
+
: path.join(cwd, opts?.fnDir ?? "functions");
|
|
1487
|
+
if (!fs.existsSync(fnDir) || !fs.statSync(fnDir).isDirectory()) {
|
|
1488
|
+
return { queries: [], actions: [] };
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
const files: string[] = fs
|
|
1492
|
+
.readdirSync(fnDir)
|
|
1493
|
+
.filter((f: string) => f.endsWith(".ts") || f.endsWith(".js"))
|
|
1494
|
+
// Sorted so two machines produce byte-identical manifests.
|
|
1495
|
+
.sort();
|
|
1496
|
+
|
|
1497
|
+
const queries: QueryDefinition[] = [];
|
|
1498
|
+
const actions: ActionDefinition[] = [];
|
|
1499
|
+
|
|
1500
|
+
for (const file of files) {
|
|
1501
|
+
const name = file.replace(/\.(ts|js)$/, "");
|
|
1502
|
+
let definition: RuntimeFnDefinition | undefined;
|
|
1503
|
+
try {
|
|
1504
|
+
const mod = await import(
|
|
1505
|
+
url.pathToFileURL(path.join(fnDir, file)).href
|
|
1506
|
+
);
|
|
1507
|
+
definition = mod?.default as RuntimeFnDefinition | undefined;
|
|
1508
|
+
} catch {
|
|
1509
|
+
// A module that won't import can't be serving traffic either.
|
|
1510
|
+
// The runtime loader logs and skips; so do we, rather than
|
|
1511
|
+
// failing the whole manifest build over one bad file.
|
|
1512
|
+
continue;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// The runtime's own registration check. A default export that isn't
|
|
1516
|
+
// a function definition (a config object, a React component in the
|
|
1517
|
+
// wrong directory) is silently skipped there and here.
|
|
1518
|
+
const shape = definition as unknown as Record<string, unknown> | undefined;
|
|
1519
|
+
if (
|
|
1520
|
+
!shape ||
|
|
1521
|
+
typeof shape.type !== "string" ||
|
|
1522
|
+
typeof shape.handler !== "function"
|
|
1523
|
+
) {
|
|
1524
|
+
continue;
|
|
1525
|
+
}
|
|
1526
|
+
if (shape.internal === true) continue;
|
|
1527
|
+
|
|
1528
|
+
const input = argsToInput(definition!.args);
|
|
1529
|
+
const auth = typeof shape.auth === "string" ? shape.auth : undefined;
|
|
1530
|
+
if (definition!.type === "query") {
|
|
1531
|
+
queries.push({ name, input, auth });
|
|
1532
|
+
} else {
|
|
1533
|
+
actions.push({ name, input, auth, fnType: definition!.type });
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
return { queries, actions };
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1325
1540
|
export function queriesToManifest(queries: QueryDefinition[]): ManifestQuery[] {
|
|
1326
1541
|
return queries.map((q) => {
|
|
1327
1542
|
const result: ManifestQuery = { name: q.name };
|
|
@@ -1333,6 +1548,7 @@ export function queriesToManifest(queries: QueryDefinition[]): ManifestQuery[] {
|
|
|
1333
1548
|
unique: false as const,
|
|
1334
1549
|
}));
|
|
1335
1550
|
}
|
|
1551
|
+
if (q.auth) result.auth = q.auth;
|
|
1336
1552
|
return result;
|
|
1337
1553
|
});
|
|
1338
1554
|
}
|
|
@@ -1350,6 +1566,8 @@ export function actionsToManifest(
|
|
|
1350
1566
|
unique: false as const,
|
|
1351
1567
|
}));
|
|
1352
1568
|
}
|
|
1569
|
+
if (a.auth) result.auth = a.auth;
|
|
1570
|
+
if (a.fnType) result.fn_type = a.fnType;
|
|
1353
1571
|
return result;
|
|
1354
1572
|
});
|
|
1355
1573
|
}
|