1/**
2 * Copyright © 2022 650 Industries.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7import fs from 'fs';
8import path from 'path';
9
10import { ExpoMiddleware } from './ExpoMiddleware';
11import { ServerRequest, ServerResponse } from './server.types';
12
13const debug = require('debug')('expo:start:server:middleware:createFile') as typeof console.log;
14
15export type TouchFileBody = {
16  /** @deprecated */
17  path: string;
18  absolutePath?: string;
19  contents: string;
20};
21
22/**
23 * Middleware for creating a file given a `POST` request with
24 * `{ contents: string, path: string }` in the body.
25 */
26export class CreateFileMiddleware extends ExpoMiddleware {
27  constructor(protected projectRoot: string) {
28    super(projectRoot, ['/_expo/touch']);
29  }
30
31  protected resolvePath(inputPath: string): string {
32    return this.resolveExtension(path.join(this.projectRoot, inputPath));
33  }
34
35  protected resolveExtension(inputPath: string): string {
36    let resolvedPath = inputPath;
37    const extension = path.extname(inputPath);
38    if (extension === '.js') {
39      // Automatically convert JS files to TS files when added to a project
40      // with TypeScript.
41      const tsconfigPath = path.join(this.projectRoot, 'tsconfig.json');
42      if (fs.existsSync(tsconfigPath)) {
43        resolvedPath = resolvedPath.replace(/\.js$/, '.tsx');
44      }
45    }
46
47    return resolvedPath;
48  }
49
50  protected async parseRawBody(req: ServerRequest): Promise<TouchFileBody> {
51    const rawBody = await new Promise<string>((resolve, reject) => {
52      let body = '';
53      req.on('data', (chunk) => {
54        body += chunk.toString();
55      });
56      req.on('end', () => {
57        resolve(body);
58      });
59      req.on('error', (err) => {
60        reject(err);
61      });
62    });
63
64    const properties = JSON.parse(rawBody);
65    this.assertTouchFileBody(properties);
66
67    return properties;
68  }
69
70  private assertTouchFileBody(body: any): asserts body is TouchFileBody {
71    if (typeof body !== 'object' || body == null) {
72      throw new Error('Expected object');
73    }
74    if (typeof body.path !== 'string') {
75      throw new Error('Expected "path" in body to be string');
76    }
77    if (typeof body.contents !== 'string') {
78      throw new Error('Expected "contents" in body to be string');
79    }
80  }
81
82  async handleRequestAsync(req: ServerRequest, res: ServerResponse): Promise<void> {
83    if (req.method !== 'POST') {
84      res.statusCode = 405;
85      res.end('Method Not Allowed');
86      return;
87    }
88
89    let properties: TouchFileBody;
90
91    try {
92      properties = await this.parseRawBody(req);
93    } catch (e) {
94      debug('Error parsing request body', e);
95      res.statusCode = 400;
96      res.end('Bad Request');
97      return;
98    }
99
100    debug(`Requested: %O`, properties);
101
102    const resolvedPath = properties.absolutePath
103      ? this.resolveExtension(path.resolve(properties.absolutePath))
104      : this.resolvePath(properties.path);
105
106    if (fs.existsSync(resolvedPath)) {
107      res.statusCode = 409;
108      res.end('File already exists.');
109      return;
110    }
111
112    debug(`Resolved path:`, resolvedPath);
113
114    try {
115      await fs.promises.mkdir(path.dirname(resolvedPath), { recursive: true });
116      await fs.promises.writeFile(resolvedPath, properties.contents, 'utf8');
117    } catch (e) {
118      debug('Error writing file', e);
119      res.statusCode = 500;
120      res.end('Error writing file.');
121      return;
122    }
123
124    debug(`File created`);
125    res.statusCode = 200;
126    res.end('OK');
127  }
128}
129