diff --git a/package-lock.json b/package-lock.json index dc707602023..bf7e8103265 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,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", @@ -2337,6 +2338,14 @@ "resolved": "https://registry.npmjs.org/@netlify/binary-info/-/binary-info-1.0.0.tgz", "integrity": "sha512-4wMPu9iN3/HL97QblBsBay3E1etIciR84izI3U+4iALY+JHCrI+a2jO0qbAZ/nxKoegypYEaiiqWXylm+/zfrw==" }, + "node_modules/@netlify/blobs": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-4.0.0.tgz", + "integrity": "sha512-jjAzsH5WCceUz8ubVlYppfhUKuTR4E6OBNherIdH7tYHWy4NnLQ5FQgVP9kR7Ps5HOxl3aPsr5ygu1KQY0mdTQ==", + "engines": { + "node": "^14.16.0 || >=16.0.0" + } + }, "node_modules/@netlify/build": { "version": "29.23.4", "resolved": "https://registry.npmjs.org/@netlify/build/-/build-29.23.4.tgz", @@ -27034,6 +27043,11 @@ "resolved": "https://registry.npmjs.org/@netlify/binary-info/-/binary-info-1.0.0.tgz", "integrity": "sha512-4wMPu9iN3/HL97QblBsBay3E1etIciR84izI3U+4iALY+JHCrI+a2jO0qbAZ/nxKoegypYEaiiqWXylm+/zfrw==" }, + "@netlify/blobs": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-4.0.0.tgz", + "integrity": "sha512-jjAzsH5WCceUz8ubVlYppfhUKuTR4E6OBNherIdH7tYHWy4NnLQ5FQgVP9kR7Ps5HOxl3aPsr5ygu1KQY0mdTQ==" + }, "@netlify/build": { "version": "29.23.4", "resolved": "https://registry.npmjs.org/@netlify/build/-/build-29.23.4.tgz", diff --git a/package.json b/package.json index 61245b090a7..36b90593d87 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/commands/dev/dev.mjs b/src/commands/dev/dev.mjs index dc06dfca8d3..d697672456d 100644 --- a/src/commands/dev/dev.mjs +++ b/src/commands/dev/dev.mjs @@ -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' @@ -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, diff --git a/src/lib/blobs/blobs.mjs b/src/lib/blobs/blobs.mjs new file mode 100644 index 00000000000..31ee0aa98e3 --- /dev/null +++ b/src/lib/blobs/blobs.mjs @@ -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 } +} diff --git a/src/lib/functions/netlify-function.mjs b/src/lib/functions/netlify-function.mjs index 1002cde8696..6b866418a21 100644 --- a/src/lib/functions/netlify-function.mjs +++ b/src/lib/functions/netlify-function.mjs @@ -1,4 +1,5 @@ // @ts-check +import { Buffer } from 'buffer' import { basename, extname } from 'path' import { version as nodeVersion } from 'process' @@ -23,6 +24,7 @@ const getNextRun = function (schedule) { export default class NetlifyFunction { constructor({ + blobsContext, config, directory, displayName, @@ -34,6 +36,7 @@ export default class NetlifyFunction { timeoutBackground, timeoutSynchronous, }) { + this.blobsContext = blobsContext this.buildError = null this.config = config this.directory = directory @@ -181,7 +184,7 @@ 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) { @@ -189,10 +192,32 @@ export default class NetlifyFunction { } 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, diff --git a/src/lib/functions/registry.mjs b/src/lib/functions/registry.mjs index 85a563b3f7a..469846cce58 100644 --- a/src/lib/functions/registry.mjs +++ b/src/lib/functions/registry.mjs @@ -34,6 +34,7 @@ const ZIP_EXTENSION = '.zip' export class FunctionsRegistry { constructor({ + blobsContext, capabilities, config, debug = false, @@ -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 @@ -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, diff --git a/src/lib/functions/runtimes/js/index.mjs b/src/lib/functions/runtimes/js/index.mjs index 7f8dfcea0c9..493f9691a7f 100644 --- a/src/lib/functions/runtimes/js/index.mjs +++ b/src/lib/functions/runtimes/js/index.mjs @@ -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. diff --git a/src/lib/functions/runtimes/js/worker.mjs b/src/lib/functions/runtimes/js/worker.mjs index 3fc15efdf63..5b2d9f2583b 100644 --- a/src/lib/functions/runtimes/js/worker.mjs +++ b/src/lib/functions/runtimes/js/worker.mjs @@ -1,4 +1,5 @@ import { createServer } from 'net' +import process from 'process' import { isMainThread, workerData, parentPort } from 'worker_threads' import { isStream } from 'is-stream' @@ -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) diff --git a/src/lib/functions/server.mjs b/src/lib/functions/server.mjs index d0eadf4762b..75d2f225a32 100644 --- a/src/lib/functions/server.mjs +++ b/src/lib/functions/server.mjs @@ -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 @@ -258,8 +259,19 @@ const getFunctionsServer = (options) => { * @returns {Promise} */ 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 @@ -306,6 +318,7 @@ export const startFunctionsServer = async (options) => { } const functionsRegistry = new FunctionsRegistry({ + blobsContext, capabilities, config, debug, diff --git a/tests/integration/__fixtures__/dev-server-with-v2-functions/functions/blobs.mjs b/tests/integration/__fixtures__/dev-server-with-v2-functions/functions/blobs.mjs new file mode 100644 index 00000000000..f5cfd1ac1dc --- /dev/null +++ b/tests/integration/__fixtures__/dev-server-with-v2-functions/functions/blobs.mjs @@ -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', +} diff --git a/tests/integration/__fixtures__/dev-server-with-v2-functions/package-lock.json b/tests/integration/__fixtures__/dev-server-with-v2-functions/package-lock.json new file mode 100644 index 00000000000..5184e2f8665 --- /dev/null +++ b/tests/integration/__fixtures__/dev-server-with-v2-functions/package-lock.json @@ -0,0 +1,24 @@ +{ + "name": "dev-server-with-v2-functions", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dev-server-with-v2-functions", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "@netlify/blobs": "^4.0.0" + } + }, + "node_modules/@netlify/blobs": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-4.0.0.tgz", + "integrity": "sha512-jjAzsH5WCceUz8ubVlYppfhUKuTR4E6OBNherIdH7tYHWy4NnLQ5FQgVP9kR7Ps5HOxl3aPsr5ygu1KQY0mdTQ==", + "engines": { + "node": "^14.16.0 || >=16.0.0" + } + } + } +} diff --git a/tests/integration/__fixtures__/dev-server-with-v2-functions/package.json b/tests/integration/__fixtures__/dev-server-with-v2-functions/package.json new file mode 100644 index 00000000000..8072ec22dd1 --- /dev/null +++ b/tests/integration/__fixtures__/dev-server-with-v2-functions/package.json @@ -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" + } +} diff --git a/tests/integration/commands/dev/dev-miscellaneous.test.mjs b/tests/integration/commands/dev/dev-miscellaneous.test.mjs index f0a492f52c6..db88bc34108 100644 --- a/tests/integration/commands/dev/dev-miscellaneous.test.mjs +++ b/tests/integration/commands/dev/dev-miscellaneous.test.mjs @@ -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) }) }) diff --git a/tests/integration/commands/dev/dev.zisi.test.mjs b/tests/integration/commands/dev/dev.zisi.test.mjs index 25ef97b6c29..190baa2160f 100644 --- a/tests/integration/commands/dev/dev.zisi.test.mjs +++ b/tests/integration/commands/dev/dev.zisi.test.mjs @@ -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' @@ -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({ @@ -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. diff --git a/tests/integration/commands/dev/v2-api.test.ts b/tests/integration/commands/dev/v2-api.test.ts index 8cf23d30880..1814f6887b0 100644 --- a/tests/integration/commands/dev/v2-api.test.ts +++ b/tests/integration/commands/dev/v2-api.test.ts @@ -1,5 +1,6 @@ import { version } from 'process' +import execa from 'execa' import { gte } from 'semver' import { describe, expect, test } from 'vitest' @@ -21,8 +22,12 @@ const routes = [ }, ] +const setup = async ({ fixture }) => { + await execa('npm', ['install'], { cwd: fixture.directory }) +} + describe.runIf(gte(version, '18.13.0'))('v2 api', () => { - setupFixtureTests('dev-server-with-v2-functions', { devServer: true, mockApi: { routes } }, () => { + setupFixtureTests('dev-server-with-v2-functions', { devServer: true, mockApi: { routes }, setup }, () => { test('should successfully be able to run v2 functions', async ({ devServer }) => { const response = await got(`http://localhost:${devServer.port}/.netlify/functions/ping`, { throwHttpErrors: false, @@ -184,5 +189,16 @@ describe.runIf(gte(version, '18.13.0'))('v2 api', () => { expect(await response.text()).toBe('/v2-to-custom-without-force from origin') }) }) + + test('has access to Netlify Blobs', async ({ devServer }) => { + const response = await fetch(`http://localhost:${devServer.port}/blobs`) + + expect(response.status).toBe(200) + + const body = await response.json() + + expect(body.data).toBe('hello world') + expect(body.metadata).toEqual({ name: 'Netlify', features: { blobs: true, functions: true } }) + }) }) })