From 60ecb333a1396f9aa7244eac2f38741a58e7281f Mon Sep 17 00:00:00 2001 From: Alec Aivazis Date: Sun, 4 Sep 2022 14:07:32 -0700 Subject: [PATCH] Add sessions (#508) * Merge fehnomenal's work (#507) * Allow passing session data to the fetch function * Prefer the client side session * Receive the server session only in the browser * Separate client and server sessions more clearly * make test more resilient * fake root layout file * fake +layout.server.js * kit transform threads session from +layout.server.js to client * remove client.init Co-authored-by: Andreas Fehn * rename setServerSession to setSession * receiving server session data needs to be reactive * document no more init * update authentication guide * auth guide tweaks * use App.Session for SessionData * remove document proxy * added integration test for session * make checker happy * add changeset * remove unused import * fix inconsistent absolutely path in CI * tweak util names * find_exported_fn considers export const * better support for load functions with implicit returns * track hydrated state in root layout * pass session to client side operations (mutations, loadNextPage, etc) * updated snapshots * add log if no session in locals * block on error * document onError blocking * update snapshot * fix duplicate test name Co-authored-by: Andreas Fehn --- .changeset/nervous-pots-sort.md | 5 + integration/src/app.d.ts | 4 +- integration/src/hooks.ts | 15 +- integration/src/lib/graphql/houdiniClient.ts | 12 +- .../lib/graphql/operations/QUERY.Session.gql | 3 - integration/src/lib/utils/routes.ts | 1 + .../src/routes/plugin/query/onError/+page.ts | 3 +- .../src/routes/plugin/query/onError/spec.ts | 9 +- .../src/routes/plugin/query/scalars/spec.ts | 3 +- .../routes/stores/endpoint-query/+page.svelte | 2 +- .../src/routes/stores/endpoint-query/spec.ts | 2 +- .../src/routes/stores/session/+page.svelte | 15 ++ integration/src/routes/stores/session/spec.ts | 13 + rollup.config.js | 5 + site/src/routes/api/routes.svx | 9 +- site/src/routes/guides/authentication.svx | 59 ++-- site/src/routes/guides/migrating-to-016.svx | 10 + .../routes/guides/setting-up-your-project.svx | 14 +- src/cmd/generate.ts | 4 +- src/cmd/generators/definitions/schema.test.ts | 6 +- src/cmd/generators/runtime/adapter.ts | 42 +-- .../generators/runtime/copyRuntime.test.ts | 208 +------------- src/cmd/generators/typescript/index.ts | 4 +- src/cmd/init.ts | 20 -- src/cmd/transforms/list.ts | 12 +- src/cmd/transforms/schema.ts | 28 +- src/cmd/validators/typeCheck.ts | 8 +- src/common/config.ts | 14 + src/runtime/adapter.ts | 4 + src/runtime/index.ts | 8 +- src/runtime/inline/query.ts | 2 +- src/runtime/lib/constants.ts | 12 +- src/runtime/lib/index.ts | 5 +- src/runtime/lib/log.ts | 2 +- src/runtime/lib/network.ts | 54 +++- src/runtime/lib/proxy.ts | 28 -- src/runtime/stores/mutation.ts | 5 +- src/runtime/stores/pagination/cursor.ts | 9 +- src/runtime/stores/pagination/fragment.ts | 4 +- src/runtime/stores/pagination/offset.ts | 5 +- src/runtime/stores/pagination/pageInfo.ts | 4 +- src/runtime/stores/query.ts | 40 ++- src/runtime/stores/subscription.ts | 2 +- src/vite/ast.ts | 89 +++++- src/vite/fsPatch.ts | 91 +++++-- src/vite/imports.ts | 70 ++--- src/vite/tests.ts | 48 ++++ src/vite/transforms/index.ts | 1 - src/vite/transforms/kit/index.ts | 20 ++ src/vite/transforms/kit/init.test.ts | 28 ++ src/vite/transforms/kit/init.ts | 64 +++++ .../{kit.test.ts => kit/load.test.ts} | 27 +- src/vite/transforms/{kit.ts => kit/load.ts} | 37 +-- src/vite/transforms/kit/session.test.ts | 253 ++++++++++++++++++ src/vite/transforms/kit/session.ts | 136 ++++++++++ src/vite/transforms/query.ts | 12 +- src/vite/transforms/tags.ts | 9 +- vitest.setup.ts | 6 +- 58 files changed, 1075 insertions(+), 530 deletions(-) create mode 100644 .changeset/nervous-pots-sort.md delete mode 100644 integration/src/lib/graphql/operations/QUERY.Session.gql create mode 100644 integration/src/routes/stores/session/+page.svelte create mode 100644 integration/src/routes/stores/session/spec.ts delete mode 100644 src/runtime/lib/proxy.ts create mode 100644 src/vite/transforms/kit/index.ts create mode 100644 src/vite/transforms/kit/init.test.ts create mode 100644 src/vite/transforms/kit/init.ts rename src/vite/transforms/{kit.test.ts => kit/load.test.ts} (97%) rename src/vite/transforms/{kit.ts => kit/load.ts} (94%) create mode 100644 src/vite/transforms/kit/session.test.ts create mode 100644 src/vite/transforms/kit/session.ts diff --git a/.changeset/nervous-pots-sort.md b/.changeset/nervous-pots-sort.md new file mode 100644 index 000000000..151224d1f --- /dev/null +++ b/.changeset/nervous-pots-sort.md @@ -0,0 +1,5 @@ +--- +'houdini': patch +--- + +added support for sessions diff --git a/integration/src/app.d.ts b/integration/src/app.d.ts index 9d978f5b2..d6331076a 100644 --- a/integration/src/app.d.ts +++ b/integration/src/app.d.ts @@ -6,7 +6,9 @@ declare namespace App { // interface Platform {} interface Session { - token?: string | null; + user?: { + token: string; + }; } // interface Stuff {} diff --git a/integration/src/hooks.ts b/integration/src/hooks.ts index 0dd0c7c93..77f97f14d 100644 --- a/integration/src/hooks.ts +++ b/integration/src/hooks.ts @@ -1,5 +1,10 @@ -export function getSession() { - return { - token: '1234-Houdini-Token-5678' - }; -} +import houdini from './lib/graphql/houdiniClient'; +import type { Handle } from '@sveltejs/kit'; + +export const handle: Handle = async ({ event, resolve }) => { + // set the session information for this event + houdini.setSession(event, { user: { token: '1234-Houdini-Token-5678' } }); + + // pass the event onto the default handle + return await resolve(event); +}; diff --git a/integration/src/lib/graphql/houdiniClient.ts b/integration/src/lib/graphql/houdiniClient.ts index f18767f25..21d1a2460 100644 --- a/integration/src/lib/graphql/houdiniClient.ts +++ b/integration/src/lib/graphql/houdiniClient.ts @@ -3,7 +3,13 @@ import { HoudiniClient } from '$houdini'; import { stry } from '@kitql/helper'; // For Query & Mutation -async function fetchQuery({ fetch, text = '', variables = {}, metadata }: RequestHandlerArgs) { +async function fetchQuery({ + fetch, + text = '', + variables = {}, + metadata, + session +}: RequestHandlerArgs) { // Prepare the request const url = import.meta.env.VITE_GRAPHQL_ENDPOINT || 'http://localhost:4000/graphql'; @@ -11,8 +17,8 @@ async function fetchQuery({ fetch, text = '', variables = {}, metadata }: Reques const result = await fetch(url, { method: 'POST', headers: { - 'Content-Type': 'application/json' - // Authorization: `Bearer ${session?.token}` // session usage example + 'Content-Type': 'application/json', + Authorization: `Bearer ${session?.user?.token}` // session usage example }, body: JSON.stringify({ query: text, diff --git a/integration/src/lib/graphql/operations/QUERY.Session.gql b/integration/src/lib/graphql/operations/QUERY.Session.gql deleted file mode 100644 index 654d2aa31..000000000 --- a/integration/src/lib/graphql/operations/QUERY.Session.gql +++ /dev/null @@ -1,3 +0,0 @@ -query Session { - session -} diff --git a/integration/src/lib/utils/routes.ts b/integration/src/lib/utils/routes.ts index 1b1b98723..85e3f68ec 100644 --- a/integration/src/lib/utils/routes.ts +++ b/integration/src/lib/utils/routes.ts @@ -16,6 +16,7 @@ export const routes = { Stores_Metadata: '/stores/metadata', Stores_Endpoint_Query: '/stores/endpoint-query', Stores_Endpoint_Mutation: '/stores/endpoint-mutation', + Stores_Session: '/stores/session', Stores_Partial_List: '/stores/partial/partial_List', Stores_Pagination_query_forward_cursor: '/stores/pagination/query/forward-cursor', diff --git a/integration/src/routes/plugin/query/onError/+page.ts b/integration/src/routes/plugin/query/onError/+page.ts index 4ce4ca7a0..b9fe5aa4f 100644 --- a/integration/src/routes/plugin/query/onError/+page.ts +++ b/integration/src/routes/plugin/query/onError/+page.ts @@ -1,5 +1,4 @@ import { graphql } from '$houdini'; -import type { OnErrorEvent } from './$houdini'; export const houdini_load = graphql` query PreprocessorOnErrorTestQuery { @@ -9,7 +8,7 @@ export const houdini_load = graphql` } `; -export const onError = ({ error, input }: OnErrorEvent) => { +export const onError = () => { return { fancyMessage: 'hello' }; diff --git a/integration/src/routes/plugin/query/onError/spec.ts b/integration/src/routes/plugin/query/onError/spec.ts index ee7967664..e088cded8 100644 --- a/integration/src/routes/plugin/query/onError/spec.ts +++ b/integration/src/routes/plugin/query/onError/spec.ts @@ -1,6 +1,6 @@ import { test } from '@playwright/test'; import { routes } from '../../../../lib/utils/routes.js'; -import { expectToBe, goto } from '../../../../lib/utils/testsHelper.js'; +import { clientSideNavigation, expectToBe, goto } from '../../../../lib/utils/testsHelper.js'; test.describe('query preprocessor', () => { test('onError hook', async ({ page }) => { @@ -8,4 +8,11 @@ test.describe('query preprocessor', () => { await expectToBe(page, 'hello'); }); + + test('onError hook blocks on client', async ({ page }) => { + await goto(page, routes.Home); + await clientSideNavigation(page, routes.Plugin_query_onError); + + await expectToBe(page, 'hello'); + }); }); diff --git a/integration/src/routes/plugin/query/scalars/spec.ts b/integration/src/routes/plugin/query/scalars/spec.ts index cd669dc84..c1a0c660e 100644 --- a/integration/src/routes/plugin/query/scalars/spec.ts +++ b/integration/src/routes/plugin/query/scalars/spec.ts @@ -1,13 +1,12 @@ import { routes } from '../../../../lib/utils/routes.js'; import { - expect_1_gql, expectToBe, goto, navSelector, clientSideNavigation, expect_0_gql } from '../../../../lib/utils/testsHelper.js'; -import { expect, test } from '@playwright/test'; +import { test } from '@playwright/test'; test.describe('query preprocessor variables', () => { test('query values get unmarshaled into complex values', async function ({ page }) { diff --git a/integration/src/routes/stores/endpoint-query/+page.svelte b/integration/src/routes/stores/endpoint-query/+page.svelte index 4cdbf9e5f..c4c2bf0cb 100644 --- a/integration/src/routes/stores/endpoint-query/+page.svelte +++ b/integration/src/routes/stores/endpoint-query/+page.svelte @@ -4,5 +4,5 @@
- {JSON.stringify({ data })} + {data.hello}
diff --git a/integration/src/routes/stores/endpoint-query/spec.ts b/integration/src/routes/stores/endpoint-query/spec.ts index d70a8f890..1a42970e7 100644 --- a/integration/src/routes/stores/endpoint-query/spec.ts +++ b/integration/src/routes/stores/endpoint-query/spec.ts @@ -6,6 +6,6 @@ test.describe('query endpoint', () => { test('happy path query ', async ({ page }) => { await goto(page, routes.Stores_Endpoint_Query); - await expectToBe(page, JSON.stringify({ data: { hello: 'Hello World! // From Houdini!' } })); + await expectToBe(page, 'Hello World! // From Houdini!'); }); }); diff --git a/integration/src/routes/stores/session/+page.svelte b/integration/src/routes/stores/session/+page.svelte new file mode 100644 index 000000000..40f2256f4 --- /dev/null +++ b/integration/src/routes/stores/session/+page.svelte @@ -0,0 +1,15 @@ + + +

SSR Session

+ +
+ {$Session.data?.session} +
diff --git a/integration/src/routes/stores/session/spec.ts b/integration/src/routes/stores/session/spec.ts new file mode 100644 index 000000000..081e2efad --- /dev/null +++ b/integration/src/routes/stores/session/spec.ts @@ -0,0 +1,13 @@ +import { test } from '@playwright/test'; +import { routes } from '../../../lib/utils/routes.js'; +import { expectToBe, expect_0_gql } from '../../../lib/utils/testsHelper.js'; + +test.describe('SSR Session Page', () => { + test('No GraphQL request & Should display the session token', async ({ page }) => { + await page.goto(routes.Stores_Session); + + await expect_0_gql(page); + + await expectToBe(page, '1234-Houdini-Token-5678'); + }); +}); diff --git a/rollup.config.js b/rollup.config.js index b5ab41cce..a5d3db347 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -2,8 +2,10 @@ import commonjs from '@rollup/plugin-commonjs' import json from '@rollup/plugin-json' import { nodeResolve } from '@rollup/plugin-node-resolve' import replace from '@rollup/plugin-replace' +import fs from 'node:fs' import typescript from 'rollup-plugin-typescript2' +import changesetConfig from './.changeset/config.json' import packgeJSON from './package.json' // grab the environment variables @@ -43,6 +45,9 @@ export default { nodeResolve({ preferBuiltins: true }), replace({ HOUDINI_VERSION: packgeJSON.version, + SITE_URL: fs.existsSync('./.changeset/pre.json') + ? 'https://docs-next-kohl.vercel.app' + : 'https://houdinigraphql.com', }), ], } diff --git a/site/src/routes/api/routes.svx b/site/src/routes/api/routes.svx index 0d02e8a09..cef5a6a76 100644 --- a/site/src/routes/api/routes.svx +++ b/site/src/routes/api/routes.svx @@ -6,7 +6,7 @@ description: Queries in Houdini -``` - ### Svelte If you are building a vanilla svelte project, you will have to configure the compiler and preprocessor to generate the correct logic by setting the `framework` @@ -112,7 +101,8 @@ export default defineConfig({ ``` If you aren't using vite, it's a lot harder to give an exact recommendation but somehow -you should import houdini's preprocessor and pass it to your svelte config +you should import houdini's preprocessor and pass it to your svelte config. You will also +need to make sure that the `$houdini` alias resolves to the directory in the root of your project. ```javascript // svelte.config.js diff --git a/src/cmd/generate.ts b/src/cmd/generate.ts index 4d116bca9..9f5b00987 100755 --- a/src/cmd/generate.ts +++ b/src/cmd/generate.ts @@ -17,7 +17,7 @@ import { parseJS, HoudiniError, } from '../common' -import { getSiteUrl } from '../runtime/lib/constants' +import { siteURL } from '../runtime/lib/constants' import * as generators from './generators' import * as transforms from './transforms' import { CollectedGraphQLDocument, ArtifactKind } from './types' @@ -160,7 +160,7 @@ export async function runPipeline(config: Config, docs: CollectedGraphQLDocument const major = parseInt(previousVersion.split('.')[1]) if (major < 16) { console.log( - `❓ For a description of what's changed, visit this guide: ${getSiteUrl()}/guides/release-notes` + `❓ For a description of what's changed, visit this guide: ${siteURL}/guides/release-notes` ) } } diff --git a/src/cmd/generators/definitions/schema.test.ts b/src/cmd/generators/definitions/schema.test.ts index 8688a9553..2bc9c32d5 100644 --- a/src/cmd/generators/definitions/schema.test.ts +++ b/src/cmd/generators/definitions/schema.test.ts @@ -35,7 +35,7 @@ test('adds internal documents to schema', async function () { """ @paginate is used to to mark a field for pagination. - More info in the [doc](https://docs-next-kohl.vercel.app/guides/pagination). + More info in the [doc](SITE_URL/guides/pagination). """ directive @paginate(name: String) on FIELD @@ -96,7 +96,7 @@ test('list operations are included', async function () { """ @paginate is used to to mark a field for pagination. - More info in the [doc](https://docs-next-kohl.vercel.app/guides/pagination). + More info in the [doc](SITE_URL/guides/pagination). """ directive @paginate(name: String) on FIELD @@ -176,7 +176,7 @@ test("writing twice doesn't duplicate definitions", async function () { """ @paginate is used to to mark a field for pagination. - More info in the [doc](https://docs-next-kohl.vercel.app/guides/pagination). + More info in the [doc](SITE_URL/guides/pagination). """ directive @paginate(name: String) on FIELD diff --git a/src/cmd/generators/runtime/adapter.ts b/src/cmd/generators/runtime/adapter.ts index 17ff69485..35bbeac70 100644 --- a/src/cmd/generators/runtime/adapter.ts +++ b/src/cmd/generators/runtime/adapter.ts @@ -3,13 +3,17 @@ import path from 'path' import { Config, writeFile } from '../../../common' export default async function generateAdapter(config: Config) { + // we only need to generate an adapter for kit (the default one is fine for vanilla svelte) + if (config.framework !== 'kit') { + return + } + // the location of the adapter const adapterLocation = path.join(config.runtimeDirectory, 'adapter.js') // figure out which adapter we need to lay down const adapter = { kit: sveltekitAdapter, - svelte: svelteAdapter, }[config.framework] // write the index file that exports the runtime @@ -19,8 +23,9 @@ export default async function generateAdapter(config: Config) { const sveltekitAdapter = `import { goto as go } from '$app/navigation' import { get } from 'svelte/store'; import { browser, prerendering } from '$app/environment' +import { page } from '$app/stores' import { error as svelteKitError } from '@sveltejs/kit' - +import { sessionKeyName } from './lib/network' export function goTo(location, options) { go(location, options) @@ -28,38 +33,13 @@ export function goTo(location, options) { export const isBrowser = browser -/** - * After \`clientStarted = true\`, only client side navigation will happen. - */ -export let clientStarted = false; // Will be true on a client side navigation -if (browser) { - addEventListener('sveltekit:start', () => { - clientStarted = true; - }); +export let clientStarted = false; + +export function setClientStarted() { + clientStarted = true } export const isPrerender = prerendering export const error = svelteKitError - -` - -const svelteAdapter = ` -import { readable, writable } from 'svelte/store' - -export function goTo(location, options) { - window.location = location -} - -export const isBrowser = true - -export const clientStarted = true - -export const isPrerender = false - -export const error = (code, message) => { - const err = new Error(message) - error.code = code - return err -} ` diff --git a/src/cmd/generators/runtime/copyRuntime.test.ts b/src/cmd/generators/runtime/copyRuntime.test.ts index 6a550236f..45a6a2ce1 100644 --- a/src/cmd/generators/runtime/copyRuntime.test.ts +++ b/src/cmd/generators/runtime/copyRuntime.test.ts @@ -78,213 +78,9 @@ test('updates the network file with the client path', async function () { const parsedQuery: ProgramKind = recast.parse(fileContents!, { parser: typeScriptParser, }).program - // verify contents - expect(parsedQuery).toMatchInlineSnapshot(` - import { error, redirect } from '@sveltejs/kit'; - import { get } from 'svelte/store'; - import cache from '../cache'; - import * as log from './log'; - import { marshalInputs } from './scalars'; - import { CachePolicy, DataSource, } from './types'; - export class HoudiniClient { - constructor(networkFn, subscriptionHandler) { - this.fetchFn = networkFn; - this.socket = subscriptionHandler; - } - async sendRequest(ctx, params) { - let url = ''; - // invoke the function - const result = await this.fetchFn({ - // wrap the user's fetch function so we can identify SSR by checking - // the response.url - fetch: async (...args) => { - const response = await ctx.fetch(...args); - if (response.url) { - url = response.url; - } - return response; - }, - ...params, - metadata: ctx.metadata, - }); - // return the result - return { - body: result, - ssr: !url, - }; - } - init() { } - } - export class Environment extends HoudiniClient { - constructor(...args) { - super(...args); - log.info(\`\${log.red('⚠️ Environment has been renamed to HoudiniClient. ⚠️')} - You should update your client to look something like the following: - import { HoudiniClient } from '$houdini/runtime' - - export default new HoudiniClient(fetchQuery) - \`); - } - } - // This function is responsible for simulating the fetch context and executing the query with fetchQuery. - // It is mainly used for mutations, refetch and possible other client side operations in the future. - export async function executeQuery({ artifact, variables, cached, config, fetch, metadata, }) { - const { result: res, partial } = await fetchQuery({ - context: { - fetch: fetch !== null && fetch !== void 0 ? fetch : globalThis.fetch.bind(globalThis), - metadata, - }, - config, - artifact, - variables, - cached, - }); - // we could have gotten a null response - if (res.errors && res.errors.length > 0) { - throw res.errors; - } - if (!res.data) { - throw new Error('Encountered empty data response in payload'); - } - return { result: res, partial }; - } - export async function getCurrentClient() { - // @ts-ignore - return (await import('../../../my/client/path')).default; - } - export async function fetchQuery({ artifact, variables, cached = true, policy, context, }) { - const client = await getCurrentClient(); - // if there is no environment - if (!client) { - throw new Error('could not find houdini environment'); - } - // enforce cache policies for queries - if (cached && artifact.kind === 'HoudiniQuery') { - // if the user didn't specify a policy, use the artifacts - if (!policy) { - policy = artifact.policy; - } - // this function is called as the first step in requesting data. If the policy prefers - // cached data, we need to load data from the cache (if its available). If the policy - // prefers network data we need to send a request (the onLoad of the component will - // resolve the next data) - // if the cache policy allows for cached data, look at the caches value first - if (policy !== CachePolicy.NetworkOnly) { - // look up the current value in the cache - const value = cache.read({ selection: artifact.selection, variables }); - // if the result is partial and we dont allow it, dont return the value - const allowed = !value.partial || artifact.partial; - // if we have data, use that unless its partial data and we dont allow that - if (value.data !== null && allowed) { - return { - result: { - data: value.data, - errors: [], - }, - source: DataSource.Cache, - partial: value.partial, - }; - } - // if the policy is cacheOnly and we got this far, we need to return null (no network request will be sent) - else if (policy === CachePolicy.CacheOnly) { - return { - result: { - data: null, - errors: [], - }, - source: DataSource.Cache, - partial: false, - }; - } - } - } - // tick the garbage collector asynchronously - setTimeout(() => { - cache._internal_unstable.collectGarbage(); - }, 0); - // the request must be resolved against the network - const result = await client.sendRequest(context, { - text: artifact.raw, - hash: artifact.hash, - variables, - }); - return { - result: result.body, - source: result.ssr ? DataSource.Ssr : DataSource.Network, - partial: false, - }; - } - export class RequestContext { - constructor(ctx) { - this.continue = true; - this.returnValue = {}; - this.loadEvent = ctx; - } - error(status, message) { - throw error(status, typeof message === 'string' ? message : message.message); - } - redirect(status, location) { - throw redirect(status, location); - } - fetch(input, init) { - // make sure to bind the window object to the fetch in a browser - const fetch = typeof window !== 'undefined' ? this.loadEvent.fetch.bind(window) : this.loadEvent.fetch; - return fetch(input, init); - } - graphqlErrors(payload) { - // if we have a list of errors - if (payload.errors) { - return this.error(500, payload.errors.map(({ message }) => message).join('\\n')); - } - return this.error(500, 'Encountered invalid response: ' + JSON.stringify(payload)); - } - // This hook fires before executing any queries, it allows custom props to be passed to the component. - async invokeLoadHook({ variant, hookFn, input, data, error, }) { - // call the onLoad function to match the framework - let hookCall; - if (variant === 'before') { - hookCall = hookFn.call(this, this.loadEvent); - } - else if (variant === 'after') { - // we have to assign input and data onto load so that we don't read values that - // are deprecated - hookCall = hookFn.call(this, { - event: this.loadEvent, - input, - data: Object.fromEntries(Object.entries(data).map(([key, store]) => [ - key, - get(store).data, - ])), - }); - } - else if (variant === 'error') { - hookCall = hookFn.call(this, { - event: this.loadEvent, - input, - error, - }); - } - // make sure any promises are resolved - let result = await hookCall; - // If the returnValue is already set through this.error or this.redirect return early - if (!this.continue) { - return; - } - // If the result is null or undefined, or the result isn't an object return early - if (result == null || typeof result !== 'object') { - return; - } - this.returnValue = result; - } - // compute the inputs for an operation should reflect the framework's conventions. - async computeInput({ variableFunction, artifact, }) { - // call the variable function to match the framework - let input = await variableFunction.call(this, this.loadEvent); - return await marshalInputs({ artifact, input }); - } - } - `) + // verify contents + expect(recast.print(parsedQuery).code).toContain(config.client) }) test('updates the config file with import path', async function () { diff --git a/src/cmd/generators/typescript/index.ts b/src/cmd/generators/typescript/index.ts index 2e1197836..753fa081d 100644 --- a/src/cmd/generators/typescript/index.ts +++ b/src/cmd/generators/typescript/index.ts @@ -5,7 +5,7 @@ import path from 'path' import * as recast from 'recast' import { Config, HoudiniError, writeFile } from '../../../common' -import { getSiteUrl } from '../../../runtime/lib/constants' +import { siteURL } from '../../../runtime/lib/constants' import { CollectedGraphQLDocument } from '../../types' import { flattenSelections } from '../../utils' import { addReferencedInputTypes } from './addReferencedInputTypes' @@ -152,7 +152,7 @@ ${[...missingScalars] } } -For more information, please visit this link: ${getSiteUrl()}/api/config#custom-scalars`) +For more information, please visit this link: ${siteURL}/api/config#custom-scalars`) } } diff --git a/src/cmd/init.ts b/src/cmd/init.ts index 6a1171e6c..e4c56059f 100644 --- a/src/cmd/init.ts +++ b/src/cmd/init.ts @@ -150,7 +150,6 @@ export default async function init( // - svelte only // - both (with small variants) if (framework === 'kit') { - await updateLayoutFile(targetPath, typescript) await updateSvelteConfig(targetPath) } else if (framework === 'svelte') { await updateSvelteMainJs(targetPath) @@ -301,25 +300,6 @@ async function tjsConfig(targetPath: string, framework: 'kit' | 'svelte') { return false } -async function updateLayoutFile(targetPath: string, ts: boolean) { - const layoutFile = path.join(targetPath, 'src', 'routes', '+layout.svelte') - - const content = ` - - -` - - await updateFile({ - projectPath: targetPath, - filepath: layoutFile, - content, - }) -} - async function updateViteConfig(targetPath: string, framework: 'kit' | 'svelte') { const viteConfigPath = path.join(targetPath, 'vite.config.js') diff --git a/src/cmd/transforms/list.ts b/src/cmd/transforms/list.ts index e6f583c5f..65a4f092e 100644 --- a/src/cmd/transforms/list.ts +++ b/src/cmd/transforms/list.ts @@ -2,7 +2,7 @@ import { logGreen, logYellow } from '@kitql/helper' import * as graphql from 'graphql' import { Config, parentTypeFromAncestors, HoudiniError } from '../../common' -import { getSiteUrl } from '../../runtime/lib/constants' +import { siteURL } from '../../runtime/lib/constants' import { ArtifactKind } from '../../runtime/lib/types' import { CollectedGraphQLDocument } from '../types' import { TypeWrapper, unwrapType } from '../utils' @@ -450,7 +450,7 @@ const missingPaginationArgMessage = ( )} directive on a field but have not provided a ${logYellow('first')}, ${logYellow( 'last' )}, or ${logYellow('limit')} argument. Please add one and try again. -For more information, visit this link: ${getSiteUrl()}/guides/pagination` +For more information, visit this link: ${siteURL}/guides/pagination` const missingEdgeSelectionMessage = ( config: Config @@ -459,7 +459,7 @@ const missingEdgeSelectionMessage = ( )} directive on a field but your selection does not contain an ${logYellow( 'edges' )} field. Please add one and try again. -For more information, visit this link: ${getSiteUrl()}/guides/pagination` +For more information, visit this link: ${siteURL}/guides/pagination` const missingNodeSelectionMessage = ( config: Config @@ -468,14 +468,14 @@ const missingNodeSelectionMessage = ( )} directive on a field but your selection does not contain a ${logYellow( 'node' )} field. Please add one and try again. -For more information, visit this link: ${getSiteUrl()}/guides/pagination` +For more information, visit this link: ${siteURL}/guides/pagination` const edgeInvalidTypeMessage = (config: Config) => `Looks like you are trying to use the ${logGreen( `@${config.paginateDirective}` )} directive on a field but your field does not conform to the connection spec: your edges field seems strange. -For more information, visit this link: ${getSiteUrl()}/guides/pagination` +For more information, visit this link: ${siteURL}/guides/pagination` const nodeNotDefinedMessage = (config: Config) => `Looks like you are trying to use the ${logGreen( `@${config.paginateDirective}` )} directive on a field but your field does not conform to the connection spec: your edge type does not have node as a field. -For more information, visit this link: ${getSiteUrl()}/guides/pagination` +For more information, visit this link: ${siteURL}/guides/pagination` diff --git a/src/cmd/transforms/schema.ts b/src/cmd/transforms/schema.ts index e46154ed6..b7fb31d49 100644 --- a/src/cmd/transforms/schema.ts +++ b/src/cmd/transforms/schema.ts @@ -2,7 +2,7 @@ import { mergeSchemas } from '@graphql-tools/schema' import * as graphql from 'graphql' import { Config } from '../../common' -import { getSiteUrl } from '../../runtime/lib/constants' +import { siteURL } from '../../runtime/lib/constants' import { CachePolicy } from '../../runtime/lib/types' import { CollectedGraphQLDocument } from '../types' @@ -28,7 +28,7 @@ directive @${config.listDirective}(${config.listNameArg}: String!, connection: B """ @${config.paginateDirective} is used to to mark a field for pagination. - More info in the [doc](${getSiteUrl()}/guides/pagination). + More info in the [doc](${siteURL}/guides/pagination). """ directive @${config.paginateDirective}(${config.paginateNameArg}: String) on FIELD @@ -40,31 +40,23 @@ directive @${config.listPrependDirective}( ) on FRAGMENT_SPREAD """ - @${ - config.listAppendDirective - } is used to tell the runtime to add the result to the start of the list + @${config.listAppendDirective} is used to tell the runtime to add the result to the start of the list """ directive @${config.listAppendDirective}(${config.listDirectiveParentIDArg}: ID) on FRAGMENT_SPREAD """ - @${ - config.listParentDirective - } is used to provide a parentID without specifying position or in situations + @${config.listParentDirective} is used to provide a parentID without specifying position or in situations where it doesn't make sense (eg when deleting a node.) """ directive @${config.listParentDirective}(value: ID!) on FRAGMENT_SPREAD """ - @${ - config.whenDirective - } is used to provide a conditional or in situations where it doesn't make sense (eg when removing or deleting a node.) + @${config.whenDirective} is used to provide a conditional or in situations where it doesn't make sense (eg when removing or deleting a node.) """ directive @${config.whenDirective} on FRAGMENT_SPREAD """ - @${ - config.whenNotDirective - } is used to provide a conditional or in situations where it doesn't make sense (eg when removing or deleting a node.) + @${config.whenNotDirective} is used to provide a conditional or in situations where it doesn't make sense (eg when removing or deleting a node.) """ directive @${config.whenNotDirective} on FRAGMENT_SPREAD @@ -76,14 +68,10 @@ directive @${config.argumentsDirective} on FRAGMENT_DEFINITION """ @${config.cacheDirective} is used to specify cache rules for a query """ -directive @${config.cacheDirective}(${config.cachePolicyArg}: CachePolicy, ${ - config.cachePartialArg - }: Boolean) on QUERY +directive @${config.cacheDirective}(${config.cachePolicyArg}: CachePolicy, ${config.cachePartialArg}: Boolean) on QUERY """ - @${ - config.houdiniDirective - } is used to configure houdini's internal behavior such as opting-in an automatic load + @${config.houdiniDirective} is used to configure houdini's internal behavior such as opting-in an automatic load """ directive @${config.houdiniDirective}(load: Boolean = true) on QUERY ` diff --git a/src/cmd/validators/typeCheck.ts b/src/cmd/validators/typeCheck.ts index e074d721c..3c0b4a7fa 100755 --- a/src/cmd/validators/typeCheck.ts +++ b/src/cmd/validators/typeCheck.ts @@ -7,7 +7,7 @@ import { parentTypeFromAncestors, HoudiniError, } from '../../common' -import { getSiteUrl } from '../../runtime/lib/constants' +import { siteURL } from '../../runtime/lib/constants' import { FragmentArgument, fragmentArguments as collectFragmentArguments, @@ -1018,7 +1018,7 @@ extend type Query { For more information, please visit these links: - https://graphql.org/learn/global-object-identification/ -- ${getSiteUrl()}/guides/caching-data#custom-ids +- ${siteURL}/guides/caching-data#custom-ids ` const paginateOnNonNodeMessage = (config: Config, directiveName: string) => @@ -1027,6 +1027,6 @@ If this is happening inside of a fragment, make sure that the fragment either im have defined a resolver entry for the fragment type. For more information, please visit these links: -- ${getSiteUrl()}/guides/pagination#paginated-fragments -- ${getSiteUrl()}/guides/caching-data#custom-ids +- ${siteURL}/guides/pagination#paginated-fragments +- ${siteURL}/guides/caching-data#custom-ids ` diff --git a/src/common/config.ts b/src/common/config.ts index 211de2820..0f07bd461 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -645,6 +645,20 @@ ${ ) } + isRootLayout(filename: string) { + return ( + this.resolveRelative(filename).replace(this.projectRoot, '') === + path.sep + path.join('src', 'routes', '+layout.svelte') + ) + } + + isRootLayoutServer(filename: string) { + return ( + this.resolveRelative(filename).replace(this.projectRoot, '').replace('.ts', '.js') === + path.sep + path.join('src', 'routes', '+layout.server.js') + ) + } + isComponent(filename: string) { return ( this.framework === 'svelte' || diff --git a/src/runtime/adapter.ts b/src/runtime/adapter.ts index 29126d7fa..29c5da638 100644 --- a/src/runtime/adapter.ts +++ b/src/runtime/adapter.ts @@ -18,3 +18,7 @@ export let clientStarted = false // Will be true on a client side navigation export let isPrerender = false export const error = (code: number, message: string) => message + +export function setClientStarted() { + clientStarted = true +} diff --git a/src/runtime/index.ts b/src/runtime/index.ts index a402581b4..78c7edbc6 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -1,4 +1,4 @@ -import { getSiteUrl } from './lib/constants' +import { siteURL } from './lib/constants' import { GraphQLTagResult } from './lib/types' export * from './lib' @@ -21,8 +21,8 @@ export function graphql(str: TemplateStringsArray): any { } // if this is executed, the preprocessor is not enabled - throw new Error(`⚠️ graphql template was invoked at runtime. This should never happen and usually means that your project isn't properly configured. - -Please make sure you have the appropriate plugin/preprocessor enabled. For more information, visit this link: ${getSiteUrl()}/guides/setting-up-your-project + throw new Error(`⚠️ graphql template was invoked at runtime. This should never happen and usually means that your project isn't properly configured. + +Please make sure you have the appropriate plugin/preprocessor enabled. For more information, visit this link: ${siteURL}/guides/setting-up-your-project `) } diff --git a/src/runtime/inline/query.ts b/src/runtime/inline/query.ts index 778874db8..ad86f39ac 100644 --- a/src/runtime/inline/query.ts +++ b/src/runtime/inline/query.ts @@ -1,5 +1,5 @@ import { HoudiniRTError } from '../lib/HoudiniRTError' -import { getSiteUrl, InfoReleaseNote, OutdatedFunctionInlineInfo } from '../lib/constants' +import { siteURL, InfoReleaseNote, OutdatedFunctionInlineInfo } from '../lib/constants' import { GraphQLTagResult, Operation } from '../lib/types' export function query<_Query extends Operation>(store: GraphQLTagResult) { diff --git a/src/runtime/lib/constants.ts b/src/runtime/lib/constants.ts index 70427c3dd..0f86d71a9 100644 --- a/src/runtime/lib/constants.ts +++ b/src/runtime/lib/constants.ts @@ -1,18 +1,10 @@ -export const getSiteUrl = () => { - // Now, manual return - // TESTS Snapshots needs to be updated as well when changing this. - const next_url = 'https://docs-next-kohl.vercel.app' - return next_url - - // const current_url = 'https://www.houdinigraphql.com' - // return next_url -} +export const siteURL = 'SITE_URL' /** * @param focus example "#0160" */ export const InfoReleaseNote = (focus?: string) => { - return `❓ For more info, visit this guide: ${getSiteUrl()}/guides/release-notes${ + return `❓ For more info, visit this guide: ${siteURL}/guides/release-notes${ focus ? `${focus}` : '' }` } diff --git a/src/runtime/lib/index.ts b/src/runtime/lib/index.ts index 76e62ff84..2869cce70 100644 --- a/src/runtime/lib/index.ts +++ b/src/runtime/lib/index.ts @@ -1,7 +1,6 @@ import { QueryStore } from '../stores' export * from './network' -export * from './proxy' export * from './config' export { errorsToGraphQLLayout } from './errors' @@ -9,14 +8,14 @@ export * from './types' export * as log from './log' export * from './deepEquals' -type LoadResult = Promise<{ [key: string]: QueryStore }> +type LoadResult = Promise<{ [key: string]: QueryStore }> type LoadAllInput = LoadResult | Record // putting this here was the only way i could find to reliably avoid import issues // its really the only thing from lib that users should import so it makes sense to have it here.... export async function loadAll( ...loads: LoadAllInput[] -): Promise>> { +): Promise>> { // we need to collect all of the promises in a single list that we will await in promise.all and then build up const promises: LoadResult[] = [] diff --git a/src/runtime/lib/log.ts b/src/runtime/lib/log.ts index 84ead33f9..30bf1159e 100644 --- a/src/runtime/lib/log.ts +++ b/src/runtime/lib/log.ts @@ -29,7 +29,7 @@ function colorize(message: string): string[] { let final = message.replaceAll(/\$HOUDINI\$(\w*\$)?/g, '%c') let colors = [] - // ever match with a color, adds somethign to the list. the others add an empty string (a reset) + // ever match with a color, adds something to the list. the others add an empty string (a reset) for (const match of matches) { const color = match[1] ? `color:${match[1].slice(0, -1)}` : '' colors.push(color) diff --git a/src/runtime/lib/network.ts b/src/runtime/lib/network.ts index 191273398..0292f66df 100644 --- a/src/runtime/lib/network.ts +++ b/src/runtime/lib/network.ts @@ -1,10 +1,10 @@ -import { LoadEvent, error, redirect } from '@sveltejs/kit' +import { error, LoadEvent, redirect, RequestEvent } from '@sveltejs/kit' import { get } from 'svelte/store' +import { isBrowser } from '../adapter' import cache from '../cache' import { QueryResult } from '../stores/query' import type { ConfigFile } from './config' -import { getSiteUrl } from './constants' import * as log from './log' import { marshalInputs } from './scalars' import { @@ -16,9 +16,15 @@ import { SubscriptionArtifact, } from './types' +export const sessionKeyName = 'HOUDINI_SESSION_KEY_NAME' + +// @ts-ignore +type SessionData = App.Session + export class HoudiniClient { private fetchFn: RequestHandler socket: SubscriptionHandler | null | undefined + private clientSideSession: SessionData | undefined constructor(networkFn: RequestHandler, subscriptionHandler?: SubscriptionHandler | null) { this.fetchFn = networkFn @@ -45,6 +51,7 @@ export class HoudiniClient { }, ...params, metadata: ctx.metadata, + session: (isBrowser ? this.clientSideSession : ctx.session) ?? {}, }) // return the result @@ -55,6 +62,23 @@ export class HoudiniClient { } init() {} + + setSession(event: RequestEvent, session: SessionData) { + ;(event.locals as any)[sessionKeyName] = session + } + + passServerSession(event: RequestEvent): {} { + if (!(sessionKeyName in event.locals)) { + // todo: Warn the user that houdini session is not setup correctly. + console.log( + `Could not find session in event.locals. This should never happen. Please open a ticket on Github and we'll sort it out.` + ) + } + + return { + [sessionKeyName]: (event.locals as any)[sessionKeyName], + } + } } export class Environment extends HoudiniClient { @@ -93,6 +117,8 @@ export type FetchContext = { fetch: (info: RequestInfo, init?: RequestInit) => Promise // @ts-ignore metadata?: App.Metadata | null + // @ts-ignore + session: App.Session | null } export type BeforeLoadArgs = LoadEvent @@ -143,22 +169,23 @@ export type RequestPayload<_Data = any> = { * ``` * */ -export type RequestHandlerArgs = Omit +export type RequestHandlerArgs = FetchContext & FetchParams & { session?: SessionData } export type RequestHandler<_Data> = (args: RequestHandlerArgs) => Promise> // This function is responsible for simulating the fetch context and executing the query with fetchQuery. // It is mainly used for mutations, refetch and possible other client side operations in the future. -export async function executeQuery<_Data extends GraphQLObject, _Input>({ +export async function executeQuery<_Data extends GraphQLObject, _Input extends {}>({ artifact, variables, + session, cached, - config, fetch, metadata, }: { artifact: QueryArtifact | MutationArtifact variables: _Input + session: any cached: boolean config: ConfigFile fetch?: typeof globalThis.fetch @@ -168,8 +195,8 @@ export async function executeQuery<_Data extends GraphQLObject, _Input>({ context: { fetch: fetch ?? globalThis.fetch.bind(globalThis), metadata, + session, }, - config, artifact, variables, cached, @@ -199,14 +226,13 @@ export async function getCurrentClient(): Promise { return (await import('HOUDINI_CLIENT_PATH')).default } -export async function fetchQuery<_Data extends GraphQLObject, _Input>({ +export async function fetchQuery<_Data extends GraphQLObject, _Input extends {}>({ artifact, variables, cached = true, policy, context, }: { - config: ConfigFile context: FetchContext artifact: QueryArtifact | MutationArtifact variables: _Input @@ -391,3 +417,15 @@ export class RequestContext { type KitBeforeLoad = (ctx: BeforeLoadArgs) => Record | Promise> type KitAfterLoad = (ctx: AfterLoadArgs) => Record type KitOnError = (ctx: OnErrorArgs) => Record + +// @ts-ignore +let session: App.Session | {} = {} + +// @ts-ignore +export function setSession(val: App.Session) { + session = val +} + +export function getSession() { + return session +} diff --git a/src/runtime/lib/proxy.ts b/src/runtime/lib/proxy.ts deleted file mode 100644 index fbaee536c..000000000 --- a/src/runtime/lib/proxy.ts +++ /dev/null @@ -1,28 +0,0 @@ -// a proxy is an object that we can embed in an artifact so that -// we can mutate the internals of a document handler without worrying about -// the return value of the handler -export class HoudiniDocumentProxy { - initial: any = null - - callbacks: ((val: any) => void)[] = [] - - listen(callback: (val: any) => void) { - this.callbacks.push(callback) - - if (this.initial) { - callback(this.initial) - } - } - - invoke(val: any) { - // if there are no callbacks, just save the value and wait for the first one - if (this.callbacks.length === 0) { - this.initial = val - return - } - - for (const callback of this.callbacks) { - callback(val) - } - } -} diff --git a/src/runtime/stores/mutation.ts b/src/runtime/stores/mutation.ts index fff8a3d1e..6808af0a9 100644 --- a/src/runtime/stores/mutation.ts +++ b/src/runtime/stores/mutation.ts @@ -3,14 +3,14 @@ import { Writable, writable } from 'svelte/store' import cache from '../cache' import type { SubscriptionSpec, MutationArtifact } from '../lib' -import { executeQuery } from '../lib/network' +import { executeQuery, getSession } from '../lib/network' import { marshalInputs, marshalSelection, unmarshalSelection } from '../lib/scalars' import { GraphQLObject } from '../lib/types' import { BaseStore } from './store' export class MutationStore< _Data extends GraphQLObject, - _Input, + _Input extends {}, _Optimistic extends GraphQLObject > extends BaseStore { artifact: MutationArtifact @@ -80,6 +80,7 @@ export class MutationStore< config, artifact: this.artifact, variables: newVariables, + session: getSession(), cached: false, metadata, fetch, diff --git a/src/runtime/stores/pagination/cursor.ts b/src/runtime/stores/pagination/cursor.ts index deaceb1c8..8476adad0 100644 --- a/src/runtime/stores/pagination/cursor.ts +++ b/src/runtime/stores/pagination/cursor.ts @@ -2,9 +2,9 @@ import { Writable, writable } from 'svelte/store' import cache from '../../cache' import { ConfigFile } from '../../lib/config' -import { getSiteUrl } from '../../lib/constants' +import { siteURL } from '../../lib/constants' import { deepEquals } from '../../lib/deepEquals' -import { executeQuery } from '../../lib/network' +import { executeQuery, getSession } from '../../lib/network' import { GraphQLObject, QueryArtifact } from '../../lib/types' import { QueryResult, QueryStoreFetchParams } from '../query' import { fetchParams } from '../query' @@ -64,6 +64,7 @@ export function cursorHandlers<_Data extends GraphQLObject, _Input>({ const { result } = await executeQuery({ artifact, variables: loadVariables, + session: getSession(), cached: false, config, fetch, @@ -78,7 +79,7 @@ export function cursorHandlers<_Data extends GraphQLObject, _Input>({ // make sure we have a type config for the pagination target type if (!config.types?.[targetType]?.resolve) { throw new Error( - `Missing type resolve configuration for ${targetType}. For more information, see ${getSiteUrl()}/guides/pagination#paginated-fragments` + `Missing type resolve configuration for ${targetType}. For more information, see ${siteURL}/guides/pagination#paginated-fragments` ) } @@ -179,7 +180,7 @@ export function cursorHandlers<_Data extends GraphQLObject, _Input>({ args?: QueryStoreFetchParams<_Data, _Input> ): Promise> { // validate and prepare the request context for the current environment (client vs server) - const { params } = fetchParams(artifact, storeName, args) + const { params } = await fetchParams(artifact, storeName, args) const { variables } = params ?? {} diff --git a/src/runtime/stores/pagination/fragment.ts b/src/runtime/stores/pagination/fragment.ts index a4e1d83de..48bdafaf9 100644 --- a/src/runtime/stores/pagination/fragment.ts +++ b/src/runtime/stores/pagination/fragment.ts @@ -1,7 +1,7 @@ import { derived, get, readable, Readable, Subscriber, Writable, writable } from 'svelte/store' import { keyFieldsForType, getCurrentConfig } from '../../lib/config' -import { getSiteUrl } from '../../lib/constants' +import { siteURL } from '../../lib/constants' import { GraphQLObject, FragmentArtifact, @@ -44,7 +44,7 @@ class BasePaginatedFragmentStore<_Data extends GraphQLObject, _Input> extends Ba const typeConfig = config.types?.[targetType || ''] if (!typeConfig) { throw new Error( - `Missing type refetch configuration for ${targetType}. For more information, see ${getSiteUrl()}/guides/pagination#paginated-fragments` + `Missing type refetch configuration for ${targetType}. For more information, see ${siteURL}/guides/pagination#paginated-fragments` ) } diff --git a/src/runtime/stores/pagination/offset.ts b/src/runtime/stores/pagination/offset.ts index c73c37e3c..a6cbb8cf8 100644 --- a/src/runtime/stores/pagination/offset.ts +++ b/src/runtime/stores/pagination/offset.ts @@ -1,7 +1,7 @@ import { deepEquals } from '../..' import cache from '../../cache' import { ConfigFile } from '../../lib/config' -import { executeQuery } from '../../lib/network' +import { executeQuery, getSession } from '../../lib/network' import { GraphQLObject, QueryArtifact } from '../../lib/types' import { QueryResult, QueryStoreFetchParams } from '../query' import { fetchParams } from '../query' @@ -69,6 +69,7 @@ export function offsetHandlers<_Data extends GraphQLObject, _Input>({ const { result } = await executeQuery({ artifact, variables: queryVariables, + session: getSession(), cached: false, config, fetch, @@ -93,7 +94,7 @@ export function offsetHandlers<_Data extends GraphQLObject, _Input>({ async fetch( args?: QueryStoreFetchParams<_Data, _Input> ): Promise> { - const { params } = fetchParams(artifact, storeName, args) + const { params } = await fetchParams(artifact, storeName, args) const { variables } = params ?? {} diff --git a/src/runtime/stores/pagination/pageInfo.ts b/src/runtime/stores/pagination/pageInfo.ts index e06a0d20d..2373b7f46 100644 --- a/src/runtime/stores/pagination/pageInfo.ts +++ b/src/runtime/stores/pagination/pageInfo.ts @@ -1,5 +1,5 @@ import { GraphQLObject } from '../../lib' -import { getSiteUrl } from '../../lib/constants' +import { siteURL } from '../../lib/constants' import * as log from '../../lib/log' export function nullPageInfo(): PageInfo { @@ -15,7 +15,7 @@ export type PageInfo = { export function missingPageSizeError(fnName: string) { return { - message: `${fnName} is missing the required page arguments. For more information, please visit this link: ${getSiteUrl()}/guides/pagination`, + message: `${fnName} is missing the required page arguments. For more information, please visit this link: ${siteURL}/guides/pagination`, } } diff --git a/src/runtime/stores/query.ts b/src/runtime/stores/query.ts index 29dfa14cb..39e75e768 100644 --- a/src/runtime/stores/query.ts +++ b/src/runtime/stores/query.ts @@ -6,15 +6,19 @@ import cache from '../cache' import type { ConfigFile, QueryArtifact } from '../lib' import { deepEquals } from '../lib/deepEquals' import * as log from '../lib/log' -import { fetchQuery } from '../lib/network' -import { FetchContext } from '../lib/network' +import { fetchQuery, sessionKeyName } from '../lib/network' +import { FetchContext, getSession } from '../lib/network' import { marshalInputs, unmarshalSelection } from '../lib/scalars' // internals import { CachePolicy, DataSource, GraphQLObject } from '../lib/types' import { SubscriptionSpec, CompiledQueryKind, HoudiniFetchContext } from '../lib/types' import { BaseStore } from './store' -export class QueryStore<_Data extends GraphQLObject, _Input, _ExtraFields = {}> extends BaseStore { +export class QueryStore< + _Data extends GraphQLObject, + _Input extends {}, + _ExtraFields = {} +> extends BaseStore { // the underlying artifact artifact: QueryArtifact @@ -66,7 +70,7 @@ export class QueryStore<_Data extends GraphQLObject, _Input, _ExtraFields = {}> cache.setConfig(config) // validate and prepare the request context for the current environment (client vs server) - const { policy, params, context } = fetchParams(this.artifact, this.storeName, args) + const { policy, params, context } = await fetchParams(this.artifact, this.storeName, args) // identify if this is a CSF or load const isLoadFetch = Boolean('event' in params && params.event) @@ -210,7 +214,6 @@ If this is leftovers from old versions of houdini, you can safely remove this \` }) { const request = await fetchQuery<_Data, _Input>({ ...context, - config, artifact, variables, cached, @@ -353,15 +356,15 @@ export type StoreConfig<_Data extends GraphQLObject, _Input, _Artifact> = { type StoreState<_Data, _Input, _Extra = {}> = QueryResult<_Data, _Input> & _Extra -export function fetchParams<_Data extends GraphQLObject, _Input>( +export async function fetchParams<_Data extends GraphQLObject, _Input>( artifact: QueryArtifact, storeName: string, params?: QueryStoreFetchParams<_Data, _Input> -): { +): Promise<{ context: FetchContext policy: CachePolicy params: QueryStoreFetchParams<_Data, _Input> -} { +}> { // if we aren't on the browser but there's no event there's a big mistake if (!isBrowser && !(params && 'fetch' in params) && (!params || !('event' in params))) { // prettier-ignore @@ -392,10 +395,31 @@ export function fetchParams<_Data extends GraphQLObject, _Input>( fetchFn = globalThis.fetch.bind(globalThis) } + let session: any = undefined + // cannot re-use the variable from above + // we need to check for ourselves to satisfy typescript + if (params && 'event' in params && params.event) { + // get the session either from the server side event or the client side event + if ('locals' in params.event) { + // this is a server side event (RequestEvent) -> extract the session from locals + session = (params.event.locals as any)[sessionKeyName] + } else { + // this is a client side event -> await the parent data which include the session + session = (await params.event.parent())[sessionKeyName] + } + } else if (isBrowser) { + session = getSession() + } else { + log.error(contextError(storeName)) + + throw new Error('Error, check above logs for help.') + } + return { context: { fetch: fetchFn, metadata: params?.metadata ?? {}, + session, }, policy, params: params ?? {}, diff --git a/src/runtime/stores/subscription.ts b/src/runtime/stores/subscription.ts index 290a94ecf..3521a397f 100644 --- a/src/runtime/stores/subscription.ts +++ b/src/runtime/stores/subscription.ts @@ -8,7 +8,7 @@ import { marshalInputs, unmarshalSelection } from '../lib/scalars' import { CompiledSubscriptionKind, SubscriptionArtifact } from '../lib/types' import { BaseStore } from './store' -export class SubscriptionStore<_Data, _Input> extends BaseStore { +export class SubscriptionStore<_Data, _Input extends {}> extends BaseStore { artifact: SubscriptionArtifact kind = CompiledSubscriptionKind diff --git a/src/vite/ast.ts b/src/vite/ast.ts index 6d56d3dde..1d9948328 100644 --- a/src/vite/ast.ts +++ b/src/vite/ast.ts @@ -4,6 +4,10 @@ type Statement = recast.types.namedTypes.Statement type Program = recast.types.namedTypes.Program type ExportNamedDeclaration = recast.types.namedTypes.ExportNamedDeclaration type FunctionDeclaration = recast.types.namedTypes.FunctionDeclaration +type VariableDeclaration = recast.types.namedTypes.VariableDeclaration +type Identifier = recast.types.namedTypes.Identifier +type ArrowFunctionExpression = recast.types.namedTypes.ArrowFunctionExpression +type FunctionExpression = recast.types.namedTypes.FunctionExpression export function find_insert_index(script: Program) { let insert_index = script.body.findIndex((statement) => { @@ -18,12 +22,87 @@ export function find_insert_index(script: Program) { return insert_index } -export function find_exported_fn(body: Statement[], name: string): ExportNamedDeclaration | null { - return body.find( +export function find_exported_fn( + body: Statement[], + name: string +): FunctionDeclaration | FunctionExpression | ArrowFunctionExpression | null { + for (const statement of body) { + if (statement.type !== 'ExportNamedDeclaration') { + continue + } + + const exportDeclaration = statement as ExportNamedDeclaration + + // if the exported thing is a function it could be what we're looking for + if (exportDeclaration.declaration?.type === 'FunctionDeclaration') { + const value = exportDeclaration.declaration as FunctionDeclaration + if (value.id?.name === name) { + return exportDeclaration.declaration + } + } + + // we also need to find exported variables that are functions or arrow functions + else if (exportDeclaration.declaration?.type === 'VariableDeclaration') { + const value = exportDeclaration.declaration as VariableDeclaration + + // make sure that the declared value has a matching name + if ( + value.declarations.length !== 1 || + value.declarations[0].type !== 'VariableDeclarator' || + value.declarations[0].id.type !== 'Identifier' || + value.declarations[0].id.name !== name + ) { + continue + } + + // we only care about this exported thing if it's a function or arrow function + const declaration = value.declarations[0] + + if ( + declaration.init?.type === 'FunctionExpression' || + declaration.init?.type === 'ArrowFunctionExpression' + ) { + return declaration.init + } + console.log(declaration.id) + } + // it wasn't something we care about, move along + else { + continue + } + } + const exported = body.find( (expression) => expression.type === 'ExportNamedDeclaration' && - (expression as ExportNamedDeclaration).declaration?.type === 'FunctionDeclaration' && - ((expression as ExportNamedDeclaration).declaration as FunctionDeclaration).id?.name === - name + (((expression as ExportNamedDeclaration).declaration?.type === 'FunctionDeclaration' && + ((expression as ExportNamedDeclaration).declaration as FunctionDeclaration).id + ?.name === name) || + ((expression as ExportNamedDeclaration).declaration?.type === + 'VariableDeclaration' && + ((expression as ExportNamedDeclaration).declaration as VariableDeclaration) + .declarations.length === 1 && + ((expression as ExportNamedDeclaration).declaration as VariableDeclaration) + .declarations[0].type === 'Identifier' && + ( + ((expression as ExportNamedDeclaration).declaration as VariableDeclaration) + .declarations[0] as Identifier + ).name === name)) ) as ExportNamedDeclaration + if (!exported) { + return null + } + + return exported.declaration as FunctionDeclaration +} + +export function find_exported_id(program: Program, name: string) { + return program.body.find( + (statement): statement is ExportNamedDeclaration => + statement.type === 'ExportNamedDeclaration' && + statement.declaration?.type === 'VariableDeclaration' && + statement.declaration.declarations.length === 1 && + statement.declaration.declarations[0].type === 'VariableDeclarator' && + statement.declaration.declarations[0].id.type === 'Identifier' && + statement.declaration.declarations[0].id.name === name + ) } diff --git a/src/vite/fsPatch.ts b/src/vite/fsPatch.ts index 5f570480a..343ffa68c 100644 --- a/src/vite/fsPatch.ts +++ b/src/vite/fsPatch.ts @@ -5,10 +5,10 @@ import type { Plugin } from 'vite' import { Config } from '../common' import { getConfig, readFile } from '../common' +let config: Config + // this plugin is responsible for faking `+page.js` existence in the eyes of sveltekit export default function HoudiniFsPatch(configFile?: string): Plugin { - let config: Config - return { name: 'houdini-fs-patch', @@ -17,8 +17,12 @@ export default function HoudiniFsPatch(configFile?: string): Plugin { }, resolveId(id, _, { ssr }) { - // if we are resolving a route script, pretend its always there - if (config.isRouteScript(id)) { + // if we are resolving any of the files we need to generate + if ( + config.isRouteScript(id) || + config.isRootLayout(id) || + config.isRootLayoutServer(id) + ) { return { id, } @@ -27,16 +31,20 @@ export default function HoudiniFsPatch(configFile?: string): Plugin { return null }, - async load(id) { - let filepath = id - // if we are processing a route script, we should always return _something_ - if (config.isRouteScript(filepath)) { + async load(filepath) { + // if we are processing a route script or the root layout, we should always return _something_ + if (config.isRouteScript(filepath) || config.isRootLayoutServer(filepath)) { return { - // code: '', code: (await readFile(filepath)) || '', } } + if (config.isRootLayout(filepath)) { + return { + code: (await readFile(filepath)) || empty_root_layout, + } + } + // do the normal thing return null }, @@ -46,22 +54,40 @@ export default function HoudiniFsPatch(configFile?: string): Plugin { const _readDirSync = filesystem.readdirSync const _statSync = filesystem.statSync const _readFileSync = filesystem.readFileSync +const _unlinkSync = filesystem.unlinkSync // @ts-ignore -filesystem.readFileSync = function (filepath, options) { - if (filepath.toString().endsWith('+page.js') || filepath.toString().endsWith('+layout.js')) { +filesystem.readFileSync = function (fp, options) { + const filepath = fp.toString() + + if ( + filepath.endsWith('+page.js') || + filepath.endsWith('+layout.js') || + filepath.replace('.ts', '.js').endsWith('+layout.server.js') + ) { try { return _readFileSync(filepath, options) } catch { return typeof options === 'string' || options?.encoding ? '' : Buffer.from('') } } + + if (filepath.endsWith(path.join('src', 'routes', '+layout.svelte'))) { + try { + return _readFileSync(filepath, options) + } catch { + return typeof options === 'string' || options?.encoding + ? empty_root_layout + : Buffer.from(empty_root_layout) + } + } return _readFileSync(filepath, options) } // @ts-ignore filesystem.statSync = function (filepath: string, options: Parameters[1]) { - if (!filepath.includes('routes')) return _statSync(filepath, options) + if (!filepath.includes('routes') || !path.basename(filepath).startsWith('+')) + return _statSync(filepath, options) try { const result = _statSync(filepath, options) return result @@ -70,6 +96,12 @@ filesystem.statSync = function (filepath: string, options: Parameters { - return getFileName(file) === name + return names.includes(getFileName(file)) }) } // if there is a route component but no script, add the script - const loadFiles = ['+page.js', '+page.ts', '+page.server.js', '+page.server.ts'] - if (hasFileName('+page.svelte') && !result.find((fp) => loadFiles.includes(getFileName(fp)))) { + if ( + hasFileName('+page.svelte') && + !hasFileName('+page.js', '+page.ts', '+page.server.js', '+page.server.ts') + ) { result.push(virtualFile('+page.js', options)) } @@ -111,6 +145,21 @@ filesystem.readdirSync = function ( result.push(virtualFile('+layout.js', options)) } + // if we are in looking inside of src/routes and there's no +layout.svelte file + // we need to create one + if (is_root_layout(filepath.toString()) && !hasFileName('+layout.svelte')) { + result.push(virtualFile('+layout.svelte', options)) + } + // if we are in looking inside of src/routes and there's no +layout.server.js file + // we need to create one + if ( + is_root_layout(filepath.toString()) && + !hasFileName('+layout.server.js', '+layout.server.ts') + ) { + result.push(virtualFile('+layout.server.js', options)) + } + + // we're done modifying the results return result } @@ -134,3 +183,13 @@ function virtualFile(name: string, options: Parameters false, } } + +function is_root_layout(filepath: string): boolean { + return ( + filepath.toString().endsWith(path.join('src', 'routes')) && + // ignore the src/routes that exists in the + !filepath.toString().includes(path.join('.svelte-kit', 'types')) + ) +} + +const empty_root_layout = '' diff --git a/src/vite/imports.ts b/src/vite/imports.ts index 7a01f317f..de62d3094 100644 --- a/src/vite/imports.ts +++ b/src/vite/imports.ts @@ -1,6 +1,6 @@ import * as recast from 'recast' -import { Config, Script } from '../common' +import { TransformPage } from './plugin' const AST = recast.types.builders @@ -8,54 +8,66 @@ type Identifier = recast.types.namedTypes.Identifier type ImportDeclaration = recast.types.namedTypes.ImportDeclaration export function ensure_imports({ - config, - script, + page: TransformPage, import: importID, sourceModule, importKind, }: { - config: Config - script: Script - import: string + page: TransformPage + import?: string as?: never sourceModule: string importKind?: 'value' | 'type' }): { ids: Identifier; added: number } export function ensure_imports({ - config, - script, + page: TransformPage, import: importID, sourceModule, importKind, }: { - config: Config - script: Script - import: string[] + page: TransformPage + import?: string[] as?: string[] sourceModule: string importKind?: 'value' | 'type' }): { ids: Identifier[]; added: number } export function ensure_imports({ - config, - script, + page, import: importID, sourceModule, importKind, as, }: { - config: Config - script: Script - import: string[] | string + page: TransformPage + import?: string[] | string as?: string[] sourceModule: string importKind?: 'value' | 'type' }): { ids: Identifier[] | Identifier; added: number } { + // if there is no import, we can simplify the logic, just look for something with a matching source + if (!importID) { + // look for an import from the source module + const has_import = page.script.body.find( + (statement) => + statement.type === 'ImportDeclaration' && statement.source.value === sourceModule + ) + if (!has_import) { + page.script.body.unshift({ + type: 'ImportDeclaration', + source: AST.stringLiteral(sourceModule), + importKind, + }) + } + + return { ids: [], added: has_import ? 0 : 1 } + } + const idList = (Array.isArray(importID) ? importID : [importID]).map((id) => AST.identifier(id)) // figure out the list of things to import const toImport = idList.filter( (identifier) => - !script.body.find( + !page.script.body.find( (statement) => statement.type === 'ImportDeclaration' && (statement as unknown as ImportDeclaration).specifiers?.find( @@ -74,7 +86,7 @@ export function ensure_imports({ // add the import if it doesn't exist, add it if (toImport.length > 0) { - script.body.unshift({ + page.script.body.unshift({ type: 'ImportDeclaration', source: AST.stringLiteral(sourceModule), specifiers: toImport.map((identifier, i) => @@ -100,20 +112,17 @@ export function ensure_imports({ } export function store_import({ - config, + page, artifact, - script, local, }: { - config: Config + page: TransformPage artifact: { name: string } - script: Script local?: string }): { id: Identifier; added: number } { const { ids, added } = ensure_imports({ - config, - script, - sourceModule: config.storeImportPath(artifact.name), + page, + sourceModule: page.config.storeImportPath(artifact.name), import: `GQL_${artifact.name}`, }) @@ -121,20 +130,17 @@ export function store_import({ } export function artifact_import({ - config, + page, artifact, - script, local, }: { - config: Config + page: TransformPage artifact: { name: string } - script: Script local?: string }) { const { ids, added } = ensure_imports({ - config, - script, - sourceModule: config.artifactImportPath(artifact.name), + page, + sourceModule: page.config.artifactImportPath(artifact.name), import: local || `_${artifact.name}Artifact`, }) return { id: ids, added } diff --git a/src/vite/tests.ts b/src/vite/tests.ts index 495623184..465ec24b3 100644 --- a/src/vite/tests.ts +++ b/src/vite/tests.ts @@ -145,3 +145,51 @@ export async function component_test( // return both return (await parseSvelte(result.code))?.script ?? null } + +export async function test_transform_svelte(filepath: string, content: string) { + // build up the document we'll pass to the processor + const config = testConfig({ schema }) + + // write the content + filepath = path.join(config.projectRoot, filepath) + await mkdirp(path.dirname(filepath)) + await writeFile(filepath, content) + + // we want to run the transformer on both the component and script paths + const result = await runTransforms( + config, + { + config, + filepath, + watch_file: () => {}, + }, + content + ) + + // return both + return (await parseSvelte(result.code))?.script ?? null +} + +export async function test_transform_js(filepath: string, content: string) { + // build up the document we'll pass to the processor + const config = testConfig({ schema }) + + // write the content + filepath = path.join(config.projectRoot, filepath) + await mkdirp(path.dirname(filepath)) + await writeFile(filepath, content) + + // we want to run the transformer on both the component and script paths + const result = await runTransforms( + config, + { + config, + filepath, + watch_file: () => {}, + }, + content + ) + + // return both + return (await parseJS(result.code))?.script ?? null +} diff --git a/src/vite/transforms/index.ts b/src/vite/transforms/index.ts index 1df89243d..d0843ba9c 100644 --- a/src/vite/transforms/index.ts +++ b/src/vite/transforms/index.ts @@ -88,6 +88,5 @@ function replace_tag_content(source: string, start: number, end: number, insert: return replace_between(source, start, end, insert) } -// replaceSubstring replaces the substring string between the indices with the provided new value const replace_between = (origin: string, startIndex: number, endIndex: number, insertion: string) => origin.substring(0, startIndex) + insertion + origin.substring(endIndex) diff --git a/src/vite/transforms/kit/index.ts b/src/vite/transforms/kit/index.ts new file mode 100644 index 000000000..9cc72ae0c --- /dev/null +++ b/src/vite/transforms/kit/index.ts @@ -0,0 +1,20 @@ +import { IdentifierKind } from 'ast-types/gen/kinds' +import { StatementKind } from 'ast-types/gen/kinds' +import { namedTypes } from 'ast-types/gen/namedTypes' +import * as graphql from 'graphql' +import * as recast from 'recast' + +import { Config } from '../../../common' +import { TransformPage } from '../../plugin' +import init from './init' +import load from './load' +import session from './session' + +export default async function SvelteKitProcessor(config: Config, page: TransformPage) { + // if we aren't running on a kit project, don't do anything + if (page.config.framework !== 'kit') { + return + } + + await Promise.all([load(page), session(page), init(page)]) +} diff --git a/src/vite/transforms/kit/init.test.ts b/src/vite/transforms/kit/init.test.ts new file mode 100644 index 000000000..9ddb9b904 --- /dev/null +++ b/src/vite/transforms/kit/init.test.ts @@ -0,0 +1,28 @@ +import { test, expect } from 'vitest' + +import { test_transform_svelte } from '../../tests' + +test('modifies root +layout.svelte to import adapter', async function () { + // run the test + const result = await test_transform_svelte( + 'src/routes/+layout.svelte', + ` + + ` + ) + + expect(result).toMatchInlineSnapshot(` + import { page } from "$app/stores"; + import { setSession } from "$houdini/runtime/lib/network"; + import { onMount } from "svelte"; + import { setClientStarted } from "$houdini/runtime/adapter"; + export let data; + onMount(() => setClientStarted()); + + page.subscribe(val => { + setSession(val.data); + }); + `) +}) diff --git a/src/vite/transforms/kit/init.ts b/src/vite/transforms/kit/init.ts new file mode 100644 index 000000000..bdbbbdd3a --- /dev/null +++ b/src/vite/transforms/kit/init.ts @@ -0,0 +1,64 @@ +import * as recast from 'recast' + +import { ensure_imports } from '../../imports' +import { TransformPage } from '../../plugin' + +const AST = recast.types.builders + +export default async function kit_init(page: TransformPage) { + // we only care about the root layout file + if (!page.config.isRootLayout(page.filepath)) { + return + } + + // we need to call setClientStarted onMount + + // make sure we have the right imports + const set_client_started = ensure_imports({ + page, + sourceModule: '$houdini/runtime/adapter', + import: ['setClientStarted'], + }).ids[0] + const on_mount = ensure_imports({ + page, + sourceModule: 'svelte', + import: ['onMount'], + }).ids[0] + const set_session = ensure_imports({ + page, + sourceModule: '$houdini/runtime/lib/network', + import: ['setSession'], + }).ids[0] + + // add the onMount at the end of the component + page.script.body.push( + AST.expressionStatement( + AST.callExpression(on_mount, [ + AST.arrowFunctionExpression([], AST.callExpression(set_client_started, [])), + ]) + ) + ) + + // we need to track updates in the page store as the client-side session + const store_id = ensure_imports({ + page, + sourceModule: '$app/stores', + import: ['page'], + }).ids[0] + page.script.body.push( + AST.expressionStatement( + AST.callExpression(AST.memberExpression(store_id, AST.identifier('subscribe')), [ + AST.arrowFunctionExpression( + [AST.identifier('val')], + AST.blockStatement([ + AST.expressionStatement( + AST.callExpression(set_session, [ + AST.memberExpression(AST.identifier('val'), AST.identifier('data')), + ]) + ), + ]) + ), + ]) + ) + ) +} diff --git a/src/vite/transforms/kit.test.ts b/src/vite/transforms/kit/load.test.ts similarity index 97% rename from src/vite/transforms/kit.test.ts rename to src/vite/transforms/kit/load.test.ts index 8f5f4aeaf..7190e2f01 100644 --- a/src/vite/transforms/kit.test.ts +++ b/src/vite/transforms/kit/load.test.ts @@ -1,6 +1,6 @@ import { test, expect, describe } from 'vitest' -import { route_test } from '../tests' +import { route_test } from '../../tests' describe('kit route processor', function () { test('inline store', async function () { @@ -1011,7 +1011,18 @@ test('layout loads', async function () { } `) - expect(route.layout).toMatchInlineSnapshot('export let data;') + expect(route.layout).toMatchInlineSnapshot(` + import { page } from "$app/stores"; + import { setSession } from "$houdini/runtime/lib/network"; + import { onMount } from "svelte"; + import { setClientStarted } from "$houdini/runtime/adapter"; + export let data; + onMount(() => setClientStarted()); + + page.subscribe(val => { + setSession(val.data); + }); + `) }) test('layout inline query', async function () { @@ -1030,10 +1041,20 @@ test('layout inline query', async function () { }) expect(route.layout).toMatchInlineSnapshot(` + import { page } from "$app/stores"; + import { setSession } from "$houdini/runtime/lib/network"; + import { onMount } from "svelte"; + import { setClientStarted } from "$houdini/runtime/adapter"; export let data; $: result = data.TestQuery; + + onMount(() => setClientStarted()); + + page.subscribe(val => { + setSession(val.data); + }); `) expect(route.layout_script).toMatchInlineSnapshot(` @@ -1128,7 +1149,7 @@ test('onError hook', async function () { promises.push(load_TestQuery({ "variables": inputs["TestQuery"], "event": context, - "blocking": false + "blocking": true })); let result = {}; diff --git a/src/vite/transforms/kit.ts b/src/vite/transforms/kit/load.ts similarity index 94% rename from src/vite/transforms/kit.ts rename to src/vite/transforms/kit/load.ts index 817552925..d6984d5c6 100644 --- a/src/vite/transforms/kit.ts +++ b/src/vite/transforms/kit/load.ts @@ -12,22 +12,17 @@ import { HoudiniRouteScript, readFile, stat, -} from '../../common' -import { find_insert_index } from '../ast' -import { ensure_imports, store_import } from '../imports' -import { TransformPage } from '../plugin' -import { LoadTarget, find_inline_queries, query_variable_fn } from './query' +} from '../../../common' +import { find_insert_index } from '../../ast' +import { ensure_imports, store_import } from '../../imports' +import { TransformPage } from '../../plugin' +import { LoadTarget, find_inline_queries, query_variable_fn } from '../query' const AST = recast.types.builders type ExportNamedDeclaration = ReturnType -export default async function SvelteKitProcessor(config: Config, page: TransformPage) { - // if we aren't running on a kit project, don't do anything - if (page.config.framework !== 'kit') { - return - } - +export default async function kit_load_generator(page: TransformPage) { // if this isn't a route, move on const is_route = page.config.isRoute(page.filepath) const is_route_script = page.config.isRouteScript(page.filepath) @@ -40,8 +35,7 @@ export default async function SvelteKitProcessor(config: Config, page: Transform is_route ? AST.memberExpression(AST.identifier('data'), AST.identifier(name)) : store_import({ - config: page.config, - script: page.script, + page, artifact: { name }, }).id @@ -157,14 +151,12 @@ function add_load({ // make sure we have RequestContext imported ensure_imports({ - config: page.config, - script: page.script, + page, import: ['RequestContext'], sourceModule: '$houdini/runtime/lib/network', }) ensure_imports({ - config: page.config, - script: page.script, + page, import: ['getCurrentConfig'], sourceModule: '$houdini/runtime/lib/config', }) @@ -238,8 +230,7 @@ function add_load({ // every query that we found needs to be triggered in this function for (const query of queries) { const { ids } = ensure_imports({ - config: page.config, - script: page.script, + page, import: [`load_${query.name}`], sourceModule: page.config.storeImportPath(query.name), }) @@ -264,8 +255,7 @@ function add_load({ AST.literal('artifact'), AST.memberExpression( store_import({ - config: page.config, - script: page.script, + page, artifact: query, }).id, AST.identifier('artifact') @@ -304,7 +294,7 @@ function add_load({ AST.objectProperty(AST.literal('event'), AST.identifier('context')), AST.objectProperty( AST.literal('blocking'), - AST.booleanLiteral(!!after_load) + AST.booleanLiteral(after_load || on_error) ), ]), ]), @@ -432,9 +422,8 @@ async function find_page_query(page: TransformPage): Promise // generate an import for the store const { id } = store_import({ - config: page.config, + page, artifact: { name: definition.name!.value }, - script: page.script, }) return { diff --git a/src/vite/transforms/kit/session.test.ts b/src/vite/transforms/kit/session.test.ts new file mode 100644 index 000000000..a903e0b5e --- /dev/null +++ b/src/vite/transforms/kit/session.test.ts @@ -0,0 +1,253 @@ +import { test, expect } from 'vitest' + +import { test_transform_js, test_transform_svelte } from '../../tests' + +test('modifies root +layout.svelte with data prop', async function () { + // run the test + const result = await test_transform_svelte( + 'src/routes/+layout.svelte', + ` + + ` + ) + + expect(result).toMatchInlineSnapshot(` + import { page } from "$app/stores"; + import { setSession } from "$houdini/runtime/lib/network"; + import { onMount } from "svelte"; + import { setClientStarted } from "$houdini/runtime/adapter"; + export let data; + onMount(() => setClientStarted()); + + page.subscribe(val => { + setSession(val.data); + }); + `) +}) + +test('modifies root +layout.svelte without data prop', async function () { + // run the test + const result = await test_transform_svelte('src/routes/+layout.svelte', ``) + + expect(result).toMatchInlineSnapshot(` + import { page } from "$app/stores"; + import { setSession } from "$houdini/runtime/lib/network"; + import { onMount } from "svelte"; + import { setClientStarted } from "$houdini/runtime/adapter"; + onMount(() => setClientStarted()); + + page.subscribe(val => { + setSession(val.data); + }); + `) +}) + +test('adds load to +layout.server.js', async function () { + const result = await test_transform_js('src/routes/+layout.server.js', ``) + + expect(result).toMatchInlineSnapshot(` + import __houdini_client__ from "PROJECT_ROOT/my/client/path"; + + export async function load(event) { + const __houdini__vite__plugin__return__value__ = {}; + + return { + ...__houdini_client__.passServerSession(event), + ...__houdini__vite__plugin__return__value__ + }; + } + `) +}) + +test('modifies existing load +layout.server.js', async function () { + const result = await test_transform_js( + 'src/routes/+layout.server.js', + ` + export async function load() { + "some random stuff that's valid javascript" + return { + hello: "world", + } + + } + ` + ) + + expect(result).toMatchInlineSnapshot(` + import __houdini_client__ from "PROJECT_ROOT/my/client/path"; + + export async function load(event) { + "some random stuff that's valid javascript"; + const __houdini__vite__plugin__return__value__ = { + hello: "world" + }; + + return { + ...__houdini_client__.passServerSession(event), + ...__houdini__vite__plugin__return__value__ + }; + } + `) +}) + +test('modifies existing load +layout.server.js - no return', async function () { + const result = await test_transform_js( + 'src/routes/+layout.server.js', + ` + export async function load() { + "some random stuff that's valid javascript" + } + ` + ) + + expect(result).toMatchInlineSnapshot(` + import __houdini_client__ from "PROJECT_ROOT/my/client/path"; + + export async function load(event) { + "some random stuff that's valid javascript"; + const __houdini__vite__plugin__return__value__ = {}; + + return { + ...__houdini_client__.passServerSession(event), + ...__houdini__vite__plugin__return__value__ + }; + } + `) +}) + +test('modifies existing load +layout.server.js - rest params', async function () { + const result = await test_transform_js( + 'src/routes/+layout.server.js', + ` + export async function load({ foo, bar, ...baz }) { + console.log(foo) + return { + some: 'value' + } + } + ` + ) + + expect(result).toMatchInlineSnapshot(` + import __houdini_client__ from "PROJECT_ROOT/my/client/path"; + + export async function load(event) { + let { + foo, + bar, + ...baz + } = event; + + console.log(foo); + + const __houdini__vite__plugin__return__value__ = { + some: "value" + }; + + return { + ...__houdini_client__.passServerSession(event), + ...__houdini__vite__plugin__return__value__ + }; + } + `) +}) + +test('modifies existing load +layout.server.js - const arrow function', async function () { + const result = await test_transform_js( + 'src/routes/+layout.server.js', + ` + export const load = ({ foo, bar, ...baz }) => { + console.log(foo) + return { + some: 'value' + } + } + ` + ) + + expect(result).toMatchInlineSnapshot(` + import __houdini_client__ from "PROJECT_ROOT/my/client/path"; + + export const load = event => { + let { + foo, + bar, + ...baz + } = event; + + console.log(foo); + + const __houdini__vite__plugin__return__value__ = { + some: "value" + }; + + return { + ...__houdini_client__.passServerSession(event), + ...__houdini__vite__plugin__return__value__ + }; + }; + `) +}) + +test('modifies existing load +layout.server.js - const function', async function () { + const result = await test_transform_js( + 'src/routes/+layout.server.js', + ` + export const load = function({ foo, bar, ...baz }) { + console.log(foo) + return { + some: 'value' + } + } + ` + ) + + expect(result).toMatchInlineSnapshot(` + import __houdini_client__ from "PROJECT_ROOT/my/client/path"; + + export const load = function(event) { + let { + foo, + bar, + ...baz + } = event; + + console.log(foo); + + const __houdini__vite__plugin__return__value__ = { + some: "value" + }; + + return { + ...__houdini_client__.passServerSession(event), + ...__houdini__vite__plugin__return__value__ + }; + }; + `) +}) + +test('modifies existing load +layout.server.js - implicit return', async function () { + const result = await test_transform_js( + 'src/routes/+layout.server.js', + ` + export const load = () => ({ hello: 'world'}) + ` + ) + + expect(result).toMatchInlineSnapshot(` + import __houdini_client__ from "PROJECT_ROOT/my/client/path"; + + export const load = event => { + const __houdini__vite__plugin__return__value__ = ({ + hello: "world" + }); + + return { + ...__houdini_client__.passServerSession(event), + ...__houdini__vite__plugin__return__value__ + }; + }; + `) +}) diff --git a/src/vite/transforms/kit/session.ts b/src/vite/transforms/kit/session.ts new file mode 100644 index 000000000..6ba66f174 --- /dev/null +++ b/src/vite/transforms/kit/session.ts @@ -0,0 +1,136 @@ +import path from 'path' +import * as recast from 'recast' + +import { find_exported_fn, find_exported_id, find_insert_index } from '../../ast' +import { ensure_imports } from '../../imports' +import { TransformPage } from '../../plugin' + +const AST = recast.types.builders + +type ReturnStatement = recast.types.namedTypes.ReturnStatement +type BlockStatement = recast.types.namedTypes.BlockStatement + +// the root layout server file (src/routes/+layout.server.js) needs to define a load that's accessible +// to all routes that adds the session set in the application's hook file along with any existing values +// This is done in three steps: +// - define a load if there isn't one +// - set the current return value to some internal name +// - add a new return statement that mixes in `...client.passServerSession(event)` +export default function (page: TransformPage) { + if (!page.config.isRootLayoutServer(page.filepath)) { + return + } + + // make sure we have a reference to the client + const client_id = ensure_imports({ + page, + import: '__houdini_client__', + sourceModule: path.join(page.config.projectRoot, page.config.client), + }).ids + + // before we do anything, we need to find the load function + let load_fn = find_exported_fn(page.script.body, 'load') + let event_id = AST.identifier('event') + + // lets get a reference to the body of the function + let body: BlockStatement = AST.blockStatement([]) + if (load_fn?.type === 'ArrowFunctionExpression') { + if (load_fn.body.type === 'BlockStatement') { + body = load_fn.body + } else { + body = AST.blockStatement([AST.returnStatement(load_fn.body)]) + load_fn.body = body + } + } else if (load_fn) { + body = load_fn.body + } + + // if there is no load function, then we have to add one + if (!load_fn) { + load_fn = AST.functionDeclaration( + AST.identifier('load'), + [event_id], + AST.blockStatement([]) + ) + load_fn.async = true + page.script.body.splice( + find_insert_index(page.script), + 0, + AST.exportNamedDeclaration(load_fn) + ) + body = load_fn.body + } + // there is a load function, we need the event + else { + // if there are no identifiers, we need to add one + if (load_fn.params.length === 0) { + load_fn.params.push(event_id) + } + // if the first parameter of the function declaration is an identifier, we're in business + else if (load_fn.params[0]?.type === 'Identifier') { + event_id = load_fn.params[0] + } + // the first parameter is not an identifier so it's almost certainly an object pattern pulling parameters out + else if (load_fn.params[0].type === 'ObjectPattern') { + // hold onto the pattern so we can re-use it as the first + const pattern = load_fn.params[0] + + // overwrite the parameter as event + load_fn.params[0] = event_id + + // redefine the variables as let in the first statement of the function + body.body.unshift( + AST.variableDeclaration('let', [AST.variableDeclarator(pattern, event_id)]) + ) + } + // we can't work with this + else { + throw new Error( + 'Could not inject session data into load. Please open a ticket with the contents of ' + + page.filepath + ) + } + } + + // we have a load function and `event` is guaranteed to resolve correctly + + // now we need to find the return statement and replace it with a local variable + // that we will use later + let return_statement_index = body.body.findIndex( + (statement) => statement.type === 'ReturnStatement' + ) + let return_statement: ReturnStatement + if (return_statement_index !== -1) { + return_statement = body.body[return_statement_index] as ReturnStatement + } + // there was no return statement so its safe to just push one at the end that sets an empty + // object + else { + return_statement = AST.returnStatement(AST.objectExpression([])) + body.body.push(return_statement) + return_statement_index = body.body.length - 1 + } + + // replace the return statement with the variable declaration + const local_return_var = AST.identifier('__houdini__vite__plugin__return__value__') + body.body[return_statement_index] = AST.variableDeclaration('const', [ + AST.variableDeclarator(local_return_var, return_statement.argument), + ]) + + // its safe to insert a return statement after the declaration that references event + body.body.splice( + return_statement_index + 1, + 0, + AST.returnStatement( + AST.objectExpression([ + AST.spreadElement( + AST.callExpression( + AST.memberExpression(client_id, AST.identifier('passServerSession')), + [event_id] + ) + ), + AST.spreadElement(local_return_var), + ]) + ) + ) +} diff --git a/src/vite/transforms/query.ts b/src/vite/transforms/query.ts index 3696b3b91..b0cbc8f33 100644 --- a/src/vite/transforms/query.ts +++ b/src/vite/transforms/query.ts @@ -49,23 +49,20 @@ export default async function QueryProcessor(config: Config, page: TransformPage ) ensure_imports({ - config: page.config, - script: page.script, + page, import: ['marshalInputs'], sourceModule: '$houdini/runtime/lib/scalars', }) ensure_imports({ - config: page.config, - script: page.script, + page, import: ['RequestContext'], sourceModule: '$houdini/runtime/lib/network', }) // import the browser check ensure_imports({ - config: page.config, - script: page.script, + page, import: ['isBrowser'], sourceModule: '$houdini/runtime/adapter', }) @@ -73,10 +70,9 @@ export default async function QueryProcessor(config: Config, page: TransformPage // define the store values at the top of the file for (const query of queries) { const factory = ensure_imports({ + page, import: [`${query.name}Store`], sourceModule: config.storeImportPath(query.name), - config: page.config, - script: page.script, }).ids[0] page.script.body.splice( diff --git a/src/vite/transforms/tags.ts b/src/vite/transforms/tags.ts index 0a903af2e..90e277bf8 100644 --- a/src/vite/transforms/tags.ts +++ b/src/vite/transforms/tags.ts @@ -7,10 +7,10 @@ import { TransformPage } from '../plugin' const AST = recast.types.builders -export default async function GraphQLTagProcessor(config: Config, ctx: TransformPage) { +export default async function GraphQLTagProcessor(config: Config, page: TransformPage) { // all graphql template tags need to be turned into a reference to the appropriate store - await walkGraphQLTags(config, ctx.script, { - dependency: ctx.watch_file, + await walkGraphQLTags(config, page.script, { + dependency: page.watch_file, tag(tag) { // pull out what we need const { node, parsedDocument } = tag @@ -20,8 +20,7 @@ export default async function GraphQLTagProcessor(config: Config, ctx: Transform // store node.replaceWith( store_import({ - config, - script: ctx.script, + page, artifact: { name: operation.name!.value }, }).id ) diff --git a/vitest.setup.ts b/vitest.setup.ts index 857c644d1..bcbced2a4 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -5,7 +5,7 @@ import * as recast from 'recast' import typeScriptParser from 'recast/parsers/typescript' import { expect, afterEach } from 'vitest' -import { parseJS } from './src/common' +import { parseJS, testConfig } from './src/common' import { clearMock } from './src/common/fs' import * as fs from './src/common/fs' @@ -15,10 +15,12 @@ clearMock() afterEach(fs.clearMock) +const config = testConfig() + expect.addSnapshotSerializer({ test: (val) => val && Object.keys(recast.types.namedTypes).includes(val.type), serialize: (val) => { - return recast.print(val).code + return recast.print(val).code.replaceAll(config.projectRoot, 'PROJECT_ROOT') }, })