create-express-new 1.1.0 → 1.3.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 +27 -1
- package/README.md +72 -12
- package/bin/express-cli.js +67 -12
- package/lib/app.js +71 -11
- package/lib/wizard.js +52 -11
- package/package.json +15 -5
- package/templates/app/Dockerfile.ejs +19 -0
- 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/templates/public/stylesheets/style.scss +10 -0
- package/templates/versions.json +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,30 @@ All notable changes to this project are documented here. The format is based on
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [1.3.0] - 2026-10-11
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `--sass` adds Sass to a web app: a `style.scss` compiled to the CSS the app serves by a `css`
|
|
14
|
+
script that runs before `start` and `dev`, a `css:watch` script, and a stage in the `--docker`
|
|
15
|
+
image that compiles the stylesheets. The wizard offers it under Extras
|
|
16
|
+
- Issue templates for bug reports and feature requests
|
|
17
|
+
|
|
18
|
+
## [1.2.0] - 2026-10-11
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `--api` with a view engine, such as `--api --view=pug`, generates a web app with a JSON API: a
|
|
23
|
+
`routes/api.js` router at `/api`, and JSON responses for every error under `/api`. The wizard offers
|
|
24
|
+
it as "Web app with a JSON API"
|
|
25
|
+
- `--cors-scope` and `--csrf-scope`, for a web app with a JSON API, choose whether other origins can
|
|
26
|
+
call only the API (default) or the whole app, and whether the API needs the CSRF token (default) or
|
|
27
|
+
only the pages do; the wizard asks both
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- `--api` with `--view` is no longer an error, and `--session` and `--csrf` can be used with them
|
|
32
|
+
|
|
9
33
|
## [1.1.0] - 2026-10-10
|
|
10
34
|
|
|
11
35
|
### Added
|
|
@@ -121,6 +145,8 @@ the full list and a table for migrating commands.
|
|
|
121
145
|
- The `-e/--ejs`, `--hbs`, `--pug` and `-H/--hogan` aliases; use `--view=<engine>`
|
|
122
146
|
- The `debug` package and `DEBUG=...` start instructions
|
|
123
147
|
|
|
124
|
-
[Unreleased]: https://github.com/w0244079/create-express-new/compare/v1.
|
|
148
|
+
[Unreleased]: https://github.com/w0244079/create-express-new/compare/v1.3.0...HEAD
|
|
149
|
+
[1.3.0]: https://github.com/w0244079/create-express-new/compare/v1.2.0...v1.3.0
|
|
150
|
+
[1.2.0]: https://github.com/w0244079/create-express-new/compare/v1.1.0...v1.2.0
|
|
125
151
|
[1.1.0]: https://github.com/w0244079/create-express-new/compare/v1.0.0...v1.1.0
|
|
126
152
|
[1.0.0]: https://github.com/w0244079/create-express-new/releases/tag/v1.0.0
|
package/README.md
CHANGED
|
@@ -26,11 +26,11 @@ $ 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
|
|
33
|
-
- extras: a Dockerfile and
|
|
33
|
+
- extras: a Dockerfile, ESLint and Sass
|
|
34
34
|
- a `.gitignore`
|
|
35
35
|
- the [package manager](#package-manager) the app requires: pnpm, or npm
|
|
36
36
|
|
|
@@ -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
|
|
@@ -203,6 +237,7 @@ The image:
|
|
|
203
237
|
- is based on `node:22-slim`, the latest release of the minimum supported Node.js version
|
|
204
238
|
- installs only production dependencies, in a build stage of their own, so the package manager is
|
|
205
239
|
not in the final image
|
|
240
|
+
- with `--sass`, compiles the stylesheets in another build stage, so Sass is not in the final image
|
|
206
241
|
- runs the app as the unprivileged `node` user with `NODE_ENV=production`
|
|
207
242
|
- checks `/health` with a `HEALTHCHECK`
|
|
208
243
|
- runs `node` directly, so it receives `SIGTERM` and shuts down gracefully
|
|
@@ -216,22 +251,45 @@ The image:
|
|
|
216
251
|
`--ts` it also adds [typescript-eslint](https://typescript-eslint.io/); as typescript-eslint does not
|
|
217
252
|
support TypeScript 7 yet, these apps use TypeScript 6.
|
|
218
253
|
|
|
254
|
+
### Sass
|
|
255
|
+
|
|
256
|
+
`--sass` adds [Sass](https://sass-lang.com/) to a web app: a `public/stylesheets/style.scss` in place
|
|
257
|
+
of `style.css`, compiled to the CSS the app serves.
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
$ pnpm start # compiles the stylesheets, then starts the app
|
|
261
|
+
$ pnpm dev # compiles them once, then restarts the app on change
|
|
262
|
+
$ pnpm css:watch # recompiles them on change, in a second terminal
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Sass is a development dependency, so `pnpm start` fails where only production dependencies are
|
|
266
|
+
installed: compile the stylesheets first with `pnpm css`, as the `--docker` image does.
|
|
267
|
+
[Using Sass](docs/sass.md) covers this, the files it ignores and adding Sass to an existing app.
|
|
268
|
+
|
|
219
269
|
## Command Line Options
|
|
220
270
|
|
|
221
271
|
-v, --view <engine> add view <engine> support (ejs|hbs|pug|twig) (defaults to pug)
|
|
222
272
|
--no-view use static html instead of view engine
|
|
223
273
|
--api generate a JSON API, without views or static files
|
|
274
|
+
(with --view, a web app with a JSON API at /api)
|
|
224
275
|
--cjs generate CommonJS modules instead of ES modules
|
|
225
276
|
--ts generate TypeScript, run directly by Node.js
|
|
226
277
|
--helmet add helmet middleware for security headers
|
|
227
278
|
--compression add compression middleware for gzip/brotli responses
|
|
228
279
|
--cookies add cookie-parser middleware
|
|
229
280
|
--cors add cors middleware for cross-origin requests
|
|
281
|
+
--cors-scope <scope>
|
|
282
|
+
with --api and --view, the routes other origins can
|
|
283
|
+
call (api|all) (defaults to api)
|
|
230
284
|
--rate-limit add express-rate-limit to limit requests per client
|
|
231
|
-
--session add express-session for sessions (not with --api)
|
|
285
|
+
--session add express-session for sessions (not with --api alone)
|
|
232
286
|
--csrf add CSRF protection for forms (needs --session)
|
|
287
|
+
--csrf-scope <scope>
|
|
288
|
+
with --api and --view, the routes that need a CSRF
|
|
289
|
+
token (all|pages) (defaults to all)
|
|
233
290
|
--uploads add multer and an upload route at /uploads
|
|
234
291
|
--logger <name> request logger (morgan|pino) (defaults to morgan)
|
|
292
|
+
--sass add Sass, compiling .scss stylesheets (not with --api alone)
|
|
235
293
|
--docker add a Dockerfile for a production image
|
|
236
294
|
--lint add ESLint and a lint script
|
|
237
295
|
--pm <name> package manager the app requires (pnpm|npm) (defaults to pnpm)
|
|
@@ -248,22 +306,24 @@ support TypeScript 7 yet, these apps use TypeScript 6.
|
|
|
248
306
|
|
|
249
307
|
This fork started from `express-generator` 4.16.1. It generates Express 5 apps as ES modules, with
|
|
250
308
|
pug in place of jade, for Node.js 22.9 or newer. `--view=<engine>` replaces the `--ejs`, `--hbs` and
|
|
251
|
-
`--pug` aliases, and apps use plain CSS
|
|
309
|
+
`--pug` aliases, and apps use plain CSS unless you add Sass with `--sass`; the other CSS preprocessors
|
|
310
|
+
are gone.
|
|
252
311
|
|
|
253
312
|
- [Changes from express-generator](docs/changes-from-express-generator.md): everything added,
|
|
254
313
|
changed and removed, and a table for migrating commands
|
|
255
|
-
- [Using Sass](docs/sass.md): adding Sass to
|
|
314
|
+
- [Using Sass](docs/sass.md): what `--sass` adds, and adding Sass to an existing app
|
|
256
315
|
|
|
257
316
|
## Contributing
|
|
258
317
|
|
|
259
318
|
```bash
|
|
260
|
-
$
|
|
261
|
-
$
|
|
262
|
-
$
|
|
319
|
+
$ pnpm install
|
|
320
|
+
$ pnpm test # generates, installs and runs apps for each option
|
|
321
|
+
$ pnpm lint
|
|
263
322
|
```
|
|
264
323
|
|
|
265
|
-
|
|
266
|
-
|
|
324
|
+
This repository requires pnpm 11 or newer, as generated apps do by default, so
|
|
325
|
+
[install pnpm](https://pnpm.io/installation) first; npm refuses to install it or run its scripts.
|
|
326
|
+
The tests that install an npm app are skipped unless your npm is version 12 or newer.
|
|
267
327
|
|
|
268
328
|
See [Maintaining](docs/maintaining.md) for keeping the dependency versions of generated apps current,
|
|
269
329
|
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' },
|
|
@@ -33,6 +35,7 @@ const OPTIONS = {
|
|
|
33
35
|
'no-view': { type: 'boolean' },
|
|
34
36
|
pm: { type: 'string' },
|
|
35
37
|
'rate-limit': { type: 'boolean' },
|
|
38
|
+
sass: { type: 'boolean' },
|
|
36
39
|
session: { type: 'boolean' },
|
|
37
40
|
ts: { type: 'boolean' },
|
|
38
41
|
uploads: { type: 'boolean' },
|
|
@@ -283,6 +286,14 @@ function main (options, done) {
|
|
|
283
286
|
usage()
|
|
284
287
|
error('option `--pm <name>\' argument missing')
|
|
285
288
|
done(1)
|
|
289
|
+
} else if (options.corsScope === '') {
|
|
290
|
+
usage()
|
|
291
|
+
error('option `--cors-scope <scope>\' argument missing')
|
|
292
|
+
done(1)
|
|
293
|
+
} else if (options.csrfScope === '') {
|
|
294
|
+
usage()
|
|
295
|
+
error('option `--csrf-scope <scope>\' argument missing')
|
|
296
|
+
done(1)
|
|
286
297
|
} else {
|
|
287
298
|
// Path
|
|
288
299
|
const destinationPath = options._[0] || '.'
|
|
@@ -290,14 +301,9 @@ function main (options, done) {
|
|
|
290
301
|
// App name
|
|
291
302
|
const appName = createAppName(path.resolve(destinationPath)) || 'hello-world'
|
|
292
303
|
|
|
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
|
-
|
|
304
|
+
// A JSON API has no view engine, unless one is given with `--view`,
|
|
305
|
+
// for a web app with a JSON API
|
|
306
|
+
if (options.api && typeof options.view !== 'string') {
|
|
301
307
|
options.view = false
|
|
302
308
|
}
|
|
303
309
|
|
|
@@ -340,9 +346,16 @@ function main (options, done) {
|
|
|
340
346
|
}
|
|
341
347
|
|
|
342
348
|
// Sessions are for web apps, and CSRF protection needs them and views
|
|
343
|
-
if (options.session && options.api) {
|
|
349
|
+
if (options.session && options.api && !options.view) {
|
|
344
350
|
usage()
|
|
345
|
-
error('option `--session\' cannot be used with `--api\'')
|
|
351
|
+
error('option `--session\' cannot be used with `--api\', unless `--view\' is given too')
|
|
352
|
+
return done(1)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Sass is for the stylesheets of web apps
|
|
356
|
+
if (options.sass && options.api && !options.view) {
|
|
357
|
+
usage()
|
|
358
|
+
error('option `--sass\' cannot be used with `--api\', unless `--view\' is given too')
|
|
346
359
|
return done(1)
|
|
347
360
|
}
|
|
348
361
|
|
|
@@ -358,6 +371,38 @@ function main (options, done) {
|
|
|
358
371
|
return done(1)
|
|
359
372
|
}
|
|
360
373
|
|
|
374
|
+
// Where CORS and CSRF protection apply is a choice only for a web app
|
|
375
|
+
// with a JSON API
|
|
376
|
+
const webApi = Boolean(options.api && options.view)
|
|
377
|
+
|
|
378
|
+
if (options.corsScope !== undefined) {
|
|
379
|
+
if (!CORS_SCOPES.includes(options.corsScope)) {
|
|
380
|
+
usage()
|
|
381
|
+
error('unsupported CORS scope `' + options.corsScope + "'")
|
|
382
|
+
return done(1)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (!webApi || !options.cors) {
|
|
386
|
+
usage()
|
|
387
|
+
error('option `--cors-scope\' requires `--cors\', `--api\' and `--view\'')
|
|
388
|
+
return done(1)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (options.csrfScope !== undefined) {
|
|
393
|
+
if (!CSRF_SCOPES.includes(options.csrfScope)) {
|
|
394
|
+
usage()
|
|
395
|
+
error('unsupported CSRF scope `' + options.csrfScope + "'")
|
|
396
|
+
return done(1)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (!webApi || !options.csrf) {
|
|
400
|
+
usage()
|
|
401
|
+
error('option `--csrf-scope\' requires `--csrf\', `--api\' and `--view\'')
|
|
402
|
+
return done(1)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
361
406
|
// Check the destination before writing anything
|
|
362
407
|
const entries = planApp(appName, options)
|
|
363
408
|
const { blockers, conflicts, leftovers } = checkDestination(destinationPath, entries)
|
|
@@ -446,6 +491,8 @@ function parseOptions (argv) {
|
|
|
446
491
|
options.git = !options['no-git']
|
|
447
492
|
options.keepConfig = Boolean(options['keep-config'])
|
|
448
493
|
options.rateLimit = Boolean(options['rate-limit'])
|
|
494
|
+
options.corsScope = options['cors-scope']
|
|
495
|
+
options.csrfScope = options['csrf-scope']
|
|
449
496
|
options.logger ??= 'morgan'
|
|
450
497
|
options.pm ??= DEFAULT_PACKAGE_MANAGER
|
|
451
498
|
|
|
@@ -470,17 +517,25 @@ function usage () {
|
|
|
470
517
|
console.log(' -v, --view <engine> add view <engine> support (ejs|hbs|pug|twig) (defaults to pug)')
|
|
471
518
|
console.log(' --no-view use static html instead of view engine')
|
|
472
519
|
console.log(' --api generate a JSON API, without views or static files')
|
|
520
|
+
console.log(' (with --view, a web app with a JSON API at /api)')
|
|
473
521
|
console.log(' --cjs generate CommonJS modules instead of ES modules')
|
|
474
522
|
console.log(' --ts generate TypeScript, run directly by Node.js')
|
|
475
523
|
console.log(' --helmet add helmet middleware for security headers')
|
|
476
524
|
console.log(' --compression add compression middleware for gzip/brotli responses')
|
|
477
525
|
console.log(' --cookies add cookie-parser middleware')
|
|
478
526
|
console.log(' --cors add cors middleware for cross-origin requests')
|
|
527
|
+
console.log(' --cors-scope <scope>')
|
|
528
|
+
console.log(' with --api and --view, the routes other origins can')
|
|
529
|
+
console.log(' call (api|all) (defaults to api)')
|
|
479
530
|
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)')
|
|
531
|
+
console.log(' --session add express-session for sessions (not with --api alone)')
|
|
481
532
|
console.log(' --csrf add CSRF protection for forms (needs --session)')
|
|
533
|
+
console.log(' --csrf-scope <scope>')
|
|
534
|
+
console.log(' with --api and --view, the routes that need a CSRF')
|
|
535
|
+
console.log(' token (all|pages) (defaults to all)')
|
|
482
536
|
console.log(' --uploads add multer and an upload route at /uploads')
|
|
483
537
|
console.log(' --logger <name> request logger (morgan|pino) (defaults to morgan)')
|
|
538
|
+
console.log(' --sass add Sass, compiling .scss stylesheets (not with --api alone)')
|
|
484
539
|
console.log(' --docker add a Dockerfile for a production image')
|
|
485
540
|
console.log(' --lint add ESLint and a lint script')
|
|
486
541
|
console.log(' --pm <name> package manager the app requires (pnpm|npm) (defaults to pnpm)')
|
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,10 +80,11 @@ 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',
|
|
87
|
+
'public/stylesheets/style.scss',
|
|
82
88
|
...fs.readdirSync(path.join(TEMPLATE_DIR, 'views')).map((file) => 'views/' + file)
|
|
83
89
|
]
|
|
84
90
|
|
|
@@ -107,23 +113,36 @@ export function planApp (name, options) {
|
|
|
107
113
|
file(to, fs.readFileSync(path.join(TEMPLATE_DIR, from), 'utf-8'), extra)
|
|
108
114
|
}
|
|
109
115
|
|
|
110
|
-
// an ignore file, ignoring uploaded files too
|
|
116
|
+
// an ignore file, ignoring uploaded files and compiled stylesheets too
|
|
111
117
|
function ignore (from) {
|
|
112
|
-
|
|
113
|
-
|
|
118
|
+
let contents = fs.readFileSync(path.join(TEMPLATE_DIR, from), 'utf-8')
|
|
119
|
+
if (options.uploads) contents += '\n# Uploaded files\nuploads/\n'
|
|
120
|
+
if (sass) contents += '\n# Stylesheets compiled from Sass: write styles in .scss files\npublic/stylesheets/*.css\npublic/stylesheets/*.css.map\n'
|
|
121
|
+
return contents
|
|
114
122
|
}
|
|
115
123
|
|
|
116
124
|
// Module format
|
|
117
125
|
const esm = !options.cjs
|
|
118
126
|
const dirname = esm ? 'import.meta.dirname' : '__dirname'
|
|
119
127
|
|
|
120
|
-
// App kind: a JSON API has no views or static files
|
|
121
|
-
|
|
128
|
+
// App kind: a JSON API has no views or static files, and a web app with
|
|
129
|
+
// a view engine can have a JSON API too, under /api
|
|
130
|
+
const api = Boolean(options.api) && !options.view
|
|
131
|
+
const webApi = Boolean(options.api) && Boolean(options.view)
|
|
122
132
|
|
|
123
133
|
// Sessions, and CSRF protection using them, are for web apps with views
|
|
124
134
|
const session = Boolean(options.session) && !api
|
|
125
135
|
const csrf = Boolean(options.csrf) && session && Boolean(options.view)
|
|
126
136
|
|
|
137
|
+
// In a web app with a JSON API, other origins can call only the API,
|
|
138
|
+
// unless the whole app is chosen, and CSRF protection covers the API,
|
|
139
|
+
// unless only the pages are chosen
|
|
140
|
+
const corsApi = webApi && options.corsScope !== 'all'
|
|
141
|
+
const csrfSkipApi = webApi && csrf && options.csrfScope === 'pages'
|
|
142
|
+
|
|
143
|
+
// Sass, for the stylesheets of apps with static files
|
|
144
|
+
const sass = Boolean(options.sass) && !api
|
|
145
|
+
|
|
127
146
|
// File uploads, with an upload form in apps with views
|
|
128
147
|
const uploads = Boolean(options.uploads)
|
|
129
148
|
|
|
@@ -182,6 +201,7 @@ export function planApp (name, options) {
|
|
|
182
201
|
}
|
|
183
202
|
|
|
184
203
|
app.locals.dirname = dirname
|
|
204
|
+
app.locals.webApi = webApi
|
|
185
205
|
app.locals.httpErrors = Boolean(options.view) || api
|
|
186
206
|
app.locals.trustProxy = trustProxy
|
|
187
207
|
app.locals.session = session
|
|
@@ -191,6 +211,8 @@ export function planApp (name, options) {
|
|
|
191
211
|
test.locals.csrf = csrf
|
|
192
212
|
test.locals.uploads = uploads
|
|
193
213
|
test.locals.view = Boolean(options.view)
|
|
214
|
+
test.locals.webApi = webApi
|
|
215
|
+
test.locals.csrfSkipApi = csrfSkipApi
|
|
194
216
|
|
|
195
217
|
// App modules
|
|
196
218
|
app.locals.localModules = Object.create(null)
|
|
@@ -216,7 +238,7 @@ export function planApp (name, options) {
|
|
|
216
238
|
// Cross-origin requests
|
|
217
239
|
if (options.cors) {
|
|
218
240
|
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})")
|
|
241
|
+
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
242
|
pkg.dependencies.cors = VERSIONS.cors
|
|
221
243
|
}
|
|
222
244
|
|
|
@@ -258,7 +280,25 @@ export function planApp (name, options) {
|
|
|
258
280
|
dir('public/stylesheets')
|
|
259
281
|
|
|
260
282
|
// Stylesheet
|
|
261
|
-
|
|
283
|
+
if (sass) {
|
|
284
|
+
copy('public/stylesheets/style.scss', 'public/stylesheets/style.scss')
|
|
285
|
+
} else {
|
|
286
|
+
copy('public/stylesheets/style.css', 'public/stylesheets/style.css')
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Sass compiles each .scss file to the .css file the app serves, before
|
|
291
|
+
// the app starts: a development dependency, so the stylesheets of an app
|
|
292
|
+
// installed without them are compiled first, as in the Dockerfile
|
|
293
|
+
if (sass) {
|
|
294
|
+
const styles = 'public/stylesheets:public/stylesheets'
|
|
295
|
+
|
|
296
|
+
pkg.scripts.css = 'sass ' + styles
|
|
297
|
+
// recompile on change, alongside the dev script
|
|
298
|
+
pkg.scripts['css:watch'] = 'sass --watch ' + styles
|
|
299
|
+
pkg.scripts.prestart = scriptCommand(pm, 'css')
|
|
300
|
+
pkg.scripts.predev = scriptCommand(pm, 'css')
|
|
301
|
+
pkg.devDependencies.sass = VERSIONS.sass
|
|
262
302
|
}
|
|
263
303
|
|
|
264
304
|
// copy route templates
|
|
@@ -304,6 +344,16 @@ export function planApp (name, options) {
|
|
|
304
344
|
pkg.dependencies['http-errors'] = VERSIONS['http-errors']
|
|
305
345
|
}
|
|
306
346
|
|
|
347
|
+
// API router, for the JSON routes of a web app
|
|
348
|
+
if (webApi) {
|
|
349
|
+
const router = loadTemplate('app/routes/api.js')
|
|
350
|
+
router.locals.esm = esm
|
|
351
|
+
file('routes/api.' + ext, router.render())
|
|
352
|
+
|
|
353
|
+
app.locals.localModules.apiRouter = './routes/api.' + ext
|
|
354
|
+
app.locals.mounts.push({ path: '/api', code: 'apiRouter' })
|
|
355
|
+
}
|
|
356
|
+
|
|
307
357
|
// Template support
|
|
308
358
|
if (options.view) {
|
|
309
359
|
const view = VIEW_ENGINES[options.view]
|
|
@@ -373,6 +423,7 @@ export function planApp (name, options) {
|
|
|
373
423
|
const module = loadTemplate('app/csrf.js')
|
|
374
424
|
module.locals.esm = esm
|
|
375
425
|
module.locals.uploads = uploads
|
|
426
|
+
module.locals.skipApi = csrfSkipApi
|
|
376
427
|
file('csrf.' + ext, module.render())
|
|
377
428
|
|
|
378
429
|
app.locals.localModules.csrf = './csrf.' + ext
|
|
@@ -394,7 +445,15 @@ export function planApp (name, options) {
|
|
|
394
445
|
|
|
395
446
|
// Package manager settings, protecting installs from compromised packages
|
|
396
447
|
if (pm === 'pnpm') {
|
|
397
|
-
|
|
448
|
+
let settings = fs.readFileSync(path.join(TEMPLATE_DIR, 'app/pnpm-workspace.yaml'), 'utf-8')
|
|
449
|
+
|
|
450
|
+
// Sass's optional file watcher has an install script, which would stop
|
|
451
|
+
// the install, and which it works without
|
|
452
|
+
if (sass) {
|
|
453
|
+
settings += '\n# Do not run the install script of the file watcher Sass uses, which works\n# without it.\nallowBuilds:\n \'@parcel/watcher\': false\n'
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
file('pnpm-workspace.yaml', settings, { config: true })
|
|
398
457
|
} else {
|
|
399
458
|
copy('app/npmrc', '.npmrc', { config: true })
|
|
400
459
|
}
|
|
@@ -405,6 +464,8 @@ export function planApp (name, options) {
|
|
|
405
464
|
dockerfile.locals.name = name
|
|
406
465
|
dockerfile.locals.www = www
|
|
407
466
|
dockerfile.locals.uploads = uploads
|
|
467
|
+
dockerfile.locals.sass = sass
|
|
468
|
+
dockerfile.locals.css = scriptCommand(pm, 'css')
|
|
408
469
|
dockerfile.locals.pm = pm
|
|
409
470
|
// the exact package manager version, for repeatable image builds
|
|
410
471
|
dockerfile.locals.pmVersion = VERSIONS[pm].replace(/^\^/, '')
|
|
@@ -550,8 +611,7 @@ export function scriptCommand (pm, script) {
|
|
|
550
611
|
|
|
551
612
|
export function mergeLines (existing, generated) {
|
|
552
613
|
const have = new Set(existing.split(/\r?\n/).map((line) => line.trim()))
|
|
553
|
-
const missing = generated.split('\n')
|
|
554
|
-
.map((line) => line.trim())
|
|
614
|
+
const missing = [...new Set(generated.split('\n').map((line) => line.trim()))]
|
|
555
615
|
.filter((line) => line && !line.startsWith('#') && !have.has(line))
|
|
556
616
|
|
|
557
617
|
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, {
|
|
@@ -108,7 +138,9 @@ export async function wizard (io) {
|
|
|
108
138
|
initial: answers.extras,
|
|
109
139
|
choices: [
|
|
110
140
|
{ label: 'Dockerfile', value: 'docker', hint: 'production image with a /health check' },
|
|
111
|
-
{ label: 'ESLint', value: 'lint', hint: 'with a lint script' }
|
|
141
|
+
{ label: 'ESLint', value: 'lint', hint: 'with a lint script' },
|
|
142
|
+
// stylesheets are for web apps
|
|
143
|
+
...(answers.kind !== 'api' ? [{ label: 'Sass', value: 'sass', hint: 'stylesheets compiled from .scss files' }] : [])
|
|
112
144
|
]
|
|
113
145
|
})
|
|
114
146
|
},
|
|
@@ -305,18 +337,22 @@ function eraseText (io, printed) {
|
|
|
305
337
|
|
|
306
338
|
function toOptions (answers) {
|
|
307
339
|
const { directory, kind, language, middleware = [], extras = [] } = answers
|
|
308
|
-
const view = kind === '
|
|
309
|
-
const session = kind
|
|
340
|
+
const view = kind === 'api' ? false : answers.view
|
|
341
|
+
const session = kind !== 'api' && middleware.includes('session')
|
|
342
|
+
const csrf = session && view !== false && answers.csrf === true
|
|
310
343
|
|
|
311
344
|
return {
|
|
312
345
|
_: [directory.dir],
|
|
313
346
|
'!': [],
|
|
314
|
-
api: kind
|
|
347
|
+
api: kind !== 'web',
|
|
315
348
|
cjs: language === 'cjs',
|
|
316
349
|
compression: middleware.includes('compression'),
|
|
317
350
|
cookies: middleware.includes('cookies'),
|
|
318
351
|
cors: middleware.includes('cors'),
|
|
319
|
-
|
|
352
|
+
// the choices of a web app with a JSON API
|
|
353
|
+
...(kind === 'both' && middleware.includes('cors') && { corsScope: answers.corsScope ?? 'api' }),
|
|
354
|
+
csrf,
|
|
355
|
+
...(kind === 'both' && csrf && { csrfScope: answers.csrfScope ?? 'all' }),
|
|
320
356
|
docker: extras.includes('docker'),
|
|
321
357
|
force: directory.force,
|
|
322
358
|
git: answers.git,
|
|
@@ -327,6 +363,7 @@ function toOptions (answers) {
|
|
|
327
363
|
logger: answers.logger ?? 'morgan',
|
|
328
364
|
pm: answers.pm ?? DEFAULT_PACKAGE_MANAGER,
|
|
329
365
|
rateLimit: middleware.includes('rate-limit'),
|
|
366
|
+
sass: kind !== 'api' && extras.includes('sass'),
|
|
330
367
|
session,
|
|
331
368
|
ts: language === 'ts',
|
|
332
369
|
uploads: middleware.includes('uploads'),
|
|
@@ -345,7 +382,8 @@ export function toCommand (options) {
|
|
|
345
382
|
const args = [pm, 'create', 'express-new@latest', quote(options._[0])]
|
|
346
383
|
const flags = []
|
|
347
384
|
|
|
348
|
-
|
|
385
|
+
// a JSON API has no view engine, unless one is given, the default included
|
|
386
|
+
if (options.api) flags.push('--api', ...(options.view ? ['--view=' + options.view] : []))
|
|
349
387
|
else if (options.view === false) flags.push('--no-view')
|
|
350
388
|
else if (options.view !== 'pug') flags.push('--view=' + options.view)
|
|
351
389
|
|
|
@@ -355,11 +393,14 @@ export function toCommand (options) {
|
|
|
355
393
|
if (options.compression) flags.push('--compression')
|
|
356
394
|
if (options.cookies) flags.push('--cookies')
|
|
357
395
|
if (options.cors) flags.push('--cors')
|
|
396
|
+
if (options.corsScope === 'all') flags.push('--cors-scope=all')
|
|
358
397
|
if (options.rateLimit) flags.push('--rate-limit')
|
|
359
398
|
if (options.session) flags.push('--session')
|
|
360
399
|
if (options.csrf) flags.push('--csrf')
|
|
400
|
+
if (options.csrfScope === 'pages') flags.push('--csrf-scope=pages')
|
|
361
401
|
if (options.uploads) flags.push('--uploads')
|
|
362
402
|
if (options.logger && options.logger !== 'morgan') flags.push('--logger=' + options.logger)
|
|
403
|
+
if (options.sass) flags.push('--sass')
|
|
363
404
|
if (options.docker) flags.push('--docker')
|
|
364
405
|
if (options.lint) flags.push('--lint')
|
|
365
406
|
if (pm !== DEFAULT_PACKAGE_MANAGER) flags.push('--pm=' + pm)
|
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.3.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
|
+
}
|
|
@@ -36,6 +36,20 @@ COPY package*.json .npmrc ./
|
|
|
36
36
|
RUN npm ci --omit=dev
|
|
37
37
|
<% } -%>
|
|
38
38
|
|
|
39
|
+
<% if (sass) { -%>
|
|
40
|
+
# Stylesheets are compiled in their own stage too, as Sass is a development
|
|
41
|
+
# dependency, which the final image does not have.
|
|
42
|
+
FROM dependencies AS stylesheets
|
|
43
|
+
|
|
44
|
+
<% if (pm === 'pnpm') { -%>
|
|
45
|
+
RUN pnpm install --frozen-lockfile
|
|
46
|
+
<% } else { -%>
|
|
47
|
+
RUN npm ci
|
|
48
|
+
<% } -%>
|
|
49
|
+
COPY public/stylesheets ./public/stylesheets
|
|
50
|
+
RUN <%- css %>
|
|
51
|
+
|
|
52
|
+
<% } -%>
|
|
39
53
|
FROM node:22-slim
|
|
40
54
|
|
|
41
55
|
ENV NODE_ENV=production
|
|
@@ -47,6 +61,11 @@ COPY --from=dependencies /app/node_modules ./node_modules
|
|
|
47
61
|
# copy the app owned by the unprivileged node user, which runs it, so it can
|
|
48
62
|
# read the files whatever their permissions on the build machine
|
|
49
63
|
COPY --chown=node:node . .
|
|
64
|
+
<% if (sass) { -%>
|
|
65
|
+
|
|
66
|
+
# the stylesheets compiled above
|
|
67
|
+
COPY --chown=node:node --from=stylesheets /app/public/stylesheets ./public/stylesheets
|
|
68
|
+
<% } -%>
|
|
50
69
|
<% if (uploads) { -%>
|
|
51
70
|
|
|
52
71
|
# a folder for uploaded files that the node user can write to; files saved
|
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
|
<% } -%>
|