handoff-app 2.0.0-0 → 2.0.0-2
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 +39 -17
- package/config/docs/foundations/colors/guidelines.md +9 -0
- package/config/docs/foundations/effects/guidelines.md +9 -0
- package/config/docs/foundations/effects/resources.md +9 -0
- package/config/docs/foundations/grid.md +10 -0
- package/config/docs/foundations/icons/guidelines.md +9 -0
- package/config/docs/foundations/images/guidelines.md +9 -0
- package/config/docs/foundations/images/resources.md +9 -0
- package/config/docs/foundations/images.md +10 -0
- package/config/docs/foundations/logo/resources.md +9 -0
- package/config/docs/foundations/typography/guidelines.md +9 -0
- package/config/docs/foundations/typography/resources.md +9 -0
- package/config/docs/foundations.md +5 -3
- package/dist/app-builder/build.js +89 -46
- package/dist/app-builder/client-config.js +5 -5
- package/dist/app-builder/paths.js +23 -1
- package/dist/app-builder/static-export.js +25 -0
- package/dist/app-builder/vercel-output.js +2 -2
- package/dist/artifacts/index.d.ts +1 -1
- package/dist/artifacts/index.js +4 -1
- package/dist/artifacts/url.d.ts +18 -0
- package/dist/artifacts/url.js +31 -1
- package/dist/cli/create.js +48 -3
- package/dist/commands/checkout/index.d.ts +6 -6
- package/dist/commands/checkout/index.js +8 -12
- package/dist/commands/publish/index.d.ts +6 -5
- package/dist/commands/publish/index.js +8 -11
- package/dist/commands/utils.d.ts +6 -0
- package/dist/commands/utils.js +9 -1
- package/dist/index.d.ts +12 -8
- package/dist/index.js +26 -10
- package/dist/registry/asset-storage/resolve.d.ts +1 -1
- package/dist/registry/asset-storage/resolve.js +1 -1
- package/dist/registry/checkout/index.d.ts +10 -5
- package/dist/registry/checkout/index.js +62 -11
- package/dist/registry/client.d.ts +2 -1
- package/dist/registry/client.js +8 -0
- package/dist/registry/db/client.d.ts +9 -9
- package/dist/registry/db/client.js +7 -7
- package/dist/registry/db/{adapter.d.ts → driver.d.ts} +16 -14
- package/dist/registry/db/{adapter.js → driver.js} +17 -15
- package/dist/registry/db/index.d.ts +1 -1
- package/dist/registry/db/index.js +9 -9
- package/dist/registry/db/migrate.d.ts +1 -1
- package/dist/registry/db/migrate.js +5 -5
- package/dist/registry/publish/index.d.ts +8 -1
- package/dist/registry/publish/index.js +88 -2
- package/dist/registry/transfer.d.ts +13 -0
- package/dist/types/config.d.ts +12 -8
- package/drizzle.config.ts +1 -1
- package/package.json +1 -1
- package/src/app/components/Foundations/DisplayLogo.tsx +8 -2
- package/src/app/lib/docs-api/runtime-config.ts +10 -10
- package/src/app/lib/registry-api/transfer.ts +29 -0
- package/src/app/lib/registry-connection.ts +2 -2
- package/src/app/next.config.mjs +1 -1
- package/src/app/pages/api/registry/transfer/[kind]/index.ts +21 -0
- package/src/app/pages/assets/logos.tsx +4 -1
- package/src/app/pages/foundations/icons/index.tsx +7 -5
- package/src/app/pages/foundations/icons/{[name] → library/[name]}/index.tsx +8 -8
- package/src/app/pages/foundations/logo.tsx +5 -4
- package/src/app-builder/build.ts +75 -55
- package/src/app-builder/client-config.ts +3 -3
- package/src/app-builder/paths.ts +25 -1
- package/src/app-builder/static-export.ts +27 -0
- package/src/app-builder/vercel-output.ts +2 -2
- package/src/artifacts/index.ts +8 -1
- package/src/artifacts/url.ts +32 -0
- package/src/cli/create.ts +58 -3
- package/src/commands/checkout/index.ts +10 -15
- package/src/commands/publish/index.ts +10 -14
- package/src/commands/utils.ts +12 -0
- package/src/index.ts +26 -12
- package/src/registry/asset-storage/resolve.ts +1 -1
- package/src/registry/checkout/index.ts +73 -11
- package/src/registry/client.ts +7 -1
- package/src/registry/db/client.ts +12 -12
- package/src/registry/db/{adapter.ts → driver.ts} +19 -17
- package/src/registry/db/index.ts +4 -4
- package/src/registry/db/migrate.ts +5 -5
- package/src/registry/publish/index.ts +100 -2
- package/src/registry/transfer.ts +14 -0
- package/src/types/config.ts +12 -8
- package/templates/default/README.md.tpl +40 -1
- package/templates/default/gitignore.tpl +18 -3
- package/templates/default/handoff.config.js.tpl +14 -0
- package/templates/default/handoff.config.ts.tpl +14 -0
- package/templates/default/handoff.config.with-examples.js.tpl +14 -0
- package/templates/default/handoff.config.with-examples.ts.tpl +14 -0
- package/templates/default/package.json.tpl +2 -1
- package/templates/default/vercel.json.tpl +4 -0
|
@@ -432,6 +432,35 @@ const upsertBuildMetadata = async (db: RegistryDatabase, kind: TransferEntityKin
|
|
|
432
432
|
.onConflictDoUpdate({ target: [buildMetadata.entityKind, buildMetadata.entityId], set: values as any });
|
|
433
433
|
};
|
|
434
434
|
|
|
435
|
+
/**
|
|
436
|
+
* Handles `GET /api/registry/transfer/{component|pattern|page}`. Lists summaries for one kind of
|
|
437
|
+
* entity: each id plus its build hashes and status. A connected workspace uses these to skip
|
|
438
|
+
* unchanged entities on a bulk publish and to enumerate published ids for a bulk checkout. The read
|
|
439
|
+
* is unauthenticated; only the registry-runtime and method guards apply.
|
|
440
|
+
*/
|
|
441
|
+
export const handleEntitySummaryRoute = (req: NextApiRequest, res: NextApiResponse, kind: TransferEntityKind): Promise<void> =>
|
|
442
|
+
handleRegistryRoute(req, res, ['GET'], async ({ db }) => {
|
|
443
|
+
const table = ENTITY[kind].table;
|
|
444
|
+
const rows = await db
|
|
445
|
+
.select({
|
|
446
|
+
id: table.id,
|
|
447
|
+
status: buildMetadata.status,
|
|
448
|
+
artifactHash: buildMetadata.artifactHash,
|
|
449
|
+
sourceHash: buildMetadata.sourceHash,
|
|
450
|
+
})
|
|
451
|
+
.from(table)
|
|
452
|
+
.leftJoin(buildMetadata, and(eq(buildMetadata.entityKind, kind), eq(buildMetadata.entityId, table.id)));
|
|
453
|
+
|
|
454
|
+
const entities = rows.map((row) => ({
|
|
455
|
+
id: row.id,
|
|
456
|
+
kind,
|
|
457
|
+
status: row.status ?? undefined,
|
|
458
|
+
artifactHash: row.artifactHash ?? undefined,
|
|
459
|
+
sourceHash: row.sourceHash ?? undefined,
|
|
460
|
+
}));
|
|
461
|
+
sendRegistryData(res, 200, { entities }, buildMeta());
|
|
462
|
+
});
|
|
463
|
+
|
|
435
464
|
/**
|
|
436
465
|
* Handle `GET /api/registry/transfer/{component|pattern}/:id` — checkout read. Returns the
|
|
437
466
|
* normalized record plus its registry-safe source files so a connected workspace can reconstruct
|
|
@@ -7,7 +7,7 @@ import { getServerRuntimeConfig } from './docs-api/runtime-config';
|
|
|
7
7
|
* Both the registry-mode docs read API and the registry management API resolve their database
|
|
8
8
|
* through this single helper so one connection pool backs the whole registry runtime rather than
|
|
9
9
|
* one pool per consumer. The connection string is read from the configured env var *name* at
|
|
10
|
-
* request time — never persisted in config — and the selected
|
|
10
|
+
* request time — never persisted in config — and the selected driver comes from the server runtime
|
|
11
11
|
* config baked at build time.
|
|
12
12
|
*
|
|
13
13
|
* Server-only: imported exclusively by registry-mode API route helpers, which are themselves only
|
|
@@ -48,7 +48,7 @@ export const getRegistryConnection = (): Promise<RegistryDbConnection> => {
|
|
|
48
48
|
)
|
|
49
49
|
);
|
|
50
50
|
}
|
|
51
|
-
connectionPromise = createRegistryDbConnection({
|
|
51
|
+
connectionPromise = createRegistryDbConnection({ driver: registry.driver, connectionString }).catch((error) => {
|
|
52
52
|
connectionPromise = null;
|
|
53
53
|
// TODO: Route server errors through the shared logger.
|
|
54
54
|
console.error('Registry database connection failed.', error);
|
package/src/app/next.config.mjs
CHANGED
|
@@ -95,7 +95,7 @@ const nextConfig = {
|
|
|
95
95
|
// config-only — these are derived from `runtime.*`, not inferred from env at runtime. The DB
|
|
96
96
|
// connection string itself is read from the named env var at request time (deployment-supplied).
|
|
97
97
|
HANDOFF_RUNTIME_MODE: '%HANDOFF_RUNTIME_MODE%',
|
|
98
|
-
|
|
98
|
+
HANDOFF_REGISTRY_DRIVER: '%HANDOFF_REGISTRY_DRIVER%',
|
|
99
99
|
HANDOFF_REGISTRY_DATABASE_URL_ENV: '%HANDOFF_REGISTRY_DATABASE_URL_ENV%',
|
|
100
100
|
HANDOFF_REGISTRY_API_TOKEN_ENV: '%HANDOFF_REGISTRY_API_TOKEN_ENV%',
|
|
101
101
|
// Asset storage selection (provider + non-secret options + env-var names). Secret values (Blob
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { NextApiRequest, NextApiResponse } from 'next';
|
|
2
|
+
import { singleQueryValue } from '@/lib/api/query';
|
|
3
|
+
import { handleRegistryRoute, sendRegistryError } from '@/lib/registry-api';
|
|
4
|
+
import { handleEntitySummaryRoute } from '@/lib/registry-api/transfer';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `GET /api/registry/transfer/{component|pattern|page}`. Lists the registry's published entity
|
|
8
|
+
* summaries for one kind (id, build hashes, status) so a connected workspace can skip unchanged
|
|
9
|
+
* entities on a bulk publish and enumerate published ids for a bulk checkout. Registry-runtime only,
|
|
10
|
+
* enforced by the guard stack. The `tokens` and `assets` summaries have their own static routes,
|
|
11
|
+
* which Next resolves before this dynamic segment.
|
|
12
|
+
*/
|
|
13
|
+
export default function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
|
|
14
|
+
const kind = singleQueryValue(req.query.kind);
|
|
15
|
+
if (kind !== 'component' && kind !== 'pattern' && kind !== 'page') {
|
|
16
|
+
return handleRegistryRoute(req, res, ['GET'], async () => {
|
|
17
|
+
sendRegistryError(res, 'not_found', `Unknown transfer entity kind "${kind ?? ''}".`);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return handleEntitySummaryRoute(req, res, kind);
|
|
21
|
+
}
|
|
@@ -6,6 +6,7 @@ import rehypeRaw from 'rehype-raw';
|
|
|
6
6
|
import remarkGfm from 'remark-gfm';
|
|
7
7
|
import Footer from '../../components/Footer';
|
|
8
8
|
import { MarkdownComponents, remarkCodeMeta } from '../../components/Markdown/MarkdownComponents';
|
|
9
|
+
import { buildAssetDownloadUrl } from '@handoff/artifacts/url';
|
|
9
10
|
import Header from '../../components/old/Header';
|
|
10
11
|
import { DocumentationProps, fetchDocPageMarkdown, getClientRuntimeConfig } from '../../components/util';
|
|
11
12
|
|
|
@@ -52,7 +53,9 @@ const AssetsLogosPage = ({ content, menu, metadata, config }: DocumentationProps
|
|
|
52
53
|
<h4>{config?.app?.client} Logo</h4>
|
|
53
54
|
<p>Vector files of approved {config?.app?.client} logos.</p>
|
|
54
55
|
<p>
|
|
55
|
-
<a href={config?.assets_zip_links?.logos ?? '
|
|
56
|
+
<a href={config?.assets_zip_links?.logos ?? buildAssetDownloadUrl('logos', process.env.HANDOFF_APP_BASE_PATH)} download>
|
|
57
|
+
Download Logos
|
|
58
|
+
</a>
|
|
56
59
|
</p>
|
|
57
60
|
</div>
|
|
58
61
|
</div>
|
|
@@ -12,6 +12,7 @@ import { MarkdownComponents, remarkCodeMeta } from '../../../components/Markdown
|
|
|
12
12
|
import HeadersType from '../../../components/Typography/Headers';
|
|
13
13
|
import { buttonVariants } from '../../../components/ui/button';
|
|
14
14
|
import { Input } from '../../../components/ui/input';
|
|
15
|
+
import { buildAssetDownloadUrl } from '@handoff/artifacts/url';
|
|
15
16
|
import { AssetDocumentationProps, buildTimeAssets, fetchDocPageMarkdown, getClientRuntimeConfig } from '../../../components/util';
|
|
16
17
|
import { useCollectionAssets, type DisplayableAsset } from '../../../components/util/useCollectionAssets';
|
|
17
18
|
|
|
@@ -35,13 +36,13 @@ export const DisplayIcon: React.FC<{ icon: DisplayableAsset }> = ({ icon }) => {
|
|
|
35
36
|
|
|
36
37
|
return (
|
|
37
38
|
<div className="flex flex-col gap-2">
|
|
38
|
-
<Link href={`/foundations/icons/${icon.icon}`}>
|
|
39
|
+
<Link href={`/foundations/icons/library/${icon.icon}`}>
|
|
39
40
|
<div className="flex flex-col items-center gap-6 rounded-lg border border-gray-100/80 bg-gray-100/80 py-12 transition-all hover:border-gray-300 hover:bg-gray-100 dark:border-gray-800 dark:bg-gray-900 dark:hover:border-gray-700 dark:hover:bg-gray-900">
|
|
40
41
|
{/* Workspace/static: inline SVG body. Registry: the individual asset content URL. */}
|
|
41
42
|
{icon.data ? <div dangerouslySetInnerHTML={{ __html: htmlData }} /> : <img className="o-icon h-6 w-6" src={icon.src} alt={icon.name} />}
|
|
42
43
|
</div>
|
|
43
44
|
</Link>
|
|
44
|
-
<Link href={`/foundations/icons/${icon.icon}`}>
|
|
45
|
+
<Link href={`/foundations/icons/library/${icon.icon}`}>
|
|
45
46
|
<p className="font-mono text-[11px]">{icon.icon}</p>
|
|
46
47
|
</Link>
|
|
47
48
|
</div>
|
|
@@ -94,12 +95,13 @@ const IconsPage = ({ content, menu, metadata, current, config, assets }: AssetDo
|
|
|
94
95
|
<HeadersType.H1>{metadata.title}</HeadersType.H1>
|
|
95
96
|
<p className="max-w-[800px] text-lg font-light text-gray-500 dark:text-gray-300">{metadata.description}</p>
|
|
96
97
|
<div className="mt-3 flex flex-row gap-3">
|
|
97
|
-
<
|
|
98
|
+
<a
|
|
98
99
|
className={buttonVariants({ variant: 'outline', size: 'sm' }) + ' font-normal [&_svg]:size-3!'}
|
|
99
|
-
href={config?.assets_zip_links?.icons ?? '
|
|
100
|
+
href={config?.assets_zip_links?.icons ?? buildAssetDownloadUrl('icons', process.env.HANDOFF_APP_BASE_PATH)}
|
|
101
|
+
download
|
|
100
102
|
>
|
|
101
103
|
Download Icons <Download strokeWidth={1.5} />
|
|
102
|
-
</
|
|
104
|
+
</a>
|
|
103
105
|
</div>
|
|
104
106
|
</div>
|
|
105
107
|
<hr className="mb-10" />
|
|
@@ -5,12 +5,12 @@ import { GetStaticProps } from 'next';
|
|
|
5
5
|
import Link from 'next/link';
|
|
6
6
|
import { useRouter } from 'next/router';
|
|
7
7
|
import * as React from 'react';
|
|
8
|
-
import Footer from '
|
|
9
|
-
import Layout from '
|
|
10
|
-
import HeadersType from '
|
|
11
|
-
import { buttonVariants } from '
|
|
12
|
-
import { AssetDocumentationProps, buildTimeAssets, fetchDocPageMarkdown, getClientRuntimeConfig, getTokens, isRegistryRuntime } from '
|
|
13
|
-
import { useSingleAsset, type DisplayableAsset } from '
|
|
8
|
+
import Footer from '../../../../../components/Footer';
|
|
9
|
+
import Layout from '../../../../../components/Layout/Main';
|
|
10
|
+
import HeadersType from '../../../../../components/Typography/Headers';
|
|
11
|
+
import { buttonVariants } from '../../../../../components/ui/button';
|
|
12
|
+
import { AssetDocumentationProps, buildTimeAssets, fetchDocPageMarkdown, getClientRuntimeConfig, getTokens, isRegistryRuntime } from '../../../../../components/util';
|
|
13
|
+
import { useSingleAsset, type DisplayableAsset } from '../../../../../components/util/useCollectionAssets';
|
|
14
14
|
|
|
15
15
|
const DisplayIcon: React.FC<{ icon: DisplayableAsset }> = ({ icon }) => {
|
|
16
16
|
const htmlData = React.useMemo(() => {
|
|
@@ -124,13 +124,13 @@ export default function SingleIcon({ menu, metadata, current, config, assets }:
|
|
|
124
124
|
Copy SVG <Code strokeWidth={1.5} />
|
|
125
125
|
</Link>
|
|
126
126
|
|
|
127
|
-
<
|
|
127
|
+
<a
|
|
128
128
|
href={icon.src ?? 'data:text/plain;charset=utf-8,' + encodeURIComponent(icon.data ?? '')}
|
|
129
129
|
download={icon.name}
|
|
130
130
|
className={buttonVariants({ variant: 'outline', size: 'sm' }) + ' font-normal [&_svg]:size-3!'}
|
|
131
131
|
>
|
|
132
132
|
Download SVG <Download strokeWidth={1.5} />
|
|
133
|
-
</
|
|
133
|
+
</a>
|
|
134
134
|
</div>
|
|
135
135
|
</div>
|
|
136
136
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { GetStaticProps } from 'next';
|
|
2
|
-
import Link from 'next/link';
|
|
3
2
|
|
|
3
|
+
import { buildAssetDownloadUrl } from '@handoff/artifacts/url';
|
|
4
4
|
import { AssetDocumentationProps, buildTimeAssets, fetchDocPageMarkdown, getClientRuntimeConfig } from '../../components/util';
|
|
5
5
|
import { useCollectionAssets } from '../../components/util/useCollectionAssets';
|
|
6
6
|
|
|
@@ -56,12 +56,13 @@ const LogoPage = ({ content, menu, metadata, current, config, assets }: AssetDoc
|
|
|
56
56
|
<HeadersType.H1>{metadata.title}</HeadersType.H1>
|
|
57
57
|
<p className="text-lg leading-relaxed text-gray-600 dark:text-gray-300">{metadata.description}</p>
|
|
58
58
|
<div className="mt-3 flex flex-row gap-3">
|
|
59
|
-
<
|
|
59
|
+
<a
|
|
60
60
|
className={buttonVariants({ variant: 'outline', size: 'sm' }) + ' font-normal [&_svg]:size-3!'}
|
|
61
|
-
href={config?.assets_zip_links?.logos ?? '
|
|
61
|
+
href={config?.assets_zip_links?.logos ?? buildAssetDownloadUrl('logos', process.env.HANDOFF_APP_BASE_PATH)}
|
|
62
|
+
download
|
|
62
63
|
>
|
|
63
64
|
Download Logos <Download strokeWidth={1.5} />
|
|
64
|
-
</
|
|
65
|
+
</a>
|
|
65
66
|
</div>
|
|
66
67
|
</div>
|
|
67
68
|
|
package/src/app-builder/build.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
1
2
|
import spawn from 'cross-spawn';
|
|
2
3
|
import fs from 'fs-extra';
|
|
3
4
|
import path from 'path';
|
|
@@ -7,7 +8,7 @@ import { buildPatterns } from '../pipeline/patterns';
|
|
|
7
8
|
import processComponents from '../transformers/preview/component/builder';
|
|
8
9
|
import { buildMainCss } from '../transformers/preview/component/css';
|
|
9
10
|
import { buildMainJS } from '../transformers/preview/component/javascript';
|
|
10
|
-
import { resolveApiTokenEnv, resolveDatabaseUrlEnv,
|
|
11
|
+
import { resolveApiTokenEnv, resolveDatabaseUrlEnv, resolveRegistryDriver } from '../registry/db/driver';
|
|
11
12
|
import { resolveAssetStorageFromConfig } from '../registry/asset-storage/resolve';
|
|
12
13
|
import type { RuntimeMode } from '../types/config';
|
|
13
14
|
import { Logger } from '../utils/logger';
|
|
@@ -104,6 +105,63 @@ const assertStaticBuildAllowed = (handoff: Handoff): void => {
|
|
|
104
105
|
}
|
|
105
106
|
};
|
|
106
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Run the Next.js production build while keeping its delayed output out of the successful CLI flow.
|
|
110
|
+
* Captured output is replayed only when the child fails so compiler diagnostics remain available.
|
|
111
|
+
*/
|
|
112
|
+
const runNextBuild = async (appPath: string, target: BuildTarget, errorLabel: string): Promise<void> => {
|
|
113
|
+
const spinner = p.spinner();
|
|
114
|
+
spinner.start('Building Next.js app...');
|
|
115
|
+
|
|
116
|
+
const stdout: Buffer[] = [];
|
|
117
|
+
const stderr: Buffer[] = [];
|
|
118
|
+
|
|
119
|
+
await new Promise<void>((resolve, reject) => {
|
|
120
|
+
const buildProcess = spawn('npx', ['next', 'build'], {
|
|
121
|
+
cwd: appPath,
|
|
122
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
123
|
+
env: {
|
|
124
|
+
...process.env,
|
|
125
|
+
NODE_ENV: 'production',
|
|
126
|
+
HANDOFF_BUILD_TARGET: target,
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
buildProcess.stdout?.on('data', (chunk: Buffer) => stdout.push(Buffer.from(chunk)));
|
|
131
|
+
buildProcess.stderr?.on('data', (chunk: Buffer) => stderr.push(Buffer.from(chunk)));
|
|
132
|
+
|
|
133
|
+
let settled = false;
|
|
134
|
+
|
|
135
|
+
const fail = (message: string): void => {
|
|
136
|
+
if (settled) return;
|
|
137
|
+
settled = true;
|
|
138
|
+
|
|
139
|
+
spinner.stop('Next.js app build failed');
|
|
140
|
+
Logger.childProcessBuffer(Buffer.concat(stdout));
|
|
141
|
+
Logger.childProcessBuffer(Buffer.concat(stderr));
|
|
142
|
+
reject(new Error(message));
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
buildProcess.once('error', (error) => {
|
|
146
|
+
fail(`${errorLabel} failed to start\nSpawn error: ${error.message}`);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
buildProcess.once('close', (code, signal) => {
|
|
150
|
+
if (settled) return;
|
|
151
|
+
|
|
152
|
+
if (code === 0) {
|
|
153
|
+
settled = true;
|
|
154
|
+
spinner.stop('Next.js app built successfully');
|
|
155
|
+
resolve();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const reason = signal ? ` because it was terminated by signal ${signal}` : ` with exit code ${code}`;
|
|
160
|
+
fail(`${errorLabel} failed${reason}`);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
|
|
107
165
|
/**
|
|
108
166
|
* Output directory for the packaged registry app. Lives under the configurable sites output
|
|
109
167
|
* directory (`sitesOutputDirectory`, default `out`) alongside the static target's `out/<projectId>`
|
|
@@ -198,7 +256,7 @@ const initializeProjectApp = async (handoff: Handoff, options: InitializeProject
|
|
|
198
256
|
// Resolved runtime mode + registry connection inputs (names only) baked into the Next bundle so the
|
|
199
257
|
// deployed registry app resolves its mode/DB env-var name without any build-machine filesystem.
|
|
200
258
|
const escapedRuntimeMode = escapeForSingleQuotedJsString(runtimeMode);
|
|
201
|
-
const
|
|
259
|
+
const escapedRegistryDriver = escapeForSingleQuotedJsString(resolveRegistryDriver(handoff.config));
|
|
202
260
|
const escapedDatabaseUrlEnv = escapeForSingleQuotedJsString(resolveDatabaseUrlEnv(handoff.config));
|
|
203
261
|
const escapedApiTokenEnv = escapeForSingleQuotedJsString(resolveApiTokenEnv(handoff.config));
|
|
204
262
|
// Asset storage selection baked (provider + module + env-var names + non-secret options JSON).
|
|
@@ -216,7 +274,7 @@ const initializeProjectApp = async (handoff: Handoff, options: InitializeProject
|
|
|
216
274
|
'%HANDOFF_EXPORT_PATH%': escapedExportPath,
|
|
217
275
|
'%HANDOFF_WEBSOCKET_PORT%': escapedWebsocketPort,
|
|
218
276
|
'%HANDOFF_RUNTIME_MODE%': escapedRuntimeMode,
|
|
219
|
-
'%
|
|
277
|
+
'%HANDOFF_REGISTRY_DRIVER%': escapedRegistryDriver,
|
|
220
278
|
'%HANDOFF_REGISTRY_DATABASE_URL_ENV%': escapedDatabaseUrlEnv,
|
|
221
279
|
'%HANDOFF_REGISTRY_API_TOKEN_ENV%': escapedApiTokenEnv,
|
|
222
280
|
'%HANDOFF_ASSET_STORAGE_ADAPTER%': escapedAssetStorageAdapter,
|
|
@@ -283,26 +341,7 @@ const buildApp = async (
|
|
|
283
341
|
|
|
284
342
|
// Build app. The static target drives Next's `output: 'export'` via HANDOFF_BUILD_TARGET so the
|
|
285
343
|
// export gate is tied to the resolved target, not to NODE_ENV.
|
|
286
|
-
|
|
287
|
-
cwd: appPath,
|
|
288
|
-
stdio: ['inherit', 'pipe', 'pipe'],
|
|
289
|
-
env: {
|
|
290
|
-
...process.env,
|
|
291
|
-
NODE_ENV: 'production',
|
|
292
|
-
HANDOFF_BUILD_TARGET: target,
|
|
293
|
-
},
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
Logger.childProcessBuffer(buildResult.stdout);
|
|
297
|
-
Logger.childProcessBuffer(buildResult.stderr);
|
|
298
|
-
|
|
299
|
-
if (buildResult.status !== 0) {
|
|
300
|
-
let errorMsg = `Next.js build failed with exit code ${buildResult.status}`;
|
|
301
|
-
if (buildResult.error) {
|
|
302
|
-
errorMsg += `\nSpawn error: ${buildResult.error.message}`;
|
|
303
|
-
}
|
|
304
|
-
throw new Error(errorMsg);
|
|
305
|
-
}
|
|
344
|
+
await runNextBuild(appPath, target, 'Next.js build');
|
|
306
345
|
|
|
307
346
|
// Reproduce the docs read API (`/api/docs/*`) as route-shaped static files in the export output,
|
|
308
347
|
// since `output: 'export'` disables the live API routes.
|
|
@@ -390,7 +429,7 @@ pipeline) before deploying new code:
|
|
|
390
429
|
${databaseUrlEnv}="postgres://…" handoff-app db:migrate
|
|
391
430
|
\`\`\`
|
|
392
431
|
|
|
393
|
-
\`db:migrate\` reads your project config + DB env vars, resolves the same database
|
|
432
|
+
\`db:migrate\` reads your project config + DB env vars, resolves the same database driver the app was
|
|
394
433
|
built with, and applies the package-owned migration set. It runs independently of \`build\` and of
|
|
395
434
|
starting the server, so run it as a release/one-shot job.
|
|
396
435
|
|
|
@@ -437,14 +476,14 @@ containers, custom Node servers, and other non-Vercel hosts.
|
|
|
437
476
|
|
|
438
477
|
/**
|
|
439
478
|
* Resolve the set of npm packages the packaged registry app must be able to `require`/`import` at
|
|
440
|
-
* runtime, given the configured database
|
|
479
|
+
* runtime, given the configured database driver. `next`/`react`/`react-dom` run the server and React
|
|
441
480
|
* runtime; `drizzle-orm` backs both the request-time DB client and the migration runner; the driver
|
|
442
|
-
* is
|
|
481
|
+
* package is driver-specific (the Neon serverless driver also needs `ws` for its Node WebSocket transport).
|
|
443
482
|
*/
|
|
444
483
|
const getRequiredRegistryRuntimeModules = (handoff: Handoff): string[] => {
|
|
445
484
|
const base = ['next', 'react', 'react-dom', 'drizzle-orm'];
|
|
446
|
-
const
|
|
447
|
-
const
|
|
485
|
+
const driver = resolveRegistryDriver(handoff.config);
|
|
486
|
+
const driverModules = driver === 'neon' ? ['@neondatabase/serverless', 'ws'] : ['pg'];
|
|
448
487
|
// Asset-storage SDKs the deployed registry must be able to load at request time. The pre-packaged
|
|
449
488
|
// Vercel Blob adapter needs `@vercel/blob`; a custom adapter may declare its installed SDK
|
|
450
489
|
// package(s) via `assetStorage.options.sdkModules` so the trace/assertion covers them.
|
|
@@ -457,7 +496,7 @@ const getRequiredRegistryRuntimeModules = (handoff: Handoff): string[] => {
|
|
|
457
496
|
if (Array.isArray(sdkModules)) {
|
|
458
497
|
storage.push(...sdkModules.filter((mod): mod is string => typeof mod === 'string'));
|
|
459
498
|
}
|
|
460
|
-
return [...base, ...
|
|
499
|
+
return [...base, ...driverModules, ...storage];
|
|
461
500
|
};
|
|
462
501
|
|
|
463
502
|
/**
|
|
@@ -584,7 +623,7 @@ const assembleRegistryStandalone = async (
|
|
|
584
623
|
* - `vercel` → the Vercel Build Output API directory (`.vercel/output`) at the repo root: the
|
|
585
624
|
* traced bundle wrapped as a Node function plus CDN static assets and a route table.
|
|
586
625
|
*
|
|
587
|
-
* Mode is forced to `registry`; the database
|
|
626
|
+
* Mode is forced to `registry`; the database driver is honored from config (single-sourced so build
|
|
588
627
|
* and `db:migrate` never diverge). No connection-string value is ever baked — only env-var *names*.
|
|
589
628
|
*/
|
|
590
629
|
const buildRegistryApp = async (handoff: Handoff, buildPackage: BuildPackage = 'standalone'): Promise<void> => {
|
|
@@ -597,16 +636,16 @@ const buildRegistryApp = async (handoff: Handoff, buildPackage: BuildPackage = '
|
|
|
597
636
|
Logger.info(`Source runtime.mode is "${sourceMode}"; forcing "registry" mode in the packaged registry app.`);
|
|
598
637
|
}
|
|
599
638
|
|
|
600
|
-
// The
|
|
639
|
+
// The driver stays single-sourced from config (never flipped by the package flag), so build and
|
|
601
640
|
// `db:migrate` always resolve the same one. `pg` uses long-lived TCP connections that pool poorly on
|
|
602
641
|
// serverless; warn (non-fatally) so the operator points DATABASE_URL at a pooled endpoint or selects
|
|
603
|
-
// the Neon
|
|
642
|
+
// the Neon driver. The connection-string value is not available at build time, so this cannot be
|
|
604
643
|
// validated — it is guidance only and never fails the build.
|
|
605
|
-
if (buildPackage === 'vercel' &&
|
|
644
|
+
if (buildPackage === 'vercel' && resolveRegistryDriver(handoff.config) === 'pg') {
|
|
606
645
|
Logger.warn(
|
|
607
|
-
'Registry Vercel build uses the "pg"
|
|
646
|
+
'Registry Vercel build uses the "pg" driver (standard TCP connections), which pools poorly on ' +
|
|
608
647
|
'serverless and can exhaust the database connection limit. Point DATABASE_URL at a pooled endpoint ' +
|
|
609
|
-
'(PgBouncer / transaction pooling), or set runtime.registry.database.
|
|
648
|
+
'(PgBouncer / transaction pooling), or set runtime.registry.database.driver: "neon" for a ' +
|
|
610
649
|
'serverless-native HTTP/WebSocket driver. The build will continue.'
|
|
611
650
|
);
|
|
612
651
|
}
|
|
@@ -632,26 +671,7 @@ const buildRegistryApp = async (handoff: Handoff, buildPackage: BuildPackage = '
|
|
|
632
671
|
|
|
633
672
|
// Build the dynamic app. `output: 'standalone'` (driven by HANDOFF_BUILD_TARGET) traces the runtime
|
|
634
673
|
// and the selected Postgres/Neon driver into a self-contained bundle — never a static export.
|
|
635
|
-
|
|
636
|
-
cwd: appPath,
|
|
637
|
-
stdio: ['inherit', 'pipe', 'pipe'],
|
|
638
|
-
env: {
|
|
639
|
-
...process.env,
|
|
640
|
-
NODE_ENV: 'production',
|
|
641
|
-
HANDOFF_BUILD_TARGET: 'registry',
|
|
642
|
-
},
|
|
643
|
-
});
|
|
644
|
-
|
|
645
|
-
Logger.childProcessBuffer(buildResult.stdout);
|
|
646
|
-
Logger.childProcessBuffer(buildResult.stderr);
|
|
647
|
-
|
|
648
|
-
if (buildResult.status !== 0) {
|
|
649
|
-
let errorMsg = `Registry app build failed with exit code ${buildResult.status}`;
|
|
650
|
-
if (buildResult.error) {
|
|
651
|
-
errorMsg += `\nSpawn error: ${buildResult.error.message}`;
|
|
652
|
-
}
|
|
653
|
-
throw new Error(errorMsg);
|
|
654
|
-
}
|
|
674
|
+
await runNextBuild(appPath, 'registry', 'Registry app build');
|
|
655
675
|
|
|
656
676
|
// Assemble the deployable package. Standalone tracing is rooted at the package (modulePath), so the
|
|
657
677
|
// app's traced files land under `<relAppDir>` inside the standalone tree; static assets and public
|
|
@@ -3,7 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import Handoff from '..';
|
|
4
4
|
import { getClientConfig } from '../config';
|
|
5
5
|
import { resolveAssetStorageFromConfig } from '../registry/asset-storage/resolve';
|
|
6
|
-
import { resolveApiTokenEnv, resolveDatabaseUrlEnv,
|
|
6
|
+
import { resolveApiTokenEnv, resolveDatabaseUrlEnv, resolveRegistryDriver } from '../registry/db/driver';
|
|
7
7
|
import type { Config, RuntimeMode } from '../types/config';
|
|
8
8
|
import { getAppPath } from './paths';
|
|
9
9
|
|
|
@@ -48,7 +48,7 @@ export const generateTokensApi = async (handoff: Handoff) => {
|
|
|
48
48
|
* Build the server-only runtime config consumed by the docs read API.
|
|
49
49
|
*
|
|
50
50
|
* The browser-facing `client.config.json` carries only the resolved mode; the server additionally
|
|
51
|
-
* needs the registry connection *inputs* (selected
|
|
51
|
+
* needs the registry connection *inputs* (selected driver + the *name* of the database-URL env
|
|
52
52
|
* var) to back the registry-mode docs read API. These are non-secret — the connection-string value
|
|
53
53
|
* itself is never persisted, only resolved from the env var at request time.
|
|
54
54
|
*/
|
|
@@ -57,7 +57,7 @@ const buildServerRuntimeConfig = (config: Config, modeOverride?: RuntimeMode) =>
|
|
|
57
57
|
return {
|
|
58
58
|
mode: modeOverride ?? config?.runtime?.mode ?? 'workspace',
|
|
59
59
|
registry: {
|
|
60
|
-
|
|
60
|
+
driver: resolveRegistryDriver(config),
|
|
61
61
|
databaseUrlEnv: resolveDatabaseUrlEnv(config),
|
|
62
62
|
apiTokenEnv: resolveApiTokenEnv(config),
|
|
63
63
|
},
|
package/src/app-builder/paths.ts
CHANGED
|
@@ -41,14 +41,34 @@ const mirrorDirectory = async (sourcePath: string, destinationPath: string): Pro
|
|
|
41
41
|
await fs.copy(sourcePath, destinationPath, { overwrite: true });
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Copy the fetch-produced asset bundles (`exported/<id>/{collection}.zip`) into the app's public
|
|
46
|
+
* root, where the docs read API serves them at `/api/docs/assets/{collection}/{collection}.zip`.
|
|
47
|
+
* `fetch` writes these bundles, but `build`/`start` cleans and re-stages `.handoff` without them, so
|
|
48
|
+
* the download links 404 until the next fetch. Copying them here keeps the route working in workspace
|
|
49
|
+
* dev and static builds. Projects with no logos/icons have no bundles, so those are skipped.
|
|
50
|
+
*/
|
|
51
|
+
const stageAssetDownloadBundles = async (handoff: Handoff, destinationPublicPath: string): Promise<void> => {
|
|
52
|
+
const bundles: Array<{ src: string; name: string }> = [
|
|
53
|
+
{ src: handoff.getLogosZipFilePath(), name: 'logos.zip' },
|
|
54
|
+
{ src: handoff.getIconsZipFilePath(), name: 'icons.zip' },
|
|
55
|
+
];
|
|
56
|
+
for (const { src, name } of bundles) {
|
|
57
|
+
if (fs.existsSync(src) && fs.statSync(src).isFile()) {
|
|
58
|
+
await fs.ensureDir(destinationPublicPath);
|
|
59
|
+
await fs.copy(src, path.resolve(destinationPublicPath, name), { overwrite: true });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
44
64
|
/**
|
|
45
65
|
* Copy the public dir from the working dir to the module dir.
|
|
46
66
|
*/
|
|
47
67
|
export const syncPublicFiles = async (handoff: Handoff): Promise<void> => {
|
|
48
68
|
const appPath = getAppPath(handoff);
|
|
69
|
+
const destinationPublicPath = path.resolve(appPath, 'public');
|
|
49
70
|
const workingPublicPath = getWorkingPublicPath(handoff);
|
|
50
71
|
if (workingPublicPath) {
|
|
51
|
-
const destinationPublicPath = path.resolve(appPath, 'public');
|
|
52
72
|
const sourceApiPath = path.resolve(workingPublicPath, 'api');
|
|
53
73
|
const destinationApiPath = path.resolve(destinationPublicPath, 'api');
|
|
54
74
|
|
|
@@ -62,4 +82,8 @@ export const syncPublicFiles = async (handoff: Handoff): Promise<void> => {
|
|
|
62
82
|
|
|
63
83
|
await mirrorDirectory(sourceApiPath, destinationApiPath);
|
|
64
84
|
}
|
|
85
|
+
|
|
86
|
+
// Stage the asset bundles even when the workspace has no `public/` dir; they come from the fetch
|
|
87
|
+
// output, not the workspace public tree.
|
|
88
|
+
await stageAssetDownloadBundles(handoff, destinationPublicPath);
|
|
65
89
|
};
|
|
@@ -209,7 +209,34 @@ export const materializeDocsReadModel = async (handoff: Handoff, outDir: string)
|
|
|
209
209
|
await fs.writeJson(path.join(docsRoot, 'patterns', `${pattern.id}.json`), detail);
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
await materializeAssetDownloads(handoff, docsRoot);
|
|
213
|
+
|
|
212
214
|
Logger.success(
|
|
213
215
|
`Materialized docs read model (${components.length} component(s), ${patterns.length} pattern(s)) for static export.`
|
|
214
216
|
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Write the whole-collection asset bundles to the asset route
|
|
221
|
+
* (`api/docs/assets/{collection}/{collection}.zip`), the same URL the docs read API serves in
|
|
222
|
+
* registry mode, so the "Download" links work identically on a static host. The bundles come from the
|
|
223
|
+
* fetch output (`exported/<id>/{collection}.zip`); `next build` never re-creates them and the
|
|
224
|
+
* fetch-time copy into `public/` is wiped by the build's clean+restage, so we copy them here again.
|
|
225
|
+
* Projects with no logos/icons have no bundles, so those are skipped rather than treated as an error.
|
|
226
|
+
*/
|
|
227
|
+
const materializeAssetDownloads = async (handoff: Handoff, docsRoot: string): Promise<void> => {
|
|
228
|
+
const bundles: Array<{ collection: string; src: string }> = [
|
|
229
|
+
{ collection: 'logos', src: handoff.getLogosZipFilePath() },
|
|
230
|
+
{ collection: 'icons', src: handoff.getIconsZipFilePath() },
|
|
231
|
+
];
|
|
232
|
+
let copied = 0;
|
|
233
|
+
for (const { collection, src } of bundles) {
|
|
234
|
+
if (fs.existsSync(src) && fs.statSync(src).isFile()) {
|
|
235
|
+
await fs.copy(src, path.join(docsRoot, 'assets', collection, `${collection}.zip`), { overwrite: true });
|
|
236
|
+
copied += 1;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (copied > 0) {
|
|
240
|
+
Logger.info(`Materialized ${copied} asset download bundle(s) for static export.`);
|
|
241
|
+
}
|
|
215
242
|
};
|
|
@@ -66,7 +66,7 @@ export const writeStaticVercelOutput = async (handoff: Handoff, exportDir: strin
|
|
|
66
66
|
};
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
|
-
* Node.js runtime identifier baked into the registry function's `.vc-config.json`. The `pg`
|
|
69
|
+
* Node.js runtime identifier baked into the registry function's `.vc-config.json`. The `pg` driver
|
|
70
70
|
* (and the Neon serverless driver in its Node fallback) need the Node.js runtime, not the edge
|
|
71
71
|
* runtime — this is the required baseline. Vercel resolves the patch
|
|
72
72
|
* version within this major; pinning the major keeps the artifact stable.
|
|
@@ -98,7 +98,7 @@ const MUTABLE_REGISTRY_PAGE_SRC_ROUTE = '/[...slug]';
|
|
|
98
98
|
* data routes resolve the same way they do under the standalone server. `minimalMode:false` keeps
|
|
99
99
|
* the function doing full in-process routing (the catch-all route forwards everything here).
|
|
100
100
|
*
|
|
101
|
-
* Runtime mode,
|
|
101
|
+
* Runtime mode, driver, and the DB env-var *name* are already baked into the resolved config the
|
|
102
102
|
* standalone build wrote to `.next/required-server-files.json`; the connection-string value is still
|
|
103
103
|
* read from the named env var at request time, never baked.
|
|
104
104
|
*/
|
package/src/artifacts/index.ts
CHANGED
|
@@ -14,4 +14,11 @@ export {
|
|
|
14
14
|
type ArtifactSizeDiagnostics,
|
|
15
15
|
} from './types';
|
|
16
16
|
|
|
17
|
-
export {
|
|
17
|
+
export {
|
|
18
|
+
ARTIFACTS_ROUTE_SEGMENT,
|
|
19
|
+
ASSETS_ROUTE_SEGMENT,
|
|
20
|
+
buildArtifactUrl,
|
|
21
|
+
buildAssetDownloadUrl,
|
|
22
|
+
buildAssetUrl,
|
|
23
|
+
normalizeBasePath,
|
|
24
|
+
} from './url';
|
package/src/artifacts/url.ts
CHANGED
|
@@ -20,6 +20,9 @@ export const ARTIFACTS_ROUTE_SEGMENT = 'api/docs/artifacts';
|
|
|
20
20
|
/** Route prefix for docs read API metadata (`.json`) reads. */
|
|
21
21
|
export const DOCS_ROUTE_SEGMENT = 'api/docs';
|
|
22
22
|
|
|
23
|
+
/** Route prefix all asset content URLs are served under, regardless of runtime mode. */
|
|
24
|
+
export const ASSETS_ROUTE_SEGMENT = 'api/docs/assets';
|
|
25
|
+
|
|
23
26
|
/**
|
|
24
27
|
* Normalize a configured basePath to either an empty string or a single-leading-slash,
|
|
25
28
|
* no-trailing-slash form. Mirrors the Next app's `resolveBasePath` so builder output composes
|
|
@@ -88,3 +91,32 @@ export const buildComponentDetailUrl = (id: string, basePath?: string | null): s
|
|
|
88
91
|
*/
|
|
89
92
|
export const buildPatternDetailUrl = (id: string, basePath?: string | null): string =>
|
|
90
93
|
`${normalizeBasePath(basePath)}/${DOCS_ROUTE_SEGMENT}/patterns/${encodeURIComponent(id)}.json`;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build the basePath-aware content URL for a single asset in a collection
|
|
97
|
+
* (`{basePath}/api/docs/assets/{collection}/{logicalPath}`). This is the same route the docs read API
|
|
98
|
+
* serves in every runtime mode: from the database in registry mode, and from statically materialized
|
|
99
|
+
* files in a static export. Each segment is encoded on its own and traversal segments are rejected,
|
|
100
|
+
* matching {@link buildArtifactUrl}.
|
|
101
|
+
*
|
|
102
|
+
* @throws If the collection or logical path has no usable segments, or contains a `.`/`..` segment.
|
|
103
|
+
*/
|
|
104
|
+
export const buildAssetUrl = (collection: string, logicalPath: string, basePath?: string | null): string => {
|
|
105
|
+
const segments = [...toArtifactSegments(collection), ...toArtifactSegments(logicalPath)];
|
|
106
|
+
if (segments.length < 2) {
|
|
107
|
+
throw new Error(`Cannot build asset URL: empty collection ("${collection}") or path ("${logicalPath}").`);
|
|
108
|
+
}
|
|
109
|
+
if (segments.some(isTraversalSegment)) {
|
|
110
|
+
throw new Error(`Cannot build asset URL: path traversal is not allowed in "${collection}/${logicalPath}".`);
|
|
111
|
+
}
|
|
112
|
+
const encoded = segments.map((segment) => encodeURIComponent(segment)).join('/');
|
|
113
|
+
return `${normalizeBasePath(basePath)}/${ASSETS_ROUTE_SEGMENT}/${encoded}`;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Build the download URL for a collection's zip bundle
|
|
118
|
+
* (`{basePath}/api/docs/assets/{collection}/{collection}.zip`). This is the default target for the
|
|
119
|
+
* docs "Download" links and can be overridden via `assets_zip_links`.
|
|
120
|
+
*/
|
|
121
|
+
export const buildAssetDownloadUrl = (collection: string, basePath?: string | null): string =>
|
|
122
|
+
buildAssetUrl(collection, `${collection}.zip`, basePath);
|