@nerest/nerest 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,14 +10,28 @@ React micro frontend framework
10
10
  npm i --save @nerest/nerest react react-dom
11
11
  ```
12
12
 
13
+ ## Points of Interest
14
+
15
+ - SSR server entry: [server/index.ts](server/index.ts)
16
+ - Hydrating client entry: [client/index.ts](client/index.ts)
17
+ - CLI entry: [bin/index.ts](bin/index.ts)
18
+
13
19
  ## Conventions
14
20
 
15
21
  The `apps` directory must contain all of the apps provided by the micro frontend. E.g. `/apps/foo/index.tsx` is the entrypoint component of the `foo` app. It becomes available as the `/api/foo` route of the micro frontend server.
16
22
 
17
- The single app directory may contain an `examples` subdirectory with example JSON files which can be used as props for the app entrypoint component. E.g. `/apps/foo/examples/example-1.json` becomes available as the `/api/foo/examples/example-1` route of the micro frontend server.
18
-
19
23
  See [nerest-harness](https://github.com/nerestjs/harness) for the minimal example of a nerest micro frontend.
20
24
 
25
+ ### Examples (`/examples/*.json`)
26
+
27
+ The app directory may contain an `examples` subdirectory with example JSON files which can be used as props for the app entrypoint component. E.g. `/apps/foo/examples/example-1.json` becomes available as the `/api/foo/examples/example-1` route of the micro frontend server.
28
+
29
+ ### Schema (`/schema.json`)
30
+
31
+ The app directory should contain a `schema.json` file that describes the schema of a request body for this specific app in the [JSON Schema language](https://json-schema.org/). All requests sent to this app, and app examples from the `examples` subdirectory will be validated against this schema. `ajv` and `ajv-formats` are used for validation, so all JSON Schema features implemented by them are supported.
32
+
33
+ OpenAPI specification is compiled automatically based on the provided schemas and becomes available at `/api/json`. It can also be explored through Swagger UI that becomes available at `/api`.
34
+
21
35
  ## Development
22
36
 
23
37
  Run the build script to build the framework.
@@ -0,0 +1 @@
1
+ export declare function loadAppSchema(appRoot: string): Promise<any>;
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.loadAppSchema = void 0;
7
+ const path_1 = __importDefault(require("path"));
8
+ const fs_1 = require("fs");
9
+ const promises_1 = __importDefault(require("fs/promises"));
10
+ // Loads and parses the schema file for a specific app
11
+ async function loadAppSchema(appRoot) {
12
+ const schemaPath = path_1.default.join(appRoot, 'schema.json');
13
+ let schema = null;
14
+ // TODO: error handling and reporting
15
+ if ((0, fs_1.existsSync)(schemaPath)) {
16
+ const file = await promises_1.default.readFile(schemaPath, { encoding: 'utf-8' });
17
+ schema = JSON.parse(file);
18
+ }
19
+ return schema;
20
+ }
21
+ exports.loadAppSchema = loadAppSchema;
@@ -1,9 +1,10 @@
1
- export declare type AppEntry = {
1
+ export type AppEntry = {
2
2
  name: string;
3
3
  root: string;
4
4
  entry: string;
5
5
  assets: string[];
6
6
  examples: Record<string, unknown>;
7
+ schema: Record<string, unknown> | null;
7
8
  };
8
9
  export declare function loadApps(root: string): Promise<{
9
10
  [k: string]: AppEntry;
@@ -8,6 +8,7 @@ const path_1 = __importDefault(require("path"));
8
8
  const promises_1 = __importDefault(require("fs/promises"));
9
9
  const assets_1 = require("./app-parts/assets");
10
10
  const examples_1 = require("./app-parts/examples");
11
+ const schema_1 = require("./app-parts/schema");
11
12
  // Build the record of the available apps by convention
12
13
  // apps -> /apps/{name}/index.tsx
13
14
  // examples -> /apps/{name}/examples/{example}.json
@@ -35,6 +36,7 @@ async function loadApp(appsRoot, name, manifest) {
35
36
  entry: path_1.default.join(appRoot, 'index.tsx'),
36
37
  assets: (0, assets_1.loadAppAssets)(manifest, name),
37
38
  examples: await (0, examples_1.loadAppExamples)(appRoot),
39
+ schema: await (0, schema_1.loadAppSchema)(appRoot),
38
40
  },
39
41
  ];
40
42
  }
@@ -12,6 +12,8 @@ const static_1 = __importDefault(require("@fastify/static"));
12
12
  const apps_1 = require("./apps");
13
13
  const render_1 = require("./render");
14
14
  const preview_1 = require("./preview");
15
+ const validator_1 = require("./validator");
16
+ const swagger_1 = require("./swagger");
15
17
  // TODO: this turned out to be a dev server, production server
16
18
  // will most likely be implemented separately
17
19
  async function createServer() {
@@ -47,10 +49,31 @@ async function createServer() {
47
49
  // Start vite server that will be rendering SSR components
48
50
  const viteSsr = await vite_1.default.createServer(config);
49
51
  const app = (0, fastify_1.default)();
52
+ // Setup schema validation. We have to use our own ajv instance that
53
+ // we can use both to validate request bodies and examples against
54
+ // app schemas
55
+ app.setValidatorCompiler(({ schema }) => validator_1.validator.compile(schema));
56
+ await (0, swagger_1.setupSwagger)(app);
50
57
  for (const appEntry of Object.values(apps)) {
51
- const { name, entry, examples } = appEntry;
58
+ const { name, entry, examples, schema } = appEntry;
59
+ const routeOptions = {};
60
+ // TODO: report error if schema is missing, unless this app is client-only.
61
+ // TODO: disallow apps without schemas in production build
62
+ if (schema) {
63
+ routeOptions.schema = {
64
+ // Use description as Swagger summary, since summary is visible
65
+ // even when the route is collapsed in the UI
66
+ summary: schema.description,
67
+ body: {
68
+ ...schema,
69
+ // Mix examples into the schema so they become accessible
70
+ // in the Swagger UI
71
+ examples: Object.values(examples),
72
+ },
73
+ };
74
+ }
52
75
  // POST /api/{name} -> render app with request.body as props
53
- app.post(`/api/${name}`, async (request) => {
76
+ app.post(`/api/${name}`, routeOptions, async (request) => {
54
77
  // ssrLoadModule drives the "hot-reload" logic, and allows
55
78
  // picking up changes to the source without restarting the server
56
79
  const ssrComponent = await viteSsr.ssrLoadModule(entry, {
@@ -59,9 +82,21 @@ async function createServer() {
59
82
  return (0, render_1.renderApp)(appEntry, ssrComponent.default, request.body);
60
83
  });
61
84
  for (const [exampleName, example] of Object.entries(examples)) {
85
+ // Validate example against schema when specified
86
+ if (schema && !validator_1.validator.validate(schema, example)) {
87
+ // TODO: use logger and display errors more prominently
88
+ console.error(`Example "${exampleName}" of app "${name}" does not satisfy schema: ${validator_1.validator.errorsText()}`);
89
+ }
62
90
  // GET /api/{name}/examples/{example} -> render a preview page
63
91
  // with a predefined example body
64
- app.get(`/api/${name}/examples/${exampleName}`, async (_, reply) => {
92
+ const exampleRoute = `/api/${name}/examples/${exampleName}`;
93
+ app.get(exampleRoute, {
94
+ schema: {
95
+ // Add a clickable link to the example route in route's Swagger
96
+ // description so it's easier to navigate to
97
+ description: `Open sandbox: [${exampleRoute}](${exampleRoute})`,
98
+ },
99
+ }, async (_, reply) => {
65
100
  const ssrComponent = await viteSsr.ssrLoadModule(entry, {
66
101
  fixStacktrace: true,
67
102
  });
@@ -72,7 +107,7 @@ async function createServer() {
72
107
  }
73
108
  }
74
109
  // TODO: only do this locally, load from CDN in production
75
- app.register(static_1.default, {
110
+ await app.register(static_1.default, {
76
111
  root: path_1.default.join(root, 'dist'),
77
112
  // TODO: maybe use @fastify/cors instead
78
113
  setHeaders(res) {
@@ -0,0 +1,2 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ export declare function setupSwagger(app: FastifyInstance): Promise<void>;
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.setupSwagger = void 0;
7
+ const path_1 = __importDefault(require("path"));
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const swagger_1 = __importDefault(require("@fastify/swagger"));
10
+ const swagger_ui_1 = __importDefault(require("@fastify/swagger-ui"));
11
+ // Setup automatic OpenAPI specification compilation and enable
12
+ // Swagger UI at the `/api` route
13
+ async function setupSwagger(app) {
14
+ let appInfo = {};
15
+ try {
16
+ const packageJson = fs_1.default.readFileSync(path_1.default.join(process.cwd(), 'package.json'), { encoding: 'utf-8' });
17
+ appInfo = JSON.parse(packageJson);
18
+ }
19
+ catch (e) {
20
+ // We only use package.json info to setup Swagger info and links,
21
+ // if we are unable to load them -- that's fine
22
+ }
23
+ const homepage = appInfo.homepage ||
24
+ (typeof appInfo.repository === 'string'
25
+ ? appInfo.repository
26
+ : appInfo.repository?.url);
27
+ await app.register(swagger_1.default, {
28
+ openapi: {
29
+ info: {
30
+ title: appInfo.name ?? 'Nerest micro frontend',
31
+ description: appInfo.description,
32
+ version: appInfo.version ?? '',
33
+ contact: homepage
34
+ ? {
35
+ name: 'Homepage',
36
+ url: homepage,
37
+ }
38
+ : undefined,
39
+ },
40
+ externalDocs: {
41
+ url: 'https://github.com/nerestjs/nerest',
42
+ description: 'Built with Nerest',
43
+ },
44
+ },
45
+ });
46
+ await app.register(swagger_ui_1.default, {
47
+ routePrefix: '/api',
48
+ });
49
+ }
50
+ exports.setupSwagger = setupSwagger;
@@ -0,0 +1,2 @@
1
+ import Ajv from 'ajv';
2
+ export declare const validator: Ajv;
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.validator = void 0;
7
+ const ajv_1 = __importDefault(require("ajv"));
8
+ const fast_uri_1 = __importDefault(require("fast-uri"));
9
+ const ajv_formats_1 = __importDefault(require("ajv-formats"));
10
+ exports.validator = new ajv_1.default({
11
+ coerceTypes: 'array',
12
+ useDefaults: true,
13
+ removeAdditional: true,
14
+ uriResolver: fast_uri_1.default,
15
+ addUsedSchema: false,
16
+ // Explicitly set allErrors to `false`.
17
+ // When set to `true`, a DoS attack is possible.
18
+ allErrors: false,
19
+ });
20
+ // Support additional type formats in JSON schema like `date`,
21
+ // `email`, `url`, etc. Used by default in fastify
22
+ // https://www.npmjs.com/package/ajv-formats
23
+ (0, ajv_formats_1.default)(exports.validator);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nerest/nerest",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "React micro frontend framework",
5
5
  "homepage": "https://github.com/nerestjs/nerest#readme",
6
6
  "repository": {
@@ -46,24 +46,29 @@
46
46
  ]
47
47
  },
48
48
  "dependencies": {
49
- "@fastify/static": "^6.5.0",
50
- "fastify": "^4.9.2",
49
+ "@fastify/static": "^6.9.0",
50
+ "@fastify/swagger": "^8.3.1",
51
+ "@fastify/swagger-ui": "^1.4.0",
52
+ "ajv": "^8.12.0",
53
+ "ajv-formats": "^2.1.1",
54
+ "fast-uri": "^2.2.0",
55
+ "fastify": "^4.13.0",
51
56
  "nanoid": "^3.3.4",
52
- "vite": "^3.2.3"
57
+ "vite": "^4.1.4"
53
58
  },
54
59
  "devDependencies": {
55
- "@tinkoff/eslint-config": "^1.41.0",
56
- "@tinkoff/eslint-config-react": "^1.41.0",
57
- "@tinkoff/prettier-config": "^1.32.1",
58
- "@types/react": "^18.0.25",
59
- "@types/react-dom": "^18.0.8",
60
- "jest": "^29.3.1",
61
- "lint-staged": "^13.0.3",
60
+ "@tinkoff/eslint-config": "^1.50.1",
61
+ "@tinkoff/eslint-config-react": "^1.50.2",
62
+ "@tinkoff/prettier-config": "^1.47.1",
63
+ "@types/react": "^18.0.28",
64
+ "@types/react-dom": "^18.0.11",
65
+ "jest": "^29.4.3",
66
+ "lint-staged": "^13.1.2",
62
67
  "react": "^18.2.0",
63
68
  "react-dom": "^18.2.0",
64
69
  "simple-git-hooks": "^2.8.1",
65
- "sort-package-json": "^2.1.0",
66
- "typescript": "^4.8.4"
70
+ "sort-package-json": "^2.4.1",
71
+ "typescript": "^4.9.5"
67
72
  },
68
73
  "peerDependencies": {
69
74
  "react": "^18.0.0",
@@ -0,0 +1,18 @@
1
+ import path from 'path';
2
+ import { existsSync } from 'fs';
3
+ import fs from 'fs/promises';
4
+
5
+ // Loads and parses the schema file for a specific app
6
+ export async function loadAppSchema(appRoot: string) {
7
+ const schemaPath = path.join(appRoot, 'schema.json');
8
+
9
+ let schema = null;
10
+
11
+ // TODO: error handling and reporting
12
+ if (existsSync(schemaPath)) {
13
+ const file = await fs.readFile(schemaPath, { encoding: 'utf-8' });
14
+ schema = JSON.parse(file);
15
+ }
16
+
17
+ return schema;
18
+ }
package/server/apps.ts CHANGED
@@ -4,6 +4,7 @@ import type { Manifest } from 'vite';
4
4
 
5
5
  import { loadAppAssets } from './app-parts/assets';
6
6
  import { loadAppExamples } from './app-parts/examples';
7
+ import { loadAppSchema } from './app-parts/schema';
7
8
 
8
9
  export type AppEntry = {
9
10
  name: string;
@@ -11,6 +12,7 @@ export type AppEntry = {
11
12
  entry: string;
12
13
  assets: string[];
13
14
  examples: Record<string, unknown>;
15
+ schema: Record<string, unknown> | null;
14
16
  };
15
17
 
16
18
  // Build the record of the available apps by convention
@@ -47,6 +49,7 @@ async function loadApp(
47
49
  entry: path.join(appRoot, 'index.tsx'),
48
50
  assets: loadAppAssets(manifest, name),
49
51
  examples: await loadAppExamples(appRoot),
52
+ schema: await loadAppSchema(appRoot),
50
53
  },
51
54
  ];
52
55
  }
package/server/index.ts CHANGED
@@ -6,12 +6,15 @@ import vite from 'vite';
6
6
  import type { InlineConfig } from 'vite';
7
7
  import type { RollupWatcher, RollupWatcherEvent } from 'rollup';
8
8
 
9
+ import type { RouteShorthandOptions } from 'fastify';
9
10
  import fastify from 'fastify';
10
11
  import fastifyStatic from '@fastify/static';
11
12
 
12
13
  import { loadApps } from './apps';
13
14
  import { renderApp } from './render';
14
15
  import { renderPreviewPage } from './preview';
16
+ import { validator } from './validator';
17
+ import { setupSwagger } from './swagger';
15
18
 
16
19
  // TODO: this turned out to be a dev server, production server
17
20
  // will most likely be implemented separately
@@ -53,11 +56,36 @@ export async function createServer() {
53
56
  const viteSsr = await vite.createServer(config);
54
57
  const app = fastify();
55
58
 
59
+ // Setup schema validation. We have to use our own ajv instance that
60
+ // we can use both to validate request bodies and examples against
61
+ // app schemas
62
+ app.setValidatorCompiler(({ schema }) => validator.compile(schema));
63
+
64
+ await setupSwagger(app);
65
+
56
66
  for (const appEntry of Object.values(apps)) {
57
- const { name, entry, examples } = appEntry;
67
+ const { name, entry, examples, schema } = appEntry;
68
+
69
+ const routeOptions: RouteShorthandOptions = {};
70
+
71
+ // TODO: report error if schema is missing, unless this app is client-only.
72
+ // TODO: disallow apps without schemas in production build
73
+ if (schema) {
74
+ routeOptions.schema = {
75
+ // Use description as Swagger summary, since summary is visible
76
+ // even when the route is collapsed in the UI
77
+ summary: schema.description as string,
78
+ body: {
79
+ ...schema,
80
+ // Mix examples into the schema so they become accessible
81
+ // in the Swagger UI
82
+ examples: Object.values(examples),
83
+ },
84
+ };
85
+ }
58
86
 
59
87
  // POST /api/{name} -> render app with request.body as props
60
- app.post(`/api/${name}`, async (request) => {
88
+ app.post(`/api/${name}`, routeOptions, async (request) => {
61
89
  // ssrLoadModule drives the "hot-reload" logic, and allows
62
90
  // picking up changes to the source without restarting the server
63
91
  const ssrComponent = await viteSsr.ssrLoadModule(entry, {
@@ -71,27 +99,46 @@ export async function createServer() {
71
99
  });
72
100
 
73
101
  for (const [exampleName, example] of Object.entries(examples)) {
74
- // GET /api/{name}/examples/{example} -> render a preview page
75
- // with a predefined example body
76
- app.get(`/api/${name}/examples/${exampleName}`, async (_, reply) => {
77
- const ssrComponent = await viteSsr.ssrLoadModule(entry, {
78
- fixStacktrace: true,
79
- });
80
- const { html, assets } = renderApp(
81
- appEntry,
82
- ssrComponent.default,
83
- example as Record<string, unknown>
102
+ // Validate example against schema when specified
103
+ if (schema && !validator.validate(schema, example)) {
104
+ // TODO: use logger and display errors more prominently
105
+ console.error(
106
+ `Example "${exampleName}" of app "${name}" does not satisfy schema: ${validator.errorsText()}`
84
107
  );
108
+ }
85
109
 
86
- reply.type('text/html');
87
-
88
- return renderPreviewPage(html, assets);
89
- });
110
+ // GET /api/{name}/examples/{example} -> render a preview page
111
+ // with a predefined example body
112
+ const exampleRoute = `/api/${name}/examples/${exampleName}`;
113
+ app.get(
114
+ exampleRoute,
115
+ {
116
+ schema: {
117
+ // Add a clickable link to the example route in route's Swagger
118
+ // description so it's easier to navigate to
119
+ description: `Open sandbox: [${exampleRoute}](${exampleRoute})`,
120
+ },
121
+ },
122
+ async (_, reply) => {
123
+ const ssrComponent = await viteSsr.ssrLoadModule(entry, {
124
+ fixStacktrace: true,
125
+ });
126
+ const { html, assets } = renderApp(
127
+ appEntry,
128
+ ssrComponent.default,
129
+ example as Record<string, unknown>
130
+ );
131
+
132
+ reply.type('text/html');
133
+
134
+ return renderPreviewPage(html, assets);
135
+ }
136
+ );
90
137
  }
91
138
  }
92
139
 
93
140
  // TODO: only do this locally, load from CDN in production
94
- app.register(fastifyStatic, {
141
+ await app.register(fastifyStatic, {
95
142
  root: path.join(root, 'dist'),
96
143
  // TODO: maybe use @fastify/cors instead
97
144
  setHeaders(res: ServerResponse) {
@@ -0,0 +1,58 @@
1
+ import path from 'path';
2
+ import fs from 'fs';
3
+ import type { FastifyInstance } from 'fastify';
4
+ import fastifySwagger from '@fastify/swagger';
5
+ import fastifySwaggerUi from '@fastify/swagger-ui';
6
+
7
+ // Setup automatic OpenAPI specification compilation and enable
8
+ // Swagger UI at the `/api` route
9
+ export async function setupSwagger(app: FastifyInstance) {
10
+ let appInfo: {
11
+ name?: string;
12
+ description?: string;
13
+ version?: string;
14
+ homepage?: string;
15
+ repository?: string | { url?: string };
16
+ } = {};
17
+
18
+ try {
19
+ const packageJson = fs.readFileSync(
20
+ path.join(process.cwd(), 'package.json'),
21
+ { encoding: 'utf-8' }
22
+ );
23
+ appInfo = JSON.parse(packageJson);
24
+ } catch (e) {
25
+ // We only use package.json info to setup Swagger info and links,
26
+ // if we are unable to load them -- that's fine
27
+ }
28
+
29
+ const homepage =
30
+ appInfo.homepage ||
31
+ (typeof appInfo.repository === 'string'
32
+ ? appInfo.repository
33
+ : appInfo.repository?.url);
34
+
35
+ await app.register(fastifySwagger, {
36
+ openapi: {
37
+ info: {
38
+ title: appInfo.name ?? 'Nerest micro frontend',
39
+ description: appInfo.description,
40
+ version: appInfo.version ?? '',
41
+ contact: homepage
42
+ ? {
43
+ name: 'Homepage',
44
+ url: homepage,
45
+ }
46
+ : undefined,
47
+ },
48
+ externalDocs: {
49
+ url: 'https://github.com/nerestjs/nerest',
50
+ description: 'Built with Nerest',
51
+ },
52
+ },
53
+ });
54
+
55
+ await app.register(fastifySwaggerUi, {
56
+ routePrefix: '/api',
57
+ });
58
+ }
@@ -0,0 +1,19 @@
1
+ import Ajv from 'ajv';
2
+ import fastUri from 'fast-uri';
3
+ import addFormats from 'ajv-formats';
4
+
5
+ export const validator = new Ajv({
6
+ coerceTypes: 'array',
7
+ useDefaults: true,
8
+ removeAdditional: true,
9
+ uriResolver: fastUri,
10
+ addUsedSchema: false,
11
+ // Explicitly set allErrors to `false`.
12
+ // When set to `true`, a DoS attack is possible.
13
+ allErrors: false,
14
+ });
15
+
16
+ // Support additional type formats in JSON schema like `date`,
17
+ // `email`, `url`, etc. Used by default in fastify
18
+ // https://www.npmjs.com/package/ajv-formats
19
+ addFormats(validator);