Maybe there is a better way, but here's one way to do it: submit a form with the desired filename and the data as two hidden form elements.  Have the server simply return the data with the appropriate headers set for a file download.  No need for tmp files;  works on all browsers.
HTML:
<form id="download-form" method="post">
   <input type="button" value="download CSV">
</form>
<!-- the button is right above the HTML table -->
<table>... </table>
JavaScript/D3:
var jsonData;
var filenameDateFormat = d3.time.format("%Y%m%d-%H%M%S");
// ... after loading the data, and setting jsonData to the data returned from d3.csv()
jsonData = data;
// display the form/button, which is initially hidden
d3.select("#download-form").style("display", "block");
d3.select("#download-form input[type=button]").on('click', function() {
    var downloadForm = d3.select("#download-form");
    // remove any existing hidden fields, because maybe the data changed
    downloadForm.selectAll("input[type=hidden]").remove();
    downloadForm
        .each(function() {
            d3.select(this).append("input")
                .attr({ type:  "hidden",
                        name:  "filename",
                        value: CHART_NAME + "-" 
                               + filenameDateFormat(new Date()) + ".csv"});
            d3.select(this).append("input")
                .attr({ type:  "hidden",
                        name:  "data",
                        value: convertToCsv(jsonData) });
        });
    document.getElementById("download-form").submit();
});
function convertToCsv(data) {
    var csvArray = ['field_name1_here,field_name2_here,...'];
    data.forEach(function(d) {
        csvArray.push(d.field_name1_here + ',' + d.field_name2_here + ...);
    });
    return csvArray.join("\n");
}
Server (Python, using Bottle):
@app.route('/download', method='POST')
def download():
    if request.environ.get('HTTP_USER_AGENT').find('Chrome'):
        # don't add the Content-Type, as this causes Chrome to output the following
        # to the console:
        #  Resource interpreted as Document but transferred with MIME type text/csv
        pass
    else:
        response.set_header('Content-Type', 'text/csv')
    response.set_header('Content-Disposition',
        'attachment; filename="' + request.forms.filename + '"')
    return request.forms.data
Not pretty, but it works.