diff --git a/.github/workflows/BuildFun.yml b/.github/workflows/BuildFun.yml
index a2f7c80..136cb03 100644
--- a/.github/workflows/BuildFun.yml
+++ b/.github/workflows/BuildFun.yml
@@ -2,91 +2,8 @@
name: Build Fun
on:
push:
- pull_request:
workflow_dispatch:
jobs:
- PowerShellStaticAnalysis:
- runs-on: ubuntu-latest
- steps:
- - name: InstallScriptCop
- id: InstallScriptCop
- shell: pwsh
- run: |
- [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
- Install-Module -Name ScriptCop -Repository PSGallery -Force -Scope CurrentUser
- Import-Module ScriptCop -Force -PassThru
- - name: InstallPSScriptAnalyzer
- id: InstallPSScriptAnalyzer
- shell: pwsh
- run: |
- [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
- Install-Module -Name PSScriptAnalyzer -Repository PSGallery -Force -Scope CurrentUser
- Import-Module PSScriptAnalyzer -Force -PassThru
- - name: InstallPSDevOps
- id: InstallPSDevOps
- shell: pwsh
- run: |
- [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
- Install-Module -Name PSDevOps -Repository PSGallery -Force -Scope CurrentUser
- Import-Module PSDevOps -Force -PassThru
- - name: Check out repository
- uses: actions/checkout@v2
- - name: RunScriptCop
- id: RunScriptCop
- shell: pwsh
- run: |
- $Parameters = @{}
- $Parameters.ModulePath = ${env:ModulePath}
- foreach ($k in @($parameters.Keys)) {
- if ([String]::IsNullOrEmpty($parameters[$k])) {
- $parameters.Remove($k)
- }
- }
- Write-Host "::debug:: RunScriptCop $(@(foreach ($p in $Parameters.GetEnumerator()) {'-' + $p.Key + ' ' + $p.Value}) -join ' ')"
- & {param([string]$ModulePath)
- Import-Module ScriptCop, PSDevOps -PassThru | Out-Host
-
- if (-not $ModulePath) {
- $orgName, $moduleName = $env:GITHUB_REPOSITORY -split "/"
- $ModulePath = ".\$moduleName.psd1"
- }
- if ($ModulePath -like '*PSDevOps*') {
- Remove-Module PSDeVOps # If running ScriptCop on PSDeVOps, we need to remove the global module first.
- }
-
-
- $importedModule =Import-Module $ModulePath -Force -PassThru
-
- $importedModule | Out-Host
-
- $importedModule |
- Test-Command |
- Tee-Object -Variable scriptCopIssues |
- Out-Host
-
- foreach ($issue in $scriptCopIssues) {
- Write-GitHubWarning -Message "$($issue.ItemWithProblem): $($issue.Problem)"
- }
- } @Parameters
- - name: RunPSScriptAnalyzer
- id: RunPSScriptAnalyzer
- shell: pwsh
- run: |
- Import-Module PSScriptAnalyzer, PSDevOps -PassThru | Out-Host
- $invokeScriptAnalyzerSplat = @{Path='.\'}
- if ($ENV:PSScriptAnalyzer_Recurse) {
- $invokeScriptAnalyzerSplat.Recurse = $true
- }
- $result = Invoke-ScriptAnalyzer @invokeScriptAnalyzerSplat
-
- foreach ($r in $result) {
- if ('information', 'warning' -contains $r.Severity) {
- Write-GitHubWarning -Message "$($r.RuleName) : $($r.Message)" -SourcePath $r.ScriptPath -LineNumber $r.Line -ColumnNumber $r.Column
- }
- elseif ($r.Severity -eq 'Error') {
- Write-GitHubError -Message "$($r.RuleName) : $($r.Message)" -SourcePath $r.ScriptPath -LineNumber $r.Line -ColumnNumber $r.Column
- }
- }
TestPowerShellOnLinux:
runs-on: ubuntu-latest
steps:
@@ -124,7 +41,6 @@ jobs:
run: |
$Parameters = @{}
$Parameters.ModulePath = ${env:ModulePath}
- $Parameters.PesterMaxVersion = ${env:PesterMaxVersion}
$Parameters.NoCoverage = ${env:NoCoverage}
$Parameters.NoCoverage = $parameters.NoCoverage -match 'true';
foreach ($k in @($parameters.Keys)) {
@@ -143,9 +59,6 @@ jobs:
# The module path. If not provided, will default to the second half of the repository ID.
[string]
$ModulePath,
- # The Pester max version. By default, this is pinned to 4.99.99.
- [string]
- $PesterMaxVersion = '4.99.99',
# If set, will not collect code coverage.
[switch]
@@ -573,6 +486,31 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@main
+ - name: PreviewFun
+ id: PreviewFun
+ shell: pwsh
+ run: |
+ <#
+ .SYNOPSIS
+ Preview Fun
+ .DESCRIPTION
+
+ #>
+ if ($PSScriptRoot -notmatch '/_temp') {
+ Push-Location ($PSScriptRoot | Split-Path | Split-Path | Split-Path)
+ }
+ Import-Module .\Fun.psd1 -PassThru | Out-Host
+ $funFun = . .\Fun.fun.ps1
+ $funFun.Deploy("./preview")
+ if ($PSScriptRoot -notmatch '/_temp') {
+ Pop-Location
+ }
+ - name: PreviewUpload
+ uses: actions/upload-artifact@main
+ with:
+ name: FunPreview
+ path: '**/preview/**'
+ if: ${{always()}}
- name: Log in to the Container registry
if: ${{github.ref_name == 'main' || github.ref_name == 'master' || github.ref_name == 'latest'}}
uses: docker/login-action@master
diff --git a/Build/Fun.GitHubWorkflow.PSDevOps.ps1 b/Build/Fun.GitHubWorkflow.PSDevOps.ps1
index e647c58..c0a52ad 100644
--- a/Build/Fun.GitHubWorkflow.PSDevOps.ps1
+++ b/Build/Fun.GitHubWorkflow.PSDevOps.ps1
@@ -5,8 +5,7 @@ Import-BuildStep -SourcePath (
Push-Location ($PSScriptRoot | Split-Path)
-New-GitHubWorkflow -Name "Build Fun" -On Push, PullRequest, Demand -Job PowerShellStaticAnalysis,
- TestPowerShellOnLinux,
+New-GitHubWorkflow -Name "Build Fun" -On Push, Demand -Job TestPowerShellOnLinux,
TagReleaseAndPublish,
buildfun -OutputPath .\.github\workflows\BuildFun.yml -Env ([Ordered]@{
"REGISTRY" = "ghcr.io"
diff --git a/Build/GitHub/Jobs/BuildFun.psd1 b/Build/GitHub/Jobs/BuildFun.psd1
index 0b538c3..57e6930 100644
--- a/Build/GitHub/Jobs/BuildFun.psd1
+++ b/Build/GitHub/Jobs/BuildFun.psd1
@@ -6,7 +6,21 @@
name = 'Check out repository'
uses = 'actions/checkout@main'
}
+
+ 'PreviewFun'
+
+ <#@{
+ name = 'Upload Preview Artifact'
+ uses = 'actions/checkout@main'
+ with = @{
+ name = 'preview'
+ path = './preview/**'
+ }
+ }#>
+
+ 'PreviewFunUpload'
+ #>
@{
'name'='Log in to the Container registry'
if = '${{github.ref_name == ''main'' || github.ref_name == ''master'' || github.ref_name == ''latest''}}'
diff --git a/Build/GitHub/Steps/PreviewFun.ps1 b/Build/GitHub/Steps/PreviewFun.ps1
new file mode 100644
index 0000000..1ef1a15
--- /dev/null
+++ b/Build/GitHub/Steps/PreviewFun.ps1
@@ -0,0 +1,14 @@
+<#
+.SYNOPSIS
+ Preview Fun
+.DESCRIPTION
+
+#>
+if ($PSScriptRoot -notmatch '/_temp') {
+ Push-Location ($PSScriptRoot | Split-Path | Split-Path | Split-Path)
+}
+Import-Module .\Fun.psd1 -PassThru | Out-Host
+. .\Fun.fun.ps1 deploy "preview"
+if ($PSScriptRoot -notmatch '/_temp') {
+ Pop-Location
+}
\ No newline at end of file
diff --git a/Build/GitHub/Steps/PreviewFunUpload.psd1 b/Build/GitHub/Steps/PreviewFunUpload.psd1
new file mode 100644
index 0000000..b86978c
--- /dev/null
+++ b/Build/GitHub/Steps/PreviewFunUpload.psd1
@@ -0,0 +1,10 @@
+@{
+ name = 'PreviewUpload'
+ uses = 'actions/upload-artifact@main'
+ with = @{
+ name = 'FunPreview'
+ path = '**/preview/**'
+ }
+ if = '${{always()}}'
+}
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 95c2957..85a34e6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,43 @@
+# Fun CHANGELOG
+
+## Fun Server 0.2
+
+* Fun Server Improvements
+ * Not building wildcard functions ( #24 )
+ * Binary http output (#46)
+ * The Server is the `$site` ( #57 )
+ * Fun `-InitializeScript` ( #48 )
+ * `Fun -Deploy` ( #51 )
+ * `Fun -Parameter` ( #58 )
+ * Fun` Path Parameters ( #60, #61, #62 )
+ * More Fun Tests ( #60 )
+ * `Fun -Layout` ( #65 )
+ * Fun output `.ToString()` ( #53, re #24 )
+ * Fun `$headers` and `$cookies` ( #40, #41 )
+ * Fun with WebSockets and parameters ( #29, #32, #33, #34, #35, #36, #37, #38, #39 )
+ * Fun streaming optional ( #28 )
+ * Fun exact matching improvement ( #25 )
+ * Fun `$body` and json support ( #26, #27 )
+ * Fun prefers exact matches ( #25 )
+ * Fun `$method` ( #23 )
+ * Fun `POST` forms ( #22 )
+ * Fun Verb Arguments ( #66 )
+ * Fun ArgumentList passthru ( #66 )
+ * Fun container passthru ( #68 )
+* Fun Website Improvements:
+ * `/_includes/FeatherIcon` ( #30 )
+ * `/_includes/Palette` ( #44 )
+ * `/_includes/HighlightScript` ( #43 )
+ * `/_includes/HighlightColors` ( #45 )
+ * `/_includes/CopyCode` ( #31 )
+ * `layout.ps1` ( #20 )
+ * `Fun.fun.ps1` updates ( #42 )
+ * `PoshWeb.svg.ps1` ( #63 )
+ * `Fun.css.ps1` ( #54, #59 )
+ * Adding `*.*.ps1` and Markdown support ( #55, #56, #47 )
+
+---
+
## Fun Server 0.1
* `Fun` is a fun functional PowerShell server (#1)
diff --git a/Container.start.ps1 b/Container.start.ps1
index 7e4009b..a140fe9 100644
--- a/Container.start.ps1
+++ b/Container.start.ps1
@@ -37,27 +37,32 @@ function / {
if ($args) {
- # If there are arguments, output them (you could handle them in a more complex way).
+ # If there are arguments,
+
$remainingArgs = @(foreach ($arg in $args) {
- if ($arg -match '\.ps1$' -and (Test-Path $arg)) {
+ # run any `.fun.ps1` that exists
+ if ($arg -match '\.fun\.ps1$' -and (Test-Path $arg)) {
. $arg
} else {
- $arg
+ $arg
}
})
- $fun = fun "http://*/" @remainingArgs
- # Launch three replicas
- $fun.Start();$fun.Start();$fun.Start()
+ # and pass thru any remaining arguments.
+ $fun = fun @remainingArgs
+ $fun
} else {
if (Test-Path ./Fun.fun.ps1) {
- . ./Fun.fun.ps1
+ $fun = . ./Fun.fun.ps1
+ $fun | Add-Member NoteProperty Prefixes "http://*/" -Force
+ $fun.Start();$fun.Start();$fun.Start();
} else {
function / { "Hello from Fun" }
+ $fun = fun "http://*/"
+ # Launch three replicas
+ $fun.Start();$fun.Start();$fun.Start()
}
- $fun = fun "http://*/"
- # Launch three replicas
- $fun.Start();$fun.Start();$fun.Start()
+
}
diff --git a/Fun.css.ps1 b/Fun.css.ps1
new file mode 100644
index 0000000..520ce3d
--- /dev/null
+++ b/Fun.css.ps1
@@ -0,0 +1,185 @@
+<#
+.SYNOPSIS
+ Fun css
+.DESCRIPTION
+ Generates a css stylesheet for fun.
+.EXAMPLE
+ ./Fun.css.ps1 > ./Fun.css
+#>
+[OutputType('text/css')]
+param(
+# The Google Font name.
+# This will be used for most elements.
+[Alias('FontName')]
+[string]
+$Font = $(
+ if ($page.Font) { $page.Font }
+ elseif ($site.Font) { $site.Font }
+ elseif ($env:Font) { $env:Font }
+ else { 'Roboto'}
+),
+
+# The header font name
+# This will be used for `
`,`
`, `
` elements.
+[string]
+$HeaderFont = $(
+ if ($page.HeaderFont) { $page.HeaderFont }
+ elseif ($site.HeaderFont) { $site.HeaderFont }
+ elseif ($env:HeaderFont) { $env:HeaderFont }
+ else { 'Nunito Sans'}
+),
+
+# The Google Code Font name
+# This will be used for `
` elements.
+[string]
+$CodeFont = $(
+ if ($page.CodeFont) { $page.CodeFont }
+ elseif ($site.CodeFont) { $site.CodeFont }
+ elseif ($env:CodeFont) { $env:CodeFont }
+ else { 'Inconsolata' }
+)
+)
+# In this case we want a full screen body element
+"body {"
+ @(
+ "max-width:100vw"
+ "height: 100vh"
+ "margin-left: auto"
+ "margin-right: auto"
+ # which displays inner content in a grid
+ "display:grid"
+ # with autosized top and bottom and a flexibly sized middle
+ "grid-template-rows: auto, 1fr, auto"
+ # and used the font we provided (falling back to sans-serif).
+ "font-family: '$Font', sans-serif"
+ "background-image:$(
+ $alpha = "4.2%"
+ $colors = 'var(--foreground)','transparent'
+ $randomPositions = foreach ($n in 1..(Get-Random -Min 2 -Max 5)) {
+ "$(
+ Get-Random -Minimum 1 -Maximum 99
+ )% $(
+ Get-Random -Minimum 1 -Maximum 99
+ )%"
+ }
+ @(foreach ($position in $randomPositions) {
+ "repeating-radial-gradient($(@(
+ "ellipse $(
+ Get-Random -Min 40 -Max 60
+ )% $(
+ Get-Random -Min 40 -Max 60
+ )% at $position"
+ $colorNumber = 0
+ $bandSize = Get-Random -Minimum 4 -Maximum 8
+ foreach ($color in $colors) {
+ $colorNumber++
+ "color-mix(in srgb, $color $alpha, transparent) $($colorNumber * $bandSize)rem"
+ }
+ ) -join ',' + [Environment]::NewLine))"
+ }) -join (',' + [Environment]::NewLine)
+)"
+
+ ) -join ';'
+"}"
+
+# Main content we want to "nudge in" a bit.
+".main { width: 80%; margin-left:auto; margin-right: auto }"
+
+# `h1`, `h2`, `h3` are centered with slight font size and line height adjustments
+"header h1, header h2, header h3 { text-align: center; font-family: '$HeaderFont', sans-serif; letter-spacing: 0.1rem; }"
+
+"h1 { text-align: center; font-size: 4rem; line-height: 5rem}"
+"h2 { text-align: center; font-size: 2rem; line-height: 3rem}"
+"h3 { font-size: 1.5rem; line-height: 2rem }"
+
+# `footer`
+"footer {"
+ @(
+ "margin-top: 2vh;" # has a vertical margin to make it stand out.
+ ) -join ';'
+ "ul li { list-style-type: none; display: inline; }"
+"}"
+
+".fixed { position: fixed; }"
+".bottom { bottom: 0 }"
+".top { top: 0 }"
+".left { left: 0 }"
+".right { right: 0 }"
+".end { margin-bottom: 3rem }"
+
+"button { border-radius: 4.2%; }"
+"select { border-radius: 4.2% }"
+
+# `footer menu`
+"footer menu {"
+ @(
+ # display as a grid
+ "display: grid"
+ "grid-template-columns: repeat(auto-fit, minmax(200px, 1fr))"
+ "text-align: center"
+ "place-items: center"
+ "justify-items: center"
+ "justify-content: space-between"
+ ) -join ';'
+"}"
+
+
+
+# `a` anchors do not get text decoration
+"a, a:visited { text-decoration: none; }"
+# unless they are focused or hovered
+"a:hover, a:focus {text-decoration: underline;}"
+
+# `select` and `button` should hover
+"select:hover, button:hover { cursor: pointer }"
+"* { box-sizing: border-box }"
+
+# The `header` should be a fixed grid
+"header {"
+ "position: fixed",
+ "display: grid",
+ "grid-template-areas: '$("header-left header-middle header-right")'",
+ "grid-template-columns: auto 1fr auto",
+ "padding: 0.5rem",
+ "top:0",
+ "width: 100%" -join ';'
+"}"
+
+"p {line-height: 1.5rem }"
+
+"@keyframes grow-progress { from { transform: scaleX(0); } to { transform: scaleX(1); } }"
+# Arrange the various headers
+".header-left{ grid-area: header-left; text-align: left; }"
+".header-middle { grid-area: header-middle; flex:1; text-align: center; }"
+".header-right { grid-area: header-right; text-align: right; margin-left: auto; }"
+".scroll-progress {"
+ "grid-area: header-middle",
+ "width: 100%",
+ "height: 1rem",
+ "margin-top: auto",
+ "margin-bottom: auto",
+ "transform-origin: 0 50%",
+ "background: linear-gradient(to right, transparent, var(--foreground))",
+ "animation: grow-progress auto linear",
+ "animation-timeline: scroll()" -join ';'
+"}"
+
+# Render code and pre elements in our code font, fall back to monospace.
+"pre, code { font-family: '$CodeFont', monospace; }"
+"code { padding: 0.5rem }"
+
+".adknowledge {"
+ @(
+ "font-size: 0.9rem"
+ "margin-bottom: 1rem"
+ "text-align: center"
+ "display: grid"
+ "place-items: center"
+ "height: 4.2rem"
+ ) -join ';'
+"}"
+".adknowledge svg { height: 4.2rem }"
+
+# Include our highlight colors.
+/_includes/HighlightColors
+
diff --git a/Fun.fun.ps1 b/Fun.fun.ps1
index 2116ac8..9a26b10 100644
--- a/Fun.fun.ps1
+++ b/Fun.fun.ps1
@@ -1,3 +1,4 @@
+#requires -Module Fun
# Fun Website
if (-not $ExecutionContext.SessionState.InvokeCommand.GetCommand('layout', 'Alias')) {
@@ -7,14 +8,10 @@ if (-not $ExecutionContext.SessionState.InvokeCommand.GetCommand('layout', 'Alia
""
''
"$([Web.HttpUtility]::HtmlEncode("$title"))"
- ""
""
""
@@ -24,7 +21,69 @@ if (-not $ExecutionContext.SessionState.InvokeCommand.GetCommand('layout', 'Alia
""
) -join "`n"
}
+}
+
+if ($PSScriptRoot) {
+ $layoutPath = Join-Path $PSScriptRoot layout.ps1
+ if (Test-Path $layoutPath) {
+ Set-Alias Layout $layoutPath
+ }
+ Push-Location $PSScriptRoot
+}
+
+#region Markdown Files
+# Next up are Markdown files
+$markdownFiles = @(Get-ChildItem -Path $PSScriptRoot -Filter *.md -File -Recurse)
+foreach ($markdownFile in
+ $markdownFiles) {
+
+ # We _could_ create a function that reads the markdown
+ # But it will be faster to create a function that has already read the markdown.
+
+ # Convert the contents from markdown
+ $markdown = Get-Content $markdownFile.FullName -Raw |
+ ConvertFrom-Markdown
+
+ # and determine the right relative path
+ $relativeName = $markdownFile.FullName.Substring(
+ $PSScriptRoot.Length
+ ) -replace '\.md$' -replace '[\\/]', '/'
+
+ # and the name of the markdown
+ $markdownName = $markdownFile.Name -replace '\.md$'
+
+ # Our function name is our relative path
+ $functionName = "$relativeName"
+
+ # We will use the function provider to create the function
+ # so we need to predetermine the function name.
+ $functionPath = "function:/$functionName"
+
+ # And then magically create a script
+ $scriptLines = @(
+ # Set a title
+ "`$title = '$($markdownName -replace
+ '-','\s' -replace
+ "'","''"
+ )'"
+ # embed the html and pass it to layout
+ "'$($markdown.Html -replace "'","''")'"
+ )
+
+ # Create the function
+ $ExecutionContext.SessionState.PSVariable.Set(
+ $functionPath,
+ ($scriptLines -join [Environment]::NewLine)
+ )
+ # and set an /index.html alias
+ Set-Alias "$functionName/index.html" $functionName
+}
+#endregion Markdown Files
+$includesPath = Join-Path $PSScriptRoot _includes
+
+foreach ($include in Get-ChildItem -Path $includesPath -Filter *.ps1) {
+ Set-Alias "/_includes/$($include.Name -replace '\.ps1$')" $include.FullName
}
function / {
@@ -37,55 +96,142 @@ function / {
[OutputType('text/html')]
param()
-$title = "Fun Server $(Get-Module Fun | Select-Object -ExpandProperty Version)"
+$funModule = Get-Module Fun
+$title = "Fun Server $($funModule | Select-Object -ExpandProperty Version)"
+$markdown = @(
@"
+# $($funModule.Name)
+## $($funModule.Description)
+
+Fun is a fun functional server, written in PowerShell.
+
+Any function named with `/` is a server function.
+
+This makes servers incredibly simple.
+
+_Your function is your server_.
+
+Let's write Hello World:
+$(. /_includes/HighlightScript {function / {"Hello World"}})
+
+If we wanted to return a different content type, we can use the `[OutputType]` attribute.
+
+$(. /_includes/HighlightScript {function / {
+ [OutputType("text/plain")]
+ param()
+ "Hello World"
+}})
+
+This approach makes PowerShell web development simple and fun.
+
+We can use Fun to make static and dynamic websites.
+
+### Static Fun
+
+Static sites don't have to be set in stone.
+
+We can make static sites in fun simply by naming functions with an extension.
+$(. /_includes/HighlightScript {function /index.html {
+ $message = 'This is a website', 'Hello World' | Get-Random
+ "
$message
"
+}})
+
+To "build" our page, we can run our function and redirect the output
+
+$(. /_includes/HighlightScript {/index.html > ./index.html})
+
+To learn more, see [Fun Websites](/fun/website)
+
+### Fun Fun
+
+Fun's website is built in Fun.
+
+The source code is in [`/Fun.fun.ps1`](/Fun.fun.ps1)
+
+For example, the [current request](/) is handled by the function `/`:
+"@
+)
+
+@(
+$markdown -join [Environment]::NewLine |
+ ConvertFrom-Markdown |
+ Select-Object -ExpandProperty html
-
+ "It outputs an object that serves functions and aliases named */*"
-
-Fun is defined in a single PowerShell script:
-
+ "It is cross-platform and works out of the box in PowerShell Core"
-
+ "When we run the script, we return a server object. This is used to route functions and keep server state"
+
+ "We can run this script with the argument start to start our server"
+
+ "Or we can (./Fun.ps1).Start() our server"
- Fun.ps1
+ "When requests come in, we route them. We find the right function and call it"
+) | p
-
"
+""
+)
}
function /get/command {
@@ -106,7 +252,7 @@ function /get/command {
}
""
""
- ) | Layout
+ )
}
Set-Alias /index.html /
@@ -120,6 +266,11 @@ function /fun/state {
}
function /fun/website {
+ $title = 'Fun Websites'
+
+ filter p { "
$_$args
" }
+ @(
+
"
"
"Fun Websites"
"
"
@@ -127,79 +278,114 @@ function /fun/website {
"Static Websites with Fun"
""
- "
"
- "In a dynamic site, we run our functions on demand"
- "
"
+ p "In a dynamic site, we run our functions on demand"
+
+ p "To make a static site, we can just run our functions save their output to a file"
+
+ /_includes/HighlightScript {/ > ./index.html}
+
+ p "To make this easier, fun includes two methods: .Build() and .Deploy()"
+
+ p "Build runs every function named *.*, and gives you their output as text"
+
+ /_includes/HighlightScript {
+ # Fun .Build()
+ (fun).Build()
+ }
+
+ p "Since Build is an approved verb, we can also"
+
+ /_includes/HighlightScript {
+ # Build-Fun
+ Build-Fun
+ }
+
+ p "We can also .Deploy() a build."
- "
"
- "To make a static site, we can just run our fun once and save it to a file"
- "
"
+ p "This will run the .Build() and write all the files to disk, beneath the current directory."
- "
"
+ p "This will Deploy the content as a static site"
+
+ /_includes/HighlightScript {
+ # Deploy-Fun will build and deploy any *.* file.
+ # Existing files will be overwritten.
+ Deploy-Fun
+ }
- "
"
+ . /_includes/HighlightScript $site.Define
}
-Set-Alias /fun/security/index.html /fun/security
-
-function /fun/contributing {
- "$((ConvertFrom-Markdown -LiteralPath (
- Get-Module Fun | Split-Path | Join-Path -ChildPath "contributing.md"
- )).Html)"|
- Layout
+function /fun/website/code {
+ [OutputType('text/plain')]
+ param()
+ "$($site.Define)"
}
-Set-Alias /fun/contributing/index.html /fun/contributing
+Set-Alias /fun/website.ps1 /fun/website/code
+
+# We can turn a number of files into endpoints
+# First up is the easy case of `*.*.ps1` files
+foreach ($fileToMount in
+ Get-ChildItem -Recurse -File -Filter *.*.ps1 -Path $PSScriptRoot) {
+
+ # Skip any *.*.ps1 that does not have a web-friendly implied extension.
+ if ($fileToMount.Name -notmatch '\.(?>html|css|json|js|svg)\.ps1$') {
+ continue
+ }
+
+ # Get the relative path
+ $relativePath =
+ $fileToMount.FullName.Substring($PSScriptRoot.Length) -replace
+ '\.ps1$' -replace '[\\/]','/'
+
+ # Set an alias from our relative path to our file
+ Set-Alias $relativePath $fileToMount.FullName
-function /fun/code-of-conduct {
- ConvertFrom-Markdown -LiteralPath (
- Get-Module Fun | Split-Path | Join-Path -ChildPath "code_of_conduct.md"
- ) |
- Select-Object -ExpandProperty Html |
- Layout
+ # If the file was an index, also mount to the base path
+ if ($relativePath -match '/index[^/]+$') {
+ Set-Alias ($relativePath -replace '/index[^/]+$') $fileToMount.FullName
+ }
}
-Set-Alias /fun/code-of-conduct/index.html /fun/code-of-conduct
-function /get/fun {
- [OutputType('text/plain')]
+Set-Alias /main.css /Fun.css
+
+function /get/fun.ps1 {
+ [OutputType('text/x-powershell')]
param()
Get-Command Get-Fun |
Select-Object -ExpandProperty ScriptBlock
}
-Set-Alias /get/fun.ps1 /get/fun
function /404 {
@(
"
404
"
"
"
- ) | Layout
+ )
}
+Set-Alias /404.html /404
+
+. Fun -Parameter @{
+ PaletteName = 'AdventureTime', 'Andromeda', 'Konsolas', 'Popping-and-Locking', 'Wez' | Get-Random
+ AnalyticsId = 'G-HLWZJJGDCP'
+ Layout = $ExecutionContext.SessionState.InvokeCommand.GetCommand('layout','Alias').ResolvedCommand.ScriptBlock
+} @args
+
+if ($PSScriptRoot) {
+ Pop-Location
+}
# $fun
# $fun = 1..10 | ./fun.ps1 some fun args
diff --git a/Fun.ps1 b/Fun.ps1
index 723295f..31d223f 100644
--- a/Fun.ps1
+++ b/Fun.ps1
@@ -28,7 +28,6 @@
This allows for fun interations between the browser and the terminal.
.NOTES
- .NOTES
This is a fun experimental server in PowerShell.
It is build atop a design pattern:
@@ -67,11 +66,16 @@
For these reasons, we want to run `Fun` locally on a random loopback port,
or in a container with a constrained list of commands.
- We also want to avoid code injection at all costs,
- and only expose safe commands.
+ We also want to avoid code injection at all costs, and only expose safe commands.
+
+ Fun also allows you to redefine how it serves content and outputs results.
+
+ You should not need to do this in most scenarios, but it can be fun to mod an engine.
+
+ Fun can run isolated when provided an -InitializeScript file.
.EXAMPLE
# Hello World server
- / { "
hello world
" }
+ function / { "
hello world
" }
Start-Fun
.EXAMPLE
@@ -91,10 +95,10 @@
Start-Fun
- Pop-Location
+ Pop-Location
#>
[CmdletBinding(PositionalBinding=$false)]
-[Alias('Start-Fun')]
+[Alias('Start-Fun','Deploy-Fun','Build-Fun')]
param(
# A list of any arguments.
# If an argument starts with `https?://`,
@@ -108,13 +112,481 @@ param(
$ArgumentList,
# Any Input Object.
-
# This is currently passed on directly to a server instance.
# Any function can reference this input with `$this.Input`
[Parameter(ValueFromPipeline)]
[Alias('Input')]
[PSObject]
-$InputObject
+$InputObject,
+
+# If set, will not allow websocket requests.
+# Requests to websockets will return 405 - Method Not Allowed.
+[switch]
+$NoWebSocket,
+
+# If set, will stream responses.
+# This will stream output from running commands
+[switch]
+$Stream,
+
+# The size of websocket buffers.
+# This is the maximum size of a message sent to a websocket.
+[uint32]
+$BufferSize = 64kb,
+
+# The depth used to serialize websocket responses to json.
+[ValidateRange(1,100)]
+[byte]
+$JsonDepth = 5,
+
+# Any site wide parameters or metadata.
+# If any keys match parameter names, will attempt to set the parameter.
+[Alias('Parameters','Metadata')]
+[Collections.IDictionary]
+$Parameter,
+
+# The Script Block used to route requests
+# This accepts a url as the first argument,
+# and all other arguments as functions.
+# It should return the function that best matches the url.
+[ScriptBlock]
+$Router = {
+ param()
+ $url, $functions = $args
+ if (-not $url) { return }
+ # We can have one of three possible names
+
+ $exactNames = @(
+ # Fully qualified (i.e `function http://127.0.0.1/ {}` )
+ $url.Scheme,'://',
+ $url.DnsSafeHost,
+ $url.LocalPath -join ''
+ # Host qualified (i.e `function example.com/ {}` )
+ $url.DnsSafeHost,
+ $url.LocalPath -join ''
+ # Scheme qualified (i.e. `function http:// {}` )
+ $url.Scheme,':/',
+ $url.LocalPath -join ''
+ # Locally qualified (i.e. `function / {} )
+ $url.LocalPath
+ )
+
+ $localPath = $url.LocalPath
+
+ [Array]::Reverse($functions)
+
+ $exactMatches = # Simply -match the function to the exact names
+ @(@($functions) -match "^(?>$(
+ @(foreach ($exactName in $exactNames) {
+ [Regex]::Escape($exactName)
+ }) -join '|'
+ ))/?$")
+
+ return @(
+ if ($exactMatches) {
+ $exactMatches[0]
+ } else {
+ foreach ($function in $functions) {
+ # We don't want to be too picky about ending slashes,
+ # so remove them from our function name.
+ $functionWildcard = $function.Name -replace
+ '/$' -replace
+ '/[\:\$][^/]+','/?*'
+ if (
+ # If the local path is like our function name
+ $localPath -and (
+ # we've found our function
+ $localPath -replace '/$' -like $functionWildcard
+ )
+ ) {
+ # Break after the first function we find.
+ $function
+ break
+ }
+ }
+ }
+ )
+},
+
+[ScriptBlock]
+$GetFunctionFormData = {
+ param([uri]$url, [string]$body, [string]$contentType)
+
+ $formData = [Ordered]@{}
+ if ($Url.Query) {
+ $parsedQueryString = [Web.HttpUtility]::ParseQueryString($Url.Query)
+ # Then copy over our parameters.
+ foreach ($queryParameter in $parsedQueryString.Keys) {
+ if (-not $queryParameter) { continue }
+ $formData[$queryParameter] = $parsedQueryString[$queryParameter]
+ if ($formData[$queryParameter] -match '^(true|false)$') {
+ $formData[$queryParameter] = $formData[$queryParameter] -match '^true'
+ }
+ }
+ }
+
+ # If the content type is urlencoded and we have a body
+ if ($ContentType -eq 'application/x-www-form-urlencoded' -and $body) {
+ # Read the input
+ $parsedQueryString = [Web.HttpUtility]::ParseQueryString($Body)
+ foreach ($queryParameter in $parsedQueryString.Keys) {
+ if (-not $queryParameter) { continue }
+ $formData[$queryParameter] = $parsedQueryString[$queryParameter]
+ if ($formData[$queryParameter] -match '^(true|false)$') {
+ $formData[$queryParameter] = $formData[$queryParameter] -match '^true'
+ }
+ }
+ }
+
+ return $formData
+},
+
+[Alias('GetFunctionArguments','GetFunctionArgs')]
+[ScriptBlock]
+$GetFunctionPathParameter = {
+ <#
+ .SYNOPSIS
+ Gets parameters from the path
+ .DESCRIPTION
+ Gets function parameters from the path segments.
+ #>
+ param(
+ [Parameter(Mandatory)]$Function,
+ [Parameter(Mandatory)][uri]$Url
+ )
+
+ # Break the name into segments after we
+ $nameSegments = @(
+ $function.Name -replace
+ # remove http and ws protocols
+ '^(?>http|ws)s?://' -replace
+ # replace any `*.*` before a slash
+ '^[^\.]+\..+/' -split
+ # and split them just after every / or the end
+ '(?<=(?>/|$))'
+ )
+
+ # If we want verbose information, trace out the name and url segments
+ if ($VerbosePreference -notin 'Ignore','SilentlyContinue') {
+ Write-Verbose "$($NameSegments -join "`t")"
+ Write-Verbose "$($url.Segments -join "`t")"
+ }
+
+ $PathParameters = [Ordered]@{
+ Function = $Function
+ Url = $Url
+ BoundParameters = [Ordered]@{}
+ }
+
+ $PathParameters.UnboundArguments = @(
+ # To get arguments, we need to go thru each segment in the name
+ for ($nSegment = 0; $nSegment -lt $nameSegments.Length; $nSegment++) {
+ # and (potentially) map it to the corresponding request segment.
+ $requestSegment =
+ if ($nSegment -le $url.Segments.Count) {
+ $Url.Segments[$nSegment]
+ } else {
+ # If we are out of actual request segments, break
+ break
+ }
+
+ $nameSegment = $nameSegments[$nSegment]
+
+ $requestSegment = $requestSegment -replace '/'
+
+ # If the name segment is a variable
+ if ($nameSegment -match '^[\:\$]') {
+ # it should be a parameter name.
+ $parameterName = $nameSegment -replace '^[\:\$]'
+ # and we should map the segment by name.
+ $PathParameters.BoundParameters[$parameterName] = $requestSegment
+ }
+
+ # If the segment is a wildcard, we will map the segment positionally
+ if ($nameSegment -match '^\*/?$') { $requestSegment }
+ }
+
+ $nSegment--
+
+ if ($nSegment -lt $url.Segments.Length) {
+ $range = $nSegment..($url.Segments.Length - 1)
+ foreach ($segment in $Url.Segments[$range]) {
+ $segment -replace '/'
+ }
+ }
+ )
+ return $PathParameters
+},
+
+[ScriptBlock]
+$GetFunctionQueryParameter = {
+ param($url)
+
+},
+
+# Initialization scripts
+# Providing initialization scripts will isolate the server.
+# This should not expose any functions not defined in the initialization scripts.
+# Any required modules should be imported.
+[Alias('Init','IsolateScript')]
+[ValidateScript({
+ if ($_ -isnot [ScriptBlock] -and
+ $_ -isnot [Management.Automation.ExternalScriptInfo] -and
+ -not ($_ -match '\.ps1$' -and (Test-Path $_))
+ ) {
+ throw "Must be ScriptBlock or External Script"
+ }
+ return $true
+})]
+[PSObject[]]
+$InitializeScript,
+
+# A script block used to output http requests.
+[ScriptBlock]
+$HttpOutput = {
+ $allOutput = @($input)
+ $allOutputBytes = $allOutput -as [byte[]]
+ if (-not $allOutputBytes) {
+ $allContent =
+ @(foreach ($in in $allOutput) {
+ $inXml = $in.OuterXml
+ if ($inXml) {"$inXml"}
+ elseif ($($inHtml = $in.html;$inHtml)) {"$inHtml"}
+ elseif ($in.ToString.Invoke) {$in.ToString()}
+ })
+ if ($response.ContentType -eq 'text/html' -and
+ $site.Layout -is [ScriptBlock]) {
+
+ $allOutputBytes = $encoding.GetBytes(
+ ($allContent | . $site.Layout.GetNewClosure())
+ )
+ } else {
+ $allOutputBytes = $encoding.GetBytes($allContent -join '')
+ }
+ }
+ if ($allOutputBytes) {
+ # CGI requests just need to close
+ $response.Close(
+ $allOutputBytes,$false
+ )
+ } else {
+ $response.Close()
+ }
+},
+
+# A script block used to stream http outputs.
+# This will be used when `-Streaming` output.
+[ScriptBlock]
+$HttpStreamOutput = {
+ # If we are streaming a CGI request
+ begin {
+ # We need to set the protocol version
+ $response.ProtocolVersion = '1.1'
+ # and send chunked responses.
+ $response.SendChunked = $true
+ $outputStream = $response.OutputStream
+ }
+ process {
+ # Then we output each object
+ $in = $_
+ if ($outputStream.CanWrite) {
+ $outBytes = $in -as [byte[]]
+ $buffer = if ($outBytes) {
+ $outBytes
+ } else {
+ $encoding.GetBytes(
+ $(
+ $inXml = $in.OuterXml
+ if ($inXml) {"$inXml"}
+ elseif (
+ $($inHtml = $in.html;$inHtml)
+ ) {"$inHtml"}
+ elseif ($in.ToString.Invoke) {
+ $in.ToString()
+ }
+ )
+ )
+ }
+
+ $outputStream.Write($buffer, 0, $buffer.Length)
+ $outputStream.Flush()
+ } else {
+ # If there was no output stream, emit the result.
+ $in
+ }
+ }
+ end {
+ # Close our response when the command is done
+ if ($response.Close) {$response.Close()}
+ }
+},
+
+# A Layout script block
+# If this is provided, and the function outputs html,
+# all non-streamed HTTP output will be passed to the layout script.
+# Any output will be piped to the layout.
+[ScriptBlock]
+$Layout,
+
+# The server script.
+# This should listen for requests and `.Run` with that context.
+[ScriptBlock]
+$ServerScript = {
+ param($server, [Collections.IDictionary]$IO = [Ordered]@{})
+ # It will have a listener
+ $httpListener = $server.HttpListener
+
+ foreach ($key in @($IO.Keys)) {
+ $ExecutionContext.SessionState.PSVariable.set($key, $IO[$key])
+ }
+
+ if ($server.Initialize) {
+ $server.Functions =
+ $ExecutionContext.SessionState.InvokeCommand.GetCommands('*/*','Function,Alias', $true)
+ }
+
+ # and we can loop while it is listening
+ if (-not $server.Counter) {
+ $server | Add-Member NoteProperty Counter ([long]0) -Force
+ }
+
+ :nextRequest while ($httpListener.IsListening) {
+ # Get the next context
+ $getContext = $httpListener.GetContextAsync()
+ # and wait until it's ready
+ while (-not $getContext.Wait(11)) { }
+ $context = $getContext.Result
+ # Run our function
+ # If we don't yet have a counter, create one.
+ $request, $response = $context.Request, $context.Response
+
+ # Increment our counter
+ $server.Counter++
+
+ try {
+ $server.Run($context)
+ } catch {
+ $err = $_
+ $response.StatusCode = 400
+ $response.ContentType = 'text/plain'
+ $response.Close([Text.Encoding]::UTF8.GetBytes(
+ "$err$(
+ if ($err.Exception.InnerException) { [Environment]::Newline; $err.Exception.InnerException}
+ $err | Select-Object * | Out-String
+ )"
+ ), $false)
+ $err
+ }
+ }
+},
+
+# The Socket Job.
+# This will run whenever a new socket is created.
+# It should listen to results from that socket and output them
+# It should run any functions associated with the socket and reply with their output.
+[ScriptBlock]
+$SocketJob = {
+ param($this, $socketInfo)
+ $webSocket = $socketInfo.WebSocket
+ $context = $socketInfo.Context
+ $request, $response = $context.Request, $context.Response
+ # If we had an initialization script,
+ # Locally scope our functions.
+ if ($this.InitializationScript) {
+ $this.Functions =
+ $ExecutionContext.SessionState.InvokeCommand.GetCommands(
+ '*/*','Function,Alias', $true
+ )
+ }
+
+ $url = $Request.Url
+
+ # This loop will run as long as the websocket is open.
+ :WebSocketMessageLoop while ($websocket.State -eq 'Open') {
+ # Websockets fill a buffer of memory
+ $Buffer = [byte[]]::new($this.BufferSize)
+ # Get a segment containing our buffer
+ $Segment = [ArraySegment[byte]]::new($Buffer)
+ # and await the next message.
+ $receivingWebSocket = $webSocket.ReceiveAsync(
+ $Segment, [Threading.CancellationToken]::None
+ )
+
+ # use this tight loop to let us cancel the await if we need to.
+ while (-not $receivingWebSocket.Wait(11)) {}
+
+ # If we had a problem, write an error.
+ if ($receivingWebSocket.Exception) {
+ Write-Error -Exception (
+ $receivingWebSocket.Exception
+ ) -Category ProtocolError
+ continue
+ }
+
+ # At this point we should have a json message, in UTF8
+ $encoding = [Text.Encoding]::UTF8
+ try {
+ # WebSocket buffers are "old school" null terminated strings
+ # So let's find the null terminator (the first 0)
+ $nullTerminator = $Buffer.IndexOf([byte]0)
+ # and let's get the message.
+ $messageString = if ($nullTerminator -ge 0) {
+ $encoding.GetString($Buffer, 0, $nullTerminator)
+ } else {
+ $encoding.GetString($Buffer, 0, $Buffer.Length)
+ }
+
+ # Now let's convert it from json
+ $socketMessage =
+ if ($messageString) {
+ try {
+ ConvertFrom-Json -InputObject $messageString
+ } catch {
+ # if we could not, treat it as plain text.
+ "$messageString"
+ }
+ } else { $null }
+
+ if ($socketMessage) {
+ $this.Run($socketInfo, $socketMessage)
+ } else {
+ $this.Run($socketInfo)
+ }
+ } catch {
+ Write-Error $_
+ }
+ }
+},
+
+# The WebSocket Output.
+# This will be called to output a result to a websocket.
+[ScriptBlock]
+$WebSocketOutput = {
+ $out = @($input) # Websockets just need to take all output
+ if ($out.Length -eq 1) { $out = $out[0] }
+ # and send it down the wire as json
+ $webSocket.SendAsync(
+ [ArraySegment[byte]]::new($encoding.GetBytes(
+ (ConvertTo-Json -InputObject $out -Depth $JsonDepth)
+ )
+ ), 'Text', $true, [Threading.CancellationToken]::None)
+},
+
+# The WebSocket streaming output.
+# This will be called to output a series of results to a websocket.
+[ScriptBlock]
+$WebSocketStreamOutput = {
+ process {
+ $out = @($_) # take each output
+ if ($out.Length -eq 1) { $out = $out[0] }
+ # and send it directly along to the socket.
+ $webSocket.SendAsync(
+ [ArraySegment[byte]]::new($encoding.GetBytes(
+ (ConvertTo-Json -InputObject $out -Depth $JsonDepth)
+ )
+ ), 'Text', $true, [Threading.CancellationToken]::None)
+ }
+}
)
# This function is designed to be pretty performant,
@@ -126,23 +598,50 @@ if (-not $allInput -and $InputObject) {
}
# We will be outputting a custom object named after ourself
-$myTypeName =
- $MyInvocation.MyCommand.Name -replace
+$myTypeName =
+ $MyInvocation.MyCommand.Name -replace
'\.ps1$' -replace '^.+?-' # (replacing the extension and any verb)
-Update-TypeData -TypeName $myTypeName -Force -DefaultDisplayPropertySet 'CreatedAt','RequestRate','Functions'
-# Create our output object
-$outputObject = New-Object PSObject -Property ([Ordered]@{
- PSTypeName = $myTypeName
- CreatedAt = [DateTime]::Now
- # Fun fact: this kind of enumeration is always up to date
- # We will not need to watch for new commands, this variable will always have them.
- Functions = $ExecutionContext.SessionState.InvokeCommand.GetCommands('/*','Function,Alias', $true)
- Arguments = $ArgumentList
- Input = $allInput
-}) |
- # Extend our output with a script methods and properties
- #region `.Build`
+Update-TypeData -TypeName $myTypeName -Force -DefaultDisplayPropertySet (
+ 'CreatedAt','Prefix','Functions'
+)
+
+# Create a dictionary for our output object
+$output = [Ordered]@{PSTypeName = $myTypeName}
+
+# Handle any defined `-Parameter`
+$myParameters = $MyInvocation.MyCommand.Parameters
+if ($Parameter.Count) {
+ foreach ($parameterName in $Parameter.Keys) {
+ # If the parameterName is one of this commands parameters
+ if ($myParameters[$parameterName]) {
+ # Try to bind the value
+ $ExecutionContext.SessionState.PSVariable.Set(
+ $parameterName, $Parameter[$parameterName])
+ } else {
+ # Otherwise, set the key
+ $output[$parameterName] = $Parameter[$parameterName]
+ }
+ }
+}
+
+# Bind all of our existing parameters
+foreach ($key in $myParameters.Keys) {
+ $var = $ExecutionContext.SessionState.PSVariable.get($key)
+ if ($var) { $output[$key] = $var.Value }
+}
+
+# Hard code a small number of fields:
+$output.CreatedAt = [DateTime]::Now # * `.CreatedAt`
+$output.Functions = $ExecutionContext.SessionState.InvokeCommand.GetCommands(
+ '*/*','Function,Alias', $true
+) # * `.Functions`
+$output.Arguments = $ArgumentList # * `.Arguments`
+$output.Input = $allInput # * `.Input`
+
+# Create our object and extend it
+$outputObject = New-Object PSObject -Property $output |
+ #region `.Build()`
Add-Member ScriptMethod Build {
<#
.SYNOPSIS
@@ -150,34 +649,80 @@ $outputObject = New-Object PSObject -Property ([Ordered]@{
.DESCRIPTION
Builds the server into a static site.
- Will build any `/` function whose name is like *.*.
+ Will build any `/` function whose name is like *.*
+
+ Output a list of paths and the contents of the files.
#>
- param([string]$Path = $pwd)
- $this.Functions |
+ param()
+
+ # Collect our functions
+ $functions = @($this.Functions)
+ # and set our site
+ $site = $this
+
+ # Walk over each function
+ $functions |
. { process {
$cmd = $_
+
+ # Only build functions named *.*
if ($cmd.Name -notlike '*.*') { return }
- $output = . $cmd
- $path = Join-Path $pwd $cmd.Name
- $newFile = [Ordered]@{
- Path = Join-Path "." "./$($cmd.Name -replace "^/")"
- Value=$output -join [Environment]::NewLine
+ # that do not have wildcards or variables
+ if ($cmd.Name -match '[\*\:\$]') { return }
+
+ # Try to run each command.
+ try {
+ # Collect it's output
+ $output = . $cmd
+ if (
+ # If we have a layout script
+ ($site.Layout -is [ScriptBlock]) -and
+ (
+ # and the output type is `text/html`
+ ($cmd.OutputType -match 'text/html') -or
+ (-not $cmd.OutputType) # or unspecified
+ )
+ ) {
+ # Send our output to our layout.
+ $output = $output | . $site.Layout
+ }
+ }
+ catch {
+ Write-Warning "Error building $($cmd.Name): $_ "
+ return
+ }
+
+ [Ordered]@{
+ Path = "./$($cmd.Name -replace "^/")"
+ Value= if ($output -as [byte[]]) {
+ $output -as [byte[]]
+ } else {
+ $output -join [Environment]::NewLine
+ }
}
- New-Item @newFile -Force -ItemType File
} }
} -Force -PassThru |
#endregion `.Build`
+
#region `.Clear`
Add-Member ScriptMethod Clear {
foreach ($func in $this.Functions) {
- if ($func -is [Management.Automation.FunctionInfo]) {
- Remove-Item "function:/$($func.Name)"
- } elseif ($func -is [Management.Automation.AliasInfo]) {
- Remove-Item "alias:/$($func.Name)"
+ try {
+ if ($func -is [Management.Automation.FunctionInfo]) {
+ Remove-Item "function:/$(
+ $func.Name -replace '\*','`*' -replace '\?', '`?'
+ )"
+ } elseif ($func -is [Management.Automation.AliasInfo]) {
+ Remove-Item "alias:/$(
+ $func.Name -replace '\*','`*' -replace '\?', '`?'
+ )"
+ }
+ } catch {
+ Write-Warning "Could not remove $($func.Name) - $_"
}
}
} -Force -PassThru |
- #endregion `.Clear
+ #endregion `.Clear`
#region `.Define`
Add-Member ScriptProperty Define {
<#
@@ -192,9 +737,7 @@ $outputObject = New-Object PSObject -Property ([Ordered]@{
if ($func -is [Management.Automation.FunctionInfo]) {
"function $func {$(
$func.ScriptBlock
- )$(
- [Environment]::NewLine
- )}"
+ )$([Environment]::NewLine)}"
} elseif ($func -is [Management.Automation.AliasInfo]) {
"Set-Alias '$(
$func.Name -replace "'","''"
@@ -207,62 +750,64 @@ $outputObject = New-Object PSObject -Property ([Ordered]@{
)
} -Force -PassThru |
#endregion `.Define`
- #region `.JobScript`
- Add-Member ScriptProperty JobScript {
- return {
- # All we need to do is pass this object
- param($this)
+ #region `.Deploy`
+ Add-Member ScriptMethod Deploy {
+ <#
+ .SYNOPSIS
+ Deploys the server
+ .DESCRIPTION
+ Deploys the server as a static site.
+
+ Will deploy any `/` function whose name is like *.*
- # It will have a listener
- $httpListener = $this.HttpListener
- # and we can loop while it is listening
- while ($httpListener.IsListening) {
- # Get the next context
- $getContext = $httpListener.GetContextAsync()
- # and wait until it's ready
- while (-not $getContext.Wait(13)) { }
- $context = $getContext.Result
- # If we don't yet have a counter
- if (-not $this.Counter) {
- # create one.
- $this |
- Add-Member NoteProperty Counter ([long]0) -Force
- }
- # Increment our counter
- $this.Counter++
- # And run our function
- if ($this.Run) {
- try {
- $this.Run($context)
- } catch {
- $err = $_
- $context.Response.StatusCode = 400
- $context.Response.Close([Text.Encoding]::UTF8.GetBytes(
- "$err"
- ), $false)
- $err
- }
- }
+ Existing files will be overwritten.
+ #>
+ param([string]$Path = $pwd)
+ foreach ($fileBuilt in $this.Build()) {
+ $fileBuilt.Path = Join-Path $path $fileBuilt.Path
+ if ($fileBuilt.Value -is [byte[]]) {
+ $newFile = New-Item -Path $fileBuilt -Force
+ [IO.File]::WriteAllBytes($newFile.FullName, $fileBuilt.Value)
+ Get-Item -LiteralPath $fileBuilt
+ } else {
+ New-Item @fileBuilt -Force
}
+ }
+ } -Force -PassThru |
+ #endregion `.Deploy`
+ #region `.Prefix`
+ Add-Member ScriptProperty Prefix {
+ if ($this.HttpListener.Prefixes.Length -eq 1) {
+ $this.HttpListener.Prefixes[0]
+ } else {
+ $this.HttpListener.Prefixes
}
} -Force -PassThru |
- #endregion `.JobScript`
+ #endregion `.Prefix`
+
#region `.Remove`
Add-Member ScriptMethod Remove {
param([string]$Wildcard)
if (-not $Wildcard) { return }
foreach ($func in $this.Functions) {
- if ($func.Name -notlike $Wildcard) {
- continue
- }
- if ($func -is [Management.Automation.FunctionInfo]) {
- Remove-Item "function:/$($func.Name)"
- } elseif ($func -is [Management.Automation.AliasInfo]) {
- Remove-Item "alias:/$($func.Name)"
+ if ($func.Name -notlike $Wildcard) { continue }
+ try {
+ if ($func -is [Management.Automation.FunctionInfo]) {
+ Remove-Item "function:/$(
+ $func.Name -replace '\*','`*' -replace '\?', '`?'
+ )"
+ } elseif ($func -is [Management.Automation.AliasInfo]) {
+ Remove-Item "alias:/$(
+ $func.Name -replace '\*','`*' -replace '\?', '`?'
+ )"
+ }
+ } catch {
+ Write-Warning "Could not remove $($func.Name) - $_"
}
}
} -Force -PassThru |
#endregion `.Remove
+
#region `.RequestRate`
# We also want one script property that calculates a request rate
Add-Member ScriptProperty RequestRate {
@@ -272,110 +817,277 @@ $outputObject = New-Object PSObject -Property ([Ordered]@{
([DateTime]::Now - $this.CreatedAt).TotalMinutes
} -Force -PassThru |
#endregion `.RequestRate`
+
#region `.Run`
Add-Member ScriptMethod Run {
<#
.SYNOPSIS
Run in a context
.DESCRIPTION
- Run the function in a context
+ Run the function in http request context or websocket context
#>
- param($context)
+ param(
+ # The context
+ $context,
+
+ $socketMessage
+ )
# Allow for mock requests by enabling casting to uris
if ($context -as [uri]) {
$request = [Ordered]@{HttpMethod='Get';Url = $context -as [uri]}
+ } elseif ($context.context) {
+ $request, $response = $context.Context.Request, $context.Context.Response
} else {
$request, $response = $context.Request, $context.Response
}
- # Use the local path if present
- $localPath =
- if ($request.Url.LocalPath) {
- $request.Url.LocalPath
- } else { $null }
+ $site = $this
+
+ # WebSocket handshake requests should be specially handled before we route to a function.
+ if ($Request.IsWebSocketRequest -and -not $context.WebSocket) {
+ if ($site.NoWebSocket) {
+ # Method not allowed
+ $response.StatusCode = 405
+ $response.Close()
+ }
+ # We need to call the `AcceptWebSocketAsync` method to upgrade the connection.
+ $acceptWebSocket = $context.AcceptWebSocketAsync('json')
+ # We'll .Wait until the upgrade is finished.
+ while (-not $acceptWebSocket.Wait(11)) { }
+ # If it fails,
+ if ($acceptWebSocket.IsFaulted) {
+ # we will write an error and return.
+ Write-Error -Exception $acceptWebSocket.Exception -Category ProtocolError
+ return
+ }
+ # If it succeeds, capture the result.
+ $webSocketResult = try { $acceptWebSocket.Result } catch { $_ }
+
+ # If we do not have any sockets on this object
+ if (-not $site.Sockets) {
+ # create a dictionary to hold them.
+ $site | Add-Member NoteProperty Sockets ([Ordered]@{}) -Force
+ }
+
+ # If we do not have any sockets on this url
+ if (-not $site.Sockets[$request.Url]) {
+ # create a list
+ $site.Sockets[$request.Url] = @()
+ }
+
+ # Prepare our socket info.
+ # This contains the initial request as well as web socket context.
+ $socketInfo = ([PSCustomObject]@{
+ Context = $context
+ Request = $request
+ Response = $response
+ WebSocket = $webSocketResult.WebSocket
+ WebSocketContext = $webSocketResult
+ })
+
+ $request |
+ Add-Member NoteProperty Url (
+ ($request.Url -replace '^http', 'ws') -as [uri]
+ ) -Force
+ $socketJobParams = [Ordered]@{
+ Name = "$($request.Url)"
+ ArgumentList = $site,$socketInfo
+ ThrottleLimit = 32kb
+ ScriptBlock = $site.SocketJob
+ }
+ if ($site.InitializationScript) {
+ $socketJobParams.InitializationScript = $socketJobParams
+ }
+ # Each websocket runs in its own thread job
+ $socketJob = Start-ThreadJob @socketJobParams |
+ Add-Member NoteProperty HttpListener $site.HttpListener -Force -PassThru |
+ Add-Member NoteProperty SocketInfo $socketInfo -Force -PassThru |
+ Add-Member NoteProperty WebSocket $socketInfo.WebSocket -Force -PassThru |
+ Add-Member NoteProperty Url $socketInfo.Request.Url -Force -PassThru |
+ Add-Member NoteProperty Fun $site -Force -PassThru
+
+ $urlString = "$($request.Url)"
+ $site.Sockets[$urlString] += $socketJob
+
+ # While we're here, might as well clean up finished socket jobs.
+ $toRemove = @()
+ $site.Sockets[$urlString] = # Make one pass thru all sockets to this url
+ @(foreach ($socket in $site.Sockets[$urlString]) {
+ # If they are not completed or failed
+ if ($socket.State -notin 'Completed', 'Failed') {
+ $socket # keep it in the list
+ } else {
+ # otherwise, mark it for removal
+ $toRemove += $socket
+ }
+ })
+
+ # Remove only the jobs that completed without error.
+ $toRemove | Where-Object State -ne 'Failed' | Remove-Job
+ # Either way, return the socket job.
+ return $socketJob
+ }
+
+ # * `$Method` should contain the HttpMethod
+ $Method = $request.HttpMethod
+ # * `$body` should contain the request body as a string
+ $body = ''
# We want to match the url to a function.
- $functions = @(foreach ($function in @($this.Functions)) {
- # We don't want to be too picky about ending slashes,
- # so remove them from our function name.
- $functionNameNoSlash = $function.Name -replace '/$'
- if (
- # If the local path is like our function name
- $localPath -and
- (
- # we've found our function
- $localPath -replace '/$' -like $functionNameNoSlash
- )
- ) {
- # Break after the first function we find.
- $function
- break
+ $url = $request.Url
+
+ $encoding = [Text.Encoding]::UTF8
+
+ $headers = [Ordered]@{}
+ if ($request.Headers) {
+ foreach ($key in $request.Headers.Keys) {
+ $headers[$key] = $request.Headers[$key]
}
- })
+ }
+
+ $cookies = [Ordered]@{}
+ foreach ($cookie in $request.Cookies) {
+ if (-not $cookie.Name) { continue }
+ $Cookies[$cookie.Name] = $cookie
+ }
- # If there were no found functions
- if (-not $functions) {
+ $ContentType = $request.ContentType
+
+ $webSocket = $null
+ # This is only _slightly_ different for websocket requests.
+ # If the request is a websocket request, and we've got a live socket
+ if ($request.IsWebSocketRequest -and $context.WebSocket) {
+ $webSocket = $context.WebSocket
+ # and make $url reflect the new value
+ $url = $request.Url
+ $JsonDepth = $this.JsonDepth
+ }
+
+ # Now that URL is properly defined (websocket or not)
+ # we can just `.Route` our functions.
+ $functions = @(
+ # (We just need to use use a new closure to avoid potential locks)
+ . $this.Router.GetNewClosure() $url $this.Functions
+ )
+
+ # If there were no found functions and this isn't a websocket request
+ if (-not $functions -and -not $request.IsWebSocketRequest) {
# We're going to send a 404.
- if ($response.StatusCode) {
- $response.StatusCode = 404
- }
+ if ($response.StatusCode) { $response.StatusCode = 404 }
# We want that 404 to be customizable,
# so look for a function named the status code `(i.e. /404)
$statusCodeFunction = @($this.functions -match "^/$($response.StatusCode)/?$")
if ($statusCodeFunction) {
- # If one existed, set `$functions` and call it normally.
+ # If one existed, set `$functions` and call it normally.
$functions = $statusCodeFunction
} else {
# Otherwise, close the response
- $response.Close()
+ $response.Close([Text.Encoding]::UTF8.GetBytes("No Fun @ $($request.Url)"), $false)
return
}
}
+ # After we've routed, get the last matching function.
+
+ $function = $functions[-1]
+
+ # If we have not mapped a function, return.
+ if (-not $function) { return }
+
+ # Http input streams can only be read once
+ # and we may want to read the content twice
+ # (or in two different ways)
+ $memoryStream = [IO.MemoryStream]::new()
+ $streamReader = $null
+
+ if ($request.InputStream.CanRead) {
+ $request.InputStream.CopyTo($memoryStream)
+ }
+
+ # If we have any input
+ if ($memoryStream.Length) {
+ $null = $memoryStream.Seek(0,'begin')
+ $streamReader = [IO.StreamReader]::new($memoryStream)
+ # read the body
+ $Body = $streamReader.ReadToEnd()
+ # seek back to 0 so we can read things again
+ $null = $memoryStream.Seek(0,'begin')
+ }
+
# To add to the fun, we want our functions to take parameters
- $query = [Ordered]@{}
- # If the request had a query
- if ($request.Url.Query) {
- # parse it
- $parsedQueryString = [Web.HttpUtility]::ParseQueryString($request.Url.Query)
- # and copy over our parameters.
- foreach ($queryParameter in $parsedQueryString.Keys) {
- $query[$queryParameter] = $parsedQueryString[$queryParameter]
- if ($query[$queryParameter] -match '^(true|false)$') {
- $query[$queryParameter] = $query[$queryParameter] -match '^true'
- }
+ $FormData = . $site.GetFunctionFormData.GetNewClosure() $request.Url $body $ContentType
+
+ $jsonData = [Ordered]@{}
+ # If the method is POST and we can read input
+ if ($body -and $ContentType -eq 'application/json') {
+ $parsedBody = ConvertFrom-Json -InputObject $body
+ foreach ($property in $parsedBody.psobject.properties) {
+ if (-not $property) { continue }
+ $jsonData[$property.Name] = $parsedBody.($property.Name)
}
}
-
- # Get the last matching function
- $function = $functions[-1]
- # And use its command metadata to find all possible parameters
+ # Get our path parameters
+ $pathParameters = @(
+ . $site.GetFunctionPathParameter.GetNewClosure() $function $url
+ )
+
+ # Create a map of all potential parameter names.
$functionParameterMap = @{}
- foreach ($parameter in ($function -as [Management.Automation.CommandMetadata]).Parameters.Values) {
+ foreach ($parameter in @((
+ $function -as [Management.Automation.CommandMetadata]
+ ).Parameters.Values)) {
+ if (-not $parameter) { continue }
+ # PowerShell parameters have names
$functionParameterMap[$parameter.Name] = $parameter
+ # but can also have any number of aliases.
foreach ($alias in $parameter.Aliases) {
$functionParameterMap[$alias] = $parameter
}
}
- # Now take all of our query parameters
+ # Create a collection of all function parameters
$functionParameters = [Ordered]@{}
- foreach ($queryParameter in $query.Keys) {
- # and map them to the function where we can
- $functionParameter = $functionParameterMap[$queryParameter]
- if ($functionParameter) {
- $functionParameters[
- $functionParameter.Name
- ] = $query[$queryParameter]
+
+ # Go over every source of potential named parameters,
+ foreach ($namedParameters in
+ $FormData,
+ $jsonData,
+ $pathParameters.BoundParameters
+ ) {
+ if (-not $namedParameters) { continue }
+ # walk over each parameter name in the set,
+ foreach ($parameterName in $namedParameters.Keys) {
+ # check that it is a parameter
+ $functionParameter = $functionParameterMap[$parameterName]
+ if ($functionParameter) {
+ # and map the parameter to the value.
+ $functionParameters[
+ $functionParameter.Name
+ ] = $namedParameters[$parameterName]
+ }
}
}
+ # If we passed a socket message, walk over its properties
+ if ($socketMessage -isnot [string] -and
+ $socketMessage -isnot [object[]]
+ ) {
+ foreach ($property in $socketMessage.psobject.properties) {
+ # and map them to the function where we can.
+ $functionParameter = $functionParameterMap[$property.Name]
+ if ($functionParameter) {
+ $functionParameters[$functionParameter.Name] =
+ $socketMessage.$($property.Name)
+ }
+ }
+ }
+
+
# If the function had an output type like `*/*`
if ($function.OutputType.Name -like '*/*' -and
- $response.OutputStream
- ) {
+ $response.OutputStream) {
foreach ($outputType in $function.OutputType) {
if ($outputType.Name -like '*/*') {
# this will become the response content type
@@ -384,95 +1096,87 @@ $outputObject = New-Object PSObject -Property ([Ordered]@{
}
}
}
+
# If we do not have an output type
if (-not $function.OutputType) {
# default to `text/html`
$response.ContentType = 'text/html'
}
-
- $functionOutput = {
- begin {
- # To stream output, we need to set the protocol version
- $response.ProtocolVersion = '1.1'
- # and send chunked responses.
- $response.SendChunked = $true
- # Get a pointer to the output stream for repeated use.
- $outputStream = $response.OutputStream
- $encoding = if ($request.ContentEncoding) {
- $request.ContentEncoding
+
+ # We need to determine how we will handle function output.
+ $FunctionOutput =
+ # We do not always want to stream content
+ if ($(
+ # we should only stream if `$this` or the `$function` say so.
+ $site.Stream
+ )) {
+ if ($request.IsWebSocketRequest) {
+ # If we are streaming a websocket request
+ $site.WebSocketStreamOutput
} else {
- [Text.Encoding]::UTF8
+ $site.HttpStreamOutput
}
- }
-
- process {
- # Then we need to take each output object
- $in = $_
-
- # If it is XML,
- if ($in.OuterXml -and $outputStream.CanWrite) {
- # write it out.
- $buffer = $encoding.GetBytes("$($in.OuterXml)")
- $outputStream.Write($buffer, 0, $buffer.Length)
- $outputStream.Flush()
- }
- # If it has an HTML property
- elseif ($in.html -and $outputStream.CanWrite) {
- # write that out
- $buffer = $encoding.GetBytes("$($in.html)")
- $outputStream.Write($buffer, 0, $buffer.Length)
- $outputStream.Flush()
- }
- # Otherwise
- elseif ($outputStream.CanWrite) {
- # Stringify the result.
- $buffer = $encoding.GetBytes("$in")
- $outputStream.Write($buffer, 0, $buffer.Length)
- $outputStream.Flush()
+ } else {
+ # If we are not streaming, output is easier
+ if ($request.IsWebSocketRequest) {
+ $site.WebSocketOutput
} else {
- $in
- }
- }
-
- end {
- # Close our response when the command is done
- if ($response.Close) {
- $response.Close()
+ $site.HttpOutput
}
}
- }
-
+
+ $functionArgs = @($pathParameters.UnboundArguments)
+ $functionOutput = $functionOutput.GetNewClosure()
# Call our function and stream the results
try {
- . $function @functionParameters *>&1 |
- . $functionOutput
+ # If the function had positional parameters
+ if ($functionArgs) {
+ # pass them first
+ . $function @functionArgs @functionParameters *>&1 |
+ . $functionOutput
+ } else {
+ # otherwise, call our function with named parameters
+ . $function @functionParameters *>&1 |
+ . $functionOutput
+ }
} catch {
$err = $_
$response.StatusCode = 400
+ $response.ContentType = 'text/plain'
$response.Close([Text.Encoding]::UTF8.GetBytes(
- "$err"
+ "$($err | Out-String)"
), $false)
$err
+ } finally {
+ if ($streamReader) {
+ $streamReader.Close(),$streamReader.Dispose()
+ }
+ if ($memoryStream) {
+ $memoryStream.Close(),$memoryStream.Dispose()
+ }
}
-
} -Force -PassThru |
#endregion `.Run`
+
#region `.Start`
Add-Member ScriptMethod Start {
- param()
+ param([string]$Prefix)
# In order to start the fun, we need an http listener
if (-not $this.HttpListener) {
# Attach this listener to this object
- $this |
- Add-Member NoteProperty HttpListener (
- [Net.HttpListener]::new()
- ) -Force
- # If we have any prefixes, add them
- if ($this.Prefixes) {
+ $this | Add-Member NoteProperty HttpListener (
+ [Net.HttpListener]::new()
+ ) -Force
+ # If we have any prefixes, add them.
+ if ($prefix) {
+ $httpPrefix = $prefix -replace '/{0,}$' -replace '$', '/'
+ $this.HttpListener.Prefixes.Add($httpPrefix)
+ }
+ elseif ($this.Prefixes) {
foreach ($prefix in $this.Prefixes) {
$httpPrefix = $prefix -replace '/{0,}$' -replace '$', '/'
$this.HttpListener.Prefixes.Add($httpPrefix)
- }
+ }
} else {
# Otherwise, pick a random local loopback port
$this.HttpListener.Prefixes.Add(
@@ -483,17 +1187,49 @@ $outputObject = New-Object PSObject -Property ([Ordered]@{
# Start the listener
if (-not $this.HttpListener.IsListening) {
- # Write a warning so we know something is listening
+ # and warn that something is listening
Write-Warning "Listening on $($this.HttpListener.Prefixes)"
$this.HttpListener.Start()
}
- $listener = $this.HttpListener
-
- # Now start our fun little server loop in a thread job.
- $newJob = Start-ThreadJob -ScriptBlock $this.JobScript -ArgumentList $this -Name "$(
- $listener.Prefixes -replace '/$'
- )" -ThrottleLimit 16kb |
+ # Now start our fun little server loop in a thread job.
+ $newJob = [Ordered]@{
+ ScriptBlock = $this.ServerScript;ArgumentList = $this
+ Name = "$($this.HttpListener.Prefixes -replace '/$')"
+ ThrottleLimit = 16kb
+ }
+ if ($this.InitializeScript) {
+ $initializationScript = [ScriptBlock]::Create(
+ @(
+ foreach ($initScript in $this.InitializeScript) {
+ if ($initScript.File -or $initScript -is [string]) {
+ $initScript =
+ $ExecutionContext.SessionState.InvokeCommand.GetCommand(
+ $(
+ if ($initScript.File) {
+ $initScript.File
+ } else {
+ $initScript
+ }
+ ), 'ExternalScript'
+ )
+ }
+ if ($initScript -is [Management.Automation.ExternalScriptInfo]) {
+ foreach ($required in $initScript.ScriptBlock.Ast.ScriptRequirements.RequiredModules) {
+ "Import-Module $($required.Name) -Global"
+ }
+ ". '$($initScript.Source -replace "'","''")'"
+ } else {
+ ". {$initScript}"
+ }
+ }
+ ) -join [Environment]::NewLine
+ )
+ $this | Add-Member NoteProperty Initialize $InitializationScript -Force
+ $newJob.InitializationScript = $initializationScript
+ }
+
+ $newJob = Start-ThreadJob @newJob |
Add-Member NoteProperty HttpListener $this.HttpListener -Force -PassThru |
Add-Member NoteProperty Fun $this -Force -PassThru
@@ -510,20 +1246,24 @@ $outputObject = New-Object PSObject -Property ([Ordered]@{
$prefixArguments = $ArgumentList -match '^https?://'
if ($prefixArguments) {
- $OutputObject |
+ $OutputObject |
Add-Member NoteProperty Prefixes (
$prefixArguments -replace '/?$', '/'
) -Force
}
-# If the arguments contained `start`
-if ($ArgumentList -contains 'Start' -or
- # or the invocation name started with `Start-`
- $MyInvocation.InvocationName -match '^Start-') {
- # start the fun now.
- $outputObject.Start()
-} else {
- # otherwise, output the fun
- $outputObject
+
+foreach ($verb in 'Build', 'Deploy', 'Start') {
+ if ($ArgumentList -contains $verb -or
+ $MyInvocation.InvocationName -match "^$verb-") {
+ # Any other arguments should be passed to the verb
+ $remainingArgs = @(
+ foreach ($arg in $ArgumentList) {
+ if ($arg -ne $verb) {$arg }
+ }
+ )
+ return $outputObject.$Verb.Invoke($remainingArgs)
+ }
}
-return
\ No newline at end of file
+# otherwise, output the fun
+return $outputObject
\ No newline at end of file
diff --git a/Fun.psd1 b/Fun.psd1
index b092805..987d322 100644
--- a/Fun.psd1
+++ b/Fun.psd1
@@ -12,7 +12,7 @@
RootModule = 'Fun.psm1'
# Version number of this module.
-ModuleVersion = '0.1'
+ModuleVersion = '0.2'
# Supported PSEditions
# CompatiblePSEditions = @()
@@ -75,7 +75,7 @@ FunctionsToExport = 'Get-Fun', 'Fun'
VariablesToExport = 'fun'
# Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export.
-AliasesToExport = 'Start-Fun'
+AliasesToExport = 'Start-Fun', 'Build-Fun', 'Deploy-Fun'
# DSC resources to export from this module
# DscResourcesToExport = @()
@@ -104,29 +104,43 @@ PrivateData = @{
# ReleaseNotes of this module
ReleaseNotes = @'
-## Fun Server 0.1
-
-* `Fun` is a fun functional PowerShell server (#1)
-* `Fun.ps1` defines a fun server (#2)
-* `Fun.psm1` defines eponyoms for Fun.ps1 (#3)
-* `Fun` has a build workflow (#4)
-* `Fun` has tests (#6)
-* `Fun` GitHub common documents
- * `Fun` `security.md` (#5)
- * `Fun` `contributing.md` (#7)
- * `Fun` `code_of_conduct.md` (#8)
-* `Fun` can run in a container
- * Dockerfile (#9)
- * Container.init.ps1 (#10)
- * Container.start.ps1 (#11)
- * Container.stop.ps1 (#12)
-* Example Site:
- * `Fun.fun.ps1` (#13)
-* Sponsorship support (#14)
-* GitHub Page
- * `deploy.ps1` (#15)
- * `deploy.yml` (#16)
-* README.md.ps1 (#17)
+## Fun Server 0.2
+
+* Fun Server Improvements
+ * Not building wildcard functions ( #24 )
+ * Binary http output (#46)
+ * The Server is the `$site` ( #57 )
+ * Fun `-InitializeScript` ( #48 )
+ * `Fun -Deploy` ( #51 )
+ * `Fun -Parameter` ( #58 )
+ * Fun` Path Parameters ( #60, #61, #62 )
+ * More Fun Tests ( #60 )
+ * `Fun -Layout` ( #65 )
+ * Fun output `.ToString()` ( #53, re #24 )
+ * Fun `$headers` and `$cookies` ( #40, #41 )
+ * Fun with WebSockets and parameters ( #29, #32, #33, #34, #35, #36, #37, #38, #39 )
+ * Fun streaming optional ( #28 )
+ * Fun exact matching improvement ( #25 )
+ * Fun `$body` and json support ( #26, #27 )
+ * Fun prefers exact matches ( #25 )
+ * Fun `$method` ( #23 )
+ * Fun `POST` forms ( #22 )
+ * Fun Verb Arguments ( #66 )
+ * Fun ArgumentList passthru ( #66 )
+ * Fun container passthru ( #68 )
+* Fun Website Improvements:
+ * `/_includes/FeatherIcon` ( #30 )
+ * `/_includes/Palette` ( #44 )
+ * `/_includes/HighlightScript` ( #43 )
+ * `/_includes/HighlightColors` ( #45 )
+ * `/_includes/CopyCode` ( #31 )
+ * `layout.ps1` ( #20 )
+ * `Fun.fun.ps1` updates ( #42 )
+ * `PoshWeb.svg.ps1` ( #63 )
+ * `Fun.css.ps1` ( #54, #59 )
+ * Adding `*.*.ps1` and Markdown support ( #55, #56, #47 )
+
+---
'@
diff --git a/Fun.tests.ps1 b/Fun.tests.ps1
index e23cd3b..172254b 100644
--- a/Fun.tests.ps1
+++ b/Fun.tests.ps1
@@ -1,9 +1,88 @@
+
describe Fun {
+ BeforeAll {
+ New-Module -Name Foo -ScriptBlock {
+ function /hi { "Hello from Fun" }
+ function /query { param([int]$Number) $Number }
+
+ function /form { param([int]$Number) $Number }
+
+ function /json { param([int]$Number) $Number }
+
+ function /args/* { $args }
+
+ function /named/:named {
+ param($named)
+ $Named
+ }
+
+ function /socket {
+ param(
+ [double]$Number
+ )
+
+ if ($request.IsWebSocketRequest) {
+ if ($Number) {
+ [Ordered]@{number = $Number}
+ } else {
+ [Ordered]@{number = Get-Random}
+ }
+ } else {
+
+ }
+ }
+ } | Import-Module -Global
+
+ $global:funJob = Start-Fun
+ Set-Alias Invoke-Rest Invoke-RestMethod
+ }
it 'Is Fun To Make a Server' {
- function / { "Hello from Fun" }
+ Invoke-Rest "$($global:funJob.Name)/hi" |
+ Should -Be "Hello from Fun"
+ }
+
+ it 'Is easy to map query strings' {
+ $randomNumber = [Random]::new().next()
+ $funServer = Start-Fun -InitializeScript {
+ function /query { param([int]$Number) $Number }
+ }
+ Invoke-Rest (
+ "$($funServer.Name)/query/?number=$randomNumber"
+ ) | Should -Be "$randomNumber"
+ $funServer | Stop-Job
+ }
+
+ it 'Can map form data' {
+ $randomNumber = [Random]::new().next()
+ $funServer = Start-Fun -InitializeScript {
+ function /form { param([int]$Number) $Number }
+ }
+ Invoke-Rest "$($funServer.Name)/form" -Method POST -Body "number=$randomNumber" -ContentType (
+ 'application/x-www-form-urlencoded'
+ ) | Should -Be "$randomNumber"
+ $funServer | Stop-Job
+ }
+
+ it 'Can map json data' {
+ $randomNumber = [Random]::new().next()
+ $funServer = Start-Fun -InitializeScript {
+ function /json { param([int]$Number) $Number }
+ }
+ Invoke-Rest "$($funServer.Name)/json" -Method POST -Body (
+ @{number=$randomNumber} | ConvertTo-Json
+ ) -ContentType (
+ 'application/json'
+ ) | Should -Be "$randomNumber"
+ $funServer | Stop-Job
+ }
+
+ it 'Can map positional arguments' {
+ $randomNumber = [Random]::new().next()
+ Invoke-Rest "$($global:funJob.Name)/args/$randomNumber" | Should -Be "$randomNumber"
+ }
- Invoke-RestMethod (
- Start-Fun | Select-Object -ExpandProperty Name
- ) | Should -Be "Hello from Fun"
+ it 'Can map positional named parameters' {
+ $randomNumber = [Random]::new().next()
+ Invoke-Rest "$($global:funJob.Name)/named/$randomNumber/" | Should -Be "$randomNumber"
}
}
diff --git a/PoshWeb.svg.ps1 b/PoshWeb.svg.ps1
new file mode 100644
index 0000000..49cb222
--- /dev/null
+++ b/PoshWeb.svg.ps1
@@ -0,0 +1,74 @@
+param(
+[string]
+$Variant = '',
+
+[int[]]
+$Sequence,
+
+[int]
+$First
+)
+
+$psChevron = '
+
+'
+
+$stroke = @(
+ "stroke='#4488ff'"
+ "class='foreground-stroke'"
+ "stroke-width='0.5%'"
+)
+$transparentFill = "fill='transparent'"
+$animationLoop = "repeatCount='indefinite'"
+ $centered = "cx='50%'", "cy='50%'"
+
+""
diff --git a/_includes/CopyCode.ps1 b/_includes/CopyCode.ps1
new file mode 100644
index 0000000..3440e22
--- /dev/null
+++ b/_includes/CopyCode.ps1
@@ -0,0 +1,22 @@
+<#
+.SYNOPSIS
+ Includes a CopyCode button
+.DESCRIPTION
+ Includes a copy-to-clipboard near all code blocks.
+#>
+[OutputType('text/html')]
+param()
+""
+"
+
+"
\ No newline at end of file
diff --git a/_includes/FeatherIcon.ps1 b/_includes/FeatherIcon.ps1
new file mode 100644
index 0000000..3444055
--- /dev/null
+++ b/_includes/FeatherIcon.ps1
@@ -0,0 +1,44 @@
+<#
+.SYNOPSIS
+ Includes Feather Icons
+.DESCRIPTION
+ Includes a feather icon in the site.
+.NOTES
+ Icons will be cached in memory to avoid repeated CDN requests.
+.EXAMPLE
+ /_includes/FeatherIcon clipboard
+.LINK
+ https://feathericons.com/
+#>
+[OutputType('image/svg+xml')]
+param(
+# The feather icon name
+[string]
+$Icon = 'terminal',
+
+[uri]
+$FeatherCDN = "https://cdn.jsdelivr.net/gh/feathericons/feather@latest/icons/"
+)
+
+if (-not $script:FeatherIconCache) {
+ $script:FeatherIconCache = [Ordered]@{}
+}
+
+$iconUri =
+ (
+ $FeatherCDN -replace '^https?://' -replace '^',
+ 'https://' -replace '/$'
+ ), (
+ $icon.ToLower() -replace '\.svg$' -replace '^/' -replace '$' -replace '\s',
+ '-' -replace '$', '.svg'
+ ) -join '/'
+
+if (-not $script:FeatherIconCache[$iconUri]) {
+ $script:FeatherIconCache[$iconUri] = try {
+ Invoke-RestMethod $iconUri
+ } catch {
+ Write-Warning "Could not get $iconUri : $_"
+ }
+}
+
+$script:FeatherIconCache[$iconUri].OuterXml
diff --git a/_includes/HighlightColors.ps1 b/_includes/HighlightColors.ps1
new file mode 100644
index 0000000..01599ce
--- /dev/null
+++ b/_includes/HighlightColors.ps1
@@ -0,0 +1,82 @@
+<#
+.SYNOPSIS
+ Includes Highlight Colors
+.DESCRIPTION
+ Includes Highlight Colors for PowerShell Scripts
+#>
+[OutputType('text/css')]
+param(
+# PowerShell Attribute css properties
+[psobject]$Attribute = 'color: var(--cyan); font-weight: bold;',
+# PowerShell Command css properties
+[psobject]$Command = 'color: var(--cyan); font-weight: bold;',
+# PowerShell CommandArgument css properties
+[psobject]$CommandArgument = "color: var(--cyan); font-weight: bolder;",
+# PowerShell CommandParameter css properties
+[psobject]$CommandParameter = 'color: var(--brightCyan); font-weight: bold;',
+# PowerShell Comment css properties
+[psobject]$Comment = "color: var(--green);",
+# PowerShell GroupEnd css properties
+[psobject]$GroupEnd = 'color: var(--purple); font-weight: bolder;',
+# PowerShell GroupStart css properties
+[psobject]$GroupStart = 'color: var(--purple); font-weight: bolder;',
+# PowerShell Keyword css properties
+[psobject]$Keyword = "color: var(--cyan); font-weight: bolder;",
+# PowerShell LineContinuation css properties
+[psobject]$LineContinuation,
+# PowerShell LoopLabel css properties
+[psobject]$LoopLabel = "color: var(--brightCyan); font-weight: bold;",
+# PowerShell Member css properties
+[psobject]$Member,
+# PowerShell NewLine css properties
+[psobject]$NewLine,
+# PowerShell Number css properties
+[psobject]$Number,
+# PowerShell Operator css properties
+[psobject]$Operator = 'color: var(--cyan); font-weight: bold;',
+# PowerShell Position css properties
+[psobject]$Position,
+# PowerShell StatementSeparator css properties
+[psobject]$StatementSeparator,
+# PowerShell String css properties
+[psobject]$String = 'color: var(--brightCyan)',
+# PowerShell Type css properties
+[psobject]$Type,
+# PowerShell Unknown css properties
+[psobject]$Unknown,
+# PowerShell Variable css properties
+[psobject]$Variable = "color: var(--yellow);"
+)
+
+# Get our metadata
+$myMetadata = [Management.Automation.CommandMetadata]$MyInvocation.MyCommand
+# walk over each of our parameters
+foreach ($parameterName in $myMetadata.Parameters.Keys) {
+ # and get the parameter variable.
+ $var = $ExecutionContext.SessionState.PSVariable.Get($parameterName)
+ $css =
+ # If it is a string
+ if ($var.value -is [string]) {
+ $var.value # include it inline.
+ }
+ # If it is a dictionary
+ elseif ($var.value -is [Collections.IDictionary]) {
+ # Include each key/value pair
+ @(foreach ($key in $var.Value.keys) {
+ $key, $var.Value[$key] -join ':'
+ }) -join ';'
+ }
+ # If it is a psobject
+ elseif ($var.value -is [PSObject]) {
+ # Include each property/value
+ @(foreach ($property in $var.Value.psobject.properties) {
+ $property.Name, $var.Value.($property.Name) -join ':'
+ }) -join ';'
+ }
+
+ # If we had a CSS value for this parameter
+ if ($css) {
+ # declare a selector with that value.
+ ".powershell-$($parameterName.ToLower()) { $css }"
+ }
+}
\ No newline at end of file
diff --git a/_includes/HighlightScript.ps1 b/_includes/HighlightScript.ps1
new file mode 100644
index 0000000..37c5974
--- /dev/null
+++ b/_includes/HighlightScript.ps1
@@ -0,0 +1,63 @@
+<#
+.SYNOPSIS
+ Includes a highlighted script
+.DESCRIPTION
+ Includes a highlighted script block.
+.NOTES
+ This will apply CSS classes to each token within a script block.
+
+ We need to also /_includes/HighlightColors to define the CSS files.
+#>
+[OutputType('text/html')]
+param(
+# The script block to highlight
+[ScriptBlock]
+$ScriptBlock = {}
+)
+
+# Stringify our code
+$code = "$ScriptBlock"
+@(
+# Create the `
` and `` blocks.
+"
"
+
+# Tokenize our scripts
+$codeTokens = [Management.Automation.PSParser]::Tokenize($code, [ref]$null)
+# and keep track of the previous token.
+$PreviousToken = $null
+# For each token
+foreach ($token in $codeTokens) {
+ # get the content
+ $content = $token.Content
+ # variables don't include the `$` or `@`
+ if ($token.Type -in 'Variable', 'String') {
+ # so fix our content for this special case.
+ $content = $code.Substring($token.Start, $token.Length)
+ }
+ # If there was a previous token
+ if ($PreviousToken) {
+ # Figure out how far it has been since our previous span,
+ $prevEnd = $PreviousToken.Start + $PreviousToken.Length
+ # get a substring,
+ $substring = $code.Substring($prevEnd, $token.Start - $prevEnd)
+ if ($substring) {
+ # and encode it.
+ [Web.HttpUtility]::HtmlEncode($substring)
+ }
+ }
+
+ # If we have any content
+ if ($content) {
+ # create a span of the right CSS class
+ "$(
+ # and encode the content
+ [Web.HttpUtility]::HtmlEncode($content)
+ )"
+ }
+
+ # Update our previous token pointer
+ $PreviousToken = $token
+}
+# After we have processed all tokens,
+"
" # close our block.
+) -join '' # Output our highlighted codeblock.
\ No newline at end of file
diff --git a/_includes/Palette.ps1 b/_includes/Palette.ps1
new file mode 100644
index 0000000..6fee1a4
--- /dev/null
+++ b/_includes/Palette.ps1
@@ -0,0 +1,137 @@
+<#
+.SYNOPSIS
+ Includes a palette selector
+.DESCRIPTION
+ Includes a palette selector and randomized palette switcher.
+
+ This allows the page to use multiple color palettes.
+#>
+param(
+# The source for all palette information
+[uri]
+$AllPalettesSource = 'https://4bitcss.com/Palettes.json',
+
+# The Palette CDN. This is the root URL of all palettes.
+[uri]
+$PaletteCDN = 'https://cdn.jsdelivr.net/gh/2bitdesigns/4bitcss@latest/css/',
+
+# The identifier for the palette `