1
0
Fork 0
mirror of https://github.com/terribleplan/next.js.git synced 2024-01-19 02:48:18 +00:00

custom-server-actionhero (#3905)

This commit is contained in:
Evan Tahler 2018-02-27 04:16:17 -08:00 committed by Tim Neutkens
parent ba226c2d26
commit a39e54c675
20 changed files with 917 additions and 1 deletions

3
.gitignore vendored
View file

@ -7,8 +7,9 @@ node_modules
package-lock.json
test/node_modules
# logs
# logs & pids
*.log
pids
# coverage
.nyc_output

View file

@ -0,0 +1,99 @@
# Running Next.JS and React /inside/ of ActionHero
This server will render dynamic next.js/react pages on some routes, and normal ActionHero API requests on others.
This configuration works with both Next and ActionHero hot reloading of code.
A more detailed example showcasing how to use fetch and web sockets to interact with your API can be found here: https://github.com/actionhero/next-in-actionhero
## To install:
(assuming you have [node](http://nodejs.org/) and NPM installed)
`npm install`
## To Run:
`npm start`
## How does this work?
1. Create an initializer to load next.js and create a handler that can extract the normal node `req` and `res` from the connection
```js
// initializers/next.js
const {Initializer, api} = require('actionhero')
const next = require('next')
module.exports = class NextInitializer extends Initializer {
constructor () {
super()
this.name = 'next'
}
async initialize () {
api.next = {
render: async (connection) => {
if (connection.type !== 'web') { throw new Error('Connections for NEXT apps must be of type "web"') }
const req = connection.rawConnection.req
const res = connection.rawConnection.res
return api.next.handle(req, res)
}
}
api.next.dev = (api.env === 'development')
if (api.next.dev) { api.log('Running next in development mode...') }
api.next.app = next({dev: api.next.dev})
api.next.handle = api.next.app.getRequestHandler()
await api.next.app.prepare()
}
async stop () {
await api.next.app.close()
}
}
```
2. Create an action which will run the above `api.next.render(connection)`. Note that we will not be relying on ActionHero to respond to the client's request in this case, and leave that up to next (via: `data.toRender = false`)
```js
// actions/next.js
const {Action, api} = require('actionhero')
module.exports = class CreateChatRoom extends Action {
constructor () {
super()
this.name = 'render'
this.description = 'I render the next.js react website'
}
async run (data) {
data.toRender = false
return api.next.render(data.connection)
}
}
```
3. Tell ActionHero to use the api rather than the file server as the top-level route in `api.config.servers.web.rootEndpointType = 'api'`. This will allows "/" to listen to API requests. Also update `api.config.general.paths.public = [ path.join(__dirname, '/../static') ]`. In this configuration, the next 'static' renderer will take priority over the ActionHero 'public file' api. Note that any static assets (CSS, fonts, etc) will need to be in "./static" rather than "./public".
Note that this is where the websocket server, if you enable it, will place the `ActionheroWebsocketClient` libraray.
4. Configure a wild-card route at the lowest priority of your GET handler to catch all web requests that aren't caught by other actions:
```js
// config/routes.js
exports['default'] = {
routes: (api) => {
return {
get: [
{ path: '/time', action: 'time' },
{ path: '/', matchTrailingPathParts: true, action: 'render' }
]
}
}
}
```

View file

@ -0,0 +1,15 @@
'use strict'
const {Action, api} = require('actionhero')
module.exports = class CreateChatRoom extends Action {
constructor () {
super()
this.name = 'render'
this.description = 'I render the next.js react website'
}
async run (data) {
data.toRender = false
return api.next.render(data.connection)
}
}

View file

@ -0,0 +1,97 @@
'use strict'
const path = require('path')
exports['default'] = {
general: (api) => {
const packageJSON = require(api.projectRoot + path.sep + 'package.json')
return {
apiVersion: packageJSON.version,
serverName: packageJSON.name,
// id can be set here, or it will be generated dynamically.
// Be sure that every server you run has a unique ID (which will happen when generated dynamically)
// id: 'myActionHeroServer',
// A unique token to your application that servers will use to authenticate to each other
serverToken: 'change-me',
// the redis prefix for actionhero's cache objects
cachePrefix: 'actionhero:cache:',
// the redis prefix for actionhero's cache/lock objects
lockPrefix: 'actionhero:lock:',
// how long will a lock last before it exipres (ms)?
lockDuration: 1000 * 10, // 10 seconds
// Watch for changes in actions and tasks, and reload/restart them on the fly
developmentMode: true,
// How many pending actions can a single connection be working on
simultaneousActions: 5,
// allow connections to be created without remoteIp and remotePort (they will be set to 0)
enforceConnectionProperties: true,
// disables the whitelisting of client params
disableParamScrubbing: false,
// params you would like hidden from any logs
filteredParams: [],
// values that signify missing params
missingParamChecks: [null, '', undefined],
// The default filetype to server when a user requests a directory
directoryFileType: 'index.html',
// What log-level should we use for file requests?
fileRequestLogLevel: 'info',
// The default priority level given to middleware of all types (action, connection, say, and task)
defaultMiddlewarePriority: 100,
// Which channel to use on redis pub/sub for RPC communication
channel: 'actionhero',
// How long to wait for an RPC call before considering it a failure
rpcTimeout: 5000,
// should CLI methods and help include internal ActionHero CLI methods?
cliIncludeInternal: true,
// configuration for your actionhero project structure
paths: {
'action': [path.join(__dirname, '/../actions')],
'task': [path.join(__dirname, '/../tasks')],
'public': [path.join(__dirname, '/../static')],
'pid': [path.join(__dirname, '/../pids')],
'log': [path.join(__dirname, '/../log')],
'server': [path.join(__dirname, '/../servers')],
'cli': [path.join(__dirname, '/../bin')],
'initializer': [path.join(__dirname, '/../initializers')],
'plugin': [path.join(__dirname, '/../node_modules')],
'locale': [path.join(__dirname, '/../locales')]
},
// hash containing chat rooms you wish to be created at server boot
startingChatRooms: {
// format is {roomName: {authKey, authValue}}
// 'secureRoom': {authorized: true},
}
}
}
}
exports.test = {
general: (api) => {
return {
id: 'test-server-' + process.pid,
serverToken: 'serverToken-' + process.pid,
developmentMode: true,
startingChatRooms: {
'defaultRoom': {},
'otherRoom': {}
},
paths: {
'locale': [
// require('os').tmpdir() + require('path').sep + 'locales',
path.join(__dirname, '/../locales')
]
},
rpcTimeout: 3000
}
}
}
exports.production = {
general: (api) => {
return {
fileRequestLogLevel: 'debug',
developmentMode: false
}
}
}

View file

@ -0,0 +1,148 @@
'use strict'
// error messages can be strings of objects
exports['default'] = {
errors: (api) => {
return {
'_toExpand': false,
// ///////////////
// SERIALIZERS //
// ///////////////
serializers: {
servers: {
web: (error) => {
if (error.message) {
return String(error.message)
} else {
return error
}
},
websocket: (error) => {
if (error.message) {
return String(error.message)
} else {
return error
}
},
socket: (error) => {
if (error.message) {
return String(error.message)
} else {
return error
}
},
specHelper: (error) => {
if (error.message) {
return 'Error: ' + String(error.message)
} else {
return error
}
}
}
},
// ///////////
// ACTIONS //
// ///////////
// When a params for an action is invalid
invalidParams: (data, validationErrors) => {
if (validationErrors.length >= 0) { return validationErrors[0] }
return data.connection.localize('actionhero.errors.invalidParams')
},
// When a required param for an action is not provided
missingParams: (data, missingParams) => {
return data.connection.localize(['actionhero.errors.missingParams', {param: missingParams[0]}])
},
// user requested an unknown action
unknownAction: (data) => {
return data.connection.localize('actionhero.errors.unknownAction')
},
// action not useable by this client/server type
unsupportedServerType: (data) => {
return data.connection.localize(['actionhero.errors.unsupportedServerType', {type: data.connection.type}])
},
// action failed because server is mid-shutdown
serverShuttingDown: (data) => {
return data.connection.localize('actionhero.errors.serverShuttingDown')
},
// action failed because this client already has too many pending acitons
// limit defined in api.config.general.simultaneousActions
tooManyPendingActions: (data) => {
return data.connection.localize('actionhero.errors.tooManyPendingActions')
},
dataLengthTooLarge: (maxLength, receivedLength) => {
return api.i18n.localize(['actionhero.errors.dataLengthTooLarge', {maxLength: maxLength, receivedLength: receivedLength}])
},
// ///////////////
// FILE SERVER //
// ///////////////
// The body message to accompany 404 (file not found) errors regarding flat files
// You may want to load in the contnet of 404.html or similar
fileNotFound: (connection) => {
return connection.localize(['actionhero.errors.fileNotFound'])
},
// user didn't request a file
fileNotProvided: (connection) => {
return connection.localize('actionhero.errors.fileNotProvided')
},
// something went wrong trying to read the file
fileReadError: (connection, error) => {
return connection.localize(['actionhero.errors.fileReadError', {error: String(error)}])
},
// ///////////////
// CONNECTIONS //
// ///////////////
verbNotFound: (connection, verb) => {
return connection.localize(['actionhero.errors.verbNotFound', {verb: verb}])
},
verbNotAllowed: (connection, verb) => {
return connection.localize(['actionhero.errors.verbNotAllowed', {verb: verb}])
},
connectionRoomAndMessage: (connection) => {
return connection.localize('actionhero.errors.connectionRoomAndMessage')
},
connectionNotInRoom: (connection, room) => {
return connection.localize(['actionhero.errors.connectionNotInRoom', {room: room}])
},
connectionAlreadyInRoom: (connection, room) => {
return connection.localize(['actionhero.errors.connectionAlreadyInRoom', {room: room}])
},
connectionRoomHasBeenDeleted: (room) => {
return api.i18n.localize('actionhero.errors.connectionRoomHasBeenDeleted')
},
connectionRoomNotExist: (room) => {
return api.i18n.localize('actionhero.errors.connectionRoomNotExist')
},
connectionRoomExists: (room) => {
return api.i18n.localize('actionhero.errors.connectionRoomExists')
},
connectionRoomRequired: (room) => {
return api.i18n.localize('actionhero.errors.connectionRoomRequired')
}
}
}
}

View file

@ -0,0 +1,36 @@
exports['default'] = {
i18n: (api) => {
return {
// visit https://github.com/mashpie/i18n-node to see all configuration options
// locale path can be configired from within ./config/api.js
locales: ['en'],
// how would you like your lanaguages to fall back if a translation string is missing?
fallbacks: {
// 'es': 'en'
},
// configure i18n to allow for object-style key lookup
objectNotation: true,
// should actionhero append any missing translations to the locale file?
updateFiles: true,
// this will configure logging and error messages in the log(s)
defaultLocale: 'en',
// the name of the method by which to determine the connection's locale
// by default, every request will be in the 'en' locale
// this method will be called witin the localiazation middleware on all requests
determineConnectionLocale: 'api.i18n.determineConnectionLocale'
}
}
}
exports.test = {
i18n: (api) => {
return {
updateFiles: true
}
}
}

View file

@ -0,0 +1,60 @@
'use strict'
const fs = require('fs')
const cluster = require('cluster')
exports['default'] = {
logger: (api) => {
let logger = {transports: []}
// console logger
if (cluster.isMaster) {
logger.transports.push(function (api, winston) {
return new (winston.transports.Console)({
colorize: true,
level: 'info',
timestamp: function () { return api.id + ' @ ' + new Date().toISOString() }
})
})
}
// file logger
logger.transports.push(function (api, winston) {
if (api.config.general.paths.log.length === 1) {
const logDirectory = api.config.general.paths.log[0]
try {
fs.mkdirSync(logDirectory)
} catch (e) {
if (e.code !== 'EEXIST') {
throw (new Error('Cannot create log directory @ ' + logDirectory))
}
}
}
return new (winston.transports.File)({
filename: api.config.general.paths.log[0] + '/' + api.pids.title + '.log',
level: 'info',
timestamp: function () { return api.id + ' @ ' + new Date().toISOString() }
})
})
// the maximum length of param to log (we will truncate)
logger.maxLogStringLength = 100
// you can optionally set custom log levels
// logger.levels = {good: 0, bad: 1};
// you can optionally set custom log colors
// logger.colors = {good: 'blue', bad: 'red'};
return logger
}
}
exports.test = {
logger: (api) => {
return {
transports: null
}
}
}

View file

@ -0,0 +1,27 @@
exports['default'] = {
plugins: (api) => {
/*
If you want to use plugins in your application, include them here:
return {
'myPlugin': { path: __dirname + '/../node_modules/myPlugin' }
}
You can also toggle on or off sections of a plugin to include (default true for all sections):
return {
'myPlugin': {
path: __dirname + '/../node_modules/myPlugin',
actions: true,
tasks: true,
initializers: true,
servers: true,
public: true,
cli: true
}
}
*/
return {}
}
}

View file

@ -0,0 +1,49 @@
let host = process.env.REDIS_HOST || '127.0.0.1'
let port = process.env.REDIS_PORT || 6379
let db = process.env.REDIS_DB || 0
let password = process.env.REDIS_PASSWORD || null
const maxBackoff = 1000
if (process.env.REDIS_URL) {
password = process.env.REDIS_URL.match(/redis:\/\/.*:(.*)@.*:\d*$/i)[1]
host = process.env.REDIS_URL.match(/redis:\/\/.*:.*@(.*):\d*$/i)[1]
port = parseInt(process.env.REDIS_URL.match(/redis:\/\/.*:.*@.*:(\d*)$/i)[1])
}
exports['default'] = {
redis: (api) => {
// konstructor: The redis client constructor method. All redis methods must be promises
// args: The arguments to pass to the constructor
// buildNew: is it `new konstructor()` or just `konstructor()`?
function retryStrategy (times) {
if (times === 1) {
const error = 'Unable to connect to Redis - please check your Redis config!'
if (process.env.NODE_ENV === 'test') { console.error(error) } else { api.log(error, 'error') }
return 5000
}
return Math.min(times * 50, maxBackoff)
}
return {
enabled: true,
'_toExpand': false,
client: {
konstructor: require('ioredis'),
args: [{ port: port, host: host, password: password, db: db, retryStrategy: retryStrategy }],
buildNew: true
},
subscriber: {
konstructor: require('ioredis'),
args: [{ port: port, host: host, password: password, db: db, retryStrategy: retryStrategy }],
buildNew: true
},
tasks: {
konstructor: require('ioredis'),
args: [{ port: port, host: host, password: password, db: db, retryStrategy: retryStrategy }],
buildNew: true
}
}
}
}

View file

@ -0,0 +1,9 @@
exports['default'] = {
routes: (api) => {
return {
get: [
{ path: '/', matchTrailingPathParts: true, action: 'render' }
]
}
}
}

View file

@ -0,0 +1,37 @@
'use strict'
exports['default'] = {
servers: {
socket: (api) => {
return {
enabled: (process.env.ENABLE_TCP_SERVER !== undefined),
// TCP or TLS?
secure: false,
// Passed to tls.createServer if secure=true. Should contain SSL certificates
serverOptions: {},
// Port or Socket
port: 5000,
// Which IP to listen on (use 0.0.0.0 for all)
bindIP: '0.0.0.0',
// Enable TCP KeepAlive pings on each connection?
setKeepAlive: false,
// Delimiter string for incoming messages
delimiter: '\n',
// Maximum incoming message string length in Bytes (use 0 for Infinite)
maxDataLength: 0
}
}
}
}
exports.test = {
servers: {
socket: (api) => {
return {
enabled: true,
port: 1001 + (process.pid % 64535),
secure: false
}
}
}
}

View file

@ -0,0 +1,121 @@
'use strict'
const os = require('os')
exports['default'] = {
servers: {
web: (api) => {
return {
enabled: true,
// HTTP or HTTPS?
secure: false,
// Passed to https.createServer if secure=true. Should contain SSL certificates
serverOptions: {},
// Should we redirect all traffic to the first host in this array if hte request header doesn't match?
// i.e.: [ 'https://www.site.com' ]
allowedRequestHosts: process.env.ALLOWED_HOSTS ? process.env.ALLOWED_HOSTS.split(',') : [],
// Port or Socket Path
port: process.env.PORT || 8080,
// Which IP to listen on (use '0.0.0.0' for all; '::' for all on ipv4 and ipv6)
// Set to `null` when listening to socket
bindIP: '0.0.0.0',
// Any additional headers you want actionhero to respond with
httpHeaders: {
'X-Powered-By': api.config.general.serverName,
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'HEAD, GET, POST, PUT, PATCH, DELETE, OPTIONS, TRACE',
'Access-Control-Allow-Headers': 'Content-Type'
},
// Route that actions will be served from; secondary route against this route will be treated as actions,
// IE: /api/?action=test == /api/test/
urlPathForActions: 'api',
// Route that static files will be served from;
// path (relative to your project root) to serve static content from
// set to `null` to disable the file server entirely
urlPathForFiles: 'public',
// When visiting the root URL, should visitors see 'api' or 'file'?
// Visitors can always visit /api and /public as normal
rootEndpointType: 'api',
// simple routing also adds an 'all' route which matches /api/:action for all actions
simpleRouting: true,
// queryRouting allows an action to be defined via a URL param, ie: /api?action=:action
queryRouting: true,
// The cache or (if etags are enabled) next-revalidation time to be returned for all flat files served from /public; defined in seconds
flatFileCacheDuration: 60,
// Add an etag header to requested flat files which acts as fingerprint that changes when the file is updated;
// Client will revalidate the fingerprint at latest after flatFileCacheDuration and reload it if the etag (and therfore the file) changed
// or continue to use the cached file if it's still valid
enableEtag: true,
// should we save the un-parsed HTTP POST/PUT payload to connection.rawConnection.params.rawBody?
saveRawBody: false,
// How many times should we try to boot the server?
// This might happen if the port is in use by another process or the socketfile is claimed
bootAttempts: 1,
// Settings for determining the id of an http(s) request (browser-fingerprint)
fingerprintOptions: {
cookieKey: 'sessionID',
toSetCookie: true,
onlyStaticElements: false,
settings: {
path: '/',
expires: 3600000
}
},
// Options to be applied to incoming file uploads.
// More options and details at https://github.com/felixge/node-formidable
formOptions: {
uploadDir: os.tmpdir(),
keepExtensions: false,
maxFieldsSize: 1024 * 1024 * 100
},
// Should we pad JSON responses with whitespace to make them more human-readable?
// set to null to disable
padding: 2,
// Options to configure metadata in responses
metadataOptions: {
serverInformation: true,
requesterInformation: true
},
// When true, returnErrorCodes will modify the response header for http(s) clients if connection.error is not null.
// You can also set connection.rawConnection.responseHttpCode to specify a code per request.
returnErrorCodes: true,
// should this node server attempt to gzip responses if the client can accept them?
// this will slow down the performance of actionhero, and if you need this funcionality, it is recommended that you do this upstream with nginx or your load balancer
compress: false,
// options to pass to the query parser
// learn more about the options @ https://github.com/hapijs/qs
queryParseOptions: {}
}
}
}
}
exports.production = {
servers: {
web: (api) => {
return {
padding: null,
metadataOptions: {
serverInformation: false,
requesterInformation: false
}
}
}
}
}
exports.test = {
servers: {
web: (api) => {
return {
secure: false,
port: process.env.PORT || 1000 + (process.pid % 64535),
matchExtensionMime: true,
metadataOptions: {
serverInformation: true,
requesterInformation: true
}
}
}
}
}

View file

@ -0,0 +1,62 @@
'use strict'
// Note that to use the websocket server, you also need the web server enabled
exports['default'] = {
servers: {
websocket: (api) => {
return {
enabled: true,
// you can pass a FQDN (string) here or 'window.location.origin'
clientUrl: 'window.location.origin',
// Directory to render client-side JS.
// Path should start with "/" and will be built starting from api.config..general.paths.public
clientJsPath: 'javascript/',
// the name of the client-side JS file to render. Both `.js` and `.min.js` versions will be created
// do not include the file exension
// set to `undefined` to not render the client-side JS on boot
clientJsName: 'ActionheroWebsocketClient',
// should the server signal clients to not reconnect when the server is shutdown/reboot
destroyClientsOnShutdown: false,
// websocket Server Options:
server: {
// authorization: null,
// pathname: '/primus',
// parser: 'JSON',
// transformer: 'websockets',
// plugin: {},
// timeout: 35000,
// origins: '*',
// methods: ['GET','HEAD','PUT','POST','DELETE','OPTIONS'],
// credentials: true,
// maxAge: '30 days',
// exposed: false,
},
// websocket Client Options:
client: {
apiPath: '/api' // the api base endpoint on your actionhero server
// reconnect: {},
// timeout: 10000,
// ping: 25000,
// pong: 10000,
// strategy: "online",
// manual: false,
// websockets: true,
// network: true,
// transport: {},
// queueSize: Infinity,
}
}
}
}
}
exports['test'] = {
servers: {
websocket: (api) => {
return { clientUrl: null }
}
}
}

View file

@ -0,0 +1,61 @@
exports['default'] = {
tasks: (api) => {
return {
// Should this node run a scheduler to promote delayed tasks?
scheduler: false,
// what queues should the taskProcessors work?
queues: ['*'],
// Logging levels of task workers
workerLogging: {
failure: 'error', // task failure
success: 'info', // task success
start: 'info',
end: 'info',
cleaning_worker: 'info',
poll: 'debug',
job: 'debug',
pause: 'debug',
internalError: 'error',
multiWorkerAction: 'debug'
},
// Logging levels of the task scheduler
schedulerLogging: {
start: 'info',
end: 'info',
poll: 'debug',
enqueue: 'debug',
reEnqueue: 'debug',
working_timestamp: 'debug',
transferred_job: 'debug'
},
// how long to sleep between jobs / scheduler checks
timeout: 5000,
// at minimum, how many parallel taskProcessors should this node spawn?
// (have number > 0 to enable, and < 1 to disable)
minTaskProcessors: 0,
// at maximum, how many parallel taskProcessors should this node spawn?
maxTaskProcessors: 0,
// how often should we check the event loop to spawn more taskProcessors?
checkTimeout: 500,
// how many ms would constitue an event loop delay to halt taskProcessors spawning?
maxEventLoopDelay: 5,
// When we kill off a taskProcessor, should we disconnect that local redis connection?
toDisconnectProcessors: true,
// Customize Resque primitives, replace null with required replacement.
resque_overrides: {
queue: null,
multiWorker: null,
scheduler: null
}
}
}
}
exports.test = {
tasks: (api) => {
return {
timeout: 100,
checkTimeout: 50
}
}
}

View file

@ -0,0 +1,32 @@
'use strict'
const {Initializer, api} = require('actionhero')
const next = require('next')
module.exports = class NextInitializer extends Initializer {
constructor () {
super()
this.name = 'next'
}
async initialize () {
api.next = {
render: async (connection) => {
if (connection.type !== 'web') { throw new Error('Connections for NEXT apps must be of type "web"') }
const req = connection.rawConnection.req
const res = connection.rawConnection.res
return api.next.handle(req, res)
}
}
api.next.dev = (api.env === 'development')
if (api.next.dev) { api.log('Running next in development mode...') }
api.next.app = next({dev: api.next.dev})
api.next.handle = api.next.app.getRequestHandler()
await api.next.app.prepare()
}
async stop () {
await api.next.app.close()
}
}

View file

@ -0,0 +1,34 @@
{
"actionhero": {
"welcomeMessage": "Hello! Welcome to the actionhero api",
"goodbyeMessage": "Bye!",
"cache": {
"objectNotFound": "Object not found",
"objectLocked": "Object locked",
"objectExpired": "Object expired"
},
"errors": {
"invalidParams": "validation error",
"missingParams": "{{param}} is a required parameter for this action",
"unknownAction": "unknown action or invalid apiVersion",
"unsupportedServerType": "this action does not support the {{type}} connection type",
"serverShuttingDown": "the server is shutting down",
"tooManyPendingActions": "you have too many pending requests",
"dataLengthTooLarge": "data length is too big ({{maxLength}} received/{{receivedLength}} max)",
"fileNotFound": "That file is not found",
"fileNotProvided": "file is a required param to send a file",
"fileReadError": "error reading file: {{error}}",
"verbNotFound": "I do not know know to perform this verb ({{verb}})",
"verbNotAllowed": "verb not found or not allowed ({{verb}})",
"connectionRoomAndMessage": "both room and message are required",
"connectionNotInRoom": "connection not in this room ({{room}})",
"connectionAlreadyInRoom": "connection already in this room ({{room}})",
"connectionRoomHasBeenDeleted": "this room has been deleted",
"connectionRoomNotExist": "room does not exist",
"connectionRoomExists": "room exists",
"connectionRoomRequired": "a room is required"
}
},
"Your random number is {{number}}": "Your random number is {{number}}",
"Node Healthy": "Node Healthy"
}

View file

@ -0,0 +1,17 @@
{
"engines": {
"node": ">=8.0.0"
},
"dependencies": {
"actionhero": "^18.1.2",
"ioredis": "^3.2.2",
"isomorphic-fetch": "^2.2.1",
"next": "^5.0.1-canary.9",
"react": "^16.1.1",
"react-dom": "^16.1.1",
"ws": "^4.1.0"
},
"scripts": {
"start": "actionhero start"
}
}

View file

@ -0,0 +1 @@
export default () => <div>a</div>

View file

@ -0,0 +1 @@
export default () => <div>b</div>

View file

@ -0,0 +1,9 @@
import React from 'react'
import Link from 'next/link'
export default () => (
<ul>
<li><Link href='/b' as='/a'><a>a</a></Link></li>
<li><Link href='/a' as='/b'><a>b</a></Link></li>
</ul>
)