waku 0.20.2-alpha.0 → 0.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -46,6 +46,9 @@ const { values, positionals } = parseArgs({
46
46
  'with-aws-lambda': {
47
47
  type: 'boolean'
48
48
  },
49
+ 'experimental-partial': {
50
+ type: 'boolean'
51
+ },
49
52
  port: {
50
53
  type: 'string',
51
54
  short: 'p'
@@ -102,6 +105,7 @@ async function runBuild() {
102
105
  await build({
103
106
  config,
104
107
  env: process.env,
108
+ partial: !!values['experimental-partial'],
105
109
  deploy: (values['with-vercel'] ?? !!process.env.VERCEL ? values['with-vercel-static'] ? 'vercel-static' : 'vercel-serverless' : undefined) || (values['with-netlify'] ?? !!process.env.NETLIFY ? values['with-netlify-static'] ? 'netlify-static' : 'netlify-functions' : undefined) || (values['with-cloudflare'] ? 'cloudflare' : undefined) || (values['with-partykit'] ? 'partykit' : undefined) || (values['with-deno'] ? 'deno' : undefined) || (values['with-aws-lambda'] ? 'aws-lambda' : undefined)
106
110
  });
107
111
  }
package/dist/client.d.ts CHANGED
@@ -5,8 +5,10 @@ declare global {
5
5
  readonly env: Record<string, string>;
6
6
  }
7
7
  }
8
- type Elements = Promise<Record<string, ReactNode>>;
9
- type SetElements = (updater: Elements | ((prev: Elements) => Elements)) => void;
8
+ type Elements = Promise<Record<string, ReactNode>> & {
9
+ prev?: Record<string, ReactNode> | undefined;
10
+ };
11
+ type SetElements = (updater: (prev: Elements) => Elements) => void;
10
12
  type CacheEntry = [
11
13
  input: string,
12
14
  searchParamsString: string,
@@ -24,10 +26,11 @@ export declare const Root: ({ initialInput, initialSearchParamsString, cache, un
24
26
  children: ReactNode;
25
27
  }) => import("react").FunctionComponentElement<import("react").ProviderProps<(input: string, searchParams?: URLSearchParams) => void>>;
26
28
  export declare const useRefetch: () => (input: string, searchParams?: URLSearchParams) => void;
27
- export declare const Slot: ({ id, children, fallback, }: {
29
+ export declare const Slot: ({ id, children, fallback, unstable_shouldRenderPrev, }: {
28
30
  id: string;
29
31
  children?: ReactNode;
30
32
  fallback?: ReactNode;
33
+ unstable_shouldRenderPrev?: (err: unknown) => boolean;
31
34
  }) => string | number | bigint | true | import("react").ReactElement<any, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | Promise<import("react").AwaitedReactNode> | import("react").FunctionComponentElement<import("react").ProviderProps<ReactNode>>;
32
35
  export declare const Children: () => ReactNode;
33
36
  /**
package/dist/client.js CHANGED
@@ -17,13 +17,30 @@ const checkStatus = async (responsePromise)=>{
17
17
  const getCached = (c, m, k)=>(m.has(k) ? m : m.set(k, c())).get(k);
18
18
  const cache1 = new WeakMap();
19
19
  const mergeElements = (a, b)=>{
20
- const getResult = async ()=>{
21
- const nextElements = {
22
- ...await a,
23
- ...await b
24
- };
25
- delete nextElements._value;
26
- return nextElements;
20
+ const getResult = ()=>{
21
+ const promise = new Promise((resolve, reject)=>{
22
+ Promise.all([
23
+ a,
24
+ b
25
+ ]).then(([a, b])=>{
26
+ const nextElements = {
27
+ ...a,
28
+ ...b
29
+ };
30
+ delete nextElements._value;
31
+ promise.prev = a;
32
+ resolve(nextElements);
33
+ }).catch((e)=>{
34
+ a.then((a)=>{
35
+ promise.prev = a;
36
+ reject(e);
37
+ }, ()=>{
38
+ promise.prev = a.prev;
39
+ reject(e);
40
+ });
41
+ });
42
+ });
43
+ return promise;
27
44
  };
28
45
  const cache2 = getCached(()=>new WeakMap(), cache1, a);
29
46
  return getCached(getResult, cache2, b);
@@ -96,7 +113,7 @@ export const Root = ({ initialInput, initialSearchParamsString, cache, unstable_
96
113
  export const useRefetch = ()=>use(RefetchContext);
97
114
  const ChildrenContext = createContext(undefined);
98
115
  const ChildrenContextProvider = memo(ChildrenContext.Provider);
99
- export const Slot = ({ id, children, fallback })=>{
116
+ export const Slot = ({ id, children, fallback, unstable_shouldRenderPrev })=>{
100
117
  const elementsPromise = use(ElementsContext);
101
118
  if (!elementsPromise) {
102
119
  throw new Error('Missing Root component');
@@ -105,12 +122,16 @@ export const Slot = ({ id, children, fallback })=>{
105
122
  try {
106
123
  elements = use(elementsPromise);
107
124
  } catch (e) {
108
- if (e instanceof Error) {
125
+ if (e instanceof Error && !('statusCode' in e)) {
109
126
  // HACK we assume any error as Not Found,
110
127
  // probably caused by history api fallback
111
128
  e.statusCode = 404;
112
129
  }
113
- throw e;
130
+ if (unstable_shouldRenderPrev?.(e) && elementsPromise.prev) {
131
+ elements = elementsPromise.prev;
132
+ } else {
133
+ throw e;
134
+ }
114
135
  }
115
136
  if (!(id in elements)) {
116
137
  if (fallback) {
@@ -2,5 +2,6 @@ import type { Config } from '../../config.js';
2
2
  export declare function build(options: {
3
3
  config: Config;
4
4
  env?: Record<string, string>;
5
+ partial?: boolean;
5
6
  deploy?: 'vercel-static' | 'vercel-serverless' | 'netlify-static' | 'netlify-functions' | 'cloudflare' | 'partykit' | 'deno' | 'aws-lambda' | undefined;
6
7
  }): Promise<void>;
@@ -105,7 +105,7 @@ const analyzeEntries = async (rootDir, config)=>{
105
105
  };
106
106
  };
107
107
  // For RSC
108
- const buildServerBundle = async (rootDir, config, clientEntryFiles, serverEntryFiles, serverModuleFiles, serve, isNodeCompatible)=>{
108
+ const buildServerBundle = async (rootDir, config, clientEntryFiles, serverEntryFiles, serverModuleFiles, serve, isNodeCompatible, partial)=>{
109
109
  const serverBuildOutput = await buildVite({
110
110
  plugins: [
111
111
  nonjsResolvePlugin(),
@@ -189,6 +189,7 @@ const buildServerBundle = async (rootDir, config, clientEntryFiles, serverEntryF
189
189
  },
190
190
  publicDir: false,
191
191
  build: {
192
+ emptyOutDir: !partial,
192
193
  ssr: true,
193
194
  ssrEmitAssets: true,
194
195
  target: 'node18',
@@ -210,7 +211,7 @@ const buildServerBundle = async (rootDir, config, clientEntryFiles, serverEntryF
210
211
  return serverBuildOutput;
211
212
  };
212
213
  // For SSR (render client components on server to generate HTML)
213
- const buildSsrBundle = async (rootDir, config, clientEntryFiles, serverBuildOutput, isNodeCompatible)=>{
214
+ const buildSsrBundle = async (rootDir, config, clientEntryFiles, serverBuildOutput, isNodeCompatible, partial)=>{
214
215
  const cssAssets = serverBuildOutput.output.flatMap(({ type, fileName })=>type === 'asset' && fileName.endsWith('.css') ? [
215
216
  fileName
216
217
  ] : []);
@@ -252,6 +253,7 @@ const buildSsrBundle = async (rootDir, config, clientEntryFiles, serverBuildOutp
252
253
  },
253
254
  publicDir: false,
254
255
  build: {
256
+ emptyOutDir: !partial,
255
257
  ssr: true,
256
258
  target: 'node18',
257
259
  outDir: joinPath(rootDir, config.distDir, DIST_SSR),
@@ -274,7 +276,7 @@ const buildSsrBundle = async (rootDir, config, clientEntryFiles, serverBuildOutp
274
276
  });
275
277
  };
276
278
  // For Browsers
277
- const buildClientBundle = async (rootDir, config, clientEntryFiles, serverBuildOutput)=>{
279
+ const buildClientBundle = async (rootDir, config, clientEntryFiles, serverBuildOutput, partial)=>{
278
280
  const nonJsAssets = serverBuildOutput.output.flatMap(({ type, fileName })=>type === 'asset' && !fileName.endsWith('.js') ? [
279
281
  fileName
280
282
  ] : []);
@@ -297,6 +299,7 @@ const buildClientBundle = async (rootDir, config, clientEntryFiles, serverBuildO
297
299
  })
298
300
  ],
299
301
  build: {
302
+ emptyOutDir: !partial,
300
303
  outDir: joinPath(rootDir, config.distDir, DIST_PUBLIC),
301
304
  rollupOptions: {
302
305
  onwarn,
@@ -349,6 +352,10 @@ const emitRscFiles = async (rootDir, config, distEntries, buildConfig)=>{
349
352
  }
350
353
  staticInputSet.add(input);
351
354
  const destRscFile = joinPath(rootDir, config.distDir, DIST_PUBLIC, config.rscPath, encodeInput(input));
355
+ // Skip if the file already exists.
356
+ if (existsSync(destRscFile)) {
357
+ continue;
358
+ }
352
359
  await mkdir(joinPath(destRscFile, '..'), {
353
360
  recursive: true
354
361
  });
@@ -398,7 +405,7 @@ const emitHtmlFiles = async (rootDir, config, distEntriesFile, distEntries, buil
398
405
  let htmlStr = publicIndexHtml;
399
406
  let htmlHead = publicIndexHtmlHead;
400
407
  if (cssAssets.length) {
401
- const cssStr = cssAssets.map((asset)=>`<link rel="stylesheet" href="${asset}">`).join('\n');
408
+ const cssStr = cssAssets.map((asset)=>`<link rel="stylesheet" href="${config.basePath}${asset}">`).join('\n');
402
409
  // HACK is this too naive to inject style code?
403
410
  htmlStr = htmlStr.replace(/<\/head>/, cssStr);
404
411
  htmlHead += cssStr;
@@ -426,6 +433,10 @@ const emitHtmlFiles = async (rootDir, config, distEntriesFile, distEntries, buil
426
433
  pathname = pathSpec2pathname(pathSpec);
427
434
  const destHtmlFile = joinPath(rootDir, config.distDir, DIST_PUBLIC, extname(pathname) ? pathname : pathname === '/404' ? '404.html' // HACK special treatment for 404, better way?
428
435
  : pathname + '/index.html');
436
+ // In partial mode, skip if the file already exists.
437
+ if (existsSync(destHtmlFile)) {
438
+ return;
439
+ }
429
440
  const htmlReadable = await renderHtml({
430
441
  config,
431
442
  pathname,
@@ -475,9 +486,9 @@ export async function build(options) {
475
486
  const distEntriesFile = joinPath(rootDir, config.distDir, DIST_ENTRIES_JS);
476
487
  const isNodeCompatible = options.deploy !== 'cloudflare' && options.deploy !== 'partykit' && options.deploy !== 'deno';
477
488
  const { clientEntryFiles, serverEntryFiles, serverModuleFiles } = await analyzeEntries(rootDir, config);
478
- const serverBuildOutput = await buildServerBundle(rootDir, config, clientEntryFiles, serverEntryFiles, serverModuleFiles, (options.deploy === 'vercel-serverless' ? 'vercel' : false) || (options.deploy === 'netlify-functions' ? 'netlify' : false) || (options.deploy === 'cloudflare' ? 'cloudflare' : false) || (options.deploy === 'partykit' ? 'partykit' : false) || (options.deploy === 'deno' ? 'deno' : false) || (options.deploy === 'aws-lambda' ? 'aws-lambda' : false), isNodeCompatible);
479
- await buildSsrBundle(rootDir, config, clientEntryFiles, serverBuildOutput, isNodeCompatible);
480
- const clientBuildOutput = await buildClientBundle(rootDir, config, clientEntryFiles, serverBuildOutput);
489
+ const serverBuildOutput = await buildServerBundle(rootDir, config, clientEntryFiles, serverEntryFiles, serverModuleFiles, (options.deploy === 'vercel-serverless' ? 'vercel' : false) || (options.deploy === 'netlify-functions' ? 'netlify' : false) || (options.deploy === 'cloudflare' ? 'cloudflare' : false) || (options.deploy === 'partykit' ? 'partykit' : false) || (options.deploy === 'deno' ? 'deno' : false) || (options.deploy === 'aws-lambda' ? 'aws-lambda' : false), isNodeCompatible, !!options.partial);
490
+ await buildSsrBundle(rootDir, config, clientEntryFiles, serverBuildOutput, isNodeCompatible, !!options.partial);
491
+ const clientBuildOutput = await buildClientBundle(rootDir, config, clientEntryFiles, serverBuildOutput, !!options.partial);
481
492
  const distEntries = await import(filePathToFileURL(distEntriesFile));
482
493
  // TODO: Add progress indication for static builds.
483
494
  const buildConfig = await getBuildConfig({
@@ -94,11 +94,7 @@ export const devServer = (options)=>{
94
94
  },
95
95
  ssr: {
96
96
  external: [
97
- 'waku',
98
- 'waku/client',
99
- 'waku/server',
100
- 'waku/router/client',
101
- 'waku/router/server'
97
+ 'waku'
102
98
  ]
103
99
  },
104
100
  server: {
@@ -71,14 +71,6 @@ ${opts.htmlHead}
71
71
  },
72
72
  transformIndexHtml () {
73
73
  return [
74
- // HACK without <base>, some relative assets don't work.
75
- // FIXME ideally, we should avoid this.
76
- {
77
- tag: 'base',
78
- attrs: {
79
- href: opts.basePath
80
- }
81
- },
82
74
  {
83
75
  tag: 'script',
84
76
  attrs: {
@@ -91,7 +83,7 @@ ${opts.htmlHead}
91
83
  tag: 'link',
92
84
  attrs: {
93
85
  rel: 'stylesheet',
94
- href
86
+ href: `${opts.basePath}${href}`
95
87
  },
96
88
  injectTo: 'head'
97
89
  }))
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { normalizePath } from 'vite';
3
4
  // HACK: Depending on a different plugin isn't ideal.
4
5
  // Maybe we could put in vite config object?
5
6
  import { SRC_ENTRIES } from './vite-plugin-rsc-managed.js';
@@ -19,7 +20,7 @@ export function rscServePlugin(opts) {
19
20
  name: 'rsc-serve-plugin',
20
21
  config (viteConfig) {
21
22
  // FIXME This seems too hacky (The use of viteConfig.root, '.', path.resolve and resolveFileName)
22
- const entriesFile = resolveFileName(path.resolve(viteConfig.root || '.', opts.srcDir, SRC_ENTRIES + '.js'));
23
+ const entriesFile = normalizePath(resolveFileName(path.resolve(viteConfig.root || '.', opts.srcDir, SRC_ENTRIES + '.jsx')));
23
24
  const { input } = viteConfig.build?.rollupOptions ?? {};
24
25
  if (input && !(typeof input === 'string') && !(input instanceof Array)) {
25
26
  input[opts.distServeJs.replace(/\.js$/, '')] = opts.srcServeFile;
@@ -5,7 +5,7 @@ import { Server } from 'node:http';
5
5
  import { AsyncLocalStorage } from 'node:async_hooks';
6
6
  import { createServer as createViteServer } from 'vite';
7
7
  import viteReact from '@vitejs/plugin-react';
8
- import { joinPath, fileURLToFilePath, encodeFilePathToAbsolute } from '../utils/path.js';
8
+ import { joinPath, fileURLToFilePath, encodeFilePathToAbsolute, decodeFilePathFromAbsolute } from '../utils/path.js';
9
9
  import { deepFreeze, hasStatusCode } from './utils.js';
10
10
  import { renderRsc, getSsrConfig } from './rsc-renderer.js';
11
11
  import { nonjsResolvePlugin } from '../plugins/vite-plugin-nonjs-resolve.js';
@@ -29,12 +29,12 @@ const configSrcDir = getEnvironmentData('CONFIG_SRC_DIR');
29
29
  const configEntries = getEnvironmentData('CONFIG_ENTRIES');
30
30
  const configPrivateDir = getEnvironmentData('CONFIG_PRIVATE_DIR');
31
31
  const resolveClientEntryForDev = (id, config, initialModules)=>{
32
+ let file = id.startsWith('file://') ? decodeFilePathFromAbsolute(fileURLToFilePath(id)) : id;
32
33
  for (const moduleNode of initialModules){
33
- if (moduleNode.file === id) {
34
+ if (moduleNode.file === file) {
34
35
  return moduleNode.url;
35
36
  }
36
37
  }
37
- let file = id.startsWith('file://') ? fileURLToFilePath(id) : id;
38
38
  if (file.startsWith(config.rootDir)) {
39
39
  file = file.slice(config.rootDir.length + 1); // '+ 1' to remove '/'
40
40
  } else {
@@ -69,7 +69,6 @@ const handleRender = async (mesg)=>{
69
69
  }, {
70
70
  isDev: true,
71
71
  loadServerFile,
72
- loadServerModule,
73
72
  resolveClientEntry: (id)=>resolveClientEntryForDev(id, {
74
73
  rootDir: vite.config.root,
75
74
  basePath: rest.config.basePath
@@ -191,9 +190,7 @@ const mergedViteConfig = await mergeUserViteConfig({
191
190
  'workerd'
192
191
  ]
193
192
  },
194
- external: [],
195
- // FIXME We want to externalize waku, but it fails on windows.
196
- noExternal: [
193
+ external: [
197
194
  'waku'
198
195
  ]
199
196
  },
@@ -214,10 +211,6 @@ const loadServerFile = async (fileURL)=>{
214
211
  const vite = await vitePromise;
215
212
  return vite.ssrLoadModule(fileURLToFilePath(fileURL));
216
213
  };
217
- const loadServerModule = async (id)=>{
218
- const vite = await vitePromise;
219
- return vite.ssrLoadModule(id);
220
- };
221
214
  const loadEntries = async (config)=>{
222
215
  const vite = await vitePromise;
223
216
  const filePath = joinPath(vite.config.root, config.srcDir, configEntries);
@@ -21,7 +21,6 @@ type RenderRscOpts = {
21
21
  isDev: true;
22
22
  entries: EntriesDev;
23
23
  loadServerFile: (fileURL: string) => Promise<unknown>;
24
- loadServerModule: (id: string) => Promise<unknown>;
25
24
  resolveClientEntry: (id: string) => string;
26
25
  };
27
26
  export declare function renderRsc(args: RenderRscArgs, opts: RenderRscOpts): Promise<ReadableStream>;
@@ -20,7 +20,7 @@ export async function renderRsc(args, opts) {
20
20
  const loadServerModule = (key)=>isDev ? import(/* @vite-ignore */ SERVER_MODULE_MAP[key]) : loadModule(key);
21
21
  const [{ default: { renderToReadableStream, decodeReply } }, { runWithRenderStore }] = await Promise.all([
22
22
  loadServerModule('rsdw-server'),
23
- isDev ? opts.loadServerModule(SERVER_MODULE_MAP['waku-server']) : loadModule('waku-server')
23
+ loadServerModule('waku-server')
24
24
  ]);
25
25
  const bundlerConfig = new Proxy({}, {
26
26
  get (_target, encodedId) {
@@ -8,7 +8,7 @@
8
8
  const ABSOLUTE_WIN32_PATH_REGEXP = /^\/[a-zA-Z]:\//;
9
9
  export const encodeFilePathToAbsolute = (filePath)=>{
10
10
  if (ABSOLUTE_WIN32_PATH_REGEXP.test(filePath)) {
11
- throw new Error('Unsupported absolute file path');
11
+ throw new Error('Unsupported absolute file path: ' + filePath);
12
12
  }
13
13
  if (filePath.startsWith('/')) {
14
14
  return filePath;
@@ -193,7 +193,21 @@ const equalRouteProps = (a, b)=>{
193
193
  }
194
194
  return true;
195
195
  };
196
- function InnerRouter({ routerData }) {
196
+ const RouterSlot = ({ route, routerData, cachedRef, id, fallback, children })=>{
197
+ const unstable_shouldRenderPrev = (_err)=>{
198
+ const shouldSkip = routerData[0];
199
+ const skip = getSkipList(shouldSkip, [
200
+ id
201
+ ], route, cachedRef.current);
202
+ return skip.length > 0;
203
+ };
204
+ return createElement(Slot, {
205
+ id,
206
+ fallback,
207
+ unstable_shouldRenderPrev
208
+ }, children);
209
+ };
210
+ const InnerRouter = ({ routerData })=>{
197
211
  const refetch = useRefetch();
198
212
  const [route, setRoute] = useState(()=>parseRoute(new URL(window.location.href)));
199
213
  const componentIds = getComponentIds(route.path);
@@ -314,7 +328,10 @@ function InnerRouter({ routerData }) {
314
328
  behavior: state?.waku_new_path ? 'instant' : 'auto'
315
329
  });
316
330
  });
317
- const children = componentIds.reduceRight((acc, id)=>createElement(Slot, {
331
+ const children = componentIds.reduceRight((acc, id)=>createElement(RouterSlot, {
332
+ route,
333
+ routerData,
334
+ cachedRef,
318
335
  id,
319
336
  fallback: acc
320
337
  }, acc), null);
@@ -325,7 +342,7 @@ function InnerRouter({ routerData }) {
325
342
  prefetchRoute
326
343
  }
327
344
  }, children);
328
- }
345
+ };
329
346
  const DEFAULT_ROUTER_DATA = [];
330
347
  export function Router({ routerData = DEFAULT_ROUTER_DATA }) {
331
348
  const route = parseRoute(new URL(window.location.href));
@@ -13,7 +13,7 @@ export type CreatePage = <Path extends string, SlugKey extends string, WildSlugK
13
13
  component: FunctionComponent<RouteProps>;
14
14
  } | {
15
15
  render: 'static';
16
- path: PathWithSlug<Path, SlugKey>;
16
+ path: PathWithWildcard<Path, SlugKey, WildSlugKey>;
17
17
  staticPaths: string[] | string[][];
18
18
  component: FunctionComponent<RouteProps & Record<SlugKey, string>>;
19
19
  } | {
@@ -42,25 +42,33 @@ export function createPages(fn) {
42
42
  ]);
43
43
  const id = joinPath(page.path, 'page').replace(/^\//, '');
44
44
  registerStaticComponent(id, page.component);
45
- } else if (page.render === 'static' && numSlugs > 0 && numWildcards === 0) {
45
+ } else if (page.render === 'static' && numSlugs > 0) {
46
46
  const staticPaths = page.staticPaths.map((item)=>Array.isArray(item) ? item : [
47
47
  item
48
48
  ]);
49
49
  for (const staticPath of staticPaths){
50
- if (staticPath.length !== numSlugs) {
50
+ if (staticPath.length !== numSlugs && numWildcards === 0) {
51
51
  throw new Error('staticPaths does not match with slug pattern');
52
52
  }
53
53
  const mapping = {};
54
54
  let slugIndex = 0;
55
- const pathItems = pathSpec.map(({ type, name })=>{
56
- if (type !== 'literal') {
57
- const actualName = staticPath[slugIndex++];
58
- if (name) {
59
- mapping[name] = actualName;
60
- }
61
- return actualName;
55
+ const pathItems = [];
56
+ pathSpec.forEach(({ type, name })=>{
57
+ switch(type){
58
+ case 'literal':
59
+ pathItems.push(name);
60
+ break;
61
+ case 'wildcard':
62
+ mapping[name] = staticPath.slice(slugIndex);
63
+ staticPath.slice(slugIndex++).forEach((slug)=>{
64
+ pathItems.push(slug);
65
+ });
66
+ break;
67
+ case 'group':
68
+ pathItems.push(staticPath[slugIndex++]);
69
+ mapping[name] = pathItems[pathItems.length - 1];
70
+ break;
62
71
  }
63
- return name;
64
72
  });
65
73
  staticPathSet.add([
66
74
  page.path,
package/package.json CHANGED
@@ -1,10 +1,7 @@
1
1
  {
2
2
  "name": "waku",
3
3
  "description": "⛩️ The minimal React framework",
4
- "version": "0.20.2-alpha.0",
5
- "publishConfig": {
6
- "tag": "next"
7
- },
4
+ "version": "0.20.2",
8
5
  "type": "module",
9
6
  "author": "Daishi Kato",
10
7
  "homepage": "https://waku.gg",
@@ -70,25 +67,25 @@
70
67
  "node": "^20.8.0 || ^18.17.0"
71
68
  },
72
69
  "dependencies": {
73
- "@hono/node-server": "1.9.1",
74
- "@swc/core": "1.4.12",
70
+ "@hono/node-server": "1.11.1",
71
+ "@swc/core": "1.4.17",
75
72
  "@vitejs/plugin-react": "4.2.1",
76
73
  "dotenv": "16.4.5",
77
- "hono": "4.2.2",
74
+ "hono": "4.3.2",
78
75
  "rsc-html-stream": "0.0.3",
79
- "vite": "5.2.8"
76
+ "vite": "5.2.11"
80
77
  },
81
78
  "devDependencies": {
82
- "@netlify/functions": "^2.6.0",
79
+ "@netlify/functions": "^2.6.3",
83
80
  "@swc/cli": "^0.3.12",
84
- "rollup": "^4.14.0",
81
+ "rollup": "^4.17.2",
85
82
  "ts-expect": "^1.3.0",
86
- "vitest": "^1.5.0"
83
+ "vitest": "^1.6.0"
87
84
  },
88
85
  "peerDependencies": {
89
- "react": "19.0.0-beta-4508873393-20240430",
90
- "react-dom": "19.0.0-beta-4508873393-20240430",
91
- "react-server-dom-webpack": "19.0.0-beta-4508873393-20240430"
86
+ "react": "19.0.0-beta-e7d213dfb0-20240507",
87
+ "react-dom": "19.0.0-beta-e7d213dfb0-20240507",
88
+ "react-server-dom-webpack": "19.0.0-beta-e7d213dfb0-20240507"
92
89
  },
93
90
  "scripts": {
94
91
  "dev": "swc src -d dist -w --strip-leading-paths",
package/src/cli.ts CHANGED
@@ -47,6 +47,9 @@ const { values, positionals } = parseArgs({
47
47
  'with-aws-lambda': {
48
48
  type: 'boolean',
49
49
  },
50
+ 'experimental-partial': {
51
+ type: 'boolean',
52
+ },
50
53
  port: {
51
54
  type: 'string',
52
55
  short: 'p',
@@ -103,6 +106,7 @@ async function runBuild() {
103
106
  await build({
104
107
  config,
105
108
  env: process.env as any,
109
+ partial: !!values['experimental-partial'],
106
110
  deploy:
107
111
  (values['with-vercel'] ?? !!process.env.VERCEL
108
112
  ? values['with-vercel-static']
package/src/client.ts CHANGED
@@ -39,25 +39,43 @@ const checkStatus = async (
39
39
  return response;
40
40
  };
41
41
 
42
- type Elements = Promise<Record<string, ReactNode>>;
42
+ type Elements = Promise<Record<string, ReactNode>> & {
43
+ prev?: Record<string, ReactNode> | undefined;
44
+ };
43
45
 
44
46
  const getCached = <T>(c: () => T, m: WeakMap<object, T>, k: object): T =>
45
47
  (m.has(k) ? m : m.set(k, c())).get(k) as T;
46
48
  const cache1 = new WeakMap();
47
- const mergeElements = (
48
- a: Elements,
49
- b: Elements | Awaited<Elements>,
50
- ): Elements => {
51
- const getResult = async () => {
52
- const nextElements = { ...(await a), ...(await b) };
53
- delete nextElements._value;
54
- return nextElements;
49
+ const mergeElements = (a: Elements, b: Elements): Elements => {
50
+ const getResult = () => {
51
+ const promise: Elements = new Promise((resolve, reject) => {
52
+ Promise.all([a, b])
53
+ .then(([a, b]) => {
54
+ const nextElements = { ...a, ...b };
55
+ delete nextElements._value;
56
+ promise.prev = a;
57
+ resolve(nextElements);
58
+ })
59
+ .catch((e) => {
60
+ a.then(
61
+ (a) => {
62
+ promise.prev = a;
63
+ reject(e);
64
+ },
65
+ () => {
66
+ promise.prev = a.prev;
67
+ reject(e);
68
+ },
69
+ );
70
+ });
71
+ });
72
+ return promise;
55
73
  };
56
74
  const cache2 = getCached(() => new WeakMap(), cache1, a);
57
75
  return getCached(getResult, cache2, b);
58
76
  };
59
77
 
60
- type SetElements = (updater: Elements | ((prev: Elements) => Elements)) => void;
78
+ type SetElements = (updater: (prev: Elements) => Elements) => void;
61
79
  type CacheEntry = [
62
80
  input: string,
63
81
  searchParamsString: string,
@@ -191,10 +209,12 @@ export const Slot = ({
191
209
  id,
192
210
  children,
193
211
  fallback,
212
+ unstable_shouldRenderPrev,
194
213
  }: {
195
214
  id: string;
196
215
  children?: ReactNode;
197
216
  fallback?: ReactNode;
217
+ unstable_shouldRenderPrev?: (err: unknown) => boolean;
198
218
  }) => {
199
219
  const elementsPromise = use(ElementsContext);
200
220
  if (!elementsPromise) {
@@ -204,12 +224,16 @@ export const Slot = ({
204
224
  try {
205
225
  elements = use(elementsPromise);
206
226
  } catch (e) {
207
- if (e instanceof Error) {
227
+ if (e instanceof Error && !('statusCode' in e)) {
208
228
  // HACK we assume any error as Not Found,
209
229
  // probably caused by history api fallback
210
230
  (e as any).statusCode = 404;
211
231
  }
212
- throw e;
232
+ if (unstable_shouldRenderPrev?.(e) && elementsPromise.prev) {
233
+ elements = elementsPromise.prev;
234
+ } else {
235
+ throw e;
236
+ }
213
237
  }
214
238
  if (!(id in elements)) {
215
239
  if (fallback) {
@@ -165,6 +165,7 @@ const buildServerBundle = async (
165
165
  | 'aws-lambda'
166
166
  | false,
167
167
  isNodeCompatible: boolean,
168
+ partial: boolean,
168
169
  ) => {
169
170
  const serverBuildOutput = await buildVite({
170
171
  plugins: [
@@ -247,6 +248,7 @@ const buildServerBundle = async (
247
248
  },
248
249
  publicDir: false,
249
250
  build: {
251
+ emptyOutDir: !partial,
250
252
  ssr: true,
251
253
  ssrEmitAssets: true,
252
254
  target: 'node18',
@@ -275,6 +277,7 @@ const buildSsrBundle = async (
275
277
  clientEntryFiles: Record<string, string>,
276
278
  serverBuildOutput: Awaited<ReturnType<typeof buildServerBundle>>,
277
279
  isNodeCompatible: boolean,
280
+ partial: boolean,
278
281
  ) => {
279
282
  const cssAssets = serverBuildOutput.output.flatMap(({ type, fileName }) =>
280
283
  type === 'asset' && fileName.endsWith('.css') ? [fileName] : [],
@@ -310,6 +313,7 @@ const buildSsrBundle = async (
310
313
  },
311
314
  publicDir: false,
312
315
  build: {
316
+ emptyOutDir: !partial,
313
317
  ssr: true,
314
318
  target: 'node18',
315
319
  outDir: joinPath(rootDir, config.distDir, DIST_SSR),
@@ -343,6 +347,7 @@ const buildClientBundle = async (
343
347
  config: ResolvedConfig,
344
348
  clientEntryFiles: Record<string, string>,
345
349
  serverBuildOutput: Awaited<ReturnType<typeof buildServerBundle>>,
350
+ partial: boolean,
346
351
  ) => {
347
352
  const nonJsAssets = serverBuildOutput.output.flatMap(({ type, fileName }) =>
348
353
  type === 'asset' && !fileName.endsWith('.js') ? [fileName] : [],
@@ -361,6 +366,7 @@ const buildClientBundle = async (
361
366
  rscManagedPlugin({ ...config, addMainToInput: true }),
362
367
  ],
363
368
  build: {
369
+ emptyOutDir: !partial,
364
370
  outDir: joinPath(rootDir, config.distDir, DIST_PUBLIC),
365
371
  rollupOptions: {
366
372
  onwarn,
@@ -426,6 +432,10 @@ const emitRscFiles = async (
426
432
  config.rscPath,
427
433
  encodeInput(input),
428
434
  );
435
+ // Skip if the file already exists.
436
+ if (existsSync(destRscFile)) {
437
+ continue;
438
+ }
429
439
  await mkdir(joinPath(destRscFile, '..'), { recursive: true });
430
440
  const readable = await renderRsc(
431
441
  {
@@ -504,7 +514,10 @@ const emitHtmlFiles = async (
504
514
  let htmlHead = publicIndexHtmlHead;
505
515
  if (cssAssets.length) {
506
516
  const cssStr = cssAssets
507
- .map((asset) => `<link rel="stylesheet" href="${asset}">`)
517
+ .map(
518
+ (asset) =>
519
+ `<link rel="stylesheet" href="${config.basePath}${asset}">`,
520
+ )
508
521
  .join('\n');
509
522
  // HACK is this too naive to inject style code?
510
523
  htmlStr = htmlStr.replace(/<\/head>/, cssStr);
@@ -549,6 +562,10 @@ const emitHtmlFiles = async (
549
562
  ? '404.html' // HACK special treatment for 404, better way?
550
563
  : pathname + '/index.html',
551
564
  );
565
+ // In partial mode, skip if the file already exists.
566
+ if (existsSync(destHtmlFile)) {
567
+ return;
568
+ }
552
569
  const htmlReadable = await renderHtml({
553
570
  config,
554
571
  pathname,
@@ -606,6 +623,7 @@ export const publicIndexHtml = ${JSON.stringify(publicIndexHtml)};
606
623
  export async function build(options: {
607
624
  config: Config;
608
625
  env?: Record<string, string>;
626
+ partial?: boolean;
609
627
  deploy?:
610
628
  | 'vercel-static'
611
629
  | 'vercel-serverless'
@@ -643,6 +661,7 @@ export async function build(options: {
643
661
  (options.deploy === 'deno' ? 'deno' : false) ||
644
662
  (options.deploy === 'aws-lambda' ? 'aws-lambda' : false),
645
663
  isNodeCompatible,
664
+ !!options.partial,
646
665
  );
647
666
  await buildSsrBundle(
648
667
  rootDir,
@@ -650,12 +669,14 @@ export async function build(options: {
650
669
  clientEntryFiles,
651
670
  serverBuildOutput,
652
671
  isNodeCompatible,
672
+ !!options.partial,
653
673
  );
654
674
  const clientBuildOutput = await buildClientBundle(
655
675
  rootDir,
656
676
  config,
657
677
  clientEntryFiles,
658
678
  serverBuildOutput,
679
+ !!options.partial,
659
680
  );
660
681
 
661
682
  const distEntries = await import(filePathToFileURL(distEntriesFile));
@@ -97,13 +97,7 @@ export const devServer: Middleware = (options) => {
97
97
  ],
98
98
  },
99
99
  ssr: {
100
- external: [
101
- 'waku',
102
- 'waku/client',
103
- 'waku/server',
104
- 'waku/router/client',
105
- 'waku/router/server',
106
- ],
100
+ external: ['waku'],
107
101
  },
108
102
  server: { middlewareMode: true },
109
103
  });
@@ -79,9 +79,6 @@ ${opts.htmlHead}
79
79
  },
80
80
  transformIndexHtml() {
81
81
  return [
82
- // HACK without <base>, some relative assets don't work.
83
- // FIXME ideally, we should avoid this.
84
- { tag: 'base', attrs: { href: opts.basePath } },
85
82
  {
86
83
  tag: 'script',
87
84
  attrs: { type: 'module', async: true },
@@ -89,7 +86,7 @@ ${opts.htmlHead}
89
86
  },
90
87
  ...(opts.cssAssets || []).map((href) => ({
91
88
  tag: 'link',
92
- attrs: { rel: 'stylesheet', href },
89
+ attrs: { rel: 'stylesheet', href: `${opts.basePath}${href}` },
93
90
  injectTo: 'head' as const,
94
91
  })),
95
92
  ];
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { normalizePath } from 'vite';
3
4
  import type { Plugin } from 'vite';
4
5
 
5
6
  // HACK: Depending on a different plugin isn't ideal.
@@ -37,8 +38,14 @@ export function rscServePlugin(opts: {
37
38
  name: 'rsc-serve-plugin',
38
39
  config(viteConfig) {
39
40
  // FIXME This seems too hacky (The use of viteConfig.root, '.', path.resolve and resolveFileName)
40
- const entriesFile = resolveFileName(
41
- path.resolve(viteConfig.root || '.', opts.srcDir, SRC_ENTRIES + '.js'),
41
+ const entriesFile = normalizePath(
42
+ resolveFileName(
43
+ path.resolve(
44
+ viteConfig.root || '.',
45
+ opts.srcDir,
46
+ SRC_ENTRIES + '.jsx',
47
+ ),
48
+ ),
42
49
  );
43
50
  const { input } = viteConfig.build?.rollupOptions ?? {};
44
51
  if (input && !(typeof input === 'string') && !(input instanceof Array)) {
@@ -13,6 +13,7 @@ import {
13
13
  joinPath,
14
14
  fileURLToFilePath,
15
15
  encodeFilePathToAbsolute,
16
+ decodeFilePathFromAbsolute,
16
17
  } from '../utils/path.js';
17
18
  import { deepFreeze, hasStatusCode } from './utils.js';
18
19
  import type { MessageReq, MessageRes } from './dev-worker-api.js';
@@ -50,12 +51,14 @@ const resolveClientEntryForDev = (
50
51
  config: { rootDir: string; basePath: string },
51
52
  initialModules: ClonableModuleNode[],
52
53
  ) => {
54
+ let file = id.startsWith('file://')
55
+ ? decodeFilePathFromAbsolute(fileURLToFilePath(id))
56
+ : id;
53
57
  for (const moduleNode of initialModules) {
54
- if (moduleNode.file === id) {
58
+ if (moduleNode.file === file) {
55
59
  return moduleNode.url;
56
60
  }
57
61
  }
58
- let file = id.startsWith('file://') ? fileURLToFilePath(id) : id;
59
62
  if (file.startsWith(config.rootDir)) {
60
63
  file = file.slice(config.rootDir.length + 1); // '+ 1' to remove '/'
61
64
  } else {
@@ -95,7 +98,6 @@ const handleRender = async (mesg: MessageReq & { type: 'render' }) => {
95
98
  {
96
99
  isDev: true,
97
100
  loadServerFile,
98
- loadServerModule,
99
101
  resolveClientEntry: (id: string) =>
100
102
  resolveClientEntryForDev(
101
103
  id,
@@ -201,16 +203,7 @@ const mergedViteConfig = await mergeUserViteConfig({
201
203
  conditions: ['react-server', 'workerd'],
202
204
  externalConditions: ['react-server', 'workerd'],
203
205
  },
204
- external: [
205
- // FIXME We want to externalize waku, but it fails on windows.
206
- // 'waku',
207
- // 'waku/client',
208
- // 'waku/server',
209
- // 'waku/router/client',
210
- // 'waku/router/server',
211
- ],
212
- // FIXME We want to externalize waku, but it fails on windows.
213
- noExternal: ['waku'],
206
+ external: ['waku'],
214
207
  },
215
208
  appType: 'custom',
216
209
  server: { middlewareMode: true, hmr: { server: dummyServer } },
@@ -227,11 +220,6 @@ const loadServerFile = async (fileURL: string) => {
227
220
  return vite.ssrLoadModule(fileURLToFilePath(fileURL));
228
221
  };
229
222
 
230
- const loadServerModule = async (id: string) => {
231
- const vite = await vitePromise;
232
- return vite.ssrLoadModule(id);
233
- };
234
-
235
223
  const loadEntries = async (config: { srcDir: string }) => {
236
224
  const vite = await vitePromise;
237
225
  const filePath = joinPath(vite.config.root, config.srcDir, configEntries);
@@ -41,7 +41,6 @@ type RenderRscOpts =
41
41
  isDev: true;
42
42
  entries: EntriesDev;
43
43
  loadServerFile: (fileURL: string) => Promise<unknown>;
44
- loadServerModule: (id: string) => Promise<unknown>;
45
44
  resolveClientEntry: (id: string) => string;
46
45
  };
47
46
 
@@ -85,11 +84,9 @@ export async function renderRsc(
85
84
  { runWithRenderStore },
86
85
  ] = await Promise.all([
87
86
  loadServerModule<{ default: typeof RSDWServerType }>('rsdw-server'),
88
- (isDev
89
- ? opts.loadServerModule(SERVER_MODULE_MAP['waku-server'])
90
- : loadModule('waku-server')) as Promise<{
91
- runWithRenderStore: typeof runWithRenderStoreType;
92
- }>,
87
+ loadServerModule<{ runWithRenderStore: typeof runWithRenderStoreType }>(
88
+ 'waku-server',
89
+ ),
93
90
  ]);
94
91
 
95
92
  const bundlerConfig = new Proxy(
@@ -10,7 +10,7 @@ const ABSOLUTE_WIN32_PATH_REGEXP = /^\/[a-zA-Z]:\//;
10
10
 
11
11
  export const encodeFilePathToAbsolute = (filePath: string) => {
12
12
  if (ABSOLUTE_WIN32_PATH_REGEXP.test(filePath)) {
13
- throw new Error('Unsupported absolute file path');
13
+ throw new Error('Unsupported absolute file path: ' + filePath);
14
14
  }
15
15
  if (filePath.startsWith('/')) {
16
16
  return filePath;
@@ -15,6 +15,7 @@ import {
15
15
  import type {
16
16
  ComponentProps,
17
17
  FunctionComponent,
18
+ MutableRefObject,
18
19
  ReactNode,
19
20
  AnchorHTMLAttributes,
20
21
  ReactElement,
@@ -286,7 +287,34 @@ const equalRouteProps = (a: RouteProps, b: RouteProps) => {
286
287
  return true;
287
288
  };
288
289
 
289
- function InnerRouter({ routerData }: { routerData: RouterData }) {
290
+ const RouterSlot = ({
291
+ route,
292
+ routerData,
293
+ cachedRef,
294
+ id,
295
+ fallback,
296
+ children,
297
+ }: {
298
+ route: RouteProps;
299
+ routerData: RouterData;
300
+ cachedRef: MutableRefObject<Record<string, RouteProps>>;
301
+ id: string;
302
+ fallback?: ReactNode;
303
+ children?: ReactNode;
304
+ }) => {
305
+ const unstable_shouldRenderPrev = (_err: unknown) => {
306
+ const shouldSkip = routerData[0];
307
+ const skip = getSkipList(shouldSkip, [id], route, cachedRef.current);
308
+ return skip.length > 0;
309
+ };
310
+ return createElement(
311
+ Slot,
312
+ { id, fallback, unstable_shouldRenderPrev },
313
+ children,
314
+ );
315
+ };
316
+
317
+ const InnerRouter = ({ routerData }: { routerData: RouterData }) => {
290
318
  const refetch = useRefetch();
291
319
 
292
320
  const [route, setRoute] = useState(() =>
@@ -418,7 +446,12 @@ function InnerRouter({ routerData }: { routerData: RouterData }) {
418
446
  });
419
447
 
420
448
  const children = componentIds.reduceRight(
421
- (acc: ReactNode, id) => createElement(Slot, { id, fallback: acc }, acc),
449
+ (acc: ReactNode, id) =>
450
+ createElement(
451
+ RouterSlot,
452
+ { route, routerData, cachedRef, id, fallback: acc },
453
+ acc,
454
+ ),
422
455
  null,
423
456
  );
424
457
 
@@ -427,7 +460,7 @@ function InnerRouter({ routerData }: { routerData: RouterData }) {
427
460
  { value: { route, changeRoute, prefetchRoute } },
428
461
  children,
429
462
  );
430
- }
463
+ };
431
464
 
432
465
  // Note: The router data must be a stable mutable object (array).
433
466
  type RouterData = [
@@ -82,7 +82,7 @@ export type CreatePage = <
82
82
  }
83
83
  | {
84
84
  render: 'static';
85
- path: PathWithSlug<Path, SlugKey>;
85
+ path: PathWithWildcard<Path, SlugKey, WildSlugKey>;
86
86
  staticPaths: string[] | string[][];
87
87
  component: FunctionComponent<RouteProps & Record<SlugKey, string>>;
88
88
  }
@@ -170,27 +170,35 @@ export function createPages(
170
170
  staticPathSet.add([page.path, pathSpec]);
171
171
  const id = joinPath(page.path, 'page').replace(/^\//, '');
172
172
  registerStaticComponent(id, page.component);
173
- } else if (page.render === 'static' && numSlugs > 0 && numWildcards === 0) {
173
+ } else if (page.render === 'static' && numSlugs > 0) {
174
174
  const staticPaths = (
175
175
  page as {
176
176
  staticPaths: string[] | string[][];
177
177
  }
178
178
  ).staticPaths.map((item) => (Array.isArray(item) ? item : [item]));
179
179
  for (const staticPath of staticPaths) {
180
- if (staticPath.length !== numSlugs) {
180
+ if (staticPath.length !== numSlugs && numWildcards === 0) {
181
181
  throw new Error('staticPaths does not match with slug pattern');
182
182
  }
183
- const mapping: Record<string, string> = {};
183
+ const mapping: Record<string, string | string[]> = {};
184
184
  let slugIndex = 0;
185
- const pathItems = pathSpec.map(({ type, name }) => {
186
- if (type !== 'literal') {
187
- const actualName = staticPath[slugIndex++]!;
188
- if (name) {
189
- mapping[name] = actualName;
190
- }
191
- return actualName;
185
+ const pathItems = [] as string[];
186
+ pathSpec.forEach(({ type, name }) => {
187
+ switch (type) {
188
+ case 'literal':
189
+ pathItems.push(name!);
190
+ break;
191
+ case 'wildcard':
192
+ mapping[name!] = staticPath.slice(slugIndex);
193
+ staticPath.slice(slugIndex++).forEach((slug) => {
194
+ pathItems.push(slug);
195
+ });
196
+ break;
197
+ case 'group':
198
+ pathItems.push(staticPath[slugIndex++]!);
199
+ mapping[name!] = pathItems[pathItems.length - 1]!;
200
+ break;
192
201
  }
193
- return name;
194
202
  });
195
203
  staticPathSet.add([
196
204
  page.path,