@lukoweb/apitogo 0.2.42 → 0.2.44
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/cli/cli.js +291 -36
- package/dist/declarations/lib/plugins/openapi/playground/result-panel/ResultPanel.d.ts +3 -1
- package/dist/declarations/lib/plugins/openapi/playground/warmUpRetry.d.ts +5 -0
- package/package.json +1 -1
- package/src/config/v2/full-server.ts +192 -0
- package/src/config/v2/manifest.ts +472 -452
- package/src/config/v2/services.ts +59 -20
- package/src/config/v2/wrapper.ts +60 -4
- package/src/lib/plugins/openapi/playground/Playground.tsx +619 -602
- package/src/lib/plugins/openapi/playground/result-panel/ResultPanel.tsx +179 -106
- package/src/lib/plugins/openapi/playground/warmUpRetry.ts +43 -0
- package/src/vite/plugin-modules.ts +190 -188
- package/src/vite/watch-ignore.ts +80 -70
|
@@ -9,6 +9,15 @@ import {
|
|
|
9
9
|
} from "node:fs/promises";
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
import { ApitogoError } from "../../lib/util/invariant.js";
|
|
12
|
+
import {
|
|
13
|
+
FULL_SERVER_HEALTH_PATH,
|
|
14
|
+
FULL_SERVER_TIMEOUT_SECONDS,
|
|
15
|
+
FULL_SERVER_RUNTIME,
|
|
16
|
+
FULL_SERVER_RUNTIME_VERSION,
|
|
17
|
+
FULL_SERVER_SOURCE,
|
|
18
|
+
FULL_SPEC,
|
|
19
|
+
fullServerFiles,
|
|
20
|
+
} from "./full-server.js";
|
|
12
21
|
import { docsFromPaths, starterPage } from "./init.js";
|
|
13
22
|
import { detectExistingDocs } from "./layout.js";
|
|
14
23
|
import { loadConfigV2 } from "./load.js";
|
|
@@ -129,8 +138,11 @@ export type AddOptions = {
|
|
|
129
138
|
disabled?: boolean;
|
|
130
139
|
/** Include every docs folder found instead of asking. */
|
|
131
140
|
acceptAllDocs?: boolean;
|
|
132
|
-
/**
|
|
133
|
-
|
|
141
|
+
/**
|
|
142
|
+
* Content to create: `blank` is an empty shell, `starter` a filled example (portal, gateway and plans), and `full`
|
|
143
|
+
* the starter plus a backend the platform runs, so the first deploy works without further steps.
|
|
144
|
+
*/
|
|
145
|
+
template?: "blank" | "starter" | "full";
|
|
134
146
|
portal?: {
|
|
135
147
|
name?: string;
|
|
136
148
|
docs?: string[] | false;
|
|
@@ -176,6 +188,10 @@ export type AddOptions = {
|
|
|
176
188
|
|
|
177
189
|
export type PlannedFile = { path: string; content: string };
|
|
178
190
|
|
|
191
|
+
/** The templates that fill the new files with examples. */
|
|
192
|
+
const isFilled = (template: AddOptions["template"]) =>
|
|
193
|
+
template === "starter" || template === "full";
|
|
194
|
+
|
|
179
195
|
export type ServiceEdit = {
|
|
180
196
|
service: ServiceName;
|
|
181
197
|
action: "add" | "remove";
|
|
@@ -397,7 +413,7 @@ async function buildPortal(
|
|
|
397
413
|
const pages = path.join(resolved.root, "portal", "pages");
|
|
398
414
|
if (template === "blank")
|
|
399
415
|
await addFile(context, path.join(pages, ".gitkeep"), "");
|
|
400
|
-
else if (options.template
|
|
416
|
+
else if (isFilled(options.template)) {
|
|
401
417
|
// The starter has a full home page of its own, so the docs start at /introduction, where the page links to.
|
|
402
418
|
await addFile(
|
|
403
419
|
context,
|
|
@@ -447,20 +463,30 @@ async function buildPortal(
|
|
|
447
463
|
|
|
448
464
|
/**
|
|
449
465
|
* A managed gateway runs the code in its source folder, and a new project has none. The folder is made (a `.gitkeep`
|
|
450
|
-
* keeps it in git) and the user is told it is empty
|
|
466
|
+
* keeps it in git) and the user is told it is empty; the full template writes a small Node.js server there instead.
|
|
467
|
+
* A folder that has files is left as it is. Returns whether the sample server was written.
|
|
451
468
|
*/
|
|
452
469
|
async function createSourceFolder(
|
|
453
470
|
context: Context,
|
|
454
471
|
sourcePath: string,
|
|
455
|
-
|
|
472
|
+
runtime: Runtime,
|
|
473
|
+
): Promise<boolean> {
|
|
456
474
|
const folder = path.join(context.dir, sourcePath);
|
|
457
475
|
const entries = await readdir(folder).catch(() => [] as string[]);
|
|
458
|
-
if (entries.some((name) => name !== ".gitkeep")) return;
|
|
476
|
+
if (entries.some((name) => name !== ".gitkeep")) return false;
|
|
477
|
+
if (context.options.template === "full" && runtime === FULL_SERVER_RUNTIME) {
|
|
478
|
+
for (const [file, content] of Object.entries(
|
|
479
|
+
fullServerFiles(context.resolved.project.name),
|
|
480
|
+
))
|
|
481
|
+
await addFile(context, path.join(folder, file), content);
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
459
484
|
if (entries.length === 0)
|
|
460
485
|
await addFile(context, path.join(folder, ".gitkeep"), "");
|
|
461
486
|
context.notes.push(
|
|
462
487
|
`The folder ${sourcePath}/ is empty: put your backend code there before you deploy.`,
|
|
463
488
|
);
|
|
489
|
+
return false;
|
|
464
490
|
}
|
|
465
491
|
|
|
466
492
|
async function buildGateway(
|
|
@@ -469,9 +495,11 @@ async function buildGateway(
|
|
|
469
495
|
const { options, dir, resolved } = context;
|
|
470
496
|
const o = options.gateway ?? {};
|
|
471
497
|
const ask = options.ask;
|
|
498
|
+
// The full template runs a backend of its own, so it asks nothing about the origin.
|
|
499
|
+
const full = options.template === "full" && !o.url && o.origin !== "proxy";
|
|
472
500
|
let mode =
|
|
473
501
|
o.origin ??
|
|
474
|
-
(o.url ? "proxy" : o.source || o.runtime ? "managed" : undefined);
|
|
502
|
+
(o.url ? "proxy" : o.source || o.runtime || full ? "managed" : undefined);
|
|
475
503
|
if (!mode && ask) {
|
|
476
504
|
mode = await ask.choose("How does the gateway reach your API?", [
|
|
477
505
|
{
|
|
@@ -492,6 +520,8 @@ async function buildGateway(
|
|
|
492
520
|
);
|
|
493
521
|
}
|
|
494
522
|
let origin: NonNullable<ConfigV2["gateway"]>["origin"];
|
|
523
|
+
// The sample server was written, so the sample OpenAPI file can describe its endpoints.
|
|
524
|
+
let sampleServer = false;
|
|
495
525
|
if (mode === "proxy") {
|
|
496
526
|
const url =
|
|
497
527
|
o.url ??
|
|
@@ -506,6 +536,7 @@ async function buildGateway(
|
|
|
506
536
|
} else {
|
|
507
537
|
const source =
|
|
508
538
|
o.source ??
|
|
539
|
+
(full ? FULL_SERVER_SOURCE : undefined) ??
|
|
509
540
|
(ask
|
|
510
541
|
? await ask.text("Folder with your code, relative to the project", {
|
|
511
542
|
default: "server",
|
|
@@ -513,6 +544,7 @@ async function buildGateway(
|
|
|
513
544
|
: undefined);
|
|
514
545
|
const runtime =
|
|
515
546
|
o.runtime ??
|
|
547
|
+
(full ? FULL_SERVER_RUNTIME : undefined) ??
|
|
516
548
|
(ask
|
|
517
549
|
? await ask.choose("Runtime of your code", [
|
|
518
550
|
{ value: "nodejs" as const, label: "Node.js" },
|
|
@@ -523,16 +555,18 @@ async function buildGateway(
|
|
|
523
555
|
const runtimeVersion =
|
|
524
556
|
o.runtimeVersion !== undefined && runtime
|
|
525
557
|
? runtimeVersionSlug(runtime, o.runtimeVersion)
|
|
526
|
-
:
|
|
527
|
-
?
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
558
|
+
: full && runtime === FULL_SERVER_RUNTIME
|
|
559
|
+
? FULL_SERVER_RUNTIME_VERSION
|
|
560
|
+
: ask && runtime
|
|
561
|
+
? await ask.choose(
|
|
562
|
+
"Runtime version",
|
|
563
|
+
RUNTIME_VERSIONS[runtime].map((choice, index) => ({
|
|
564
|
+
value: choice.value as string,
|
|
565
|
+
label:
|
|
566
|
+
index === 0 ? `${choice.label} (suggested)` : choice.label,
|
|
567
|
+
})),
|
|
568
|
+
)
|
|
569
|
+
: undefined;
|
|
536
570
|
if (!source || !runtime || !runtimeVersion) {
|
|
537
571
|
throw new ServiceEditError(
|
|
538
572
|
"--origin managed needs --source, --runtime and --runtime-version.",
|
|
@@ -542,14 +576,17 @@ async function buildGateway(
|
|
|
542
576
|
);
|
|
543
577
|
}
|
|
544
578
|
const sourcePath = insideProject(dir, source, "Source folder");
|
|
579
|
+
sampleServer = await createSourceFolder(context, sourcePath, runtime);
|
|
545
580
|
origin = {
|
|
546
581
|
mode,
|
|
547
582
|
source: sourcePath,
|
|
548
583
|
runtime,
|
|
549
584
|
runtimeVersion,
|
|
550
585
|
...(o.exclude?.length ? { exclude: o.exclude } : {}),
|
|
586
|
+
...(sampleServer ? { healthPath: FULL_SERVER_HEALTH_PATH } : {}),
|
|
587
|
+
// A full project's API is hosted by the platform and sleeps when idle; the first call must outlast its start.
|
|
588
|
+
...(full ? { timeoutSeconds: FULL_SERVER_TIMEOUT_SECONDS } : {}),
|
|
551
589
|
};
|
|
552
|
-
await createSourceFolder(context, sourcePath);
|
|
553
590
|
}
|
|
554
591
|
|
|
555
592
|
let specs: string[];
|
|
@@ -591,7 +628,9 @@ async function buildGateway(
|
|
|
591
628
|
target,
|
|
592
629
|
options.template === "blank"
|
|
593
630
|
? BLANK_SPEC(resolved.project.name)
|
|
594
|
-
:
|
|
631
|
+
: sampleServer
|
|
632
|
+
? FULL_SPEC(resolved.project.name)
|
|
633
|
+
: STARTER_SPEC(resolved.project.name),
|
|
595
634
|
);
|
|
596
635
|
specs = [rel(dir, target)];
|
|
597
636
|
} else {
|
|
@@ -706,7 +745,7 @@ async function buildPlans(
|
|
|
706
745
|
o.template ??
|
|
707
746
|
(options.template === "blank"
|
|
708
747
|
? "none"
|
|
709
|
-
: options.template
|
|
748
|
+
: isFilled(options.template)
|
|
710
749
|
? "starter"
|
|
711
750
|
: "free");
|
|
712
751
|
// Only paid plans need a billing provider. A catalog of free plans takes no payment, so its default is `none` and the
|
package/src/config/v2/wrapper.ts
CHANGED
|
@@ -69,7 +69,8 @@ export type PortalPlan = {
|
|
|
69
69
|
};
|
|
70
70
|
/** Pricing, subscriptions, API keys and usage pages. */
|
|
71
71
|
plansPlugin?: {
|
|
72
|
-
|
|
72
|
+
/** The plans backend; absent in a demo preview, where the pages show sample data. */
|
|
73
|
+
apiBaseUrl?: string;
|
|
73
74
|
keysApiBaseUrl?: string;
|
|
74
75
|
/** The billing provider of the plans service (`none`, `stripe`, ...). `none`: the pages offer no payment. */
|
|
75
76
|
billing?: string;
|
|
@@ -84,6 +85,11 @@ export type PortalPlan = {
|
|
|
84
85
|
skipped: { piece: string; reason: string }[];
|
|
85
86
|
/** True only for a stub manifest. Production builds refuse it. */
|
|
86
87
|
preview: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* A preview with no deployed identity: the portal signs in with the demo account instead, and the account and plans
|
|
90
|
+
* pages show sample data. Never set outside a preview.
|
|
91
|
+
*/
|
|
92
|
+
demo?: boolean;
|
|
87
93
|
};
|
|
88
94
|
|
|
89
95
|
/** npm packages the generated site needs, beyond `@lukoweb/apitogo`, for what the plan registers. */
|
|
@@ -203,6 +209,20 @@ export function planPortal(
|
|
|
203
209
|
return plan;
|
|
204
210
|
}
|
|
205
211
|
|
|
212
|
+
// Before the first deploy there is no identity to sign in with. A preview signs in with the demo account instead,
|
|
213
|
+
// and the account and plans pages show sample data, so the console can be tried locally.
|
|
214
|
+
if (plan.preview && identity && !identity.issuer) {
|
|
215
|
+
plan.demo = true;
|
|
216
|
+
plan.userPanel = true;
|
|
217
|
+
skip("sign-in", "preview: the demo account signs in");
|
|
218
|
+
if (plans)
|
|
219
|
+
plan.plansPlugin = {
|
|
220
|
+
billing: "none",
|
|
221
|
+
links: plan.apiReference ? { api: `/${API_REFERENCE_PATH}` } : {},
|
|
222
|
+
};
|
|
223
|
+
return plan;
|
|
224
|
+
}
|
|
225
|
+
|
|
206
226
|
if (!identity) {
|
|
207
227
|
skip("sign-in", "services.json has no identity entry");
|
|
208
228
|
} else if (!serviceReady(identity)) {
|
|
@@ -274,6 +294,10 @@ export function buildSiteConfig(
|
|
|
274
294
|
extras: {
|
|
275
295
|
redirects?: HomeRedirect[];
|
|
276
296
|
docsNavigation?: unknown[];
|
|
297
|
+
/** The first docs page, linked from the header next to the API reference. */
|
|
298
|
+
docsPath?: string;
|
|
299
|
+
/** A demo preview's plan catalog, from `plans.json`; the pages read it from the site config. */
|
|
300
|
+
demoPlans?: unknown[];
|
|
277
301
|
protectedRoutes?: string[];
|
|
278
302
|
} = {},
|
|
279
303
|
): Record<string, unknown> {
|
|
@@ -346,22 +370,29 @@ export function buildSiteConfig(
|
|
|
346
370
|
}
|
|
347
371
|
: {}),
|
|
348
372
|
...(navigation.length > 0 ? { navigation } : {}),
|
|
349
|
-
// The sidebar link above is only seen inside the docs; the header is what every page shows.
|
|
373
|
+
// The sidebar link above is only seen inside the docs; the header is what every page shows. A header navigation
|
|
374
|
+
// replaces the docs tab, so the docs get a header link of their own.
|
|
350
375
|
...(plan.apiReference
|
|
351
376
|
? {
|
|
352
377
|
header: {
|
|
353
378
|
navigation: [
|
|
379
|
+
...(plan.docs && extras.docsPath
|
|
380
|
+
? [{ label: "Documentation", to: extras.docsPath }]
|
|
381
|
+
: []),
|
|
354
382
|
{ label: "API Reference", to: `/${API_REFERENCE_PATH}` },
|
|
355
383
|
],
|
|
356
384
|
},
|
|
357
385
|
}
|
|
358
386
|
: {}),
|
|
387
|
+
...(extras.demoPlans?.length ? { plans: extras.demoPlans } : {}),
|
|
359
388
|
...(redirects.length > 0 ? { redirects } : {}),
|
|
360
389
|
...(extras.protectedRoutes?.length
|
|
361
390
|
? { protectedRoutes: extras.protectedRoutes }
|
|
362
391
|
: {}),
|
|
363
392
|
...(theme !== undefined ? { theme } : {}),
|
|
364
|
-
...(plan.auth
|
|
393
|
+
...(plan.auth || plan.demo
|
|
394
|
+
? { authentication: authenticationFor(plan) }
|
|
395
|
+
: {}),
|
|
365
396
|
...(plan.preview ? { preview: true } : {}),
|
|
366
397
|
};
|
|
367
398
|
}
|
|
@@ -373,6 +404,8 @@ export function buildSiteConfig(
|
|
|
373
404
|
* form. Without the plans service there is no backend, and the portal signs in with the identity directly (OpenID).
|
|
374
405
|
*/
|
|
375
406
|
function authenticationFor(plan: PortalPlan): Record<string, unknown> {
|
|
407
|
+
// The demo form: demo@apitogo.local / password, only in a local run.
|
|
408
|
+
if (plan.demo) return { type: "dev-portal", demoMode: true };
|
|
376
409
|
if (plan.plansPlugin)
|
|
377
410
|
return {
|
|
378
411
|
type: "dev-portal",
|
|
@@ -389,7 +422,7 @@ function authenticationFor(plan: PortalPlan): Record<string, unknown> {
|
|
|
389
422
|
|
|
390
423
|
/** Shown on every page of a preview run, so a stand-in manifest is never mistaken for a deployed portal. */
|
|
391
424
|
export const PREVIEW_BANNER_MESSAGE =
|
|
392
|
-
"Preview mode: sign
|
|
425
|
+
"Preview mode: sign in with demo@apitogo.local / password to try the console. Run `apitogo deploy` for the real services.";
|
|
393
426
|
|
|
394
427
|
/** The wrapper source. Imports exist only for what the plan registers. */
|
|
395
428
|
export function renderWrapper(plan: PortalPlan): string {
|
|
@@ -423,6 +456,24 @@ export function renderWrapper(plan: PortalPlan): string {
|
|
|
423
456
|
].join("\n");
|
|
424
457
|
}
|
|
425
458
|
|
|
459
|
+
/**
|
|
460
|
+
* The plans of the project's `plans.json`, for the pricing and account pages of a demo preview: with no plans backend
|
|
461
|
+
* they read their catalog from the site config. A missing or unreadable file gives no plans; `check` reports it.
|
|
462
|
+
*/
|
|
463
|
+
async function readDemoPlans(
|
|
464
|
+
file: string | undefined,
|
|
465
|
+
): Promise<unknown[] | undefined> {
|
|
466
|
+
if (!file) return undefined;
|
|
467
|
+
try {
|
|
468
|
+
const parsed = JSON.parse(await readFile(file, "utf8")) as {
|
|
469
|
+
plans?: unknown;
|
|
470
|
+
};
|
|
471
|
+
return Array.isArray(parsed.plans) ? parsed.plans : undefined;
|
|
472
|
+
} catch {
|
|
473
|
+
return undefined;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
426
477
|
/** The portal theme file, when it exists and parses. `check` reports an invalid one; the build just skips it. */
|
|
427
478
|
async function readTheme(resolved: ResolvedConfigV2): Promise<unknown> {
|
|
428
479
|
const file = resolved.portal?.theme;
|
|
@@ -567,6 +618,11 @@ export async function preparePortal(
|
|
|
567
618
|
{
|
|
568
619
|
redirects: home ? [home] : [],
|
|
569
620
|
docsNavigation: docsNavigation(hostedDocRoutes),
|
|
621
|
+
docsPath: pickLandingRoute(hostedDocRoutes),
|
|
622
|
+
demoPlans:
|
|
623
|
+
plan.demo && plan.plansPlugin
|
|
624
|
+
? await readDemoPlans(resolved.plans?.file)
|
|
625
|
+
: undefined,
|
|
570
626
|
protectedRoutes: protectedRoutesFor({
|
|
571
627
|
requireAuth: resolved.portal?.requireAuth,
|
|
572
628
|
hasAuth: !!plan.auth,
|