handoff-app 2.0.0-11 → 2.0.0-13
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/{Changelog.md → CHANGELOG.md} +84 -0
- package/{License.md → LICENSE.md} +1 -1
- package/README.md +760 -801
- package/RECIPES.md +975 -0
- package/components.json +20 -20
- package/config/docs/foundations/colors/guidelines.md +0 -1
- package/config/docs/foundations/colors.md +0 -1
- package/config/docs/foundations/effects/guidelines.md +0 -1
- package/config/docs/foundations/effects/resources.md +0 -1
- package/config/docs/foundations/effects.md +0 -1
- package/config/docs/foundations/fonts.md +0 -1
- package/config/docs/foundations/grid.md +0 -1
- package/config/docs/foundations/icon.md +0 -1
- package/config/docs/foundations/icons/guidelines.md +0 -1
- package/config/docs/foundations/icons.md +0 -1
- package/config/docs/foundations/images/guidelines.md +0 -1
- package/config/docs/foundations/images/resources.md +0 -1
- package/config/docs/foundations/images.md +0 -1
- package/config/docs/foundations/logo/resources.md +0 -1
- package/config/docs/foundations/logo.md +0 -1
- package/config/docs/foundations/typography/guidelines.md +0 -1
- package/config/docs/foundations/typography/resources.md +0 -1
- package/config/docs/foundations/typography.md +0 -1
- package/config/docs/foundations.md +0 -1
- package/config/docs/guidelines.md +0 -1
- package/config/docs/index.md +0 -1
- package/config/docs/system/tokens/foundations/colors.md +0 -4
- package/config/docs/system/tokens/foundations/effects.md +0 -4
- package/config/docs/system/tokens/foundations/typography.md +0 -2
- package/config/docs/system.md +0 -2
- package/config/templates/page.md +2 -1
- package/config/templates/page.mdx +2 -1
- package/dist/ai/define.d.ts +1 -1
- package/dist/ai/define.js +1 -1
- package/dist/ai/types.d.ts +1 -1
- package/dist/ai/types.js +1 -1
- package/dist/app-builder/build.js +16 -16
- package/dist/app-builder/client-config.js +1 -1
- package/dist/config/loader.js +1 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/nav/index.d.ts +2 -2
- package/dist/nav/registry.d.ts +3 -3
- package/dist/nav/registry.js +1 -1
- package/dist/nav/workspace.d.ts +3 -3
- package/dist/nav/workspace.js +1 -1
- package/dist/providers.d.ts +10 -0
- package/dist/{define.js → providers.js} +4 -4
- package/dist/registry/asset-storage/define.d.ts +5 -5
- package/dist/registry/asset-storage/define.js +4 -4
- package/dist/registry/asset-storage/resolve.d.ts +8 -11
- package/dist/registry/asset-storage/resolve.js +12 -13
- package/dist/registry/asset-storage/types.d.ts +12 -12
- package/dist/registry/asset-storage/types.js +2 -2
- package/dist/registry/db/schema.d.ts +2 -2
- package/dist/registry/db/schema.js +2 -2
- package/dist/registry/email/define.d.ts +1 -1
- package/dist/registry/email/define.js +1 -1
- package/dist/registry/email/resolve.d.ts +7 -8
- package/dist/registry/email/resolve.js +21 -23
- package/dist/registry/email/types.d.ts +1 -1
- package/dist/registry/email/types.js +1 -1
- package/dist/store/registry.d.ts +3 -3
- package/dist/store/registry.js +4 -4
- package/dist/types/config.d.ts +4 -13
- package/docs/cli.md +275 -54
- package/package.json +8 -9
- package/src/ai/define.ts +1 -1
- package/src/ai/types.ts +1 -1
- package/src/app/components/Auth/AccountLayout.tsx +1 -1
- package/src/app/components/Auth/AuthShell.tsx +10 -4
- package/src/app/components/Foundations/ColorGrid.tsx +13 -14
- package/src/app/components/Layout/Main.tsx +2 -2
- package/src/app/components/McpIntegration/McpConfigDialog.tsx +12 -51
- package/src/app/components/Navigation/MobileNav.tsx +0 -2
- package/src/app/components/util/index.ts +0 -11
- package/src/app/lib/asset-storage.ts +25 -25
- package/src/app/lib/auth/email.ts +4 -5
- package/src/app/lib/docs-api/page-rendering.ts +1 -1
- package/src/app/lib/docs-api/registry.ts +2 -3
- package/src/app/lib/docs-api/runtime-config.ts +8 -8
- package/src/app/lib/mcp/components.ts +33 -29
- package/src/app/lib/mcp/pages.ts +26 -19
- package/src/app/lib/mcp/server.ts +33 -9
- package/src/app/lib/mcp/tokens.ts +22 -16
- package/src/app/lib/mcp/tool.ts +19 -0
- package/src/app/lib/registry-api/asset-transfer.ts +25 -25
- package/src/app/lib/server-module.ts +1 -1
- package/src/app/lib/utils.ts +7 -0
- package/src/app/next.config.mjs +2 -2
- package/src/app/pages/[...slug]/index.tsx +2 -1
- package/src/app/pages/account/ai.tsx +1 -1
- package/src/app/pages/account/index.tsx +1 -1
- package/src/app/pages/account/tokens.tsx +1 -1
- package/src/app/pages/account/users.tsx +1 -1
- package/src/app/pages/api/docs/nav.json.ts +1 -1
- package/src/app/pages/api/mcp.ts +6 -0
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +1 -1
- package/src/app/pages/cli/device.tsx +1 -1
- package/src/app/pages/foundations/colors.tsx +9 -8
- package/src/app/pages/foundations/index.tsx +3 -3
- package/src/app/pages/index.tsx +2 -2
- package/src/app/pages/login.tsx +10 -5
- package/src/app/pages/reset-password.tsx +7 -2
- package/src/app/pages/system/component/[component]/index.tsx +2 -5
- package/src/app/pages/system/index.tsx +11 -11
- package/src/app/pages/system/pattern/[pattern]/index.tsx +2 -2
- package/src/app/pages/system/tokens/components/[component]/index.tsx +0 -4
- package/src/app-builder/build.ts +12 -12
- package/src/app-builder/client-config.ts +1 -1
- package/src/config/loader.ts +1 -2
- package/src/index.ts +6 -6
- package/src/nav/index.ts +2 -2
- package/src/nav/registry.ts +3 -3
- package/src/nav/workspace.ts +3 -3
- package/src/{define.ts → providers.ts} +8 -8
- package/src/registry/asset-storage/define.ts +6 -6
- package/src/registry/asset-storage/resolve.ts +16 -20
- package/src/registry/asset-storage/types.ts +12 -12
- package/src/registry/db/schema.ts +3 -3
- package/src/registry/email/define.ts +1 -1
- package/src/registry/email/resolve.ts +23 -23
- package/src/registry/email/types.ts +1 -1
- package/src/store/registry.ts +7 -7
- package/src/types/config.ts +4 -14
- package/.editorconfig +0 -19
- package/.eslintrc.json +0 -4
- package/.prettierignore +0 -0
- package/.prettierrc.json +0 -8
- package/.vscode/settings.json +0 -17
- package/dist/define.d.ts +0 -10
- package/dist/registry/email/adapters/resend.d.ts +0 -7
- package/dist/registry/email/adapters/resend.js +0 -35
- package/src/app/components/old/Header.tsx +0 -89
- package/src/app/pages/assets/fonts.tsx +0 -129
- package/src/app/pages/assets/index.tsx +0 -91
- package/src/app/pages/assets/logos.tsx +0 -75
- package/src/registry/email/adapters/resend.ts +0 -22
- /package/dist/registry/email/{adapters → providers}/smtp.d.ts +0 -0
- /package/dist/registry/email/{adapters → providers}/smtp.js +0 -0
- /package/src/registry/email/{adapters → providers}/smtp.ts +0 -0
|
@@ -130,7 +130,7 @@ export const getStaticProps = async (context: { params: IParams }) => {
|
|
|
130
130
|
metadata: {
|
|
131
131
|
title: fallbackTitle,
|
|
132
132
|
description: patternData?.description || '',
|
|
133
|
-
metaTitle:
|
|
133
|
+
metaTitle: '',
|
|
134
134
|
metaDescription: patternData?.description || '',
|
|
135
135
|
},
|
|
136
136
|
previousPattern,
|
|
@@ -189,7 +189,7 @@ const PatternPage = ({ menu, metadata, current, id, config, previousPattern, nex
|
|
|
189
189
|
|
|
190
190
|
if (patternNotFound) {
|
|
191
191
|
return (
|
|
192
|
-
<Layout config={config} menu={menu} current={current} metadata={{ ...metadata, title: '
|
|
192
|
+
<Layout config={config} menu={menu} current={current} metadata={{ ...metadata, title: 'Page Not Found', metaTitle: '' }}>
|
|
193
193
|
<div className="flex min-h-[60vh] flex-col items-center justify-center">
|
|
194
194
|
<NotFound />
|
|
195
195
|
</div>
|
|
@@ -70,14 +70,10 @@ export const getStaticProps = async (context) => {
|
|
|
70
70
|
description = componentData?.description;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
const fallbackTitle = displayName || startCase(componentSlug);
|
|
74
|
-
const fallbackMetaTitle = `${fallbackTitle}${config?.app?.client ? ` | ${config.app.client} Design System` : ''}`;
|
|
75
|
-
|
|
76
73
|
markdownProps.metadata = {
|
|
77
74
|
...markdownProps.metadata,
|
|
78
75
|
title: displayName || markdownProps.metadata.title || startCase(componentSlug),
|
|
79
76
|
description: markdownProps.metadata.description || description || '',
|
|
80
|
-
metaTitle: markdownProps.metadata.metaTitle || fallbackMetaTitle,
|
|
81
77
|
metaDescription: markdownProps.metadata.metaDescription || description || '',
|
|
82
78
|
};
|
|
83
79
|
|
package/src/app-builder/build.ts
CHANGED
|
@@ -300,14 +300,14 @@ const initializeProjectApp = async (handoff: Handoff, options: InitializeProject
|
|
|
300
300
|
const escapedMcpEnabled = escapeForSingleQuotedJsString(String(isMcpEnabled(handoff.config)));
|
|
301
301
|
const escapedRegistryDriver = escapeForSingleQuotedJsString(resolveRegistryDriver(handoff.config));
|
|
302
302
|
const escapedDatabaseUrlEnv = escapeForSingleQuotedJsString(resolveDatabaseUrlEnv(handoff.config));
|
|
303
|
-
// Asset storage selection baked (
|
|
303
|
+
// Asset storage selection baked (provider + module + options JSON with `{ $env }` references).
|
|
304
304
|
const assetStorage = resolveAssetStorageFromConfig(handoff.config);
|
|
305
|
-
const
|
|
305
|
+
const escapedAssetStorageProvider = escapeForSingleQuotedJsString(assetStorage.provider);
|
|
306
306
|
const escapedAssetStorageModule = escapeForSingleQuotedJsString(assetStorage.module ?? '');
|
|
307
307
|
const escapedAssetStorageMaxInline = escapeForSingleQuotedJsString(String(assetStorage.maxInlineBytes));
|
|
308
308
|
const escapedAssetStorageOptions = escapeForSingleQuotedJsString(JSON.stringify(assetStorage.options ?? {}));
|
|
309
309
|
const email = resolveEmailFromConfig(handoff.config);
|
|
310
|
-
const escapedEmailProvider = escapeForSingleQuotedJsString(email.provider);
|
|
310
|
+
const escapedEmailProvider = escapeForSingleQuotedJsString(email.provider ?? '');
|
|
311
311
|
const escapedEmailFrom = escapeForSingleQuotedJsString(email.from ?? '');
|
|
312
312
|
const escapedEmailModule = escapeForSingleQuotedJsString(email.module ?? '');
|
|
313
313
|
const escapedEmailOptions = escapeForSingleQuotedJsString(JSON.stringify(email.options));
|
|
@@ -330,7 +330,7 @@ const initializeProjectApp = async (handoff: Handoff, options: InitializeProject
|
|
|
330
330
|
'%HANDOFF_MCP_ENABLED%': escapedMcpEnabled,
|
|
331
331
|
'%HANDOFF_REGISTRY_DRIVER%': escapedRegistryDriver,
|
|
332
332
|
'%HANDOFF_REGISTRY_DATABASE_URL_ENV%': escapedDatabaseUrlEnv,
|
|
333
|
-
'%
|
|
333
|
+
'%HANDOFF_ASSET_STORAGE_PROVIDER%': escapedAssetStorageProvider,
|
|
334
334
|
'%HANDOFF_ASSET_STORAGE_MODULE%': escapedAssetStorageModule,
|
|
335
335
|
'%HANDOFF_ASSET_STORAGE_MAX_INLINE_BYTES%': escapedAssetStorageMaxInline,
|
|
336
336
|
'%HANDOFF_ASSET_STORAGE_OPTIONS%': escapedAssetStorageOptions,
|
|
@@ -474,7 +474,7 @@ merged over the list this build baked in, keyed by \`id\`. Use it to add or repo
|
|
|
474
474
|
without rebuilding.
|
|
475
475
|
`;
|
|
476
476
|
|
|
477
|
-
const assetStorageEnvs = assetStorage.
|
|
477
|
+
const assetStorageEnvs = assetStorage.provider === 'custom' ? envReferenceNames(assetStorage.options) : [];
|
|
478
478
|
const emailEnvs = envReferenceNames(email.options);
|
|
479
479
|
const emailSection = !email.from
|
|
480
480
|
? 'Email delivery is off because `runtime.registry.email.from` is not set. Invitation links are shown once to an administrator for manual delivery.'
|
|
@@ -498,7 +498,7 @@ and \`.next/static/\` already copied alongside so the server serves them.
|
|
|
498
498
|
- \`${databaseUrlEnv}\` — PostgreSQL/Neon connection string, read at request time.
|
|
499
499
|
- \`AUTH_SECRET\` — a long, random secret used to sign browser sessions.
|
|
500
500
|
- \`AUTH_URL\` — the canonical public registry URL, including the configured base path.
|
|
501
|
-
${assetStorageEnvs.map((name) => `- \`${name}\` — read by the custom asset storage
|
|
501
|
+
${assetStorageEnvs.map((name) => `- \`${name}\` — read by the custom asset storage provider at request time.\n`).join('')}
|
|
502
502
|
${emailSection}
|
|
503
503
|
${aiSection}
|
|
504
504
|
## Database migrations
|
|
@@ -569,8 +569,8 @@ For multi-instance self-hosting, front \`/_next/static\` with a CDN and configur
|
|
|
569
569
|
|
|
570
570
|
## Deploying to Vercel
|
|
571
571
|
|
|
572
|
-
Vercel does **not** use \`output: 'standalone'\` — it ignores that setting and
|
|
573
|
-
|
|
572
|
+
Vercel does **not** use \`output: 'standalone'\` — it ignores that setting and runs its
|
|
573
|
+
own build, so this pre-built bundle is **not** the artifact you deploy there. For Vercel, deploy the
|
|
574
574
|
registry app via Vercel's Git/CLI source build (it produces and serves the dynamic app itself) and
|
|
575
575
|
supply the same environment variables above as project env vars. Use this standalone bundle for
|
|
576
576
|
containers, custom Node servers, and other non-Vercel hosts.
|
|
@@ -582,7 +582,7 @@ containers, custom Node servers, and other non-Vercel hosts.
|
|
|
582
582
|
* Resolve the set of npm packages the packaged registry app must be able to `require`/`import` at
|
|
583
583
|
* runtime, given the configured database driver. `next`/`react`/`react-dom` run the server and React
|
|
584
584
|
* runtime; `drizzle-orm` backs both the request-time DB client and the migration runner; the MCP SDK
|
|
585
|
-
*
|
|
585
|
+
* is only needed when `/api/mcp/` is enabled; the driver package is
|
|
586
586
|
* driver-specific (the Neon serverless driver also needs `ws` for its Node WebSocket transport); the
|
|
587
587
|
* SMTP email provider needs `nodemailer`.
|
|
588
588
|
*/
|
|
@@ -592,7 +592,7 @@ const getRequiredRegistryRuntimeModules = (handoff: Handoff): string[] => {
|
|
|
592
592
|
// The assistant runs the MCP tools in-process, so it needs the MCP SDK whether or not the HTTP
|
|
593
593
|
// endpoint is served.
|
|
594
594
|
if (isMcpEnabled(handoff.config) || ai.enabled) {
|
|
595
|
-
base.push('@modelcontextprotocol/sdk'
|
|
595
|
+
base.push('@modelcontextprotocol/sdk');
|
|
596
596
|
}
|
|
597
597
|
if (ai.enabled) {
|
|
598
598
|
base.push('ai', '@ai-sdk/openai-compatible');
|
|
@@ -635,7 +635,7 @@ const getCustomServerModules = (handoff: Handoff): string[] => {
|
|
|
635
635
|
const assetStorage = resolveAssetStorageFromConfig(handoff.config);
|
|
636
636
|
const email = resolveEmailFromConfig(handoff.config);
|
|
637
637
|
const modules = [
|
|
638
|
-
assetStorage.
|
|
638
|
+
assetStorage.provider === 'custom' ? assetStorage.module : undefined,
|
|
639
639
|
email.provider === 'custom' ? email.module : undefined,
|
|
640
640
|
...resolveAiFromConfig(handoff.config).connections.map((connection) => connection.module),
|
|
641
641
|
];
|
|
@@ -667,7 +667,7 @@ const copyCustomServerModules = async (handoff: Handoff, tracingRoot: string, en
|
|
|
667
667
|
}
|
|
668
668
|
if ([...fileList].some((file) => file.split(path.sep).join('/').endsWith('node_modules/handoff-app/dist/index.js'))) {
|
|
669
669
|
throw new HandoffBuildError(
|
|
670
|
-
`The custom server module "${module}" imports "handoff-app", which loads the whole CLI. Import the define helpers from "handoff-app/
|
|
670
|
+
`The custom server module "${module}" imports "handoff-app", which loads the whole CLI. Import the define helpers from "handoff-app/providers".`
|
|
671
671
|
);
|
|
672
672
|
}
|
|
673
673
|
fileList.forEach((file) => files.add(file));
|
|
@@ -64,7 +64,7 @@ const buildServerRuntimeConfig = (config: ResolvedConfig, modeOverride?: Runtime
|
|
|
64
64
|
databaseUrlEnv: resolveDatabaseUrlEnv(config),
|
|
65
65
|
},
|
|
66
66
|
assetStorage: {
|
|
67
|
-
|
|
67
|
+
provider: assetStorage.provider,
|
|
68
68
|
module: assetStorage.module,
|
|
69
69
|
maxInlineBytes: assetStorage.maxInlineBytes,
|
|
70
70
|
options: assetStorage.options,
|
package/src/config/loader.ts
CHANGED
|
@@ -5,7 +5,6 @@ import mergeWith from 'lodash/mergeWith';
|
|
|
5
5
|
import omit from 'lodash/omit';
|
|
6
6
|
import { createRequire } from 'module';
|
|
7
7
|
import path from 'path';
|
|
8
|
-
import { DEFAULT_EMAIL_PROVIDER } from '../registry/email/resolve';
|
|
9
8
|
import { Config, HandoffEmailConfig, ResolvedConfig } from '../types/config';
|
|
10
9
|
import { Logger } from '../utils/logger';
|
|
11
10
|
import { resolveWorkingPath } from '../utils/path';
|
|
@@ -240,7 +239,7 @@ const replaceValues = (destination: unknown, source: unknown) => {
|
|
|
240
239
|
* the deep merge never mixes the settings of two providers.
|
|
241
240
|
*/
|
|
242
241
|
const resetEmailOnProviderChange = (layers: Partial<Config>[]): Partial<Config>[] => {
|
|
243
|
-
let provider: string
|
|
242
|
+
let provider: string | undefined;
|
|
244
243
|
let start = 0;
|
|
245
244
|
layers.forEach((layer, index) => {
|
|
246
245
|
const next = layer.runtime?.registry?.email?.provider;
|
package/src/index.ts
CHANGED
|
@@ -697,13 +697,13 @@ export type {
|
|
|
697
697
|
SourceFormat,
|
|
698
698
|
RendererKind,
|
|
699
699
|
} from './catalog';
|
|
700
|
-
export {
|
|
700
|
+
export { defineStorageProvider } from './registry/asset-storage/define';
|
|
701
701
|
export type {
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
702
|
+
StorageProvider,
|
|
703
|
+
StorageProviderContext,
|
|
704
|
+
StorageProviderFactory,
|
|
705
|
+
StorageInput,
|
|
706
|
+
StorageReadResult,
|
|
707
707
|
AssetUpload,
|
|
708
708
|
AssetUploadInput,
|
|
709
709
|
} from './registry/asset-storage/types';
|
package/src/nav/index.ts
CHANGED
|
@@ -89,6 +89,6 @@ export interface NavData {
|
|
|
89
89
|
export { getNavData } from './resolver';
|
|
90
90
|
export type { GetNavDataInput } from './resolver';
|
|
91
91
|
export { getWorkspaceNavData } from './workspace';
|
|
92
|
-
export type {
|
|
92
|
+
export type { WorkspaceNavOptions, WorkspaceNavLoaders } from './workspace';
|
|
93
93
|
export { getRegistryNavData } from './registry';
|
|
94
|
-
export type {
|
|
94
|
+
export type { RegistryNavOptions, RegistryNavRecords } from './registry';
|
package/src/nav/registry.ts
CHANGED
|
@@ -9,7 +9,7 @@ export interface RegistryNavRecords {
|
|
|
9
9
|
tokenSets: NavTokenSet[];
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export interface
|
|
12
|
+
export interface RegistryNavOptions {
|
|
13
13
|
shell: SectionLink[];
|
|
14
14
|
load: NavLoad;
|
|
15
15
|
basePath?: string;
|
|
@@ -18,8 +18,8 @@ export interface RegistryNavAdapterOptions {
|
|
|
18
18
|
|
|
19
19
|
const emptyRecords = (): RegistryNavRecords => ({ components: [], patterns: [], pages: [], tokenSets: [] });
|
|
20
20
|
|
|
21
|
-
/** Registry
|
|
22
|
-
export const getRegistryNavData = async (options:
|
|
21
|
+
/** Registry nav data: shell-only initial load, or one best-effort runtime-record fetch on refresh. */
|
|
22
|
+
export const getRegistryNavData = async (options: RegistryNavOptions): Promise<NavData> => {
|
|
23
23
|
let records = emptyRecords();
|
|
24
24
|
if (options.load === 'refresh') {
|
|
25
25
|
try {
|
package/src/nav/workspace.ts
CHANGED
|
@@ -14,7 +14,7 @@ export interface WorkspaceNavLoaders {
|
|
|
14
14
|
tokenSets: () => Awaitable<NavTokenSet[]>;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export interface
|
|
17
|
+
export interface WorkspaceNavOptions {
|
|
18
18
|
docRoot: string;
|
|
19
19
|
workingPagesDir?: string;
|
|
20
20
|
basePath?: string;
|
|
@@ -43,8 +43,8 @@ const markdownTitle = (id: string, docRoot: string, workingPagesDir?: string): s
|
|
|
43
43
|
return undefined;
|
|
44
44
|
};
|
|
45
45
|
|
|
46
|
-
/** Workspace/static
|
|
47
|
-
export const getWorkspaceNavData = async (options:
|
|
46
|
+
/** Workspace/static nav data: live filesystem shell plus best-effort local snapshot loaders. */
|
|
47
|
+
export const getWorkspaceNavData = async (options: WorkspaceNavOptions): Promise<NavData> => {
|
|
48
48
|
const [loadedComponents, patterns, pages, loadedTokenSets] = await Promise.all([
|
|
49
49
|
safeLoad(options.loaders.components),
|
|
50
50
|
safeLoad(options.loaders.patterns),
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Define helpers for custom server modules. This entry point has no
|
|
3
|
-
* that imports it does not pull the CLI into the registry bundle.
|
|
2
|
+
* Define helpers for custom server modules: storage, email and AI providers. This entry point has no
|
|
3
|
+
* runtime dependencies, so a module that imports it does not pull the CLI into the registry bundle.
|
|
4
4
|
*/
|
|
5
5
|
export { defineAiProvider } from './ai/define';
|
|
6
6
|
export type { AiProvider, AiProviderContext, AiProviderFactory } from './ai/types';
|
|
7
|
-
export {
|
|
7
|
+
export { defineStorageProvider } from './registry/asset-storage/define';
|
|
8
8
|
export type {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
StorageProvider,
|
|
10
|
+
StorageProviderContext,
|
|
11
|
+
StorageProviderFactory,
|
|
12
|
+
StorageInput,
|
|
13
|
+
StorageReadResult,
|
|
14
14
|
AssetUpload,
|
|
15
15
|
AssetUploadInput,
|
|
16
16
|
} from './registry/asset-storage/types';
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Typed identity helper for custom asset storage
|
|
2
|
+
* Typed identity helper for custom asset storage providers (mirrors `defineCatalogItem`): a consumer
|
|
3
3
|
* default-exports its result from a **server-only** module and points
|
|
4
4
|
* `runtime.registry.assetStorage.module` at it. It must be its own module (not an inline
|
|
5
5
|
* `defineConfig` function) so the build can trace it into the deployed registry. The default export
|
|
6
|
-
* may be the
|
|
6
|
+
* may be the provider object, or a factory `(context) => StorageProvider` when it needs its options/env.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type {
|
|
9
|
+
import type { StorageProvider, StorageProviderFactory } from './types';
|
|
10
10
|
|
|
11
|
-
export function
|
|
12
|
-
export function
|
|
13
|
-
export function
|
|
11
|
+
export function defineStorageProvider(provider: StorageProvider): StorageProvider;
|
|
12
|
+
export function defineStorageProvider(factory: StorageProviderFactory): StorageProviderFactory;
|
|
13
|
+
export function defineStorageProvider(input: StorageProvider | StorageProviderFactory): StorageProvider | StorageProviderFactory {
|
|
14
14
|
return input;
|
|
15
15
|
}
|
|
@@ -2,54 +2,50 @@
|
|
|
2
2
|
* Asset storage settings resolution.
|
|
3
3
|
*
|
|
4
4
|
* `runtime.registry.assetStorage` selects the active provider for **new** uploads: the built-in
|
|
5
|
-
* `database` inline default or a `custom` module. The build bakes the
|
|
5
|
+
* `database` inline default or a `custom` module. The build bakes the provider, the module location,
|
|
6
6
|
* and the options. An option literal is baked as it is. An environment reference stays `{ $env }`
|
|
7
|
-
* and is resolved at request time, before the custom
|
|
7
|
+
* and is resolved at request time, before the custom provider factory runs.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { HandoffConfigError } from '../../config/errors';
|
|
11
11
|
import type { ResolvedConfig } from '../../types/config';
|
|
12
12
|
import type { AssetStorageProvider } from '../db/schema';
|
|
13
13
|
|
|
14
|
-
/** Which adapter implementation is active. Recorded on each blob row as its storage provider. */
|
|
15
|
-
export type AssetStorageAdapterKind = AssetStorageProvider;
|
|
16
|
-
|
|
17
14
|
/** The raw, authored settings block (a structural copy of the config type, resolvable from bake). */
|
|
18
15
|
export interface AssetStorageSettings {
|
|
19
|
-
|
|
16
|
+
provider?: AssetStorageProvider;
|
|
20
17
|
module?: string;
|
|
21
18
|
maxInlineBytes?: number;
|
|
22
19
|
options?: Record<string, unknown>;
|
|
23
20
|
}
|
|
24
21
|
|
|
25
|
-
const
|
|
22
|
+
const PROVIDERS: readonly AssetStorageProvider[] = ['database', 'custom'];
|
|
26
23
|
|
|
27
|
-
|
|
28
|
-
export const DEFAULT_ASSET_STORAGE_ADAPTER: AssetStorageAdapterKind = 'database';
|
|
24
|
+
export const DEFAULT_ASSET_STORAGE_PROVIDER: AssetStorageProvider = 'database';
|
|
29
25
|
|
|
30
26
|
/** Default max bytes kept inline in Postgres `bytea` (4 MB, under Vercel's ~4.5 MB function limit). */
|
|
31
27
|
export const DEFAULT_MAX_INLINE_BYTES = 4 * 1024 * 1024;
|
|
32
28
|
|
|
33
29
|
/** Fully resolved asset storage settings. */
|
|
34
30
|
export interface ResolvedAssetStorage {
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
/** For `custom`: the server-only
|
|
31
|
+
/** Recorded on each new blob row as its storage provider. */
|
|
32
|
+
provider: AssetStorageProvider;
|
|
33
|
+
/** For `custom`: the server-only provider module path. */
|
|
38
34
|
module?: string;
|
|
39
35
|
/** For `database`: the inline-content size ceiling. */
|
|
40
36
|
maxInlineBytes: number;
|
|
41
|
-
/**
|
|
37
|
+
/** Provider options: literals and `{ $env }` references, never secret values. */
|
|
42
38
|
options: Record<string, unknown>;
|
|
43
39
|
}
|
|
44
40
|
|
|
45
41
|
/** Resolve raw asset storage settings into a fully-defaulted shape. */
|
|
46
42
|
export const resolveAssetStorageSettings = (settings: AssetStorageSettings | null | undefined): ResolvedAssetStorage => {
|
|
47
|
-
const
|
|
43
|
+
const provider = settings?.provider ?? DEFAULT_ASSET_STORAGE_PROVIDER;
|
|
48
44
|
const options = settings?.options ?? {};
|
|
49
45
|
const maxInlineBytes =
|
|
50
46
|
typeof settings?.maxInlineBytes === 'number' && settings.maxInlineBytes > 0 ? settings.maxInlineBytes : DEFAULT_MAX_INLINE_BYTES;
|
|
51
47
|
|
|
52
|
-
return {
|
|
48
|
+
return { provider, module: settings?.module?.trim() || undefined, maxInlineBytes, options };
|
|
53
49
|
};
|
|
54
50
|
|
|
55
51
|
/** Resolve and validate the asset storage block from a loaded config (CLI/build side). An unusable block fails the build. */
|
|
@@ -59,17 +55,17 @@ export const resolveAssetStorageFromConfig = (config: ResolvedConfig | null | un
|
|
|
59
55
|
throw new HandoffConfigError(`Config "runtime.registry.assetStorage": ${problem}`);
|
|
60
56
|
};
|
|
61
57
|
|
|
62
|
-
const
|
|
63
|
-
if (!
|
|
64
|
-
fail(`unknown
|
|
58
|
+
const provider = settings?.provider ?? DEFAULT_ASSET_STORAGE_PROVIDER;
|
|
59
|
+
if (!PROVIDERS.includes(provider as AssetStorageProvider)) {
|
|
60
|
+
fail(`unknown provider ${JSON.stringify(provider)}. Use "database" or "custom".`);
|
|
65
61
|
}
|
|
66
62
|
const options = settings?.options;
|
|
67
63
|
if (options !== undefined && (options === null || typeof options !== 'object' || Array.isArray(options))) {
|
|
68
64
|
fail('"options" must be an object.');
|
|
69
65
|
}
|
|
70
66
|
const module = settings?.module;
|
|
71
|
-
if (
|
|
72
|
-
fail('
|
|
67
|
+
if (provider === 'custom' && (typeof module !== 'string' || !module.trim())) {
|
|
68
|
+
fail('provider "custom" needs a "module" that default-exports a defineStorageProvider() result.');
|
|
73
69
|
}
|
|
74
70
|
const maxInlineBytes = settings?.maxInlineBytes;
|
|
75
71
|
if (maxInlineBytes !== undefined && (typeof maxInlineBytes !== 'number' || !Number.isInteger(maxInlineBytes) || maxInlineBytes <= 0)) {
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Server-only asset storage contract for custom {@link
|
|
2
|
+
* Server-only asset storage contract for custom {@link defineStorageProvider} object storage modules.
|
|
3
3
|
* The built-in `database` provider is the inline default: bytes live in the `asset_blobs.content`
|
|
4
|
-
* bytea column, read/written directly by the registry, so it needs no
|
|
4
|
+
* bytea column, read/written directly by the registry, so it needs no module. Object providers
|
|
5
5
|
* record a `storageRef` + provider id so reads resolve back through whichever provider stored the
|
|
6
6
|
* blob.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
/** Input for storing one content-addressed blob. */
|
|
10
|
-
export interface
|
|
10
|
+
export interface StorageInput {
|
|
11
11
|
/** SHA-256 of the bytes (hex), the content identity, usable as a stable object key. */
|
|
12
12
|
hash: string;
|
|
13
13
|
bytes: Buffer;
|
|
@@ -19,7 +19,7 @@ export interface AssetStorageInput {
|
|
|
19
19
|
* How a provider returns stored content: raw bytes, a readable stream, or a signed/redirect URL the
|
|
20
20
|
* client can fetch directly (used to avoid routing large payloads through the serverless function).
|
|
21
21
|
*/
|
|
22
|
-
export type
|
|
22
|
+
export type StorageReadResult =
|
|
23
23
|
| { kind: 'bytes'; bytes: Buffer; contentType?: string }
|
|
24
24
|
| { kind: 'stream'; stream: NodeJS.ReadableStream; contentType?: string }
|
|
25
25
|
| { kind: 'redirect'; url: string };
|
|
@@ -40,10 +40,10 @@ export interface AssetUpload {
|
|
|
40
40
|
storageRef: string;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
/** The pluggable storage
|
|
44
|
-
export interface
|
|
43
|
+
/** The pluggable storage contract for object-backed asset providers. */
|
|
44
|
+
export interface StorageProvider {
|
|
45
45
|
/** Store one blob and return the reference to persist on its `asset_blobs` row. */
|
|
46
|
-
put(input:
|
|
46
|
+
put(input: StorageInput): Promise<{ storageRef: string }>;
|
|
47
47
|
/**
|
|
48
48
|
* Return a signed URL so the CLI uploads the blob directly to storage instead of through the
|
|
49
49
|
* registry, which avoids serverless request size limits. The URL must make storage reject bytes
|
|
@@ -51,18 +51,18 @@ export interface AssetStorage {
|
|
|
51
51
|
*/
|
|
52
52
|
createUpload?(input: AssetUploadInput): Promise<AssetUpload>;
|
|
53
53
|
/** Resolve stored content by its `storageRef`. */
|
|
54
|
-
get(storageRef: string): Promise<
|
|
54
|
+
get(storageRef: string): Promise<StorageReadResult>;
|
|
55
55
|
/** Delete stored content by its `storageRef`. Should be retryable/idempotent. */
|
|
56
56
|
delete(storageRef: string): Promise<void>;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
/** Context passed to a custom
|
|
60
|
-
export interface
|
|
59
|
+
/** Context passed to a custom provider factory: its config options and the process env. */
|
|
60
|
+
export interface StorageProviderContext {
|
|
61
61
|
/** `runtime.registry.assetStorage.options`, with each `fromEnv()` reference replaced by its value. */
|
|
62
62
|
options: Record<string, unknown>;
|
|
63
63
|
/** The process environment. */
|
|
64
64
|
env: NodeJS.ProcessEnv;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
/** A custom
|
|
68
|
-
export type
|
|
67
|
+
/** A custom provider module may export the provider directly or a factory that builds it from its context. */
|
|
68
|
+
export type StorageProviderFactory = (context: StorageProviderContext) => StorageProvider | Promise<StorageProvider>;
|
|
@@ -30,7 +30,7 @@ import type { TokenSetKind } from '../tokens/sets';
|
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* PostgreSQL `bytea` column. Drizzle pg-core has no built-in binary type, so declare it once here.
|
|
33
|
-
* Reads/writes are Node `Buffer`s. The default asset storage
|
|
33
|
+
* Reads/writes are Node `Buffer`s. The default asset storage provider keeps small/ordinary asset
|
|
34
34
|
* bytes inline in this column; larger assets are offloaded to an object-storage `storageRef` instead.
|
|
35
35
|
*/
|
|
36
36
|
const bytea = customType<{ data: Buffer; driverData: Buffer }>({
|
|
@@ -39,7 +39,7 @@ const bytea = customType<{ data: Buffer; driverData: Buffer }>({
|
|
|
39
39
|
},
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
-
/** Where an asset blob's bytes live: inline in the database (`database`) or in
|
|
42
|
+
/** Where an asset blob's bytes live: inline in the database (`database`) or in a custom provider (`custom`). */
|
|
43
43
|
export type AssetStorageProvider = 'database' | 'custom';
|
|
44
44
|
import type { ComponentListObject, PageListObject, PatternComponentEntry, PatternListObject } from '../../transformers/preview/types';
|
|
45
45
|
|
|
@@ -432,7 +432,7 @@ export const assets = pgTable(
|
|
|
432
432
|
/**
|
|
433
433
|
* Content-addressed binary blob store, shared across assets/collections (the `hash` is the blob
|
|
434
434
|
* identity, so an unchanged file re-references the same row). Exactly one location is active per row:
|
|
435
|
-
* inline `content` (bytea, default
|
|
435
|
+
* inline `content` (bytea, default `database` provider) or an external `storageRef` resolved through the
|
|
436
436
|
* recorded `storageProvider`. Both nullable so DB- and object-backed blobs coexist.
|
|
437
437
|
*/
|
|
438
438
|
export const assetBlobs = pgTable('asset_blobs', {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Typed identity helper for custom email providers (mirrors `
|
|
2
|
+
* Typed identity helper for custom email providers (mirrors `defineStorageProvider`): a consumer
|
|
3
3
|
* default-exports its result from a **server-only** module and points `runtime.registry.email.module`
|
|
4
4
|
* at it. It must be its own module (not an inline `defineConfig` function) so the build can trace it
|
|
5
5
|
* into the deployed registry. The default export may be the provider object, or a factory
|
|
@@ -1,33 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Registry email settings resolution.
|
|
3
3
|
*
|
|
4
|
-
* `runtime.registry.email.provider` selects the built-in `
|
|
5
|
-
*
|
|
6
|
-
* baked as it is. An environment reference stays `{ $env }` and is
|
|
4
|
+
* `runtime.registry.email.provider` selects the built-in `smtp` provider or a `custom` module. Without
|
|
5
|
+
* an email block, email delivery is off. The build bakes the provider, the module location, and the
|
|
6
|
+
* options. An option literal is baked as it is. An environment reference stays `{ $env }` and is
|
|
7
|
+
* resolved at request time.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import { HandoffConfigError } from '../../config/errors';
|
|
10
11
|
import type { ResolvedConfig } from '../../types/config';
|
|
11
12
|
|
|
12
|
-
export type EmailProviderKind = '
|
|
13
|
+
export type EmailProviderKind = 'smtp' | 'custom';
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
/** Default env-var name holding the Resend API key. */
|
|
17
|
-
export const DEFAULT_RESEND_API_KEY_ENV = 'RESEND_API_KEY';
|
|
18
|
-
|
|
19
|
-
const PROVIDERS: readonly EmailProviderKind[] = ['resend', 'smtp', 'custom'];
|
|
15
|
+
const PROVIDERS: readonly EmailProviderKind[] = ['smtp', 'custom'];
|
|
20
16
|
|
|
21
17
|
/** The options each built-in provider accepts. A custom provider accepts any. */
|
|
22
18
|
const BUILT_IN_OPTIONS: Record<Exclude<EmailProviderKind, 'custom'>, readonly string[]> = {
|
|
23
|
-
resend: ['apiKey'],
|
|
24
19
|
smtp: ['host', 'port', 'secure', 'user', 'password'],
|
|
25
20
|
};
|
|
26
21
|
|
|
27
22
|
/** Email settings as they are baked: literals and `{ $env }` references, never secret values. */
|
|
28
23
|
export interface EmailSettings {
|
|
29
24
|
from?: string;
|
|
30
|
-
|
|
25
|
+
/** Unset when the config has no email block. */
|
|
26
|
+
provider?: EmailProviderKind;
|
|
31
27
|
/** For `custom`: the server-only provider module path. */
|
|
32
28
|
module?: string;
|
|
33
29
|
options: Record<string, unknown>;
|
|
@@ -38,25 +34,30 @@ const isPlainRecord = (value: unknown): value is Record<string, unknown> =>
|
|
|
38
34
|
|
|
39
35
|
/** Resolve and validate the email block from a loaded config (CLI/build side). An unusable block fails the build. */
|
|
40
36
|
export const resolveEmailFromConfig = (config: ResolvedConfig | null | undefined): EmailSettings => {
|
|
41
|
-
const email = config?.runtime?.registry?.email as Record<string, unknown> | undefined;
|
|
37
|
+
const email = config?.runtime?.registry?.email as unknown as Record<string, unknown> | undefined;
|
|
42
38
|
const fail = (problem: string): never => {
|
|
43
39
|
throw new HandoffConfigError(`Config "runtime.registry.email": ${problem}`);
|
|
44
40
|
};
|
|
45
41
|
|
|
46
|
-
|
|
42
|
+
if (email === undefined) return { options: {} };
|
|
43
|
+
const provider = email.provider as EmailProviderKind;
|
|
47
44
|
if (!PROVIDERS.includes(provider)) {
|
|
48
|
-
fail(
|
|
45
|
+
fail(
|
|
46
|
+
email.provider === undefined
|
|
47
|
+
? '"provider" is required. Use "smtp" or "custom".'
|
|
48
|
+
: `unknown provider ${JSON.stringify(provider)}. Use "smtp" or "custom".`
|
|
49
|
+
);
|
|
49
50
|
}
|
|
50
|
-
if (email
|
|
51
|
+
if (email.options !== undefined && !isPlainRecord(email.options)) {
|
|
51
52
|
fail('"options" must be an object.');
|
|
52
53
|
}
|
|
53
|
-
const options = { ...((email
|
|
54
|
-
const module = typeof email
|
|
54
|
+
const options = { ...((email.options as Record<string, unknown> | undefined) ?? {}) };
|
|
55
|
+
const module = typeof email.module === 'string' ? email.module.trim() : undefined;
|
|
55
56
|
|
|
56
57
|
if (provider === 'custom') {
|
|
57
58
|
if (!module) fail('provider "custom" needs a "module" that default-exports a defineEmailProvider() result.');
|
|
58
59
|
} else {
|
|
59
|
-
if (email
|
|
60
|
+
if (email.module !== undefined) fail(`"module" is used only by provider "custom", not "${provider}".`);
|
|
60
61
|
const allowed = BUILT_IN_OPTIONS[provider];
|
|
61
62
|
const unknown = Object.keys(options).filter((key) => !allowed.includes(key));
|
|
62
63
|
if (unknown.length > 0) {
|
|
@@ -67,9 +68,8 @@ export const resolveEmailFromConfig = (config: ResolvedConfig | null | undefined
|
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
70
|
if (provider === 'smtp' && options.host === undefined) fail('provider "smtp" needs "options.host".');
|
|
70
|
-
if (provider === 'resend' && options.apiKey === undefined) options.apiKey = { $env: DEFAULT_RESEND_API_KEY_ENV };
|
|
71
71
|
|
|
72
|
-
const from = typeof email
|
|
72
|
+
const from = typeof email.from === 'string' ? email.from.trim() || undefined : undefined;
|
|
73
73
|
return { from, provider, ...(module ? { module } : {}), options };
|
|
74
74
|
};
|
|
75
75
|
|
|
@@ -83,11 +83,11 @@ export const parseEmailSettings = (raw: { from?: unknown; provider?: unknown; mo
|
|
|
83
83
|
options = {};
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
|
-
const provider = PROVIDERS.find((kind) => kind === raw.provider)
|
|
86
|
+
const provider = PROVIDERS.find((kind) => kind === raw.provider);
|
|
87
87
|
const module = typeof raw.module === 'string' ? raw.module.trim() : '';
|
|
88
88
|
return {
|
|
89
89
|
from: typeof raw.from === 'string' ? raw.from.trim() || undefined : undefined,
|
|
90
|
-
provider,
|
|
90
|
+
...(provider ? { provider } : {}),
|
|
91
91
|
...(module ? { module } : {}),
|
|
92
92
|
options: isPlainRecord(options) ? options : {},
|
|
93
93
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Server-only email provider contract. Handoff renders every message, and a provider only delivers it.
|
|
3
|
-
*
|
|
3
|
+
* SMTP is built in. Anything else is a {@link defineEmailProvider} module.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/** One rendered message. */
|
package/src/store/registry.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { orderPreviews } from '../catalog/previews';
|
|
18
18
|
import { and, eq, inArray, or, sql, type SQLWrapper } from 'drizzle-orm';
|
|
19
|
-
import type {
|
|
19
|
+
import type { StorageProvider, StorageReadResult } from '../registry/asset-storage/types';
|
|
20
20
|
import type { RegistryDatabase } from '../registry/db/client';
|
|
21
21
|
import {
|
|
22
22
|
assetBlobs,
|
|
@@ -53,11 +53,11 @@ import type {
|
|
|
53
53
|
export interface RegistryStoreContext {
|
|
54
54
|
db: RegistryDatabase;
|
|
55
55
|
/**
|
|
56
|
-
* Resolve the {@link
|
|
56
|
+
* Resolve the {@link StorageProvider} for a blob's recorded provider (or `null` for the inline
|
|
57
57
|
* `database` provider). Injected by the app runtime so `src/store` stays free of app-side imports;
|
|
58
58
|
* when omitted, only inline DB-backed asset content is resolvable.
|
|
59
59
|
*/
|
|
60
|
-
|
|
60
|
+
resolveStorageProvider?: (provider: AssetStorageProvider) => Promise<StorageProvider | null>;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/** A persisted text-file row, as selected from `component_files`/`pattern_files`. */
|
|
@@ -422,14 +422,14 @@ export class RegistryAssetStore implements AssetStore {
|
|
|
422
422
|
}
|
|
423
423
|
|
|
424
424
|
// Object-backed - resolve through the provider that stored it.
|
|
425
|
-
if (!row.storageRef || !this.context.
|
|
425
|
+
if (!row.storageRef || !this.context.resolveStorageProvider) {
|
|
426
426
|
return null;
|
|
427
427
|
}
|
|
428
|
-
const
|
|
429
|
-
if (!
|
|
428
|
+
const storage = await this.context.resolveStorageProvider(row.storageProvider);
|
|
429
|
+
if (!storage) {
|
|
430
430
|
return null;
|
|
431
431
|
}
|
|
432
|
-
const result:
|
|
432
|
+
const result: StorageReadResult = await storage.get(row.storageRef);
|
|
433
433
|
if (result.kind === 'redirect') {
|
|
434
434
|
return { ...base, redirectUrl: result.url };
|
|
435
435
|
}
|