Skip to content

Commit

Permalink
create fuse app (#54)
Browse files Browse the repository at this point in the history
* create fuse app

* add buildies

* transform next config file

* Update tsconfig in create-fuse-app

* tweak packagejson

* consider module exports

* add todo

* add api

* anotehr todo

* nested call

* safeguard duplicate import

* Handle vscode config in create-fuse-app

* add build

* add todo

* finishing touches

* tagline

* remove todo

* tweak colors

* tweak messaging

* tweak more

* add update to docs

* add docs

---------

Co-authored-by: Bogdan Soare <[email protected]>
  • Loading branch information
JoviDeCroock and bogdansoare authored Dec 6, 2023
1 parent f34c080 commit 1503183
Show file tree
Hide file tree
Showing 11 changed files with 842 additions and 18 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "fuse",
"private": true,
"scripts": {
"build": "pnpm --filter fuse build",
"build": "pnpm --filter fuse build && pnpm --filter create-fuse-app build",
"dev": "pnpm --filter @fuse-examples/spacex dev",
"prepare": "husky install && pnpm build",
"website": "pnpm --filter @fuse/website dev"
Expand Down
21 changes: 21 additions & 0 deletions packages/create-fuse-app/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) Stellate

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
16 changes: 16 additions & 0 deletions packages/create-fuse-app/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Create-Fuse-App

Get started with your datalayer!

When running this in an existing `next` project it will automatically generate all the needed files to get
started with [`fuse`](https://fusejs.org/)

```sh
npx create-fuse-app
## or
npm init fuse-app
## or
yarn create fuse-app
## or
pnpm create fuse-app
```
42 changes: 42 additions & 0 deletions packages/create-fuse-app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"name": "create-fuse-app",
"version": "0.1.0",
"description": "The magical GraphQL framework",
"homepage": "https://github.com/StellateHQ/fuse.js",
"bugs": "https://github.com/StellateHQ/fuse.js/issues",
"license": "MIT",
"author": "Stellate engineering <[email protected]>",
"keywords": [],
"bin": "./dist/index.js",
"type": "module",
"files": [
"dist",
"LICENSE",
"README.md"
],
"repository": {
"type": "git",
"url": "https://github.com/StellateHQ/fuse.js",
"directory": "packages/create-fuse-app"
},
"scripts": {
"build": "tsup",
"prepublishOnly": "tsup"
},
"dependencies": {
"@babel/core": "^7.23.5",
"@clack/prompts": "^0.7.0",
"kolorist": "^1.8.0",
"pkg-install": "^1.0.0"
},
"devDependencies": {
"@types/node": "^20.10.3",
"tsup": "^7.2.0",
"type-fest": "^4.8.3",
"typescript": "^5.3.2"
},
"publishConfig": {
"access": "public",
"provenance": true
}
}
275 changes: 275 additions & 0 deletions packages/create-fuse-app/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
#!/usr/bin/env node
import { promises as fs, existsSync } from 'node:fs'
import { resolve } from 'node:path'
import * as prompts from '@clack/prompts'
import { install } from 'pkg-install'
import babel from '@babel/core'
import * as kl from 'kolorist'
import { type PackageJson, TsConfigJson } from 'type-fest'
import rewriteNext from './rewrite-next'

const s = prompts.spinner()

async function createFuseApp() {
const packageManager = /yarn/.test(process.env.npm_execpath || '')
? 'yarn'
: 'npm'

prompts.intro(kl.trueColor(219, 254, 1)('Fuse - Your new datalayer'))

// TODO: we can prompt for the name of the dir in the future
// when we make this work standalone
const targetDir = resolve(process.cwd())

const packageJson = await fs.readFile(
resolve(targetDir, 'package.json'),
'utf-8',
)
const { dependencies, devDependencies } = JSON.parse(
packageJson,
) as PackageJson
const allDeps = { ...dependencies, ...devDependencies }
const nextVersion = allDeps['next']

if (!nextVersion) {
throw new Error(
'Could not find "next" as a dependency in your package.json. Please install Next.js first.',
)
}

s.start('Installing fuse...')
await install(['fuse'], {
prefer: packageManager,
cwd: targetDir,
dev: false,
})
await install(['@0no-co/graphqlsp', '@graphql-typed-document-node/core'], {
prefer: packageManager,
cwd: targetDir,
dev: true,
})
s.stop(kl.green('Installed fuse!'))

// Create initial types and API-Route
s.start('Creating API Route...')
const isUsingSrc = existsSync(resolve(targetDir, 'src'))
const shouldUseAppDir = existsSync(resolve(targetDir, 'app'))
const apiRouteSnippet = createSnippet(shouldUseAppDir)

if (isUsingSrc) {
const dir = shouldUseAppDir
? resolve(targetDir, 'src', 'app', 'api', 'fuse', 'route.ts')
: resolve(targetDir, 'src', 'pages', 'api', 'fuse.ts')

if (shouldUseAppDir) {
await fs.mkdir(resolve(targetDir, 'src', 'app', 'api'))
await fs.mkdir(resolve(targetDir, 'src', 'app', 'api', 'fuse'))
} else {
await fs.mkdir(resolve(targetDir, 'src', 'pages', 'api'))
}
await fs.writeFile(dir, apiRouteSnippet)
await fs.mkdir(resolve(targetDir, 'src', 'types'))
await fs.writeFile(
resolve(targetDir, 'src', 'types', 'User.ts'),
initialTypeSnippet,
)
} else {
const dir = shouldUseAppDir
? resolve(targetDir, 'app', 'api', 'fuse', 'route.ts')
: resolve(targetDir, 'pages', 'api', 'fuse.ts')
if (shouldUseAppDir) {
await fs.mkdir(resolve(targetDir, 'app', 'api'))
await fs.mkdir(resolve(targetDir, 'app', 'api', 'fuse'))
} else {
await fs.mkdir(resolve(targetDir, 'pages', 'api'))
}
await fs.writeFile(dir, apiRouteSnippet)
await fs.mkdir(resolve(targetDir, 'types'))
await fs.writeFile(
resolve(targetDir, 'types', 'User.ts'),
initialTypeSnippet,
)
}
s.stop(kl.green('Created API Route!'))

// Add next plugin to config
s.start('Adding Fuse plugin to Next config...')
const hasJsConfig = existsSync(resolve(targetDir, 'next.config.js'))
const hasMjsConfig = existsSync(resolve(targetDir, 'next.config.mjs'))

if (hasJsConfig) {
try {
const code = await fs.readFile(
resolve(targetDir, 'next.config.js'),
'utf-8',
)
const result = await babel.transformAsync(code, {
plugins: [[rewriteNext, { isMjs: false }]],
})
if (result.code) {
await fs.writeFile(
resolve(targetDir, 'next.config.js'),
result.code,
'utf-8',
)
}
} catch (e) {}
} else if (hasMjsConfig) {
try {
const code = await fs.readFile(
resolve(targetDir, 'next.config.mjs'),
'utf-8',
)
const result = await babel.transformAsync(code, {
plugins: [[rewriteNext, { isMjs: true }]],
})
if (result.code) {
await fs.writeFile(
resolve(targetDir, 'next.config.js'),
result.code,
'utf-8',
)
}
} catch (e) {}
} else {
prompts.text({
message:
'No next config found, you can add the fuse plugin yourself by importing it from "fuse/next/plugin"!',
})
}

if (existsSync(resolve(targetDir, '.vscode', 'settings.json'))) {
const vscodeSettingsFile = await fs.readFile(
resolve(targetDir, '.vscode', 'settings.json'),
'utf-8',
)
const vscodeSettings = JSON.parse(vscodeSettingsFile)

if (
vscodeSettings['typescript.tsdk'] !== 'node_modules/typescript/lib' ||
vscodeSettings['typescript.enablePromptUseWorkspaceTsdk'] !== true
) {
await fs.writeFile(
resolve(targetDir, '.vscode', 'settings.json'),
JSON.stringify(generateVscodeSettings(vscodeSettings), undefined, 2),
'utf-8',
)
}
} else {
await fs.mkdir(resolve(targetDir, '.vscode'))
await fs.writeFile(
resolve(targetDir, '.vscode', 'settings.json'),
JSON.stringify(generateVscodeSettings(), undefined, 2),
'utf-8',
)
}

const tsConfigFile = await fs.readFile(
resolve(targetDir, 'tsconfig.json'),
'utf-8',
)
const tsConfig = JSON.parse(tsConfigFile) as TsConfigJson
if (
!tsConfig.compilerOptions?.plugins?.find(
(plugin) => plugin.name === '@0no-co/graphqlsp',
)
) {
const updatedTsConfig = {
...tsConfig,
compilerOptions: {
...tsConfig.compilerOptions,
plugins: [
...(tsConfig.compilerOptions?.plugins || []),
{
name: '@0no-co/graphqlsp',
schema: './schema.graphql',
disableTypegen: true,
templateIsCallExpression: true,
template: 'graphql',
},
],
},
}
await fs.writeFile(
resolve(targetDir, 'tsconfig.json'),
JSON.stringify(updatedTsConfig, undefined, 2),
'utf-8',
)
}
s.stop(kl.green('Added Fuse plugin to next config!'))
prompts.outro(
kl.trueColor(219, 254, 1)("You're all set to work with your datalayer!"),
)
}

createFuseApp().catch(console.error)

const initialTypeSnippet = `import { node } from 'fuse'
type UserSource = {
id: string
name: string
avatar_url: string
}
// "Nodes" are the core abstraction of Fuse.js. Each node represents
// a resource/entity with multiple fields and has to define two things:
// 1. load(): How to fetch from the underlying data source
// 2. fields: What fields should be exposed and added for clients
export const UserNode = node<UserSource>({
name: 'User',
load: async (ids) => getUsers(ids),
fields: (t) => ({
name: t.exposeString('name'),
// rename to camel-case
avatarUrl: t.exposeString('avatar_url'),
// Add an additional firstName field
firstName: t.string({
resolve: (user) => user.name.split(' ')[0],
}),
}),
})
// Fake function to fetch users. In real applications, this would
// talk to an underlying REST API/gRPC service/third-party API/…
async function getUsers(ids: string[]): Promise<UserSource[]> {
return ids.map((id) => ({
id,
name: \`Peter #\${id}\`,
avatar_url: \`https://i.pravatar.cc/300?u=\${id}\`,
}))
}`

const createSnippet = (appDir) => `import { ${
appDir ? 'createAPIRouteHandler' : 'createPagesRouteHandler'
} } from 'fuse/next'
// NOTE: This makes Fuse.js automatically pick up every type in the /types folder
// Alternatively, you can manually import each type in the /types folder and remove this snippet
// @ts-expect-error
const files = require.context(${
appDir ? "'../../../types'" : "'../../types'"
}, true, /\.ts$/)
files
.keys()
.filter((path: string) => path.includes('types/'))
.forEach(files)
const handler = ${
appDir ? 'createAPIRouteHandler' : 'createPagesRouteHandler'
}()
${
appDir
? `export const GET = handler\nexport const POST = handler`
: `export default handler`
}
`

function generateVscodeSettings(settings: any = {}) {
return {
...settings,
'typescript.tsdk': 'node_modules/typescript/lib',
'typescript.enablePromptUseWorkspaceTsdk': true,
}
}
Loading

1 comment on commit 1503183

@vercel
Copy link

@vercel vercel bot commented on 1503183 Dec 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

spacex-fuse – ./examples/spacex

spacex-fuse-git-main-stellate.vercel.app
spacex-fuse-stellate.vercel.app
spacex-fuse.vercel.app

Please sign in to comment.