lowdefy 5.6.0 → 6.0.0
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/commands/agentSetup/agentSetup.js +85 -0
- package/dist/commands/agentSetup/agentsMd.js +59 -0
- package/dist/commands/agentSetup/buildMcpServerEntry.js +21 -0
- package/dist/commands/agentSetup/findProjectRoot.js +39 -0
- package/dist/commands/agentSetup/getDevCommand.js +50 -0
- package/dist/commands/agentSetup/skillMd.js +48 -0
- package/dist/commands/agentSetup/upsertAgentsMdSection.js +63 -0
- package/dist/commands/agentSetup/upsertClaudeSettings.js +56 -0
- package/dist/commands/agentSetup/upsertMcpServer.js +83 -0
- package/dist/commands/agentSetup/writeSkillFile.js +32 -0
- package/dist/commands/build/build.js +3 -3
- package/dist/commands/dev/dev.js +13 -8
- package/dist/commands/dev/resolveMockUser.js +37 -0
- package/dist/commands/dev/runDevServer.js +17 -11
- package/dist/commands/dockerOutput/dockerOutput.js +99 -0
- package/dist/commands/emails/emails.js +84 -0
- package/dist/commands/emails/generateEmailShims.js +94 -0
- package/dist/commands/emails/installReactEmail.js +53 -0
- package/dist/commands/emails/readNotificationArtifacts.js +37 -0
- package/dist/commands/emails/runEmailPreview.js +47 -0
- package/dist/commands/emails/warnMissingDataKeys.js +45 -0
- package/dist/commands/init-docker/Dockerfile +37 -19
- package/dist/commands/init-docker/dockerignore +8 -0
- package/dist/commands/init-docker/initDocker.js +3 -1
- package/dist/commands/init-vercel/README.md +94 -20
- package/dist/commands/init-vercel/initVercel.js +12 -2
- package/dist/commands/init-vercel/vercel.build.sh +8 -0
- package/dist/commands/init-vercel/vercel.install.sh +4 -2
- package/dist/commands/init-vercel/vercel.json +5 -0
- package/dist/commands/start/runStart.js +1 -2
- package/dist/commands/vercelOutput/apiHandler.js +83 -0
- package/dist/commands/vercelOutput/vercelOutput.js +138 -0
- package/dist/program.js +24 -2
- package/dist/utils/checkPortAvailable.js +9 -16
- package/dist/utils/copyTracedFiles.js +94 -0
- package/dist/utils/findTraceBase.js +45 -0
- package/dist/utils/getDirectories.js +2 -1
- package/dist/utils/getOptions.js +2 -0
- package/dist/utils/installServer.js +31 -23
- package/dist/utils/{runNextBuild.js → runClientBuild.js} +11 -14
- package/package.json +14 -13
|
@@ -1,30 +1,104 @@
|
|
|
1
1
|
# Vercel Deployment
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Lowdefy v6 builds a [Hono](https://hono.dev) server serving a [Vite](https://vite.dev)-built React
|
|
4
|
+
client. To run that on Vercel, the app is deployed via the
|
|
5
|
+
[Build Output API](https://vercel.com/docs/build-output-api) as **static assets on the CDN plus one
|
|
6
|
+
Serverless Function** that runs the Hono app for every dynamic request.
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
`lowdefy init-vercel` scaffolds everything into a `deploy/` directory next to your `lowdefy.yaml`:
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
- `vercel.install.sh` — install command: downloads the Lowdefy server into `deploy/` and builds the
|
|
11
|
+
config artifacts (skips the client build, which the build step runs).
|
|
12
|
+
- `vercel.build.sh` — build command: builds the Vite client, then runs `lowdefy vercel-output` to
|
|
13
|
+
assemble the Vercel Build Output (`.vercel/output/`).
|
|
14
|
+
- `vercel.json` — sets the build command; the deployment itself is described by the generated
|
|
15
|
+
`.vercel/output/config.json`.
|
|
8
16
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
17
|
+
## Project settings
|
|
18
|
+
|
|
19
|
+
In the Vercel project settings:
|
|
20
|
+
|
|
21
|
+
- **Framework Preset:** `Other` (not Next.js — Lowdefy v6 is not a Next.js app).
|
|
22
|
+
- **Root Directory:** `<config-directory>/deploy`
|
|
23
|
+
- `<config-directory>` is where your `lowdefy.yaml` lives. If `lowdefy.yaml` is at the repo root,
|
|
24
|
+
use `deploy`; if it is in `apps/my-app`, use `apps/my-app/deploy`.
|
|
25
|
+
- **Install Command:** `bash vercel.install.sh`.
|
|
26
|
+
- **Build Command:** comes from `vercel.json` (`bash vercel.build.sh`); leave the dashboard override
|
|
27
|
+
off. There is no Output Directory setting — the build emits `.vercel/output`, which Vercel detects
|
|
28
|
+
automatically.
|
|
29
|
+
- Enable **"Include files outside the root directory in the Build Step"** — the Lowdefy config
|
|
30
|
+
usually lives in the parent of `deploy/`.
|
|
31
|
+
|
|
32
|
+
## Public files
|
|
33
|
+
|
|
34
|
+
Files in your app's `public/` directory (favicon, icons, images, `manifest.webmanifest`, etc.) are
|
|
35
|
+
copied into `dist/client` by Vite during the build, placed in `.vercel/output/static`, and served by
|
|
36
|
+
the Vercel CDN — no extra configuration needed.
|
|
37
|
+
|
|
38
|
+
## Secrets and environment variables
|
|
39
|
+
|
|
40
|
+
Set secrets in the Vercel project's Environment Variables, prefixed with `LOWDEFY_SECRET_`. Use
|
|
41
|
+
`AUTH_SECRET` (and, for OAuth, `AUTH_URL`) for auth. Different values can be set for Production and
|
|
42
|
+
Preview deployments.
|
|
43
|
+
|
|
44
|
+
## Scheduled endpoints (cron)
|
|
45
|
+
|
|
46
|
+
An `Api` (or `InternalApi`) endpoint can declare `schedules` to run its routine on a timer:
|
|
47
|
+
|
|
48
|
+
```yaml
|
|
49
|
+
id: purge-stale-conversations
|
|
50
|
+
type: Api
|
|
51
|
+
schedules:
|
|
52
|
+
- cron: '0 6 * * *'
|
|
53
|
+
payload: { mode: full }
|
|
54
|
+
- cron: '*/15 * * * *'
|
|
55
|
+
payload: { mode: incremental }
|
|
56
|
+
routine:
|
|
57
|
+
- id: purge
|
|
58
|
+
type: MongoDBDeleteMany
|
|
59
|
+
connectionId: conversations
|
|
60
|
+
properties:
|
|
61
|
+
filter: { updatedAt: { $lt: { _payload: cutoff } } }
|
|
14
62
|
```
|
|
15
63
|
|
|
16
|
-
|
|
64
|
+
`lowdefy vercel-output` turns each schedule into a Vercel cron job in `.vercel/output/config.json`,
|
|
65
|
+
pointing at `/api/cron/<endpointId>`. When it fires, Vercel makes a GET request to that path and the
|
|
66
|
+
Hono app runs the endpoint's routine.
|
|
67
|
+
|
|
68
|
+
- **Secure it:** add a project Environment Variable named exactly `CRON_SECRET` (a random string of
|
|
69
|
+
16+ characters). Vercel automatically sends it as `Authorization: Bearer <CRON_SECRET>`, and the
|
|
70
|
+
`/api/cron/*` route rejects any request whose header does not match. If `CRON_SECRET` is unset the
|
|
71
|
+
route always returns `401` (fail closed).
|
|
72
|
+
- **System context:** scheduled runs have no logged-in user, so `_user` is `undefined`. The routine
|
|
73
|
+
has full access to connections, requests, operators and secrets — just not a user. The `payload`
|
|
74
|
+
for a run comes from the matching schedule's `payload`.
|
|
75
|
+
- **UTC only.** Cron expressions use 5 fields (`minute hour day-of-month month day-of-week`); named
|
|
76
|
+
values like `MON`/`JAN` are not supported and day-of-month and day-of-week are mutually exclusive.
|
|
77
|
+
- **Plan limits:** Vercel **Hobby** only allows daily crons (a sub-daily expression fails the
|
|
78
|
+
deployment); **Pro**/**Enterprise** allow per-minute. Up to 100 cron jobs per project.
|
|
79
|
+
- **Idempotency:** Vercel does not retry failed runs and delivery is best-effort (a run can be
|
|
80
|
+
missed or delivered more than once). Write scheduled routines to be idempotent.
|
|
81
|
+
|
|
82
|
+
## How it works
|
|
83
|
+
|
|
84
|
+
`vercel.build.sh` builds the client and runs `lowdefy vercel-output`, which assembles:
|
|
85
|
+
|
|
86
|
+
- `.vercel/output/static/` — the built client and `public/` files, served by the CDN.
|
|
87
|
+
- `.vercel/output/functions/api.func/` — one Node Serverless Function: the Hono app plus its exact
|
|
88
|
+
runtime dependency closure, traced with [`@vercel/nft`](https://github.com/vercel/nft) (pnpm
|
|
89
|
+
symlinks and workspace packages included), and the `build/**`,
|
|
90
|
+
`dist/client/.vite/manifest.json` and `package.json` files it reads at runtime, with a
|
|
91
|
+
`.vc-config.json`.
|
|
92
|
+
- `.vercel/output/config.json` — routes that serve static files first and send every other request
|
|
93
|
+
to the function (running with `serveStaticAssets: false`), plus the `crons` array.
|
|
17
94
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
-
|
|
21
|
-
|
|
22
|
-
- `<config-directory>` is the path to the directory in which the `lowdefy.yaml` file is placed
|
|
23
|
-
- Eg: If the `lowdefy.yaml` is in the top level of the repository the configured root directory should be `deploy`
|
|
24
|
-
- Eg: If the `lowdefy.yaml` is in the directory `apps/app_name` the configured root directory should be `apps/app_name/deploy`
|
|
25
|
-
- The build command should be `pnpm next build`
|
|
26
|
-
- The install command should be `sh vercel.install.sh`
|
|
95
|
+
> The function runs on the Vercel **Node.js** runtime. Streaming agent responses (`/api/agent/*`)
|
|
96
|
+
> are subject to Vercel's function response/duration limits; for heavy streaming or agent workloads
|
|
97
|
+
> a long-lived Node host (Docker, Fly.io, Railway, Render) running `node src/index.js` may suit
|
|
98
|
+
> better.
|
|
27
99
|
|
|
28
|
-
|
|
100
|
+
## Manual setup
|
|
29
101
|
|
|
30
|
-
|
|
102
|
+
The same files can be created by hand at `<config-directory>/deploy/` instead of running
|
|
103
|
+
`lowdefy init-vercel`. All other Vercel configuration (domains, preview branches, regions,
|
|
104
|
+
redirects) can be set as desired.
|
|
@@ -15,13 +15,23 @@
|
|
|
15
15
|
*/ import path from 'path';
|
|
16
16
|
import url from 'url';
|
|
17
17
|
import { readFile, writeFile } from '@lowdefy/node-utils';
|
|
18
|
+
// The Vercel function entry is no longer scaffolded here: the deployment uses the Vercel Build
|
|
19
|
+
// Output API, and `lowdefy vercel-output` (run by vercel.build.sh) generates the function and
|
|
20
|
+
// deployment config into .vercel/output at build time.
|
|
18
21
|
async function initVercel({ context }) {
|
|
19
22
|
context.logger.info('Initializing Vercel deployment.');
|
|
23
|
+
const deployDir = path.join(context.directories.config, 'deploy');
|
|
20
24
|
const installScript = await readFile(url.fileURLToPath(new URL('./vercel.install.sh', import.meta.url)));
|
|
21
|
-
await writeFile(path.join(
|
|
25
|
+
await writeFile(path.join(deployDir, 'vercel.install.sh'), installScript);
|
|
22
26
|
context.logger.info("Created 'vercel.install.sh'.");
|
|
27
|
+
const buildScript = await readFile(url.fileURLToPath(new URL('./vercel.build.sh', import.meta.url)));
|
|
28
|
+
await writeFile(path.join(deployDir, 'vercel.build.sh'), buildScript);
|
|
29
|
+
context.logger.info("Created 'vercel.build.sh'.");
|
|
30
|
+
const vercelJson = await readFile(url.fileURLToPath(new URL('./vercel.json', import.meta.url)));
|
|
31
|
+
await writeFile(path.join(deployDir, 'vercel.json'), vercelJson);
|
|
32
|
+
context.logger.info("Created 'vercel.json'.");
|
|
23
33
|
const readMe = await readFile(url.fileURLToPath(new URL('./README.md', import.meta.url)));
|
|
24
|
-
await writeFile(path.join(
|
|
34
|
+
await writeFile(path.join(deployDir, 'README.md'), readMe);
|
|
25
35
|
context.logger.info("Created 'README.md'.");
|
|
26
36
|
await context.sendTelemetry();
|
|
27
37
|
context.logger.info({
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Vercel build command. The install step (vercel.install.sh) already downloaded the Hono server and
|
|
2
|
+
# built the config artifacts into this directory. Here we build the Vite client, then assemble the
|
|
3
|
+
# Vercel Build Output (.vercel/output) — including cron jobs generated from endpoint `schedules`.
|
|
4
|
+
# Read Lowdefy version from lowdefy.yaml using sed.
|
|
5
|
+
LOWDEFY_VERSION=$(sed -nE "s/lowdefy:(.*)/\1/p" ../lowdefy.yaml)
|
|
6
|
+
# Substitution params are to trim whitespace from the LOWDEFY_VERSION var.
|
|
7
|
+
pnpm run build:client
|
|
8
|
+
npx lowdefy@${LOWDEFY_VERSION//[[:space:]]/} vercel-output --config-directory ../ --server-directory . --log-level=debug
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
# Read Lowdefy version from lowdefy.yaml using sed
|
|
2
2
|
LOWDEFY_VERSION=$(sed -nE "s/lowdefy:(.*)/\1/p" ../lowdefy.yaml)
|
|
3
|
-
# Substitution params are to trim whitespace from the LOWDEFY_VERSION var
|
|
4
|
-
|
|
3
|
+
# Substitution params are to trim whitespace from the LOWDEFY_VERSION var.
|
|
4
|
+
# Writes the Hono server and config artifacts into this directory. The client build and the Vercel
|
|
5
|
+
# Build Output assembly run separately in the build step (vercel.build.sh).
|
|
6
|
+
npx lowdefy@${LOWDEFY_VERSION//[[:space:]]/} build --config-directory ../ --server-directory . --no-client-build --log-level=debug
|
|
@@ -31,8 +31,7 @@ async function runStart({ context, directory }) {
|
|
|
31
31
|
env: {
|
|
32
32
|
...process.env,
|
|
33
33
|
LOWDEFY_LOG_LEVEL: context.options.logLevel,
|
|
34
|
-
PORT: context.options.port
|
|
35
|
-
NEXT_TELEMETRY_DISABLED: context.options.disableTelemetry ? '1' : undefined
|
|
34
|
+
PORT: context.options.port
|
|
36
35
|
}
|
|
37
36
|
}
|
|
38
37
|
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/ // The Vercel function entry, written into the Build Output at
|
|
16
|
+
// .vercel/output/functions/api.func/<relServer>/api/index.js, where <relServer> is the server
|
|
17
|
+
// directory's path relative to the trace base (empty for standalone apps). Kept as a string (not a
|
|
18
|
+
// template file) so it ships verbatim — a real source file would be transpiled by the CLI's swc
|
|
19
|
+
// build, stripping these comments. Its `../src/app.js` import and the chdir to `..` resolve to the
|
|
20
|
+
// server directory inside the function, where the assembly places src/, build/, lib/ and the traced
|
|
21
|
+
// dependency closure.
|
|
22
|
+
const apiHandler = `/*
|
|
23
|
+
Vercel Serverless Function entry for a Lowdefy (Hono) app — generated into the Vercel Build Output
|
|
24
|
+
by lowdefy vercel-output.
|
|
25
|
+
|
|
26
|
+
The built client and public files are served from Vercel's CDN (.vercel/output/static); every
|
|
27
|
+
other request is routed here (config.json routes) and run through the Lowdefy Hono app (page
|
|
28
|
+
rendering, /api/* requests, endpoints, cron, auth, agents).
|
|
29
|
+
|
|
30
|
+
Runs on the Node runtime (the app uses fs to read its build). The request body is buffered eagerly
|
|
31
|
+
and a Web Request is built from it: Vercel's Node runtime does not drain a lazily-read body stream
|
|
32
|
+
reliably, so a streaming adapter hangs on every request that has a body (POST).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import { fileURLToPath } from 'node:url';
|
|
37
|
+
|
|
38
|
+
// The app reads its build artifacts relative to process.cwd(). On Vercel the function's cwd is the
|
|
39
|
+
// lambda root (e.g. /var/task), not this directory, so point the cwd at the server directory (the
|
|
40
|
+
// parent of this api/ folder) before loading the app. The import is dynamic so it runs AFTER chdir —
|
|
41
|
+
// a static import is hoisted and would read files at the wrong cwd.
|
|
42
|
+
process.chdir(path.join(path.dirname(fileURLToPath(import.meta.url)), '..'));
|
|
43
|
+
|
|
44
|
+
const { default: createApp } = await import('../src/app.js');
|
|
45
|
+
const app = createApp({ serveStaticAssets: false });
|
|
46
|
+
|
|
47
|
+
export const config = { runtime: 'nodejs' };
|
|
48
|
+
|
|
49
|
+
export default async function handler(req, res) {
|
|
50
|
+
const method = req.method || 'GET';
|
|
51
|
+
|
|
52
|
+
// Buffer the body eagerly — see the note above.
|
|
53
|
+
let body;
|
|
54
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
55
|
+
const chunks = [];
|
|
56
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
57
|
+
if (chunks.length > 0) body = Buffer.concat(chunks);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const host = req.headers['x-forwarded-host'] ?? req.headers.host;
|
|
61
|
+
const protocol = req.headers['x-forwarded-proto'] ?? 'https';
|
|
62
|
+
const request = new Request(protocol + '://' + host + req.url, {
|
|
63
|
+
method,
|
|
64
|
+
headers: req.headers,
|
|
65
|
+
body,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const response = await app.fetch(request);
|
|
69
|
+
|
|
70
|
+
res.statusCode = response.status;
|
|
71
|
+
response.headers.forEach((value, key) => res.setHeader(key, value));
|
|
72
|
+
if (response.body) {
|
|
73
|
+
const reader = response.body.getReader();
|
|
74
|
+
for (;;) {
|
|
75
|
+
const { done, value } = await reader.read();
|
|
76
|
+
if (done) break;
|
|
77
|
+
res.write(value);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
res.end();
|
|
81
|
+
}
|
|
82
|
+
`;
|
|
83
|
+
export default apiHandler;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/ import fs from 'fs';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import { cleanDirectory, copyFileOrDirectory, readFile, writeFile } from '@lowdefy/node-utils';
|
|
18
|
+
import apiHandler from './apiHandler.js';
|
|
19
|
+
import copyTracedFiles from '../../utils/copyTracedFiles.js';
|
|
20
|
+
import findTraceBase from '../../utils/findTraceBase.js';
|
|
21
|
+
// Function settings come from lowdefy.yaml `config.vercel` (written to build/config.json by
|
|
22
|
+
// @lowdefy/build). maxDuration defaults to 60; plan limits are enforced by Vercel at deploy time.
|
|
23
|
+
async function readFunctionConfig({ buildDirectory }) {
|
|
24
|
+
const raw = await readFile(path.join(buildDirectory, 'config.json'));
|
|
25
|
+
const config = raw ? JSON.parse(raw) : {};
|
|
26
|
+
const vercel = config.vercel ?? {};
|
|
27
|
+
return {
|
|
28
|
+
maxDuration: vercel.maxDuration ?? 60,
|
|
29
|
+
...vercel.memory ? {
|
|
30
|
+
memory: vercel.memory
|
|
31
|
+
} : {}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
// The crons array is generated from build/schedules.json (written by @lowdefy/build for endpoints
|
|
35
|
+
// that declare `schedules`). A missing file means no schedules → no crons. Vercel fires crons only
|
|
36
|
+
// on the production deployment, so with config.cron.environments the schedules of every other
|
|
37
|
+
// environment are registered here too, as cron-forward jobs the production deployment relays to
|
|
38
|
+
// that environment's own /api/cron route.
|
|
39
|
+
async function readCrons({ buildDirectory }) {
|
|
40
|
+
const raw = await readFile(path.join(buildDirectory, 'schedules.json'));
|
|
41
|
+
if (!raw) return [];
|
|
42
|
+
const schedules = JSON.parse(raw);
|
|
43
|
+
return schedules.map(({ endpointId, cron, environment, forward })=>({
|
|
44
|
+
path: forward === true ? `/api/cron-forward/${environment}/${endpointId}` : `/api/cron/${endpointId}`,
|
|
45
|
+
schedule: cron
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
// Assembles a Vercel Build Output (.vercel/output) from an already-built server directory, so the
|
|
49
|
+
// deployment config — including cron jobs derived from endpoint `schedules` — is generated at build
|
|
50
|
+
// time and never committed. Run after `lowdefy build` (server + config artifacts) and the client
|
|
51
|
+
// build (dist/client).
|
|
52
|
+
async function vercelOutput({ context }) {
|
|
53
|
+
const { directories, logger } = context;
|
|
54
|
+
logger.info('Assembling Vercel Build Output.');
|
|
55
|
+
const serverDirectory = path.resolve(directories.server);
|
|
56
|
+
const buildDirectory = path.resolve(directories.build);
|
|
57
|
+
const clientDirectory = path.join(serverDirectory, 'dist', 'client');
|
|
58
|
+
const outputDirectory = path.join(serverDirectory, '.vercel', 'output');
|
|
59
|
+
const staticDirectory = path.join(outputDirectory, 'static');
|
|
60
|
+
const functionDirectory = path.join(outputDirectory, 'functions', 'api.func');
|
|
61
|
+
const entrypoints = [
|
|
62
|
+
path.join(serverDirectory, 'src', 'index.js'),
|
|
63
|
+
path.join(serverDirectory, 'src', 'app.js')
|
|
64
|
+
];
|
|
65
|
+
entrypoints.forEach((entrypoint)=>{
|
|
66
|
+
if (!fs.existsSync(entrypoint)) {
|
|
67
|
+
throw new Error(`Cannot trace "${entrypoint}" — run "lowdefy build" first.`);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
// The function preserves paths relative to the trace base (the pnpm workspace root when the
|
|
71
|
+
// server directory is a workspace member), so the server's relative node_modules symlinks still
|
|
72
|
+
// resolve inside the function. The server lands at <relServer>/ inside api.func.
|
|
73
|
+
const base = findTraceBase({
|
|
74
|
+
serverDirectory
|
|
75
|
+
});
|
|
76
|
+
const relServer = path.relative(base, serverDirectory);
|
|
77
|
+
const functionServerDirectory = path.join(functionDirectory, relServer);
|
|
78
|
+
// Start clean so artifacts from a previous build do not leak into the deployment.
|
|
79
|
+
await cleanDirectory(outputDirectory);
|
|
80
|
+
// 1. Static assets + public files served by the Vercel CDN.
|
|
81
|
+
await copyFileOrDirectory(clientDirectory, staticDirectory);
|
|
82
|
+
// 2. The runtime file closure of the Hono server, traced with @vercel/nft — the app source, the
|
|
83
|
+
// build-generated plugin import files, and exactly the node_modules files they resolve to
|
|
84
|
+
// (through pnpm's symlinks, which are preserved).
|
|
85
|
+
const fileCount = await copyTracedFiles({
|
|
86
|
+
base,
|
|
87
|
+
entrypoints,
|
|
88
|
+
functionDirectory,
|
|
89
|
+
logger,
|
|
90
|
+
processCwd: serverDirectory
|
|
91
|
+
});
|
|
92
|
+
logger.info(`Traced ${fileCount} runtime files into the function.`);
|
|
93
|
+
// 3. Artifacts the server reads from the filesystem at runtime (not imported, so not traced),
|
|
94
|
+
// plus src/ and lib/ wholesale as belt-and-braces alongside the traced copies.
|
|
95
|
+
await copyFileOrDirectory(path.join(serverDirectory, 'src'), path.join(functionServerDirectory, 'src'));
|
|
96
|
+
await copyFileOrDirectory(path.join(serverDirectory, 'lib'), path.join(functionServerDirectory, 'lib'));
|
|
97
|
+
await copyFileOrDirectory(buildDirectory, path.join(functionServerDirectory, 'build'));
|
|
98
|
+
await copyFileOrDirectory(path.join(clientDirectory, '.vite', 'manifest.json'), path.join(functionServerDirectory, 'dist', 'client', '.vite', 'manifest.json'));
|
|
99
|
+
await copyFileOrDirectory(path.join(serverDirectory, 'package.json'), path.join(functionServerDirectory, 'package.json'));
|
|
100
|
+
// 4. The function entry and its config. The handler path is relative to the api.func root.
|
|
101
|
+
const handler = path.posix.join(...relServer.split(path.sep), 'api', 'index.js');
|
|
102
|
+
const functionConfig = await readFunctionConfig({
|
|
103
|
+
buildDirectory
|
|
104
|
+
});
|
|
105
|
+
await writeFile(path.join(functionServerDirectory, 'api', 'index.js'), apiHandler);
|
|
106
|
+
await writeFile(path.join(functionDirectory, '.vc-config.json'), JSON.stringify({
|
|
107
|
+
runtime: 'nodejs22.x',
|
|
108
|
+
handler,
|
|
109
|
+
launcherType: 'Nodejs',
|
|
110
|
+
...functionConfig
|
|
111
|
+
}, null, 2));
|
|
112
|
+
// 5. Deployment config: serve static files first, route everything else to the function, and
|
|
113
|
+
// register the cron jobs.
|
|
114
|
+
const crons = await readCrons({
|
|
115
|
+
buildDirectory
|
|
116
|
+
});
|
|
117
|
+
const config = {
|
|
118
|
+
version: 3,
|
|
119
|
+
routes: [
|
|
120
|
+
{
|
|
121
|
+
handle: 'filesystem'
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
src: '/(.*)',
|
|
125
|
+
dest: '/api'
|
|
126
|
+
}
|
|
127
|
+
]
|
|
128
|
+
};
|
|
129
|
+
if (crons.length > 0) {
|
|
130
|
+
config.crons = crons;
|
|
131
|
+
}
|
|
132
|
+
await writeFile(path.join(outputDirectory, 'config.json'), JSON.stringify(config, null, 2));
|
|
133
|
+
logger.info({
|
|
134
|
+
spin: 'succeed'
|
|
135
|
+
}, `Vercel Build Output assembled with ${crons.length} cron job${crons.length === 1 ? '' : 's'}.`);
|
|
136
|
+
await context.sendTelemetry();
|
|
137
|
+
}
|
|
138
|
+
export default vercelOutput;
|
package/dist/program.js
CHANGED
|
@@ -14,13 +14,17 @@
|
|
|
14
14
|
limitations under the License.
|
|
15
15
|
*/ import { createRequire } from 'module';
|
|
16
16
|
import { Command, Option } from 'commander';
|
|
17
|
+
import agentSetup from './commands/agentSetup/agentSetup.js';
|
|
17
18
|
import build from './commands/build/build.js';
|
|
18
19
|
import dev from './commands/dev/dev.js';
|
|
20
|
+
import dockerOutput from './commands/dockerOutput/dockerOutput.js';
|
|
21
|
+
import emails from './commands/emails/emails.js';
|
|
19
22
|
import init from './commands/init/init.js';
|
|
20
23
|
import initDocker from './commands/init-docker/initDocker.js';
|
|
21
24
|
import initVercel from './commands/init-vercel/initVercel.js';
|
|
22
25
|
import start from './commands/start/start.js';
|
|
23
26
|
import upgrade from './commands/upgrade/upgrade.js';
|
|
27
|
+
import vercelOutput from './commands/vercelOutput/vercelOutput.js';
|
|
24
28
|
import runCommand from './utils/runCommand.js';
|
|
25
29
|
const require = createRequire(import.meta.url);
|
|
26
30
|
const packageJson = require('../package.json');
|
|
@@ -37,22 +41,36 @@ const options = {
|
|
|
37
41
|
'info',
|
|
38
42
|
'debug'
|
|
39
43
|
]).default('info').env('LOWDEFY_LOG_LEVEL'),
|
|
44
|
+
mockUser: new Option('--mock-user [user]', 'Start the dev server authenticated as a mock user (auth.dev.mockUser). Pass a JSON user object to set identity/roles, e.g. \'{"sub":"dev","roles":["admin"]}\'. Bare flag uses a default roleless user. Dev only.').env('LOWDEFY_DEV_USER'),
|
|
40
45
|
port: new Option('--port <port>', 'Change the port the development server is hosted at. Default is 3000.').env('PORT'),
|
|
46
|
+
projectDirectory: new Option('--project-directory <project-directory>', 'Change the directory where agent files (.mcp.json, AGENTS.md, Claude Code skill) are written. Default is the nearest ancestor directory containing .git, falling back to the config directory.').env('LOWDEFY_DIRECTORY_PROJECT'),
|
|
41
47
|
refResolver: new Option('--ref-resolver <ref-resolver-function-path>', 'Path to a JavaScript file containing a _ref resolver function to be used as the app default _ref resolver.'),
|
|
42
48
|
serverDirectory: new Option('--server-directory <server-directory>', 'Change the server directory. Default is "<config-directory>/.lowdefy/server".').env('LOWDEFY_DIRECTORY_SERVER'),
|
|
43
49
|
watch: new Option('--watch <paths...>', 'A list of paths to files or directories that should be watched for changes. Globs are supported. Specify each path to watch separated by spaces.'),
|
|
44
50
|
watchIgnore: new Option('--watch-ignore <paths...>', 'A list of paths to files or directories that should be ignored by the file watcher. Globs are supported. Specify each path to watch separated by spaces.')
|
|
45
51
|
};
|
|
46
|
-
program.command('
|
|
52
|
+
program.command('agent-setup').description('Set up this project for AI coding agents (.mcp.json, AGENTS.md, Claude Code skill).').usage('[options]').addOption(options.configDirectory).addOption(options.disableTelemetry).addOption(options.logLevel).addOption(options.port).addOption(options.projectDirectory).action(runCommand({
|
|
53
|
+
cliVersion,
|
|
54
|
+
handler: agentSetup
|
|
55
|
+
}));
|
|
56
|
+
program.command('build').description('Build a Lowdefy production app.').usage('[options]').addOption(options.configDirectory).addOption(options.disableTelemetry).addOption(options.logLevel).option('--no-client-build', 'Do not run the Vite client build.').addOption(new Option('--no-next-build', 'Deprecated alias of --no-client-build.').hideHelp()).addOption(options.refResolver).addOption(new Option('--server <server>', 'Server package variant. Use "e2e" for @lowdefy/server-e2e.').choices([
|
|
47
57
|
'e2e'
|
|
48
58
|
])).addOption(options.serverDirectory).action(runCommand({
|
|
49
59
|
cliVersion,
|
|
50
60
|
handler: build
|
|
51
61
|
}));
|
|
52
|
-
program.command('dev').description('Start a Lowdefy development server.').usage('[options]').addOption(options.configDirectory).addOption(options.devDirectory).addOption(options.disableTelemetry).addOption(options.logLevel).option('--no-open', 'Do not open a new tab in the default browser.').addOption(options.port).addOption(options.refResolver).addOption(options.watch).addOption(options.watchIgnore).action(runCommand({
|
|
62
|
+
program.command('dev').description('Start a Lowdefy development server.').usage('[options]').addOption(options.configDirectory).addOption(options.devDirectory).addOption(options.disableTelemetry).addOption(options.logLevel).addOption(options.mockUser).option('--no-open', 'Do not open a new tab in the default browser.').addOption(options.port).addOption(options.refResolver).addOption(options.watch).addOption(options.watchIgnore).action(runCommand({
|
|
53
63
|
cliVersion,
|
|
54
64
|
handler: dev
|
|
55
65
|
}));
|
|
66
|
+
program.command('emails').description('Preview notification emails with the React Email preview server.').usage('[options]').addOption(options.configDirectory).addOption(options.disableTelemetry).addOption(options.logLevel).addOption(new Option('--port <port>', 'Change the port the email preview server is hosted at. Default is 3001.').env('PORT')).addOption(options.refResolver).addOption(options.serverDirectory).action(runCommand({
|
|
67
|
+
cliVersion,
|
|
68
|
+
handler: emails
|
|
69
|
+
}));
|
|
70
|
+
program.command('docker-output').description('Assemble a minimal Docker runtime (.lowdefy/docker) from a built app.').usage('[options]').addOption(options.configDirectory).addOption(options.disableTelemetry).addOption(options.logLevel).addOption(options.serverDirectory).action(runCommand({
|
|
71
|
+
cliVersion,
|
|
72
|
+
handler: dockerOutput
|
|
73
|
+
}));
|
|
56
74
|
program.command('init').description('Initialize a Lowdefy project.').usage('[options]').addOption(options.disableTelemetry).addOption(options.logLevel).action(runCommand({
|
|
57
75
|
cliVersion,
|
|
58
76
|
handler: init
|
|
@@ -65,6 +83,10 @@ program.command('init-vercel').description('Initialize Vercel deployment install
|
|
|
65
83
|
cliVersion,
|
|
66
84
|
handler: initVercel
|
|
67
85
|
}));
|
|
86
|
+
program.command('vercel-output').description('Assemble a Vercel Build Output (.vercel/output) from a built app.').usage('[options]').addOption(options.configDirectory).addOption(options.disableTelemetry).addOption(options.logLevel).addOption(options.serverDirectory).action(runCommand({
|
|
87
|
+
cliVersion,
|
|
88
|
+
handler: vercelOutput
|
|
89
|
+
}));
|
|
68
90
|
program.command('start').description('Start a Lowdefy production app.').usage('[options]').addOption(options.configDirectory).addOption(options.disableTelemetry).addOption(options.logLevel).addOption(options.port).addOption(options.serverDirectory).action(runCommand({
|
|
69
91
|
cliVersion,
|
|
70
92
|
handler: start
|
|
@@ -12,26 +12,19 @@
|
|
|
12
12
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
13
|
See the License for the specific language governing permissions and
|
|
14
14
|
limitations under the License.
|
|
15
|
-
*/ import
|
|
16
|
-
function checkPortAvailable({ port }) {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
*/ import { isPortAvailable } from '@lowdefy/node-utils';
|
|
16
|
+
async function checkPortAvailable({ port }) {
|
|
17
|
+
const available = await isPortAvailable({
|
|
18
|
+
port
|
|
19
|
+
});
|
|
20
|
+
if (!available) {
|
|
21
|
+
throw new Error(`[Server Error] Port ${port} is already in use.
|
|
22
22
|
|
|
23
23
|
To use a different port, either:
|
|
24
24
|
- Run with the --port flag: pnpx lowdefy dev --port 3001
|
|
25
25
|
- Set the port in your lowdefy.yaml:
|
|
26
26
|
cli:
|
|
27
|
-
port: 3001`)
|
|
28
|
-
|
|
29
|
-
reject(error);
|
|
30
|
-
}
|
|
31
|
-
});
|
|
32
|
-
server.listen(port, ()=>{
|
|
33
|
-
server.close(()=>resolve());
|
|
34
|
-
});
|
|
35
|
-
});
|
|
27
|
+
port: 3001`);
|
|
28
|
+
}
|
|
36
29
|
}
|
|
37
30
|
export default checkPortAvailable;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright 2020-2026 Lowdefy, Inc
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
*/ import fs from 'fs/promises';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import { nodeFileTrace } from '@vercel/nft';
|
|
18
|
+
const CONCURRENCY = 64;
|
|
19
|
+
async function inBatches(items, worker) {
|
|
20
|
+
for(let i = 0; i < items.length; i += CONCURRENCY){
|
|
21
|
+
await Promise.all(items.slice(i, i + CONCURRENCY).map(worker));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// Traces the runtime file closure of the server entrypoints with @vercel/nft and reproduces it
|
|
25
|
+
// under functionDirectory, preserving relative symlinks. pnpm layouts depend on this: the server's
|
|
26
|
+
// node_modules entries are relative symlinks into a .pnpm virtual store (at the workspace root for
|
|
27
|
+
// workspace members), and nft's fileList contains both the symlink paths and the real target files,
|
|
28
|
+
// all relative to `base` — recreating both yields a working resolution tree inside the function.
|
|
29
|
+
async function copyTracedFiles({ base, entrypoints, functionDirectory, logger, processCwd }) {
|
|
30
|
+
const { fileList, warnings } = await nodeFileTrace(entrypoints, {
|
|
31
|
+
base,
|
|
32
|
+
processCwd
|
|
33
|
+
});
|
|
34
|
+
for (const warning of warnings){
|
|
35
|
+
logger.debug(`nft: ${warning.message}`);
|
|
36
|
+
}
|
|
37
|
+
// Partition by lstat so links are recreated as links, not followed.
|
|
38
|
+
const symlinks = [];
|
|
39
|
+
const files = [];
|
|
40
|
+
const directories = [];
|
|
41
|
+
await inBatches([
|
|
42
|
+
...fileList
|
|
43
|
+
], async (file)=>{
|
|
44
|
+
let stat;
|
|
45
|
+
try {
|
|
46
|
+
stat = await fs.lstat(path.join(base, file));
|
|
47
|
+
} catch {
|
|
48
|
+
logger.debug(`nft traced a missing file: ${file}`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (stat.isSymbolicLink()) {
|
|
52
|
+
symlinks.push(file);
|
|
53
|
+
} else if (stat.isDirectory()) {
|
|
54
|
+
directories.push(file);
|
|
55
|
+
} else {
|
|
56
|
+
files.push({
|
|
57
|
+
file,
|
|
58
|
+
mode: stat.mode
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
await inBatches(directories, async (file)=>{
|
|
63
|
+
await fs.mkdir(path.join(functionDirectory, file), {
|
|
64
|
+
recursive: true
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
// Symlinks before files, parents before children: a file whose destination path traverses a
|
|
68
|
+
// symlinked directory must land at the link's target, not turn the link's path into a real
|
|
69
|
+
// directory. Sequential — symlink creation is cheap and ordering matters.
|
|
70
|
+
symlinks.sort((a, b)=>a.length - b.length);
|
|
71
|
+
for (const file of symlinks){
|
|
72
|
+
const source = path.join(base, file);
|
|
73
|
+
const destination = path.join(functionDirectory, file);
|
|
74
|
+
await fs.mkdir(path.dirname(destination), {
|
|
75
|
+
recursive: true
|
|
76
|
+
});
|
|
77
|
+
try {
|
|
78
|
+
await fs.symlink(await fs.readlink(source), destination);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (error.code !== 'EEXIST') throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
await inBatches(files, async ({ file, mode })=>{
|
|
84
|
+
const source = path.join(base, file);
|
|
85
|
+
const destination = path.join(functionDirectory, file);
|
|
86
|
+
await fs.mkdir(path.dirname(destination), {
|
|
87
|
+
recursive: true
|
|
88
|
+
});
|
|
89
|
+
await fs.copyFile(source, destination);
|
|
90
|
+
await fs.chmod(destination, mode);
|
|
91
|
+
});
|
|
92
|
+
return fileList.size;
|
|
93
|
+
}
|
|
94
|
+
export default copyTracedFiles;
|