├── .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 | ![Deploy Flow](./docs/ssr-deploy-flow.png) 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 | 9 | 10 | 16 | 17 | 18 | 19 | 20 | 26 | 27 | 28 | 29 | 30 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 57 | 58 | 59 | 63 | 64 | 65 | 66 | 67 | 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 |
4 |
5 |
加载中...
6 |
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 |
6 |
关于项目
7 |
8 | 使用 Next.js + TypeScript 开发,并且基于 Serverless 部署的 V2EX 客户端 9 |
10 |
源码地址
11 |
12 | 13 | https://github.com/serverless-plus/serverless-v2ex 14 | 15 |
16 |
意见反馈
17 |
18 | 19 | 发表意见或者提需求 20 | 21 |
22 |
当前版本
23 |
V0.0.1
24 |
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 | --------------------------------------------------------------------------------