create-express-new 1.1.0 → 1.2.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/CHANGELOG.md +17 -1
- package/README.md +51 -9
- package/bin/express-cli.js +58 -12
- package/lib/app.js +32 -6
- package/lib/wizard.js +47 -10
- package/package.json +15 -5
- package/templates/app/app.js.ejs +24 -0
- package/templates/app/csrf.js.ejs +11 -2
- package/templates/app/routes/api.js.ejs +26 -0
- package/templates/app/test/app.test.js.ejs +48 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,21 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [1.2.0] - 2026-10-11
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `--api` with a view engine, such as `--api --view=pug`, generates a web app with a JSON API: a
|
|
14
|
+
`routes/api.js` router at `/api`, and JSON responses for every error under `/api`. The wizard offers
|
|
15
|
+
it as "Web app with a JSON API"
|
|
16
|
+
- `--cors-scope` and `--csrf-scope`, for a web app with a JSON API, choose whether other origins can
|
|
17
|
+
call only the API (default) or the whole app, and whether the API needs the CSRF token (default) or
|
|
18
|
+
only the pages do; the wizard asks both
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- `--api` with `--view` is no longer an error, and `--session` and `--csrf` can be used with them
|
|
23
|
+
|
|
9
24
|
## [1.1.0] - 2026-10-10
|
|
10
25
|
|
|
11
26
|
### Added
|
|
@@ -121,6 +136,7 @@ the full list and a table for migrating commands.
|
|
|
121
136
|
- The `-e/--ejs`, `--hbs`, `--pug` and `-H/--hogan` aliases; use `--view=<engine>`
|
|
122
137
|
- The `debug` package and `DEBUG=...` start instructions
|
|
123
138
|
|
|
124
|
-
[Unreleased]: https://github.com/w0244079/create-express-new/compare/v1.
|
|
139
|
+
[Unreleased]: https://github.com/w0244079/create-express-new/compare/v1.2.0...HEAD
|
|
140
|
+
[1.2.0]: https://github.com/w0244079/create-express-new/compare/v1.1.0...v1.2.0
|
|
125
141
|
[1.1.0]: https://github.com/w0244079/create-express-new/compare/v1.0.0...v1.1.0
|
|
126
142
|
[1.0.0]: https://github.com/w0244079/create-express-new/releases/tag/v1.0.0
|
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ $ pnpm create express-new@latest
|
|
|
26
26
|
`npm create express-new@latest` starts the same wizard. It asks for:
|
|
27
27
|
|
|
28
28
|
- the project directory (`.` for the current directory)
|
|
29
|
-
- a web app or a JSON API, and the view engine
|
|
29
|
+
- a web app, a JSON API or a web app with a JSON API, and the view engine
|
|
30
30
|
- the language: JavaScript, TypeScript or CommonJS
|
|
31
31
|
- optional middleware, and CSRF protection with sessions
|
|
32
32
|
- the request logger
|
|
@@ -122,7 +122,40 @@ client errors include their message, server errors only say `Internal Server Err
|
|
|
122
122
|
and development adds the stack trace. Express runs in development mode unless `NODE_ENV` is set, so
|
|
123
123
|
set `NODE_ENV=production` when you deploy.
|
|
124
124
|
|
|
125
|
-
`--api` works with `--cjs`, `--ts` and the optional middleware,
|
|
125
|
+
`--api` works with `--cjs`, `--ts` and the optional middleware, except `--session`.
|
|
126
|
+
|
|
127
|
+
### Web apps with a JSON API
|
|
128
|
+
|
|
129
|
+
`--api` with a view engine, such as `--api --view=pug`, generates a web app that has a JSON API too:
|
|
130
|
+
everything a web app has, and a `routes/api.js` router mounted at `/api`.
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
my-app
|
|
134
|
+
├── app.js # the web app, with JSON errors under /api
|
|
135
|
+
├── routes/
|
|
136
|
+
│ ├── api.js # JSON routes: GET /api and GET /api/users
|
|
137
|
+
│ ├── index.js # the home page
|
|
138
|
+
│ └── users.js
|
|
139
|
+
├── views/
|
|
140
|
+
└── ...
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Every error of a request under `/api` is JSON, in the same shape as a JSON API's: the 404 of an
|
|
144
|
+
unknown route, an error a route throws, and the errors of the middleware before the routes, such as a
|
|
145
|
+
request body that is not valid JSON. Every other error renders the `error` view.
|
|
146
|
+
|
|
147
|
+
With `--cors` and `--csrf`, you choose which routes they apply to:
|
|
148
|
+
|
|
149
|
+
- `--cors-scope=api` (default) lets other origins call the API only, not read the pages;
|
|
150
|
+
`--cors-scope=all` lets them call the whole app.
|
|
151
|
+
- `--csrf-scope=all` (default) requires the CSRF token under `/api` too, for an API your own pages
|
|
152
|
+
call; `--csrf-scope=pages` leaves the API out, for an API other clients call without the session
|
|
153
|
+
cookie.
|
|
154
|
+
|
|
155
|
+
The wizard asks both.
|
|
156
|
+
|
|
157
|
+
The view engine has to be given, as `--api` alone is a JSON API without views: use `--view=pug` for
|
|
158
|
+
the default one. `--api` does not add an API to a `--no-view` app.
|
|
126
159
|
|
|
127
160
|
### Module formats
|
|
128
161
|
|
|
@@ -158,7 +191,8 @@ files with `--api`). Add more with:
|
|
|
158
191
|
- `--cors`: [cors](https://github.com/expressjs/cors), allowing requests from other origins
|
|
159
192
|
- `--rate-limit`: [express-rate-limit](https://express-rate-limit.mintlify.app/), limiting each client
|
|
160
193
|
to 100 requests every 15 minutes
|
|
161
|
-
- `--session` (
|
|
194
|
+
- `--session` (not for JSON APIs without views): [express-session](https://github.com/expressjs/session),
|
|
195
|
+
for `req.session`
|
|
162
196
|
- `--csrf` (with `--session` and a view engine): [csrf-sync](https://github.com/Psifi-Solutions/csrf-sync)
|
|
163
197
|
CSRF protection for forms
|
|
164
198
|
- `--uploads`: [multer](https://github.com/expressjs/multer) and a `POST /uploads` route that takes one
|
|
@@ -221,15 +255,22 @@ support TypeScript 7 yet, these apps use TypeScript 6.
|
|
|
221
255
|
-v, --view <engine> add view <engine> support (ejs|hbs|pug|twig) (defaults to pug)
|
|
222
256
|
--no-view use static html instead of view engine
|
|
223
257
|
--api generate a JSON API, without views or static files
|
|
258
|
+
(with --view, a web app with a JSON API at /api)
|
|
224
259
|
--cjs generate CommonJS modules instead of ES modules
|
|
225
260
|
--ts generate TypeScript, run directly by Node.js
|
|
226
261
|
--helmet add helmet middleware for security headers
|
|
227
262
|
--compression add compression middleware for gzip/brotli responses
|
|
228
263
|
--cookies add cookie-parser middleware
|
|
229
264
|
--cors add cors middleware for cross-origin requests
|
|
265
|
+
--cors-scope <scope>
|
|
266
|
+
with --api and --view, the routes other origins can
|
|
267
|
+
call (api|all) (defaults to api)
|
|
230
268
|
--rate-limit add express-rate-limit to limit requests per client
|
|
231
|
-
--session add express-session for sessions (not with --api)
|
|
269
|
+
--session add express-session for sessions (not with --api alone)
|
|
232
270
|
--csrf add CSRF protection for forms (needs --session)
|
|
271
|
+
--csrf-scope <scope>
|
|
272
|
+
with --api and --view, the routes that need a CSRF
|
|
273
|
+
token (all|pages) (defaults to all)
|
|
233
274
|
--uploads add multer and an upload route at /uploads
|
|
234
275
|
--logger <name> request logger (morgan|pino) (defaults to morgan)
|
|
235
276
|
--docker add a Dockerfile for a production image
|
|
@@ -257,13 +298,14 @@ pug in place of jade, for Node.js 22.9 or newer. `--view=<engine>` replaces the
|
|
|
257
298
|
## Contributing
|
|
258
299
|
|
|
259
300
|
```bash
|
|
260
|
-
$
|
|
261
|
-
$
|
|
262
|
-
$
|
|
301
|
+
$ pnpm install
|
|
302
|
+
$ pnpm test # generates, installs and runs apps for each option
|
|
303
|
+
$ pnpm lint
|
|
263
304
|
```
|
|
264
305
|
|
|
265
|
-
|
|
266
|
-
|
|
306
|
+
This repository requires pnpm 11 or newer, as generated apps do by default, so
|
|
307
|
+
[install pnpm](https://pnpm.io/installation) first; npm refuses to install it or run its scripts.
|
|
308
|
+
The tests that install an npm app are skipped unless your npm is version 12 or newer.
|
|
267
309
|
|
|
268
310
|
See [Maintaining](docs/maintaining.md) for keeping the dependency versions of generated apps current,
|
|
269
311
|
and for releasing.
|
package/bin/express-cli.js
CHANGED
|
@@ -5,7 +5,7 @@ import fs from 'node:fs'
|
|
|
5
5
|
import path from 'node:path'
|
|
6
6
|
import readline from 'node:readline'
|
|
7
7
|
import util from 'node:util'
|
|
8
|
-
import { DEFAULT_PACKAGE_MANAGER, LOGGERS, PACKAGE_MANAGERS, VIEW_ENGINES, checkDestination, createAppName, mergeLines, planApp, scriptCommand } from '../lib/app.js'
|
|
8
|
+
import { CORS_SCOPES, CSRF_SCOPES, DEFAULT_PACKAGE_MANAGER, LOGGERS, PACKAGE_MANAGERS, VIEW_ENGINES, checkDestination, createAppName, mergeLines, planApp, scriptCommand } from '../lib/app.js'
|
|
9
9
|
import { CancelError } from '../lib/prompts.js'
|
|
10
10
|
import { wizard } from '../lib/wizard.js'
|
|
11
11
|
|
|
@@ -19,7 +19,9 @@ const OPTIONS = {
|
|
|
19
19
|
compression: { type: 'boolean' },
|
|
20
20
|
cookies: { type: 'boolean' },
|
|
21
21
|
cors: { type: 'boolean' },
|
|
22
|
+
'cors-scope': { type: 'string' },
|
|
22
23
|
csrf: { type: 'boolean' },
|
|
24
|
+
'csrf-scope': { type: 'string' },
|
|
23
25
|
docker: { type: 'boolean' },
|
|
24
26
|
force: { type: 'boolean', short: 'f' },
|
|
25
27
|
git: { type: 'boolean' },
|
|
@@ -283,6 +285,14 @@ function main (options, done) {
|
|
|
283
285
|
usage()
|
|
284
286
|
error('option `--pm <name>\' argument missing')
|
|
285
287
|
done(1)
|
|
288
|
+
} else if (options.corsScope === '') {
|
|
289
|
+
usage()
|
|
290
|
+
error('option `--cors-scope <scope>\' argument missing')
|
|
291
|
+
done(1)
|
|
292
|
+
} else if (options.csrfScope === '') {
|
|
293
|
+
usage()
|
|
294
|
+
error('option `--csrf-scope <scope>\' argument missing')
|
|
295
|
+
done(1)
|
|
286
296
|
} else {
|
|
287
297
|
// Path
|
|
288
298
|
const destinationPath = options._[0] || '.'
|
|
@@ -290,14 +300,9 @@ function main (options, done) {
|
|
|
290
300
|
// App name
|
|
291
301
|
const appName = createAppName(path.resolve(destinationPath)) || 'hello-world'
|
|
292
302
|
|
|
293
|
-
// A JSON API has no view engine
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
usage()
|
|
297
|
-
error('option `--api\' cannot be used with `--view\'')
|
|
298
|
-
return done(1)
|
|
299
|
-
}
|
|
300
|
-
|
|
303
|
+
// A JSON API has no view engine, unless one is given with `--view`,
|
|
304
|
+
// for a web app with a JSON API
|
|
305
|
+
if (options.api && typeof options.view !== 'string') {
|
|
301
306
|
options.view = false
|
|
302
307
|
}
|
|
303
308
|
|
|
@@ -340,9 +345,9 @@ function main (options, done) {
|
|
|
340
345
|
}
|
|
341
346
|
|
|
342
347
|
// Sessions are for web apps, and CSRF protection needs them and views
|
|
343
|
-
if (options.session && options.api) {
|
|
348
|
+
if (options.session && options.api && !options.view) {
|
|
344
349
|
usage()
|
|
345
|
-
error('option `--session\' cannot be used with `--api\'')
|
|
350
|
+
error('option `--session\' cannot be used with `--api\', unless `--view\' is given too')
|
|
346
351
|
return done(1)
|
|
347
352
|
}
|
|
348
353
|
|
|
@@ -358,6 +363,38 @@ function main (options, done) {
|
|
|
358
363
|
return done(1)
|
|
359
364
|
}
|
|
360
365
|
|
|
366
|
+
// Where CORS and CSRF protection apply is a choice only for a web app
|
|
367
|
+
// with a JSON API
|
|
368
|
+
const webApi = Boolean(options.api && options.view)
|
|
369
|
+
|
|
370
|
+
if (options.corsScope !== undefined) {
|
|
371
|
+
if (!CORS_SCOPES.includes(options.corsScope)) {
|
|
372
|
+
usage()
|
|
373
|
+
error('unsupported CORS scope `' + options.corsScope + "'")
|
|
374
|
+
return done(1)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (!webApi || !options.cors) {
|
|
378
|
+
usage()
|
|
379
|
+
error('option `--cors-scope\' requires `--cors\', `--api\' and `--view\'')
|
|
380
|
+
return done(1)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (options.csrfScope !== undefined) {
|
|
385
|
+
if (!CSRF_SCOPES.includes(options.csrfScope)) {
|
|
386
|
+
usage()
|
|
387
|
+
error('unsupported CSRF scope `' + options.csrfScope + "'")
|
|
388
|
+
return done(1)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (!webApi || !options.csrf) {
|
|
392
|
+
usage()
|
|
393
|
+
error('option `--csrf-scope\' requires `--csrf\', `--api\' and `--view\'')
|
|
394
|
+
return done(1)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
361
398
|
// Check the destination before writing anything
|
|
362
399
|
const entries = planApp(appName, options)
|
|
363
400
|
const { blockers, conflicts, leftovers } = checkDestination(destinationPath, entries)
|
|
@@ -446,6 +483,8 @@ function parseOptions (argv) {
|
|
|
446
483
|
options.git = !options['no-git']
|
|
447
484
|
options.keepConfig = Boolean(options['keep-config'])
|
|
448
485
|
options.rateLimit = Boolean(options['rate-limit'])
|
|
486
|
+
options.corsScope = options['cors-scope']
|
|
487
|
+
options.csrfScope = options['csrf-scope']
|
|
449
488
|
options.logger ??= 'morgan'
|
|
450
489
|
options.pm ??= DEFAULT_PACKAGE_MANAGER
|
|
451
490
|
|
|
@@ -470,15 +509,22 @@ function usage () {
|
|
|
470
509
|
console.log(' -v, --view <engine> add view <engine> support (ejs|hbs|pug|twig) (defaults to pug)')
|
|
471
510
|
console.log(' --no-view use static html instead of view engine')
|
|
472
511
|
console.log(' --api generate a JSON API, without views or static files')
|
|
512
|
+
console.log(' (with --view, a web app with a JSON API at /api)')
|
|
473
513
|
console.log(' --cjs generate CommonJS modules instead of ES modules')
|
|
474
514
|
console.log(' --ts generate TypeScript, run directly by Node.js')
|
|
475
515
|
console.log(' --helmet add helmet middleware for security headers')
|
|
476
516
|
console.log(' --compression add compression middleware for gzip/brotli responses')
|
|
477
517
|
console.log(' --cookies add cookie-parser middleware')
|
|
478
518
|
console.log(' --cors add cors middleware for cross-origin requests')
|
|
519
|
+
console.log(' --cors-scope <scope>')
|
|
520
|
+
console.log(' with --api and --view, the routes other origins can')
|
|
521
|
+
console.log(' call (api|all) (defaults to api)')
|
|
479
522
|
console.log(' --rate-limit add express-rate-limit to limit requests per client')
|
|
480
|
-
console.log(' --session add express-session for sessions (not with --api)')
|
|
523
|
+
console.log(' --session add express-session for sessions (not with --api alone)')
|
|
481
524
|
console.log(' --csrf add CSRF protection for forms (needs --session)')
|
|
525
|
+
console.log(' --csrf-scope <scope>')
|
|
526
|
+
console.log(' with --api and --view, the routes that need a CSRF')
|
|
527
|
+
console.log(' token (all|pages) (defaults to all)')
|
|
482
528
|
console.log(' --uploads add multer and an upload route at /uploads')
|
|
483
529
|
console.log(' --logger <name> request logger (morgan|pino) (defaults to morgan)')
|
|
484
530
|
console.log(' --docker add a Dockerfile for a production image')
|
package/lib/app.js
CHANGED
|
@@ -35,6 +35,11 @@ export const PACKAGE_MANAGERS = {
|
|
|
35
35
|
|
|
36
36
|
export const DEFAULT_PACKAGE_MANAGER = 'pnpm'
|
|
37
37
|
|
|
38
|
+
// where the CORS headers and the CSRF protection of a web app with a JSON
|
|
39
|
+
// API apply, with the default first
|
|
40
|
+
export const CORS_SCOPES = ['api', 'all']
|
|
41
|
+
export const CSRF_SCOPES = ['all', 'pages']
|
|
42
|
+
|
|
38
43
|
// views generated only with some options
|
|
39
44
|
const OPTIONAL_VIEWS = { 'new-user': 'csrf', uploads: 'uploads' }
|
|
40
45
|
|
|
@@ -75,7 +80,7 @@ const TSCONFIG = {
|
|
|
75
80
|
// not use: code in the other language, the other ESLint config, the pnpm
|
|
76
81
|
// settings and the templates of other view engines
|
|
77
82
|
const ALTERNATIVES = [
|
|
78
|
-
...['app', 'bin/www', 'csrf', 'routes/index', 'routes/uploads', 'routes/users', 'test/app.test'].flatMap((file) => [file + '.js', file + '.ts']),
|
|
83
|
+
...['app', 'bin/www', 'csrf', 'routes/api', 'routes/index', 'routes/uploads', 'routes/users', 'test/app.test'].flatMap((file) => [file + '.js', file + '.ts']),
|
|
79
84
|
'eslint.config.js',
|
|
80
85
|
'eslint.config.mjs',
|
|
81
86
|
'pnpm-workspace.yaml',
|
|
@@ -117,13 +122,21 @@ export function planApp (name, options) {
|
|
|
117
122
|
const esm = !options.cjs
|
|
118
123
|
const dirname = esm ? 'import.meta.dirname' : '__dirname'
|
|
119
124
|
|
|
120
|
-
// App kind: a JSON API has no views or static files
|
|
121
|
-
|
|
125
|
+
// App kind: a JSON API has no views or static files, and a web app with
|
|
126
|
+
// a view engine can have a JSON API too, under /api
|
|
127
|
+
const api = Boolean(options.api) && !options.view
|
|
128
|
+
const webApi = Boolean(options.api) && Boolean(options.view)
|
|
122
129
|
|
|
123
130
|
// Sessions, and CSRF protection using them, are for web apps with views
|
|
124
131
|
const session = Boolean(options.session) && !api
|
|
125
132
|
const csrf = Boolean(options.csrf) && session && Boolean(options.view)
|
|
126
133
|
|
|
134
|
+
// In a web app with a JSON API, other origins can call only the API,
|
|
135
|
+
// unless the whole app is chosen, and CSRF protection covers the API,
|
|
136
|
+
// unless only the pages are chosen
|
|
137
|
+
const corsApi = webApi && options.corsScope !== 'all'
|
|
138
|
+
const csrfSkipApi = webApi && csrf && options.csrfScope === 'pages'
|
|
139
|
+
|
|
127
140
|
// File uploads, with an upload form in apps with views
|
|
128
141
|
const uploads = Boolean(options.uploads)
|
|
129
142
|
|
|
@@ -182,6 +195,7 @@ export function planApp (name, options) {
|
|
|
182
195
|
}
|
|
183
196
|
|
|
184
197
|
app.locals.dirname = dirname
|
|
198
|
+
app.locals.webApi = webApi
|
|
185
199
|
app.locals.httpErrors = Boolean(options.view) || api
|
|
186
200
|
app.locals.trustProxy = trustProxy
|
|
187
201
|
app.locals.session = session
|
|
@@ -191,6 +205,8 @@ export function planApp (name, options) {
|
|
|
191
205
|
test.locals.csrf = csrf
|
|
192
206
|
test.locals.uploads = uploads
|
|
193
207
|
test.locals.view = Boolean(options.view)
|
|
208
|
+
test.locals.webApi = webApi
|
|
209
|
+
test.locals.csrfSkipApi = csrfSkipApi
|
|
194
210
|
|
|
195
211
|
// App modules
|
|
196
212
|
app.locals.localModules = Object.create(null)
|
|
@@ -216,7 +232,7 @@ export function planApp (name, options) {
|
|
|
216
232
|
// Cross-origin requests
|
|
217
233
|
if (options.cors) {
|
|
218
234
|
app.locals.modules.cors = 'cors'
|
|
219
|
-
app.locals.uses.push("cors({\n // any origin, or only the comma-separated origins in CORS_ORIGIN\n origin: process.env.CORS_ORIGIN?.split(',').map((origin) => origin.trim()) ?? '*'\n})")
|
|
235
|
+
app.locals.uses.push((corsApi ? "'/api', " : '') + "cors({\n // any origin, or only the comma-separated origins in CORS_ORIGIN\n origin: process.env.CORS_ORIGIN?.split(',').map((origin) => origin.trim()) ?? '*'\n})")
|
|
220
236
|
pkg.dependencies.cors = VERSIONS.cors
|
|
221
237
|
}
|
|
222
238
|
|
|
@@ -304,6 +320,16 @@ export function planApp (name, options) {
|
|
|
304
320
|
pkg.dependencies['http-errors'] = VERSIONS['http-errors']
|
|
305
321
|
}
|
|
306
322
|
|
|
323
|
+
// API router, for the JSON routes of a web app
|
|
324
|
+
if (webApi) {
|
|
325
|
+
const router = loadTemplate('app/routes/api.js')
|
|
326
|
+
router.locals.esm = esm
|
|
327
|
+
file('routes/api.' + ext, router.render())
|
|
328
|
+
|
|
329
|
+
app.locals.localModules.apiRouter = './routes/api.' + ext
|
|
330
|
+
app.locals.mounts.push({ path: '/api', code: 'apiRouter' })
|
|
331
|
+
}
|
|
332
|
+
|
|
307
333
|
// Template support
|
|
308
334
|
if (options.view) {
|
|
309
335
|
const view = VIEW_ENGINES[options.view]
|
|
@@ -373,6 +399,7 @@ export function planApp (name, options) {
|
|
|
373
399
|
const module = loadTemplate('app/csrf.js')
|
|
374
400
|
module.locals.esm = esm
|
|
375
401
|
module.locals.uploads = uploads
|
|
402
|
+
module.locals.skipApi = csrfSkipApi
|
|
376
403
|
file('csrf.' + ext, module.render())
|
|
377
404
|
|
|
378
405
|
app.locals.localModules.csrf = './csrf.' + ext
|
|
@@ -550,8 +577,7 @@ export function scriptCommand (pm, script) {
|
|
|
550
577
|
|
|
551
578
|
export function mergeLines (existing, generated) {
|
|
552
579
|
const have = new Set(existing.split(/\r?\n/).map((line) => line.trim()))
|
|
553
|
-
const missing = generated.split('\n')
|
|
554
|
-
.map((line) => line.trim())
|
|
580
|
+
const missing = [...new Set(generated.split('\n').map((line) => line.trim()))]
|
|
555
581
|
.filter((line) => line && !line.startsWith('#') && !have.has(line))
|
|
556
582
|
|
|
557
583
|
if (!missing.length) return existing
|
package/lib/wizard.js
CHANGED
|
@@ -47,14 +47,20 @@ export async function wizard (io) {
|
|
|
47
47
|
initial: answers.kind,
|
|
48
48
|
choices: [
|
|
49
49
|
{ label: 'Web app', value: 'web', hint: 'server-rendered views' },
|
|
50
|
-
{ label: 'JSON API', value: 'api', hint: 'no views or static files' }
|
|
50
|
+
{ label: 'JSON API', value: 'api', hint: 'no views or static files' },
|
|
51
|
+
{ label: 'Web app with a JSON API', value: 'both', hint: 'under /api' }
|
|
51
52
|
]
|
|
52
53
|
})
|
|
53
54
|
},
|
|
54
55
|
{
|
|
55
56
|
name: 'view',
|
|
56
|
-
when: () => answers.kind
|
|
57
|
-
ask: (step) => step.ask(select, {
|
|
57
|
+
when: () => answers.kind !== 'api',
|
|
58
|
+
ask: (step) => step.ask(select, {
|
|
59
|
+
message: 'View engine',
|
|
60
|
+
initial: answers.view,
|
|
61
|
+
// the pages of a web app with a JSON API need a view engine
|
|
62
|
+
choices: answers.kind === 'both' ? VIEWS.filter((view) => view.value !== false) : VIEWS
|
|
63
|
+
})
|
|
58
64
|
},
|
|
59
65
|
{
|
|
60
66
|
name: 'language',
|
|
@@ -80,16 +86,40 @@ export async function wizard (io) {
|
|
|
80
86
|
{ label: 'cors', value: 'cors', hint: 'cross-origin requests' },
|
|
81
87
|
{ label: 'express-rate-limit', value: 'rate-limit', hint: 'limit requests per client' },
|
|
82
88
|
// sessions are for web apps
|
|
83
|
-
...(answers.kind
|
|
89
|
+
...(answers.kind !== 'api' ? [{ label: 'express-session', value: 'session', hint: 'req.session' }] : []),
|
|
84
90
|
{ label: 'multer', value: 'uploads', hint: 'file uploads at /uploads' }
|
|
85
91
|
]
|
|
86
92
|
})
|
|
87
93
|
},
|
|
94
|
+
{
|
|
95
|
+
name: 'corsScope',
|
|
96
|
+
when: () => answers.kind === 'both' && answers.middleware.includes('cors'),
|
|
97
|
+
ask: (step) => step.ask(select, {
|
|
98
|
+
message: 'Other origins can call',
|
|
99
|
+
initial: answers.corsScope,
|
|
100
|
+
choices: [
|
|
101
|
+
{ label: 'The API only', value: 'api', hint: 'routes under /api' },
|
|
102
|
+
{ label: 'The whole app', value: 'all', hint: 'the pages too' }
|
|
103
|
+
]
|
|
104
|
+
})
|
|
105
|
+
},
|
|
88
106
|
{
|
|
89
107
|
name: 'csrf',
|
|
90
|
-
when: () => answers.kind
|
|
108
|
+
when: () => answers.kind !== 'api' && answers.view !== false && answers.middleware.includes('session'),
|
|
91
109
|
ask: (step) => step.ask(confirm, { message: 'Protect forms with CSRF tokens?', initial: answers.csrf ?? true })
|
|
92
110
|
},
|
|
111
|
+
{
|
|
112
|
+
name: 'csrfScope',
|
|
113
|
+
when: () => answers.kind === 'both' && answers.middleware.includes('session') && answers.csrf === true,
|
|
114
|
+
ask: (step) => step.ask(select, {
|
|
115
|
+
message: 'CSRF tokens for the API',
|
|
116
|
+
initial: answers.csrfScope,
|
|
117
|
+
choices: [
|
|
118
|
+
{ label: 'Required', value: 'all', hint: 'your pages call the API' },
|
|
119
|
+
{ label: 'Not required', value: 'pages', hint: 'other clients call it, without the session cookie' }
|
|
120
|
+
]
|
|
121
|
+
})
|
|
122
|
+
},
|
|
93
123
|
{
|
|
94
124
|
name: 'logger',
|
|
95
125
|
ask: (step) => step.ask(select, {
|
|
@@ -305,18 +335,22 @@ function eraseText (io, printed) {
|
|
|
305
335
|
|
|
306
336
|
function toOptions (answers) {
|
|
307
337
|
const { directory, kind, language, middleware = [], extras = [] } = answers
|
|
308
|
-
const view = kind === '
|
|
309
|
-
const session = kind
|
|
338
|
+
const view = kind === 'api' ? false : answers.view
|
|
339
|
+
const session = kind !== 'api' && middleware.includes('session')
|
|
340
|
+
const csrf = session && view !== false && answers.csrf === true
|
|
310
341
|
|
|
311
342
|
return {
|
|
312
343
|
_: [directory.dir],
|
|
313
344
|
'!': [],
|
|
314
|
-
api: kind
|
|
345
|
+
api: kind !== 'web',
|
|
315
346
|
cjs: language === 'cjs',
|
|
316
347
|
compression: middleware.includes('compression'),
|
|
317
348
|
cookies: middleware.includes('cookies'),
|
|
318
349
|
cors: middleware.includes('cors'),
|
|
319
|
-
|
|
350
|
+
// the choices of a web app with a JSON API
|
|
351
|
+
...(kind === 'both' && middleware.includes('cors') && { corsScope: answers.corsScope ?? 'api' }),
|
|
352
|
+
csrf,
|
|
353
|
+
...(kind === 'both' && csrf && { csrfScope: answers.csrfScope ?? 'all' }),
|
|
320
354
|
docker: extras.includes('docker'),
|
|
321
355
|
force: directory.force,
|
|
322
356
|
git: answers.git,
|
|
@@ -345,7 +379,8 @@ export function toCommand (options) {
|
|
|
345
379
|
const args = [pm, 'create', 'express-new@latest', quote(options._[0])]
|
|
346
380
|
const flags = []
|
|
347
381
|
|
|
348
|
-
|
|
382
|
+
// a JSON API has no view engine, unless one is given, the default included
|
|
383
|
+
if (options.api) flags.push('--api', ...(options.view ? ['--view=' + options.view] : []))
|
|
349
384
|
else if (options.view === false) flags.push('--no-view')
|
|
350
385
|
else if (options.view !== 'pug') flags.push('--view=' + options.view)
|
|
351
386
|
|
|
@@ -355,9 +390,11 @@ export function toCommand (options) {
|
|
|
355
390
|
if (options.compression) flags.push('--compression')
|
|
356
391
|
if (options.cookies) flags.push('--cookies')
|
|
357
392
|
if (options.cors) flags.push('--cors')
|
|
393
|
+
if (options.corsScope === 'all') flags.push('--cors-scope=all')
|
|
358
394
|
if (options.rateLimit) flags.push('--rate-limit')
|
|
359
395
|
if (options.session) flags.push('--session')
|
|
360
396
|
if (options.csrf) flags.push('--csrf')
|
|
397
|
+
if (options.csrfScope === 'pages') flags.push('--csrf-scope=pages')
|
|
361
398
|
if (options.uploads) flags.push('--uploads')
|
|
362
399
|
if (options.logger && options.logger !== 'morgan') flags.push('--logger=' + options.logger)
|
|
363
400
|
if (options.docker) flags.push('--docker')
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-express-new",
|
|
3
3
|
"description": "Express 5 application generator: ESM, TypeScript and CommonJS app templates. A modern successor to express-generator.",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.2.0",
|
|
5
5
|
"author": "Michael Caines (https://github.com/w0244079)",
|
|
6
6
|
"contributors": [
|
|
7
7
|
"TJ Holowaychuk <tj@vision-media.ca>",
|
|
@@ -27,7 +27,10 @@
|
|
|
27
27
|
"web",
|
|
28
28
|
"api"
|
|
29
29
|
],
|
|
30
|
-
"repository":
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "github:w0244079/create-express-new"
|
|
33
|
+
},
|
|
31
34
|
"homepage": "https://github.com/w0244079/create-express-new#readme",
|
|
32
35
|
"bugs": "https://github.com/w0244079/create-express-new/issues",
|
|
33
36
|
"license": "MIT",
|
|
@@ -41,6 +44,7 @@
|
|
|
41
44
|
},
|
|
42
45
|
"devDependencies": {
|
|
43
46
|
"eslint": "^9.39.5",
|
|
47
|
+
"fast-check": "^4.10.2",
|
|
44
48
|
"mocha": "^12.0.3",
|
|
45
49
|
"neostandard": "^0.13.0",
|
|
46
50
|
"supertest": "^7.3.0",
|
|
@@ -50,6 +54,13 @@
|
|
|
50
54
|
"engines": {
|
|
51
55
|
"node": ">=22.9"
|
|
52
56
|
},
|
|
57
|
+
"devEngines": {
|
|
58
|
+
"packageManager": {
|
|
59
|
+
"name": "pnpm",
|
|
60
|
+
"version": ">=11",
|
|
61
|
+
"onFail": "error"
|
|
62
|
+
}
|
|
63
|
+
},
|
|
53
64
|
"files": [
|
|
54
65
|
"CHANGELOG.md",
|
|
55
66
|
"LICENSE",
|
|
@@ -61,7 +72,6 @@
|
|
|
61
72
|
"lint": "eslint .",
|
|
62
73
|
"test": "mocha --reporter spec --bail --check-leaks test/",
|
|
63
74
|
"test-ci": "mocha --reporter spec --check-leaks test/",
|
|
64
|
-
"versions": "node scripts/versions.js"
|
|
65
|
-
"prepublishOnly": "npm run lint && npm run test-ci"
|
|
75
|
+
"versions": "node scripts/versions.js"
|
|
66
76
|
}
|
|
67
|
-
}
|
|
77
|
+
}
|
package/templates/app/app.js.ejs
CHANGED
|
@@ -84,7 +84,31 @@ app.use((req, res, next) => {
|
|
|
84
84
|
next(createError(404));
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
+
<% if (webApi) { -%>
|
|
88
|
+
// error handler for the API, responding with JSON to every error of a
|
|
89
|
+
// request under /api, including those of the middleware above
|
|
90
|
+
<% if (ts) { -%>
|
|
91
|
+
app.use('/api', (err: HttpError, req: Request, res: Response, next: NextFunction) => {
|
|
92
|
+
<% } else { -%>
|
|
93
|
+
app.use('/api', (err, req, res, next) => {
|
|
94
|
+
<% } -%>
|
|
95
|
+
const status = err.status || 500;
|
|
96
|
+
const development = req.app.get('env') === 'development';
|
|
97
|
+
|
|
98
|
+
// log server errors, which are bugs, but not client errors
|
|
99
|
+
if (status >= 500) console.error(err);
|
|
100
|
+
|
|
101
|
+
// only expose client error messages, or every error in development
|
|
102
|
+
res.status(status).json({
|
|
103
|
+
error: err.expose || development ? err.message : 'Internal Server Error',
|
|
104
|
+
...(development && { stack: err.stack })
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// error handler for the pages
|
|
109
|
+
<% } else { -%>
|
|
87
110
|
// error handler
|
|
111
|
+
<% } -%>
|
|
88
112
|
<% if (ts) { -%>
|
|
89
113
|
app.use((err: HttpError, req: Request, res: Response, next: NextFunction) => {
|
|
90
114
|
<% } else { -%>
|
|
@@ -7,8 +7,17 @@ const { csrfSync } = require('csrf-sync');
|
|
|
7
7
|
// CSRF protection for requests that change data, which must send the token
|
|
8
8
|
// in a _csrf form field or an x-csrf-token header (GET is not checked)
|
|
9
9
|
const csrf = csrfSync({
|
|
10
|
-
getTokenFromRequest: (req) => req.body?._csrf ?? req.get('x-csrf-token')<%- uploads ? ',' : '' %>
|
|
11
|
-
<% if (uploads) { -%>
|
|
10
|
+
getTokenFromRequest: (req) => req.body?._csrf ?? req.get('x-csrf-token')<%- uploads || skipApi ? ',' : '' %>
|
|
11
|
+
<% if (uploads && skipApi) { -%>
|
|
12
|
+
// uploads check the token themselves, as multer reads their form, and the
|
|
13
|
+
// API is not protected, so its routes must not trust the session cookie:
|
|
14
|
+
// authenticate them another way, such as a token in a header
|
|
15
|
+
skipCsrfProtection: (req) => req.path === '/uploads' || /^\/api(\/|$)/i.test(req.path)
|
|
16
|
+
<% } else if (skipApi) { -%>
|
|
17
|
+
// the API is not protected, so its routes must not trust the session
|
|
18
|
+
// cookie: authenticate them another way, such as a token in a header
|
|
19
|
+
skipCsrfProtection: (req) => /^\/api(\/|$)/i.test(req.path)
|
|
20
|
+
<% } else if (uploads) { -%>
|
|
12
21
|
// uploads check the token themselves, as multer reads their form
|
|
13
22
|
skipCsrfProtection: (req) => req.path === '/uploads'
|
|
14
23
|
<% } -%>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<% if (esm) { -%>
|
|
2
|
+
import express from 'express';
|
|
3
|
+
<% } else { -%>
|
|
4
|
+
const express = require('express');
|
|
5
|
+
<% } -%>
|
|
6
|
+
|
|
7
|
+
const router = express.Router();
|
|
8
|
+
|
|
9
|
+
// JSON routes, mounted at /api: errors under /api, such as the 404 of a
|
|
10
|
+
// route that is not here, respond with JSON too (see the app's error handlers)
|
|
11
|
+
|
|
12
|
+
/* GET the API's welcome message. */
|
|
13
|
+
router.get('/', (req, res) => {
|
|
14
|
+
res.json({ message: 'Welcome to Express' });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/* GET users listing. */
|
|
18
|
+
router.get('/users', (req, res) => {
|
|
19
|
+
res.json([]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
<% if (esm) { -%>
|
|
23
|
+
export default router;
|
|
24
|
+
<% } else { -%>
|
|
25
|
+
module.exports = router;
|
|
26
|
+
<% } -%>
|
|
@@ -194,6 +194,54 @@ describe('app', () => {
|
|
|
194
194
|
it('responds with 404 for unknown routes', async () => {
|
|
195
195
|
const res = await fetch(`${baseUrl}/does-not-exist`);
|
|
196
196
|
assert.strictEqual(res.status, 404);
|
|
197
|
+
<% if (webApi) { -%>
|
|
198
|
+
assert.match(res.headers.get('content-type') ?? '', /text\/html/);
|
|
199
|
+
<% } -%>
|
|
200
|
+
});
|
|
201
|
+
<% if (webApi) { -%>
|
|
202
|
+
|
|
203
|
+
it('responds to GET /api', async () => {
|
|
204
|
+
const res = await fetch(`${baseUrl}/api`);
|
|
205
|
+
assert.strictEqual(res.status, 200);
|
|
206
|
+
assert.deepStrictEqual(await res.json(), { message: 'Welcome to Express' });
|
|
197
207
|
});
|
|
208
|
+
|
|
209
|
+
it('responds to GET /api/users', async () => {
|
|
210
|
+
const res = await fetch(`${baseUrl}/api/users`);
|
|
211
|
+
assert.strictEqual(res.status, 200);
|
|
212
|
+
assert.deepStrictEqual(await res.json(), []);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('responds with a JSON 404 for unknown API routes', async () => {
|
|
216
|
+
const res = await fetch(`${baseUrl}/api/does-not-exist`);
|
|
217
|
+
assert.strictEqual(res.status, 404);
|
|
218
|
+
assert.match(res.headers.get('content-type') ?? '', /application\/json/);
|
|
219
|
+
assert.strictEqual((await res.json()).error, 'Not Found');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('responds with a JSON error for an invalid API request body', async () => {
|
|
223
|
+
const res = await fetch(`${baseUrl}/api/users`, {
|
|
224
|
+
method: 'POST',
|
|
225
|
+
headers: { 'content-type': 'application/json' },
|
|
226
|
+
body: '{'
|
|
227
|
+
});
|
|
228
|
+
assert.strictEqual(res.status, 400);
|
|
229
|
+
assert.match(res.headers.get('content-type') ?? '', /application\/json/);
|
|
230
|
+
});
|
|
231
|
+
<% if (csrf && csrfSkipApi) { -%>
|
|
232
|
+
|
|
233
|
+
it('does not ask API posts for a CSRF token', async () => {
|
|
234
|
+
const res = await fetch(`${baseUrl}/api/does-not-exist`, { method: 'POST' });
|
|
235
|
+
assert.strictEqual(res.status, 404);
|
|
236
|
+
});
|
|
237
|
+
<% } else if (csrf) { -%>
|
|
238
|
+
|
|
239
|
+
it('rejects API posts without a CSRF token, with a JSON error', async () => {
|
|
240
|
+
const res = await fetch(`${baseUrl}/api/users`, { method: 'POST' });
|
|
241
|
+
assert.strictEqual(res.status, 403);
|
|
242
|
+
assert.match(res.headers.get('content-type') ?? '', /application\/json/);
|
|
243
|
+
});
|
|
244
|
+
<% } -%>
|
|
245
|
+
<% } -%>
|
|
198
246
|
});
|
|
199
247
|
<% } -%>
|