├── .babelrc
├── .env.example
├── .gitignore
├── README.md
├── apis
└── index.ts
├── assets
├── css
│ └── app.css
└── images
│ └── nodejs.svg
├── components
├── BackButton
│ └── index.tsx
├── Icons
│ └── nodejs.tsx
├── Layout.tsx
├── Loading
│ └── index.tsx
├── MenuBar
│ └── index.tsx
└── TopicList
│ ├── Topic.tsx
│ └── index.tsx
├── docs
├── demo.png
└── ssr-deploy-flow.png
├── interfaces
└── index.ts
├── layer
└── serverless.yml
├── next-env.d.ts
├── next.config.js
├── nodemon.json
├── package.json
├── pages
├── _app.tsx
├── _document.tsx
├── about.tsx
├── api
│ └── topics
│ │ ├── detail.ts
│ │ └── index.ts
├── index.tsx
├── topic.tsx
└── topics.tsx
├── public
└── favicon.png
├── server
└── index.ts
├── serverless.yml
├── sls.js
├── tsconfig.json
├── tsconfig.server.json
├── utils
├── index.ts
└── request.ts
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "next/babel"
4 | ],
5 | "plugins": [
6 | [
7 | "import",
8 | {
9 | "libraryName": "antd-mobile"
10 | }
11 | ]
12 | ]
13 | }
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # 腾讯云授权密钥
2 | TENCENT_SECRET_ID=xxx
3 | TENCENT_SECRET_KEY=xxx
4 |
5 | # 部署地区
6 | REGION=ap-guangzhou
7 |
8 | # 静态资源上传 COS 桶名称
9 | BUCKET=serverless-v2ex
10 |
11 | # API 网关自定义域名 和 证书 ID
12 | APIGW_CUSTOM_DOMAIN=v2ex.yuga.chat
13 | APIGW_CUSTOM_DOMAIN_CERTID=xxx
14 |
15 | # CDN 域名,证书 ID
16 | CDN_DOMAIN=static.v2ex.yuga.chat
17 | CDN_DOMAIN_CERTID=xxx
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 | /dist
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 |
24 | # lock file
25 | yarn.lock
26 | package-lock.json
27 |
28 | # debug
29 | npm-debug.log*
30 | yarn-debug.log*
31 | yarn-error.log*
32 |
33 | # local env files
34 | .env
35 | .env.local
36 | .env.development.local
37 | .env.test.local
38 | .env.production.local
39 |
40 | # vercel
41 | .vercel
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Serverless V2EX
2 |
3 | [在线预览](https://v2ex.yuga.chat)
4 |
5 | 使用 Next.js + TypeScript 开发,并且基于 Serverless 部署的 v2ex 客户端
6 |
7 | ## 流程图
8 |
9 | 
10 |
11 | ## 功能
12 |
13 | - [x] Typescript
14 | - [x] Next.js
15 | - [x] 自定义 Express Server
16 | - [x] SSR Cache [cacheable-response](https://github.com/Kikobeats/cacheable-response)
17 | - [x] 基于 Serverless Next.js 组件部署
18 | - [x] **静态资源分离,自动部署到 COS**
19 | - [x] **自动为静态 COS 配置 CDN**
20 | - [x] **node_modules 基于层部署,大大提高部署效率**
21 |
22 | ## 展示
23 |
24 |
25 |
26 | ## 本地开发
27 |
28 | ```bash
29 | $ npm install
30 |
31 | $ npm run dev
32 | ```
33 |
34 | ## 构建
35 |
36 | ```bash
37 | $ npm run build
38 | ```
39 |
40 | ## 配置
41 |
42 | 在部署到 Serverless 前,将 `.env.example` 重命名为 `.env`,并请完成如下配置:
43 |
44 | ```dotenv
45 | # 腾讯云授权密钥
46 | TENCENT_APP_ID=xxx
47 | TENCENT_SECRET_ID=xxx
48 | TENCENT_SECRET_KEY=xxx
49 |
50 | # 部署地区
51 | REGION=ap-guangzhou
52 |
53 | # 静态资源上传 COS 桶名称
54 | BUCKET=serverless-v2ex
55 |
56 | # API 网关自定义域名 和 证书 ID
57 | APIGW_CUSTOM_DOMAIN=v2ex.yuga.chat
58 | APIGW_CUSTOM_DOMAIN_CERTID=xxx
59 |
60 | # CDN 域名,证书 ID
61 | CDN_DOMAIN=static.v2ex.yuga.chat
62 | CDN_DOMAIN_CERTID=xxx
63 | ```
64 |
65 | > 注意:如果不需要使用 CDN,直接使用 COS 自动生成的域名,也是可以的,只需要删除
66 | > `serverless.yml` 中的 `cdnConf` 即可。
67 |
68 | ## 部署
69 |
70 | 此项目会先将 `node_modules` 部署到
71 | [层](https://cloud.tencent.com/document/product/583/40159),然后在部署项目代码,
72 | 这样下次部署项目时,如果 `node_modules` 没有修改,我们就不需要部署庞大的
73 | `node_modules` 文件夹了。
74 |
75 | 1. 部署层:
76 |
77 | ```bash
78 | $ npm run deploy:layer
79 | ```
80 |
81 | > 注意:如果项目 `node_modules` 没有变更,就不需要执行此命令。
82 |
83 | 2. 部署业务代码:
84 |
85 | ```bash
86 | $ npm run deploy
87 | ```
88 |
89 | ## License
90 |
91 | MIT
92 |
--------------------------------------------------------------------------------
/apis/index.ts:
--------------------------------------------------------------------------------
1 | import { get } from '../utils/request';
2 |
3 | const BASE_URL = 'https://www.v2ex.com/api';
4 |
5 | // `/api/topics/show.json?node_name=${topics}`
6 |
7 | const getTopicList = async (tab: string) => {
8 | let url = '';
9 | if (tab === 'latest') {
10 | url = 'latest.json';
11 | } else if (tab === 'hot') {
12 | url = 'hot.json';
13 | } else {
14 | url = `show.json?node_name=${tab}`;
15 | }
16 | console.log(`axios.get.url: ${url}`);
17 |
18 | const res = await get({
19 | url: `${BASE_URL}/topics/${url}`,
20 | });
21 |
22 | return res;
23 | };
24 |
25 | const getTopic = async (id: string | number) => {
26 | const [detail] = await get({
27 | url: `${BASE_URL}/topics/show.json?id=${id}`,
28 | });
29 |
30 | const replise = await get({
31 | url: `${BASE_URL}/replies/show.json?topic_id=${id}`,
32 | });
33 |
34 | detail.replyList = replise;
35 | return detail;
36 | };
37 |
38 | export { getTopicList, getTopic };
39 |
--------------------------------------------------------------------------------
/assets/css/app.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | padding: 0;
4 | margin: 0;
5 | }
6 |
7 | html {
8 | font-size: 80%;
9 | margin: 0;
10 | padding: 0;
11 | }
12 |
13 | body {
14 | margin: 0;
15 | padding: 0;
16 | font-family: 'Helvetica Neue', 'Luxi Sans', 'DejaVu Sans', Tahoma,
17 | 'Hiragino Sans GB', 'Microsoft Yahei', sans-serif;
18 | }
19 |
20 | .reply-container {
21 | width: 100%;
22 | }
23 |
24 | .container a {
25 | text-decoration: none;
26 | color: white;
27 | }
28 |
29 | .article-container {
30 | padding: 0.25rem;
31 | background-color: #e2e2e2;
32 | }
33 |
34 | .cell {
35 | display: flex;
36 | justify-content: space-between;
37 | align-items: center;
38 | padding: 0.5rem;
39 | line-height: 200%;
40 | text-align: left;
41 | border-bottom: 0.15rem solid #e2e2e2;
42 | border-radius: 0.2rem;
43 | background-color: white;
44 | }
45 |
46 | .member {
47 | width: 40px;
48 | height: 40px;
49 | }
50 |
51 | .member img {
52 | border-radius: 0.5rem;
53 | height: 100%;
54 | }
55 |
56 | .title {
57 | width: 80%;
58 | }
59 |
60 | .link-label {
61 | text-decoration: none;
62 | color: #333;
63 | }
64 |
65 | .info {
66 | border-radius: 0.2rem;
67 | color: #caa;
68 | margin-right: 0.2rem;
69 | }
70 |
71 | .topic-container {
72 | padding: 0.25rem;
73 | background-color: #e2e2e2;
74 | display: flex;
75 | flex-direction: column;
76 | }
77 |
78 | .topic {
79 | display: flex;
80 | flex-direction: column;
81 | background-color: white;
82 | padding: 1rem;
83 | border-radius: 0.4rem;
84 | margin-bottom: 1rem;
85 | }
86 |
87 | .topic-title {
88 | display: flex;
89 | flex-direction: row;
90 | justify-content: space-between;
91 | padding-bottom: 0.8rem;
92 | margin-bottom: 0.8rem;
93 | line-height: 150%;
94 | border-bottom: 0.2rem solid #e2e2e2;
95 | }
96 |
97 | .left-info {
98 | display: flex;
99 | flex-direction: column;
100 | justify-content: space-between;
101 | width: 80%;
102 | }
103 | .left-info span {
104 | color: #caa;
105 | }
106 |
107 | .right-avater {
108 | width: 20%;
109 | }
110 |
111 | .right-avater-img {
112 | margin: 0.2rem;
113 | border-radius: 0.4rem;
114 | }
115 |
116 | .reply-container {
117 | display: flex;
118 | flex-direction: row;
119 | justify-content: flex-start;
120 | overflow: hidden;
121 | line-height: 190%;
122 | background-color: white;
123 | border-bottom: 0.2rem solid #e2e2e2;
124 | border-radius: 0.2rem;
125 | }
126 | .reply-avatar {
127 | width: 15%;
128 | padding: 1rem;
129 | margin-right: 20px;
130 | }
131 |
132 | .reply-content {
133 | width: 85%;
134 | padding: 0.4rem 1rem 0.4rem 0.1rem;
135 | display: flex;
136 | flex-direction: column;
137 | justify-content: space-between;
138 | }
139 |
140 | .reply-content span,
141 | .reply-content a {
142 | color: #caa;
143 | }
144 |
145 | .rendered-content {
146 | overflow: hidden;
147 | }
148 |
149 | .topic-container img,
150 | .reply-container img {
151 | width: 100%;
152 | }
153 |
154 | .onloading {
155 | display: flex;
156 | flex-direction: column;
157 | height: 20rem;
158 | align-items: center;
159 | justify-content: center;
160 | }
161 |
162 | .onloading-circle {
163 | width: 2rem;
164 | height: 2rem;
165 | border: 0.1rem solid gray;
166 | border-radius: 50%;
167 | border-bottom-color: transparent;
168 | animation: 1s ease-in-out infinite loading;
169 | }
170 |
171 | @keyframes loading {
172 | from {
173 | transform: translate3d(0, 0, 0) rotate(0deg);
174 | }
175 | to {
176 | transform: translate3d(0, 0, 0) rotate(360deg);
177 | }
178 | }
179 |
180 | .about-info {
181 | height: 100%;
182 | padding: 10px 15px;
183 | line-height: 1.5;
184 | background: #f7f7f7;
185 | }
186 |
187 | .about-info dt {
188 | color: #2c3e50;
189 | font-size: 16px;
190 | line-height: 1.5;
191 | white-space: nowrap;
192 | text-overflow: ellipsis;
193 | overflow: hidden;
194 | padding: 5px 0;
195 | font-weight: bold;
196 | }
197 | .about-info dd {
198 | padding-bottom: 15px;
199 | font-size: 14px;
200 | border-bottom: 1px solid #d5dbdb;
201 | }
202 |
203 | .about-info a {
204 | color: #42b983;
205 | }
206 |
207 | .my-drawer {
208 | position: relative;
209 | overflow: auto;
210 | -webkit-overflow-scrolling: touch;
211 | }
212 | .my-drawer .am-drawer-sidebar {
213 | background-color: #fff;
214 | overflow: auto;
215 | -webkit-overflow-scrolling: touch;
216 | }
217 | .my-drawer .am-drawer-sidebar .am-list {
218 | width: 150px;
219 | padding: 0;
220 | }
221 |
222 | .back-button {
223 | position: fixed;
224 | right: 10px;
225 | bottom: 10px;
226 | z-index: 1000;
227 | color: #108ee9;
228 | }
229 |
230 | .nav-item {
231 | color: #000;
232 | }
233 | .nav-item a {
234 | color: #000;
235 | }
236 |
237 | .nav-item.active {
238 | background-color: #5fa9de;
239 | color: #fff;
240 | }
241 |
242 | .nav-item.active a {
243 | color: #fff;
244 | }
245 |
--------------------------------------------------------------------------------
/assets/images/nodejs.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/components/BackButton/index.tsx:
--------------------------------------------------------------------------------
1 | import { LeftCircleFilled } from '@ant-design/icons';
2 | import { useState, useEffect } from 'react';
3 | import { useRouter } from 'next/router';
4 |
5 | const BackButton = () => {
6 | const router = useRouter();
7 | const [show, setShow] = useState(false);
8 | useEffect(() => {
9 | setShow(history.length > 1);
10 | });
11 |
12 | function goBack() {
13 | router.back();
14 | }
15 |
16 | return show ? (
17 |
22 | ) : null;
23 | };
24 |
25 | export { BackButton };
26 |
--------------------------------------------------------------------------------
/components/Icons/nodejs.tsx:
--------------------------------------------------------------------------------
1 | import Icon from '@ant-design/icons';
2 |
3 | const NodejsSvg = () => (
4 |
68 | );
69 |
70 | type Props = {
71 | [propName: string]: any;
72 | };
73 |
74 | const NodejsIcon = (props: Props) => (
75 |
76 | );
77 |
78 | export default NodejsIcon;
79 |
--------------------------------------------------------------------------------
/components/Layout.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactNode } from 'react';
2 | import { LocaleProvider } from 'antd-mobile';
3 | import enUS from 'antd-mobile/lib/locale-provider/en_US';
4 | import { MenuBar } from './MenuBar';
5 | import { BackButton } from './BackButton';
6 |
7 | type Props = {
8 | children?: ReactNode;
9 | title?: string;
10 | tab?: string;
11 | language?: string;
12 | };
13 |
14 | const Layout = ({ language, children }: Props) => {
15 | const locale: any =
16 | language && language.substr(0, 2) === 'en' ? enUS : undefined;
17 |
18 | return (
19 |
20 |
21 | {children}
22 |
23 |
24 | );
25 | };
26 |
27 | export default Layout;
28 |
--------------------------------------------------------------------------------
/components/Loading/index.tsx:
--------------------------------------------------------------------------------
1 | const Loading = () => {
2 | return (
3 |
7 | );
8 | };
9 |
10 | export { Loading };
11 |
--------------------------------------------------------------------------------
/components/MenuBar/index.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode, useState, useEffect, Fragment } from 'react';
2 | import Link from 'next/link';
3 | import { useRouter } from 'next/router';
4 | import { Drawer, List, NavBar } from 'antd-mobile';
5 | import {
6 | MenuOutlined,
7 | HomeOutlined,
8 | FireOutlined,
9 | FieldTimeOutlined,
10 | CodeOutlined,
11 | UserOutlined,
12 | QuestionCircleOutlined,
13 | DesktopOutlined,
14 | } from '@ant-design/icons';
15 | import NodejsIcon from '../Icons/nodejs';
16 |
17 | type MenuBarProps = {
18 | pathname?: string;
19 | children: ReactNode;
20 | };
21 |
22 | const nodeList = [
23 | {
24 | title: '首页',
25 | tab: 'index',
26 | href: '/',
27 | icon: ,
28 | },
29 | {
30 | title: '最热',
31 | tab: 'hot',
32 | href: 'topics?tab=hot',
33 | icon: ,
34 | },
35 | {
36 | title: '最新',
37 | tab: 'latest',
38 | href: 'topics?tab=latest',
39 | icon: ,
40 | },
41 | {
42 | title: 'Node.js',
43 | tab: 'nodejs',
44 | href: 'topics?tab=nodejs',
45 | icon: ,
46 | },
47 | {
48 | title: 'Python',
49 | tab: 'python',
50 | href: 'topics?tab=python',
51 | icon: ,
52 | },
53 | {
54 | title: '程序员',
55 | tab: 'programmer',
56 | href: 'topics?tab=programmer',
57 | icon: ,
58 | },
59 | {
60 | title: 'Linux',
61 | tab: 'linux',
62 | href: 'topics?tab=linux',
63 | icon: ,
64 | },
65 | {
66 | title: '问与答',
67 | tab: 'qna',
68 | href: 'topics?tab=qna',
69 | icon: ,
70 | },
71 | {
72 | title: '酷工作',
73 | tab: 'jobs',
74 | href: 'topics?tab=jobs',
75 | icon: ,
76 | },
77 | ];
78 |
79 | function initTitle(tab = 'index') {
80 | const [current] = nodeList.filter((item) => item.tab === tab);
81 | return current.title;
82 | }
83 |
84 | const MenuBar = ({ children }: MenuBarProps) => {
85 | const router = useRouter();
86 | const [open, setOpen] = useState(false);
87 | const [drawHeight, setDrawHeight] = useState(1000);
88 | const [tab] = useState(router.query.tab || 'index');
89 | const [title, setTitle] = useState(initTitle(tab as string));
90 |
91 | useEffect(() => {
92 | setDrawHeight(document.documentElement.clientHeight);
93 | }, []);
94 |
95 | const sidebar = (
96 |
97 | {nodeList.map((item) => {
98 | return (
99 | {
101 | setOpen(!open);
102 | setTitle(item.title);
103 | }}
104 | key={item.tab}
105 | thumb={item.icon}
106 | className={tab === item.tab ? `nav-item active` : 'nav-item'}>
107 | {item.title}
108 |
109 | );
110 | })}
111 |
112 | );
113 |
114 | return (
115 |
116 | }
118 | onLeftClick={() => {
119 | setOpen(!open);
120 | }}
121 | rightContent={[
122 |
123 |
124 | ,
125 | ]}>
126 | {title && `${title} - `}Serverless V2EX
127 |
128 | {
135 | setOpen(!open);
136 | }}>
137 | {children}
138 |
139 |
140 | );
141 | };
142 |
143 | export { MenuBar };
144 |
--------------------------------------------------------------------------------
/components/TopicList/Topic.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import { formatDate } from '../../utils';
3 | import { Topic } from '../../interfaces';
4 |
5 | type Props = {
6 | topic: Topic;
7 | };
8 |
9 | const TopicItem = ({ topic }: Props) => (
10 |
11 |
12 |

13 |
14 |
15 |
16 |
17 | {topic.title}
18 |
19 |
20 |
21 |
22 | {topic.node.title} • {formatDate(topic.last_touched)} •{' '}
23 | {topic.replies}
24 |
25 |
26 |
27 |
28 | );
29 |
30 | export { TopicItem };
31 |
--------------------------------------------------------------------------------
/components/TopicList/index.tsx:
--------------------------------------------------------------------------------
1 | import { TopicItem } from './Topic';
2 | import { Topic } from '../../interfaces';
3 |
4 | type Props = {
5 | topics: Topic[];
6 | };
7 |
8 | const TopicList = ({ topics }: Props) => (
9 |
10 | {topics.map((item) => (
11 |
12 | ))}
13 |
14 | );
15 |
16 | export { TopicList };
17 |
--------------------------------------------------------------------------------
/docs/demo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/serverless-plus/serverless-v2ex/c1309bbcff928a3f821457e8200ac9bc5a7a7ace/docs/demo.png
--------------------------------------------------------------------------------
/docs/ssr-deploy-flow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/serverless-plus/serverless-v2ex/c1309bbcff928a3f821457e8200ac9bc5a7a7ace/docs/ssr-deploy-flow.png
--------------------------------------------------------------------------------
/interfaces/index.ts:
--------------------------------------------------------------------------------
1 | export type Member = {
2 | username: string;
3 | website: null | string;
4 | github: null | string;
5 | psn: null | string;
6 | bio: null | string;
7 | tagline: null | string;
8 | twitter: null | string;
9 | avatar_normal: string;
10 | url: string;
11 | created: number;
12 | avatar_large: string;
13 | avatar_mini: string;
14 | location: any;
15 | btc: any;
16 | id: number;
17 | };
18 |
19 | export type Node = {
20 | avatar_large: string;
21 | name: string;
22 | avatar_normal: string;
23 | title: string;
24 | url: string;
25 | topics: number;
26 | footer: string;
27 | header: string;
28 | title_alternative: string;
29 | avatar_mini: string;
30 | stars: number;
31 | aliases: any[];
32 | root: boolean;
33 | id: number;
34 | parent_node_name: string;
35 | };
36 |
37 | export type Reply = {
38 | member: Member;
39 | created: number;
40 | topic_id: number;
41 | content: string;
42 | content_rendered: string;
43 | last_modified: number;
44 | member_id: number;
45 | id: number;
46 | };
47 |
48 | export type Topic = {
49 | node: Node;
50 | member: Member;
51 | last_reply_by: string;
52 | last_touched: number;
53 | title: string;
54 | url: string;
55 | created: number;
56 | content: string;
57 | content_rendered: string;
58 | last_modified: number;
59 | replies: number;
60 | id: number;
61 | replyList?: Reply[];
62 | };
63 |
--------------------------------------------------------------------------------
/layer/serverless.yml:
--------------------------------------------------------------------------------
1 | org: serverless-v2ex
2 | app: serverless-v2ex
3 | stage: dev
4 | component: layer
5 | name: serverless-v2ex-layer
6 |
7 | inputs:
8 | region: ${env:REGION}
9 | name: ${name}
10 | src: ../node_modules
11 | runtimes:
12 | - Nodejs10.15
13 | - Nodejs12.16
14 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | require('dotenv').config();
2 | const path = require('path');
3 |
4 | function moduleDir(m) {
5 | return path.dirname(require.resolve(`${m}/package.json`));
6 | }
7 |
8 | const isProd = process.env.NODE_ENV === 'production';
9 |
10 | // if not use CDN, change to your cos access domain
11 | const STATIC_URL = 'https://static.v2ex.yuga.chat';
12 |
13 | module.exports = {
14 | env: {
15 | STATIC_URL: isProd
16 | ? STATIC_URL
17 | : `http://localhost:${parseInt(process.env.PORT, 10) || 8000}`,
18 | },
19 | assetPrefix: isProd ? STATIC_URL : '',
20 | webpack: (config, { dev }) => {
21 | config.resolve.extensions = [
22 | '.web.js',
23 | '.js',
24 | '.jsx',
25 | '.ts',
26 | '.tsx',
27 | '.json',
28 | ];
29 |
30 | config.module.rules.push(
31 | {
32 | test: /\.(svg)$/i,
33 | loader: 'emit-file-loader',
34 | options: {
35 | name: 'dist/[path][name].[ext]',
36 | },
37 | include: [moduleDir('antd-mobile'), __dirname],
38 | },
39 | {
40 | test: /\.(svg)$/i,
41 | loader: 'svg-sprite-loader',
42 | include: [moduleDir('antd-mobile'), __dirname],
43 | },
44 | );
45 |
46 | return config;
47 | },
48 | };
49 |
--------------------------------------------------------------------------------
/nodemon.json:
--------------------------------------------------------------------------------
1 | {
2 | "watch": ["server", "public"],
3 | "exec": "ts-node --project tsconfig.server.json server/index.ts",
4 | "ext": "js ts"
5 | }
6 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "serverless-v2ex",
3 | "version": "0.0.1",
4 | "scripts": {
5 | "dev": "nodemon",
6 | "build": "rimraf .next && cross-env NODE_ENV=production next build && tsc --project tsconfig.server.json",
7 | "build:sls": "rimraf .next && cross-env NODE_ENV=production SERVERLESS=true next build && tsc --project tsconfig.server.json",
8 | "start": "cross-env NODE_ENV=production node ./dist/index.js",
9 | "deploy": "serverless deploy",
10 | "deploy:layer": "serverless deploy --target=./layer"
11 | },
12 | "dependencies": {
13 | "@ant-design/icons": "^4.2.2",
14 | "antd-mobile": "^2.3.4",
15 | "axios": "^0.20.0",
16 | "cacheable-response": "^2.1.7",
17 | "dotenv": "^8.2.0",
18 | "express": "^4.17.1",
19 | "next": "latest",
20 | "react": "^16.12.0",
21 | "react-dom": "^16.12.0"
22 | },
23 | "devDependencies": {
24 | "@types/express": "^4.17.8",
25 | "@types/lru-cache": "^5.1.0",
26 | "@types/node": "^12.12.21",
27 | "@types/react": "^16.9.16",
28 | "@types/react-dom": "^16.9.4",
29 | "babel-plugin-import": "^1.13.1",
30 | "cross-env": "^7.0.2",
31 | "nodemon": "^2.0.5",
32 | "rimraf": "^3.0.2",
33 | "svg-sprite-loader": "^5.0.0",
34 | "typescript": "4.0"
35 | },
36 | "license": "MIT",
37 | "description": "This is a really simple project that shows the usage of Next.js with TypeScript.",
38 | "repository": {
39 | "type": "git",
40 | "url": "git+https://github.com/serverless-plus/serverless-v2ex.git"
41 | },
42 | "keywords": [
43 | "serverless-v2ex",
44 | "serverless",
45 | "v2ex",
46 | "next.js",
47 | "typescript",
48 | "serverless-framework",
49 | "serverless-components",
50 | "tencent-cloud"
51 | ],
52 | "author": "yugasun",
53 | "bugs": {
54 | "url": "https://github.com/serverless-plus/serverless-v2ex/issues"
55 | },
56 | "homepage": "https://github.com/serverless-plus/serverless-v2ex#readme"
57 | }
58 |
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import App from 'next/app';
2 | import React from 'react';
3 | import { AppProps } from 'next/app';
4 |
5 | import '../assets/css/app.css';
6 |
7 | type APageProps = {
8 | props: any;
9 | };
10 |
11 | type MyAppProps = AppProps & APageProps;
12 |
13 | // @ts-ignore
14 | class MyApp extends App {
15 | render() {
16 | const { Component, pageProps } = this.props;
17 | return ;
18 | }
19 | }
20 |
21 | export default MyApp;
22 |
--------------------------------------------------------------------------------
/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Document, {
3 | Html,
4 | Head,
5 | Main,
6 | NextScript,
7 | DocumentContext,
8 | } from 'next/document';
9 |
10 | class MyDocument extends Document {
11 | static async getInitialProps(ctx: DocumentContext) {
12 | const initialProps = await Document.getInitialProps(ctx);
13 | return { ...initialProps };
14 | }
15 |
16 | render() {
17 | return (
18 |
19 |
20 |
21 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | );
33 | }
34 | }
35 | export default MyDocument;
36 |
--------------------------------------------------------------------------------
/pages/about.tsx:
--------------------------------------------------------------------------------
1 | import Layout from '../components/Layout';
2 |
3 | const AboutPage = () => (
4 |
5 |
25 |
26 | );
27 |
28 | export default AboutPage;
29 |
--------------------------------------------------------------------------------
/pages/api/topics/detail.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next';
2 | import { getTopic } from '../../../apis';
3 |
4 | const handler = async (req: NextApiRequest, res: NextApiResponse) => {
5 | try {
6 | const id = req.query.id;
7 | if (id) {
8 | const topic = await getTopic(id as string);
9 |
10 | res.status(200).json(topic);
11 | } else {
12 | res.status(500).json({ statusCode: 500, message: 'Topic id not exist.' });
13 | }
14 | } catch (err) {
15 | res.status(500).json({ statusCode: 500, message: err.message });
16 | }
17 | };
18 |
19 | export default handler;
20 |
--------------------------------------------------------------------------------
/pages/api/topics/index.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next';
2 | import { getTopicList } from '../../../apis';
3 |
4 | const handler = async (req: NextApiRequest, res: NextApiResponse) => {
5 | try {
6 | const tab = req.query.tab || 'latest';
7 | const topics = await getTopicList(tab as string);
8 |
9 | res.status(200).json(topics);
10 | } catch (err) {
11 | res.status(500).json({ statusCode: 500, message: err.message });
12 | }
13 | };
14 |
15 | export default handler;
16 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { GetServerSideProps, GetServerSidePropsContext } from 'next';
3 | import Layout from '../components/Layout';
4 | import { getTopicList } from '../apis';
5 | import { Topic } from '../interfaces';
6 | import { TopicList } from '../components/TopicList';
7 |
8 | interface Props {
9 | topics: Topic[];
10 | }
11 |
12 | const IndexPage = ({ topics }: Props) => {
13 | return (
14 |
15 |
16 |
17 | );
18 | };
19 |
20 | export const getServerSideProps: GetServerSideProps = async (
21 | context: GetServerSidePropsContext,
22 | ) => {
23 | const { query = {} } = context;
24 | const tab = query.tab ? (query.tab as string) : 'latest';
25 |
26 | const topics: Topic[] = await getTopicList(tab);
27 | return { props: { topics } };
28 | };
29 |
30 | export default IndexPage;
31 |
--------------------------------------------------------------------------------
/pages/topic.tsx:
--------------------------------------------------------------------------------
1 | import { GetServerSideProps, GetServerSidePropsContext } from 'next';
2 | import Layout from '../components/Layout';
3 | import { formatDate } from '../utils';
4 | import { getTopic } from '../apis';
5 | import { Loading } from '../components/Loading';
6 | import { Topic } from '../interfaces';
7 | // import BackToTop from './BackTop';
8 |
9 | type TopicProps = {
10 | topic: Topic;
11 | topicId: string;
12 | };
13 |
14 | const TopicPage = ({ topic }: TopicProps) => {
15 | const { replyList = [] } = topic || {};
16 |
17 | //获取回复列表
18 | let repliesItems: JSX.Element[] = [];
19 | if (replyList.length > 0) {
20 | repliesItems = replyList.map((reply, i) => {
21 | return (
22 |
23 |
24 |

25 |
26 |
27 |
28 |
29 | {reply.member.username}
30 | {formatDate(reply.last_modified)}
31 |
32 |
33 |
37 |
38 |
39 | );
40 | });
41 | }
42 |
43 | return (
44 |
45 | {topic && topic.member ? (
46 |
47 |
48 |
49 |
50 |
51 | {topic.title}
52 |
53 |
54 |
55 | {topic.member.username}
56 | {formatDate(topic.last_modified)}
57 |
58 |
59 |
60 |
61 |

64 |
65 |
66 |
70 |
71 | {repliesItems}
72 |
73 | ) : (
74 |
75 | )}
76 | {/* */}
77 |
78 | );
79 | };
80 |
81 | export const getServerSideProps: GetServerSideProps = async (
82 | context: GetServerSidePropsContext,
83 | ) => {
84 | const { query = {} } = context;
85 |
86 | if (query && query.id) {
87 | const topic: Topic = await getTopic(query.id as string);
88 |
89 | return { props: { topic: topic, topicId: query.id } };
90 | }
91 | return { props: { topic: {} } };
92 | };
93 |
94 | export default TopicPage;
95 |
--------------------------------------------------------------------------------
/pages/topics.tsx:
--------------------------------------------------------------------------------
1 | import { GetServerSideProps, GetServerSidePropsContext } from 'next';
2 | import Layout from '../components/Layout';
3 | import { Topic } from '../interfaces';
4 | import { getTopicList } from '../apis';
5 | import { TopicList } from '../components/TopicList';
6 |
7 | interface Props {
8 | tab: string;
9 | topics: Topic[];
10 | }
11 |
12 | const TopicsPage = ({ tab, topics }: Props) => {
13 | return (
14 |
15 |
16 |
17 | );
18 | };
19 |
20 | export const getServerSideProps: GetServerSideProps = async (
21 | context: GetServerSidePropsContext,
22 | ) => {
23 | const { query = {} } = context;
24 | const tab = query.tab ? (query.tab as string) : 'latest';
25 |
26 | const topics: Topic[] = await getTopicList(tab);
27 | return { props: { topics, tab } };
28 | };
29 |
30 | export default TopicsPage;
31 |
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/serverless-plus/serverless-v2ex/c1309bbcff928a3f821457e8200ac9bc5a7a7ace/public/favicon.png
--------------------------------------------------------------------------------
/server/index.ts:
--------------------------------------------------------------------------------
1 | import { join } from 'path';
2 | import Next from 'next';
3 | import Express, { Request, Response } from 'express';
4 | import { ParsedUrlQuery } from 'querystring';
5 | import cacheableResponse from 'cacheable-response';
6 |
7 | const dev = process.env.NODE_ENV !== 'production';
8 | const port = parseInt(process.env.PORT as string, 10) || 8000;
9 |
10 | const app = Next({ dev });
11 | const handle = app.getRequestHandler();
12 |
13 | type CacheOptions = {
14 | req: Request;
15 | res: Response;
16 | };
17 |
18 | const ssrCache = cacheableResponse({
19 | ttl: 1000 * 60 * 60, // 1hour
20 | get: async ({ req, res }: CacheOptions) => {
21 | const data = await app.render(req, res, req.path, {
22 | ...(req.query as ParsedUrlQuery),
23 | ...req.params,
24 | });
25 |
26 | // Add here custom logic for when you do not want to cache the page, for
27 | // example when the page returns a 404 status code:
28 | if (res.statusCode === 404) {
29 | res.end(data);
30 | return null;
31 | }
32 |
33 | return null;
34 | },
35 | send: ({ data, res }) => res.send(data),
36 | });
37 |
38 | async function startServer() {
39 | await app.prepare();
40 |
41 | const server = Express();
42 | server.use(Express.static(join(__dirname, '../public/static')));
43 |
44 | server.get('/', async (req, res) => {
45 | return ssrCache({ req, res });
46 | });
47 |
48 | server.get('/about', async (req, res) => {
49 | return ssrCache({ req, res });
50 | });
51 |
52 | server.get('/topic', async (req, res) => {
53 | return ssrCache({ req, res });
54 | });
55 |
56 | server.get('*', (req, res) => {
57 | // @ts-ignore
58 | req['__SLS_NO_REPORT__'] = true;
59 | return handle(req, res);
60 | });
61 |
62 | // define binary type for response
63 | // if includes, will return base64 encoded, very useful for images
64 | // @ts-ignore
65 | server['binaryTypes'] = ['*/*'];
66 |
67 | return server;
68 | }
69 |
70 | if (process.env.SERVERLESS) {
71 | module.exports = startServer;
72 | } else {
73 | try {
74 | startServer().then((server) => {
75 | server.listen(port, () => {
76 | console.log(`> Ready on http://localhost:${port}`);
77 | });
78 | });
79 | } catch (e) {
80 | throw e;
81 | }
82 | }
83 |
84 | process.on('unhandledRejection', (e) => {
85 | throw e;
86 | });
87 |
--------------------------------------------------------------------------------
/serverless.yml:
--------------------------------------------------------------------------------
1 | org: serverless-v2ex
2 | app: serverless-v2ex
3 | stage: dev
4 | component: nextjs
5 | name: serverless-v2ex
6 |
7 | inputs:
8 | src:
9 | dist: ./
10 | hook: npm run build:sls
11 | exclude:
12 | - .env
13 | - '.git/**'
14 | - 'docs/**'
15 | - '.next/cache/**'
16 | - 'node_modules/**'
17 | region: ${env:REGION}
18 | runtime: Nodejs12.16
19 | functionName: ${name}
20 | layers:
21 | - name: ${output:${stage}:${app}:${name}-layer.name}
22 | version: ${output:${stage}:${app}:${name}-layer.version}
23 | functionConf:
24 | timeout: 10
25 | environment:
26 | variables:
27 | NODE_ENV: production
28 | SERVERLESS: true
29 | apigatewayConf:
30 | protocols:
31 | - http
32 | - https
33 | environment: release
34 | enableCORS: true
35 | function:
36 | functionQualifier: $DEFAULT
37 | customDomains:
38 | - domain: ${env:APIGW_CUSTOM_DOMAIN}
39 | certificateId: ${env:APIGW_CUSTOM_DOMAIN_CERTID}
40 | isDefaultMapping: false
41 | pathMappingSet:
42 | - path: /
43 | environment: release
44 | protocols:
45 | - http
46 | - https
47 | staticConf:
48 | cosConf:
49 | bucket: ${env:BUCKET}
50 | cdnConf:
51 | # First deployment, plz set onlyRefresh to false !!!!
52 | # after you deploy CDN once, just set onlyRefresh to true for refresh CDN cache
53 | onlyRefresh: true
54 | domain: ${env:CDN_DOMAIN}
55 | https:
56 | certId: ${env:CDN_DOMAIN_CERTID}
57 |
--------------------------------------------------------------------------------
/sls.js:
--------------------------------------------------------------------------------
1 | process.env.NODE_ENV = 'production';
2 | process.env.SERVERLESS = true;
3 |
4 | const createServer = require('./dist');
5 |
6 | module.exports = createServer;
7 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowJs": true,
4 | "alwaysStrict": true,
5 | "esModuleInterop": true,
6 | "forceConsistentCasingInFileNames": true,
7 | "isolatedModules": true,
8 | "jsx": "preserve",
9 | "lib": ["dom", "es2017"],
10 | "module": "esnext",
11 | "moduleResolution": "node",
12 | "noEmit": true,
13 | "noFallthroughCasesInSwitch": true,
14 | "noUnusedLocals": true,
15 | "noUnusedParameters": true,
16 | "resolveJsonModule": true,
17 | "skipLibCheck": true,
18 | "strict": true,
19 | "target": "esnext",
20 | "baseUrl": ".",
21 | "paths": {
22 | "@assets": ["assets"],
23 | }
24 | },
25 | "exclude": [
26 | "node_modules",
27 | "dist",
28 | ".next",
29 | "out",
30 | "next.config.js",
31 | ".babelrc",
32 | "bundles",
33 | "coverage",
34 | "test/*"
35 | ],
36 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
37 | }
38 |
--------------------------------------------------------------------------------
/tsconfig.server.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "module": "commonjs",
5 | "outDir": "dist",
6 | "target": "es2017",
7 | "isolatedModules": false,
8 | "noEmit": false
9 | },
10 | "include": ["server/**/*.ts"]
11 | }
12 |
--------------------------------------------------------------------------------
/utils/index.ts:
--------------------------------------------------------------------------------
1 | function pluralize(time: number, label: string) {
2 | return time + label;
3 | }
4 |
5 | function formatDate(time: number) {
6 | const between = Math.round(new Date().getTime() / 100) - time;
7 | if (between < 3600) {
8 | return pluralize(~~(between / 60), '分钟前');
9 | } else if (between < 86400) {
10 | return pluralize(~~(between / 3600), '小时前');
11 | } else {
12 | return pluralize(~~(between / 86400), '天前');
13 | }
14 | }
15 |
16 | export const typeOf = (obj: any) => {
17 | return Object.prototype.toString.call(obj).slice(8, -1).toLowerCase();
18 | };
19 |
20 | export const isObject = (obj: any) => {
21 | return typeOf(obj) === 'object';
22 | };
23 |
24 | export const param = function (a: any) {
25 | const s: any[] = [];
26 | const add = function (k: any, v: any) {
27 | v = typeof v === 'function' ? v() : v;
28 | v = v === null ? '' : v === undefined ? '' : v;
29 | s[s.length] = encodeURIComponent(k) + '=' + encodeURIComponent(v);
30 | };
31 | const buildParams = function (prefix: string, obj: any) {
32 | if (prefix) {
33 | if (Array.isArray(obj)) {
34 | for (let i = 0, len = obj.length; i < len; i++) {
35 | buildParams(
36 | prefix +
37 | '[' +
38 | (typeof obj[i] === 'object' && obj[i] ? i : '') +
39 | ']',
40 | obj[i],
41 | );
42 | }
43 | } else if (String(obj) === '[object Object]') {
44 | for (let key in obj) {
45 | buildParams(prefix + '[' + key + ']', obj[key]);
46 | }
47 | } else {
48 | add(prefix, obj);
49 | }
50 | } else if (Array.isArray(obj)) {
51 | for (let i = 0, len = obj.length; i < len; i++) {
52 | add(obj[i].name, obj[i].value);
53 | }
54 | } else {
55 | for (let key in obj) {
56 | buildParams(key, obj[key]);
57 | }
58 | }
59 | return s;
60 | };
61 |
62 | return buildParams('', a).join('&');
63 | };
64 |
65 | export { formatDate };
66 |
--------------------------------------------------------------------------------
/utils/request.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import * as utils from './index';
3 |
4 | export const post = async (options: any, data?: any): Promise => {
5 | if (typeof options == 'string') {
6 | options = {
7 | url: options,
8 | };
9 | }
10 | if (utils.isObject(data)) {
11 | options.data = data;
12 | }
13 | const res = await axios.request({
14 | header: {
15 | 'Content-Type': 'application/x-www-form-urlencoded',
16 | Accept: 'application/json',
17 | },
18 | ...options,
19 | data: utils.param(options.data),
20 | method: 'post',
21 | });
22 | return res.data;
23 | };
24 |
25 | export const get = async (options: any, data?: any): Promise => {
26 | if (typeof options == 'string') {
27 | options = {
28 | url: options,
29 | };
30 | }
31 | if (utils.isObject(data)) {
32 | options.data = data;
33 | }
34 | const res = await axios.get(options.url, {
35 | ...options,
36 | params: options.data,
37 | });
38 | return res.data;
39 | };
40 |
--------------------------------------------------------------------------------