Skip to content

Commit

Permalink
feat: add Blobs context (#6101)
Browse files Browse the repository at this point in the history
* feat: add blobs to functions context

* chore: add tests

* chore: tidy up comment

* chore: fix typo

* chore: run npm install in test

* fix: use default context
  • Loading branch information
eduardoboucas authored Oct 31, 2023
1 parent b72aa6c commit 719b2e3
Show file tree
Hide file tree
Showing 15 changed files with 224 additions and 14 deletions.
14 changes: 14 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"dependencies": {
"@bugsnag/js": "7.20.2",
"@fastify/static": "6.10.2",
"@netlify/blobs": "^4.0.0",
"@netlify/build": "29.23.4",
"@netlify/build-info": "7.10.1",
"@netlify/config": "20.9.0",
Expand Down
8 changes: 8 additions & 0 deletions src/commands/dev/dev.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import process from 'process'

import { Option } from 'commander'

import { getBlobsContext } from '../../lib/blobs/blobs.mjs'
import { promptEditorHelper } from '../../lib/edge-functions/editor-helper.mjs'
import { startFunctionsServer } from '../../lib/functions/server.mjs'
import { printBanner } from '../../utils/banner.mjs'
Expand Down Expand Up @@ -161,8 +162,15 @@ const dev = async (options, command) => {
},
})

const blobsContext = await getBlobsContext({
debug: options.debug,
projectRoot: command.workingDir,
siteID: site.id ?? 'unknown-site-id',
})

const functionsRegistry = await startFunctionsServer({
api,
blobsContext,
command,
config,
debug: options.debug,
Expand Down
50 changes: 50 additions & 0 deletions src/lib/blobs/blobs.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import path from 'path'

import { BlobsServer } from '@netlify/blobs'
import { v4 as uuidv4 } from 'uuid'

import { getPathInProject } from '../settings.mjs'

/**
* @typedef BlobsContext
* @type {object}
* @property {string} edgeURL
* @property {string} deployID
* @property {string} siteID
* @property {string} token
*/

/**
* Starts a local Blobs server and returns a context object that lets functions
* connect to it.
*
* @param {object} options
* @param {boolean} options.debug
* @param {string} options.projectRoot
* @param {string} options.siteID
* @returns {BlobsContext}
*/
export const getBlobsContext = async ({ debug, projectRoot, siteID }) => {
const token = uuidv4()
const { port } = await startBlobsServer({ debug, projectRoot, token })
const context = {
deployID: '0',
edgeURL: `http://localhost:${port}`,
siteID,
token,
}

return context
}

const startBlobsServer = async ({ debug, projectRoot, token }) => {
const directory = path.resolve(projectRoot, getPathInProject(['blobs']))
const server = new BlobsServer({
debug,
directory,
token,
})
const { port } = await server.start()

return { port }
}
27 changes: 26 additions & 1 deletion src/lib/functions/netlify-function.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// @ts-check
import { Buffer } from 'buffer'
import { basename, extname } from 'path'
import { version as nodeVersion } from 'process'

Expand All @@ -23,6 +24,7 @@ const getNextRun = function (schedule) {

export default class NetlifyFunction {
constructor({
blobsContext,
config,
directory,
displayName,
Expand All @@ -34,6 +36,7 @@ export default class NetlifyFunction {
timeoutBackground,
timeoutSynchronous,
}) {
this.blobsContext = blobsContext
this.buildError = null
this.config = config
this.directory = directory
Expand Down Expand Up @@ -181,18 +184,40 @@ export default class NetlifyFunction {
}

// Invokes the function and returns its response object.
async invoke(event, context) {
async invoke(event, context = {}) {
await this.buildQueue

if (this.buildError) {
return { result: null, error: { errorMessage: this.buildError.message } }
}

const timeout = this.isBackground ? this.timeoutBackground : this.timeoutSynchronous
const environment = {}

if (this.blobsContext) {
if (this.runtimeAPIVersion === 2) {
// For functions using the v2 API, we inject the context object into an
// environment variable.
environment.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(this.blobsContext)).toString('base64')
} else {
const payload = JSON.stringify({
url: this.blobsContext.edgeURL,
token: this.blobsContext.token,
})

// For functions using the Lambda compatibility mode, we pass the
// context as part of the `clientContext` property.
context.custom = {
...context?.custom,
blobs: Buffer.from(payload).toString('base64'),
}
}
}

try {
const result = await this.runtime.invokeFunction({
context,
environment,
event,
func: this,
timeout,
Expand Down
9 changes: 9 additions & 0 deletions src/lib/functions/registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const ZIP_EXTENSION = '.zip'

export class FunctionsRegistry {
constructor({
blobsContext,
capabilities,
config,
debug = false,
Expand All @@ -52,6 +53,13 @@ export class FunctionsRegistry {
this.timeouts = timeouts
this.settings = settings

/**
* Context object for Netlify Blobs
*
* @type {import("../blobs/blobs.mjs").BlobsContext}
*/
this.blobsContext = blobsContext

/**
* An object to be shared among all functions in the registry. It can be
* used to cache the results of the build function — e.g. it's used in
Expand Down Expand Up @@ -493,6 +501,7 @@ export class FunctionsRegistry {
}

const func = new NetlifyFunction({
blobsContext: this.blobsContext,
config: this.config,
directory: directories.find((directory) => mainFile.startsWith(directory)),
mainFile,
Expand Down
3 changes: 2 additions & 1 deletion src/lib/functions/runtimes/js/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,14 @@ export const getBuildFunction = async ({ config, directory, errorExit, func, pro

const workerURL = new URL('worker.mjs', import.meta.url)

export const invokeFunction = async ({ context, event, func, timeout }) => {
export const invokeFunction = async ({ context, environment, event, func, timeout }) => {
if (func.buildData.runtimeAPIVersion !== 2) {
return await invokeFunctionDirectly({ context, event, func, timeout })
}

const workerData = {
clientContext: JSON.stringify(context),
environment,
event,
// If a function builder has defined a `buildPath` property, we use it.
// Otherwise, we'll invoke the function's main file.
Expand Down
8 changes: 7 additions & 1 deletion src/lib/functions/runtimes/js/worker.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createServer } from 'net'
import process from 'process'
import { isMainThread, workerData, parentPort } from 'worker_threads'

import { isStream } from 'is-stream'
Expand All @@ -13,7 +14,12 @@ sourceMapSupport.install()

lambdaLocal.getLogger().level = 'alert'

const { clientContext, entryFilePath, event, timeoutMs } = workerData
const { clientContext, entryFilePath, environment = {}, event, timeoutMs } = workerData

// Injecting into the environment any properties passed in by the parent.
for (const key in environment) {
process.env[key] = environment[key]
}

const lambdaFunc = await import(entryFilePath)

Expand Down
17 changes: 15 additions & 2 deletions src/lib/functions/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ const getFunctionsServer = (options) => {
/**
*
* @param {object} options
* @param {import("../blobs/blobs.mjs").BlobsContext} options.blobsContext
* @param {import('../../commands/base-command.mjs').default} options.command
* @param {*} options.capabilities
* @param {*} options.config
Expand All @@ -258,8 +259,19 @@ const getFunctionsServer = (options) => {
* @returns {Promise<import('./registry.mjs').FunctionsRegistry | undefined>}
*/
export const startFunctionsServer = async (options) => {
const { capabilities, command, config, debug, loadDistFunctions, settings, site, siteInfo, siteUrl, timeouts } =
options
const {
blobsContext,
capabilities,
command,
config,
debug,
loadDistFunctions,
settings,
site,
siteInfo,
siteUrl,
timeouts,
} = options
const internalFunctionsDir = await getInternalFunctionsDir({ base: site.root })
const functionsDirectories = []
let manifest
Expand Down Expand Up @@ -306,6 +318,7 @@ export const startFunctionsServer = async (options) => {
}

const functionsRegistry = new FunctionsRegistry({
blobsContext,
capabilities,
config,
debug,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { getStore } from '@netlify/blobs'

export default async (req) => {
const store = getStore('my-store')
const metadata = {
name: 'Netlify',
features: {
blobs: true,
functions: true,
},
}

await store.set('my-key', 'hello world', { metadata })

const entry = await store.getWithMetadata('my-key')

return Response.json(entry)
}

export const config = {
path: '/blobs',
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "dev-server-with-v2-functions",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@netlify/blobs": "^4.0.0"
}
}
3 changes: 2 additions & 1 deletion tests/integration/commands/dev/dev-miscellaneous.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,8 @@ describe.concurrent('commands/dev-miscellaneous', () => {

const output = outputBuffer.toString()
const context = JSON.parse(output.match(/__CLIENT_CONTEXT__START__(.*)__CLIENT_CONTEXT__END__/)[1])
t.expect(context.clientContext).toBe(null)
t.expect(Object.keys(context.clientContext)).toEqual(['custom'])
t.expect(Object.keys(context.clientContext.custom)).toEqual(['blobs'])
t.expect(context.identity).toBe(null)
})
})
Expand Down
19 changes: 12 additions & 7 deletions tests/integration/commands/dev/dev.zisi.test.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Handlers are meant to be async outside tests
import { Buffer } from 'buffer'
import { copyFile } from 'fs/promises'
import { Agent } from 'node:https'
import os from 'os'
Expand Down Expand Up @@ -213,9 +214,9 @@ export const handler = async function () {
})
.withFunction({
path: 'hello.js',
handler: async (event) => ({
handler: async (event, context) => ({
statusCode: 200,
body: JSON.stringify({ rawUrl: event.rawUrl }),
body: JSON.stringify({ rawUrl: event.rawUrl, blobs: context.clientContext.custom.blobs }),
}),
})
.withEdgeFunction({
Expand Down Expand Up @@ -258,11 +259,15 @@ export const handler = async function () {
t.expect(await nodeFetch(`https://localhost:${port}?ef=fetch`, options).then((res) => res.text())).toEqual(
'origin',
)
t.expect(
await nodeFetch(`https://localhost:${port}/api/hello`, options).then((res) => res.json()),
).toStrictEqual({
rawUrl: `https://localhost:${port}/api/hello`,
})

const hello = await nodeFetch(`https://localhost:${port}/api/hello`, options).then((res) => res.json())

t.expect(hello.rawUrl).toBe(`https://localhost:${port}/api/hello`)

const blobsContext = JSON.parse(Buffer.from(hello.blobs, 'base64').toString())

t.expect(blobsContext.url).toBeTruthy()
t.expect(blobsContext.token).toBeTruthy()

// the fetch will go against the `https://` url of the dev server, which isn't trusted system-wide.
// this is the expected behaviour for fetch, so we shouldn't change anything about it.
Expand Down
Loading

2 comments on commit 719b2e3

@github-actions
Copy link

Choose a reason for hiding this comment

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

📊 Benchmark results

  • Dependency count: 1,373
  • Package size: 381 MB

@github-actions
Copy link

Choose a reason for hiding this comment

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

📊 Benchmark results

  • Dependency count: 1,373
  • Package size: 381 MB

Please sign in to comment.